Skip to main content

fallow_output/
next_steps.rs

1//! Pure builders for JSON `next_steps[]` entries.
2//!
3//! Runtime probes stay with callers. This module owns the stable command,
4//! ordering, capping, and read-only contracts once a caller has already decided
5//! which signals apply.
6
7use fallow_types::output::NextStep;
8use fallow_types::results::AnalysisResults;
9use std::path::Path;
10
11use crate::HealthReport;
12
13const MAX_NEXT_STEPS: usize = 3;
14const MUTATING_VERBS: [&str; 5] = ["fix", "init", "hooks", "migrate", "setup-hooks"];
15
16/// Local impact digest counters used to render the `impact-report` next step.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub struct ImpactDigestCounts {
19    pub containment_count: usize,
20    pub resolved_total: usize,
21}
22
23/// Runtime-independent inputs for standalone dead-code next steps.
24#[derive(Debug, Clone, Copy)]
25pub struct DeadCodeNextStepsInput<'a> {
26    pub suggestions_enabled: bool,
27    pub results: &'a AnalysisResults,
28    pub root: &'a Path,
29    pub offer_setup: bool,
30    pub impact_digest: Option<ImpactDigestCounts>,
31    pub workspace_ref: Option<&'a str>,
32    pub audit_changed: bool,
33}
34
35/// Runtime-independent inputs for standalone duplication next steps.
36#[derive(Debug, Clone, Copy)]
37pub struct DupesNextStepsInput<'a> {
38    pub suggestions_enabled: bool,
39    pub clone_fingerprints: &'a [&'a str],
40    pub offer_setup: bool,
41    pub impact_digest: Option<ImpactDigestCounts>,
42    pub audit_changed: bool,
43}
44
45/// Deterministic unused-export trace target selected by the caller.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct TraceUnusedExportInput {
48    pub path: String,
49    pub export_name: String,
50}
51
52/// Runtime-independent inputs for bare `fallow` combined next steps.
53#[derive(Debug, Clone)]
54pub struct CombinedNextStepsInput<'a> {
55    pub suggestions_enabled: bool,
56    pub has_dead_code_findings: bool,
57    pub trace_unused_export: Option<TraceUnusedExportInput>,
58    pub workspace_ref: Option<&'a str>,
59    pub clone_fingerprints: &'a [&'a str],
60    pub has_complexity_findings: bool,
61    pub offer_setup: bool,
62    pub impact_digest: Option<ImpactDigestCounts>,
63    pub audit_changed: bool,
64}
65
66/// Runtime-independent inputs for audit next steps.
67#[derive(Debug, Clone)]
68pub struct AuditNextStepsInput {
69    pub suggestions_enabled: bool,
70    pub trace_unused_export: Option<TraceUnusedExportInput>,
71    pub has_complexity_findings: bool,
72}
73
74/// Runtime-independent inputs for standalone health next steps.
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub struct HealthNextStepsInput {
77    pub suggestions_enabled: bool,
78    pub has_findings: bool,
79    pub offer_setup: bool,
80    pub impact_digest: Option<ImpactDigestCounts>,
81    pub audit_changed: bool,
82}
83
84/// Build standalone health next-step inputs from a typed health report plus
85/// caller-supplied runtime probes.
86#[must_use]
87pub fn build_health_next_steps_input(
88    report: &HealthReport,
89    suggestions_enabled: bool,
90    offer_setup: bool,
91    impact_digest: Option<ImpactDigestCounts>,
92    audit_changed: bool,
93) -> HealthNextStepsInput {
94    HealthNextStepsInput {
95        suggestions_enabled,
96        has_findings: !report.findings.is_empty(),
97        offer_setup,
98        impact_digest,
99        audit_changed,
100    }
101}
102
103/// Render the human-readable impact counter summary shared by JSON and human
104/// output surfaces.
105#[must_use]
106pub fn impact_digest_summary(digest: ImpactDigestCounts) -> String {
107    let mut parts = Vec::new();
108    if digest.containment_count > 0 {
109        parts.push(format!(
110            "{} commit{} contained at the gate",
111            digest.containment_count,
112            if digest.containment_count == 1 {
113                ""
114            } else {
115                "s"
116            }
117        ));
118    }
119    if digest.resolved_total > 0 {
120        parts.push(format!(
121            "{} finding{} resolved",
122            digest.resolved_total,
123            if digest.resolved_total == 1 { "" } else { "s" }
124        ));
125    }
126    parts.join(", ")
127}
128
129/// Next-steps for standalone `fallow health`.
130#[must_use]
131pub fn build_health_next_steps(input: HealthNextStepsInput) -> Vec<NextStep> {
132    if !input.suggestions_enabled {
133        return Vec::new();
134    }
135    if !input.has_findings {
136        return impact_digest_step(input.impact_digest)
137            .into_iter()
138            .collect();
139    }
140
141    let mut steps: Vec<NextStep> = [
142        setup_pointer(input.offer_setup),
143        impact_digest_step(input.impact_digest),
144        complexity_breakdown(input.has_findings),
145        audit_changed(input.audit_changed),
146    ]
147    .into_iter()
148    .flatten()
149    .collect();
150    steps.truncate(MAX_NEXT_STEPS);
151    steps
152}
153
154/// Next-steps for standalone `fallow dead-code`.
155#[must_use]
156pub fn build_dead_code_next_steps(input: DeadCodeNextStepsInput<'_>) -> Vec<NextStep> {
157    if !input.suggestions_enabled {
158        return Vec::new();
159    }
160    if input.results.total_issues() == 0 {
161        return impact_digest_step(input.impact_digest)
162            .into_iter()
163            .collect();
164    }
165
166    let mut steps: Vec<NextStep> = [
167        setup_pointer(input.offer_setup),
168        impact_digest_step(input.impact_digest),
169        trace_unused_export(input.results, input.root),
170        scope_workspaces(input.workspace_ref),
171        audit_changed(input.audit_changed),
172    ]
173    .into_iter()
174    .flatten()
175    .collect();
176    steps.truncate(MAX_NEXT_STEPS);
177    steps
178}
179
180/// Next-steps for standalone `fallow dupes`.
181#[must_use]
182pub fn build_dupes_next_steps(input: DupesNextStepsInput<'_>) -> Vec<NextStep> {
183    if !input.suggestions_enabled {
184        return Vec::new();
185    }
186    if input.clone_fingerprints.is_empty() {
187        return impact_digest_step(input.impact_digest)
188            .into_iter()
189            .collect();
190    }
191
192    let mut steps: Vec<NextStep> = [
193        setup_pointer(input.offer_setup),
194        impact_digest_step(input.impact_digest),
195        trace_clone(input.clone_fingerprints),
196        audit_changed(input.audit_changed),
197    ]
198    .into_iter()
199    .flatten()
200    .collect();
201    steps.truncate(MAX_NEXT_STEPS);
202    steps
203}
204
205/// Aggregated next-steps for bare `fallow` combined output.
206#[must_use]
207pub fn build_combined_next_steps(input: &CombinedNextStepsInput<'_>) -> Vec<NextStep> {
208    if !input.suggestions_enabled {
209        return Vec::new();
210    }
211    let has_findings = input.has_dead_code_findings
212        || !input.clone_fingerprints.is_empty()
213        || input.has_complexity_findings;
214    if !has_findings {
215        return impact_digest_step(input.impact_digest)
216            .into_iter()
217            .collect();
218    }
219
220    let mut steps: Vec<NextStep> = [
221        setup_pointer(input.offer_setup),
222        impact_digest_step(input.impact_digest),
223        trace_unused_export_from_input(input.trace_unused_export.as_ref()),
224        scope_workspaces(input.workspace_ref),
225        trace_clone(input.clone_fingerprints),
226        complexity_breakdown(input.has_complexity_findings),
227        audit_changed(input.audit_changed),
228    ]
229    .into_iter()
230    .flatten()
231    .collect();
232    steps.truncate(MAX_NEXT_STEPS);
233    steps
234}
235
236/// Next-steps for `fallow audit`.
237#[must_use]
238pub fn build_audit_next_steps(input: &AuditNextStepsInput) -> Vec<NextStep> {
239    if !input.suggestions_enabled {
240        return Vec::new();
241    }
242
243    let mut steps: Vec<NextStep> = [
244        trace_unused_export_from_input(input.trace_unused_export.as_ref()),
245        complexity_breakdown(input.has_complexity_findings),
246    ]
247    .into_iter()
248    .flatten()
249    .collect();
250    steps.truncate(MAX_NEXT_STEPS);
251    steps
252}
253
254/// Build audit next-step inputs from typed analysis payloads plus the
255/// caller-supplied runtime suggestions gate.
256#[must_use]
257pub fn build_audit_next_steps_input(
258    check: Option<(&AnalysisResults, &Path)>,
259    complexity: Option<&HealthReport>,
260    suggestions_enabled: bool,
261) -> AuditNextStepsInput {
262    AuditNextStepsInput {
263        suggestions_enabled,
264        trace_unused_export: check
265            .and_then(|(results, root)| trace_unused_export_input(results, root)),
266        has_complexity_findings: complexity.is_some_and(|report| !report.findings.is_empty()),
267    }
268}
269
270fn relative_command_path(path: &Path, root: &Path) -> String {
271    path.strip_prefix(root)
272        .unwrap_or(path)
273        .to_string_lossy()
274        .replace('\\', "/")
275}
276
277/// Select the deterministic unused-export target used by read-only trace
278/// next-step commands.
279#[must_use]
280pub fn trace_unused_export_input(
281    results: &AnalysisResults,
282    root: &Path,
283) -> Option<TraceUnusedExportInput> {
284    let target = results
285        .unused_exports
286        .iter()
287        .map(|finding| {
288            (
289                relative_command_path(&finding.export.path, root),
290                finding.export.export_name.clone(),
291            )
292        })
293        .min()?;
294    Some(TraceUnusedExportInput {
295        path: target.0,
296        export_name: target.1,
297    })
298}
299
300fn trace_unused_export(results: &AnalysisResults, root: &Path) -> Option<NextStep> {
301    trace_unused_export_from_input(trace_unused_export_input(results, root).as_ref())
302}
303
304fn trace_unused_export_from_input(target: Option<&TraceUnusedExportInput>) -> Option<NextStep> {
305    let target = target?;
306    Some(next_step(
307        "trace-unused-export",
308        format!(
309            "fallow dead-code --trace {}:{}",
310            target.path, target.export_name
311        ),
312        "verify an export is truly unused before deleting",
313    ))
314}
315
316fn trace_clone(fingerprints: &[&str]) -> Option<NextStep> {
317    let fingerprint = fingerprints.iter().copied().min()?;
318    Some(next_step(
319        "trace-clone",
320        format!("fallow dupes --trace {fingerprint}"),
321        "see sibling locations and an extract-function suggestion",
322    ))
323}
324
325fn next_step(id: &str, command: String, reason: &str) -> NextStep {
326    debug_assert!(
327        !command.contains('<') && !command.contains('>'),
328        "next-step command must be runnable (no placeholder): {command}"
329    );
330    debug_assert!(
331        !command
332            .split_whitespace()
333            .any(|token| MUTATING_VERBS.contains(&token)),
334        "next-step command must be read-only (no mutating verb): {command}"
335    );
336    NextStep {
337        id: id.to_string(),
338        command,
339        reason: reason.to_string(),
340    }
341}
342
343fn setup_pointer(offer_setup: bool) -> Option<NextStep> {
344    if !offer_setup {
345        return None;
346    }
347    Some(next_step(
348        "setup",
349        "fallow schema".to_string(),
350        "fallow has no config here; the manifest lists guided-setup commands (agent guide, commit gate) to offer the user",
351    ))
352}
353
354fn impact_digest_step(digest: Option<ImpactDigestCounts>) -> Option<NextStep> {
355    let digest = digest?;
356    Some(next_step(
357        "impact-report",
358        "fallow impact".to_string(),
359        &format!(
360            "local value report: {}; share the non-zero numbers with the user",
361            impact_digest_summary(digest)
362        ),
363    ))
364}
365
366fn complexity_breakdown(has_findings: bool) -> Option<NextStep> {
367    if !has_findings {
368        return None;
369    }
370    Some(next_step(
371        "complexity-breakdown",
372        "fallow health --complexity-breakdown".to_string(),
373        "see per-decision-point contributions for a hotspot",
374    ))
375}
376
377fn audit_changed(applicable: bool) -> Option<NextStep> {
378    if !applicable {
379        return None;
380    }
381    Some(next_step(
382        "audit-changed",
383        "fallow audit".to_string(),
384        "gate only the files your branch changed (auto-detects the base)",
385    ))
386}
387
388fn scope_workspaces(workspace_ref: Option<&str>) -> Option<NextStep> {
389    let reference = workspace_ref?;
390    Some(next_step(
391        "scope-workspaces",
392        format!("fallow dead-code --changed-workspaces {reference}"),
393        "scope a monorepo run to the packages your branch touched",
394    ))
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use crate::{ComplexityViolation, ExceededThreshold, FindingSeverity, HealthFinding};
401    use fallow_types::output_dead_code::UnusedExportFinding;
402    use fallow_types::results::UnusedExport;
403
404    fn digest(containment_count: usize, resolved_total: usize) -> ImpactDigestCounts {
405        ImpactDigestCounts {
406            containment_count,
407            resolved_total,
408        }
409    }
410
411    fn dirty_input() -> HealthNextStepsInput {
412        HealthNextStepsInput {
413            suggestions_enabled: true,
414            has_findings: true,
415            offer_setup: false,
416            impact_digest: None,
417            audit_changed: false,
418        }
419    }
420
421    fn dirty_report() -> HealthReport {
422        HealthReport {
423            findings: vec![HealthFinding::from(ComplexityViolation {
424                path: "/project/src/hot.ts".into(),
425                name: "hot".to_string(),
426                line: 1,
427                col: 0,
428                cyclomatic: 21,
429                cognitive: 16,
430                line_count: 42,
431                param_count: 0,
432                react_hook_count: 0,
433                react_jsx_max_depth: 0,
434                react_prop_count: 0,
435                react_hook_profile: None,
436                exceeded: ExceededThreshold::Both,
437                severity: FindingSeverity::High,
438                crap: None,
439                coverage_pct: None,
440                coverage_tier: None,
441                coverage_source: None,
442                inherited_from: None,
443                component_rollup: None,
444                contributions: Vec::new(),
445                effective_thresholds: None,
446                threshold_source: None,
447            })],
448            ..HealthReport::default()
449        }
450    }
451
452    fn unused_export(path: &str, name: &str) -> UnusedExportFinding {
453        UnusedExportFinding::with_actions(UnusedExport {
454            path: path.into(),
455            export_name: name.to_string(),
456            is_type_only: false,
457            line: 1,
458            col: 0,
459            span_start: 0,
460            is_re_export: false,
461        })
462    }
463
464    fn dead_code_input(results: &AnalysisResults) -> DeadCodeNextStepsInput<'_> {
465        DeadCodeNextStepsInput {
466            suggestions_enabled: true,
467            results,
468            root: Path::new("/project"),
469            offer_setup: false,
470            impact_digest: None,
471            workspace_ref: None,
472            audit_changed: false,
473        }
474    }
475
476    fn dupes_input<'a>(clone_fingerprints: &'a [&'a str]) -> DupesNextStepsInput<'a> {
477        DupesNextStepsInput {
478            suggestions_enabled: true,
479            clone_fingerprints,
480            offer_setup: false,
481            impact_digest: None,
482            audit_changed: false,
483        }
484    }
485
486    fn combined_input<'a>(clone_fingerprints: &'a [&'a str]) -> CombinedNextStepsInput<'a> {
487        CombinedNextStepsInput {
488            suggestions_enabled: true,
489            has_dead_code_findings: false,
490            trace_unused_export: None,
491            workspace_ref: None,
492            clone_fingerprints,
493            has_complexity_findings: false,
494            offer_setup: false,
495            impact_digest: None,
496            audit_changed: false,
497        }
498    }
499
500    fn audit_input() -> AuditNextStepsInput {
501        AuditNextStepsInput {
502            suggestions_enabled: true,
503            trace_unused_export: None,
504            has_complexity_findings: false,
505        }
506    }
507
508    fn assert_valid(step: &NextStep) {
509        assert!(
510            !step.command.contains('<') && !step.command.contains('>'),
511            "command must be placeholder-free: {}",
512            step.command
513        );
514        assert!(
515            !step
516                .command
517                .split_whitespace()
518                .any(|token| MUTATING_VERBS.contains(&token)),
519            "command must be read-only: {}",
520            step.command
521        );
522    }
523
524    #[test]
525    fn audit_steps_are_empty_when_suggestions_are_disabled() {
526        let steps = build_audit_next_steps(&AuditNextStepsInput {
527            suggestions_enabled: false,
528            trace_unused_export: Some(TraceUnusedExportInput {
529                path: "src/a.ts".to_string(),
530                export_name: "alpha".to_string(),
531            }),
532            has_complexity_findings: true,
533        });
534
535        assert!(steps.is_empty());
536    }
537
538    #[test]
539    fn audit_input_builder_derives_trace_and_complexity_facts() {
540        let results = AnalysisResults {
541            unused_exports: vec![
542                unused_export("/project/src/b.ts", "beta"),
543                unused_export("/project/src/a.ts", "alpha"),
544            ],
545            ..AnalysisResults::default()
546        };
547        let report = dirty_report();
548
549        let input = build_audit_next_steps_input(
550            Some((&results, Path::new("/project"))),
551            Some(&report),
552            true,
553        );
554
555        assert_eq!(
556            input.trace_unused_export,
557            Some(TraceUnusedExportInput {
558                path: "src/a.ts".to_string(),
559                export_name: "alpha".to_string(),
560            })
561        );
562        assert!(input.has_complexity_findings);
563        assert!(input.suggestions_enabled);
564    }
565
566    #[test]
567    fn audit_steps_order_trace_before_complexity() {
568        let steps = build_audit_next_steps(&AuditNextStepsInput {
569            trace_unused_export: Some(TraceUnusedExportInput {
570                path: "src/a.ts".to_string(),
571                export_name: "alpha".to_string(),
572            }),
573            has_complexity_findings: true,
574            ..audit_input()
575        });
576        let ids = steps
577            .iter()
578            .map(|step| step.id.as_str())
579            .collect::<Vec<_>>();
580
581        assert_eq!(ids, ["trace-unused-export", "complexity-breakdown"]);
582        assert_eq!(steps[0].command, "fallow dead-code --trace src/a.ts:alpha");
583        for step in &steps {
584            assert_valid(step);
585        }
586    }
587
588    #[test]
589    fn audit_steps_emit_complexity_without_trace_target() {
590        let steps = build_audit_next_steps(&AuditNextStepsInput {
591            has_complexity_findings: true,
592            ..audit_input()
593        });
594
595        assert_eq!(steps.len(), 1);
596        assert_eq!(steps[0].id, "complexity-breakdown");
597    }
598
599    #[test]
600    fn health_steps_are_empty_when_suggestions_are_disabled() {
601        let steps = build_health_next_steps(HealthNextStepsInput {
602            suggestions_enabled: false,
603            has_findings: true,
604            offer_setup: true,
605            impact_digest: Some(digest(2, 1)),
606            audit_changed: true,
607        });
608
609        assert!(steps.is_empty());
610    }
611
612    #[test]
613    fn health_input_builder_derives_findings_from_report() {
614        let clean = build_health_next_steps_input(
615            &HealthReport::default(),
616            true,
617            true,
618            Some(digest(2, 1)),
619            true,
620        );
621        assert_eq!(
622            clean,
623            HealthNextStepsInput {
624                suggestions_enabled: true,
625                has_findings: false,
626                offer_setup: true,
627                impact_digest: Some(digest(2, 1)),
628                audit_changed: true,
629            }
630        );
631
632        let dirty = build_health_next_steps_input(&dirty_report(), true, false, None, false);
633        assert!(dirty.has_findings);
634    }
635
636    #[test]
637    fn dead_code_steps_trace_smallest_unused_export() {
638        let results = AnalysisResults {
639            unused_exports: vec![
640                unused_export("/project/src/b.ts", "beta"),
641                unused_export("/project/src/a.ts", "alpha"),
642            ],
643            ..AnalysisResults::default()
644        };
645
646        let steps = build_dead_code_next_steps(dead_code_input(&results));
647
648        assert_eq!(steps[0].id, "trace-unused-export");
649        assert_eq!(steps[0].command, "fallow dead-code --trace src/a.ts:alpha");
650        assert_valid(&steps[0]);
651    }
652
653    #[test]
654    fn dead_code_steps_order_setup_impact_trace_workspace_then_audit() {
655        let results = AnalysisResults {
656            unused_exports: vec![unused_export("/project/src/a.ts", "alpha")],
657            ..AnalysisResults::default()
658        };
659        let steps = build_dead_code_next_steps(DeadCodeNextStepsInput {
660            offer_setup: true,
661            impact_digest: Some(digest(2, 1)),
662            workspace_ref: Some("origin/main"),
663            audit_changed: true,
664            ..dead_code_input(&results)
665        });
666        let ids = steps
667            .iter()
668            .map(|step| step.id.as_str())
669            .collect::<Vec<_>>();
670
671        assert_eq!(ids, ["setup", "impact-report", "trace-unused-export"]);
672        for step in &steps {
673            assert_valid(step);
674        }
675    }
676
677    #[test]
678    fn clean_dead_code_run_emits_only_due_impact_digest() {
679        let results = AnalysisResults::default();
680        let steps = build_dead_code_next_steps(DeadCodeNextStepsInput {
681            impact_digest: Some(digest(2, 1)),
682            audit_changed: true,
683            ..dead_code_input(&results)
684        });
685
686        assert_eq!(steps.len(), 1);
687        assert_eq!(steps[0].id, "impact-report");
688    }
689
690    #[test]
691    fn dupes_steps_trace_smallest_clone_fingerprint() {
692        let fingerprints = ["dup:bbbbbbbb", "dup:aaaaaaaa"];
693
694        let steps = build_dupes_next_steps(dupes_input(&fingerprints));
695
696        assert_eq!(steps[0].id, "trace-clone");
697        assert_eq!(steps[0].command, "fallow dupes --trace dup:aaaaaaaa");
698        assert_valid(&steps[0]);
699    }
700
701    #[test]
702    fn dupes_steps_order_setup_impact_trace_then_audit() {
703        let fingerprints = ["dup:aaaaaaaa"];
704        let steps = build_dupes_next_steps(DupesNextStepsInput {
705            offer_setup: true,
706            impact_digest: Some(digest(2, 1)),
707            audit_changed: true,
708            ..dupes_input(&fingerprints)
709        });
710        let ids = steps
711            .iter()
712            .map(|step| step.id.as_str())
713            .collect::<Vec<_>>();
714
715        assert_eq!(ids, ["setup", "impact-report", "trace-clone"]);
716        for step in &steps {
717            assert_valid(step);
718        }
719    }
720
721    #[test]
722    fn clean_dupes_run_emits_only_due_impact_digest() {
723        let steps = build_dupes_next_steps(DupesNextStepsInput {
724            impact_digest: Some(digest(2, 1)),
725            audit_changed: true,
726            ..dupes_input(&[])
727        });
728
729        assert_eq!(steps.len(), 1);
730        assert_eq!(steps[0].id, "impact-report");
731    }
732
733    #[test]
734    fn combined_steps_are_empty_when_suggestions_are_disabled() {
735        let fingerprints = ["dup:aaaaaaaa"];
736        let steps = build_combined_next_steps(&CombinedNextStepsInput {
737            suggestions_enabled: false,
738            has_dead_code_findings: true,
739            trace_unused_export: Some(TraceUnusedExportInput {
740                path: "src/a.ts".to_string(),
741                export_name: "alpha".to_string(),
742            }),
743            workspace_ref: Some("origin/main"),
744            clone_fingerprints: &fingerprints,
745            has_complexity_findings: true,
746            offer_setup: true,
747            impact_digest: Some(digest(2, 1)),
748            audit_changed: true,
749        });
750
751        assert!(steps.is_empty());
752    }
753
754    #[test]
755    fn clean_combined_run_emits_only_due_impact_digest() {
756        let steps = build_combined_next_steps(&CombinedNextStepsInput {
757            impact_digest: Some(digest(2, 1)),
758            audit_changed: true,
759            ..combined_input(&[])
760        });
761
762        assert_eq!(steps.len(), 1);
763        assert_eq!(steps[0].id, "impact-report");
764    }
765
766    #[test]
767    fn combined_steps_order_and_cap_all_signals() {
768        let fingerprints = ["dup:bbbbbbbb", "dup:aaaaaaaa"];
769        let steps = build_combined_next_steps(&CombinedNextStepsInput {
770            has_dead_code_findings: true,
771            trace_unused_export: Some(TraceUnusedExportInput {
772                path: "src/a.ts".to_string(),
773                export_name: "alpha".to_string(),
774            }),
775            workspace_ref: Some("origin/main"),
776            has_complexity_findings: true,
777            offer_setup: true,
778            impact_digest: Some(digest(2, 1)),
779            audit_changed: true,
780            ..combined_input(&fingerprints)
781        });
782        let ids = steps
783            .iter()
784            .map(|step| step.id.as_str())
785            .collect::<Vec<_>>();
786
787        assert_eq!(ids, ["setup", "impact-report", "trace-unused-export"]);
788        for step in &steps {
789            assert_valid(step);
790        }
791    }
792
793    #[test]
794    fn combined_steps_keep_workspace_before_clone_and_complexity() {
795        let fingerprints = ["dup:aaaaaaaa"];
796        let steps = build_combined_next_steps(&CombinedNextStepsInput {
797            has_dead_code_findings: true,
798            workspace_ref: Some("origin/main"),
799            has_complexity_findings: true,
800            audit_changed: true,
801            ..combined_input(&fingerprints)
802        });
803        let ids = steps
804            .iter()
805            .map(|step| step.id.as_str())
806            .collect::<Vec<_>>();
807
808        assert_eq!(
809            ids,
810            ["scope-workspaces", "trace-clone", "complexity-breakdown"]
811        );
812    }
813
814    #[test]
815    fn clean_health_run_emits_only_due_impact_digest() {
816        let steps = build_health_next_steps(HealthNextStepsInput {
817            suggestions_enabled: true,
818            has_findings: false,
819            offer_setup: true,
820            impact_digest: Some(digest(2, 1)),
821            audit_changed: true,
822        });
823
824        assert_eq!(steps.len(), 1);
825        assert_eq!(steps[0].id, "impact-report");
826        assert_valid(&steps[0]);
827    }
828
829    #[test]
830    fn dirty_health_run_orders_setup_impact_complexity_then_audit() {
831        let steps = build_health_next_steps(HealthNextStepsInput {
832            offer_setup: true,
833            impact_digest: Some(digest(2, 1)),
834            audit_changed: true,
835            ..dirty_input()
836        });
837        let ids = steps
838            .iter()
839            .map(|step| step.id.as_str())
840            .collect::<Vec<_>>();
841
842        assert_eq!(ids, ["setup", "impact-report", "complexity-breakdown"]);
843        for step in &steps {
844            assert_valid(step);
845        }
846    }
847
848    #[test]
849    fn dirty_health_run_uses_complexity_when_setup_and_impact_are_absent() {
850        let steps = build_health_next_steps(HealthNextStepsInput {
851            audit_changed: true,
852            ..dirty_input()
853        });
854        let ids = steps
855            .iter()
856            .map(|step| step.id.as_str())
857            .collect::<Vec<_>>();
858
859        assert_eq!(ids, ["complexity-breakdown", "audit-changed"]);
860    }
861
862    #[test]
863    fn impact_digest_summary_pluralizes_real_counters() {
864        assert_eq!(
865            impact_digest_summary(digest(1, 1)),
866            "1 commit contained at the gate, 1 finding resolved"
867        );
868        assert_eq!(
869            impact_digest_summary(digest(2, 3)),
870            "2 commits contained at the gate, 3 findings resolved"
871        );
872    }
873}