1use crate::report::sink::outln;
2use std::path::Path;
3
4use fallow_core::duplicates::DuplicationReport;
5use fallow_core::results::{AnalysisResults, UnusedExport, UnusedMember};
6
7use super::grouping::ResultGroup;
8use super::{normalize_uri, relative_path};
9
10pub(super) fn print_compact(results: &AnalysisResults, root: &Path) {
11 for line in build_compact_lines(results, root) {
12 outln!("{line}");
13 }
14}
15
16fn compact_path(path: &Path, root: &Path) -> String {
17 normalize_uri(&relative_path(path, root).display().to_string())
18}
19
20fn compact_circular_dependency_line(
21 cycle: &fallow_core::results::CircularDependencyFinding,
22 root: &Path,
23) -> String {
24 let chain: Vec<String> = cycle
25 .cycle
26 .files
27 .iter()
28 .map(|path| compact_path(path, root))
29 .collect();
30 let mut display_chain = chain.clone();
31 if let Some(first) = chain.first() {
32 display_chain.push(first.clone());
33 }
34 let first_file = chain.first().map_or_else(String::new, Clone::clone);
35 let cross_pkg_tag = if cycle.cycle.is_cross_package {
36 " (cross-package)"
37 } else {
38 ""
39 };
40 format!(
41 "circular-dependency:{}:{}:{}{}",
42 first_file,
43 cycle.cycle.line,
44 display_chain.join(" \u{2192} "),
45 cross_pkg_tag
46 )
47}
48
49fn compact_re_export_cycle_line(
50 cycle: &fallow_core::results::ReExportCycleFinding,
51 root: &Path,
52) -> String {
53 let chain: Vec<String> = cycle
54 .cycle
55 .files
56 .iter()
57 .map(|path| compact_path(path, root))
58 .collect();
59 let first_file = chain.first().map_or_else(String::new, Clone::clone);
60 let kind_tag = match cycle.cycle.kind {
61 fallow_core::results::ReExportCycleKind::SelfLoop => " (self-loop)",
62 fallow_core::results::ReExportCycleKind::MultiNode => "",
63 };
64 format!(
65 "re-export-cycle:{}:{}{}",
66 first_file,
67 chain.join(" <-> "),
68 kind_tag
69 )
70}
71
72fn compact_boundary_violation_line(
73 item: &fallow_core::results::BoundaryViolationFinding,
74 root: &Path,
75) -> String {
76 format!(
77 "boundary-violation:{}:{}:{} -> {} ({} -> {})",
78 compact_path(&item.violation.from_path, root),
79 item.violation.line,
80 compact_path(&item.violation.from_path, root),
81 compact_path(&item.violation.to_path, root),
82 item.violation.from_zone,
83 item.violation.to_zone,
84 )
85}
86
87fn compact_boundary_coverage_line(
88 item: &fallow_core::results::BoundaryCoverageViolationFinding,
89 root: &Path,
90) -> String {
91 format!(
92 "boundary-coverage:{}:{}:no matching boundary zone",
93 compact_path(&item.violation.path, root),
94 item.violation.line,
95 )
96}
97
98fn compact_boundary_call_line(
99 item: &fallow_core::results::BoundaryCallViolationFinding,
100 root: &Path,
101) -> String {
102 format!(
103 "boundary-call:{}:{}:{} forbidden in zone {} (pattern {})",
104 compact_path(&item.violation.path, root),
105 item.violation.line,
106 item.violation.callee,
107 item.violation.zone,
108 item.violation.pattern,
109 )
110}
111
112fn compact_stale_suppression_line(
113 item: &fallow_core::results::StaleSuppression,
114 root: &Path,
115) -> String {
116 format!(
117 "stale-suppression:{}:{}:{}",
118 compact_path(&item.path, root),
119 item.line,
120 item.display_message(),
121 )
122}
123
124fn compact_catalog_reference_line(
125 item: &fallow_core::results::UnresolvedCatalogReferenceFinding,
126 root: &Path,
127) -> String {
128 format!(
129 "unresolved-catalog-reference:{}:{}:{}:{}",
130 compact_path(&item.reference.path, root),
131 item.reference.line,
132 item.reference.catalog_name,
133 item.reference.entry_name,
134 )
135}
136
137fn compact_unused_override_line(
138 item: &fallow_core::results::UnusedDependencyOverrideFinding,
139 root: &Path,
140) -> String {
141 format!(
142 "unused-dependency-override:{}:{}:{}:{}",
143 compact_path(&item.entry.path, root),
144 item.entry.line,
145 item.entry.source.as_label(),
146 item.entry.raw_key,
147 )
148}
149
150fn compact_misconfigured_override_line(
151 item: &fallow_core::results::MisconfiguredDependencyOverrideFinding,
152 root: &Path,
153) -> String {
154 format!(
155 "misconfigured-dependency-override:{}:{}:{}:{}",
156 compact_path(&item.entry.path, root),
157 item.entry.line,
158 item.entry.source.as_label(),
159 item.entry.raw_key,
160 )
161}
162
163pub fn build_compact_lines(results: &AnalysisResults, root: &Path) -> Vec<String> {
166 CompactLineBuilder::new(results, root).build()
167}
168
169struct CompactLineBuilder<'a> {
170 lines: Vec<String>,
171 results: &'a AnalysisResults,
172 root: &'a Path,
173}
174
175impl<'a> CompactLineBuilder<'a> {
176 fn new(results: &'a AnalysisResults, root: &'a Path) -> Self {
177 Self {
178 lines: Vec::new(),
179 results,
180 root,
181 }
182 }
183
184 fn build(mut self) -> Vec<String> {
185 self.push_core_lines();
186 self.push_unused_dependency_lines();
187 self.push_member_lines();
188 self.push_secondary_dependency_lines();
189 self.push_graph_lines();
190 self.push_workspace_lines();
191 self.lines
192 }
193
194 fn rel(&self, path: &Path) -> String {
195 compact_path(path, self.root)
196 }
197
198 fn unused_export_line(&self, export: &UnusedExport) -> String {
199 let tag = if export.is_re_export {
200 "unused-re-export"
201 } else {
202 "unused-export"
203 };
204 format!(
205 "{}:{}:{}:{}",
206 tag,
207 self.rel(&export.path),
208 export.line,
209 export.export_name
210 )
211 }
212
213 fn unused_type_line(&self, export: &UnusedExport) -> String {
214 let tag = if export.is_re_export {
215 "unused-re-export-type"
216 } else {
217 "unused-type"
218 };
219 format!(
220 "{}:{}:{}:{}",
221 tag,
222 self.rel(&export.path),
223 export.line,
224 export.export_name
225 )
226 }
227
228 fn compact_member(&self, member: &UnusedMember, kind: &str) -> String {
229 format!(
230 "{}:{}:{}:{}.{}",
231 kind,
232 self.rel(&member.path),
233 member.line,
234 member.parent_name,
235 member.member_name
236 )
237 }
238
239 fn push_core_lines(&mut self) {
240 for file in &self.results.unused_files {
241 self.lines
242 .push(format!("unused-file:{}", self.rel(&file.file.path)));
243 }
244 for export in &self.results.unused_exports {
245 self.lines.push(self.unused_export_line(&export.export));
246 }
247 for export in &self.results.unused_types {
248 self.lines.push(self.unused_type_line(&export.export));
249 }
250 for leak in &self.results.private_type_leaks {
251 self.lines.push(format!(
252 "private-type-leak:{}:{}:{}->{}",
253 self.rel(&leak.leak.path),
254 leak.leak.line,
255 leak.leak.export_name,
256 leak.leak.type_name
257 ));
258 }
259 }
260
261 fn push_unused_dependency_lines(&mut self) {
262 for dep in &self.results.unused_dependencies {
263 self.lines
264 .push(format!("unused-dep:{}", dep.dep.package_name));
265 }
266 for dep in &self.results.unused_dev_dependencies {
267 self.lines
268 .push(format!("unused-devdep:{}", dep.dep.package_name));
269 }
270 for dep in &self.results.unused_optional_dependencies {
271 self.lines
272 .push(format!("unused-optionaldep:{}", dep.dep.package_name));
273 }
274 }
275
276 fn push_member_lines(&mut self) {
277 for member in &self.results.unused_enum_members {
278 self.lines
279 .push(self.compact_member(&member.member, "unused-enum-member"));
280 }
281 for member in &self.results.unused_class_members {
282 self.lines
283 .push(self.compact_member(&member.member, "unused-class-member"));
284 }
285 for 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 for (i, group) in report.clone_groups.iter().enumerate() {
885 for instance in &group.instances {
886 let relative =
887 normalize_uri(&relative_path(&instance.file, root).display().to_string());
888 outln!(
889 "clone-group-{}:{}:{}-{}:{}tokens",
890 i + 1,
891 relative,
892 instance.start_line,
893 instance.end_line,
894 group.token_count
895 );
896 }
897 }
898}
899
900#[cfg(test)]
901mod tests {
902 use super::*;
903 use crate::health_types::{
904 RuntimeCoverageConfidence, RuntimeCoverageDataSource, RuntimeCoverageEvidence,
905 RuntimeCoverageFinding, RuntimeCoverageHotPath, RuntimeCoverageReport,
906 RuntimeCoverageReportVerdict, RuntimeCoverageSchemaVersion, RuntimeCoverageSummary,
907 RuntimeCoverageVerdict,
908 };
909 use crate::report::test_helpers::sample_results;
910 use fallow_core::extract::MemberKind;
911 use fallow_core::results::*;
912 use std::path::PathBuf;
913
914 #[test]
915 fn compact_empty_results_no_lines() {
916 let root = PathBuf::from("/project");
917 let results = AnalysisResults::default();
918 let lines = build_compact_lines(&results, &root);
919 assert!(lines.is_empty());
920 }
921
922 #[test]
923 fn compact_unused_file_format() {
924 let root = PathBuf::from("/project");
925 let mut results = AnalysisResults::default();
926 results
927 .unused_files
928 .push(UnusedFileFinding::with_actions(UnusedFile {
929 path: root.join("src/dead.ts"),
930 }));
931
932 let lines = build_compact_lines(&results, &root);
933 assert_eq!(lines.len(), 1);
934 assert_eq!(lines[0], "unused-file:src/dead.ts");
935 }
936
937 #[test]
938 fn compact_unused_export_format() {
939 let root = PathBuf::from("/project");
940 let mut results = AnalysisResults::default();
941 results
942 .unused_exports
943 .push(UnusedExportFinding::with_actions(UnusedExport {
944 path: root.join("src/utils.ts"),
945 export_name: "helperFn".to_string(),
946 is_type_only: false,
947 line: 10,
948 col: 4,
949 span_start: 120,
950 is_re_export: false,
951 }));
952
953 let lines = build_compact_lines(&results, &root);
954 assert_eq!(lines[0], "unused-export:src/utils.ts:10:helperFn");
955 }
956
957 #[test]
958 fn compact_health_includes_runtime_coverage_lines() {
959 let root = PathBuf::from("/project");
960 let report = crate::health_types::HealthReport {
961 runtime_coverage: Some(RuntimeCoverageReport {
962 schema_version: RuntimeCoverageSchemaVersion::V1,
963 verdict: RuntimeCoverageReportVerdict::ColdCodeDetected,
964 signals: Vec::new(),
965 summary: RuntimeCoverageSummary {
966 data_source: RuntimeCoverageDataSource::Local,
967 last_received_at: None,
968 functions_tracked: 4,
969 functions_hit: 2,
970 functions_unhit: 1,
971 functions_untracked: 1,
972 coverage_percent: 50.0,
973 trace_count: 512,
974 period_days: 7,
975 deployments_seen: 2,
976 capture_quality: None,
977 },
978 findings: vec![RuntimeCoverageFinding {
979 id: "fallow:prod:deadbeef".to_owned(),
980 stable_id: None,
981 path: root.join("src/cold.ts"),
982 function: "coldPath".to_owned(),
983 line: 14,
984 verdict: RuntimeCoverageVerdict::ReviewRequired,
985 invocations: Some(0),
986 confidence: RuntimeCoverageConfidence::Medium,
987 evidence: RuntimeCoverageEvidence {
988 static_status: "used".to_owned(),
989 test_coverage: "not_covered".to_owned(),
990 v8_tracking: "tracked".to_owned(),
991 untracked_reason: None,
992 observation_days: 7,
993 deployments_observed: 2,
994 },
995 actions: vec![],
996 source_hash: None,
997 }],
998 hot_paths: vec![RuntimeCoverageHotPath {
999 id: "fallow:hot:cafebabe".to_owned(),
1000 stable_id: None,
1001 path: root.join("src/hot.ts"),
1002 function: "hotPath".to_owned(),
1003 line: 3,
1004 end_line: 9,
1005 invocations: 250,
1006 percentile: 99,
1007 actions: vec![],
1008 }],
1009 blast_radius: vec![],
1010 importance: vec![],
1011 watermark: None,
1012 warnings: vec![],
1013 }),
1014 ..Default::default()
1015 };
1016
1017 let lines = build_runtime_coverage_compact_lines(
1018 report
1019 .runtime_coverage
1020 .as_ref()
1021 .expect("runtime coverage should be set"),
1022 &root,
1023 );
1024 assert_eq!(
1025 lines[0],
1026 "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"
1027 );
1028 assert_eq!(
1029 lines[1],
1030 "runtime-coverage:src/cold.ts:14:coldPath:id=fallow:prod:deadbeef,verdict=review_required,invocations=0,confidence=medium"
1031 );
1032 assert_eq!(
1033 lines[2],
1034 "production-hot-path:src/hot.ts:3:hotPath:id=fallow:hot:cafebabe,invocations=250,percentile=99"
1035 );
1036 }
1037
1038 #[test]
1039 fn compact_health_includes_coverage_intelligence_lines() {
1040 use crate::health_types::{
1041 CoverageIntelligenceAction, CoverageIntelligenceConfidence,
1042 CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
1043 CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
1044 CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
1045 CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
1046 };
1047
1048 let root = PathBuf::from("/project");
1049 let report = CoverageIntelligenceReport {
1050 schema_version: CoverageIntelligenceSchemaVersion::V1,
1051 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
1052 summary: CoverageIntelligenceSummary {
1053 findings: 1,
1054 high_confidence_deletes: 1,
1055 ..Default::default()
1056 },
1057 findings: vec![CoverageIntelligenceFinding {
1058 id: "fallow:coverage-intel:abc123".to_owned(),
1059 path: root.join("src/dead.ts"),
1060 identity: Some("deadPath".to_owned()),
1061 line: 9,
1062 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
1063 signals: vec![
1064 CoverageIntelligenceSignal::StaticUnused,
1065 CoverageIntelligenceSignal::RuntimeCold,
1066 ],
1067 recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
1068 confidence: CoverageIntelligenceConfidence::High,
1069 related_ids: vec!["fallow:prod:deadbeef".to_owned()],
1070 evidence: CoverageIntelligenceEvidence {
1071 match_confidence: CoverageIntelligenceMatchConfidence::Direct,
1072 ..Default::default()
1073 },
1074 actions: vec![CoverageIntelligenceAction {
1075 kind: "delete-after-confirming-owner".to_owned(),
1076 description: "Confirm ownership".to_owned(),
1077 auto_fixable: false,
1078 }],
1079 }],
1080 };
1081
1082 let lines = build_coverage_intelligence_compact_lines(&report, &root);
1083 assert_eq!(
1084 lines[0],
1085 "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"
1086 );
1087 assert_eq!(
1088 lines[1],
1089 "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"
1090 );
1091 }
1092
1093 #[test]
1094 fn compact_unused_type_format() {
1095 let root = PathBuf::from("/project");
1096 let mut results = AnalysisResults::default();
1097 results
1098 .unused_types
1099 .push(UnusedTypeFinding::with_actions(UnusedExport {
1100 path: root.join("src/types.ts"),
1101 export_name: "OldType".to_string(),
1102 is_type_only: true,
1103 line: 5,
1104 col: 0,
1105 span_start: 60,
1106 is_re_export: false,
1107 }));
1108
1109 let lines = build_compact_lines(&results, &root);
1110 assert_eq!(lines[0], "unused-type:src/types.ts:5:OldType");
1111 }
1112
1113 #[test]
1114 fn compact_unused_dep_format() {
1115 let root = PathBuf::from("/project");
1116 let mut results = AnalysisResults::default();
1117 results
1118 .unused_dependencies
1119 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1120 package_name: "lodash".to_string(),
1121 location: DependencyLocation::Dependencies,
1122 path: root.join("package.json"),
1123 line: 5,
1124 used_in_workspaces: Vec::new(),
1125 }));
1126
1127 let lines = build_compact_lines(&results, &root);
1128 assert_eq!(lines[0], "unused-dep:lodash");
1129 }
1130
1131 #[test]
1132 fn compact_unused_devdep_format() {
1133 let root = PathBuf::from("/project");
1134 let mut results = AnalysisResults::default();
1135 results
1136 .unused_dev_dependencies
1137 .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
1138 package_name: "jest".to_string(),
1139 location: DependencyLocation::DevDependencies,
1140 path: root.join("package.json"),
1141 line: 5,
1142 used_in_workspaces: Vec::new(),
1143 }));
1144
1145 let lines = build_compact_lines(&results, &root);
1146 assert_eq!(lines[0], "unused-devdep:jest");
1147 }
1148
1149 #[test]
1150 fn compact_unused_enum_member_format() {
1151 let root = PathBuf::from("/project");
1152 let mut results = AnalysisResults::default();
1153 results
1154 .unused_enum_members
1155 .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
1156 path: root.join("src/enums.ts"),
1157 parent_name: "Status".to_string(),
1158 member_name: "Deprecated".to_string(),
1159 kind: MemberKind::EnumMember,
1160 line: 8,
1161 col: 2,
1162 }));
1163
1164 let lines = build_compact_lines(&results, &root);
1165 assert_eq!(
1166 lines[0],
1167 "unused-enum-member:src/enums.ts:8:Status.Deprecated"
1168 );
1169 }
1170
1171 #[test]
1172 fn compact_unused_class_member_format() {
1173 let root = PathBuf::from("/project");
1174 let mut results = AnalysisResults::default();
1175 results
1176 .unused_class_members
1177 .push(UnusedClassMemberFinding::with_actions(UnusedMember {
1178 path: root.join("src/service.ts"),
1179 parent_name: "UserService".to_string(),
1180 member_name: "legacyMethod".to_string(),
1181 kind: MemberKind::ClassMethod,
1182 line: 42,
1183 col: 4,
1184 }));
1185
1186 let lines = build_compact_lines(&results, &root);
1187 assert_eq!(
1188 lines[0],
1189 "unused-class-member:src/service.ts:42:UserService.legacyMethod"
1190 );
1191 }
1192
1193 #[test]
1194 fn compact_unresolved_import_format() {
1195 let root = PathBuf::from("/project");
1196 let mut results = AnalysisResults::default();
1197 results
1198 .unresolved_imports
1199 .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
1200 path: root.join("src/app.ts"),
1201 specifier: "./missing-module".to_string(),
1202 line: 3,
1203 col: 0,
1204 specifier_col: 0,
1205 }));
1206
1207 let lines = build_compact_lines(&results, &root);
1208 assert_eq!(lines[0], "unresolved-import:src/app.ts:3:./missing-module");
1209 }
1210
1211 #[test]
1212 fn compact_unlisted_dep_format() {
1213 let root = PathBuf::from("/project");
1214 let mut results = AnalysisResults::default();
1215 results
1216 .unlisted_dependencies
1217 .push(UnlistedDependencyFinding::with_actions(
1218 UnlistedDependency {
1219 package_name: "chalk".to_string(),
1220 imported_from: vec![],
1221 },
1222 ));
1223
1224 let lines = build_compact_lines(&results, &root);
1225 assert_eq!(lines[0], "unlisted-dep:chalk");
1226 }
1227
1228 #[test]
1229 fn compact_duplicate_export_format() {
1230 let root = PathBuf::from("/project");
1231 let mut results = AnalysisResults::default();
1232 results
1233 .duplicate_exports
1234 .push(DuplicateExportFinding::with_actions(DuplicateExport {
1235 export_name: "Config".to_string(),
1236 locations: vec![
1237 DuplicateLocation {
1238 path: root.join("src/a.ts"),
1239 line: 15,
1240 col: 0,
1241 },
1242 DuplicateLocation {
1243 path: root.join("src/b.ts"),
1244 line: 30,
1245 col: 0,
1246 },
1247 ],
1248 }));
1249
1250 let lines = build_compact_lines(&results, &root);
1251 assert_eq!(lines[0], "duplicate-export:Config");
1252 }
1253
1254 #[test]
1255 fn compact_all_issue_types_produce_lines() {
1256 let root = PathBuf::from("/project");
1257 let results = sample_results(&root);
1258 let lines = build_compact_lines(&results, &root);
1259
1260 assert_eq!(lines.len(), 26);
1261
1262 assert!(lines[0].starts_with("unused-file:"));
1263 assert!(lines[1].starts_with("unused-export:"));
1264 assert!(lines[2].starts_with("unused-type:"));
1265 assert!(lines[3].starts_with("unused-dep:"));
1266 assert!(lines[4].starts_with("unused-devdep:"));
1267 assert!(lines[5].starts_with("unused-optionaldep:"));
1268 assert!(lines[6].starts_with("unused-enum-member:"));
1269 assert!(lines[7].starts_with("unused-class-member:"));
1270 assert!(lines[8].starts_with("unused-store-member:"));
1271 assert!(lines[9].starts_with("unresolved-import:"));
1272 assert!(lines[10].starts_with("unlisted-dep:"));
1273 assert!(lines[11].starts_with("duplicate-export:"));
1274 assert!(lines[12].starts_with("type-only-dep:"));
1275 assert!(lines[13].starts_with("test-only-dep:"));
1276 assert!(lines[14].starts_with("circular-dependency:"));
1277 assert!(lines[15].starts_with("boundary-violation:"));
1278 assert!(lines.iter().any(|l| l.starts_with("unprovided-inject:")));
1279 assert!(lines.iter().any(|l| l.starts_with("unrendered-component:")));
1280 assert!(
1281 lines
1282 .iter()
1283 .any(|l| l.starts_with("unused-component-prop:"))
1284 );
1285 assert!(
1286 lines
1287 .iter()
1288 .any(|l| l.starts_with("unused-component-emit:"))
1289 );
1290 assert!(
1291 lines
1292 .iter()
1293 .any(|l| l.starts_with("unused-component-input:"))
1294 );
1295 assert!(
1296 lines
1297 .iter()
1298 .any(|l| l.starts_with("unused-component-output:"))
1299 );
1300 assert!(lines.iter().any(|l| l.starts_with("unused-svelte-event:")));
1301 assert!(lines.iter().any(|l| l.starts_with("unused-server-action:")));
1302 assert!(lines.iter().any(|l| l.starts_with("unused-load-data-key:")));
1303 }
1304
1305 #[test]
1306 fn compact_covers_api_and_boundary_variants() {
1307 let root = PathBuf::from("/project");
1308 let mut results = AnalysisResults::default();
1309 results
1310 .private_type_leaks
1311 .push(PrivateTypeLeakFinding::with_actions(PrivateTypeLeak {
1312 path: root.join("src/api.ts"),
1313 export_name: "createApi".to_owned(),
1314 type_name: "InternalShape".to_owned(),
1315 line: 12,
1316 col: 4,
1317 span_start: 100,
1318 }));
1319 results
1320 .circular_dependencies
1321 .push(CircularDependencyFinding::with_actions(
1322 CircularDependency {
1323 files: vec![
1324 root.join("packages/a/index.ts"),
1325 root.join("packages/b/index.ts"),
1326 ],
1327 length: 2,
1328 line: 3,
1329 col: 0,
1330 edges: Vec::new(),
1331 is_cross_package: true,
1332 },
1333 ));
1334 results
1335 .boundary_coverage_violations
1336 .push(BoundaryCoverageViolationFinding::with_actions(
1337 BoundaryCoverageViolation {
1338 path: root.join("src/unmatched.ts"),
1339 line: 1,
1340 col: 0,
1341 },
1342 ));
1343 results
1344 .boundary_call_violations
1345 .push(BoundaryCallViolationFinding::with_actions(
1346 BoundaryCallViolation {
1347 path: root.join("src/ui/button.ts"),
1348 line: 20,
1349 col: 6,
1350 zone: "ui".to_owned(),
1351 callee: "child_process.exec".to_owned(),
1352 pattern: "child_process.*".to_owned(),
1353 },
1354 ));
1355
1356 let lines = build_compact_lines(&results, &root);
1357
1358 assert_eq!(
1359 lines[0],
1360 "private-type-leak:src/api.ts:12:createApi->InternalShape"
1361 );
1362 assert!(lines[1].contains(" (cross-package)"));
1363 assert_eq!(
1364 lines[2],
1365 "boundary-coverage:src/unmatched.ts:1:no matching boundary zone"
1366 );
1367 assert_eq!(
1368 lines[3],
1369 "boundary-call:src/ui/button.ts:20:child_process.exec forbidden in zone ui (pattern child_process.*)"
1370 );
1371 }
1372
1373 #[test]
1374 fn compact_strips_root_prefix_from_paths() {
1375 let root = PathBuf::from("/project");
1376 let mut results = AnalysisResults::default();
1377 results
1378 .unused_files
1379 .push(UnusedFileFinding::with_actions(UnusedFile {
1380 path: PathBuf::from("/project/src/deep/nested/file.ts"),
1381 }));
1382
1383 let lines = build_compact_lines(&results, &root);
1384 assert_eq!(lines[0], "unused-file:src/deep/nested/file.ts");
1385 }
1386
1387 #[test]
1388 fn compact_re_export_tagged_correctly() {
1389 let root = PathBuf::from("/project");
1390 let mut results = AnalysisResults::default();
1391 results
1392 .unused_exports
1393 .push(UnusedExportFinding::with_actions(UnusedExport {
1394 path: root.join("src/index.ts"),
1395 export_name: "reExported".to_string(),
1396 is_type_only: false,
1397 line: 1,
1398 col: 0,
1399 span_start: 0,
1400 is_re_export: true,
1401 }));
1402
1403 let lines = build_compact_lines(&results, &root);
1404 assert_eq!(lines[0], "unused-re-export:src/index.ts:1:reExported");
1405 }
1406
1407 #[test]
1408 fn compact_type_re_export_tagged_correctly() {
1409 let root = PathBuf::from("/project");
1410 let mut results = AnalysisResults::default();
1411 results
1412 .unused_types
1413 .push(UnusedTypeFinding::with_actions(UnusedExport {
1414 path: root.join("src/index.ts"),
1415 export_name: "ReExportedType".to_string(),
1416 is_type_only: true,
1417 line: 3,
1418 col: 0,
1419 span_start: 0,
1420 is_re_export: true,
1421 }));
1422
1423 let lines = build_compact_lines(&results, &root);
1424 assert_eq!(
1425 lines[0],
1426 "unused-re-export-type:src/index.ts:3:ReExportedType"
1427 );
1428 }
1429
1430 #[test]
1431 fn compact_unused_optional_dep_format() {
1432 let root = PathBuf::from("/project");
1433 let mut results = AnalysisResults::default();
1434 results
1435 .unused_optional_dependencies
1436 .push(UnusedOptionalDependencyFinding::with_actions(
1437 UnusedDependency {
1438 package_name: "fsevents".to_string(),
1439 location: DependencyLocation::OptionalDependencies,
1440 path: root.join("package.json"),
1441 line: 12,
1442 used_in_workspaces: Vec::new(),
1443 },
1444 ));
1445
1446 let lines = build_compact_lines(&results, &root);
1447 assert_eq!(lines[0], "unused-optionaldep:fsevents");
1448 }
1449
1450 #[test]
1451 fn compact_circular_dependency_format() {
1452 let root = PathBuf::from("/project");
1453 let mut results = AnalysisResults::default();
1454 results
1455 .circular_dependencies
1456 .push(CircularDependencyFinding::with_actions(
1457 CircularDependency {
1458 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1459 length: 2,
1460 line: 3,
1461 col: 0,
1462 edges: Vec::new(),
1463 is_cross_package: false,
1464 },
1465 ));
1466
1467 let lines = build_compact_lines(&results, &root);
1468 assert_eq!(lines.len(), 1);
1469 assert!(lines[0].starts_with("circular-dependency:src/a.ts:3:"));
1470 assert!(lines[0].contains("src/a.ts"));
1471 assert!(lines[0].contains("src/b.ts"));
1472 assert!(lines[0].contains("\u{2192}"));
1473 }
1474
1475 #[test]
1476 fn compact_circular_dependency_closes_cycle() {
1477 let root = PathBuf::from("/project");
1478 let mut results = AnalysisResults::default();
1479 results
1480 .circular_dependencies
1481 .push(CircularDependencyFinding::with_actions(
1482 CircularDependency {
1483 files: vec![
1484 root.join("src/a.ts"),
1485 root.join("src/b.ts"),
1486 root.join("src/c.ts"),
1487 ],
1488 length: 3,
1489 line: 1,
1490 col: 0,
1491 edges: Vec::new(),
1492 is_cross_package: false,
1493 },
1494 ));
1495
1496 let lines = build_compact_lines(&results, &root);
1497 let chain_part = lines[0].split(':').next_back().unwrap();
1498 let parts: Vec<&str> = chain_part.split(" \u{2192} ").collect();
1499 assert_eq!(parts.len(), 4);
1500 assert_eq!(parts[0], parts[3]); }
1502
1503 #[test]
1504 fn compact_type_only_dep_format() {
1505 let root = PathBuf::from("/project");
1506 let mut results = AnalysisResults::default();
1507 results
1508 .type_only_dependencies
1509 .push(TypeOnlyDependencyFinding::with_actions(
1510 TypeOnlyDependency {
1511 package_name: "zod".to_string(),
1512 path: root.join("package.json"),
1513 line: 8,
1514 },
1515 ));
1516
1517 let lines = build_compact_lines(&results, &root);
1518 assert_eq!(lines[0], "type-only-dep:zod");
1519 }
1520
1521 #[test]
1522 fn compact_multiple_unused_files() {
1523 let root = PathBuf::from("/project");
1524 let mut results = AnalysisResults::default();
1525 results
1526 .unused_files
1527 .push(UnusedFileFinding::with_actions(UnusedFile {
1528 path: root.join("src/a.ts"),
1529 }));
1530 results
1531 .unused_files
1532 .push(UnusedFileFinding::with_actions(UnusedFile {
1533 path: root.join("src/b.ts"),
1534 }));
1535
1536 let lines = build_compact_lines(&results, &root);
1537 assert_eq!(lines.len(), 2);
1538 assert_eq!(lines[0], "unused-file:src/a.ts");
1539 assert_eq!(lines[1], "unused-file:src/b.ts");
1540 }
1541
1542 #[test]
1543 fn compact_ordering_optional_dep_between_devdep_and_enum() {
1544 let root = PathBuf::from("/project");
1545 let mut results = AnalysisResults::default();
1546 results
1547 .unused_dev_dependencies
1548 .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
1549 package_name: "jest".to_string(),
1550 location: DependencyLocation::DevDependencies,
1551 path: root.join("package.json"),
1552 line: 5,
1553 used_in_workspaces: Vec::new(),
1554 }));
1555 results
1556 .unused_optional_dependencies
1557 .push(UnusedOptionalDependencyFinding::with_actions(
1558 UnusedDependency {
1559 package_name: "fsevents".to_string(),
1560 location: DependencyLocation::OptionalDependencies,
1561 path: root.join("package.json"),
1562 line: 12,
1563 used_in_workspaces: Vec::new(),
1564 },
1565 ));
1566 results
1567 .unused_enum_members
1568 .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
1569 path: root.join("src/enums.ts"),
1570 parent_name: "Status".to_string(),
1571 member_name: "Deprecated".to_string(),
1572 kind: MemberKind::EnumMember,
1573 line: 8,
1574 col: 2,
1575 }));
1576
1577 let lines = build_compact_lines(&results, &root);
1578 assert_eq!(lines.len(), 3);
1579 assert!(lines[0].starts_with("unused-devdep:"));
1580 assert!(lines[1].starts_with("unused-optionaldep:"));
1581 assert!(lines[2].starts_with("unused-enum-member:"));
1582 }
1583
1584 #[test]
1585 fn compact_path_outside_root_preserved() {
1586 let root = PathBuf::from("/project");
1587 let mut results = AnalysisResults::default();
1588 results
1589 .unused_files
1590 .push(UnusedFileFinding::with_actions(UnusedFile {
1591 path: PathBuf::from("/other/place/file.ts"),
1592 }));
1593
1594 let lines = build_compact_lines(&results, &root);
1595 assert!(lines[0].contains("/other/place/file.ts"));
1596 }
1597}