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