1use fallow_types::output::NextStep;
8use fallow_types::results::AnalysisResults;
9use std::path::Path;
10
11use crate::HealthReport;
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)]
18pub struct ImpactDigestCounts {
19 pub containment_count: usize,
20 pub resolved_total: usize,
21}
22
23#[derive(Debug, Clone, Copy)]
25pub struct DeadCodeNextStepsInput<'a> {
26 pub suggestions_enabled: bool,
27 pub results: &'a AnalysisResults,
28 pub root: &'a Path,
29 pub offer_setup: bool,
30 pub impact_digest: Option<ImpactDigestCounts>,
31 pub workspace_ref: Option<&'a str>,
32 pub audit_changed: bool,
33 pub has_external_plugins: bool,
36}
37
38#[derive(Debug, Clone, Copy)]
40pub struct DupesNextStepsInput<'a> {
41 pub suggestions_enabled: bool,
42 pub clone_fingerprints: &'a [&'a str],
43 pub offer_setup: bool,
44 pub impact_digest: Option<ImpactDigestCounts>,
45 pub audit_changed: bool,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct TraceUnusedExportInput {
51 pub path: String,
52 pub export_name: String,
53}
54
55#[derive(Debug, Clone)]
57pub struct CombinedNextStepsInput<'a> {
58 pub suggestions_enabled: bool,
59 pub has_dead_code_findings: bool,
60 pub trace_unused_export: Option<TraceUnusedExportInput>,
61 pub workspace_ref: Option<&'a str>,
62 pub clone_fingerprints: &'a [&'a str],
63 pub has_complexity_findings: bool,
64 pub offer_setup: bool,
65 pub impact_digest: Option<ImpactDigestCounts>,
66 pub audit_changed: bool,
67 pub has_external_plugins: bool,
69 pub has_unused_files: bool,
71}
72
73#[derive(Debug, Clone)]
75pub struct AuditNextStepsInput {
76 pub suggestions_enabled: bool,
77 pub trace_unused_export: Option<TraceUnusedExportInput>,
78 pub has_complexity_findings: bool,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub struct HealthNextStepsInput {
84 pub suggestions_enabled: bool,
85 pub has_findings: bool,
86 pub offer_setup: bool,
87 pub impact_digest: Option<ImpactDigestCounts>,
88 pub audit_changed: bool,
89}
90
91#[must_use]
94pub fn build_health_next_steps_input(
95 report: &HealthReport,
96 suggestions_enabled: bool,
97 offer_setup: bool,
98 impact_digest: Option<ImpactDigestCounts>,
99 audit_changed: bool,
100) -> HealthNextStepsInput {
101 HealthNextStepsInput {
102 suggestions_enabled,
103 has_findings: !report.findings.is_empty(),
104 offer_setup,
105 impact_digest,
106 audit_changed,
107 }
108}
109
110#[must_use]
113pub fn impact_digest_summary(digest: ImpactDigestCounts) -> String {
114 let mut parts = Vec::new();
115 if digest.containment_count > 0 {
116 parts.push(format!(
117 "{} commit{} contained at the gate",
118 digest.containment_count,
119 if digest.containment_count == 1 {
120 ""
121 } else {
122 "s"
123 }
124 ));
125 }
126 if digest.resolved_total > 0 {
127 parts.push(format!(
128 "{} finding{} resolved",
129 digest.resolved_total,
130 if digest.resolved_total == 1 { "" } else { "s" }
131 ));
132 }
133 parts.join(", ")
134}
135
136#[must_use]
138pub fn build_health_next_steps(input: HealthNextStepsInput) -> Vec<NextStep> {
139 if !input.suggestions_enabled {
140 return Vec::new();
141 }
142 if !input.has_findings {
143 return impact_digest_step(input.impact_digest)
144 .into_iter()
145 .collect();
146 }
147
148 let mut steps: Vec<NextStep> = [
149 setup_pointer(input.offer_setup),
150 impact_digest_step(input.impact_digest),
151 complexity_breakdown(input.has_findings),
152 audit_changed(input.audit_changed),
153 ]
154 .into_iter()
155 .flatten()
156 .collect();
157 steps.truncate(MAX_NEXT_STEPS);
158 steps
159}
160
161#[must_use]
163pub fn build_dead_code_next_steps(input: DeadCodeNextStepsInput<'_>) -> Vec<NextStep> {
164 if !input.suggestions_enabled {
165 return Vec::new();
166 }
167 if input.results.total_issues() == 0 {
168 return impact_digest_step(input.impact_digest)
169 .into_iter()
170 .collect();
171 }
172
173 let mut steps: Vec<NextStep> = [
174 verify_plugins(input.has_external_plugins && !input.results.unused_files.is_empty()),
175 setup_pointer(input.offer_setup),
176 impact_digest_step(input.impact_digest),
177 trace_unused_export(input.results, input.root),
178 scope_workspaces(input.workspace_ref),
179 audit_changed(input.audit_changed),
180 ]
181 .into_iter()
182 .flatten()
183 .collect();
184 steps.truncate(MAX_NEXT_STEPS);
185 steps
186}
187
188#[must_use]
190pub fn build_dupes_next_steps(input: DupesNextStepsInput<'_>) -> Vec<NextStep> {
191 if !input.suggestions_enabled {
192 return Vec::new();
193 }
194 if input.clone_fingerprints.is_empty() {
195 return impact_digest_step(input.impact_digest)
196 .into_iter()
197 .collect();
198 }
199
200 let mut steps: Vec<NextStep> = [
201 setup_pointer(input.offer_setup),
202 impact_digest_step(input.impact_digest),
203 trace_clone(input.clone_fingerprints),
204 audit_changed(input.audit_changed),
205 ]
206 .into_iter()
207 .flatten()
208 .collect();
209 steps.truncate(MAX_NEXT_STEPS);
210 steps
211}
212
213#[must_use]
215pub fn build_combined_next_steps(input: &CombinedNextStepsInput<'_>) -> Vec<NextStep> {
216 if !input.suggestions_enabled {
217 return Vec::new();
218 }
219 let has_findings = input.has_dead_code_findings
220 || !input.clone_fingerprints.is_empty()
221 || input.has_complexity_findings;
222 if !has_findings {
223 return impact_digest_step(input.impact_digest)
224 .into_iter()
225 .collect();
226 }
227
228 let mut steps: Vec<NextStep> = [
229 verify_plugins(input.has_external_plugins && input.has_unused_files),
230 setup_pointer(input.offer_setup),
231 impact_digest_step(input.impact_digest),
232 trace_unused_export_from_input(input.trace_unused_export.as_ref()),
233 scope_workspaces(input.workspace_ref),
234 trace_clone(input.clone_fingerprints),
235 complexity_breakdown(input.has_complexity_findings),
236 audit_changed(input.audit_changed),
237 ]
238 .into_iter()
239 .flatten()
240 .collect();
241 steps.truncate(MAX_NEXT_STEPS);
242 steps
243}
244
245#[must_use]
247pub fn build_audit_next_steps(input: &AuditNextStepsInput) -> Vec<NextStep> {
248 if !input.suggestions_enabled {
249 return Vec::new();
250 }
251
252 let mut steps: Vec<NextStep> = [
253 trace_unused_export_from_input(input.trace_unused_export.as_ref()),
254 complexity_breakdown(input.has_complexity_findings),
255 ]
256 .into_iter()
257 .flatten()
258 .collect();
259 steps.truncate(MAX_NEXT_STEPS);
260 steps
261}
262
263#[must_use]
266pub fn build_audit_next_steps_input(
267 check: Option<(&AnalysisResults, &Path)>,
268 complexity: Option<&HealthReport>,
269 suggestions_enabled: bool,
270) -> AuditNextStepsInput {
271 AuditNextStepsInput {
272 suggestions_enabled,
273 trace_unused_export: check
274 .and_then(|(results, root)| trace_unused_export_input(results, root)),
275 has_complexity_findings: complexity.is_some_and(|report| !report.findings.is_empty()),
276 }
277}
278
279fn relative_command_path(path: &Path, root: &Path) -> String {
280 path.strip_prefix(root)
281 .unwrap_or(path)
282 .to_string_lossy()
283 .replace('\\', "/")
284}
285
286#[must_use]
289pub fn trace_unused_export_input(
290 results: &AnalysisResults,
291 root: &Path,
292) -> Option<TraceUnusedExportInput> {
293 let target = results
294 .unused_exports
295 .iter()
296 .map(|finding| {
297 (
298 relative_command_path(&finding.export.path, root),
299 finding.export.export_name.clone(),
300 )
301 })
302 .min()?;
303 Some(TraceUnusedExportInput {
304 path: target.0,
305 export_name: target.1,
306 })
307}
308
309fn trace_unused_export(results: &AnalysisResults, root: &Path) -> Option<NextStep> {
310 trace_unused_export_from_input(trace_unused_export_input(results, root).as_ref())
311}
312
313fn trace_unused_export_from_input(target: Option<&TraceUnusedExportInput>) -> Option<NextStep> {
314 let target = target?;
315 Some(next_step(
316 "trace-unused-export",
317 format!(
318 "fallow dead-code --trace {}:{}",
319 target.path, target.export_name
320 ),
321 "verify an export is truly unused before deleting",
322 ))
323}
324
325fn verify_plugins(applicable: bool) -> Option<NextStep> {
326 applicable.then(|| {
327 next_step(
328 "verify-plugins",
329 "fallow plugin-check --format json".to_string(),
330 "external plugins are active and files are unused; verify what they seed",
331 )
332 })
333}
334
335fn trace_clone(fingerprints: &[&str]) -> Option<NextStep> {
336 let fingerprint = fingerprints.iter().copied().min()?;
337 Some(next_step(
338 "trace-clone",
339 format!("fallow dupes --trace {fingerprint}"),
340 "see sibling locations and an extract-function suggestion",
341 ))
342}
343
344fn next_step(id: &str, command: String, reason: &str) -> NextStep {
345 debug_assert!(
346 !command.contains('<') && !command.contains('>'),
347 "next-step command must be runnable (no placeholder): {command}"
348 );
349 debug_assert!(
350 !command
351 .split_whitespace()
352 .any(|token| MUTATING_VERBS.contains(&token)),
353 "next-step command must be read-only (no mutating verb): {command}"
354 );
355 NextStep {
356 id: id.to_string(),
357 command,
358 reason: reason.to_string(),
359 }
360}
361
362fn setup_pointer(offer_setup: bool) -> Option<NextStep> {
363 if !offer_setup {
364 return None;
365 }
366 Some(next_step(
367 "setup",
368 "fallow schema".to_string(),
369 "fallow has no config here; the manifest lists guided-setup commands (agent guide, commit gate) to offer the user",
370 ))
371}
372
373fn impact_digest_step(digest: Option<ImpactDigestCounts>) -> Option<NextStep> {
374 let digest = digest?;
375 Some(next_step(
376 "impact-report",
377 "fallow impact".to_string(),
378 &format!(
379 "local value report: {}; share the non-zero numbers with the user",
380 impact_digest_summary(digest)
381 ),
382 ))
383}
384
385fn complexity_breakdown(has_findings: bool) -> Option<NextStep> {
386 if !has_findings {
387 return None;
388 }
389 Some(next_step(
390 "complexity-breakdown",
391 "fallow health --complexity-breakdown".to_string(),
392 "see per-decision-point contributions for a hotspot",
393 ))
394}
395
396fn audit_changed(applicable: bool) -> Option<NextStep> {
397 if !applicable {
398 return None;
399 }
400 Some(next_step(
401 "audit-changed",
402 "fallow audit".to_string(),
403 "gate only the files your branch changed (auto-detects the base)",
404 ))
405}
406
407fn scope_workspaces(workspace_ref: Option<&str>) -> Option<NextStep> {
408 let reference = workspace_ref?;
409 Some(next_step(
410 "scope-workspaces",
411 format!("fallow dead-code --changed-workspaces {reference}"),
412 "scope a monorepo run to the packages your branch touched",
413 ))
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419 use crate::{ComplexityViolation, ExceededThreshold, FindingSeverity, HealthFinding};
420 use fallow_types::output_dead_code::UnusedExportFinding;
421 use fallow_types::results::UnusedExport;
422
423 fn digest(containment_count: usize, resolved_total: usize) -> ImpactDigestCounts {
424 ImpactDigestCounts {
425 containment_count,
426 resolved_total,
427 }
428 }
429
430 fn dirty_input() -> HealthNextStepsInput {
431 HealthNextStepsInput {
432 suggestions_enabled: true,
433 has_findings: true,
434 offer_setup: false,
435 impact_digest: None,
436 audit_changed: false,
437 }
438 }
439
440 fn dirty_report() -> HealthReport {
441 HealthReport {
442 findings: vec![HealthFinding::from(ComplexityViolation {
443 path: "/project/src/hot.ts".into(),
444 name: "hot".to_string(),
445 line: 1,
446 col: 0,
447 cyclomatic: 21,
448 cognitive: 16,
449 line_count: 42,
450 param_count: 0,
451 react_hook_count: 0,
452 react_jsx_max_depth: 0,
453 react_prop_count: 0,
454 react_hook_profile: None,
455 exceeded: ExceededThreshold::Both,
456 severity: FindingSeverity::High,
457 crap: None,
458 coverage_pct: None,
459 coverage_tier: None,
460 coverage_source: None,
461 inherited_from: None,
462 component_rollup: None,
463 contributions: Vec::new(),
464 effective_thresholds: None,
465 threshold_source: None,
466 })],
467 ..HealthReport::default()
468 }
469 }
470
471 fn unused_export(path: &str, name: &str) -> UnusedExportFinding {
472 UnusedExportFinding::with_actions(UnusedExport {
473 path: path.into(),
474 export_name: name.to_string(),
475 is_type_only: false,
476 line: 1,
477 col: 0,
478 span_start: 0,
479 is_re_export: false,
480 })
481 }
482
483 fn dead_code_input(results: &AnalysisResults) -> DeadCodeNextStepsInput<'_> {
484 DeadCodeNextStepsInput {
485 suggestions_enabled: true,
486 results,
487 root: Path::new("/project"),
488 offer_setup: false,
489 impact_digest: None,
490 workspace_ref: None,
491 audit_changed: false,
492 has_external_plugins: false,
493 }
494 }
495
496 fn dupes_input<'a>(clone_fingerprints: &'a [&'a str]) -> DupesNextStepsInput<'a> {
497 DupesNextStepsInput {
498 suggestions_enabled: true,
499 clone_fingerprints,
500 offer_setup: false,
501 impact_digest: None,
502 audit_changed: false,
503 }
504 }
505
506 fn combined_input<'a>(clone_fingerprints: &'a [&'a str]) -> CombinedNextStepsInput<'a> {
507 CombinedNextStepsInput {
508 suggestions_enabled: true,
509 has_dead_code_findings: false,
510 trace_unused_export: None,
511 workspace_ref: None,
512 clone_fingerprints,
513 has_complexity_findings: false,
514 offer_setup: false,
515 impact_digest: None,
516 audit_changed: false,
517 has_external_plugins: false,
518 has_unused_files: false,
519 }
520 }
521
522 fn audit_input() -> AuditNextStepsInput {
523 AuditNextStepsInput {
524 suggestions_enabled: true,
525 trace_unused_export: None,
526 has_complexity_findings: false,
527 }
528 }
529
530 fn assert_valid(step: &NextStep) {
531 assert!(
532 !step.command.contains('<') && !step.command.contains('>'),
533 "command must be placeholder-free: {}",
534 step.command
535 );
536 assert!(
537 !step
538 .command
539 .split_whitespace()
540 .any(|token| MUTATING_VERBS.contains(&token)),
541 "command must be read-only: {}",
542 step.command
543 );
544 }
545
546 #[test]
547 fn audit_steps_are_empty_when_suggestions_are_disabled() {
548 let steps = build_audit_next_steps(&AuditNextStepsInput {
549 suggestions_enabled: false,
550 trace_unused_export: Some(TraceUnusedExportInput {
551 path: "src/a.ts".to_string(),
552 export_name: "alpha".to_string(),
553 }),
554 has_complexity_findings: true,
555 });
556
557 assert!(steps.is_empty());
558 }
559
560 #[test]
561 fn audit_input_builder_derives_trace_and_complexity_facts() {
562 let results = AnalysisResults {
563 unused_exports: vec![
564 unused_export("/project/src/b.ts", "beta"),
565 unused_export("/project/src/a.ts", "alpha"),
566 ],
567 ..AnalysisResults::default()
568 };
569 let report = dirty_report();
570
571 let input = build_audit_next_steps_input(
572 Some((&results, Path::new("/project"))),
573 Some(&report),
574 true,
575 );
576
577 assert_eq!(
578 input.trace_unused_export,
579 Some(TraceUnusedExportInput {
580 path: "src/a.ts".to_string(),
581 export_name: "alpha".to_string(),
582 })
583 );
584 assert!(input.has_complexity_findings);
585 assert!(input.suggestions_enabled);
586 }
587
588 #[test]
589 fn audit_steps_order_trace_before_complexity() {
590 let steps = build_audit_next_steps(&AuditNextStepsInput {
591 trace_unused_export: Some(TraceUnusedExportInput {
592 path: "src/a.ts".to_string(),
593 export_name: "alpha".to_string(),
594 }),
595 has_complexity_findings: true,
596 ..audit_input()
597 });
598 let ids = steps
599 .iter()
600 .map(|step| step.id.as_str())
601 .collect::<Vec<_>>();
602
603 assert_eq!(ids, ["trace-unused-export", "complexity-breakdown"]);
604 assert_eq!(steps[0].command, "fallow dead-code --trace src/a.ts:alpha");
605 for step in &steps {
606 assert_valid(step);
607 }
608 }
609
610 #[test]
611 fn audit_steps_emit_complexity_without_trace_target() {
612 let steps = build_audit_next_steps(&AuditNextStepsInput {
613 has_complexity_findings: true,
614 ..audit_input()
615 });
616
617 assert_eq!(steps.len(), 1);
618 assert_eq!(steps[0].id, "complexity-breakdown");
619 }
620
621 #[test]
622 fn health_steps_are_empty_when_suggestions_are_disabled() {
623 let steps = build_health_next_steps(HealthNextStepsInput {
624 suggestions_enabled: false,
625 has_findings: true,
626 offer_setup: true,
627 impact_digest: Some(digest(2, 1)),
628 audit_changed: true,
629 });
630
631 assert!(steps.is_empty());
632 }
633
634 #[test]
635 fn health_input_builder_derives_findings_from_report() {
636 let clean = build_health_next_steps_input(
637 &HealthReport::default(),
638 true,
639 true,
640 Some(digest(2, 1)),
641 true,
642 );
643 assert_eq!(
644 clean,
645 HealthNextStepsInput {
646 suggestions_enabled: true,
647 has_findings: false,
648 offer_setup: true,
649 impact_digest: Some(digest(2, 1)),
650 audit_changed: true,
651 }
652 );
653
654 let dirty = build_health_next_steps_input(&dirty_report(), true, false, None, false);
655 assert!(dirty.has_findings);
656 }
657
658 #[test]
659 fn dead_code_steps_trace_smallest_unused_export() {
660 let results = AnalysisResults {
661 unused_exports: vec![
662 unused_export("/project/src/b.ts", "beta"),
663 unused_export("/project/src/a.ts", "alpha"),
664 ],
665 ..AnalysisResults::default()
666 };
667
668 let steps = build_dead_code_next_steps(dead_code_input(&results));
669
670 assert_eq!(steps[0].id, "trace-unused-export");
671 assert_eq!(steps[0].command, "fallow dead-code --trace src/a.ts:alpha");
672 assert_valid(&steps[0]);
673 }
674
675 #[test]
676 fn dead_code_steps_order_setup_impact_trace_workspace_then_audit() {
677 let results = AnalysisResults {
678 unused_exports: vec![unused_export("/project/src/a.ts", "alpha")],
679 ..AnalysisResults::default()
680 };
681 let steps = build_dead_code_next_steps(DeadCodeNextStepsInput {
682 offer_setup: true,
683 impact_digest: Some(digest(2, 1)),
684 workspace_ref: Some("origin/main"),
685 audit_changed: true,
686 ..dead_code_input(&results)
687 });
688 let ids = steps
689 .iter()
690 .map(|step| step.id.as_str())
691 .collect::<Vec<_>>();
692
693 assert_eq!(ids, ["setup", "impact-report", "trace-unused-export"]);
694 for step in &steps {
695 assert_valid(step);
696 }
697 }
698
699 #[test]
700 fn clean_dead_code_run_emits_only_due_impact_digest() {
701 let results = AnalysisResults::default();
702 let steps = build_dead_code_next_steps(DeadCodeNextStepsInput {
703 impact_digest: Some(digest(2, 1)),
704 audit_changed: true,
705 ..dead_code_input(&results)
706 });
707
708 assert_eq!(steps.len(), 1);
709 assert_eq!(steps[0].id, "impact-report");
710 }
711
712 #[test]
713 fn verify_plugins_step_fires_when_external_plugins_and_unused_files() {
714 let results = AnalysisResults {
715 unused_files: vec![
716 fallow_types::output_dead_code::UnusedFileFinding::with_actions(
717 fallow_types::results::UnusedFile {
718 path: "/project/src/orphan.ts".into(),
719 },
720 ),
721 ],
722 ..AnalysisResults::default()
723 };
724 let steps = build_dead_code_next_steps(DeadCodeNextStepsInput {
725 has_external_plugins: true,
726 ..dead_code_input(&results)
727 });
728 let first = &steps[0];
729 assert_eq!(first.id, "verify-plugins");
730 assert_eq!(first.command, "fallow plugin-check --format json");
731 assert_valid(first);
732
733 let without = build_dead_code_next_steps(DeadCodeNextStepsInput {
735 has_external_plugins: false,
736 ..dead_code_input(&results)
737 });
738 assert!(without.iter().all(|step| step.id != "verify-plugins"));
739
740 let no_unused = AnalysisResults {
742 unused_exports: vec![unused_export("/project/src/a.ts", "alpha")],
743 ..AnalysisResults::default()
744 };
745 let steps = build_dead_code_next_steps(DeadCodeNextStepsInput {
746 has_external_plugins: true,
747 ..dead_code_input(&no_unused)
748 });
749 assert!(steps.iter().all(|step| step.id != "verify-plugins"));
750 }
751
752 #[test]
753 fn dupes_steps_trace_smallest_clone_fingerprint() {
754 let fingerprints = ["dup:bbbbbbbb", "dup:aaaaaaaa"];
755
756 let steps = build_dupes_next_steps(dupes_input(&fingerprints));
757
758 assert_eq!(steps[0].id, "trace-clone");
759 assert_eq!(steps[0].command, "fallow dupes --trace dup:aaaaaaaa");
760 assert_valid(&steps[0]);
761 }
762
763 #[test]
764 fn dupes_steps_order_setup_impact_trace_then_audit() {
765 let fingerprints = ["dup:aaaaaaaa"];
766 let steps = build_dupes_next_steps(DupesNextStepsInput {
767 offer_setup: true,
768 impact_digest: Some(digest(2, 1)),
769 audit_changed: true,
770 ..dupes_input(&fingerprints)
771 });
772 let ids = steps
773 .iter()
774 .map(|step| step.id.as_str())
775 .collect::<Vec<_>>();
776
777 assert_eq!(ids, ["setup", "impact-report", "trace-clone"]);
778 for step in &steps {
779 assert_valid(step);
780 }
781 }
782
783 #[test]
784 fn clean_dupes_run_emits_only_due_impact_digest() {
785 let steps = build_dupes_next_steps(DupesNextStepsInput {
786 impact_digest: Some(digest(2, 1)),
787 audit_changed: true,
788 ..dupes_input(&[])
789 });
790
791 assert_eq!(steps.len(), 1);
792 assert_eq!(steps[0].id, "impact-report");
793 }
794
795 #[test]
796 fn combined_steps_are_empty_when_suggestions_are_disabled() {
797 let fingerprints = ["dup:aaaaaaaa"];
798 let steps = build_combined_next_steps(&CombinedNextStepsInput {
799 suggestions_enabled: false,
800 has_dead_code_findings: true,
801 trace_unused_export: Some(TraceUnusedExportInput {
802 path: "src/a.ts".to_string(),
803 export_name: "alpha".to_string(),
804 }),
805 workspace_ref: Some("origin/main"),
806 clone_fingerprints: &fingerprints,
807 has_complexity_findings: true,
808 offer_setup: true,
809 impact_digest: Some(digest(2, 1)),
810 audit_changed: true,
811 has_external_plugins: false,
812 has_unused_files: false,
813 });
814
815 assert!(steps.is_empty());
816 }
817
818 #[test]
819 fn clean_combined_run_emits_only_due_impact_digest() {
820 let steps = build_combined_next_steps(&CombinedNextStepsInput {
821 impact_digest: Some(digest(2, 1)),
822 audit_changed: true,
823 ..combined_input(&[])
824 });
825
826 assert_eq!(steps.len(), 1);
827 assert_eq!(steps[0].id, "impact-report");
828 }
829
830 #[test]
831 fn combined_steps_order_and_cap_all_signals() {
832 let fingerprints = ["dup:bbbbbbbb", "dup:aaaaaaaa"];
833 let steps = build_combined_next_steps(&CombinedNextStepsInput {
834 has_dead_code_findings: true,
835 trace_unused_export: Some(TraceUnusedExportInput {
836 path: "src/a.ts".to_string(),
837 export_name: "alpha".to_string(),
838 }),
839 workspace_ref: Some("origin/main"),
840 has_complexity_findings: true,
841 offer_setup: true,
842 impact_digest: Some(digest(2, 1)),
843 audit_changed: true,
844 ..combined_input(&fingerprints)
845 });
846 let ids = steps
847 .iter()
848 .map(|step| step.id.as_str())
849 .collect::<Vec<_>>();
850
851 assert_eq!(ids, ["setup", "impact-report", "trace-unused-export"]);
852 for step in &steps {
853 assert_valid(step);
854 }
855 }
856
857 #[test]
858 fn combined_steps_keep_workspace_before_clone_and_complexity() {
859 let fingerprints = ["dup:aaaaaaaa"];
860 let steps = build_combined_next_steps(&CombinedNextStepsInput {
861 has_dead_code_findings: true,
862 workspace_ref: Some("origin/main"),
863 has_complexity_findings: true,
864 audit_changed: true,
865 ..combined_input(&fingerprints)
866 });
867 let ids = steps
868 .iter()
869 .map(|step| step.id.as_str())
870 .collect::<Vec<_>>();
871
872 assert_eq!(
873 ids,
874 ["scope-workspaces", "trace-clone", "complexity-breakdown"]
875 );
876 }
877
878 #[test]
879 fn clean_health_run_emits_only_due_impact_digest() {
880 let steps = build_health_next_steps(HealthNextStepsInput {
881 suggestions_enabled: true,
882 has_findings: false,
883 offer_setup: true,
884 impact_digest: Some(digest(2, 1)),
885 audit_changed: true,
886 });
887
888 assert_eq!(steps.len(), 1);
889 assert_eq!(steps[0].id, "impact-report");
890 assert_valid(&steps[0]);
891 }
892
893 #[test]
894 fn dirty_health_run_orders_setup_impact_complexity_then_audit() {
895 let steps = build_health_next_steps(HealthNextStepsInput {
896 offer_setup: true,
897 impact_digest: Some(digest(2, 1)),
898 audit_changed: true,
899 ..dirty_input()
900 });
901 let ids = steps
902 .iter()
903 .map(|step| step.id.as_str())
904 .collect::<Vec<_>>();
905
906 assert_eq!(ids, ["setup", "impact-report", "complexity-breakdown"]);
907 for step in &steps {
908 assert_valid(step);
909 }
910 }
911
912 #[test]
913 fn dirty_health_run_uses_complexity_when_setup_and_impact_are_absent() {
914 let steps = build_health_next_steps(HealthNextStepsInput {
915 audit_changed: true,
916 ..dirty_input()
917 });
918 let ids = steps
919 .iter()
920 .map(|step| step.id.as_str())
921 .collect::<Vec<_>>();
922
923 assert_eq!(ids, ["complexity-breakdown", "audit-changed"]);
924 }
925
926 #[test]
927 fn impact_digest_summary_pluralizes_real_counters() {
928 assert_eq!(
929 impact_digest_summary(digest(1, 1)),
930 "1 commit contained at the gate, 1 finding resolved"
931 );
932 assert_eq!(
933 impact_digest_summary(digest(2, 3)),
934 "2 commits contained at the gate, 3 findings resolved"
935 );
936 }
937}