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_file_scores_compact(&report.file_scores, root);
400 print_coverage_gaps_compact(report, root);
401 print_runtime_sections_compact(report, root);
402 print_hotspots_compact(&report.hotspots, root);
403 print_health_trend_compact(report);
404 print_refactoring_targets_compact(&report.targets, root);
405}
406
407fn print_health_score_compact(report: &crate::health_types::HealthReport) {
408 if let Some(ref hs) = report.health_score {
409 outln!("health-score:{:.1}:{}", hs.score, hs.grade);
410 }
411}
412
413fn print_vital_signs_compact(report: &crate::health_types::HealthReport) {
414 if let Some(ref vs) = report.vital_signs {
415 let mut parts = Vec::new();
416 if vs.total_loc > 0 {
417 parts.push(format!("total_loc={}", vs.total_loc));
418 }
419 parts.push(format!("avg_cyclomatic={:.1}", vs.avg_cyclomatic));
420 parts.push(format!("p90_cyclomatic={}", vs.p90_cyclomatic));
421 if let Some(v) = vs.dead_file_pct {
422 parts.push(format!("dead_file_pct={v:.1}"));
423 }
424 if let Some(v) = vs.dead_export_pct {
425 parts.push(format!("dead_export_pct={v:.1}"));
426 }
427 if let Some(v) = vs.maintainability_avg {
428 parts.push(format!("maintainability_avg={v:.1}"));
429 }
430 if let Some(v) = vs.hotspot_count {
431 parts.push(format!("hotspot_count={v}"));
432 }
433 if let Some(v) = vs.circular_dep_count {
434 parts.push(format!("circular_dep_count={v}"));
435 }
436 if let Some(v) = vs.unused_dep_count {
437 parts.push(format!("unused_dep_count={v}"));
438 }
439 outln!("vital-signs:{}", parts.join(","));
440 }
441}
442
443fn health_compact_path(path: &Path, root: &Path) -> String {
444 normalize_uri(&relative_path(path, root).display().to_string())
445}
446
447fn print_health_findings_compact(findings: &[crate::health_types::HealthFinding], root: &Path) {
448 for finding in findings {
449 let relative = health_compact_path(&finding.path, root);
450 let severity = match finding.severity {
451 crate::health_types::FindingSeverity::Critical => "critical",
452 crate::health_types::FindingSeverity::High => "high",
453 crate::health_types::FindingSeverity::Moderate => "moderate",
454 };
455 let crap_suffix = match finding.crap {
456 Some(crap) => {
457 let coverage = finding
458 .coverage_pct
459 .map(|pct| format!(",coverage_pct={pct:.1}"))
460 .unwrap_or_default();
461 format!(",crap={crap:.1}{coverage}")
462 }
463 None => String::new(),
464 };
465 outln!(
466 "high-complexity:{}:{}:{}:cyclomatic={},cognitive={},severity={}{}",
467 relative,
468 finding.line,
469 finding.name,
470 finding.cyclomatic,
471 finding.cognitive,
472 severity,
473 crap_suffix,
474 );
475 }
476}
477
478fn print_file_scores_compact(scores: &[crate::health_types::FileHealthScore], root: &Path) {
479 for score in scores {
480 let relative = health_compact_path(&score.path, root);
481 outln!(
482 "file-score:{}:mi={:.1},fan_in={},fan_out={},dead={:.2},density={:.2},crap_max={:.1},crap_above={}",
483 relative,
484 score.maintainability_index,
485 score.fan_in,
486 score.fan_out,
487 score.dead_code_ratio,
488 score.complexity_density,
489 score.crap_max,
490 score.crap_above_threshold,
491 );
492 }
493}
494
495fn print_coverage_gaps_compact(report: &crate::health_types::HealthReport, root: &Path) {
496 if let Some(ref gaps) = report.coverage_gaps {
497 outln!(
498 "coverage-gap-summary:runtime_files={},covered_files={},file_coverage_pct={:.1},untested_files={},untested_exports={}",
499 gaps.summary.runtime_files,
500 gaps.summary.covered_files,
501 gaps.summary.file_coverage_pct,
502 gaps.summary.untested_files,
503 gaps.summary.untested_exports,
504 );
505 for item in &gaps.files {
506 let relative = health_compact_path(&item.file.path, root);
507 outln!(
508 "untested-file:{}:value_exports={}",
509 relative,
510 item.file.value_export_count,
511 );
512 }
513 for item in &gaps.exports {
514 let relative = health_compact_path(&item.export.path, root);
515 outln!(
516 "untested-export:{}:{}:{}",
517 relative,
518 item.export.line,
519 item.export.export_name,
520 );
521 }
522 }
523}
524
525fn print_runtime_sections_compact(report: &crate::health_types::HealthReport, root: &Path) {
526 if let Some(ref production) = report.runtime_coverage {
527 for line in build_runtime_coverage_compact_lines(production, root) {
528 outln!("{line}");
529 }
530 }
531 if let Some(ref intelligence) = report.coverage_intelligence {
532 for line in build_coverage_intelligence_compact_lines(intelligence, root) {
533 outln!("{line}");
534 }
535 }
536}
537
538fn compact_ownership_suffix(ownership: Option<&crate::health_types::OwnershipMetrics>) -> String {
539 ownership.map_or_else(String::new, |o| {
540 let mut parts = vec![
541 format!("bus={}", o.bus_factor),
542 format!("contributors={}", o.contributor_count),
543 format!("top={}", o.top_contributor.identifier),
544 format!("top_share={:.3}", o.top_contributor.share),
545 ];
546 if let Some(owner) = &o.declared_owner {
547 parts.push(format!("owner={owner}"));
548 }
549 if let Some(unowned) = o.unowned {
550 parts.push(format!("unowned={unowned}"));
551 }
552 let state = match o.ownership_state {
553 crate::health_types::OwnershipState::Active => "active",
554 crate::health_types::OwnershipState::Unowned => "unowned",
555 crate::health_types::OwnershipState::DeclaredInactive => "declared_inactive",
556 crate::health_types::OwnershipState::Drifting => "drifting",
557 };
558 parts.push(format!("ownership_state={state}"));
559 if o.drift {
560 parts.push("drift=true".to_string());
561 }
562 format!(",{}", parts.join(","))
563 })
564}
565
566fn print_hotspots_compact(hotspots: &[crate::health_types::HotspotFinding], root: &Path) {
567 for entry in hotspots {
568 let relative = health_compact_path(&entry.path, root);
569 let ownership_suffix = compact_ownership_suffix(entry.ownership.as_ref());
570 outln!(
571 "hotspot:{}:score={:.1},commits={},churn={},density={:.2},fan_in={},trend={}{}",
572 relative,
573 entry.score,
574 entry.commits,
575 entry.lines_added + entry.lines_deleted,
576 entry.complexity_density,
577 entry.fan_in,
578 entry.trend,
579 ownership_suffix,
580 );
581 }
582}
583
584fn print_health_trend_compact(report: &crate::health_types::HealthReport) {
585 if let Some(ref trend) = report.health_trend {
586 outln!(
587 "trend:overall:direction={}",
588 trend.overall_direction.label()
589 );
590 for m in &trend.metrics {
591 outln!(
592 "trend:{}:previous={:.1},current={:.1},delta={:+.1},direction={}",
593 m.name,
594 m.previous,
595 m.current,
596 m.delta,
597 m.direction.label(),
598 );
599 }
600 }
601}
602
603fn print_refactoring_targets_compact(
604 targets: &[crate::health_types::RefactoringTargetFinding],
605 root: &Path,
606) {
607 for target in targets {
608 let relative = health_compact_path(&target.path, root);
609 let category = target.category.compact_label();
610 let effort = target.effort.label();
611 let confidence = target.confidence.label();
612 outln!(
613 "refactoring-target:{}:priority={:.1},efficiency={:.1},category={},effort={},confidence={}:{}",
614 relative,
615 target.priority,
616 target.efficiency,
617 category,
618 effort,
619 confidence,
620 target.recommendation,
621 );
622 }
623}
624
625fn build_runtime_coverage_compact_lines(
626 production: &crate::health_types::RuntimeCoverageReport,
627 root: &Path,
628) -> Vec<String> {
629 let mut lines = vec![format!(
630 "runtime-coverage-summary:functions_tracked={},functions_hit={},functions_unhit={},functions_untracked={},coverage_percent={:.1},trace_count={},period_days={},deployments_seen={}",
631 production.summary.functions_tracked,
632 production.summary.functions_hit,
633 production.summary.functions_unhit,
634 production.summary.functions_untracked,
635 production.summary.coverage_percent,
636 production.summary.trace_count,
637 production.summary.period_days,
638 production.summary.deployments_seen,
639 )];
640 for finding in &production.findings {
641 let relative = normalize_uri(&relative_path(&finding.path, root).display().to_string());
642 let invocations = finding
643 .invocations
644 .map_or_else(|| "null".to_owned(), |hits| hits.to_string());
645 lines.push(format!(
646 "runtime-coverage:{}:{}:{}:id={},verdict={},invocations={},confidence={}",
647 relative,
648 finding.line,
649 finding.function,
650 finding.id,
651 finding.verdict,
652 invocations,
653 finding.confidence,
654 ));
655 }
656 for entry in &production.hot_paths {
657 let relative = normalize_uri(&relative_path(&entry.path, root).display().to_string());
658 lines.push(format!(
659 "production-hot-path:{}:{}:{}:id={},invocations={},percentile={}",
660 relative, entry.line, entry.function, entry.id, entry.invocations, entry.percentile,
661 ));
662 }
663 lines
664}
665
666fn build_coverage_intelligence_compact_lines(
667 intelligence: &crate::health_types::CoverageIntelligenceReport,
668 root: &Path,
669) -> Vec<String> {
670 let mut lines = vec![format!(
671 "coverage-intelligence-summary:verdict={},findings={},risky_changes={},high_confidence_deletes={},review_required={},refactor_carefully={},skipped_ambiguous_matches={}",
672 intelligence.verdict,
673 intelligence.summary.findings,
674 intelligence.summary.risky_changes,
675 intelligence.summary.high_confidence_deletes,
676 intelligence.summary.review_required,
677 intelligence.summary.refactor_carefully,
678 intelligence.summary.skipped_ambiguous_matches,
679 )];
680 for finding in &intelligence.findings {
681 let relative = normalize_uri(&relative_path(&finding.path, root).display().to_string());
682 let identity = finding.identity.as_deref().unwrap_or("-");
683 let signals = finding
684 .signals
685 .iter()
686 .map(ToString::to_string)
687 .collect::<Vec<_>>()
688 .join("+");
689 lines.push(format!(
690 "coverage-intelligence:{}:{}:{}:id={},verdict={},recommendation={},confidence={},signals={}",
691 relative,
692 finding.line,
693 identity,
694 finding.id,
695 finding.verdict,
696 finding.recommendation,
697 finding.confidence,
698 signals,
699 ));
700 }
701 lines
702}
703
704pub(super) fn print_duplication_compact(report: &DuplicationReport, root: &Path) {
705 for (i, group) in report.clone_groups.iter().enumerate() {
706 for instance in &group.instances {
707 let relative =
708 normalize_uri(&relative_path(&instance.file, root).display().to_string());
709 outln!(
710 "clone-group-{}:{}:{}-{}:{}tokens",
711 i + 1,
712 relative,
713 instance.start_line,
714 instance.end_line,
715 group.token_count
716 );
717 }
718 }
719}
720
721#[cfg(test)]
722mod tests {
723 use super::*;
724 use crate::health_types::{
725 RuntimeCoverageConfidence, RuntimeCoverageDataSource, RuntimeCoverageEvidence,
726 RuntimeCoverageFinding, RuntimeCoverageHotPath, RuntimeCoverageReport,
727 RuntimeCoverageReportVerdict, RuntimeCoverageSchemaVersion, RuntimeCoverageSummary,
728 RuntimeCoverageVerdict,
729 };
730 use crate::report::test_helpers::sample_results;
731 use fallow_core::extract::MemberKind;
732 use fallow_core::results::*;
733 use std::path::PathBuf;
734
735 #[test]
736 fn compact_empty_results_no_lines() {
737 let root = PathBuf::from("/project");
738 let results = AnalysisResults::default();
739 let lines = build_compact_lines(&results, &root);
740 assert!(lines.is_empty());
741 }
742
743 #[test]
744 fn compact_unused_file_format() {
745 let root = PathBuf::from("/project");
746 let mut results = AnalysisResults::default();
747 results
748 .unused_files
749 .push(UnusedFileFinding::with_actions(UnusedFile {
750 path: root.join("src/dead.ts"),
751 }));
752
753 let lines = build_compact_lines(&results, &root);
754 assert_eq!(lines.len(), 1);
755 assert_eq!(lines[0], "unused-file:src/dead.ts");
756 }
757
758 #[test]
759 fn compact_unused_export_format() {
760 let root = PathBuf::from("/project");
761 let mut results = AnalysisResults::default();
762 results
763 .unused_exports
764 .push(UnusedExportFinding::with_actions(UnusedExport {
765 path: root.join("src/utils.ts"),
766 export_name: "helperFn".to_string(),
767 is_type_only: false,
768 line: 10,
769 col: 4,
770 span_start: 120,
771 is_re_export: false,
772 }));
773
774 let lines = build_compact_lines(&results, &root);
775 assert_eq!(lines[0], "unused-export:src/utils.ts:10:helperFn");
776 }
777
778 #[test]
779 fn compact_health_includes_runtime_coverage_lines() {
780 let root = PathBuf::from("/project");
781 let report = crate::health_types::HealthReport {
782 runtime_coverage: Some(RuntimeCoverageReport {
783 schema_version: RuntimeCoverageSchemaVersion::V1,
784 verdict: RuntimeCoverageReportVerdict::ColdCodeDetected,
785 signals: Vec::new(),
786 summary: RuntimeCoverageSummary {
787 data_source: RuntimeCoverageDataSource::Local,
788 last_received_at: None,
789 functions_tracked: 4,
790 functions_hit: 2,
791 functions_unhit: 1,
792 functions_untracked: 1,
793 coverage_percent: 50.0,
794 trace_count: 512,
795 period_days: 7,
796 deployments_seen: 2,
797 capture_quality: None,
798 },
799 findings: vec![RuntimeCoverageFinding {
800 id: "fallow:prod:deadbeef".to_owned(),
801 stable_id: None,
802 path: root.join("src/cold.ts"),
803 function: "coldPath".to_owned(),
804 line: 14,
805 verdict: RuntimeCoverageVerdict::ReviewRequired,
806 invocations: Some(0),
807 confidence: RuntimeCoverageConfidence::Medium,
808 evidence: RuntimeCoverageEvidence {
809 static_status: "used".to_owned(),
810 test_coverage: "not_covered".to_owned(),
811 v8_tracking: "tracked".to_owned(),
812 untracked_reason: None,
813 observation_days: 7,
814 deployments_observed: 2,
815 },
816 actions: vec![],
817 source_hash: None,
818 }],
819 hot_paths: vec![RuntimeCoverageHotPath {
820 id: "fallow:hot:cafebabe".to_owned(),
821 stable_id: None,
822 path: root.join("src/hot.ts"),
823 function: "hotPath".to_owned(),
824 line: 3,
825 end_line: 9,
826 invocations: 250,
827 percentile: 99,
828 actions: vec![],
829 }],
830 blast_radius: vec![],
831 importance: vec![],
832 watermark: None,
833 warnings: vec![],
834 }),
835 ..Default::default()
836 };
837
838 let lines = build_runtime_coverage_compact_lines(
839 report
840 .runtime_coverage
841 .as_ref()
842 .expect("runtime coverage should be set"),
843 &root,
844 );
845 assert_eq!(
846 lines[0],
847 "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"
848 );
849 assert_eq!(
850 lines[1],
851 "runtime-coverage:src/cold.ts:14:coldPath:id=fallow:prod:deadbeef,verdict=review_required,invocations=0,confidence=medium"
852 );
853 assert_eq!(
854 lines[2],
855 "production-hot-path:src/hot.ts:3:hotPath:id=fallow:hot:cafebabe,invocations=250,percentile=99"
856 );
857 }
858
859 #[test]
860 fn compact_health_includes_coverage_intelligence_lines() {
861 use crate::health_types::{
862 CoverageIntelligenceAction, CoverageIntelligenceConfidence,
863 CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
864 CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
865 CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
866 CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
867 };
868
869 let root = PathBuf::from("/project");
870 let report = CoverageIntelligenceReport {
871 schema_version: CoverageIntelligenceSchemaVersion::V1,
872 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
873 summary: CoverageIntelligenceSummary {
874 findings: 1,
875 high_confidence_deletes: 1,
876 ..Default::default()
877 },
878 findings: vec![CoverageIntelligenceFinding {
879 id: "fallow:coverage-intel:abc123".to_owned(),
880 path: root.join("src/dead.ts"),
881 identity: Some("deadPath".to_owned()),
882 line: 9,
883 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
884 signals: vec![
885 CoverageIntelligenceSignal::StaticUnused,
886 CoverageIntelligenceSignal::RuntimeCold,
887 ],
888 recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
889 confidence: CoverageIntelligenceConfidence::High,
890 related_ids: vec!["fallow:prod:deadbeef".to_owned()],
891 evidence: CoverageIntelligenceEvidence {
892 match_confidence: CoverageIntelligenceMatchConfidence::Direct,
893 ..Default::default()
894 },
895 actions: vec![CoverageIntelligenceAction {
896 kind: "delete-after-confirming-owner".to_owned(),
897 description: "Confirm ownership".to_owned(),
898 auto_fixable: false,
899 }],
900 }],
901 };
902
903 let lines = build_coverage_intelligence_compact_lines(&report, &root);
904 assert_eq!(
905 lines[0],
906 "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"
907 );
908 assert_eq!(
909 lines[1],
910 "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"
911 );
912 }
913
914 #[test]
915 fn compact_unused_type_format() {
916 let root = PathBuf::from("/project");
917 let mut results = AnalysisResults::default();
918 results
919 .unused_types
920 .push(UnusedTypeFinding::with_actions(UnusedExport {
921 path: root.join("src/types.ts"),
922 export_name: "OldType".to_string(),
923 is_type_only: true,
924 line: 5,
925 col: 0,
926 span_start: 60,
927 is_re_export: false,
928 }));
929
930 let lines = build_compact_lines(&results, &root);
931 assert_eq!(lines[0], "unused-type:src/types.ts:5:OldType");
932 }
933
934 #[test]
935 fn compact_unused_dep_format() {
936 let root = PathBuf::from("/project");
937 let mut results = AnalysisResults::default();
938 results
939 .unused_dependencies
940 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
941 package_name: "lodash".to_string(),
942 location: DependencyLocation::Dependencies,
943 path: root.join("package.json"),
944 line: 5,
945 used_in_workspaces: Vec::new(),
946 }));
947
948 let lines = build_compact_lines(&results, &root);
949 assert_eq!(lines[0], "unused-dep:lodash");
950 }
951
952 #[test]
953 fn compact_unused_devdep_format() {
954 let root = PathBuf::from("/project");
955 let mut results = AnalysisResults::default();
956 results
957 .unused_dev_dependencies
958 .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
959 package_name: "jest".to_string(),
960 location: DependencyLocation::DevDependencies,
961 path: root.join("package.json"),
962 line: 5,
963 used_in_workspaces: Vec::new(),
964 }));
965
966 let lines = build_compact_lines(&results, &root);
967 assert_eq!(lines[0], "unused-devdep:jest");
968 }
969
970 #[test]
971 fn compact_unused_enum_member_format() {
972 let root = PathBuf::from("/project");
973 let mut results = AnalysisResults::default();
974 results
975 .unused_enum_members
976 .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
977 path: root.join("src/enums.ts"),
978 parent_name: "Status".to_string(),
979 member_name: "Deprecated".to_string(),
980 kind: MemberKind::EnumMember,
981 line: 8,
982 col: 2,
983 }));
984
985 let lines = build_compact_lines(&results, &root);
986 assert_eq!(
987 lines[0],
988 "unused-enum-member:src/enums.ts:8:Status.Deprecated"
989 );
990 }
991
992 #[test]
993 fn compact_unused_class_member_format() {
994 let root = PathBuf::from("/project");
995 let mut results = AnalysisResults::default();
996 results
997 .unused_class_members
998 .push(UnusedClassMemberFinding::with_actions(UnusedMember {
999 path: root.join("src/service.ts"),
1000 parent_name: "UserService".to_string(),
1001 member_name: "legacyMethod".to_string(),
1002 kind: MemberKind::ClassMethod,
1003 line: 42,
1004 col: 4,
1005 }));
1006
1007 let lines = build_compact_lines(&results, &root);
1008 assert_eq!(
1009 lines[0],
1010 "unused-class-member:src/service.ts:42:UserService.legacyMethod"
1011 );
1012 }
1013
1014 #[test]
1015 fn compact_unresolved_import_format() {
1016 let root = PathBuf::from("/project");
1017 let mut results = AnalysisResults::default();
1018 results
1019 .unresolved_imports
1020 .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
1021 path: root.join("src/app.ts"),
1022 specifier: "./missing-module".to_string(),
1023 line: 3,
1024 col: 0,
1025 specifier_col: 0,
1026 }));
1027
1028 let lines = build_compact_lines(&results, &root);
1029 assert_eq!(lines[0], "unresolved-import:src/app.ts:3:./missing-module");
1030 }
1031
1032 #[test]
1033 fn compact_unlisted_dep_format() {
1034 let root = PathBuf::from("/project");
1035 let mut results = AnalysisResults::default();
1036 results
1037 .unlisted_dependencies
1038 .push(UnlistedDependencyFinding::with_actions(
1039 UnlistedDependency {
1040 package_name: "chalk".to_string(),
1041 imported_from: vec![],
1042 },
1043 ));
1044
1045 let lines = build_compact_lines(&results, &root);
1046 assert_eq!(lines[0], "unlisted-dep:chalk");
1047 }
1048
1049 #[test]
1050 fn compact_duplicate_export_format() {
1051 let root = PathBuf::from("/project");
1052 let mut results = AnalysisResults::default();
1053 results
1054 .duplicate_exports
1055 .push(DuplicateExportFinding::with_actions(DuplicateExport {
1056 export_name: "Config".to_string(),
1057 locations: vec![
1058 DuplicateLocation {
1059 path: root.join("src/a.ts"),
1060 line: 15,
1061 col: 0,
1062 },
1063 DuplicateLocation {
1064 path: root.join("src/b.ts"),
1065 line: 30,
1066 col: 0,
1067 },
1068 ],
1069 }));
1070
1071 let lines = build_compact_lines(&results, &root);
1072 assert_eq!(lines[0], "duplicate-export:Config");
1073 }
1074
1075 #[test]
1076 fn compact_all_issue_types_produce_lines() {
1077 let root = PathBuf::from("/project");
1078 let results = sample_results(&root);
1079 let lines = build_compact_lines(&results, &root);
1080
1081 assert_eq!(lines.len(), 16);
1082
1083 assert!(lines[0].starts_with("unused-file:"));
1084 assert!(lines[1].starts_with("unused-export:"));
1085 assert!(lines[2].starts_with("unused-type:"));
1086 assert!(lines[3].starts_with("unused-dep:"));
1087 assert!(lines[4].starts_with("unused-devdep:"));
1088 assert!(lines[5].starts_with("unused-optionaldep:"));
1089 assert!(lines[6].starts_with("unused-enum-member:"));
1090 assert!(lines[7].starts_with("unused-class-member:"));
1091 assert!(lines[8].starts_with("unresolved-import:"));
1092 assert!(lines[9].starts_with("unlisted-dep:"));
1093 assert!(lines[10].starts_with("duplicate-export:"));
1094 assert!(lines[11].starts_with("type-only-dep:"));
1095 assert!(lines[12].starts_with("test-only-dep:"));
1096 assert!(lines[13].starts_with("circular-dependency:"));
1097 assert!(lines[14].starts_with("boundary-violation:"));
1098 }
1099
1100 #[test]
1101 fn compact_covers_api_and_boundary_variants() {
1102 let root = PathBuf::from("/project");
1103 let mut results = AnalysisResults::default();
1104 results
1105 .private_type_leaks
1106 .push(PrivateTypeLeakFinding::with_actions(PrivateTypeLeak {
1107 path: root.join("src/api.ts"),
1108 export_name: "createApi".to_owned(),
1109 type_name: "InternalShape".to_owned(),
1110 line: 12,
1111 col: 4,
1112 span_start: 100,
1113 }));
1114 results
1115 .circular_dependencies
1116 .push(CircularDependencyFinding::with_actions(
1117 CircularDependency {
1118 files: vec![
1119 root.join("packages/a/index.ts"),
1120 root.join("packages/b/index.ts"),
1121 ],
1122 length: 2,
1123 line: 3,
1124 col: 0,
1125 edges: Vec::new(),
1126 is_cross_package: true,
1127 },
1128 ));
1129 results
1130 .boundary_coverage_violations
1131 .push(BoundaryCoverageViolationFinding::with_actions(
1132 BoundaryCoverageViolation {
1133 path: root.join("src/unmatched.ts"),
1134 line: 1,
1135 col: 0,
1136 },
1137 ));
1138 results
1139 .boundary_call_violations
1140 .push(BoundaryCallViolationFinding::with_actions(
1141 BoundaryCallViolation {
1142 path: root.join("src/ui/button.ts"),
1143 line: 20,
1144 col: 6,
1145 zone: "ui".to_owned(),
1146 callee: "child_process.exec".to_owned(),
1147 pattern: "child_process.*".to_owned(),
1148 },
1149 ));
1150
1151 let lines = build_compact_lines(&results, &root);
1152
1153 assert_eq!(
1154 lines[0],
1155 "private-type-leak:src/api.ts:12:createApi->InternalShape"
1156 );
1157 assert!(lines[1].contains(" (cross-package)"));
1158 assert_eq!(
1159 lines[2],
1160 "boundary-coverage:src/unmatched.ts:1:no matching boundary zone"
1161 );
1162 assert_eq!(
1163 lines[3],
1164 "boundary-call:src/ui/button.ts:20:child_process.exec forbidden in zone ui (pattern child_process.*)"
1165 );
1166 }
1167
1168 #[test]
1169 fn compact_strips_root_prefix_from_paths() {
1170 let root = PathBuf::from("/project");
1171 let mut results = AnalysisResults::default();
1172 results
1173 .unused_files
1174 .push(UnusedFileFinding::with_actions(UnusedFile {
1175 path: PathBuf::from("/project/src/deep/nested/file.ts"),
1176 }));
1177
1178 let lines = build_compact_lines(&results, &root);
1179 assert_eq!(lines[0], "unused-file:src/deep/nested/file.ts");
1180 }
1181
1182 #[test]
1183 fn compact_re_export_tagged_correctly() {
1184 let root = PathBuf::from("/project");
1185 let mut results = AnalysisResults::default();
1186 results
1187 .unused_exports
1188 .push(UnusedExportFinding::with_actions(UnusedExport {
1189 path: root.join("src/index.ts"),
1190 export_name: "reExported".to_string(),
1191 is_type_only: false,
1192 line: 1,
1193 col: 0,
1194 span_start: 0,
1195 is_re_export: true,
1196 }));
1197
1198 let lines = build_compact_lines(&results, &root);
1199 assert_eq!(lines[0], "unused-re-export:src/index.ts:1:reExported");
1200 }
1201
1202 #[test]
1203 fn compact_type_re_export_tagged_correctly() {
1204 let root = PathBuf::from("/project");
1205 let mut results = AnalysisResults::default();
1206 results
1207 .unused_types
1208 .push(UnusedTypeFinding::with_actions(UnusedExport {
1209 path: root.join("src/index.ts"),
1210 export_name: "ReExportedType".to_string(),
1211 is_type_only: true,
1212 line: 3,
1213 col: 0,
1214 span_start: 0,
1215 is_re_export: true,
1216 }));
1217
1218 let lines = build_compact_lines(&results, &root);
1219 assert_eq!(
1220 lines[0],
1221 "unused-re-export-type:src/index.ts:3:ReExportedType"
1222 );
1223 }
1224
1225 #[test]
1226 fn compact_unused_optional_dep_format() {
1227 let root = PathBuf::from("/project");
1228 let mut results = AnalysisResults::default();
1229 results
1230 .unused_optional_dependencies
1231 .push(UnusedOptionalDependencyFinding::with_actions(
1232 UnusedDependency {
1233 package_name: "fsevents".to_string(),
1234 location: DependencyLocation::OptionalDependencies,
1235 path: root.join("package.json"),
1236 line: 12,
1237 used_in_workspaces: Vec::new(),
1238 },
1239 ));
1240
1241 let lines = build_compact_lines(&results, &root);
1242 assert_eq!(lines[0], "unused-optionaldep:fsevents");
1243 }
1244
1245 #[test]
1246 fn compact_circular_dependency_format() {
1247 let root = PathBuf::from("/project");
1248 let mut results = AnalysisResults::default();
1249 results
1250 .circular_dependencies
1251 .push(CircularDependencyFinding::with_actions(
1252 CircularDependency {
1253 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1254 length: 2,
1255 line: 3,
1256 col: 0,
1257 edges: Vec::new(),
1258 is_cross_package: false,
1259 },
1260 ));
1261
1262 let lines = build_compact_lines(&results, &root);
1263 assert_eq!(lines.len(), 1);
1264 assert!(lines[0].starts_with("circular-dependency:src/a.ts:3:"));
1265 assert!(lines[0].contains("src/a.ts"));
1266 assert!(lines[0].contains("src/b.ts"));
1267 assert!(lines[0].contains("\u{2192}"));
1268 }
1269
1270 #[test]
1271 fn compact_circular_dependency_closes_cycle() {
1272 let root = PathBuf::from("/project");
1273 let mut results = AnalysisResults::default();
1274 results
1275 .circular_dependencies
1276 .push(CircularDependencyFinding::with_actions(
1277 CircularDependency {
1278 files: vec![
1279 root.join("src/a.ts"),
1280 root.join("src/b.ts"),
1281 root.join("src/c.ts"),
1282 ],
1283 length: 3,
1284 line: 1,
1285 col: 0,
1286 edges: Vec::new(),
1287 is_cross_package: false,
1288 },
1289 ));
1290
1291 let lines = build_compact_lines(&results, &root);
1292 let chain_part = lines[0].split(':').next_back().unwrap();
1293 let parts: Vec<&str> = chain_part.split(" \u{2192} ").collect();
1294 assert_eq!(parts.len(), 4);
1295 assert_eq!(parts[0], parts[3]); }
1297
1298 #[test]
1299 fn compact_type_only_dep_format() {
1300 let root = PathBuf::from("/project");
1301 let mut results = AnalysisResults::default();
1302 results
1303 .type_only_dependencies
1304 .push(TypeOnlyDependencyFinding::with_actions(
1305 TypeOnlyDependency {
1306 package_name: "zod".to_string(),
1307 path: root.join("package.json"),
1308 line: 8,
1309 },
1310 ));
1311
1312 let lines = build_compact_lines(&results, &root);
1313 assert_eq!(lines[0], "type-only-dep:zod");
1314 }
1315
1316 #[test]
1317 fn compact_multiple_unused_files() {
1318 let root = PathBuf::from("/project");
1319 let mut results = AnalysisResults::default();
1320 results
1321 .unused_files
1322 .push(UnusedFileFinding::with_actions(UnusedFile {
1323 path: root.join("src/a.ts"),
1324 }));
1325 results
1326 .unused_files
1327 .push(UnusedFileFinding::with_actions(UnusedFile {
1328 path: root.join("src/b.ts"),
1329 }));
1330
1331 let lines = build_compact_lines(&results, &root);
1332 assert_eq!(lines.len(), 2);
1333 assert_eq!(lines[0], "unused-file:src/a.ts");
1334 assert_eq!(lines[1], "unused-file:src/b.ts");
1335 }
1336
1337 #[test]
1338 fn compact_ordering_optional_dep_between_devdep_and_enum() {
1339 let root = PathBuf::from("/project");
1340 let mut results = AnalysisResults::default();
1341 results
1342 .unused_dev_dependencies
1343 .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
1344 package_name: "jest".to_string(),
1345 location: DependencyLocation::DevDependencies,
1346 path: root.join("package.json"),
1347 line: 5,
1348 used_in_workspaces: Vec::new(),
1349 }));
1350 results
1351 .unused_optional_dependencies
1352 .push(UnusedOptionalDependencyFinding::with_actions(
1353 UnusedDependency {
1354 package_name: "fsevents".to_string(),
1355 location: DependencyLocation::OptionalDependencies,
1356 path: root.join("package.json"),
1357 line: 12,
1358 used_in_workspaces: Vec::new(),
1359 },
1360 ));
1361 results
1362 .unused_enum_members
1363 .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
1364 path: root.join("src/enums.ts"),
1365 parent_name: "Status".to_string(),
1366 member_name: "Deprecated".to_string(),
1367 kind: MemberKind::EnumMember,
1368 line: 8,
1369 col: 2,
1370 }));
1371
1372 let lines = build_compact_lines(&results, &root);
1373 assert_eq!(lines.len(), 3);
1374 assert!(lines[0].starts_with("unused-devdep:"));
1375 assert!(lines[1].starts_with("unused-optionaldep:"));
1376 assert!(lines[2].starts_with("unused-enum-member:"));
1377 }
1378
1379 #[test]
1380 fn compact_path_outside_root_preserved() {
1381 let root = PathBuf::from("/project");
1382 let mut results = AnalysisResults::default();
1383 results
1384 .unused_files
1385 .push(UnusedFileFinding::with_actions(UnusedFile {
1386 path: PathBuf::from("/other/place/file.ts"),
1387 }));
1388
1389 let lines = build_compact_lines(&results, &root);
1390 assert!(lines[0].contains("/other/place/file.ts"));
1391 }
1392}