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