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::{BaselineScopeReasons, HealthReport, ScopeReason};
12
13const MAX_NEXT_STEPS: usize = 3;
14const MUTATING_VERBS: [&str; 5] = ["fix", "init", "hooks", "migrate", "setup-hooks"];
15
16/// The loaded baseline a narrowed run could not judge, for the
17/// `recheck-baseline` next step.
18///
19/// A run narrowed to part of the project compares a whole-project baseline
20/// against a slice of it, so both the advisory and the gate stand down and the
21/// baseline can rot unnoticed for as long as every run is narrowed. The step
22/// names the one run that CAN judge it. It stays read-only, like every other
23/// entry: it re-reads the baseline and reports, it never re-saves.
24///
25/// Offered only when dropping every channel in `scope_reasons` widens the run,
26/// because the step's command carries `--baseline` and nothing else.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct BaselineRecheckInput<'a> {
29    /// The command whose baseline this is, as `fallow <command>` spells it.
30    pub command: &'a str,
31    /// The `--baseline` path as the caller wrote it. The envelope's
32    /// root-prefix post-process renders it root-relative when it lives under
33    /// the analyzed root and leaves it absolute otherwise, so the command stays
34    /// runnable from the root either way.
35    pub path: &'a str,
36    /// Entries the loaded baseline carried. Zero means there is nothing to
37    /// re-check.
38    pub baseline_entries: usize,
39    /// The channels that narrowed this run, named in the reason so the two
40    /// cannot drift from the published `scope_reasons`. A channel the printed
41    /// command cannot drop suppresses the step entirely.
42    pub scope_reasons: BaselineScopeReasons,
43}
44
45/// Local impact digest counters used to render the `impact-report` next step.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub struct ImpactDigestCounts {
48    /// Commits contained at the gate according to the local impact digest.
49    pub containment_count: usize,
50    /// Findings resolved according to the local impact digest.
51    pub resolved_total: usize,
52}
53
54/// Runtime-independent inputs for standalone dead-code next steps.
55#[derive(Debug, Clone, Copy)]
56pub struct DeadCodeNextStepsInput<'a> {
57    /// False when `FALLOW_SUGGESTIONS=off`; suppresses all steps.
58    pub suggestions_enabled: bool,
59    /// Analysis results the steps are derived from.
60    pub results: &'a AnalysisResults,
61    /// Analysis root, used to render trace targets relative.
62    pub root: &'a Path,
63    /// Offer the guided-setup pointer because no fallow config exists yet.
64    pub offer_setup: bool,
65    /// Local impact digest counters, when a digest is available.
66    pub impact_digest: Option<ImpactDigestCounts>,
67    /// Workspace name to scope suggested commands to, when analysing one.
68    pub workspace_ref: Option<&'a str>,
69    /// Offer `fallow audit` because the working tree has changed files.
70    pub audit_changed: bool,
71    /// The project has external plugins declared; when files are also unused,
72    /// route the agent to `fallow plugin-check` to verify what they seed.
73    pub has_external_plugins: bool,
74    /// The loaded baseline this run was too narrow to judge, when there is one.
75    pub baseline_recheck: Option<BaselineRecheckInput<'a>>,
76    /// The subcommand that ran (`dead-code` or `architecture`). The
77    /// `scope-workspaces` step names it.
78    pub command: &'a str,
79}
80
81/// Runtime-independent inputs for standalone duplication next steps.
82#[derive(Debug, Clone, Copy)]
83pub struct DupesNextStepsInput<'a> {
84    /// False when `FALLOW_SUGGESTIONS=off`; suppresses all steps.
85    pub suggestions_enabled: bool,
86    /// Clone fingerprints from this run, used for the suppress example.
87    pub clone_fingerprints: &'a [&'a str],
88    /// Offer the guided-setup pointer because no fallow config exists yet.
89    pub offer_setup: bool,
90    /// Local impact digest counters, when a digest is available.
91    pub impact_digest: Option<ImpactDigestCounts>,
92    /// Offer `fallow audit` because the working tree has changed files.
93    pub audit_changed: bool,
94    /// The loaded baseline this run was too narrow to judge, when there is one.
95    pub baseline_recheck: Option<BaselineRecheckInput<'a>>,
96}
97
98/// Deterministic unused-export trace target selected by the caller.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct TraceUnusedExportInput {
101    /// Root-relative path of the file declaring the export.
102    pub path: String,
103    /// Name of the unused export to trace.
104    pub export_name: String,
105}
106
107/// Runtime-independent inputs for bare `fallow` combined next steps.
108#[derive(Debug, Clone)]
109pub struct CombinedNextStepsInput<'a> {
110    /// False when `FALLOW_SUGGESTIONS=off`; suppresses all steps.
111    pub suggestions_enabled: bool,
112    /// The dead-code section reported findings.
113    pub has_dead_code_findings: bool,
114    /// Unused-export trace target, when the caller selected one.
115    pub trace_unused_export: Option<TraceUnusedExportInput>,
116    /// Workspace name to scope suggested commands to, when analysing one.
117    pub workspace_ref: Option<&'a str>,
118    /// Clone fingerprints from this run, used for the suppress example.
119    pub clone_fingerprints: &'a [&'a str],
120    /// The health section reported complexity findings.
121    pub has_complexity_findings: bool,
122    /// Offer the guided-setup pointer because no fallow config exists yet.
123    pub offer_setup: bool,
124    /// Local impact digest counters, when a digest is available.
125    pub impact_digest: Option<ImpactDigestCounts>,
126    /// Offer `fallow audit` because the working tree has changed files.
127    pub audit_changed: bool,
128    /// External plugins are declared for this project.
129    pub has_external_plugins: bool,
130    /// The run reported unused files (the signal a plugin may be misconfigured).
131    pub has_unused_files: bool,
132    /// The loaded baseline this run was too narrow to judge, when there is one.
133    /// A combined run baselines its dead-code sub-pass only, so this is that
134    /// sub-pass's baseline and the step it produces names `fallow dead-code`.
135    pub baseline_recheck: Option<BaselineRecheckInput<'a>>,
136}
137
138/// Runtime-independent inputs for audit next steps.
139#[derive(Debug, Clone)]
140pub struct AuditNextStepsInput {
141    /// False when `FALLOW_SUGGESTIONS=off`; suppresses all steps.
142    pub suggestions_enabled: bool,
143    /// Unused-export trace target, when the caller selected one.
144    pub trace_unused_export: Option<TraceUnusedExportInput>,
145    /// The audit reported complexity findings.
146    pub has_complexity_findings: bool,
147}
148
149/// Runtime-independent inputs for standalone health next steps.
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub struct HealthNextStepsInput<'a> {
152    /// False when `FALLOW_SUGGESTIONS=off`; suppresses all steps.
153    pub suggestions_enabled: bool,
154    /// The health report contains findings.
155    pub has_findings: bool,
156    /// Offer the guided-setup pointer because no fallow config exists yet.
157    pub offer_setup: bool,
158    /// Local impact digest counters, when a digest is available.
159    pub impact_digest: Option<ImpactDigestCounts>,
160    /// Offer `fallow audit` because the working tree has changed files.
161    pub audit_changed: bool,
162    /// The loaded baseline this run was too narrow to judge, when there is one.
163    pub baseline_recheck: Option<BaselineRecheckInput<'a>>,
164}
165
166/// Build standalone health next-step inputs from a typed health report plus
167/// caller-supplied runtime probes.
168#[must_use]
169pub fn build_health_next_steps_input<'a>(
170    report: &HealthReport,
171    suggestions_enabled: bool,
172    offer_setup: bool,
173    impact_digest: Option<ImpactDigestCounts>,
174    audit_changed: bool,
175    baseline_recheck: Option<BaselineRecheckInput<'a>>,
176) -> HealthNextStepsInput<'a> {
177    HealthNextStepsInput {
178        suggestions_enabled,
179        has_findings: !report.findings.is_empty(),
180        offer_setup,
181        impact_digest,
182        audit_changed,
183        baseline_recheck,
184    }
185}
186
187/// Render the human-readable impact counter summary shared by JSON and human
188/// output surfaces.
189#[must_use]
190pub fn impact_digest_summary(digest: ImpactDigestCounts) -> String {
191    let mut parts = Vec::new();
192    if digest.containment_count > 0 {
193        parts.push(format!(
194            "{} commit{} contained at the gate",
195            digest.containment_count,
196            if digest.containment_count == 1 {
197                ""
198            } else {
199                "s"
200            }
201        ));
202    }
203    if digest.resolved_total > 0 {
204        parts.push(format!(
205            "{} finding{} resolved",
206            digest.resolved_total,
207            if digest.resolved_total == 1 { "" } else { "s" }
208        ));
209    }
210    parts.join(", ")
211}
212
213/// Next-steps for standalone `fallow health`.
214#[must_use]
215pub fn build_health_next_steps(input: HealthNextStepsInput<'_>) -> Vec<NextStep> {
216    if !input.suggestions_enabled {
217        return Vec::new();
218    }
219    let recheck = recheck_baseline(input.baseline_recheck);
220    if !input.has_findings {
221        return recheck
222            .into_iter()
223            .chain(impact_digest_step(input.impact_digest))
224            .collect();
225    }
226
227    let mut steps: Vec<NextStep> = [
228        recheck,
229        setup_pointer(input.offer_setup),
230        impact_digest_step(input.impact_digest),
231        complexity_breakdown(input.has_findings),
232        audit_changed(input.audit_changed),
233    ]
234    .into_iter()
235    .flatten()
236    .collect();
237    steps.truncate(MAX_NEXT_STEPS);
238    steps
239}
240
241/// Next-steps for standalone `fallow dead-code`.
242#[must_use]
243pub fn build_dead_code_next_steps(input: DeadCodeNextStepsInput<'_>) -> Vec<NextStep> {
244    if !input.suggestions_enabled {
245        return Vec::new();
246    }
247    let recheck = recheck_baseline(input.baseline_recheck);
248    if input.results.total_issues() == 0 {
249        return recheck
250            .into_iter()
251            .chain(impact_digest_step(input.impact_digest))
252            .collect();
253    }
254
255    let mut steps: Vec<NextStep> = [
256        recheck,
257        verify_plugins(input.has_external_plugins && !input.results.unused_files.is_empty()),
258        setup_pointer(input.offer_setup),
259        impact_digest_step(input.impact_digest),
260        trace_unused_export(input.results, input.root),
261        trace_deprecated_export(input.results, input.root),
262        scope_workspaces(input.workspace_ref, input.command),
263        audit_changed(input.audit_changed),
264    ]
265    .into_iter()
266    .flatten()
267    .collect();
268    steps.truncate(MAX_NEXT_STEPS);
269    steps
270}
271
272/// Next-steps for standalone `fallow dupes`.
273#[must_use]
274pub fn build_dupes_next_steps(input: DupesNextStepsInput<'_>) -> Vec<NextStep> {
275    if !input.suggestions_enabled {
276        return Vec::new();
277    }
278    let recheck = recheck_baseline(input.baseline_recheck);
279    if input.clone_fingerprints.is_empty() {
280        return recheck
281            .into_iter()
282            .chain(impact_digest_step(input.impact_digest))
283            .collect();
284    }
285
286    let mut steps: Vec<NextStep> = [
287        recheck,
288        setup_pointer(input.offer_setup),
289        impact_digest_step(input.impact_digest),
290        trace_clone(input.clone_fingerprints),
291        audit_changed(input.audit_changed),
292    ]
293    .into_iter()
294    .flatten()
295    .collect();
296    steps.truncate(MAX_NEXT_STEPS);
297    steps
298}
299
300/// Aggregated next-steps for bare `fallow` combined output.
301#[must_use]
302pub fn build_combined_next_steps(input: &CombinedNextStepsInput<'_>) -> Vec<NextStep> {
303    if !input.suggestions_enabled {
304        return Vec::new();
305    }
306    let recheck = recheck_baseline(input.baseline_recheck);
307    let has_findings = input.has_dead_code_findings
308        || !input.clone_fingerprints.is_empty()
309        || input.has_complexity_findings;
310    if !has_findings {
311        return recheck
312            .into_iter()
313            .chain(impact_digest_step(input.impact_digest))
314            .collect();
315    }
316
317    let mut steps: Vec<NextStep> = [
318        recheck,
319        verify_plugins(input.has_external_plugins && input.has_unused_files),
320        setup_pointer(input.offer_setup),
321        impact_digest_step(input.impact_digest),
322        trace_unused_export_from_input(input.trace_unused_export.as_ref()),
323        scope_workspaces(input.workspace_ref, "dead-code"),
324        trace_clone(input.clone_fingerprints),
325        complexity_breakdown(input.has_complexity_findings),
326        audit_changed(input.audit_changed),
327    ]
328    .into_iter()
329    .flatten()
330    .collect();
331    steps.truncate(MAX_NEXT_STEPS);
332    steps
333}
334
335/// Next-steps for `fallow audit`.
336#[must_use]
337pub fn build_audit_next_steps(input: &AuditNextStepsInput) -> Vec<NextStep> {
338    if !input.suggestions_enabled {
339        return Vec::new();
340    }
341
342    let mut steps: Vec<NextStep> = [
343        trace_unused_export_from_input(input.trace_unused_export.as_ref()),
344        complexity_breakdown(input.has_complexity_findings),
345    ]
346    .into_iter()
347    .flatten()
348    .collect();
349    steps.truncate(MAX_NEXT_STEPS);
350    steps
351}
352
353/// Build audit next-step inputs from typed analysis payloads plus the
354/// caller-supplied runtime suggestions gate.
355#[must_use]
356pub fn build_audit_next_steps_input(
357    check: Option<(&AnalysisResults, &Path)>,
358    complexity: Option<&HealthReport>,
359    suggestions_enabled: bool,
360) -> AuditNextStepsInput {
361    AuditNextStepsInput {
362        suggestions_enabled,
363        trace_unused_export: check
364            .and_then(|(results, root)| trace_unused_export_input(results, root)),
365        has_complexity_findings: complexity.is_some_and(|report| !report.findings.is_empty()),
366    }
367}
368
369fn relative_command_path(path: &Path, root: &Path) -> String {
370    path.strip_prefix(root)
371        .unwrap_or(path)
372        .to_string_lossy()
373        .replace('\\', "/")
374}
375
376/// Select the deterministic unused-export target used by read-only trace
377/// next-step commands.
378#[must_use]
379pub fn trace_unused_export_input(
380    results: &AnalysisResults,
381    root: &Path,
382) -> Option<TraceUnusedExportInput> {
383    let target = results
384        .unused_exports
385        .iter()
386        .map(|finding| {
387            (
388                relative_command_path(&finding.export.path, root),
389                finding.export.export_name.clone(),
390            )
391        })
392        .min()?;
393    Some(TraceUnusedExportInput {
394        path: target.0,
395        export_name: target.1,
396    })
397}
398
399/// Trace the deprecated export with the most consumers: its `consumers`
400/// sample is the one most likely to be capped. Ties go to the smallest path
401/// and name, so the choice is deterministic.
402fn trace_deprecated_export(results: &AnalysisResults, root: &Path) -> Option<NextStep> {
403    let target = results
404        .deprecated_exports_in_use
405        .iter()
406        .map(|finding| {
407            (
408                std::cmp::Reverse(finding.export.consumer_count),
409                relative_command_path(&finding.export.path, root),
410                finding.export.export_name.as_str(),
411            )
412        })
413        .min()?;
414    Some(next_step(
415        "trace-deprecated-export",
416        format!("fallow dead-code --trace {}:{}", target.1, target.2),
417        "list every consumer of a deprecated export before you migrate it",
418    ))
419}
420
421fn trace_unused_export(results: &AnalysisResults, root: &Path) -> Option<NextStep> {
422    trace_unused_export_from_input(trace_unused_export_input(results, root).as_ref())
423}
424
425fn trace_unused_export_from_input(target: Option<&TraceUnusedExportInput>) -> Option<NextStep> {
426    let target = target?;
427    Some(next_step(
428        "trace-unused-export",
429        format!(
430            "fallow dead-code --trace {}:{}",
431            target.path, target.export_name
432        ),
433        "verify an export is truly unused before deleting",
434    ))
435}
436
437fn verify_plugins(applicable: bool) -> Option<NextStep> {
438    applicable.then(|| {
439        next_step(
440            "verify-plugins",
441            "fallow plugin-check --format json".to_string(),
442            "external plugins are active and files are unused; verify what they seed",
443        )
444    })
445}
446
447fn trace_clone(fingerprints: &[&str]) -> Option<NextStep> {
448    let fingerprint = fingerprints.iter().copied().min()?;
449    Some(next_step(
450        "trace-clone",
451        format!("fallow dupes --trace {fingerprint}"),
452        "see sibling locations and an extract-function suggestion",
453    ))
454}
455
456fn next_step(id: &str, command: String, reason: &str) -> NextStep {
457    debug_assert!(
458        !command.contains('<') && !command.contains('>'),
459        "next-step command must be runnable (no placeholder): {command}"
460    );
461    debug_assert!(
462        !command
463            .split_whitespace()
464            .any(|token| MUTATING_VERBS.contains(&token)),
465        "next-step command must be read-only (no mutating verb): {command}"
466    );
467    NextStep {
468        id: id.to_string(),
469        command,
470        reason: reason.to_string(),
471    }
472}
473
474/// The read-only re-read of a baseline this run was too narrow to judge.
475///
476/// Silent on an unscoped run, because there the advisory and the gate already
477/// spoke, and silent on an empty baseline, because there is nothing to
478/// re-check.
479///
480/// Silent as well when any channel that narrowed the run survives the printed
481/// command. The command is rebuilt from scratch and carries nothing but
482/// `--baseline`, so it drops only narrowing that came from a flag. Production
483/// mode and workspace scoping also resolve from the project config and the
484/// environment, where the same command would come back just as narrow and
485/// re-emit this step without end. An entry that cannot judge the baseline is
486/// worse than no entry: `next_steps` is a contract an agent follows.
487fn recheck_baseline(input: Option<BaselineRecheckInput<'_>>) -> Option<NextStep> {
488    let input = input?;
489    if input.scope_reasons.is_empty()
490        || input.baseline_entries == 0
491        || !input.scope_reasons.all_removable_by_rerun()
492    {
493        return None;
494    }
495    // The package map comes from the config, so the repeated command turns it
496    // off explicitly; without the flag it would come back just as narrow.
497    let package_map = if input.scope_reasons.contains(ScopeReason::PackageBaselines) {
498        " --no-package-baselines"
499    } else {
500        ""
501    };
502    Some(next_step(
503        "recheck-baseline",
504        format!(
505            "fallow {} --baseline {}{package_map}",
506            input.command, input.path
507        ),
508        &format!(
509            "this run was narrowed to part of the project ({}), which cannot judge a whole-project baseline",
510            input.scope_reasons.join()
511        ),
512    ))
513}
514
515fn setup_pointer(offer_setup: bool) -> Option<NextStep> {
516    if !offer_setup {
517        return None;
518    }
519    Some(next_step(
520        "setup",
521        "fallow schema".to_string(),
522        "fallow has no config here; the manifest lists guided-setup commands (agent guide, commit gate) to offer the user",
523    ))
524}
525
526fn impact_digest_step(digest: Option<ImpactDigestCounts>) -> Option<NextStep> {
527    let digest = digest?;
528    Some(next_step(
529        "impact-report",
530        "fallow impact".to_string(),
531        &format!(
532            "local value report: {}; share the non-zero numbers with the user",
533            impact_digest_summary(digest)
534        ),
535    ))
536}
537
538fn complexity_breakdown(has_findings: bool) -> Option<NextStep> {
539    if !has_findings {
540        return None;
541    }
542    Some(next_step(
543        "complexity-breakdown",
544        "fallow health --complexity-breakdown".to_string(),
545        "see per-decision-point contributions for a hotspot",
546    ))
547}
548
549fn audit_changed(applicable: bool) -> Option<NextStep> {
550    if !applicable {
551        return None;
552    }
553    Some(next_step(
554        "audit-changed",
555        "fallow audit".to_string(),
556        "gate only the files your branch changed (auto-detects the base)",
557    ))
558}
559
560fn scope_workspaces(workspace_ref: Option<&str>, command: &str) -> Option<NextStep> {
561    let reference = workspace_ref?;
562    Some(next_step(
563        "scope-workspaces",
564        format!("fallow {command} --changed-workspaces {reference}"),
565        "scope a monorepo run to the packages your branch touched",
566    ))
567}
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572    use crate::{
573        ComplexityViolation, ExceededThreshold, FindingSeverity, HealthFinding, ScopeReason,
574    };
575    use fallow_types::output_dead_code::{DeprecatedExportInUseFinding, UnusedExportFinding};
576    use fallow_types::results::{DeprecatedExportInUse, UnusedExport};
577
578    fn digest(containment_count: usize, resolved_total: usize) -> ImpactDigestCounts {
579        ImpactDigestCounts {
580            containment_count,
581            resolved_total,
582        }
583    }
584
585    fn dirty_input() -> HealthNextStepsInput<'static> {
586        HealthNextStepsInput {
587            suggestions_enabled: true,
588            has_findings: true,
589            offer_setup: false,
590            impact_digest: None,
591            audit_changed: false,
592            baseline_recheck: None,
593        }
594    }
595
596    fn dirty_report() -> HealthReport {
597        HealthReport {
598            findings: vec![HealthFinding::from(ComplexityViolation {
599                path: "/project/src/hot.ts".into(),
600                name: "hot".to_string(),
601                line: 1,
602                col: 0,
603                cyclomatic: 21,
604                cognitive: 16,
605                line_count: 42,
606                param_count: 0,
607                react_hook_count: 0,
608                react_jsx_max_depth: 0,
609                react_prop_count: 0,
610                react_hook_profile: None,
611                exceeded: ExceededThreshold::Both,
612                severity: FindingSeverity::High,
613                effective_severity: None,
614                crap: None,
615                mutation: None,
616                coverage_pct: None,
617                coverage_tier: None,
618                coverage_source: None,
619                inherited_from: None,
620                component_rollup: None,
621                contributions: Vec::new(),
622                effective_thresholds: None,
623                threshold_source: None,
624            })],
625            ..HealthReport::default()
626        }
627    }
628
629    fn unused_export(path: &str, name: &str) -> UnusedExportFinding {
630        UnusedExportFinding::with_actions(UnusedExport {
631            path: path.into(),
632            export_name: name.to_string(),
633            is_type_only: false,
634            line: 1,
635            col: 0,
636            span_start: 0,
637            is_re_export: false,
638            deprecated: false,
639            deprecated_reason: None,
640        })
641    }
642
643    fn dead_code_input(results: &AnalysisResults) -> DeadCodeNextStepsInput<'_> {
644        DeadCodeNextStepsInput {
645            suggestions_enabled: true,
646            results,
647            root: Path::new("/project"),
648            offer_setup: false,
649            impact_digest: None,
650            workspace_ref: None,
651            audit_changed: false,
652            has_external_plugins: false,
653            baseline_recheck: None,
654            command: "dead-code",
655        }
656    }
657
658    fn dupes_input<'a>(clone_fingerprints: &'a [&'a str]) -> DupesNextStepsInput<'a> {
659        DupesNextStepsInput {
660            suggestions_enabled: true,
661            clone_fingerprints,
662            offer_setup: false,
663            impact_digest: None,
664            audit_changed: false,
665            baseline_recheck: None,
666        }
667    }
668
669    fn combined_input<'a>(clone_fingerprints: &'a [&'a str]) -> CombinedNextStepsInput<'a> {
670        CombinedNextStepsInput {
671            suggestions_enabled: true,
672            has_dead_code_findings: false,
673            trace_unused_export: None,
674            workspace_ref: None,
675            clone_fingerprints,
676            has_complexity_findings: false,
677            offer_setup: false,
678            impact_digest: None,
679            audit_changed: false,
680            has_external_plugins: false,
681            has_unused_files: false,
682            baseline_recheck: None,
683        }
684    }
685
686    fn audit_input() -> AuditNextStepsInput {
687        AuditNextStepsInput {
688            suggestions_enabled: true,
689            trace_unused_export: None,
690            has_complexity_findings: false,
691        }
692    }
693
694    fn assert_valid(step: &NextStep) {
695        assert!(
696            !step.command.contains('<') && !step.command.contains('>'),
697            "command must be placeholder-free: {}",
698            step.command
699        );
700        assert!(
701            !step
702                .command
703                .split_whitespace()
704                .any(|token| MUTATING_VERBS.contains(&token)),
705            "command must be read-only: {}",
706            step.command
707        );
708    }
709
710    #[test]
711    fn audit_steps_are_empty_when_suggestions_are_disabled() {
712        let steps = build_audit_next_steps(&AuditNextStepsInput {
713            suggestions_enabled: false,
714            trace_unused_export: Some(TraceUnusedExportInput {
715                path: "src/a.ts".to_string(),
716                export_name: "alpha".to_string(),
717            }),
718            has_complexity_findings: true,
719        });
720
721        assert!(steps.is_empty());
722    }
723
724    #[test]
725    fn audit_input_builder_derives_trace_and_complexity_facts() {
726        let results = AnalysisResults {
727            unused_exports: vec![
728                unused_export("/project/src/b.ts", "beta"),
729                unused_export("/project/src/a.ts", "alpha"),
730            ],
731            ..AnalysisResults::default()
732        };
733        let report = dirty_report();
734
735        let input = build_audit_next_steps_input(
736            Some((&results, Path::new("/project"))),
737            Some(&report),
738            true,
739        );
740
741        assert_eq!(
742            input.trace_unused_export,
743            Some(TraceUnusedExportInput {
744                path: "src/a.ts".to_string(),
745                export_name: "alpha".to_string(),
746            })
747        );
748        assert!(input.has_complexity_findings);
749        assert!(input.suggestions_enabled);
750    }
751
752    #[test]
753    fn audit_steps_order_trace_before_complexity() {
754        let steps = build_audit_next_steps(&AuditNextStepsInput {
755            trace_unused_export: Some(TraceUnusedExportInput {
756                path: "src/a.ts".to_string(),
757                export_name: "alpha".to_string(),
758            }),
759            has_complexity_findings: true,
760            ..audit_input()
761        });
762        let ids = steps
763            .iter()
764            .map(|step| step.id.as_str())
765            .collect::<Vec<_>>();
766
767        assert_eq!(ids, ["trace-unused-export", "complexity-breakdown"]);
768        assert_eq!(steps[0].command, "fallow dead-code --trace src/a.ts:alpha");
769        for step in &steps {
770            assert_valid(step);
771        }
772    }
773
774    #[test]
775    fn audit_steps_emit_complexity_without_trace_target() {
776        let steps = build_audit_next_steps(&AuditNextStepsInput {
777            has_complexity_findings: true,
778            ..audit_input()
779        });
780
781        assert_eq!(steps.len(), 1);
782        assert_eq!(steps[0].id, "complexity-breakdown");
783    }
784
785    #[test]
786    fn health_steps_are_empty_when_suggestions_are_disabled() {
787        let steps = build_health_next_steps(HealthNextStepsInput {
788            suggestions_enabled: false,
789            has_findings: true,
790            offer_setup: true,
791            impact_digest: Some(digest(2, 1)),
792            audit_changed: true,
793            baseline_recheck: None,
794        });
795
796        assert!(steps.is_empty());
797    }
798
799    #[test]
800    fn health_input_builder_derives_findings_from_report() {
801        let clean = build_health_next_steps_input(
802            &HealthReport::default(),
803            true,
804            true,
805            Some(digest(2, 1)),
806            true,
807            None,
808        );
809        assert_eq!(
810            clean,
811            HealthNextStepsInput {
812                suggestions_enabled: true,
813                has_findings: false,
814                offer_setup: true,
815                impact_digest: Some(digest(2, 1)),
816                audit_changed: true,
817                baseline_recheck: None,
818            }
819        );
820
821        let dirty = build_health_next_steps_input(&dirty_report(), true, false, None, false, None);
822        assert!(dirty.has_findings);
823    }
824
825    fn deprecated(path: &str, name: &str, consumer_count: usize) -> DeprecatedExportInUseFinding {
826        DeprecatedExportInUseFinding::with_actions(DeprecatedExportInUse {
827            path: path.into(),
828            export_name: name.to_string(),
829            is_type_only: false,
830            line: 1,
831            col: 0,
832            span_start: 0,
833            deprecated_reason: None,
834            consumer_count,
835            consumers: Vec::new(),
836            public_api: false,
837        })
838    }
839
840    #[test]
841    fn dead_code_steps_trace_the_deprecated_export_with_most_consumers() {
842        let results = AnalysisResults {
843            deprecated_exports_in_use: vec![
844                deprecated("/project/src/a.ts", "few", 2),
845                deprecated("/project/src/b.ts", "many", 40),
846                deprecated("/project/src/c.ts", "tied", 40),
847            ],
848            ..AnalysisResults::default()
849        };
850
851        let steps = build_dead_code_next_steps(dead_code_input(&results));
852
853        assert_eq!(steps[0].id, "trace-deprecated-export");
854        assert_eq!(steps[0].command, "fallow dead-code --trace src/b.ts:many");
855        assert_valid(&steps[0]);
856    }
857
858    #[test]
859    fn dead_code_steps_trace_smallest_unused_export() {
860        let results = AnalysisResults {
861            unused_exports: vec![
862                unused_export("/project/src/b.ts", "beta"),
863                unused_export("/project/src/a.ts", "alpha"),
864            ],
865            ..AnalysisResults::default()
866        };
867
868        let steps = build_dead_code_next_steps(dead_code_input(&results));
869
870        assert_eq!(steps[0].id, "trace-unused-export");
871        assert_eq!(steps[0].command, "fallow dead-code --trace src/a.ts:alpha");
872        assert_valid(&steps[0]);
873    }
874
875    #[test]
876    fn scope_workspaces_step_names_the_command_that_ran() {
877        let results = AnalysisResults {
878            unused_exports: vec![unused_export("/project/src/a.ts", "alpha")],
879            ..AnalysisResults::default()
880        };
881        let scope_command = |command| {
882            build_dead_code_next_steps(DeadCodeNextStepsInput {
883                workspace_ref: Some("origin/main"),
884                command,
885                ..dead_code_input(&results)
886            })
887            .into_iter()
888            .find(|step| step.id == "scope-workspaces")
889            .map(|step| step.command)
890        };
891
892        assert_eq!(
893            scope_command("dead-code").as_deref(),
894            Some("fallow dead-code --changed-workspaces origin/main")
895        );
896        assert_eq!(
897            scope_command("architecture").as_deref(),
898            Some("fallow architecture --changed-workspaces origin/main")
899        );
900    }
901
902    #[test]
903    fn dead_code_steps_order_setup_impact_trace_workspace_then_audit() {
904        let results = AnalysisResults {
905            unused_exports: vec![unused_export("/project/src/a.ts", "alpha")],
906            ..AnalysisResults::default()
907        };
908        let steps = build_dead_code_next_steps(DeadCodeNextStepsInput {
909            offer_setup: true,
910            impact_digest: Some(digest(2, 1)),
911            workspace_ref: Some("origin/main"),
912            audit_changed: true,
913            ..dead_code_input(&results)
914        });
915        let ids = steps
916            .iter()
917            .map(|step| step.id.as_str())
918            .collect::<Vec<_>>();
919
920        assert_eq!(ids, ["setup", "impact-report", "trace-unused-export"]);
921        for step in &steps {
922            assert_valid(step);
923        }
924    }
925
926    #[test]
927    fn clean_dead_code_run_emits_only_due_impact_digest() {
928        let results = AnalysisResults::default();
929        let steps = build_dead_code_next_steps(DeadCodeNextStepsInput {
930            impact_digest: Some(digest(2, 1)),
931            audit_changed: true,
932            ..dead_code_input(&results)
933        });
934
935        assert_eq!(steps.len(), 1);
936        assert_eq!(steps[0].id, "impact-report");
937    }
938
939    #[test]
940    fn verify_plugins_step_fires_when_external_plugins_and_unused_files() {
941        let results = AnalysisResults {
942            unused_files: vec![
943                fallow_types::output_dead_code::UnusedFileFinding::with_actions(
944                    fallow_types::results::UnusedFile {
945                        path: "/project/src/orphan.ts".into(),
946                    },
947                ),
948            ],
949            ..AnalysisResults::default()
950        };
951        let steps = build_dead_code_next_steps(DeadCodeNextStepsInput {
952            has_external_plugins: true,
953            ..dead_code_input(&results)
954        });
955        let first = &steps[0];
956        assert_eq!(first.id, "verify-plugins");
957        assert_eq!(first.command, "fallow plugin-check --format json");
958        assert_valid(first);
959
960        // No external plugins: the step is absent even with unused files.
961        let without = build_dead_code_next_steps(DeadCodeNextStepsInput {
962            has_external_plugins: false,
963            ..dead_code_input(&results)
964        });
965        assert!(without.iter().all(|step| step.id != "verify-plugins"));
966
967        // External plugins but no unused files: absent (nothing to explain).
968        let no_unused = AnalysisResults {
969            unused_exports: vec![unused_export("/project/src/a.ts", "alpha")],
970            ..AnalysisResults::default()
971        };
972        let steps = build_dead_code_next_steps(DeadCodeNextStepsInput {
973            has_external_plugins: true,
974            ..dead_code_input(&no_unused)
975        });
976        assert!(steps.iter().all(|step| step.id != "verify-plugins"));
977    }
978
979    #[test]
980    fn dupes_steps_trace_smallest_clone_fingerprint() {
981        let fingerprints = ["dup:bbbbbbbb", "dup:aaaaaaaa"];
982
983        let steps = build_dupes_next_steps(dupes_input(&fingerprints));
984
985        assert_eq!(steps[0].id, "trace-clone");
986        assert_eq!(steps[0].command, "fallow dupes --trace dup:aaaaaaaa");
987        assert_valid(&steps[0]);
988    }
989
990    #[test]
991    fn dupes_steps_order_setup_impact_trace_then_audit() {
992        let fingerprints = ["dup:aaaaaaaa"];
993        let steps = build_dupes_next_steps(DupesNextStepsInput {
994            offer_setup: true,
995            impact_digest: Some(digest(2, 1)),
996            audit_changed: true,
997            ..dupes_input(&fingerprints)
998        });
999        let ids = steps
1000            .iter()
1001            .map(|step| step.id.as_str())
1002            .collect::<Vec<_>>();
1003
1004        assert_eq!(ids, ["setup", "impact-report", "trace-clone"]);
1005        for step in &steps {
1006            assert_valid(step);
1007        }
1008    }
1009
1010    #[test]
1011    fn clean_dupes_run_emits_only_due_impact_digest() {
1012        let steps = build_dupes_next_steps(DupesNextStepsInput {
1013            impact_digest: Some(digest(2, 1)),
1014            audit_changed: true,
1015            ..dupes_input(&[])
1016        });
1017
1018        assert_eq!(steps.len(), 1);
1019        assert_eq!(steps[0].id, "impact-report");
1020    }
1021
1022    #[test]
1023    fn combined_steps_are_empty_when_suggestions_are_disabled() {
1024        let fingerprints = ["dup:aaaaaaaa"];
1025        let steps = build_combined_next_steps(&CombinedNextStepsInput {
1026            suggestions_enabled: false,
1027            has_dead_code_findings: true,
1028            trace_unused_export: Some(TraceUnusedExportInput {
1029                path: "src/a.ts".to_string(),
1030                export_name: "alpha".to_string(),
1031            }),
1032            workspace_ref: Some("origin/main"),
1033            clone_fingerprints: &fingerprints,
1034            has_complexity_findings: true,
1035            offer_setup: true,
1036            impact_digest: Some(digest(2, 1)),
1037            audit_changed: true,
1038            has_external_plugins: false,
1039            has_unused_files: false,
1040            baseline_recheck: None,
1041        });
1042
1043        assert!(steps.is_empty());
1044    }
1045
1046    #[test]
1047    fn clean_combined_run_emits_only_due_impact_digest() {
1048        let steps = build_combined_next_steps(&CombinedNextStepsInput {
1049            impact_digest: Some(digest(2, 1)),
1050            audit_changed: true,
1051            ..combined_input(&[])
1052        });
1053
1054        assert_eq!(steps.len(), 1);
1055        assert_eq!(steps[0].id, "impact-report");
1056    }
1057
1058    #[test]
1059    fn combined_steps_order_and_cap_all_signals() {
1060        let fingerprints = ["dup:bbbbbbbb", "dup:aaaaaaaa"];
1061        let steps = build_combined_next_steps(&CombinedNextStepsInput {
1062            has_dead_code_findings: true,
1063            trace_unused_export: Some(TraceUnusedExportInput {
1064                path: "src/a.ts".to_string(),
1065                export_name: "alpha".to_string(),
1066            }),
1067            workspace_ref: Some("origin/main"),
1068            has_complexity_findings: true,
1069            offer_setup: true,
1070            impact_digest: Some(digest(2, 1)),
1071            audit_changed: true,
1072            ..combined_input(&fingerprints)
1073        });
1074        let ids = steps
1075            .iter()
1076            .map(|step| step.id.as_str())
1077            .collect::<Vec<_>>();
1078
1079        assert_eq!(ids, ["setup", "impact-report", "trace-unused-export"]);
1080        for step in &steps {
1081            assert_valid(step);
1082        }
1083    }
1084
1085    #[test]
1086    fn combined_steps_keep_workspace_before_clone_and_complexity() {
1087        let fingerprints = ["dup:aaaaaaaa"];
1088        let steps = build_combined_next_steps(&CombinedNextStepsInput {
1089            has_dead_code_findings: true,
1090            workspace_ref: Some("origin/main"),
1091            has_complexity_findings: true,
1092            audit_changed: true,
1093            ..combined_input(&fingerprints)
1094        });
1095        let ids = steps
1096            .iter()
1097            .map(|step| step.id.as_str())
1098            .collect::<Vec<_>>();
1099
1100        assert_eq!(
1101            ids,
1102            ["scope-workspaces", "trace-clone", "complexity-breakdown"]
1103        );
1104    }
1105
1106    #[test]
1107    fn clean_health_run_emits_only_due_impact_digest() {
1108        let steps = build_health_next_steps(HealthNextStepsInput {
1109            suggestions_enabled: true,
1110            has_findings: false,
1111            offer_setup: true,
1112            impact_digest: Some(digest(2, 1)),
1113            audit_changed: true,
1114            baseline_recheck: None,
1115        });
1116
1117        assert_eq!(steps.len(), 1);
1118        assert_eq!(steps[0].id, "impact-report");
1119        assert_valid(&steps[0]);
1120    }
1121
1122    #[test]
1123    fn dirty_health_run_orders_setup_impact_complexity_then_audit() {
1124        let steps = build_health_next_steps(HealthNextStepsInput {
1125            offer_setup: true,
1126            impact_digest: Some(digest(2, 1)),
1127            audit_changed: true,
1128            ..dirty_input()
1129        });
1130        let ids = steps
1131            .iter()
1132            .map(|step| step.id.as_str())
1133            .collect::<Vec<_>>();
1134
1135        assert_eq!(ids, ["setup", "impact-report", "complexity-breakdown"]);
1136        for step in &steps {
1137            assert_valid(step);
1138        }
1139    }
1140
1141    #[test]
1142    fn dirty_health_run_uses_complexity_when_setup_and_impact_are_absent() {
1143        let steps = build_health_next_steps(HealthNextStepsInput {
1144            audit_changed: true,
1145            ..dirty_input()
1146        });
1147        let ids = steps
1148            .iter()
1149            .map(|step| step.id.as_str())
1150            .collect::<Vec<_>>();
1151
1152        assert_eq!(ids, ["complexity-breakdown", "audit-changed"]);
1153    }
1154
1155    #[test]
1156    fn impact_digest_summary_pluralizes_real_counters() {
1157        assert_eq!(
1158            impact_digest_summary(digest(1, 1)),
1159            "1 commit contained at the gate, 1 finding resolved"
1160        );
1161        assert_eq!(
1162            impact_digest_summary(digest(2, 3)),
1163            "2 commits contained at the gate, 3 findings resolved"
1164        );
1165    }
1166
1167    fn narrowed_baseline(command: &'static str) -> BaselineRecheckInput<'static> {
1168        BaselineRecheckInput {
1169            command,
1170            path: ".fallow-baseline.json",
1171            baseline_entries: 8,
1172            scope_reasons: BaselineScopeReasons::empty()
1173                .with(ScopeReason::ChangedSince)
1174                .with(ScopeReason::Scope),
1175        }
1176    }
1177
1178    /// The package map comes from the config, so a repeat without flags would
1179    /// come back just as narrow. The step turns the map off explicitly.
1180    #[test]
1181    fn a_package_map_recheck_turns_the_map_off() {
1182        let results = AnalysisResults::default();
1183        let steps = build_dead_code_next_steps(DeadCodeNextStepsInput {
1184            baseline_recheck: Some(BaselineRecheckInput {
1185                scope_reasons: BaselineScopeReasons::empty().with(ScopeReason::PackageBaselines),
1186                ..narrowed_baseline("dead-code")
1187            }),
1188            ..dead_code_input(&results)
1189        });
1190        assert_eq!(steps.len(), 1);
1191        assert_eq!(
1192            steps[0].command,
1193            "fallow dead-code --baseline .fallow-baseline.json --no-package-baselines"
1194        );
1195    }
1196
1197    #[test]
1198    fn a_narrowed_zero_finding_dead_code_run_still_offers_the_baseline_recheck() {
1199        let results = AnalysisResults::default();
1200        let steps = build_dead_code_next_steps(DeadCodeNextStepsInput {
1201            baseline_recheck: Some(narrowed_baseline("dead-code")),
1202            ..dead_code_input(&results)
1203        });
1204
1205        assert_eq!(
1206            steps
1207                .iter()
1208                .map(|step| step.id.as_str())
1209                .collect::<Vec<_>>(),
1210            ["recheck-baseline"],
1211            "a cleaned project with a rotted baseline is the run that most needs the pointer"
1212        );
1213        assert_eq!(
1214            steps[0].command,
1215            "fallow dead-code --baseline .fallow-baseline.json"
1216        );
1217        assert!(
1218            steps[0].reason.contains("changed-since, scope"),
1219            "the reason must name the published scope_reasons: {}",
1220            steps[0].reason
1221        );
1222        assert_valid(&steps[0]);
1223    }
1224
1225    #[test]
1226    fn the_baseline_recheck_leads_a_run_that_triggers_everything() {
1227        let results = AnalysisResults {
1228            unused_exports: vec![unused_export("/project/src/a.ts", "alpha")],
1229            ..AnalysisResults::default()
1230        };
1231        let steps = build_dead_code_next_steps(DeadCodeNextStepsInput {
1232            offer_setup: true,
1233            impact_digest: Some(digest(2, 1)),
1234            audit_changed: true,
1235            baseline_recheck: Some(narrowed_baseline("dead-code")),
1236            ..dead_code_input(&results)
1237        });
1238
1239        assert_eq!(steps[0].id, "recheck-baseline");
1240    }
1241
1242    #[test]
1243    fn a_narrowed_zero_finding_dupes_run_still_offers_the_baseline_recheck() {
1244        let steps = build_dupes_next_steps(DupesNextStepsInput {
1245            baseline_recheck: Some(narrowed_baseline("dupes")),
1246            ..dupes_input(&[])
1247        });
1248
1249        assert_eq!(
1250            steps
1251                .iter()
1252                .map(|step| step.id.as_str())
1253                .collect::<Vec<_>>(),
1254            ["recheck-baseline"]
1255        );
1256        assert_eq!(
1257            steps[0].command,
1258            "fallow dupes --baseline .fallow-baseline.json"
1259        );
1260    }
1261
1262    #[test]
1263    fn a_narrowed_zero_finding_health_run_still_offers_the_baseline_recheck() {
1264        let steps = build_health_next_steps(HealthNextStepsInput {
1265            has_findings: false,
1266            baseline_recheck: Some(narrowed_baseline("health")),
1267            ..dirty_input()
1268        });
1269
1270        assert_eq!(
1271            steps
1272                .iter()
1273                .map(|step| step.id.as_str())
1274                .collect::<Vec<_>>(),
1275            ["recheck-baseline"]
1276        );
1277        assert_eq!(
1278            steps[0].command,
1279            "fallow health --baseline .fallow-baseline.json"
1280        );
1281    }
1282
1283    /// The combined run was the one shape that could load a baseline and offer no
1284    /// way to judge it: it baselines its dead-code sub-pass, so the step names
1285    /// that command (issue #2735).
1286    #[test]
1287    fn a_narrowed_combined_run_offers_the_baseline_recheck() {
1288        let steps = build_combined_next_steps(&CombinedNextStepsInput {
1289            baseline_recheck: Some(narrowed_baseline("dead-code")),
1290            ..combined_input(&[])
1291        });
1292
1293        assert_eq!(
1294            steps
1295                .iter()
1296                .map(|step| step.id.as_str())
1297                .collect::<Vec<_>>(),
1298            ["recheck-baseline"],
1299            "a combined run with nothing to report is still a run that loaded a baseline"
1300        );
1301        assert_eq!(
1302            steps[0].command,
1303            "fallow dead-code --baseline .fallow-baseline.json"
1304        );
1305        assert_valid(&steps[0]);
1306    }
1307
1308    #[test]
1309    fn the_baseline_recheck_leads_a_combined_run_that_triggers_everything() {
1310        let fingerprints = ["dup:aaaaaaaa"];
1311        let steps = build_combined_next_steps(&CombinedNextStepsInput {
1312            has_dead_code_findings: true,
1313            has_complexity_findings: true,
1314            offer_setup: true,
1315            impact_digest: Some(digest(2, 1)),
1316            audit_changed: true,
1317            baseline_recheck: Some(narrowed_baseline("dead-code")),
1318            ..combined_input(&fingerprints)
1319        });
1320
1321        assert_eq!(steps[0].id, "recheck-baseline");
1322    }
1323
1324    #[test]
1325    fn an_unscoped_run_offers_no_baseline_recheck() {
1326        let results = AnalysisResults::default();
1327        let steps = build_dead_code_next_steps(DeadCodeNextStepsInput {
1328            baseline_recheck: Some(BaselineRecheckInput {
1329                scope_reasons: BaselineScopeReasons::empty(),
1330                ..narrowed_baseline("dead-code")
1331            }),
1332            ..dead_code_input(&results)
1333        });
1334
1335        assert!(
1336            steps.is_empty(),
1337            "an unscoped run already got the advisory and the gate"
1338        );
1339    }
1340
1341    #[test]
1342    fn narrowing_the_printed_command_cannot_drop_offers_no_baseline_recheck() {
1343        let results = AnalysisResults::default();
1344        for reason in [
1345            ScopeReason::Production,
1346            ScopeReason::Workspace,
1347            ScopeReason::ChangedWorkspaces,
1348        ] {
1349            let steps = build_dead_code_next_steps(DeadCodeNextStepsInput {
1350                baseline_recheck: Some(BaselineRecheckInput {
1351                    scope_reasons: BaselineScopeReasons::empty().with(reason),
1352                    ..narrowed_baseline("dead-code")
1353                }),
1354                ..dead_code_input(&results)
1355            });
1356
1357            assert!(
1358                steps.is_empty(),
1359                "`fallow dead-code --baseline <path>` resolves {} again, so the step would \
1360                 re-emit itself: {steps:?}",
1361                reason.as_str()
1362            );
1363        }
1364    }
1365
1366    #[test]
1367    fn one_unremovable_channel_suppresses_the_recheck_for_the_whole_set() {
1368        let results = AnalysisResults::default();
1369        let steps = build_dead_code_next_steps(DeadCodeNextStepsInput {
1370            baseline_recheck: Some(BaselineRecheckInput {
1371                scope_reasons: BaselineScopeReasons::empty()
1372                    .with(ScopeReason::ChangedSince)
1373                    .with(ScopeReason::Production),
1374                ..narrowed_baseline("dead-code")
1375            }),
1376            ..dead_code_input(&results)
1377        });
1378
1379        assert!(
1380            steps.is_empty(),
1381            "dropping the base ref still leaves a production run, which judges nothing: {steps:?}"
1382        );
1383    }
1384
1385    #[test]
1386    fn an_empty_baseline_offers_no_baseline_recheck() {
1387        let results = AnalysisResults::default();
1388        let steps = build_dead_code_next_steps(DeadCodeNextStepsInput {
1389            baseline_recheck: Some(BaselineRecheckInput {
1390                baseline_entries: 0,
1391                ..narrowed_baseline("dead-code")
1392            }),
1393            ..dead_code_input(&results)
1394        });
1395
1396        assert!(steps.is_empty(), "there is nothing to re-check");
1397    }
1398
1399    #[test]
1400    fn no_next_step_command_ever_re_saves_a_baseline() {
1401        let results = AnalysisResults {
1402            unused_exports: vec![unused_export("/project/src/a.ts", "alpha")],
1403            ..AnalysisResults::default()
1404        };
1405        let fingerprints = ["abc123"];
1406        let mut every_step = Vec::new();
1407        every_step.extend(build_dead_code_next_steps(DeadCodeNextStepsInput {
1408            offer_setup: true,
1409            impact_digest: Some(digest(2, 1)),
1410            audit_changed: true,
1411            has_external_plugins: true,
1412            workspace_ref: Some("web"),
1413            baseline_recheck: Some(narrowed_baseline("dead-code")),
1414            ..dead_code_input(&results)
1415        }));
1416        every_step.extend(build_dupes_next_steps(DupesNextStepsInput {
1417            offer_setup: true,
1418            impact_digest: Some(digest(2, 1)),
1419            audit_changed: true,
1420            baseline_recheck: Some(narrowed_baseline("dupes")),
1421            ..dupes_input(&fingerprints)
1422        }));
1423        every_step.extend(build_health_next_steps(HealthNextStepsInput {
1424            baseline_recheck: Some(narrowed_baseline("health")),
1425            ..dirty_input()
1426        }));
1427
1428        assert!(!every_step.is_empty());
1429        for step in &every_step {
1430            assert_valid(step);
1431            assert!(
1432                !step.command.contains("--save-baseline"),
1433                "next_steps is a read-only contract: {}",
1434                step.command
1435            );
1436        }
1437    }
1438}