1use crate::report::sink::outln;
2use std::path::Path;
3
4use fallow_core::duplicates::{CloneFingerprintSet, 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 member in &self.results.unused_store_members {
286 self.lines
287 .push(self.compact_member(&member.member, "unused-store-member"));
288 }
289 for import in &self.results.unresolved_imports {
290 self.lines.push(format!(
291 "unresolved-import:{}:{}:{}",
292 self.rel(&import.import.path),
293 import.import.line,
294 import.import.specifier
295 ));
296 }
297 }
298
299 fn push_secondary_dependency_lines(&mut self) {
300 for dep in &self.results.unlisted_dependencies {
301 self.lines
302 .push(format!("unlisted-dep:{}", dep.dep.package_name));
303 }
304 for dup in &self.results.duplicate_exports {
305 self.lines
306 .push(format!("duplicate-export:{}", dup.export.export_name));
307 }
308 for dep in &self.results.type_only_dependencies {
309 self.lines
310 .push(format!("type-only-dep:{}", dep.dep.package_name));
311 }
312 for dep in &self.results.test_only_dependencies {
313 self.lines
314 .push(format!("test-only-dep:{}", dep.dep.package_name));
315 }
316 }
317
318 fn push_graph_lines(&mut self) {
319 self.push_structure_lines();
320 self.push_framework_lines();
321 self.push_component_lines();
322 self.push_route_lines();
323 self.push_suppression_lines();
324 }
325
326 fn push_structure_lines(&mut self) {
327 for cycle in &self.results.circular_dependencies {
328 self.lines
329 .push(compact_circular_dependency_line(cycle, self.root));
330 }
331 for cycle in &self.results.re_export_cycles {
332 self.lines
333 .push(compact_re_export_cycle_line(cycle, self.root));
334 }
335 for violation in &self.results.boundary_violations {
336 self.lines
337 .push(compact_boundary_violation_line(violation, self.root));
338 }
339 for violation in &self.results.boundary_coverage_violations {
340 self.lines
341 .push(compact_boundary_coverage_line(violation, self.root));
342 }
343 for violation in &self.results.boundary_call_violations {
344 self.lines
345 .push(compact_boundary_call_line(violation, self.root));
346 }
347 for violation in &self.results.policy_violations {
348 self.lines.push(format!(
349 "policy-violation:{}:{}:{} banned by {}/{}",
350 self.rel(&violation.violation.path),
351 violation.violation.line,
352 violation.violation.matched,
353 violation.violation.pack,
354 violation.violation.rule_id,
355 ));
356 }
357 }
358
359 fn push_framework_lines(&mut self) {
360 for finding in &self.results.invalid_client_exports {
361 self.lines.push(format!(
362 "invalid-client-export:{}:{}:{} (from \"{}\")",
363 self.rel(&finding.export.path),
364 finding.export.line,
365 finding.export.export_name,
366 finding.export.directive,
367 ));
368 }
369 for finding in &self.results.mixed_client_server_barrels {
370 self.lines.push(format!(
371 "mixed-client-server-barrel:{}:{}:{} (server-only \"{}\")",
372 self.rel(&finding.barrel.path),
373 finding.barrel.line,
374 finding.barrel.client_origin,
375 finding.barrel.server_origin,
376 ));
377 }
378 for finding in &self.results.misplaced_directives {
379 self.lines.push(format!(
380 "misplaced-directive:{}:{}:{}",
381 self.rel(&finding.directive_site.path),
382 finding.directive_site.line,
383 finding.directive_site.directive,
384 ));
385 }
386 for finding in &self.results.unprovided_injects {
387 self.lines.push(format!(
388 "unprovided-inject:{}:{}:{}",
389 self.rel(&finding.inject.path),
390 finding.inject.line,
391 finding.inject.key_name,
392 ));
393 }
394 }
395
396 fn push_component_lines(&mut self) {
397 for finding in &self.results.unrendered_components {
398 self.lines.push(format!(
399 "unrendered-component:{}:{}:{}",
400 self.rel(&finding.component.path),
401 finding.component.line,
402 finding.component.component_name,
403 ));
404 }
405 for finding in &self.results.unused_component_props {
406 self.lines.push(format!(
407 "unused-component-prop:{}:{}:{}",
408 self.rel(&finding.prop.path),
409 finding.prop.line,
410 finding.prop.prop_name,
411 ));
412 }
413 for finding in &self.results.unused_component_emits {
414 self.lines.push(format!(
415 "unused-component-emit:{}:{}:{}",
416 self.rel(&finding.emit.path),
417 finding.emit.line,
418 finding.emit.emit_name,
419 ));
420 }
421 for finding in &self.results.unused_component_inputs {
422 self.lines.push(format!(
423 "unused-component-input:{}:{}:{}",
424 self.rel(&finding.input.path),
425 finding.input.line,
426 finding.input.input_name,
427 ));
428 }
429 for finding in &self.results.unused_component_outputs {
430 self.lines.push(format!(
431 "unused-component-output:{}:{}:{}",
432 self.rel(&finding.output.path),
433 finding.output.line,
434 finding.output.output_name,
435 ));
436 }
437 for finding in &self.results.unused_svelte_events {
438 self.lines.push(format!(
439 "unused-svelte-event:{}:{}:{}",
440 self.rel(&finding.event.path),
441 finding.event.line,
442 finding.event.event_name,
443 ));
444 }
445 for finding in &self.results.unused_server_actions {
446 self.lines.push(format!(
447 "unused-server-action:{}:{}:{}",
448 self.rel(&finding.action.path),
449 finding.action.line,
450 finding.action.action_name,
451 ));
452 }
453 for finding in &self.results.unused_load_data_keys {
454 self.lines.push(format!(
455 "unused-load-data-key:{}:{}:{}",
456 self.rel(&finding.key.path),
457 finding.key.line,
458 finding.key.key_name,
459 ));
460 }
461 }
462
463 fn push_route_lines(&mut self) {
464 for finding in &self.results.route_collisions {
465 self.lines.push(format!(
466 "route-collision:{}:{} (url {})",
467 self.rel(&finding.collision.path),
468 finding.collision.line,
469 finding.collision.url,
470 ));
471 }
472 for finding in &self.results.dynamic_segment_name_conflicts {
473 self.lines.push(format!(
474 "dynamic-segment-name-conflict:{}:{} ({} at {})",
475 self.rel(&finding.conflict.path),
476 finding.conflict.line,
477 finding.conflict.conflicting_segments.join(" vs "),
478 finding.conflict.position,
479 ));
480 }
481 }
482
483 fn push_suppression_lines(&mut self) {
484 for suppression in &self.results.stale_suppressions {
485 self.lines
486 .push(compact_stale_suppression_line(suppression, self.root));
487 }
488 }
489
490 fn push_workspace_lines(&mut self) {
491 for entry in &self.results.unused_catalog_entries {
492 self.lines.push(format!(
493 "unused-catalog-entry:{}:{}:{}:{}",
494 self.rel(&entry.entry.path),
495 entry.entry.line,
496 entry.entry.catalog_name,
497 entry.entry.entry_name,
498 ));
499 }
500 for group in &self.results.empty_catalog_groups {
501 self.lines.push(format!(
502 "empty-catalog-group:{}:{}:{}",
503 self.rel(&group.group.path),
504 group.group.line,
505 group.group.catalog_name,
506 ));
507 }
508 for finding in &self.results.unresolved_catalog_references {
509 self.lines
510 .push(compact_catalog_reference_line(finding, self.root));
511 }
512 for finding in &self.results.unused_dependency_overrides {
513 self.lines
514 .push(compact_unused_override_line(finding, self.root));
515 }
516 for finding in &self.results.misconfigured_dependency_overrides {
517 self.lines
518 .push(compact_misconfigured_override_line(finding, self.root));
519 }
520 }
521}
522
523pub(super) fn print_grouped_compact(groups: &[ResultGroup], root: &Path) {
527 for group in groups {
528 for line in build_compact_lines(&group.results, root) {
529 outln!("{}\t{line}", group.key);
530 }
531 }
532}
533
534pub(super) fn print_health_compact(report: &crate::health_types::HealthReport, root: &Path) {
535 print_health_score_compact(report);
536 print_vital_signs_compact(report);
537 print_health_findings_compact(&report.findings, root);
538 print_threshold_overrides_compact(&report.threshold_overrides, root);
539 print_file_scores_compact(&report.file_scores, root);
540 print_coverage_gaps_compact(report, root);
541 print_runtime_sections_compact(report, root);
542 print_hotspots_compact(&report.hotspots, root);
543 print_health_trend_compact(report);
544 print_refactoring_targets_compact(&report.targets, root);
545}
546
547fn print_threshold_overrides_compact(
548 entries: &[crate::health_types::ThresholdOverrideState],
549 root: &Path,
550) {
551 for entry in entries {
552 let status = match entry.status {
553 crate::health_types::ThresholdOverrideStatus::Active => "active",
554 crate::health_types::ThresholdOverrideStatus::Stale => "stale",
555 crate::health_types::ThresholdOverrideStatus::NoMatch => "no_match",
556 };
557 let target = entry.path.as_ref().map_or_else(
558 || "no-match".to_string(),
559 |path| {
560 let display = health_compact_path(path, root);
561 entry
562 .function
563 .as_ref()
564 .map_or_else(|| display.clone(), |name| format!("{display}:{name}"))
565 },
566 );
567 let metrics = entry.metrics.map_or(String::new(), |metrics| {
568 let crap = metrics
569 .crap
570 .map_or(String::new(), |value| format!(",crap={value:.1}"));
571 format!(
572 ",cyclomatic={},cognitive={}{}",
573 metrics.cyclomatic, metrics.cognitive, crap
574 )
575 });
576 outln!(
577 "threshold-override:{}:{}:{}{}",
578 entry.override_index,
579 status,
580 target,
581 metrics
582 );
583 }
584}
585
586fn print_health_score_compact(report: &crate::health_types::HealthReport) {
587 if let Some(ref hs) = report.health_score {
588 outln!("health-score:{:.1}:{}", hs.score, hs.grade);
589 }
590}
591
592fn print_vital_signs_compact(report: &crate::health_types::HealthReport) {
593 if let Some(ref vs) = report.vital_signs {
594 let mut parts = Vec::new();
595 if vs.total_loc > 0 {
596 parts.push(format!("total_loc={}", vs.total_loc));
597 }
598 parts.push(format!("avg_cyclomatic={:.1}", vs.avg_cyclomatic));
599 parts.push(format!("p90_cyclomatic={}", vs.p90_cyclomatic));
600 if let Some(v) = vs.dead_file_pct {
601 parts.push(format!("dead_file_pct={v:.1}"));
602 }
603 if let Some(v) = vs.dead_export_pct {
604 parts.push(format!("dead_export_pct={v:.1}"));
605 }
606 if let Some(v) = vs.maintainability_avg {
607 parts.push(format!("maintainability_avg={v:.1}"));
608 }
609 if let Some(v) = vs.hotspot_count {
610 parts.push(format!("hotspot_count={v}"));
611 }
612 if let Some(v) = vs.circular_dep_count {
613 parts.push(format!("circular_dep_count={v}"));
614 }
615 if let Some(v) = vs.unused_dep_count {
616 parts.push(format!("unused_dep_count={v}"));
617 }
618 outln!("vital-signs:{}", parts.join(","));
619 }
620}
621
622fn health_compact_path(path: &Path, root: &Path) -> String {
623 normalize_uri(&relative_path(path, root).display().to_string())
624}
625
626fn print_health_findings_compact(findings: &[crate::health_types::HealthFinding], root: &Path) {
627 for finding in findings {
628 let relative = health_compact_path(&finding.path, root);
629 let severity = match finding.severity {
630 crate::health_types::FindingSeverity::Critical => "critical",
631 crate::health_types::FindingSeverity::High => "high",
632 crate::health_types::FindingSeverity::Moderate => "moderate",
633 };
634 let crap_suffix = match finding.crap {
635 Some(crap) => {
636 let coverage = finding
637 .coverage_pct
638 .map(|pct| format!(",coverage_pct={pct:.1}"))
639 .unwrap_or_default();
640 format!(",crap={crap:.1}{coverage}")
641 }
642 None => String::new(),
643 };
644 outln!(
645 "high-complexity:{}:{}:{}:cyclomatic={},cognitive={},severity={}{}",
646 relative,
647 finding.line,
648 finding.name,
649 finding.cyclomatic,
650 finding.cognitive,
651 severity,
652 crap_suffix,
653 );
654 }
655}
656
657fn print_file_scores_compact(scores: &[crate::health_types::FileHealthScore], root: &Path) {
658 for score in scores {
659 let relative = health_compact_path(&score.path, root);
660 outln!(
661 "file-score:{}:mi={:.1},fan_in={},fan_out={},dead={:.2},density={:.2},crap_max={:.1},crap_above={}",
662 relative,
663 score.maintainability_index,
664 score.fan_in,
665 score.fan_out,
666 score.dead_code_ratio,
667 score.complexity_density,
668 score.crap_max,
669 score.crap_above_threshold,
670 );
671 }
672}
673
674fn print_coverage_gaps_compact(report: &crate::health_types::HealthReport, root: &Path) {
675 if let Some(ref gaps) = report.coverage_gaps {
676 outln!(
677 "coverage-gap-summary:runtime_files={},covered_files={},file_coverage_pct={:.1},untested_files={},untested_exports={}",
678 gaps.summary.runtime_files,
679 gaps.summary.covered_files,
680 gaps.summary.file_coverage_pct,
681 gaps.summary.untested_files,
682 gaps.summary.untested_exports,
683 );
684 for item in &gaps.files {
685 let relative = health_compact_path(&item.file.path, root);
686 outln!(
687 "untested-file:{}:value_exports={}",
688 relative,
689 item.file.value_export_count,
690 );
691 }
692 for item in &gaps.exports {
693 let relative = health_compact_path(&item.export.path, root);
694 outln!(
695 "untested-export:{}:{}:{}",
696 relative,
697 item.export.line,
698 item.export.export_name,
699 );
700 }
701 }
702}
703
704fn print_runtime_sections_compact(report: &crate::health_types::HealthReport, root: &Path) {
705 if let Some(ref production) = report.runtime_coverage {
706 for line in build_runtime_coverage_compact_lines(production, root) {
707 outln!("{line}");
708 }
709 }
710 if let Some(ref intelligence) = report.coverage_intelligence {
711 for line in build_coverage_intelligence_compact_lines(intelligence, root) {
712 outln!("{line}");
713 }
714 }
715}
716
717fn compact_ownership_suffix(ownership: Option<&crate::health_types::OwnershipMetrics>) -> String {
718 ownership.map_or_else(String::new, |o| {
719 let mut parts = vec![
720 format!("bus={}", o.bus_factor),
721 format!("contributors={}", o.contributor_count),
722 format!("top={}", o.top_contributor.identifier),
723 format!("top_share={:.3}", o.top_contributor.share),
724 ];
725 if let Some(owner) = &o.declared_owner {
726 parts.push(format!("owner={owner}"));
727 }
728 if let Some(unowned) = o.unowned {
729 parts.push(format!("unowned={unowned}"));
730 }
731 let state = match o.ownership_state {
732 crate::health_types::OwnershipState::Active => "active",
733 crate::health_types::OwnershipState::Unowned => "unowned",
734 crate::health_types::OwnershipState::DeclaredInactive => "declared_inactive",
735 crate::health_types::OwnershipState::Drifting => "drifting",
736 };
737 parts.push(format!("ownership_state={state}"));
738 if o.drift {
739 parts.push("drift=true".to_string());
740 }
741 format!(",{}", parts.join(","))
742 })
743}
744
745fn print_hotspots_compact(hotspots: &[crate::health_types::HotspotFinding], root: &Path) {
746 for entry in hotspots {
747 let relative = health_compact_path(&entry.path, root);
748 let ownership_suffix = compact_ownership_suffix(entry.ownership.as_ref());
749 outln!(
750 "hotspot:{}:score={:.1},commits={},churn={},density={:.2},fan_in={},trend={}{}",
751 relative,
752 entry.score,
753 entry.commits,
754 entry.lines_added + entry.lines_deleted,
755 entry.complexity_density,
756 entry.fan_in,
757 entry.trend,
758 ownership_suffix,
759 );
760 }
761}
762
763fn print_health_trend_compact(report: &crate::health_types::HealthReport) {
764 if let Some(ref trend) = report.health_trend {
765 outln!(
766 "trend:overall:direction={}",
767 trend.overall_direction.label()
768 );
769 for m in &trend.metrics {
770 outln!(
771 "trend:{}:previous={:.1},current={:.1},delta={:+.1},direction={}",
772 m.name,
773 m.previous,
774 m.current,
775 m.delta,
776 m.direction.label(),
777 );
778 }
779 }
780}
781
782fn print_refactoring_targets_compact(
783 targets: &[crate::health_types::RefactoringTargetFinding],
784 root: &Path,
785) {
786 for target in targets {
787 let relative = health_compact_path(&target.path, root);
788 let category = target.category.compact_label();
789 let effort = target.effort.label();
790 let confidence = target.confidence.label();
791 outln!(
792 "refactoring-target:{}:priority={:.1},efficiency={:.1},category={},effort={},confidence={}:{}",
793 relative,
794 target.priority,
795 target.efficiency,
796 category,
797 effort,
798 confidence,
799 target.recommendation,
800 );
801 }
802}
803
804fn build_runtime_coverage_compact_lines(
805 production: &crate::health_types::RuntimeCoverageReport,
806 root: &Path,
807) -> Vec<String> {
808 let mut lines = vec![format!(
809 "runtime-coverage-summary:functions_tracked={},functions_hit={},functions_unhit={},functions_untracked={},coverage_percent={:.1},trace_count={},period_days={},deployments_seen={}",
810 production.summary.functions_tracked,
811 production.summary.functions_hit,
812 production.summary.functions_unhit,
813 production.summary.functions_untracked,
814 production.summary.coverage_percent,
815 production.summary.trace_count,
816 production.summary.period_days,
817 production.summary.deployments_seen,
818 )];
819 for finding in &production.findings {
820 let relative = normalize_uri(&relative_path(&finding.path, root).display().to_string());
821 let invocations = finding
822 .invocations
823 .map_or_else(|| "null".to_owned(), |hits| hits.to_string());
824 lines.push(format!(
825 "runtime-coverage:{}:{}:{}:id={},verdict={},invocations={},confidence={}",
826 relative,
827 finding.line,
828 finding.function,
829 finding.id,
830 finding.verdict,
831 invocations,
832 finding.confidence,
833 ));
834 }
835 for entry in &production.hot_paths {
836 let relative = normalize_uri(&relative_path(&entry.path, root).display().to_string());
837 lines.push(format!(
838 "production-hot-path:{}:{}:{}:id={},invocations={},percentile={}",
839 relative, entry.line, entry.function, entry.id, entry.invocations, entry.percentile,
840 ));
841 }
842 lines
843}
844
845fn build_coverage_intelligence_compact_lines(
846 intelligence: &crate::health_types::CoverageIntelligenceReport,
847 root: &Path,
848) -> Vec<String> {
849 let mut lines = vec![format!(
850 "coverage-intelligence-summary:verdict={},findings={},risky_changes={},high_confidence_deletes={},review_required={},refactor_carefully={},skipped_ambiguous_matches={}",
851 intelligence.verdict,
852 intelligence.summary.findings,
853 intelligence.summary.risky_changes,
854 intelligence.summary.high_confidence_deletes,
855 intelligence.summary.review_required,
856 intelligence.summary.refactor_carefully,
857 intelligence.summary.skipped_ambiguous_matches,
858 )];
859 for finding in &intelligence.findings {
860 let relative = normalize_uri(&relative_path(&finding.path, root).display().to_string());
861 let identity = finding.identity.as_deref().unwrap_or("-");
862 let signals = finding
863 .signals
864 .iter()
865 .map(ToString::to_string)
866 .collect::<Vec<_>>()
867 .join("+");
868 lines.push(format!(
869 "coverage-intelligence:{}:{}:{}:id={},verdict={},recommendation={},confidence={},signals={}",
870 relative,
871 finding.line,
872 identity,
873 finding.id,
874 finding.verdict,
875 finding.recommendation,
876 finding.confidence,
877 signals,
878 ));
879 }
880 lines
881}
882
883pub(super) fn print_duplication_compact(report: &DuplicationReport, root: &Path) {
884 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
885 for (index, group) in report.clone_groups.iter().enumerate() {
886 let fingerprint = fingerprints.fingerprint_for_group(group);
887 for instance in &group.instances {
888 outln!(
889 "code-duplication:{}:{}-{}:fingerprint={},group={},tokens={},lines={},instances={}",
890 compact_path(&instance.file, root),
891 instance.start_line,
892 instance.end_line,
893 fingerprint,
894 index + 1,
895 group.token_count,
896 group.line_count,
897 group.instances.len(),
898 );
899 }
900 }
901}
902
903#[cfg(test)]
904mod tests {
905 use super::*;
906 use crate::health_types::{
907 RuntimeCoverageConfidence, RuntimeCoverageDataSource, RuntimeCoverageEvidence,
908 RuntimeCoverageFinding, RuntimeCoverageHotPath, RuntimeCoverageReport,
909 RuntimeCoverageReportVerdict, RuntimeCoverageSchemaVersion, RuntimeCoverageSummary,
910 RuntimeCoverageVerdict,
911 };
912 use crate::report::test_helpers::sample_results;
913 use fallow_core::extract::MemberKind;
914 use fallow_core::results::*;
915 use std::path::PathBuf;
916
917 #[test]
918 fn compact_empty_results_no_lines() {
919 let root = PathBuf::from("/project");
920 let results = AnalysisResults::default();
921 let lines = build_compact_lines(&results, &root);
922 assert!(lines.is_empty());
923 }
924
925 #[test]
926 fn compact_unused_file_format() {
927 let root = PathBuf::from("/project");
928 let mut results = AnalysisResults::default();
929 results
930 .unused_files
931 .push(UnusedFileFinding::with_actions(UnusedFile {
932 path: root.join("src/dead.ts"),
933 }));
934
935 let lines = build_compact_lines(&results, &root);
936 assert_eq!(lines.len(), 1);
937 assert_eq!(lines[0], "unused-file:src/dead.ts");
938 }
939
940 #[test]
941 fn compact_unused_export_format() {
942 let root = PathBuf::from("/project");
943 let mut results = AnalysisResults::default();
944 results
945 .unused_exports
946 .push(UnusedExportFinding::with_actions(UnusedExport {
947 path: root.join("src/utils.ts"),
948 export_name: "helperFn".to_string(),
949 is_type_only: false,
950 line: 10,
951 col: 4,
952 span_start: 120,
953 is_re_export: false,
954 }));
955
956 let lines = build_compact_lines(&results, &root);
957 assert_eq!(lines[0], "unused-export:src/utils.ts:10:helperFn");
958 }
959
960 #[test]
961 fn compact_health_includes_runtime_coverage_lines() {
962 let root = PathBuf::from("/project");
963 let report = crate::health_types::HealthReport {
964 runtime_coverage: Some(RuntimeCoverageReport {
965 schema_version: RuntimeCoverageSchemaVersion::V1,
966 verdict: RuntimeCoverageReportVerdict::ColdCodeDetected,
967 signals: Vec::new(),
968 summary: RuntimeCoverageSummary {
969 data_source: RuntimeCoverageDataSource::Local,
970 last_received_at: None,
971 functions_tracked: 4,
972 functions_hit: 2,
973 functions_unhit: 1,
974 functions_untracked: 1,
975 coverage_percent: 50.0,
976 trace_count: 512,
977 period_days: 7,
978 deployments_seen: 2,
979 capture_quality: None,
980 },
981 findings: vec![RuntimeCoverageFinding {
982 id: "fallow:prod:deadbeef".to_owned(),
983 stable_id: None,
984 path: root.join("src/cold.ts"),
985 function: "coldPath".to_owned(),
986 line: 14,
987 verdict: RuntimeCoverageVerdict::ReviewRequired,
988 invocations: Some(0),
989 confidence: RuntimeCoverageConfidence::Medium,
990 evidence: RuntimeCoverageEvidence {
991 static_status: "used".to_owned(),
992 test_coverage: "not_covered".to_owned(),
993 v8_tracking: "tracked".to_owned(),
994 untracked_reason: None,
995 observation_days: 7,
996 deployments_observed: 2,
997 },
998 actions: vec![],
999 source_hash: None,
1000 }],
1001 hot_paths: vec![RuntimeCoverageHotPath {
1002 id: "fallow:hot:cafebabe".to_owned(),
1003 stable_id: None,
1004 path: root.join("src/hot.ts"),
1005 function: "hotPath".to_owned(),
1006 line: 3,
1007 end_line: 9,
1008 invocations: 250,
1009 percentile: 99,
1010 actions: vec![],
1011 }],
1012 blast_radius: vec![],
1013 importance: vec![],
1014 watermark: None,
1015 warnings: vec![],
1016 }),
1017 ..Default::default()
1018 };
1019
1020 let lines = build_runtime_coverage_compact_lines(
1021 report
1022 .runtime_coverage
1023 .as_ref()
1024 .expect("runtime coverage should be set"),
1025 &root,
1026 );
1027 assert_eq!(
1028 lines[0],
1029 "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"
1030 );
1031 assert_eq!(
1032 lines[1],
1033 "runtime-coverage:src/cold.ts:14:coldPath:id=fallow:prod:deadbeef,verdict=review_required,invocations=0,confidence=medium"
1034 );
1035 assert_eq!(
1036 lines[2],
1037 "production-hot-path:src/hot.ts:3:hotPath:id=fallow:hot:cafebabe,invocations=250,percentile=99"
1038 );
1039 }
1040
1041 #[test]
1042 fn compact_health_includes_coverage_intelligence_lines() {
1043 use crate::health_types::{
1044 CoverageIntelligenceAction, CoverageIntelligenceConfidence,
1045 CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
1046 CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
1047 CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
1048 CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
1049 };
1050
1051 let root = PathBuf::from("/project");
1052 let report = CoverageIntelligenceReport {
1053 schema_version: CoverageIntelligenceSchemaVersion::V1,
1054 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
1055 summary: CoverageIntelligenceSummary {
1056 findings: 1,
1057 high_confidence_deletes: 1,
1058 ..Default::default()
1059 },
1060 findings: vec![CoverageIntelligenceFinding {
1061 id: "fallow:coverage-intel:abc123".to_owned(),
1062 path: root.join("src/dead.ts"),
1063 identity: Some("deadPath".to_owned()),
1064 line: 9,
1065 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
1066 signals: vec![
1067 CoverageIntelligenceSignal::StaticUnused,
1068 CoverageIntelligenceSignal::RuntimeCold,
1069 ],
1070 recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
1071 confidence: CoverageIntelligenceConfidence::High,
1072 related_ids: vec!["fallow:prod:deadbeef".to_owned()],
1073 evidence: CoverageIntelligenceEvidence {
1074 match_confidence: CoverageIntelligenceMatchConfidence::Direct,
1075 ..Default::default()
1076 },
1077 actions: vec![CoverageIntelligenceAction {
1078 kind: "delete-after-confirming-owner".to_owned(),
1079 description: "Confirm ownership".to_owned(),
1080 auto_fixable: false,
1081 }],
1082 }],
1083 };
1084
1085 let lines = build_coverage_intelligence_compact_lines(&report, &root);
1086 assert_eq!(
1087 lines[0],
1088 "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"
1089 );
1090 assert_eq!(
1091 lines[1],
1092 "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"
1093 );
1094 }
1095
1096 #[test]
1097 fn compact_unused_type_format() {
1098 let root = PathBuf::from("/project");
1099 let mut results = AnalysisResults::default();
1100 results
1101 .unused_types
1102 .push(UnusedTypeFinding::with_actions(UnusedExport {
1103 path: root.join("src/types.ts"),
1104 export_name: "OldType".to_string(),
1105 is_type_only: true,
1106 line: 5,
1107 col: 0,
1108 span_start: 60,
1109 is_re_export: false,
1110 }));
1111
1112 let lines = build_compact_lines(&results, &root);
1113 assert_eq!(lines[0], "unused-type:src/types.ts:5:OldType");
1114 }
1115
1116 #[test]
1117 fn compact_unused_dep_format() {
1118 let root = PathBuf::from("/project");
1119 let mut results = AnalysisResults::default();
1120 results
1121 .unused_dependencies
1122 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1123 package_name: "lodash".to_string(),
1124 location: DependencyLocation::Dependencies,
1125 path: root.join("package.json"),
1126 line: 5,
1127 used_in_workspaces: Vec::new(),
1128 }));
1129
1130 let lines = build_compact_lines(&results, &root);
1131 assert_eq!(lines[0], "unused-dep:lodash");
1132 }
1133
1134 #[test]
1135 fn compact_unused_devdep_format() {
1136 let root = PathBuf::from("/project");
1137 let mut results = AnalysisResults::default();
1138 results
1139 .unused_dev_dependencies
1140 .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
1141 package_name: "jest".to_string(),
1142 location: DependencyLocation::DevDependencies,
1143 path: root.join("package.json"),
1144 line: 5,
1145 used_in_workspaces: Vec::new(),
1146 }));
1147
1148 let lines = build_compact_lines(&results, &root);
1149 assert_eq!(lines[0], "unused-devdep:jest");
1150 }
1151
1152 #[test]
1153 fn compact_unused_enum_member_format() {
1154 let root = PathBuf::from("/project");
1155 let mut results = AnalysisResults::default();
1156 results
1157 .unused_enum_members
1158 .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
1159 path: root.join("src/enums.ts"),
1160 parent_name: "Status".to_string(),
1161 member_name: "Deprecated".to_string(),
1162 kind: MemberKind::EnumMember,
1163 line: 8,
1164 col: 2,
1165 }));
1166
1167 let lines = build_compact_lines(&results, &root);
1168 assert_eq!(
1169 lines[0],
1170 "unused-enum-member:src/enums.ts:8:Status.Deprecated"
1171 );
1172 }
1173
1174 #[test]
1175 fn compact_unused_class_member_format() {
1176 let root = PathBuf::from("/project");
1177 let mut results = AnalysisResults::default();
1178 results
1179 .unused_class_members
1180 .push(UnusedClassMemberFinding::with_actions(UnusedMember {
1181 path: root.join("src/service.ts"),
1182 parent_name: "UserService".to_string(),
1183 member_name: "legacyMethod".to_string(),
1184 kind: MemberKind::ClassMethod,
1185 line: 42,
1186 col: 4,
1187 }));
1188
1189 let lines = build_compact_lines(&results, &root);
1190 assert_eq!(
1191 lines[0],
1192 "unused-class-member:src/service.ts:42:UserService.legacyMethod"
1193 );
1194 }
1195
1196 #[test]
1197 fn compact_unresolved_import_format() {
1198 let root = PathBuf::from("/project");
1199 let mut results = AnalysisResults::default();
1200 results
1201 .unresolved_imports
1202 .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
1203 path: root.join("src/app.ts"),
1204 specifier: "./missing-module".to_string(),
1205 line: 3,
1206 col: 0,
1207 specifier_col: 0,
1208 }));
1209
1210 let lines = build_compact_lines(&results, &root);
1211 assert_eq!(lines[0], "unresolved-import:src/app.ts:3:./missing-module");
1212 }
1213
1214 #[test]
1215 fn compact_unlisted_dep_format() {
1216 let root = PathBuf::from("/project");
1217 let mut results = AnalysisResults::default();
1218 results
1219 .unlisted_dependencies
1220 .push(UnlistedDependencyFinding::with_actions(
1221 UnlistedDependency {
1222 package_name: "chalk".to_string(),
1223 imported_from: vec![],
1224 },
1225 ));
1226
1227 let lines = build_compact_lines(&results, &root);
1228 assert_eq!(lines[0], "unlisted-dep:chalk");
1229 }
1230
1231 #[test]
1232 fn compact_duplicate_export_format() {
1233 let root = PathBuf::from("/project");
1234 let mut results = AnalysisResults::default();
1235 results
1236 .duplicate_exports
1237 .push(DuplicateExportFinding::with_actions(DuplicateExport {
1238 export_name: "Config".to_string(),
1239 locations: vec![
1240 DuplicateLocation {
1241 path: root.join("src/a.ts"),
1242 line: 15,
1243 col: 0,
1244 },
1245 DuplicateLocation {
1246 path: root.join("src/b.ts"),
1247 line: 30,
1248 col: 0,
1249 },
1250 ],
1251 }));
1252
1253 let lines = build_compact_lines(&results, &root);
1254 assert_eq!(lines[0], "duplicate-export:Config");
1255 }
1256
1257 #[test]
1258 fn compact_all_issue_types_produce_lines() {
1259 let root = PathBuf::from("/project");
1260 let results = sample_results(&root);
1261 let lines = build_compact_lines(&results, &root);
1262
1263 assert_eq!(lines.len(), 26);
1264
1265 assert!(lines[0].starts_with("unused-file:"));
1266 assert!(lines[1].starts_with("unused-export:"));
1267 assert!(lines[2].starts_with("unused-type:"));
1268 assert!(lines[3].starts_with("unused-dep:"));
1269 assert!(lines[4].starts_with("unused-devdep:"));
1270 assert!(lines[5].starts_with("unused-optionaldep:"));
1271 assert!(lines[6].starts_with("unused-enum-member:"));
1272 assert!(lines[7].starts_with("unused-class-member:"));
1273 assert!(lines[8].starts_with("unused-store-member:"));
1274 assert!(lines[9].starts_with("unresolved-import:"));
1275 assert!(lines[10].starts_with("unlisted-dep:"));
1276 assert!(lines[11].starts_with("duplicate-export:"));
1277 assert!(lines[12].starts_with("type-only-dep:"));
1278 assert!(lines[13].starts_with("test-only-dep:"));
1279 assert!(lines[14].starts_with("circular-dependency:"));
1280 assert!(lines[15].starts_with("boundary-violation:"));
1281 assert!(lines.iter().any(|l| l.starts_with("unprovided-inject:")));
1282 assert!(lines.iter().any(|l| l.starts_with("unrendered-component:")));
1283 assert!(
1284 lines
1285 .iter()
1286 .any(|l| l.starts_with("unused-component-prop:"))
1287 );
1288 assert!(
1289 lines
1290 .iter()
1291 .any(|l| l.starts_with("unused-component-emit:"))
1292 );
1293 assert!(
1294 lines
1295 .iter()
1296 .any(|l| l.starts_with("unused-component-input:"))
1297 );
1298 assert!(
1299 lines
1300 .iter()
1301 .any(|l| l.starts_with("unused-component-output:"))
1302 );
1303 assert!(lines.iter().any(|l| l.starts_with("unused-svelte-event:")));
1304 assert!(lines.iter().any(|l| l.starts_with("unused-server-action:")));
1305 assert!(lines.iter().any(|l| l.starts_with("unused-load-data-key:")));
1306 }
1307
1308 #[test]
1309 fn compact_covers_api_and_boundary_variants() {
1310 let root = PathBuf::from("/project");
1311 let mut results = AnalysisResults::default();
1312 results
1313 .private_type_leaks
1314 .push(PrivateTypeLeakFinding::with_actions(PrivateTypeLeak {
1315 path: root.join("src/api.ts"),
1316 export_name: "createApi".to_owned(),
1317 type_name: "InternalShape".to_owned(),
1318 line: 12,
1319 col: 4,
1320 span_start: 100,
1321 }));
1322 results
1323 .circular_dependencies
1324 .push(CircularDependencyFinding::with_actions(
1325 CircularDependency {
1326 files: vec![
1327 root.join("packages/a/index.ts"),
1328 root.join("packages/b/index.ts"),
1329 ],
1330 length: 2,
1331 line: 3,
1332 col: 0,
1333 edges: Vec::new(),
1334 is_cross_package: true,
1335 },
1336 ));
1337 results
1338 .boundary_coverage_violations
1339 .push(BoundaryCoverageViolationFinding::with_actions(
1340 BoundaryCoverageViolation {
1341 path: root.join("src/unmatched.ts"),
1342 line: 1,
1343 col: 0,
1344 },
1345 ));
1346 results
1347 .boundary_call_violations
1348 .push(BoundaryCallViolationFinding::with_actions(
1349 BoundaryCallViolation {
1350 path: root.join("src/ui/button.ts"),
1351 line: 20,
1352 col: 6,
1353 zone: "ui".to_owned(),
1354 callee: "child_process.exec".to_owned(),
1355 pattern: "child_process.*".to_owned(),
1356 },
1357 ));
1358
1359 let lines = build_compact_lines(&results, &root);
1360
1361 assert_eq!(
1362 lines[0],
1363 "private-type-leak:src/api.ts:12:createApi->InternalShape"
1364 );
1365 assert!(lines[1].contains(" (cross-package)"));
1366 assert_eq!(
1367 lines[2],
1368 "boundary-coverage:src/unmatched.ts:1:no matching boundary zone"
1369 );
1370 assert_eq!(
1371 lines[3],
1372 "boundary-call:src/ui/button.ts:20:child_process.exec forbidden in zone ui (pattern child_process.*)"
1373 );
1374 }
1375
1376 #[test]
1377 fn compact_strips_root_prefix_from_paths() {
1378 let root = PathBuf::from("/project");
1379 let mut results = AnalysisResults::default();
1380 results
1381 .unused_files
1382 .push(UnusedFileFinding::with_actions(UnusedFile {
1383 path: PathBuf::from("/project/src/deep/nested/file.ts"),
1384 }));
1385
1386 let lines = build_compact_lines(&results, &root);
1387 assert_eq!(lines[0], "unused-file:src/deep/nested/file.ts");
1388 }
1389
1390 #[test]
1391 fn compact_re_export_tagged_correctly() {
1392 let root = PathBuf::from("/project");
1393 let mut results = AnalysisResults::default();
1394 results
1395 .unused_exports
1396 .push(UnusedExportFinding::with_actions(UnusedExport {
1397 path: root.join("src/index.ts"),
1398 export_name: "reExported".to_string(),
1399 is_type_only: false,
1400 line: 1,
1401 col: 0,
1402 span_start: 0,
1403 is_re_export: true,
1404 }));
1405
1406 let lines = build_compact_lines(&results, &root);
1407 assert_eq!(lines[0], "unused-re-export:src/index.ts:1:reExported");
1408 }
1409
1410 #[test]
1411 fn compact_type_re_export_tagged_correctly() {
1412 let root = PathBuf::from("/project");
1413 let mut results = AnalysisResults::default();
1414 results
1415 .unused_types
1416 .push(UnusedTypeFinding::with_actions(UnusedExport {
1417 path: root.join("src/index.ts"),
1418 export_name: "ReExportedType".to_string(),
1419 is_type_only: true,
1420 line: 3,
1421 col: 0,
1422 span_start: 0,
1423 is_re_export: true,
1424 }));
1425
1426 let lines = build_compact_lines(&results, &root);
1427 assert_eq!(
1428 lines[0],
1429 "unused-re-export-type:src/index.ts:3:ReExportedType"
1430 );
1431 }
1432
1433 #[test]
1434 fn compact_unused_optional_dep_format() {
1435 let root = PathBuf::from("/project");
1436 let mut results = AnalysisResults::default();
1437 results
1438 .unused_optional_dependencies
1439 .push(UnusedOptionalDependencyFinding::with_actions(
1440 UnusedDependency {
1441 package_name: "fsevents".to_string(),
1442 location: DependencyLocation::OptionalDependencies,
1443 path: root.join("package.json"),
1444 line: 12,
1445 used_in_workspaces: Vec::new(),
1446 },
1447 ));
1448
1449 let lines = build_compact_lines(&results, &root);
1450 assert_eq!(lines[0], "unused-optionaldep:fsevents");
1451 }
1452
1453 #[test]
1454 fn compact_circular_dependency_format() {
1455 let root = PathBuf::from("/project");
1456 let mut results = AnalysisResults::default();
1457 results
1458 .circular_dependencies
1459 .push(CircularDependencyFinding::with_actions(
1460 CircularDependency {
1461 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1462 length: 2,
1463 line: 3,
1464 col: 0,
1465 edges: Vec::new(),
1466 is_cross_package: false,
1467 },
1468 ));
1469
1470 let lines = build_compact_lines(&results, &root);
1471 assert_eq!(lines.len(), 1);
1472 assert!(lines[0].starts_with("circular-dependency:src/a.ts:3:"));
1473 assert!(lines[0].contains("src/a.ts"));
1474 assert!(lines[0].contains("src/b.ts"));
1475 assert!(lines[0].contains("\u{2192}"));
1476 }
1477
1478 #[test]
1479 fn compact_circular_dependency_closes_cycle() {
1480 let root = PathBuf::from("/project");
1481 let mut results = AnalysisResults::default();
1482 results
1483 .circular_dependencies
1484 .push(CircularDependencyFinding::with_actions(
1485 CircularDependency {
1486 files: vec![
1487 root.join("src/a.ts"),
1488 root.join("src/b.ts"),
1489 root.join("src/c.ts"),
1490 ],
1491 length: 3,
1492 line: 1,
1493 col: 0,
1494 edges: Vec::new(),
1495 is_cross_package: false,
1496 },
1497 ));
1498
1499 let lines = build_compact_lines(&results, &root);
1500 let chain_part = lines[0].split(':').next_back().unwrap();
1501 let parts: Vec<&str> = chain_part.split(" \u{2192} ").collect();
1502 assert_eq!(parts.len(), 4);
1503 assert_eq!(parts[0], parts[3]); }
1505
1506 #[test]
1507 fn compact_type_only_dep_format() {
1508 let root = PathBuf::from("/project");
1509 let mut results = AnalysisResults::default();
1510 results
1511 .type_only_dependencies
1512 .push(TypeOnlyDependencyFinding::with_actions(
1513 TypeOnlyDependency {
1514 package_name: "zod".to_string(),
1515 path: root.join("package.json"),
1516 line: 8,
1517 },
1518 ));
1519
1520 let lines = build_compact_lines(&results, &root);
1521 assert_eq!(lines[0], "type-only-dep:zod");
1522 }
1523
1524 #[test]
1525 fn compact_multiple_unused_files() {
1526 let root = PathBuf::from("/project");
1527 let mut results = AnalysisResults::default();
1528 results
1529 .unused_files
1530 .push(UnusedFileFinding::with_actions(UnusedFile {
1531 path: root.join("src/a.ts"),
1532 }));
1533 results
1534 .unused_files
1535 .push(UnusedFileFinding::with_actions(UnusedFile {
1536 path: root.join("src/b.ts"),
1537 }));
1538
1539 let lines = build_compact_lines(&results, &root);
1540 assert_eq!(lines.len(), 2);
1541 assert_eq!(lines[0], "unused-file:src/a.ts");
1542 assert_eq!(lines[1], "unused-file:src/b.ts");
1543 }
1544
1545 #[test]
1546 fn compact_ordering_optional_dep_between_devdep_and_enum() {
1547 let root = PathBuf::from("/project");
1548 let mut results = AnalysisResults::default();
1549 results
1550 .unused_dev_dependencies
1551 .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
1552 package_name: "jest".to_string(),
1553 location: DependencyLocation::DevDependencies,
1554 path: root.join("package.json"),
1555 line: 5,
1556 used_in_workspaces: Vec::new(),
1557 }));
1558 results
1559 .unused_optional_dependencies
1560 .push(UnusedOptionalDependencyFinding::with_actions(
1561 UnusedDependency {
1562 package_name: "fsevents".to_string(),
1563 location: DependencyLocation::OptionalDependencies,
1564 path: root.join("package.json"),
1565 line: 12,
1566 used_in_workspaces: Vec::new(),
1567 },
1568 ));
1569 results
1570 .unused_enum_members
1571 .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
1572 path: root.join("src/enums.ts"),
1573 parent_name: "Status".to_string(),
1574 member_name: "Deprecated".to_string(),
1575 kind: MemberKind::EnumMember,
1576 line: 8,
1577 col: 2,
1578 }));
1579
1580 let lines = build_compact_lines(&results, &root);
1581 assert_eq!(lines.len(), 3);
1582 assert!(lines[0].starts_with("unused-devdep:"));
1583 assert!(lines[1].starts_with("unused-optionaldep:"));
1584 assert!(lines[2].starts_with("unused-enum-member:"));
1585 }
1586
1587 #[test]
1588 fn compact_path_outside_root_preserved() {
1589 let root = PathBuf::from("/project");
1590 let mut results = AnalysisResults::default();
1591 results
1592 .unused_files
1593 .push(UnusedFileFinding::with_actions(UnusedFile {
1594 path: PathBuf::from("/other/place/file.ts"),
1595 }));
1596
1597 let lines = build_compact_lines(&results, &root);
1598 assert!(lines[0].contains("/other/place/file.ts"));
1599 }
1600}