Skip to main content

fallow_cli/report/
suggestions.rs

1//! Runtime fact adapters for `next_steps[]` builders.
2//!
3//! The stable command strings, ordering, caps, and read-only contract live in
4//! `fallow-output`. This module keeps the CLI-specific probes: environment
5//! toggles, project setup state, git refs, and changed-branch applicability.
6
7use std::path::Path;
8use std::process::Command;
9
10use fallow_api::DupesReportPayload;
11use fallow_output::{
12    CombinedNextStepsInput, DeadCodeNextStepsInput, DupesNextStepsInput, HealthNextStepsInput,
13    ImpactDigestCounts, build_combined_next_steps as build_combined_next_steps_contract,
14    build_dead_code_next_steps as build_dead_code_next_steps_contract,
15    build_dupes_next_steps as build_dupes_next_steps_contract, impact_digest_summary,
16    trace_unused_export_input,
17};
18use fallow_types::output::NextStep;
19use fallow_types::results::AnalysisResults;
20
21use fallow_output::HealthReport;
22
23/// `FALLOW_SUGGESTIONS=off` (or `0`/`false`/`no`/`disabled`) disables next-steps
24/// entirely. Default on. This is the documented escape hatch for CI consumers
25/// that snapshot-diff raw `--format json` output; it reaches the spawned-CLI and
26/// MCP surfaces without a CLI flag.
27#[must_use]
28pub fn suggestions_enabled() -> bool {
29    suggestions_enabled_from(std::env::var("FALLOW_SUGGESTIONS").ok().as_deref())
30}
31
32/// Pure parse of the `FALLOW_SUGGESTIONS` value (kept separate so it is testable
33/// without mutating process env, which is `unsafe` under edition 2024).
34#[must_use]
35fn suggestions_enabled_from(value: Option<&str>) -> bool {
36    match value {
37        Some(raw) => !matches!(
38            raw.trim().to_ascii_lowercase().as_str(),
39            "off" | "0" | "false" | "no" | "disabled"
40        ),
41        None => true,
42    }
43}
44
45/// Shared first-contact gate for the `setup` next-step and the human setup hint
46/// on bare `fallow`: the project has no fallow config (searched up to the repo
47/// root, same as config loading), the run is not in CI, and onboarding has not
48/// been declined for this project (`fallow init --decline`).
49#[must_use]
50pub fn setup_pointer_applicable(root: &Path) -> bool {
51    root.exists()
52        && fallow_config::FallowConfig::find_config_path(root).is_none()
53        && !crate::telemetry::is_ci()
54        && !crate::impact::load(root).onboarding_declined
55}
56
57/// One-line human setup hint for bare `fallow` output: the prose counterpart of
58/// the `setup` next-step (agents get the JSON form, humans get this line).
59/// Worded as an offer, not a deficiency: zero-config is a supported happy path.
60pub const SETUP_HINT: &str = "Setup: `fallow init --agents` writes an agent guide; `fallow hooks install --target agent` adds a commit gate (hide this hint: `fallow init --decline`).";
61
62/// Real-counter summary fragment shared by the next-step reason and the human
63/// one-liner. The output crate owns the `impact-report` command contract.
64pub fn impact_counts(digest: crate::impact::ImpactDigest) -> ImpactDigestCounts {
65    ImpactDigestCounts {
66        containment_count: digest.containment_count,
67        resolved_total: digest.resolved_total,
68    }
69}
70
71fn digest_summary(digest: crate::impact::ImpactDigest) -> String {
72    impact_digest_summary(impact_counts(digest))
73}
74
75/// One-line human counterpart of the `impact-report` next-step, printed with
76/// the run summary on bare `fallow`.
77#[must_use]
78pub fn impact_digest_line(digest: crate::impact::ImpactDigest) -> String {
79    format!(
80        "Impact: {} (details: `fallow impact`).",
81        digest_summary(digest)
82    )
83}
84
85/// Read-and-stamp the due periodic impact digest for the envelope being built.
86/// Returns `None` in CI or when suggestions are disabled, WITHOUT consuming the
87/// cadence stamp, so the digest is never burned by a surface that will not
88/// show it.
89#[must_use]
90pub fn due_impact_digest(root: &Path) -> Option<crate::impact::ImpactDigest> {
91    if !suggestions_enabled() || crate::telemetry::is_ci() {
92        return None;
93    }
94    crate::impact::take_due_digest(root)
95}
96
97fn default_workspace_ref_for_next_step(root: &Path) -> Option<String> {
98    if fallow_config::discover_workspaces(root).is_empty() {
99        return None;
100    }
101    resolve_default_workspace_ref(root)
102}
103
104/// `audit-changed`: gate only the files the current branch changed. `fallow
105/// audit` auto-detects its base, so no ref needs embedding.
106pub fn audit_changed_applicable(root: &Path) -> bool {
107    fallow_engine::churn::is_git_repo(root)
108}
109
110// ---------------------------------------------------------------------------
111// Git ref resolution (self-contained; keeps `scope-workspaces` placeholder-free)
112// ---------------------------------------------------------------------------
113
114/// Resolve a concrete, human-readable default ref for `--changed-workspaces`.
115/// Tries `origin/HEAD` then verifies `origin/main` / `origin/master`. Returns
116/// the first that resolves, or `None` (in which case `scope-workspaces` is
117/// omitted rather than shipping an unrunnable `origin/main` guess).
118fn resolve_default_workspace_ref(root: &Path) -> Option<String> {
119    if let Some(reference) = run_git(
120        root,
121        &[
122            "symbolic-ref",
123            "--quiet",
124            "--short",
125            "refs/remotes/origin/HEAD",
126        ],
127    ) {
128        let reference = reference.trim();
129        if !reference.is_empty() {
130            return Some(reference.to_string());
131        }
132    }
133    ["origin/main", "origin/master"]
134        .into_iter()
135        .find(|candidate| git_ref_exists(root, candidate))
136        .map(str::to_string)
137}
138
139fn git_ref_exists(root: &Path, reference: &str) -> bool {
140    Command::new("git")
141        .args(["-C"])
142        .arg(root)
143        .args(["rev-parse", "--verify", "--quiet", reference])
144        .output()
145        .is_ok_and(|output| output.status.success())
146}
147
148fn run_git(root: &Path, args: &[&str]) -> Option<String> {
149    let output = Command::new("git")
150        .args(["-C"])
151        .arg(root)
152        .args(args)
153        .output()
154        .ok()?;
155    if !output.status.success() {
156        return None;
157    }
158    String::from_utf8(output.stdout).ok()
159}
160
161// ---------------------------------------------------------------------------
162// Public per-command builders. Each no-ops when suggestions are disabled or the
163// run is clean (no findings), so a clean run never emits `next_steps`, with one
164// documented exception: a due `impact-report` digest may ride a clean run.
165// ---------------------------------------------------------------------------
166
167/// Next-steps for standalone `fallow dead-code`. `offer_setup` is the caller's
168/// [`setup_pointer_applicable`] result (threaded as a parameter so the builders
169/// stay free of env/filesystem probes and deterministic under test).
170#[must_use]
171pub fn build_dead_code_next_steps(
172    results: &AnalysisResults,
173    root: &Path,
174    offer_setup: bool,
175    digest: Option<crate::impact::ImpactDigest>,
176) -> Vec<NextStep> {
177    let workspace_ref = default_workspace_ref_for_next_step(root);
178    build_dead_code_next_steps_contract(DeadCodeNextStepsInput {
179        suggestions_enabled: suggestions_enabled(),
180        results,
181        root,
182        offer_setup,
183        impact_digest: digest.map(impact_counts),
184        workspace_ref: workspace_ref.as_deref(),
185        audit_changed: audit_changed_applicable(root),
186    })
187}
188
189/// Next-steps for standalone `fallow health`. See [`build_dead_code_next_steps`]
190/// for the `offer_setup` parameter contract.
191#[must_use]
192pub fn health_next_steps_input(
193    report: &HealthReport,
194    root: &Path,
195    offer_setup: bool,
196    digest: Option<crate::impact::ImpactDigest>,
197) -> HealthNextStepsInput {
198    fallow_output::build_health_next_steps_input(
199        report,
200        suggestions_enabled(),
201        offer_setup,
202        digest.map(impact_counts),
203        audit_changed_applicable(root),
204    )
205}
206
207/// Next-steps for standalone `fallow dupes`. See [`build_dead_code_next_steps`]
208/// for the `offer_setup` parameter contract.
209#[must_use]
210pub fn build_dupes_next_steps(
211    payload: &DupesReportPayload,
212    root: &Path,
213    offer_setup: bool,
214    digest: Option<crate::impact::ImpactDigest>,
215) -> Vec<NextStep> {
216    let clone_fingerprints = payload
217        .clone_groups
218        .iter()
219        .map(|group| group.fingerprint.as_str())
220        .collect::<Vec<_>>();
221    build_dupes_next_steps_contract(DupesNextStepsInput {
222        suggestions_enabled: suggestions_enabled(),
223        clone_fingerprints: &clone_fingerprints,
224        offer_setup,
225        impact_digest: digest.map(impact_counts),
226        audit_changed: audit_changed_applicable(root),
227    })
228}
229
230/// Aggregated next-steps for bare `fallow` (combined). Candidates are pushed in
231/// priority order, then capped. `trace-unused-export` leads because it is the
232/// highest-value verification path; `scope-workspaces` is boosted above the
233/// trace-clone / complexity tier so big-repo runs that trigger everything still
234/// surface the rare monorepo-scoping capability instead of always dropping it
235/// under the cap. `audit-changed` is last (broadly applicable, least specific).
236#[must_use]
237pub fn build_combined_next_steps(
238    results: Option<&AnalysisResults>,
239    dupes: Option<&DupesReportPayload>,
240    health: Option<&HealthReport>,
241    root: &Path,
242    offer_setup: bool,
243    digest: Option<crate::impact::ImpactDigest>,
244) -> Vec<NextStep> {
245    let workspace_ref = default_workspace_ref_for_next_step(root);
246    let clone_fingerprints = dupes
247        .map(|payload| {
248            payload
249                .clone_groups
250                .iter()
251                .map(|group| group.fingerprint.as_str())
252                .collect::<Vec<_>>()
253        })
254        .unwrap_or_default();
255    build_combined_next_steps_contract(&CombinedNextStepsInput {
256        suggestions_enabled: suggestions_enabled(),
257        has_dead_code_findings: results.is_some_and(|r| r.total_issues() > 0),
258        trace_unused_export: results.and_then(|r| trace_unused_export_input(r, root)),
259        workspace_ref: workspace_ref.as_deref(),
260        clone_fingerprints: &clone_fingerprints,
261        has_complexity_findings: health.is_some_and(|h| !h.findings.is_empty()),
262        offer_setup,
263        impact_digest: digest.map(impact_counts),
264        audit_changed: audit_changed_applicable(root),
265    })
266}
267
268/// Next-steps for `fallow audit`. No `audit-changed` (audit IS the changed
269/// scope) and no `scope-workspaces` (audit already gates the change). The
270/// `check` tuple carries the changed-file analysis results plus the project root
271/// so the trace anchor is made root-relative the same way every other surface
272/// does it (in-memory finding paths are absolute; the wire form is relative).
273#[must_use]
274#[cfg(test)]
275pub fn build_audit_next_steps(
276    check: Option<(&AnalysisResults, &Path)>,
277    complexity: Option<&HealthReport>,
278) -> Vec<NextStep> {
279    fallow_output::build_audit_next_steps(&fallow_output::build_audit_next_steps_input(
280        check,
281        complexity,
282        suggestions_enabled(),
283    ))
284}
285
286/// The single highest-priority next-step for the human `Next:` line, computed
287/// from the same candidates and ordering as the combined JSON array so a human
288/// and an agent on the same run never see a contradictory top step. The `setup`
289/// pointer is deliberately excluded here (`offer_setup: false`): humans get the
290/// dedicated prose [`SETUP_HINT`] line instead, so the `Next:` slot always
291/// shows an analysis follow-up.
292#[must_use]
293pub fn top_combined_next_step(
294    results: Option<&AnalysisResults>,
295    dupes: Option<&DupesReportPayload>,
296    health: Option<&HealthReport>,
297    root: &Path,
298) -> Option<NextStep> {
299    build_combined_next_steps(results, dupes, health, root, false, None)
300        .into_iter()
301        .next()
302}
303
304#[cfg(test)]
305mod tests {
306    use std::path::PathBuf;
307
308    use fallow_engine::duplicates::{
309        CloneGroup, CloneInstance, DuplicationReport, DuplicationStats,
310    };
311    use fallow_output::build_health_next_steps as build_health_next_steps_contract;
312    use fallow_output::{
313        ComplexityViolation, ExceededThreshold, FindingSeverity, HealthFinding, HealthReport,
314    };
315    use fallow_types::output_dead_code::UnusedExportFinding;
316    use fallow_types::results::{AnalysisResults, UnusedExport};
317
318    use super::*;
319
320    fn unused_export(path: &str, name: &str) -> UnusedExportFinding {
321        UnusedExportFinding::with_actions(UnusedExport {
322            path: PathBuf::from(path),
323            export_name: name.to_string(),
324            is_type_only: false,
325            line: 1,
326            col: 0,
327            span_start: 0,
328            is_re_export: false,
329        })
330    }
331
332    fn results_with_exports(exports: Vec<UnusedExportFinding>) -> AnalysisResults {
333        AnalysisResults {
334            unused_exports: exports,
335            ..AnalysisResults::default()
336        }
337    }
338
339    fn clone_instance(path: &str, fragment: &str) -> CloneInstance {
340        CloneInstance {
341            file: PathBuf::from(path),
342            start_line: 1,
343            end_line: 8,
344            start_col: 0,
345            end_col: 0,
346            fragment: fragment.to_string(),
347        }
348    }
349
350    fn dupes_payload() -> DupesReportPayload {
351        let group = CloneGroup {
352            instances: vec![
353                clone_instance("/project/src/a.ts", "export const shared = 1;"),
354                clone_instance("/project/src/b.ts", "export const shared = 1;"),
355            ],
356            token_count: 20,
357            line_count: 8,
358        };
359        DupesReportPayload::from_report(&DuplicationReport {
360            clone_groups: vec![group],
361            clone_families: Vec::new(),
362            mirrored_directories: Vec::new(),
363            stats: DuplicationStats::default(),
364        })
365    }
366
367    fn health_report_with_finding() -> HealthReport {
368        HealthReport {
369            findings: vec![HealthFinding::from(ComplexityViolation {
370                path: PathBuf::from("/project/src/hot.ts"),
371                name: "hot".to_string(),
372                line: 1,
373                col: 0,
374                cyclomatic: 21,
375                cognitive: 16,
376                line_count: 42,
377                param_count: 0,
378                react_hook_count: 0,
379                react_jsx_max_depth: 0,
380                react_prop_count: 0,
381                react_hook_profile: None,
382                exceeded: ExceededThreshold::Both,
383                severity: FindingSeverity::High,
384                crap: None,
385                coverage_pct: None,
386                coverage_tier: None,
387                coverage_source: None,
388                inherited_from: None,
389                component_rollup: None,
390                contributions: Vec::new(),
391                effective_thresholds: None,
392                threshold_source: None,
393            })],
394            ..HealthReport::default()
395        }
396    }
397
398    fn assert_valid(step: &NextStep) {
399        const MUTATING_VERBS: [&str; 5] = ["fix", "init", "hooks", "migrate", "setup-hooks"];
400
401        assert!(
402            !step.command.contains('<') && !step.command.contains('>'),
403            "command must be placeholder-free: {}",
404            step.command
405        );
406        assert!(
407            !step
408                .command
409                .split_whitespace()
410                .any(|token| MUTATING_VERBS.contains(&token)),
411            "command must be read-only: {}",
412            step.command
413        );
414    }
415
416    #[test]
417    fn audit_next_steps_emit_runnable_relative_trace_command() {
418        let root = PathBuf::from("/project");
419        let results = results_with_exports(vec![unused_export("/project/src/util.ts", "foo")]);
420        let steps = build_audit_next_steps(Some((&results, &root)), None);
421
422        assert_eq!(steps.len(), 1);
423        assert_eq!(steps[0].id, "trace-unused-export");
424        assert_eq!(steps[0].command, "fallow dead-code --trace src/util.ts:foo");
425        assert_valid(&steps[0]);
426    }
427
428    #[test]
429    fn audit_next_steps_select_deterministic_trace_target() {
430        let root = PathBuf::from("/project");
431        let forward = results_with_exports(vec![
432            unused_export("/project/src/b.ts", "beta"),
433            unused_export("/project/src/a.ts", "alpha"),
434        ]);
435        let reverse = results_with_exports(vec![
436            unused_export("/project/src/a.ts", "alpha"),
437            unused_export("/project/src/b.ts", "beta"),
438        ]);
439        let a = build_audit_next_steps(Some((&forward, &root)), None);
440        let b = build_audit_next_steps(Some((&reverse, &root)), None);
441        assert_eq!(a[0].command, b[0].command);
442        assert_eq!(a[0].command, "fallow dead-code --trace src/a.ts:alpha");
443    }
444
445    #[test]
446    fn clean_run_emits_no_next_steps() {
447        let root = PathBuf::from("/project");
448        let results = AnalysisResults::default();
449        assert!(build_dead_code_next_steps(&results, &root, true, None).is_empty());
450    }
451
452    #[test]
453    fn setup_pointer_gate_ignores_nonexistent_roots() {
454        assert!(!setup_pointer_applicable(Path::new(
455            "/fallow-test-project-does-not-exist"
456        )));
457    }
458
459    #[test]
460    fn setup_pointer_leads_when_offered() {
461        let root = PathBuf::from("/project");
462        let results = results_with_exports(vec![unused_export("/project/src/a.ts", "alpha")]);
463        let steps = build_dead_code_next_steps(&results, &root, true, None);
464        assert_eq!(steps.first().map(|s| s.id.as_str()), Some("setup"));
465        let steps = build_dead_code_next_steps(&results, &root, false, None);
466        assert!(steps.iter().all(|s| s.id != "setup"));
467    }
468
469    #[test]
470    fn human_top_step_never_surfaces_setup() {
471        let results = results_with_exports(vec![unused_export("/project/src/a.ts", "alpha")]);
472        let top = top_combined_next_step(Some(&results), None, None, Path::new("/project"));
473        if let Some(step) = top {
474            assert_ne!(step.id, "setup");
475        }
476    }
477
478    fn digest(containment: usize, resolved: usize) -> crate::impact::ImpactDigest {
479        crate::impact::ImpactDigest {
480            containment_count: containment,
481            resolved_total: resolved,
482        }
483    }
484
485    #[test]
486    fn due_digest_rides_a_clean_run() {
487        let root = PathBuf::from("/project");
488        let results = AnalysisResults::default();
489        let steps = build_dead_code_next_steps(&results, &root, true, Some(digest(2, 0)));
490        assert_eq!(steps.len(), 1, "clean run carries ONLY the digest");
491        assert_eq!(steps[0].id, "impact-report");
492    }
493
494    #[test]
495    fn digest_follows_setup_on_dirty_runs() {
496        let root = PathBuf::from("/project");
497        let results = results_with_exports(vec![unused_export("/project/src/a.ts", "alpha")]);
498        let steps = build_dead_code_next_steps(&results, &root, true, Some(digest(2, 3)));
499        let ids: Vec<&str> = steps.iter().map(|s| s.id.as_str()).collect();
500        assert_eq!(ids[0], "setup");
501        assert_eq!(ids[1], "impact-report");
502    }
503
504    #[test]
505    fn health_steps_keep_complexity_breakdown_from_output_contract() {
506        let report = health_report_with_finding();
507        let steps = build_health_next_steps_contract(health_next_steps_input(
508            &report,
509            Path::new("/project"),
510            false,
511            None,
512        ));
513        let ids: Vec<&str> = steps.iter().map(|s| s.id.as_str()).collect();
514
515        assert_eq!(ids, ["complexity-breakdown"]);
516        assert_valid(&steps[0]);
517    }
518
519    #[test]
520    fn health_next_steps_input_feeds_output_contract_builder() {
521        let report = health_report_with_finding();
522        let input =
523            health_next_steps_input(&report, Path::new("/project"), true, Some(digest(2, 1)));
524
525        assert!(input.suggestions_enabled);
526        assert!(input.has_findings);
527        assert!(input.offer_setup);
528        assert_eq!(
529            input.impact_digest,
530            Some(ImpactDigestCounts {
531                containment_count: 2,
532                resolved_total: 1,
533            })
534        );
535
536        let steps = build_health_next_steps_contract(input);
537        let ids: Vec<&str> = steps.iter().map(|s| s.id.as_str()).collect();
538        assert_eq!(ids, ["setup", "impact-report", "complexity-breakdown"]);
539    }
540
541    #[test]
542    fn dupes_next_steps_route_payload_fingerprints_to_output_contract() {
543        let payload = dupes_payload();
544
545        let steps = build_dupes_next_steps(&payload, Path::new("/project"), false, None);
546
547        assert_eq!(steps.len(), 1);
548        assert_eq!(steps[0].id, "trace-clone");
549        assert!(steps[0].command.starts_with("fallow dupes --trace dup:"));
550        assert_valid(&steps[0]);
551    }
552
553    #[test]
554    fn combined_next_steps_route_payload_facts_to_output_contract() {
555        let root = PathBuf::from("/project");
556        let results = results_with_exports(vec![
557            unused_export("/project/src/b.ts", "beta"),
558            unused_export("/project/src/a.ts", "alpha"),
559        ]);
560        let payload = dupes_payload();
561        let report = health_report_with_finding();
562
563        let steps = build_combined_next_steps(
564            Some(&results),
565            Some(&payload),
566            Some(&report),
567            &root,
568            true,
569            Some(digest(2, 1)),
570        );
571        let ids: Vec<&str> = steps.iter().map(|s| s.id.as_str()).collect();
572
573        assert_eq!(ids, ["setup", "impact-report", "trace-unused-export"]);
574        assert_eq!(steps[2].command, "fallow dead-code --trace src/a.ts:alpha");
575        for step in &steps {
576            assert_valid(step);
577        }
578    }
579
580    #[test]
581    fn audit_next_steps_route_payload_facts_to_output_contract() {
582        let root = PathBuf::from("/project");
583        let results = results_with_exports(vec![
584            unused_export("/project/src/b.ts", "beta"),
585            unused_export("/project/src/a.ts", "alpha"),
586        ]);
587        let report = health_report_with_finding();
588
589        let steps = build_audit_next_steps(Some((&results, &root)), Some(&report));
590        let ids: Vec<&str> = steps.iter().map(|s| s.id.as_str()).collect();
591
592        assert_eq!(ids, ["trace-unused-export", "complexity-breakdown"]);
593        assert_eq!(steps[0].command, "fallow dead-code --trace src/a.ts:alpha");
594        for step in &steps {
595            assert_valid(step);
596        }
597    }
598
599    #[test]
600    fn impact_digest_line_renders_counters() {
601        let line = impact_digest_line(digest(2, 1));
602        assert_eq!(
603            line,
604            "Impact: 2 commits contained at the gate, 1 finding resolved (details: `fallow impact`)."
605        );
606    }
607
608    #[test]
609    fn suggestions_enabled_parses_off_values() {
610        for off in ["off", "0", "false", "no", "disabled", "OFF", " Off "] {
611            assert!(!suggestions_enabled_from(Some(off)), "{off} should disable");
612        }
613        for on in ["on", "1", "true", "", "yes"] {
614            assert!(suggestions_enabled_from(Some(on)), "{on} should enable");
615        }
616        assert!(suggestions_enabled_from(None), "default is enabled");
617    }
618
619    #[test]
620    fn every_emitted_command_is_runnable_and_read_only() {
621        // Exercise CLI adapters and assert the output-owned command contracts.
622        let root = PathBuf::from("/project");
623        let results = results_with_exports(vec![unused_export("/project/src/a.ts", "alpha")]);
624        let payload = dupes_payload();
625        let report = health_report_with_finding();
626        let mut all = Vec::new();
627        all.extend(build_audit_next_steps(Some((&results, &root)), None));
628        all.extend(build_dead_code_next_steps(
629            &results,
630            &root,
631            true,
632            Some(digest(2, 1)),
633        ));
634        all.extend(build_dupes_next_steps(&payload, &root, false, None));
635        all.extend(build_health_next_steps_contract(health_next_steps_input(
636            &report, &root, false, None,
637        )));
638        all.extend(build_combined_next_steps(
639            Some(&results),
640            Some(&payload),
641            Some(&report),
642            &root,
643            true,
644            Some(digest(2, 1)),
645        ));
646        assert!(!all.is_empty());
647        for step in &all {
648            assert_valid(step);
649        }
650    }
651
652    #[test]
653    fn dead_code_steps_capped_at_three() {
654        let root = PathBuf::from("/project");
655        let results = results_with_exports(vec![unused_export("/project/src/a.ts", "alpha")]);
656        // Even if git/workspaces/setup add candidates, the cap holds.
657        let steps = build_dead_code_next_steps(&results, &root, true, None);
658        assert!(steps.len() <= 3);
659    }
660}