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