1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct BaselineRecheckInput<'a> {
29 pub command: &'a str,
31 pub path: &'a str,
36 pub baseline_entries: usize,
39 pub scope_reasons: BaselineScopeReasons,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub struct ImpactDigestCounts {
48 pub containment_count: usize,
50 pub resolved_total: usize,
52}
53
54#[derive(Debug, Clone, Copy)]
56pub struct DeadCodeNextStepsInput<'a> {
57 pub suggestions_enabled: bool,
59 pub results: &'a AnalysisResults,
61 pub root: &'a Path,
63 pub offer_setup: bool,
65 pub impact_digest: Option<ImpactDigestCounts>,
67 pub workspace_ref: Option<&'a str>,
69 pub audit_changed: bool,
71 pub has_external_plugins: bool,
74 pub baseline_recheck: Option<BaselineRecheckInput<'a>>,
76 pub command: &'a str,
79}
80
81#[derive(Debug, Clone, Copy)]
83pub struct DupesNextStepsInput<'a> {
84 pub suggestions_enabled: bool,
86 pub clone_fingerprints: &'a [&'a str],
88 pub offer_setup: bool,
90 pub impact_digest: Option<ImpactDigestCounts>,
92 pub audit_changed: bool,
94 pub baseline_recheck: Option<BaselineRecheckInput<'a>>,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct TraceUnusedExportInput {
101 pub path: String,
103 pub export_name: String,
105}
106
107#[derive(Debug, Clone)]
109pub struct CombinedNextStepsInput<'a> {
110 pub suggestions_enabled: bool,
112 pub has_dead_code_findings: bool,
114 pub trace_unused_export: Option<TraceUnusedExportInput>,
116 pub workspace_ref: Option<&'a str>,
118 pub clone_fingerprints: &'a [&'a str],
120 pub has_complexity_findings: bool,
122 pub offer_setup: bool,
124 pub impact_digest: Option<ImpactDigestCounts>,
126 pub audit_changed: bool,
128 pub has_external_plugins: bool,
130 pub has_unused_files: bool,
132 pub baseline_recheck: Option<BaselineRecheckInput<'a>>,
136}
137
138#[derive(Debug, Clone)]
140pub struct AuditNextStepsInput {
141 pub suggestions_enabled: bool,
143 pub trace_unused_export: Option<TraceUnusedExportInput>,
145 pub has_complexity_findings: bool,
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub struct HealthNextStepsInput<'a> {
152 pub suggestions_enabled: bool,
154 pub has_findings: bool,
156 pub offer_setup: bool,
158 pub impact_digest: Option<ImpactDigestCounts>,
160 pub audit_changed: bool,
162 pub baseline_recheck: Option<BaselineRecheckInput<'a>>,
164}
165
166#[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#[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#[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#[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#[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#[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#[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#[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#[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
399fn 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
474fn 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 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 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 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 #[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 #[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}