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                coverage_pct: None,
616                coverage_tier: None,
617                coverage_source: None,
618                inherited_from: None,
619                component_rollup: None,
620                contributions: Vec::new(),
621                effective_thresholds: None,
622                threshold_source: None,
623            })],
624            ..HealthReport::default()
625        }
626    }
627
628    fn unused_export(path: &str, name: &str) -> UnusedExportFinding {
629        UnusedExportFinding::with_actions(UnusedExport {
630            path: path.into(),
631            export_name: name.to_string(),
632            is_type_only: false,
633            line: 1,
634            col: 0,
635            span_start: 0,
636            is_re_export: false,
637            deprecated: false,
638            deprecated_reason: None,
639        })
640    }
641
642    fn dead_code_input(results: &AnalysisResults) -> DeadCodeNextStepsInput<'_> {
643        DeadCodeNextStepsInput {
644            suggestions_enabled: true,
645            results,
646            root: Path::new("/project"),
647            offer_setup: false,
648            impact_digest: None,
649            workspace_ref: None,
650            audit_changed: false,
651            has_external_plugins: false,
652            baseline_recheck: None,
653            command: "dead-code",
654        }
655    }
656
657    fn dupes_input<'a>(clone_fingerprints: &'a [&'a str]) -> DupesNextStepsInput<'a> {
658        DupesNextStepsInput {
659            suggestions_enabled: true,
660            clone_fingerprints,
661            offer_setup: false,
662            impact_digest: None,
663            audit_changed: false,
664            baseline_recheck: None,
665        }
666    }
667
668    fn combined_input<'a>(clone_fingerprints: &'a [&'a str]) -> CombinedNextStepsInput<'a> {
669        CombinedNextStepsInput {
670            suggestions_enabled: true,
671            has_dead_code_findings: false,
672            trace_unused_export: None,
673            workspace_ref: None,
674            clone_fingerprints,
675            has_complexity_findings: false,
676            offer_setup: false,
677            impact_digest: None,
678            audit_changed: false,
679            has_external_plugins: false,
680            has_unused_files: false,
681            baseline_recheck: None,
682        }
683    }
684
685    fn audit_input() -> AuditNextStepsInput {
686        AuditNextStepsInput {
687            suggestions_enabled: true,
688            trace_unused_export: None,
689            has_complexity_findings: false,
690        }
691    }
692
693    fn assert_valid(step: &NextStep) {
694        assert!(
695            !step.command.contains('<') && !step.command.contains('>'),
696            "command must be placeholder-free: {}",
697            step.command
698        );
699        assert!(
700            !step
701                .command
702                .split_whitespace()
703                .any(|token| MUTATING_VERBS.contains(&token)),
704            "command must be read-only: {}",
705            step.command
706        );
707    }
708
709    #[test]
710    fn audit_steps_are_empty_when_suggestions_are_disabled() {
711        let steps = build_audit_next_steps(&AuditNextStepsInput {
712            suggestions_enabled: false,
713            trace_unused_export: Some(TraceUnusedExportInput {
714                path: "src/a.ts".to_string(),
715                export_name: "alpha".to_string(),
716            }),
717            has_complexity_findings: true,
718        });
719
720        assert!(steps.is_empty());
721    }
722
723    #[test]
724    fn audit_input_builder_derives_trace_and_complexity_facts() {
725        let results = AnalysisResults {
726            unused_exports: vec![
727                unused_export("/project/src/b.ts", "beta"),
728                unused_export("/project/src/a.ts", "alpha"),
729            ],
730            ..AnalysisResults::default()
731        };
732        let report = dirty_report();
733
734        let input = build_audit_next_steps_input(
735            Some((&results, Path::new("/project"))),
736            Some(&report),
737            true,
738        );
739
740        assert_eq!(
741            input.trace_unused_export,
742            Some(TraceUnusedExportInput {
743                path: "src/a.ts".to_string(),
744                export_name: "alpha".to_string(),
745            })
746        );
747        assert!(input.has_complexity_findings);
748        assert!(input.suggestions_enabled);
749    }
750
751    #[test]
752    fn audit_steps_order_trace_before_complexity() {
753        let steps = build_audit_next_steps(&AuditNextStepsInput {
754            trace_unused_export: Some(TraceUnusedExportInput {
755                path: "src/a.ts".to_string(),
756                export_name: "alpha".to_string(),
757            }),
758            has_complexity_findings: true,
759            ..audit_input()
760        });
761        let ids = steps
762            .iter()
763            .map(|step| step.id.as_str())
764            .collect::<Vec<_>>();
765
766        assert_eq!(ids, ["trace-unused-export", "complexity-breakdown"]);
767        assert_eq!(steps[0].command, "fallow dead-code --trace src/a.ts:alpha");
768        for step in &steps {
769            assert_valid(step);
770        }
771    }
772
773    #[test]
774    fn audit_steps_emit_complexity_without_trace_target() {
775        let steps = build_audit_next_steps(&AuditNextStepsInput {
776            has_complexity_findings: true,
777            ..audit_input()
778        });
779
780        assert_eq!(steps.len(), 1);
781        assert_eq!(steps[0].id, "complexity-breakdown");
782    }
783
784    #[test]
785    fn health_steps_are_empty_when_suggestions_are_disabled() {
786        let steps = build_health_next_steps(HealthNextStepsInput {
787            suggestions_enabled: false,
788            has_findings: true,
789            offer_setup: true,
790            impact_digest: Some(digest(2, 1)),
791            audit_changed: true,
792            baseline_recheck: None,
793        });
794
795        assert!(steps.is_empty());
796    }
797
798    #[test]
799    fn health_input_builder_derives_findings_from_report() {
800        let clean = build_health_next_steps_input(
801            &HealthReport::default(),
802            true,
803            true,
804            Some(digest(2, 1)),
805            true,
806            None,
807        );
808        assert_eq!(
809            clean,
810            HealthNextStepsInput {
811                suggestions_enabled: true,
812                has_findings: false,
813                offer_setup: true,
814                impact_digest: Some(digest(2, 1)),
815                audit_changed: true,
816                baseline_recheck: None,
817            }
818        );
819
820        let dirty = build_health_next_steps_input(&dirty_report(), true, false, None, false, None);
821        assert!(dirty.has_findings);
822    }
823
824    fn deprecated(path: &str, name: &str, consumer_count: usize) -> DeprecatedExportInUseFinding {
825        DeprecatedExportInUseFinding::with_actions(DeprecatedExportInUse {
826            path: path.into(),
827            export_name: name.to_string(),
828            is_type_only: false,
829            line: 1,
830            col: 0,
831            span_start: 0,
832            deprecated_reason: None,
833            consumer_count,
834            consumers: Vec::new(),
835            public_api: false,
836        })
837    }
838
839    #[test]
840    fn dead_code_steps_trace_the_deprecated_export_with_most_consumers() {
841        let results = AnalysisResults {
842            deprecated_exports_in_use: vec![
843                deprecated("/project/src/a.ts", "few", 2),
844                deprecated("/project/src/b.ts", "many", 40),
845                deprecated("/project/src/c.ts", "tied", 40),
846            ],
847            ..AnalysisResults::default()
848        };
849
850        let steps = build_dead_code_next_steps(dead_code_input(&results));
851
852        assert_eq!(steps[0].id, "trace-deprecated-export");
853        assert_eq!(steps[0].command, "fallow dead-code --trace src/b.ts:many");
854        assert_valid(&steps[0]);
855    }
856
857    #[test]
858    fn dead_code_steps_trace_smallest_unused_export() {
859        let results = AnalysisResults {
860            unused_exports: vec![
861                unused_export("/project/src/b.ts", "beta"),
862                unused_export("/project/src/a.ts", "alpha"),
863            ],
864            ..AnalysisResults::default()
865        };
866
867        let steps = build_dead_code_next_steps(dead_code_input(&results));
868
869        assert_eq!(steps[0].id, "trace-unused-export");
870        assert_eq!(steps[0].command, "fallow dead-code --trace src/a.ts:alpha");
871        assert_valid(&steps[0]);
872    }
873
874    #[test]
875    fn scope_workspaces_step_names_the_command_that_ran() {
876        let results = AnalysisResults {
877            unused_exports: vec![unused_export("/project/src/a.ts", "alpha")],
878            ..AnalysisResults::default()
879        };
880        let scope_command = |command| {
881            build_dead_code_next_steps(DeadCodeNextStepsInput {
882                workspace_ref: Some("origin/main"),
883                command,
884                ..dead_code_input(&results)
885            })
886            .into_iter()
887            .find(|step| step.id == "scope-workspaces")
888            .map(|step| step.command)
889        };
890
891        assert_eq!(
892            scope_command("dead-code").as_deref(),
893            Some("fallow dead-code --changed-workspaces origin/main")
894        );
895        assert_eq!(
896            scope_command("architecture").as_deref(),
897            Some("fallow architecture --changed-workspaces origin/main")
898        );
899    }
900
901    #[test]
902    fn dead_code_steps_order_setup_impact_trace_workspace_then_audit() {
903        let results = AnalysisResults {
904            unused_exports: vec![unused_export("/project/src/a.ts", "alpha")],
905            ..AnalysisResults::default()
906        };
907        let steps = build_dead_code_next_steps(DeadCodeNextStepsInput {
908            offer_setup: true,
909            impact_digest: Some(digest(2, 1)),
910            workspace_ref: Some("origin/main"),
911            audit_changed: true,
912            ..dead_code_input(&results)
913        });
914        let ids = steps
915            .iter()
916            .map(|step| step.id.as_str())
917            .collect::<Vec<_>>();
918
919        assert_eq!(ids, ["setup", "impact-report", "trace-unused-export"]);
920        for step in &steps {
921            assert_valid(step);
922        }
923    }
924
925    #[test]
926    fn clean_dead_code_run_emits_only_due_impact_digest() {
927        let results = AnalysisResults::default();
928        let steps = build_dead_code_next_steps(DeadCodeNextStepsInput {
929            impact_digest: Some(digest(2, 1)),
930            audit_changed: true,
931            ..dead_code_input(&results)
932        });
933
934        assert_eq!(steps.len(), 1);
935        assert_eq!(steps[0].id, "impact-report");
936    }
937
938    #[test]
939    fn verify_plugins_step_fires_when_external_plugins_and_unused_files() {
940        let results = AnalysisResults {
941            unused_files: vec![
942                fallow_types::output_dead_code::UnusedFileFinding::with_actions(
943                    fallow_types::results::UnusedFile {
944                        path: "/project/src/orphan.ts".into(),
945                    },
946                ),
947            ],
948            ..AnalysisResults::default()
949        };
950        let steps = build_dead_code_next_steps(DeadCodeNextStepsInput {
951            has_external_plugins: true,
952            ..dead_code_input(&results)
953        });
954        let first = &steps[0];
955        assert_eq!(first.id, "verify-plugins");
956        assert_eq!(first.command, "fallow plugin-check --format json");
957        assert_valid(first);
958
959        // No external plugins: the step is absent even with unused files.
960        let without = build_dead_code_next_steps(DeadCodeNextStepsInput {
961            has_external_plugins: false,
962            ..dead_code_input(&results)
963        });
964        assert!(without.iter().all(|step| step.id != "verify-plugins"));
965
966        // External plugins but no unused files: absent (nothing to explain).
967        let no_unused = AnalysisResults {
968            unused_exports: vec![unused_export("/project/src/a.ts", "alpha")],
969            ..AnalysisResults::default()
970        };
971        let steps = build_dead_code_next_steps(DeadCodeNextStepsInput {
972            has_external_plugins: true,
973            ..dead_code_input(&no_unused)
974        });
975        assert!(steps.iter().all(|step| step.id != "verify-plugins"));
976    }
977
978    #[test]
979    fn dupes_steps_trace_smallest_clone_fingerprint() {
980        let fingerprints = ["dup:bbbbbbbb", "dup:aaaaaaaa"];
981
982        let steps = build_dupes_next_steps(dupes_input(&fingerprints));
983
984        assert_eq!(steps[0].id, "trace-clone");
985        assert_eq!(steps[0].command, "fallow dupes --trace dup:aaaaaaaa");
986        assert_valid(&steps[0]);
987    }
988
989    #[test]
990    fn dupes_steps_order_setup_impact_trace_then_audit() {
991        let fingerprints = ["dup:aaaaaaaa"];
992        let steps = build_dupes_next_steps(DupesNextStepsInput {
993            offer_setup: true,
994            impact_digest: Some(digest(2, 1)),
995            audit_changed: true,
996            ..dupes_input(&fingerprints)
997        });
998        let ids = steps
999            .iter()
1000            .map(|step| step.id.as_str())
1001            .collect::<Vec<_>>();
1002
1003        assert_eq!(ids, ["setup", "impact-report", "trace-clone"]);
1004        for step in &steps {
1005            assert_valid(step);
1006        }
1007    }
1008
1009    #[test]
1010    fn clean_dupes_run_emits_only_due_impact_digest() {
1011        let steps = build_dupes_next_steps(DupesNextStepsInput {
1012            impact_digest: Some(digest(2, 1)),
1013            audit_changed: true,
1014            ..dupes_input(&[])
1015        });
1016
1017        assert_eq!(steps.len(), 1);
1018        assert_eq!(steps[0].id, "impact-report");
1019    }
1020
1021    #[test]
1022    fn combined_steps_are_empty_when_suggestions_are_disabled() {
1023        let fingerprints = ["dup:aaaaaaaa"];
1024        let steps = build_combined_next_steps(&CombinedNextStepsInput {
1025            suggestions_enabled: false,
1026            has_dead_code_findings: true,
1027            trace_unused_export: Some(TraceUnusedExportInput {
1028                path: "src/a.ts".to_string(),
1029                export_name: "alpha".to_string(),
1030            }),
1031            workspace_ref: Some("origin/main"),
1032            clone_fingerprints: &fingerprints,
1033            has_complexity_findings: true,
1034            offer_setup: true,
1035            impact_digest: Some(digest(2, 1)),
1036            audit_changed: true,
1037            has_external_plugins: false,
1038            has_unused_files: false,
1039            baseline_recheck: None,
1040        });
1041
1042        assert!(steps.is_empty());
1043    }
1044
1045    #[test]
1046    fn clean_combined_run_emits_only_due_impact_digest() {
1047        let steps = build_combined_next_steps(&CombinedNextStepsInput {
1048            impact_digest: Some(digest(2, 1)),
1049            audit_changed: true,
1050            ..combined_input(&[])
1051        });
1052
1053        assert_eq!(steps.len(), 1);
1054        assert_eq!(steps[0].id, "impact-report");
1055    }
1056
1057    #[test]
1058    fn combined_steps_order_and_cap_all_signals() {
1059        let fingerprints = ["dup:bbbbbbbb", "dup:aaaaaaaa"];
1060        let steps = build_combined_next_steps(&CombinedNextStepsInput {
1061            has_dead_code_findings: true,
1062            trace_unused_export: Some(TraceUnusedExportInput {
1063                path: "src/a.ts".to_string(),
1064                export_name: "alpha".to_string(),
1065            }),
1066            workspace_ref: Some("origin/main"),
1067            has_complexity_findings: true,
1068            offer_setup: true,
1069            impact_digest: Some(digest(2, 1)),
1070            audit_changed: true,
1071            ..combined_input(&fingerprints)
1072        });
1073        let ids = steps
1074            .iter()
1075            .map(|step| step.id.as_str())
1076            .collect::<Vec<_>>();
1077
1078        assert_eq!(ids, ["setup", "impact-report", "trace-unused-export"]);
1079        for step in &steps {
1080            assert_valid(step);
1081        }
1082    }
1083
1084    #[test]
1085    fn combined_steps_keep_workspace_before_clone_and_complexity() {
1086        let fingerprints = ["dup:aaaaaaaa"];
1087        let steps = build_combined_next_steps(&CombinedNextStepsInput {
1088            has_dead_code_findings: true,
1089            workspace_ref: Some("origin/main"),
1090            has_complexity_findings: true,
1091            audit_changed: true,
1092            ..combined_input(&fingerprints)
1093        });
1094        let ids = steps
1095            .iter()
1096            .map(|step| step.id.as_str())
1097            .collect::<Vec<_>>();
1098
1099        assert_eq!(
1100            ids,
1101            ["scope-workspaces", "trace-clone", "complexity-breakdown"]
1102        );
1103    }
1104
1105    #[test]
1106    fn clean_health_run_emits_only_due_impact_digest() {
1107        let steps = build_health_next_steps(HealthNextStepsInput {
1108            suggestions_enabled: true,
1109            has_findings: false,
1110            offer_setup: true,
1111            impact_digest: Some(digest(2, 1)),
1112            audit_changed: true,
1113            baseline_recheck: None,
1114        });
1115
1116        assert_eq!(steps.len(), 1);
1117        assert_eq!(steps[0].id, "impact-report");
1118        assert_valid(&steps[0]);
1119    }
1120
1121    #[test]
1122    fn dirty_health_run_orders_setup_impact_complexity_then_audit() {
1123        let steps = build_health_next_steps(HealthNextStepsInput {
1124            offer_setup: true,
1125            impact_digest: Some(digest(2, 1)),
1126            audit_changed: true,
1127            ..dirty_input()
1128        });
1129        let ids = steps
1130            .iter()
1131            .map(|step| step.id.as_str())
1132            .collect::<Vec<_>>();
1133
1134        assert_eq!(ids, ["setup", "impact-report", "complexity-breakdown"]);
1135        for step in &steps {
1136            assert_valid(step);
1137        }
1138    }
1139
1140    #[test]
1141    fn dirty_health_run_uses_complexity_when_setup_and_impact_are_absent() {
1142        let steps = build_health_next_steps(HealthNextStepsInput {
1143            audit_changed: true,
1144            ..dirty_input()
1145        });
1146        let ids = steps
1147            .iter()
1148            .map(|step| step.id.as_str())
1149            .collect::<Vec<_>>();
1150
1151        assert_eq!(ids, ["complexity-breakdown", "audit-changed"]);
1152    }
1153
1154    #[test]
1155    fn impact_digest_summary_pluralizes_real_counters() {
1156        assert_eq!(
1157            impact_digest_summary(digest(1, 1)),
1158            "1 commit contained at the gate, 1 finding resolved"
1159        );
1160        assert_eq!(
1161            impact_digest_summary(digest(2, 3)),
1162            "2 commits contained at the gate, 3 findings resolved"
1163        );
1164    }
1165
1166    fn narrowed_baseline(command: &'static str) -> BaselineRecheckInput<'static> {
1167        BaselineRecheckInput {
1168            command,
1169            path: ".fallow-baseline.json",
1170            baseline_entries: 8,
1171            scope_reasons: BaselineScopeReasons::empty()
1172                .with(ScopeReason::ChangedSince)
1173                .with(ScopeReason::Scope),
1174        }
1175    }
1176
1177    /// The package map comes from the config, so a repeat without flags would
1178    /// come back just as narrow. The step turns the map off explicitly.
1179    #[test]
1180    fn a_package_map_recheck_turns_the_map_off() {
1181        let results = AnalysisResults::default();
1182        let steps = build_dead_code_next_steps(DeadCodeNextStepsInput {
1183            baseline_recheck: Some(BaselineRecheckInput {
1184                scope_reasons: BaselineScopeReasons::empty().with(ScopeReason::PackageBaselines),
1185                ..narrowed_baseline("dead-code")
1186            }),
1187            ..dead_code_input(&results)
1188        });
1189        assert_eq!(steps.len(), 1);
1190        assert_eq!(
1191            steps[0].command,
1192            "fallow dead-code --baseline .fallow-baseline.json --no-package-baselines"
1193        );
1194    }
1195
1196    #[test]
1197    fn a_narrowed_zero_finding_dead_code_run_still_offers_the_baseline_recheck() {
1198        let results = AnalysisResults::default();
1199        let steps = build_dead_code_next_steps(DeadCodeNextStepsInput {
1200            baseline_recheck: Some(narrowed_baseline("dead-code")),
1201            ..dead_code_input(&results)
1202        });
1203
1204        assert_eq!(
1205            steps
1206                .iter()
1207                .map(|step| step.id.as_str())
1208                .collect::<Vec<_>>(),
1209            ["recheck-baseline"],
1210            "a cleaned project with a rotted baseline is the run that most needs the pointer"
1211        );
1212        assert_eq!(
1213            steps[0].command,
1214            "fallow dead-code --baseline .fallow-baseline.json"
1215        );
1216        assert!(
1217            steps[0].reason.contains("changed-since, scope"),
1218            "the reason must name the published scope_reasons: {}",
1219            steps[0].reason
1220        );
1221        assert_valid(&steps[0]);
1222    }
1223
1224    #[test]
1225    fn the_baseline_recheck_leads_a_run_that_triggers_everything() {
1226        let results = AnalysisResults {
1227            unused_exports: vec![unused_export("/project/src/a.ts", "alpha")],
1228            ..AnalysisResults::default()
1229        };
1230        let steps = build_dead_code_next_steps(DeadCodeNextStepsInput {
1231            offer_setup: true,
1232            impact_digest: Some(digest(2, 1)),
1233            audit_changed: true,
1234            baseline_recheck: Some(narrowed_baseline("dead-code")),
1235            ..dead_code_input(&results)
1236        });
1237
1238        assert_eq!(steps[0].id, "recheck-baseline");
1239    }
1240
1241    #[test]
1242    fn a_narrowed_zero_finding_dupes_run_still_offers_the_baseline_recheck() {
1243        let steps = build_dupes_next_steps(DupesNextStepsInput {
1244            baseline_recheck: Some(narrowed_baseline("dupes")),
1245            ..dupes_input(&[])
1246        });
1247
1248        assert_eq!(
1249            steps
1250                .iter()
1251                .map(|step| step.id.as_str())
1252                .collect::<Vec<_>>(),
1253            ["recheck-baseline"]
1254        );
1255        assert_eq!(
1256            steps[0].command,
1257            "fallow dupes --baseline .fallow-baseline.json"
1258        );
1259    }
1260
1261    #[test]
1262    fn a_narrowed_zero_finding_health_run_still_offers_the_baseline_recheck() {
1263        let steps = build_health_next_steps(HealthNextStepsInput {
1264            has_findings: false,
1265            baseline_recheck: Some(narrowed_baseline("health")),
1266            ..dirty_input()
1267        });
1268
1269        assert_eq!(
1270            steps
1271                .iter()
1272                .map(|step| step.id.as_str())
1273                .collect::<Vec<_>>(),
1274            ["recheck-baseline"]
1275        );
1276        assert_eq!(
1277            steps[0].command,
1278            "fallow health --baseline .fallow-baseline.json"
1279        );
1280    }
1281
1282    /// The combined run was the one shape that could load a baseline and offer no
1283    /// way to judge it: it baselines its dead-code sub-pass, so the step names
1284    /// that command (issue #2735).
1285    #[test]
1286    fn a_narrowed_combined_run_offers_the_baseline_recheck() {
1287        let steps = build_combined_next_steps(&CombinedNextStepsInput {
1288            baseline_recheck: Some(narrowed_baseline("dead-code")),
1289            ..combined_input(&[])
1290        });
1291
1292        assert_eq!(
1293            steps
1294                .iter()
1295                .map(|step| step.id.as_str())
1296                .collect::<Vec<_>>(),
1297            ["recheck-baseline"],
1298            "a combined run with nothing to report is still a run that loaded a baseline"
1299        );
1300        assert_eq!(
1301            steps[0].command,
1302            "fallow dead-code --baseline .fallow-baseline.json"
1303        );
1304        assert_valid(&steps[0]);
1305    }
1306
1307    #[test]
1308    fn the_baseline_recheck_leads_a_combined_run_that_triggers_everything() {
1309        let fingerprints = ["dup:aaaaaaaa"];
1310        let steps = build_combined_next_steps(&CombinedNextStepsInput {
1311            has_dead_code_findings: true,
1312            has_complexity_findings: true,
1313            offer_setup: true,
1314            impact_digest: Some(digest(2, 1)),
1315            audit_changed: true,
1316            baseline_recheck: Some(narrowed_baseline("dead-code")),
1317            ..combined_input(&fingerprints)
1318        });
1319
1320        assert_eq!(steps[0].id, "recheck-baseline");
1321    }
1322
1323    #[test]
1324    fn an_unscoped_run_offers_no_baseline_recheck() {
1325        let results = AnalysisResults::default();
1326        let steps = build_dead_code_next_steps(DeadCodeNextStepsInput {
1327            baseline_recheck: Some(BaselineRecheckInput {
1328                scope_reasons: BaselineScopeReasons::empty(),
1329                ..narrowed_baseline("dead-code")
1330            }),
1331            ..dead_code_input(&results)
1332        });
1333
1334        assert!(
1335            steps.is_empty(),
1336            "an unscoped run already got the advisory and the gate"
1337        );
1338    }
1339
1340    #[test]
1341    fn narrowing_the_printed_command_cannot_drop_offers_no_baseline_recheck() {
1342        let results = AnalysisResults::default();
1343        for reason in [
1344            ScopeReason::Production,
1345            ScopeReason::Workspace,
1346            ScopeReason::ChangedWorkspaces,
1347        ] {
1348            let steps = build_dead_code_next_steps(DeadCodeNextStepsInput {
1349                baseline_recheck: Some(BaselineRecheckInput {
1350                    scope_reasons: BaselineScopeReasons::empty().with(reason),
1351                    ..narrowed_baseline("dead-code")
1352                }),
1353                ..dead_code_input(&results)
1354            });
1355
1356            assert!(
1357                steps.is_empty(),
1358                "`fallow dead-code --baseline <path>` resolves {} again, so the step would \
1359                 re-emit itself: {steps:?}",
1360                reason.as_str()
1361            );
1362        }
1363    }
1364
1365    #[test]
1366    fn one_unremovable_channel_suppresses_the_recheck_for_the_whole_set() {
1367        let results = AnalysisResults::default();
1368        let steps = build_dead_code_next_steps(DeadCodeNextStepsInput {
1369            baseline_recheck: Some(BaselineRecheckInput {
1370                scope_reasons: BaselineScopeReasons::empty()
1371                    .with(ScopeReason::ChangedSince)
1372                    .with(ScopeReason::Production),
1373                ..narrowed_baseline("dead-code")
1374            }),
1375            ..dead_code_input(&results)
1376        });
1377
1378        assert!(
1379            steps.is_empty(),
1380            "dropping the base ref still leaves a production run, which judges nothing: {steps:?}"
1381        );
1382    }
1383
1384    #[test]
1385    fn an_empty_baseline_offers_no_baseline_recheck() {
1386        let results = AnalysisResults::default();
1387        let steps = build_dead_code_next_steps(DeadCodeNextStepsInput {
1388            baseline_recheck: Some(BaselineRecheckInput {
1389                baseline_entries: 0,
1390                ..narrowed_baseline("dead-code")
1391            }),
1392            ..dead_code_input(&results)
1393        });
1394
1395        assert!(steps.is_empty(), "there is nothing to re-check");
1396    }
1397
1398    #[test]
1399    fn no_next_step_command_ever_re_saves_a_baseline() {
1400        let results = AnalysisResults {
1401            unused_exports: vec![unused_export("/project/src/a.ts", "alpha")],
1402            ..AnalysisResults::default()
1403        };
1404        let fingerprints = ["abc123"];
1405        let mut every_step = Vec::new();
1406        every_step.extend(build_dead_code_next_steps(DeadCodeNextStepsInput {
1407            offer_setup: true,
1408            impact_digest: Some(digest(2, 1)),
1409            audit_changed: true,
1410            has_external_plugins: true,
1411            workspace_ref: Some("web"),
1412            baseline_recheck: Some(narrowed_baseline("dead-code")),
1413            ..dead_code_input(&results)
1414        }));
1415        every_step.extend(build_dupes_next_steps(DupesNextStepsInput {
1416            offer_setup: true,
1417            impact_digest: Some(digest(2, 1)),
1418            audit_changed: true,
1419            baseline_recheck: Some(narrowed_baseline("dupes")),
1420            ..dupes_input(&fingerprints)
1421        }));
1422        every_step.extend(build_health_next_steps(HealthNextStepsInput {
1423            baseline_recheck: Some(narrowed_baseline("health")),
1424            ..dirty_input()
1425        }));
1426
1427        assert!(!every_step.is_empty());
1428        for step in &every_step {
1429            assert_valid(step);
1430            assert!(
1431                !step.command.contains("--save-baseline"),
1432                "next_steps is a read-only contract: {}",
1433                step.command
1434            );
1435        }
1436    }
1437}