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