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