1use crate::report::sink::outln;
2use std::path::Path;
3
4use fallow_core::duplicates::DuplicationReport;
5use fallow_core::results::{AnalysisResults, UnusedExport, UnusedMember};
6
7use super::grouping::ResultGroup;
8use super::{normalize_uri, relative_path};
9
10pub(super) fn print_compact(results: &AnalysisResults, root: &Path) {
11 for line in build_compact_lines(results, root) {
12 outln!("{line}");
13 }
14}
15
16fn compact_path(path: &Path, root: &Path) -> String {
17 normalize_uri(&relative_path(path, root).display().to_string())
18}
19
20fn compact_circular_dependency_line(
21 cycle: &fallow_core::results::CircularDependencyFinding,
22 root: &Path,
23) -> String {
24 let chain: Vec<String> = cycle
25 .cycle
26 .files
27 .iter()
28 .map(|path| compact_path(path, root))
29 .collect();
30 let mut display_chain = chain.clone();
31 if let Some(first) = chain.first() {
32 display_chain.push(first.clone());
33 }
34 let first_file = chain.first().map_or_else(String::new, Clone::clone);
35 let cross_pkg_tag = if cycle.cycle.is_cross_package {
36 " (cross-package)"
37 } else {
38 ""
39 };
40 format!(
41 "circular-dependency:{}:{}:{}{}",
42 first_file,
43 cycle.cycle.line,
44 display_chain.join(" \u{2192} "),
45 cross_pkg_tag
46 )
47}
48
49fn compact_re_export_cycle_line(
50 cycle: &fallow_core::results::ReExportCycleFinding,
51 root: &Path,
52) -> String {
53 let chain: Vec<String> = cycle
54 .cycle
55 .files
56 .iter()
57 .map(|path| compact_path(path, root))
58 .collect();
59 let first_file = chain.first().map_or_else(String::new, Clone::clone);
60 let kind_tag = match cycle.cycle.kind {
61 fallow_core::results::ReExportCycleKind::SelfLoop => " (self-loop)",
62 fallow_core::results::ReExportCycleKind::MultiNode => "",
63 };
64 format!(
65 "re-export-cycle:{}:{}{}",
66 first_file,
67 chain.join(" <-> "),
68 kind_tag
69 )
70}
71
72fn compact_boundary_violation_line(
73 item: &fallow_core::results::BoundaryViolationFinding,
74 root: &Path,
75) -> String {
76 format!(
77 "boundary-violation:{}:{}:{} -> {} ({} -> {})",
78 compact_path(&item.violation.from_path, root),
79 item.violation.line,
80 compact_path(&item.violation.from_path, root),
81 compact_path(&item.violation.to_path, root),
82 item.violation.from_zone,
83 item.violation.to_zone,
84 )
85}
86
87fn compact_boundary_coverage_line(
88 item: &fallow_core::results::BoundaryCoverageViolationFinding,
89 root: &Path,
90) -> String {
91 format!(
92 "boundary-coverage:{}:{}:no matching boundary zone",
93 compact_path(&item.violation.path, root),
94 item.violation.line,
95 )
96}
97
98fn compact_boundary_call_line(
99 item: &fallow_core::results::BoundaryCallViolationFinding,
100 root: &Path,
101) -> String {
102 format!(
103 "boundary-call:{}:{}:{} forbidden in zone {} (pattern {})",
104 compact_path(&item.violation.path, root),
105 item.violation.line,
106 item.violation.callee,
107 item.violation.zone,
108 item.violation.pattern,
109 )
110}
111
112fn compact_stale_suppression_line(
113 item: &fallow_core::results::StaleSuppression,
114 root: &Path,
115) -> String {
116 format!(
117 "stale-suppression:{}:{}:{}",
118 compact_path(&item.path, root),
119 item.line,
120 item.display_message(),
121 )
122}
123
124fn compact_catalog_reference_line(
125 item: &fallow_core::results::UnresolvedCatalogReferenceFinding,
126 root: &Path,
127) -> String {
128 format!(
129 "unresolved-catalog-reference:{}:{}:{}:{}",
130 compact_path(&item.reference.path, root),
131 item.reference.line,
132 item.reference.catalog_name,
133 item.reference.entry_name,
134 )
135}
136
137fn compact_unused_override_line(
138 item: &fallow_core::results::UnusedDependencyOverrideFinding,
139 root: &Path,
140) -> String {
141 format!(
142 "unused-dependency-override:{}:{}:{}:{}",
143 compact_path(&item.entry.path, root),
144 item.entry.line,
145 item.entry.source.as_label(),
146 item.entry.raw_key,
147 )
148}
149
150fn compact_misconfigured_override_line(
151 item: &fallow_core::results::MisconfiguredDependencyOverrideFinding,
152 root: &Path,
153) -> String {
154 format!(
155 "misconfigured-dependency-override:{}:{}:{}:{}",
156 compact_path(&item.entry.path, root),
157 item.entry.line,
158 item.entry.source.as_label(),
159 item.entry.raw_key,
160 )
161}
162
163pub fn build_compact_lines(results: &AnalysisResults, root: &Path) -> Vec<String> {
166 CompactLineBuilder::new(results, root).build()
167}
168
169struct CompactLineBuilder<'a> {
170 lines: Vec<String>,
171 results: &'a AnalysisResults,
172 root: &'a Path,
173}
174
175impl<'a> CompactLineBuilder<'a> {
176 fn new(results: &'a AnalysisResults, root: &'a Path) -> Self {
177 Self {
178 lines: Vec::new(),
179 results,
180 root,
181 }
182 }
183
184 fn build(mut self) -> Vec<String> {
185 self.push_core_lines();
186 self.push_unused_dependency_lines();
187 self.push_member_lines();
188 self.push_secondary_dependency_lines();
189 self.push_graph_lines();
190 self.push_workspace_lines();
191 self.lines
192 }
193
194 fn rel(&self, path: &Path) -> String {
195 compact_path(path, self.root)
196 }
197
198 fn unused_export_line(&self, export: &UnusedExport) -> String {
199 let tag = if export.is_re_export {
200 "unused-re-export"
201 } else {
202 "unused-export"
203 };
204 format!(
205 "{}:{}:{}:{}",
206 tag,
207 self.rel(&export.path),
208 export.line,
209 export.export_name
210 )
211 }
212
213 fn unused_type_line(&self, export: &UnusedExport) -> String {
214 let tag = if export.is_re_export {
215 "unused-re-export-type"
216 } else {
217 "unused-type"
218 };
219 format!(
220 "{}:{}:{}:{}",
221 tag,
222 self.rel(&export.path),
223 export.line,
224 export.export_name
225 )
226 }
227
228 fn compact_member(&self, member: &UnusedMember, kind: &str) -> String {
229 format!(
230 "{}:{}:{}:{}.{}",
231 kind,
232 self.rel(&member.path),
233 member.line,
234 member.parent_name,
235 member.member_name
236 )
237 }
238
239 fn push_core_lines(&mut self) {
240 for file in &self.results.unused_files {
241 self.lines
242 .push(format!("unused-file:{}", self.rel(&file.file.path)));
243 }
244 for export in &self.results.unused_exports {
245 self.lines.push(self.unused_export_line(&export.export));
246 }
247 for export in &self.results.unused_types {
248 self.lines.push(self.unused_type_line(&export.export));
249 }
250 for leak in &self.results.private_type_leaks {
251 self.lines.push(format!(
252 "private-type-leak:{}:{}:{}->{}",
253 self.rel(&leak.leak.path),
254 leak.leak.line,
255 leak.leak.export_name,
256 leak.leak.type_name
257 ));
258 }
259 }
260
261 fn push_unused_dependency_lines(&mut self) {
262 for dep in &self.results.unused_dependencies {
263 self.lines
264 .push(format!("unused-dep:{}", dep.dep.package_name));
265 }
266 for dep in &self.results.unused_dev_dependencies {
267 self.lines
268 .push(format!("unused-devdep:{}", dep.dep.package_name));
269 }
270 for dep in &self.results.unused_optional_dependencies {
271 self.lines
272 .push(format!("unused-optionaldep:{}", dep.dep.package_name));
273 }
274 }
275
276 fn push_member_lines(&mut self) {
277 for member in &self.results.unused_enum_members {
278 self.lines
279 .push(self.compact_member(&member.member, "unused-enum-member"));
280 }
281 for member in &self.results.unused_class_members {
282 self.lines
283 .push(self.compact_member(&member.member, "unused-class-member"));
284 }
285 for import in &self.results.unresolved_imports {
286 self.lines.push(format!(
287 "unresolved-import:{}:{}:{}",
288 self.rel(&import.import.path),
289 import.import.line,
290 import.import.specifier
291 ));
292 }
293 }
294
295 fn push_secondary_dependency_lines(&mut self) {
296 for dep in &self.results.unlisted_dependencies {
297 self.lines
298 .push(format!("unlisted-dep:{}", dep.dep.package_name));
299 }
300 for dup in &self.results.duplicate_exports {
301 self.lines
302 .push(format!("duplicate-export:{}", dup.export.export_name));
303 }
304 for dep in &self.results.type_only_dependencies {
305 self.lines
306 .push(format!("type-only-dep:{}", dep.dep.package_name));
307 }
308 for dep in &self.results.test_only_dependencies {
309 self.lines
310 .push(format!("test-only-dep:{}", dep.dep.package_name));
311 }
312 }
313
314 fn push_graph_lines(&mut self) {
315 for cycle in &self.results.circular_dependencies {
316 self.lines
317 .push(compact_circular_dependency_line(cycle, self.root));
318 }
319 for cycle in &self.results.re_export_cycles {
320 self.lines
321 .push(compact_re_export_cycle_line(cycle, self.root));
322 }
323 for violation in &self.results.boundary_violations {
324 self.lines
325 .push(compact_boundary_violation_line(violation, self.root));
326 }
327 for violation in &self.results.boundary_coverage_violations {
328 self.lines
329 .push(compact_boundary_coverage_line(violation, self.root));
330 }
331 for violation in &self.results.boundary_call_violations {
332 self.lines
333 .push(compact_boundary_call_line(violation, self.root));
334 }
335 for violation in &self.results.policy_violations {
336 self.lines.push(format!(
337 "policy-violation:{}:{}:{} banned by {}/{}",
338 self.rel(&violation.violation.path),
339 violation.violation.line,
340 violation.violation.matched,
341 violation.violation.pack,
342 violation.violation.rule_id,
343 ));
344 }
345 for suppression in &self.results.stale_suppressions {
346 self.lines
347 .push(compact_stale_suppression_line(suppression, self.root));
348 }
349 }
350
351 fn push_workspace_lines(&mut self) {
352 for entry in &self.results.unused_catalog_entries {
353 self.lines.push(format!(
354 "unused-catalog-entry:{}:{}:{}:{}",
355 self.rel(&entry.entry.path),
356 entry.entry.line,
357 entry.entry.catalog_name,
358 entry.entry.entry_name,
359 ));
360 }
361 for group in &self.results.empty_catalog_groups {
362 self.lines.push(format!(
363 "empty-catalog-group:{}:{}:{}",
364 self.rel(&group.group.path),
365 group.group.line,
366 group.group.catalog_name,
367 ));
368 }
369 for finding in &self.results.unresolved_catalog_references {
370 self.lines
371 .push(compact_catalog_reference_line(finding, self.root));
372 }
373 for finding in &self.results.unused_dependency_overrides {
374 self.lines
375 .push(compact_unused_override_line(finding, self.root));
376 }
377 for finding in &self.results.misconfigured_dependency_overrides {
378 self.lines
379 .push(compact_misconfigured_override_line(finding, self.root));
380 }
381 }
382}
383
384pub(super) fn print_grouped_compact(groups: &[ResultGroup], root: &Path) {
388 for group in groups {
389 for line in build_compact_lines(&group.results, root) {
390 outln!("{}\t{line}", group.key);
391 }
392 }
393}
394
395pub(super) fn print_health_compact(report: &crate::health_types::HealthReport, root: &Path) {
396 print_health_score_compact(report);
397 print_vital_signs_compact(report);
398 print_health_findings_compact(&report.findings, root);
399 print_threshold_overrides_compact(&report.threshold_overrides, root);
400 print_file_scores_compact(&report.file_scores, root);
401 print_coverage_gaps_compact(report, root);
402 print_runtime_sections_compact(report, root);
403 print_hotspots_compact(&report.hotspots, root);
404 print_health_trend_compact(report);
405 print_refactoring_targets_compact(&report.targets, root);
406}
407
408fn print_threshold_overrides_compact(
409 entries: &[crate::health_types::ThresholdOverrideState],
410 root: &Path,
411) {
412 for entry in entries {
413 let status = match entry.status {
414 crate::health_types::ThresholdOverrideStatus::Active => "active",
415 crate::health_types::ThresholdOverrideStatus::Stale => "stale",
416 crate::health_types::ThresholdOverrideStatus::NoMatch => "no_match",
417 };
418 let target = entry.path.as_ref().map_or_else(
419 || "no-match".to_string(),
420 |path| {
421 let display = health_compact_path(path, root);
422 entry
423 .function
424 .as_ref()
425 .map_or_else(|| display.clone(), |name| format!("{display}:{name}"))
426 },
427 );
428 let metrics = entry.metrics.map_or(String::new(), |metrics| {
429 let crap = metrics
430 .crap
431 .map_or(String::new(), |value| format!(",crap={value:.1}"));
432 format!(
433 ",cyclomatic={},cognitive={}{}",
434 metrics.cyclomatic, metrics.cognitive, crap
435 )
436 });
437 outln!(
438 "threshold-override:{}:{}:{}{}",
439 entry.override_index,
440 status,
441 target,
442 metrics
443 );
444 }
445}
446
447fn print_health_score_compact(report: &crate::health_types::HealthReport) {
448 if let Some(ref hs) = report.health_score {
449 outln!("health-score:{:.1}:{}", hs.score, hs.grade);
450 }
451}
452
453fn print_vital_signs_compact(report: &crate::health_types::HealthReport) {
454 if let Some(ref vs) = report.vital_signs {
455 let mut parts = Vec::new();
456 if vs.total_loc > 0 {
457 parts.push(format!("total_loc={}", vs.total_loc));
458 }
459 parts.push(format!("avg_cyclomatic={:.1}", vs.avg_cyclomatic));
460 parts.push(format!("p90_cyclomatic={}", vs.p90_cyclomatic));
461 if let Some(v) = vs.dead_file_pct {
462 parts.push(format!("dead_file_pct={v:.1}"));
463 }
464 if let Some(v) = vs.dead_export_pct {
465 parts.push(format!("dead_export_pct={v:.1}"));
466 }
467 if let Some(v) = vs.maintainability_avg {
468 parts.push(format!("maintainability_avg={v:.1}"));
469 }
470 if let Some(v) = vs.hotspot_count {
471 parts.push(format!("hotspot_count={v}"));
472 }
473 if let Some(v) = vs.circular_dep_count {
474 parts.push(format!("circular_dep_count={v}"));
475 }
476 if let Some(v) = vs.unused_dep_count {
477 parts.push(format!("unused_dep_count={v}"));
478 }
479 outln!("vital-signs:{}", parts.join(","));
480 }
481}
482
483fn health_compact_path(path: &Path, root: &Path) -> String {
484 normalize_uri(&relative_path(path, root).display().to_string())
485}
486
487fn print_health_findings_compact(findings: &[crate::health_types::HealthFinding], root: &Path) {
488 for finding in findings {
489 let relative = health_compact_path(&finding.path, root);
490 let severity = match finding.severity {
491 crate::health_types::FindingSeverity::Critical => "critical",
492 crate::health_types::FindingSeverity::High => "high",
493 crate::health_types::FindingSeverity::Moderate => "moderate",
494 };
495 let crap_suffix = match finding.crap {
496 Some(crap) => {
497 let coverage = finding
498 .coverage_pct
499 .map(|pct| format!(",coverage_pct={pct:.1}"))
500 .unwrap_or_default();
501 format!(",crap={crap:.1}{coverage}")
502 }
503 None => String::new(),
504 };
505 outln!(
506 "high-complexity:{}:{}:{}:cyclomatic={},cognitive={},severity={}{}",
507 relative,
508 finding.line,
509 finding.name,
510 finding.cyclomatic,
511 finding.cognitive,
512 severity,
513 crap_suffix,
514 );
515 }
516}
517
518fn print_file_scores_compact(scores: &[crate::health_types::FileHealthScore], root: &Path) {
519 for score in scores {
520 let relative = health_compact_path(&score.path, root);
521 outln!(
522 "file-score:{}:mi={:.1},fan_in={},fan_out={},dead={:.2},density={:.2},crap_max={:.1},crap_above={}",
523 relative,
524 score.maintainability_index,
525 score.fan_in,
526 score.fan_out,
527 score.dead_code_ratio,
528 score.complexity_density,
529 score.crap_max,
530 score.crap_above_threshold,
531 );
532 }
533}
534
535fn print_coverage_gaps_compact(report: &crate::health_types::HealthReport, root: &Path) {
536 if let Some(ref gaps) = report.coverage_gaps {
537 outln!(
538 "coverage-gap-summary:runtime_files={},covered_files={},file_coverage_pct={:.1},untested_files={},untested_exports={}",
539 gaps.summary.runtime_files,
540 gaps.summary.covered_files,
541 gaps.summary.file_coverage_pct,
542 gaps.summary.untested_files,
543 gaps.summary.untested_exports,
544 );
545 for item in &gaps.files {
546 let relative = health_compact_path(&item.file.path, root);
547 outln!(
548 "untested-file:{}:value_exports={}",
549 relative,
550 item.file.value_export_count,
551 );
552 }
553 for item in &gaps.exports {
554 let relative = health_compact_path(&item.export.path, root);
555 outln!(
556 "untested-export:{}:{}:{}",
557 relative,
558 item.export.line,
559 item.export.export_name,
560 );
561 }
562 }
563}
564
565fn print_runtime_sections_compact(report: &crate::health_types::HealthReport, root: &Path) {
566 if let Some(ref production) = report.runtime_coverage {
567 for line in build_runtime_coverage_compact_lines(production, root) {
568 outln!("{line}");
569 }
570 }
571 if let Some(ref intelligence) = report.coverage_intelligence {
572 for line in build_coverage_intelligence_compact_lines(intelligence, root) {
573 outln!("{line}");
574 }
575 }
576}
577
578fn compact_ownership_suffix(ownership: Option<&crate::health_types::OwnershipMetrics>) -> String {
579 ownership.map_or_else(String::new, |o| {
580 let mut parts = vec![
581 format!("bus={}", o.bus_factor),
582 format!("contributors={}", o.contributor_count),
583 format!("top={}", o.top_contributor.identifier),
584 format!("top_share={:.3}", o.top_contributor.share),
585 ];
586 if let Some(owner) = &o.declared_owner {
587 parts.push(format!("owner={owner}"));
588 }
589 if let Some(unowned) = o.unowned {
590 parts.push(format!("unowned={unowned}"));
591 }
592 let state = match o.ownership_state {
593 crate::health_types::OwnershipState::Active => "active",
594 crate::health_types::OwnershipState::Unowned => "unowned",
595 crate::health_types::OwnershipState::DeclaredInactive => "declared_inactive",
596 crate::health_types::OwnershipState::Drifting => "drifting",
597 };
598 parts.push(format!("ownership_state={state}"));
599 if o.drift {
600 parts.push("drift=true".to_string());
601 }
602 format!(",{}", parts.join(","))
603 })
604}
605
606fn print_hotspots_compact(hotspots: &[crate::health_types::HotspotFinding], root: &Path) {
607 for entry in hotspots {
608 let relative = health_compact_path(&entry.path, root);
609 let ownership_suffix = compact_ownership_suffix(entry.ownership.as_ref());
610 outln!(
611 "hotspot:{}:score={:.1},commits={},churn={},density={:.2},fan_in={},trend={}{}",
612 relative,
613 entry.score,
614 entry.commits,
615 entry.lines_added + entry.lines_deleted,
616 entry.complexity_density,
617 entry.fan_in,
618 entry.trend,
619 ownership_suffix,
620 );
621 }
622}
623
624fn print_health_trend_compact(report: &crate::health_types::HealthReport) {
625 if let Some(ref trend) = report.health_trend {
626 outln!(
627 "trend:overall:direction={}",
628 trend.overall_direction.label()
629 );
630 for m in &trend.metrics {
631 outln!(
632 "trend:{}:previous={:.1},current={:.1},delta={:+.1},direction={}",
633 m.name,
634 m.previous,
635 m.current,
636 m.delta,
637 m.direction.label(),
638 );
639 }
640 }
641}
642
643fn print_refactoring_targets_compact(
644 targets: &[crate::health_types::RefactoringTargetFinding],
645 root: &Path,
646) {
647 for target in targets {
648 let relative = health_compact_path(&target.path, root);
649 let category = target.category.compact_label();
650 let effort = target.effort.label();
651 let confidence = target.confidence.label();
652 outln!(
653 "refactoring-target:{}:priority={:.1},efficiency={:.1},category={},effort={},confidence={}:{}",
654 relative,
655 target.priority,
656 target.efficiency,
657 category,
658 effort,
659 confidence,
660 target.recommendation,
661 );
662 }
663}
664
665fn build_runtime_coverage_compact_lines(
666 production: &crate::health_types::RuntimeCoverageReport,
667 root: &Path,
668) -> Vec<String> {
669 let mut lines = vec![format!(
670 "runtime-coverage-summary:functions_tracked={},functions_hit={},functions_unhit={},functions_untracked={},coverage_percent={:.1},trace_count={},period_days={},deployments_seen={}",
671 production.summary.functions_tracked,
672 production.summary.functions_hit,
673 production.summary.functions_unhit,
674 production.summary.functions_untracked,
675 production.summary.coverage_percent,
676 production.summary.trace_count,
677 production.summary.period_days,
678 production.summary.deployments_seen,
679 )];
680 for finding in &production.findings {
681 let relative = normalize_uri(&relative_path(&finding.path, root).display().to_string());
682 let invocations = finding
683 .invocations
684 .map_or_else(|| "null".to_owned(), |hits| hits.to_string());
685 lines.push(format!(
686 "runtime-coverage:{}:{}:{}:id={},verdict={},invocations={},confidence={}",
687 relative,
688 finding.line,
689 finding.function,
690 finding.id,
691 finding.verdict,
692 invocations,
693 finding.confidence,
694 ));
695 }
696 for entry in &production.hot_paths {
697 let relative = normalize_uri(&relative_path(&entry.path, root).display().to_string());
698 lines.push(format!(
699 "production-hot-path:{}:{}:{}:id={},invocations={},percentile={}",
700 relative, entry.line, entry.function, entry.id, entry.invocations, entry.percentile,
701 ));
702 }
703 lines
704}
705
706fn build_coverage_intelligence_compact_lines(
707 intelligence: &crate::health_types::CoverageIntelligenceReport,
708 root: &Path,
709) -> Vec<String> {
710 let mut lines = vec![format!(
711 "coverage-intelligence-summary:verdict={},findings={},risky_changes={},high_confidence_deletes={},review_required={},refactor_carefully={},skipped_ambiguous_matches={}",
712 intelligence.verdict,
713 intelligence.summary.findings,
714 intelligence.summary.risky_changes,
715 intelligence.summary.high_confidence_deletes,
716 intelligence.summary.review_required,
717 intelligence.summary.refactor_carefully,
718 intelligence.summary.skipped_ambiguous_matches,
719 )];
720 for finding in &intelligence.findings {
721 let relative = normalize_uri(&relative_path(&finding.path, root).display().to_string());
722 let identity = finding.identity.as_deref().unwrap_or("-");
723 let signals = finding
724 .signals
725 .iter()
726 .map(ToString::to_string)
727 .collect::<Vec<_>>()
728 .join("+");
729 lines.push(format!(
730 "coverage-intelligence:{}:{}:{}:id={},verdict={},recommendation={},confidence={},signals={}",
731 relative,
732 finding.line,
733 identity,
734 finding.id,
735 finding.verdict,
736 finding.recommendation,
737 finding.confidence,
738 signals,
739 ));
740 }
741 lines
742}
743
744pub(super) fn print_duplication_compact(report: &DuplicationReport, root: &Path) {
745 for (i, group) in report.clone_groups.iter().enumerate() {
746 for instance in &group.instances {
747 let relative =
748 normalize_uri(&relative_path(&instance.file, root).display().to_string());
749 outln!(
750 "clone-group-{}:{}:{}-{}:{}tokens",
751 i + 1,
752 relative,
753 instance.start_line,
754 instance.end_line,
755 group.token_count
756 );
757 }
758 }
759}
760
761#[cfg(test)]
762mod tests {
763 use super::*;
764 use crate::health_types::{
765 RuntimeCoverageConfidence, RuntimeCoverageDataSource, RuntimeCoverageEvidence,
766 RuntimeCoverageFinding, RuntimeCoverageHotPath, RuntimeCoverageReport,
767 RuntimeCoverageReportVerdict, RuntimeCoverageSchemaVersion, RuntimeCoverageSummary,
768 RuntimeCoverageVerdict,
769 };
770 use crate::report::test_helpers::sample_results;
771 use fallow_core::extract::MemberKind;
772 use fallow_core::results::*;
773 use std::path::PathBuf;
774
775 #[test]
776 fn compact_empty_results_no_lines() {
777 let root = PathBuf::from("/project");
778 let results = AnalysisResults::default();
779 let lines = build_compact_lines(&results, &root);
780 assert!(lines.is_empty());
781 }
782
783 #[test]
784 fn compact_unused_file_format() {
785 let root = PathBuf::from("/project");
786 let mut results = AnalysisResults::default();
787 results
788 .unused_files
789 .push(UnusedFileFinding::with_actions(UnusedFile {
790 path: root.join("src/dead.ts"),
791 }));
792
793 let lines = build_compact_lines(&results, &root);
794 assert_eq!(lines.len(), 1);
795 assert_eq!(lines[0], "unused-file:src/dead.ts");
796 }
797
798 #[test]
799 fn compact_unused_export_format() {
800 let root = PathBuf::from("/project");
801 let mut results = AnalysisResults::default();
802 results
803 .unused_exports
804 .push(UnusedExportFinding::with_actions(UnusedExport {
805 path: root.join("src/utils.ts"),
806 export_name: "helperFn".to_string(),
807 is_type_only: false,
808 line: 10,
809 col: 4,
810 span_start: 120,
811 is_re_export: false,
812 }));
813
814 let lines = build_compact_lines(&results, &root);
815 assert_eq!(lines[0], "unused-export:src/utils.ts:10:helperFn");
816 }
817
818 #[test]
819 fn compact_health_includes_runtime_coverage_lines() {
820 let root = PathBuf::from("/project");
821 let report = crate::health_types::HealthReport {
822 runtime_coverage: Some(RuntimeCoverageReport {
823 schema_version: RuntimeCoverageSchemaVersion::V1,
824 verdict: RuntimeCoverageReportVerdict::ColdCodeDetected,
825 signals: Vec::new(),
826 summary: RuntimeCoverageSummary {
827 data_source: RuntimeCoverageDataSource::Local,
828 last_received_at: None,
829 functions_tracked: 4,
830 functions_hit: 2,
831 functions_unhit: 1,
832 functions_untracked: 1,
833 coverage_percent: 50.0,
834 trace_count: 512,
835 period_days: 7,
836 deployments_seen: 2,
837 capture_quality: None,
838 },
839 findings: vec![RuntimeCoverageFinding {
840 id: "fallow:prod:deadbeef".to_owned(),
841 stable_id: None,
842 path: root.join("src/cold.ts"),
843 function: "coldPath".to_owned(),
844 line: 14,
845 verdict: RuntimeCoverageVerdict::ReviewRequired,
846 invocations: Some(0),
847 confidence: RuntimeCoverageConfidence::Medium,
848 evidence: RuntimeCoverageEvidence {
849 static_status: "used".to_owned(),
850 test_coverage: "not_covered".to_owned(),
851 v8_tracking: "tracked".to_owned(),
852 untracked_reason: None,
853 observation_days: 7,
854 deployments_observed: 2,
855 },
856 actions: vec![],
857 source_hash: None,
858 }],
859 hot_paths: vec![RuntimeCoverageHotPath {
860 id: "fallow:hot:cafebabe".to_owned(),
861 stable_id: None,
862 path: root.join("src/hot.ts"),
863 function: "hotPath".to_owned(),
864 line: 3,
865 end_line: 9,
866 invocations: 250,
867 percentile: 99,
868 actions: vec![],
869 }],
870 blast_radius: vec![],
871 importance: vec![],
872 watermark: None,
873 warnings: vec![],
874 }),
875 ..Default::default()
876 };
877
878 let lines = build_runtime_coverage_compact_lines(
879 report
880 .runtime_coverage
881 .as_ref()
882 .expect("runtime coverage should be set"),
883 &root,
884 );
885 assert_eq!(
886 lines[0],
887 "runtime-coverage-summary:functions_tracked=4,functions_hit=2,functions_unhit=1,functions_untracked=1,coverage_percent=50.0,trace_count=512,period_days=7,deployments_seen=2"
888 );
889 assert_eq!(
890 lines[1],
891 "runtime-coverage:src/cold.ts:14:coldPath:id=fallow:prod:deadbeef,verdict=review_required,invocations=0,confidence=medium"
892 );
893 assert_eq!(
894 lines[2],
895 "production-hot-path:src/hot.ts:3:hotPath:id=fallow:hot:cafebabe,invocations=250,percentile=99"
896 );
897 }
898
899 #[test]
900 fn compact_health_includes_coverage_intelligence_lines() {
901 use crate::health_types::{
902 CoverageIntelligenceAction, CoverageIntelligenceConfidence,
903 CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
904 CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
905 CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
906 CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
907 };
908
909 let root = PathBuf::from("/project");
910 let report = CoverageIntelligenceReport {
911 schema_version: CoverageIntelligenceSchemaVersion::V1,
912 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
913 summary: CoverageIntelligenceSummary {
914 findings: 1,
915 high_confidence_deletes: 1,
916 ..Default::default()
917 },
918 findings: vec![CoverageIntelligenceFinding {
919 id: "fallow:coverage-intel:abc123".to_owned(),
920 path: root.join("src/dead.ts"),
921 identity: Some("deadPath".to_owned()),
922 line: 9,
923 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
924 signals: vec![
925 CoverageIntelligenceSignal::StaticUnused,
926 CoverageIntelligenceSignal::RuntimeCold,
927 ],
928 recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
929 confidence: CoverageIntelligenceConfidence::High,
930 related_ids: vec!["fallow:prod:deadbeef".to_owned()],
931 evidence: CoverageIntelligenceEvidence {
932 match_confidence: CoverageIntelligenceMatchConfidence::Direct,
933 ..Default::default()
934 },
935 actions: vec![CoverageIntelligenceAction {
936 kind: "delete-after-confirming-owner".to_owned(),
937 description: "Confirm ownership".to_owned(),
938 auto_fixable: false,
939 }],
940 }],
941 };
942
943 let lines = build_coverage_intelligence_compact_lines(&report, &root);
944 assert_eq!(
945 lines[0],
946 "coverage-intelligence-summary:verdict=high-confidence-delete,findings=1,risky_changes=0,high_confidence_deletes=1,review_required=0,refactor_carefully=0,skipped_ambiguous_matches=0"
947 );
948 assert_eq!(
949 lines[1],
950 "coverage-intelligence:src/dead.ts:9:deadPath:id=fallow:coverage-intel:abc123,verdict=high-confidence-delete,recommendation=delete-after-confirming-owner,confidence=high,signals=static_unused+runtime_cold"
951 );
952 }
953
954 #[test]
955 fn compact_unused_type_format() {
956 let root = PathBuf::from("/project");
957 let mut results = AnalysisResults::default();
958 results
959 .unused_types
960 .push(UnusedTypeFinding::with_actions(UnusedExport {
961 path: root.join("src/types.ts"),
962 export_name: "OldType".to_string(),
963 is_type_only: true,
964 line: 5,
965 col: 0,
966 span_start: 60,
967 is_re_export: false,
968 }));
969
970 let lines = build_compact_lines(&results, &root);
971 assert_eq!(lines[0], "unused-type:src/types.ts:5:OldType");
972 }
973
974 #[test]
975 fn compact_unused_dep_format() {
976 let root = PathBuf::from("/project");
977 let mut results = AnalysisResults::default();
978 results
979 .unused_dependencies
980 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
981 package_name: "lodash".to_string(),
982 location: DependencyLocation::Dependencies,
983 path: root.join("package.json"),
984 line: 5,
985 used_in_workspaces: Vec::new(),
986 }));
987
988 let lines = build_compact_lines(&results, &root);
989 assert_eq!(lines[0], "unused-dep:lodash");
990 }
991
992 #[test]
993 fn compact_unused_devdep_format() {
994 let root = PathBuf::from("/project");
995 let mut results = AnalysisResults::default();
996 results
997 .unused_dev_dependencies
998 .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
999 package_name: "jest".to_string(),
1000 location: DependencyLocation::DevDependencies,
1001 path: root.join("package.json"),
1002 line: 5,
1003 used_in_workspaces: Vec::new(),
1004 }));
1005
1006 let lines = build_compact_lines(&results, &root);
1007 assert_eq!(lines[0], "unused-devdep:jest");
1008 }
1009
1010 #[test]
1011 fn compact_unused_enum_member_format() {
1012 let root = PathBuf::from("/project");
1013 let mut results = AnalysisResults::default();
1014 results
1015 .unused_enum_members
1016 .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
1017 path: root.join("src/enums.ts"),
1018 parent_name: "Status".to_string(),
1019 member_name: "Deprecated".to_string(),
1020 kind: MemberKind::EnumMember,
1021 line: 8,
1022 col: 2,
1023 }));
1024
1025 let lines = build_compact_lines(&results, &root);
1026 assert_eq!(
1027 lines[0],
1028 "unused-enum-member:src/enums.ts:8:Status.Deprecated"
1029 );
1030 }
1031
1032 #[test]
1033 fn compact_unused_class_member_format() {
1034 let root = PathBuf::from("/project");
1035 let mut results = AnalysisResults::default();
1036 results
1037 .unused_class_members
1038 .push(UnusedClassMemberFinding::with_actions(UnusedMember {
1039 path: root.join("src/service.ts"),
1040 parent_name: "UserService".to_string(),
1041 member_name: "legacyMethod".to_string(),
1042 kind: MemberKind::ClassMethod,
1043 line: 42,
1044 col: 4,
1045 }));
1046
1047 let lines = build_compact_lines(&results, &root);
1048 assert_eq!(
1049 lines[0],
1050 "unused-class-member:src/service.ts:42:UserService.legacyMethod"
1051 );
1052 }
1053
1054 #[test]
1055 fn compact_unresolved_import_format() {
1056 let root = PathBuf::from("/project");
1057 let mut results = AnalysisResults::default();
1058 results
1059 .unresolved_imports
1060 .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
1061 path: root.join("src/app.ts"),
1062 specifier: "./missing-module".to_string(),
1063 line: 3,
1064 col: 0,
1065 specifier_col: 0,
1066 }));
1067
1068 let lines = build_compact_lines(&results, &root);
1069 assert_eq!(lines[0], "unresolved-import:src/app.ts:3:./missing-module");
1070 }
1071
1072 #[test]
1073 fn compact_unlisted_dep_format() {
1074 let root = PathBuf::from("/project");
1075 let mut results = AnalysisResults::default();
1076 results
1077 .unlisted_dependencies
1078 .push(UnlistedDependencyFinding::with_actions(
1079 UnlistedDependency {
1080 package_name: "chalk".to_string(),
1081 imported_from: vec![],
1082 },
1083 ));
1084
1085 let lines = build_compact_lines(&results, &root);
1086 assert_eq!(lines[0], "unlisted-dep:chalk");
1087 }
1088
1089 #[test]
1090 fn compact_duplicate_export_format() {
1091 let root = PathBuf::from("/project");
1092 let mut results = AnalysisResults::default();
1093 results
1094 .duplicate_exports
1095 .push(DuplicateExportFinding::with_actions(DuplicateExport {
1096 export_name: "Config".to_string(),
1097 locations: vec![
1098 DuplicateLocation {
1099 path: root.join("src/a.ts"),
1100 line: 15,
1101 col: 0,
1102 },
1103 DuplicateLocation {
1104 path: root.join("src/b.ts"),
1105 line: 30,
1106 col: 0,
1107 },
1108 ],
1109 }));
1110
1111 let lines = build_compact_lines(&results, &root);
1112 assert_eq!(lines[0], "duplicate-export:Config");
1113 }
1114
1115 #[test]
1116 fn compact_all_issue_types_produce_lines() {
1117 let root = PathBuf::from("/project");
1118 let results = sample_results(&root);
1119 let lines = build_compact_lines(&results, &root);
1120
1121 assert_eq!(lines.len(), 16);
1122
1123 assert!(lines[0].starts_with("unused-file:"));
1124 assert!(lines[1].starts_with("unused-export:"));
1125 assert!(lines[2].starts_with("unused-type:"));
1126 assert!(lines[3].starts_with("unused-dep:"));
1127 assert!(lines[4].starts_with("unused-devdep:"));
1128 assert!(lines[5].starts_with("unused-optionaldep:"));
1129 assert!(lines[6].starts_with("unused-enum-member:"));
1130 assert!(lines[7].starts_with("unused-class-member:"));
1131 assert!(lines[8].starts_with("unresolved-import:"));
1132 assert!(lines[9].starts_with("unlisted-dep:"));
1133 assert!(lines[10].starts_with("duplicate-export:"));
1134 assert!(lines[11].starts_with("type-only-dep:"));
1135 assert!(lines[12].starts_with("test-only-dep:"));
1136 assert!(lines[13].starts_with("circular-dependency:"));
1137 assert!(lines[14].starts_with("boundary-violation:"));
1138 }
1139
1140 #[test]
1141 fn compact_covers_api_and_boundary_variants() {
1142 let root = PathBuf::from("/project");
1143 let mut results = AnalysisResults::default();
1144 results
1145 .private_type_leaks
1146 .push(PrivateTypeLeakFinding::with_actions(PrivateTypeLeak {
1147 path: root.join("src/api.ts"),
1148 export_name: "createApi".to_owned(),
1149 type_name: "InternalShape".to_owned(),
1150 line: 12,
1151 col: 4,
1152 span_start: 100,
1153 }));
1154 results
1155 .circular_dependencies
1156 .push(CircularDependencyFinding::with_actions(
1157 CircularDependency {
1158 files: vec![
1159 root.join("packages/a/index.ts"),
1160 root.join("packages/b/index.ts"),
1161 ],
1162 length: 2,
1163 line: 3,
1164 col: 0,
1165 edges: Vec::new(),
1166 is_cross_package: true,
1167 },
1168 ));
1169 results
1170 .boundary_coverage_violations
1171 .push(BoundaryCoverageViolationFinding::with_actions(
1172 BoundaryCoverageViolation {
1173 path: root.join("src/unmatched.ts"),
1174 line: 1,
1175 col: 0,
1176 },
1177 ));
1178 results
1179 .boundary_call_violations
1180 .push(BoundaryCallViolationFinding::with_actions(
1181 BoundaryCallViolation {
1182 path: root.join("src/ui/button.ts"),
1183 line: 20,
1184 col: 6,
1185 zone: "ui".to_owned(),
1186 callee: "child_process.exec".to_owned(),
1187 pattern: "child_process.*".to_owned(),
1188 },
1189 ));
1190
1191 let lines = build_compact_lines(&results, &root);
1192
1193 assert_eq!(
1194 lines[0],
1195 "private-type-leak:src/api.ts:12:createApi->InternalShape"
1196 );
1197 assert!(lines[1].contains(" (cross-package)"));
1198 assert_eq!(
1199 lines[2],
1200 "boundary-coverage:src/unmatched.ts:1:no matching boundary zone"
1201 );
1202 assert_eq!(
1203 lines[3],
1204 "boundary-call:src/ui/button.ts:20:child_process.exec forbidden in zone ui (pattern child_process.*)"
1205 );
1206 }
1207
1208 #[test]
1209 fn compact_strips_root_prefix_from_paths() {
1210 let root = PathBuf::from("/project");
1211 let mut results = AnalysisResults::default();
1212 results
1213 .unused_files
1214 .push(UnusedFileFinding::with_actions(UnusedFile {
1215 path: PathBuf::from("/project/src/deep/nested/file.ts"),
1216 }));
1217
1218 let lines = build_compact_lines(&results, &root);
1219 assert_eq!(lines[0], "unused-file:src/deep/nested/file.ts");
1220 }
1221
1222 #[test]
1223 fn compact_re_export_tagged_correctly() {
1224 let root = PathBuf::from("/project");
1225 let mut results = AnalysisResults::default();
1226 results
1227 .unused_exports
1228 .push(UnusedExportFinding::with_actions(UnusedExport {
1229 path: root.join("src/index.ts"),
1230 export_name: "reExported".to_string(),
1231 is_type_only: false,
1232 line: 1,
1233 col: 0,
1234 span_start: 0,
1235 is_re_export: true,
1236 }));
1237
1238 let lines = build_compact_lines(&results, &root);
1239 assert_eq!(lines[0], "unused-re-export:src/index.ts:1:reExported");
1240 }
1241
1242 #[test]
1243 fn compact_type_re_export_tagged_correctly() {
1244 let root = PathBuf::from("/project");
1245 let mut results = AnalysisResults::default();
1246 results
1247 .unused_types
1248 .push(UnusedTypeFinding::with_actions(UnusedExport {
1249 path: root.join("src/index.ts"),
1250 export_name: "ReExportedType".to_string(),
1251 is_type_only: true,
1252 line: 3,
1253 col: 0,
1254 span_start: 0,
1255 is_re_export: true,
1256 }));
1257
1258 let lines = build_compact_lines(&results, &root);
1259 assert_eq!(
1260 lines[0],
1261 "unused-re-export-type:src/index.ts:3:ReExportedType"
1262 );
1263 }
1264
1265 #[test]
1266 fn compact_unused_optional_dep_format() {
1267 let root = PathBuf::from("/project");
1268 let mut results = AnalysisResults::default();
1269 results
1270 .unused_optional_dependencies
1271 .push(UnusedOptionalDependencyFinding::with_actions(
1272 UnusedDependency {
1273 package_name: "fsevents".to_string(),
1274 location: DependencyLocation::OptionalDependencies,
1275 path: root.join("package.json"),
1276 line: 12,
1277 used_in_workspaces: Vec::new(),
1278 },
1279 ));
1280
1281 let lines = build_compact_lines(&results, &root);
1282 assert_eq!(lines[0], "unused-optionaldep:fsevents");
1283 }
1284
1285 #[test]
1286 fn compact_circular_dependency_format() {
1287 let root = PathBuf::from("/project");
1288 let mut results = AnalysisResults::default();
1289 results
1290 .circular_dependencies
1291 .push(CircularDependencyFinding::with_actions(
1292 CircularDependency {
1293 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1294 length: 2,
1295 line: 3,
1296 col: 0,
1297 edges: Vec::new(),
1298 is_cross_package: false,
1299 },
1300 ));
1301
1302 let lines = build_compact_lines(&results, &root);
1303 assert_eq!(lines.len(), 1);
1304 assert!(lines[0].starts_with("circular-dependency:src/a.ts:3:"));
1305 assert!(lines[0].contains("src/a.ts"));
1306 assert!(lines[0].contains("src/b.ts"));
1307 assert!(lines[0].contains("\u{2192}"));
1308 }
1309
1310 #[test]
1311 fn compact_circular_dependency_closes_cycle() {
1312 let root = PathBuf::from("/project");
1313 let mut results = AnalysisResults::default();
1314 results
1315 .circular_dependencies
1316 .push(CircularDependencyFinding::with_actions(
1317 CircularDependency {
1318 files: vec![
1319 root.join("src/a.ts"),
1320 root.join("src/b.ts"),
1321 root.join("src/c.ts"),
1322 ],
1323 length: 3,
1324 line: 1,
1325 col: 0,
1326 edges: Vec::new(),
1327 is_cross_package: false,
1328 },
1329 ));
1330
1331 let lines = build_compact_lines(&results, &root);
1332 let chain_part = lines[0].split(':').next_back().unwrap();
1333 let parts: Vec<&str> = chain_part.split(" \u{2192} ").collect();
1334 assert_eq!(parts.len(), 4);
1335 assert_eq!(parts[0], parts[3]); }
1337
1338 #[test]
1339 fn compact_type_only_dep_format() {
1340 let root = PathBuf::from("/project");
1341 let mut results = AnalysisResults::default();
1342 results
1343 .type_only_dependencies
1344 .push(TypeOnlyDependencyFinding::with_actions(
1345 TypeOnlyDependency {
1346 package_name: "zod".to_string(),
1347 path: root.join("package.json"),
1348 line: 8,
1349 },
1350 ));
1351
1352 let lines = build_compact_lines(&results, &root);
1353 assert_eq!(lines[0], "type-only-dep:zod");
1354 }
1355
1356 #[test]
1357 fn compact_multiple_unused_files() {
1358 let root = PathBuf::from("/project");
1359 let mut results = AnalysisResults::default();
1360 results
1361 .unused_files
1362 .push(UnusedFileFinding::with_actions(UnusedFile {
1363 path: root.join("src/a.ts"),
1364 }));
1365 results
1366 .unused_files
1367 .push(UnusedFileFinding::with_actions(UnusedFile {
1368 path: root.join("src/b.ts"),
1369 }));
1370
1371 let lines = build_compact_lines(&results, &root);
1372 assert_eq!(lines.len(), 2);
1373 assert_eq!(lines[0], "unused-file:src/a.ts");
1374 assert_eq!(lines[1], "unused-file:src/b.ts");
1375 }
1376
1377 #[test]
1378 fn compact_ordering_optional_dep_between_devdep_and_enum() {
1379 let root = PathBuf::from("/project");
1380 let mut results = AnalysisResults::default();
1381 results
1382 .unused_dev_dependencies
1383 .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
1384 package_name: "jest".to_string(),
1385 location: DependencyLocation::DevDependencies,
1386 path: root.join("package.json"),
1387 line: 5,
1388 used_in_workspaces: Vec::new(),
1389 }));
1390 results
1391 .unused_optional_dependencies
1392 .push(UnusedOptionalDependencyFinding::with_actions(
1393 UnusedDependency {
1394 package_name: "fsevents".to_string(),
1395 location: DependencyLocation::OptionalDependencies,
1396 path: root.join("package.json"),
1397 line: 12,
1398 used_in_workspaces: Vec::new(),
1399 },
1400 ));
1401 results
1402 .unused_enum_members
1403 .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
1404 path: root.join("src/enums.ts"),
1405 parent_name: "Status".to_string(),
1406 member_name: "Deprecated".to_string(),
1407 kind: MemberKind::EnumMember,
1408 line: 8,
1409 col: 2,
1410 }));
1411
1412 let lines = build_compact_lines(&results, &root);
1413 assert_eq!(lines.len(), 3);
1414 assert!(lines[0].starts_with("unused-devdep:"));
1415 assert!(lines[1].starts_with("unused-optionaldep:"));
1416 assert!(lines[2].starts_with("unused-enum-member:"));
1417 }
1418
1419 #[test]
1420 fn compact_path_outside_root_preserved() {
1421 let root = PathBuf::from("/project");
1422 let mut results = AnalysisResults::default();
1423 results
1424 .unused_files
1425 .push(UnusedFileFinding::with_actions(UnusedFile {
1426 path: PathBuf::from("/other/place/file.ts"),
1427 }));
1428
1429 let lines = build_compact_lines(&results, &root);
1430 assert!(lines[0].contains("/other/place/file.ts"));
1431 }
1432}