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