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