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