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