1use std::path::Path;
2
3use fallow_engine::duplicates::CloneFingerprintSet;
4use fallow_output::normalize_uri;
5use fallow_types::duplicates::DuplicationReport;
6use fallow_types::results::{AnalysisResults, UnusedExport, UnusedMember};
7
8use crate::ResultGroup;
9
10fn relative_path<'a>(path: &'a Path, root: &Path) -> &'a Path {
11 path.strip_prefix(root).unwrap_or(path)
12}
13
14fn compact_path(path: &Path, root: &Path) -> String {
15 normalize_uri(&relative_path(path, root).display().to_string())
16}
17
18fn compact_circular_dependency_line(
19 cycle: &fallow_types::output_dead_code::CircularDependencyFinding,
20 root: &Path,
21) -> String {
22 let mut display_chain: Vec<String> = cycle
23 .cycle
24 .files
25 .iter()
26 .map(|path| compact_path(path, root))
27 .collect();
28 if let Some(first) = display_chain.first() {
29 display_chain.push(first.clone());
30 }
31 let first_file = display_chain.first().map_or("", String::as_str);
32 let cross_pkg_tag = if cycle.cycle.is_cross_package {
33 " (cross-package)"
34 } else {
35 ""
36 };
37 format!(
38 "circular-dependency:{}:{}:{}{}",
39 first_file,
40 cycle.cycle.line,
41 display_chain.join(" \u{2192} "),
42 cross_pkg_tag
43 )
44}
45
46fn compact_re_export_cycle_line(
47 cycle: &fallow_types::output_dead_code::ReExportCycleFinding,
48 root: &Path,
49) -> String {
50 let chain: Vec<String> = cycle
51 .cycle
52 .files
53 .iter()
54 .map(|path| compact_path(path, root))
55 .collect();
56 let first_file = chain.first().map_or("", String::as_str);
57 let kind_tag = match cycle.cycle.kind {
58 fallow_types::results::ReExportCycleKind::SelfLoop => " (self-loop)",
59 fallow_types::results::ReExportCycleKind::MultiNode => "",
60 };
61 format!(
62 "re-export-cycle:{}:{}{}",
63 first_file,
64 chain.join(" <-> "),
65 kind_tag
66 )
67}
68
69fn compact_boundary_violation_line(
70 item: &fallow_types::output_dead_code::BoundaryViolationFinding,
71 root: &Path,
72) -> String {
73 format!(
74 "boundary-violation:{}:{}:{} -> {} ({} -> {})",
75 compact_path(&item.violation.from_path, root),
76 item.violation.line,
77 compact_path(&item.violation.from_path, root),
78 compact_path(&item.violation.to_path, root),
79 item.violation.from_zone,
80 item.violation.to_zone,
81 )
82}
83
84fn compact_boundary_coverage_line(
85 item: &fallow_types::output_dead_code::BoundaryCoverageViolationFinding,
86 root: &Path,
87) -> String {
88 format!(
89 "boundary-coverage:{}:{}:no matching boundary zone",
90 compact_path(&item.violation.path, root),
91 item.violation.line,
92 )
93}
94
95fn compact_boundary_call_line(
96 item: &fallow_types::output_dead_code::BoundaryCallViolationFinding,
97 root: &Path,
98) -> String {
99 format!(
100 "boundary-call:{}:{}:{} forbidden in zone {} (pattern {})",
101 compact_path(&item.violation.path, root),
102 item.violation.line,
103 item.violation.callee,
104 item.violation.zone,
105 item.violation.pattern,
106 )
107}
108
109fn compact_stale_suppression_line(
110 item: &fallow_types::results::StaleSuppression,
111 root: &Path,
112) -> String {
113 format!(
114 "stale-suppression:{}:{}:{}",
115 compact_path(&item.path, root),
116 item.line,
117 item.display_message(),
118 )
119}
120
121fn compact_catalog_reference_line(
122 item: &fallow_types::output_dead_code::UnresolvedCatalogReferenceFinding,
123 root: &Path,
124) -> String {
125 format!(
126 "unresolved-catalog-reference:{}:{}:{}:{}",
127 compact_path(&item.reference.path, root),
128 item.reference.line,
129 item.reference.catalog_name,
130 item.reference.entry_name,
131 )
132}
133
134fn compact_unused_override_line(
135 item: &fallow_types::output_dead_code::UnusedDependencyOverrideFinding,
136 root: &Path,
137) -> String {
138 format!(
139 "unused-dependency-override:{}:{}:{}:{}",
140 compact_path(&item.entry.path, root),
141 item.entry.line,
142 item.entry.source.as_label(),
143 item.entry.raw_key,
144 )
145}
146
147fn compact_misconfigured_override_line(
148 item: &fallow_types::output_dead_code::MisconfiguredDependencyOverrideFinding,
149 root: &Path,
150) -> String {
151 format!(
152 "misconfigured-dependency-override:{}:{}:{}:{}",
153 compact_path(&item.entry.path, root),
154 item.entry.line,
155 item.entry.source.as_label(),
156 item.entry.raw_key,
157 )
158}
159
160pub fn build_compact_lines(results: &AnalysisResults, root: &Path) -> Vec<String> {
163 CompactLineBuilder::new(results, root).build()
164}
165
166struct CompactLineBuilder<'a> {
167 lines: Vec<String>,
168 results: &'a AnalysisResults,
169 root: &'a Path,
170}
171
172impl<'a> CompactLineBuilder<'a> {
173 fn new(results: &'a AnalysisResults, root: &'a Path) -> Self {
174 Self {
175 lines: Vec::new(),
176 results,
177 root,
178 }
179 }
180
181 fn build(mut self) -> Vec<String> {
182 self.push_core_lines();
183 self.push_unused_dependency_lines();
184 self.push_member_lines();
185 self.push_secondary_dependency_lines();
186 self.push_graph_lines();
187 self.push_workspace_lines();
188 self.lines
189 }
190
191 fn rel(&self, path: &Path) -> String {
192 compact_path(path, self.root)
193 }
194
195 fn unused_export_line(&self, export: &UnusedExport) -> String {
196 let tag = if export.is_re_export {
197 "unused-re-export"
198 } else {
199 "unused-export"
200 };
201 format!(
202 "{}:{}:{}:{}",
203 tag,
204 self.rel(&export.path),
205 export.line,
206 export.export_name
207 )
208 }
209
210 fn unused_type_line(&self, export: &UnusedExport) -> String {
211 let tag = if export.is_re_export {
212 "unused-re-export-type"
213 } else {
214 "unused-type"
215 };
216 format!(
217 "{}:{}:{}:{}",
218 tag,
219 self.rel(&export.path),
220 export.line,
221 export.export_name
222 )
223 }
224
225 fn compact_member(&self, member: &UnusedMember, kind: &str) -> String {
226 format!(
227 "{}:{}:{}:{}.{}",
228 kind,
229 self.rel(&member.path),
230 member.line,
231 member.parent_name,
232 member.member_name
233 )
234 }
235
236 fn push_core_lines(&mut self) {
237 for file in &self.results.unused_files {
238 self.lines
239 .push(format!("unused-file:{}", self.rel(&file.file.path)));
240 }
241 for export in &self.results.unused_exports {
242 self.lines.push(self.unused_export_line(&export.export));
243 }
244 for export in &self.results.unused_types {
245 self.lines.push(self.unused_type_line(&export.export));
246 }
247 for leak in &self.results.private_type_leaks {
248 self.lines.push(format!(
249 "private-type-leak:{}:{}:{}->{}",
250 self.rel(&leak.leak.path),
251 leak.leak.line,
252 leak.leak.export_name,
253 leak.leak.type_name
254 ));
255 }
256 }
257
258 fn push_unused_dependency_lines(&mut self) {
259 for dep in &self.results.unused_dependencies {
260 self.lines
261 .push(format!("unused-dep:{}", dep.dep.package_name));
262 }
263 for dep in &self.results.unused_dev_dependencies {
264 self.lines
265 .push(format!("unused-devdep:{}", dep.dep.package_name));
266 }
267 for dep in &self.results.unused_optional_dependencies {
268 self.lines
269 .push(format!("unused-optionaldep:{}", dep.dep.package_name));
270 }
271 }
272
273 fn push_member_lines(&mut self) {
274 for member in &self.results.unused_enum_members {
275 self.lines
276 .push(self.compact_member(&member.member, "unused-enum-member"));
277 }
278 for member in &self.results.unused_class_members {
279 self.lines
280 .push(self.compact_member(&member.member, "unused-class-member"));
281 }
282 for member in &self.results.unused_store_members {
283 self.lines
284 .push(self.compact_member(&member.member, "unused-store-member"));
285 }
286 for import in &self.results.unresolved_imports {
287 self.lines.push(format!(
288 "unresolved-import:{}:{}:{}",
289 self.rel(&import.import.path),
290 import.import.line,
291 import.import.specifier
292 ));
293 }
294 }
295
296 fn push_secondary_dependency_lines(&mut self) {
297 for dep in &self.results.unlisted_dependencies {
298 self.lines
299 .push(format!("unlisted-dep:{}", dep.dep.package_name));
300 }
301 for dup in &self.results.duplicate_exports {
302 self.lines
303 .push(format!("duplicate-export:{}", dup.export.export_name));
304 }
305 for dep in &self.results.type_only_dependencies {
306 self.lines
307 .push(format!("type-only-dep:{}", dep.dep.package_name));
308 }
309 for dep in &self.results.test_only_dependencies {
310 self.lines
311 .push(format!("test-only-dep:{}", dep.dep.package_name));
312 }
313 for dep in &self.results.dev_dependencies_in_production {
314 self.lines
315 .push(format!("dev-dep-in-prod:{}", dep.dep.package_name));
316 }
317 }
318
319 fn push_graph_lines(&mut self) {
320 self.push_structure_lines();
321 self.push_framework_lines();
322 self.push_component_lines();
323 self.push_route_lines();
324 self.push_suppression_lines();
325 }
326
327 fn push_structure_lines(&mut self) {
328 for cycle in &self.results.circular_dependencies {
329 self.lines
330 .push(compact_circular_dependency_line(cycle, self.root));
331 }
332 for cycle in &self.results.re_export_cycles {
333 self.lines
334 .push(compact_re_export_cycle_line(cycle, self.root));
335 }
336 for violation in &self.results.boundary_violations {
337 self.lines
338 .push(compact_boundary_violation_line(violation, self.root));
339 }
340 for violation in &self.results.boundary_coverage_violations {
341 self.lines
342 .push(compact_boundary_coverage_line(violation, self.root));
343 }
344 for violation in &self.results.boundary_call_violations {
345 self.lines
346 .push(compact_boundary_call_line(violation, self.root));
347 }
348 for violation in &self.results.policy_violations {
349 self.lines.push(format!(
350 "policy-violation:{}:{}:{} banned by {}/{}",
351 self.rel(&violation.violation.path),
352 violation.violation.line,
353 violation.violation.matched,
354 violation.violation.pack,
355 violation.violation.rule_id,
356 ));
357 }
358 }
359
360 fn push_framework_lines(&mut self) {
361 for finding in &self.results.invalid_client_exports {
362 self.lines.push(format!(
363 "invalid-client-export:{}:{}:{} (from \"{}\")",
364 self.rel(&finding.export.path),
365 finding.export.line,
366 finding.export.export_name,
367 finding.export.directive,
368 ));
369 }
370 for finding in &self.results.mixed_client_server_barrels {
371 self.lines.push(format!(
372 "mixed-client-server-barrel:{}:{}:{} (server-only \"{}\")",
373 self.rel(&finding.barrel.path),
374 finding.barrel.line,
375 finding.barrel.client_origin,
376 finding.barrel.server_origin,
377 ));
378 }
379 for finding in &self.results.misplaced_directives {
380 self.lines.push(format!(
381 "misplaced-directive:{}:{}:{}",
382 self.rel(&finding.directive_site.path),
383 finding.directive_site.line,
384 finding.directive_site.directive,
385 ));
386 }
387 for finding in &self.results.unprovided_injects {
388 self.lines.push(format!(
389 "unprovided-inject:{}:{}:{}",
390 self.rel(&finding.inject.path),
391 finding.inject.line,
392 finding.inject.key_name,
393 ));
394 }
395 }
396
397 fn push_component_lines(&mut self) {
398 self.push_component_member_lines();
399 self.push_component_framework_lines();
400 }
401
402 fn push_component_member_lines(&mut self) {
404 for finding in &self.results.unrendered_components {
405 self.lines.push(format!(
406 "unrendered-component:{}:{}:{}",
407 self.rel(&finding.component.path),
408 finding.component.line,
409 finding.component.component_name,
410 ));
411 }
412 for finding in &self.results.unused_component_props {
413 self.lines.push(format!(
414 "unused-component-prop:{}:{}:{}",
415 self.rel(&finding.prop.path),
416 finding.prop.line,
417 finding.prop.prop_name,
418 ));
419 }
420 for finding in &self.results.unused_component_emits {
421 self.lines.push(format!(
422 "unused-component-emit:{}:{}:{}",
423 self.rel(&finding.emit.path),
424 finding.emit.line,
425 finding.emit.emit_name,
426 ));
427 }
428 for finding in &self.results.unused_component_inputs {
429 self.lines.push(format!(
430 "unused-component-input:{}:{}:{}",
431 self.rel(&finding.input.path),
432 finding.input.line,
433 finding.input.input_name,
434 ));
435 }
436 for finding in &self.results.unused_component_outputs {
437 self.lines.push(format!(
438 "unused-component-output:{}:{}:{}",
439 self.rel(&finding.output.path),
440 finding.output.line,
441 finding.output.output_name,
442 ));
443 }
444 }
445
446 fn push_component_framework_lines(&mut self) {
448 for finding in &self.results.unused_svelte_events {
449 self.lines.push(format!(
450 "unused-svelte-event:{}:{}:{}",
451 self.rel(&finding.event.path),
452 finding.event.line,
453 finding.event.event_name,
454 ));
455 }
456 for finding in &self.results.unused_server_actions {
457 self.lines.push(format!(
458 "unused-server-action:{}:{}:{}",
459 self.rel(&finding.action.path),
460 finding.action.line,
461 finding.action.action_name,
462 ));
463 }
464 for finding in &self.results.unused_load_data_keys {
465 self.lines.push(format!(
466 "unused-load-data-key:{}:{}:{}",
467 self.rel(&finding.key.path),
468 finding.key.line,
469 finding.key.key_name,
470 ));
471 }
472 }
473
474 fn push_route_lines(&mut self) {
475 for finding in &self.results.route_collisions {
476 self.lines.push(format!(
477 "route-collision:{}:{} (url {})",
478 self.rel(&finding.collision.path),
479 finding.collision.line,
480 finding.collision.url,
481 ));
482 }
483 for finding in &self.results.dynamic_segment_name_conflicts {
484 self.lines.push(format!(
485 "dynamic-segment-name-conflict:{}:{} ({} at {})",
486 self.rel(&finding.conflict.path),
487 finding.conflict.line,
488 finding.conflict.conflicting_segments.join(" vs "),
489 finding.conflict.position,
490 ));
491 }
492 }
493
494 fn push_suppression_lines(&mut self) {
495 for suppression in &self.results.stale_suppressions {
496 self.lines
497 .push(compact_stale_suppression_line(suppression, self.root));
498 }
499 }
500
501 fn push_workspace_lines(&mut self) {
502 for entry in &self.results.unused_catalog_entries {
503 self.lines.push(format!(
504 "unused-catalog-entry:{}:{}:{}:{}",
505 self.rel(&entry.entry.path),
506 entry.entry.line,
507 entry.entry.catalog_name,
508 entry.entry.entry_name,
509 ));
510 }
511 for group in &self.results.empty_catalog_groups {
512 self.lines.push(format!(
513 "empty-catalog-group:{}:{}:{}",
514 self.rel(&group.group.path),
515 group.group.line,
516 group.group.catalog_name,
517 ));
518 }
519 for finding in &self.results.unresolved_catalog_references {
520 self.lines
521 .push(compact_catalog_reference_line(finding, self.root));
522 }
523 for finding in &self.results.unused_dependency_overrides {
524 self.lines
525 .push(compact_unused_override_line(finding, self.root));
526 }
527 for finding in &self.results.misconfigured_dependency_overrides {
528 self.lines
529 .push(compact_misconfigured_override_line(finding, self.root));
530 }
531 }
532}
533
534#[must_use]
538pub fn build_grouped_compact_lines(groups: &[ResultGroup], root: &Path) -> Vec<String> {
539 groups
540 .iter()
541 .flat_map(|group| {
542 build_compact_lines(&group.results, root)
543 .into_iter()
544 .map(|line| format!("{}\t{line}", group.key))
545 })
546 .collect()
547}
548
549#[must_use]
551pub fn build_health_compact_lines(
552 report: &fallow_output::HealthReport,
553 root: &Path,
554) -> Vec<String> {
555 let mut lines = Vec::new();
556 push_health_score_compact(&mut lines, report);
557 push_vital_signs_compact(&mut lines, report);
558 push_health_findings_compact(&mut lines, &report.findings, root);
559 push_styling_findings_compact(&mut lines, &report.styling_findings, root);
560 push_threshold_overrides_compact(&mut lines, &report.threshold_overrides, root);
561 push_file_scores_compact(&mut lines, &report.file_scores, root);
562 push_coverage_gaps_compact(&mut lines, report, root);
563 push_runtime_sections_compact(&mut lines, report, root);
564 push_hotspots_compact(&mut lines, &report.hotspots, root);
565 push_health_trend_compact(&mut lines, report);
566 push_refactoring_targets_compact(&mut lines, &report.targets, root);
567 lines
568}
569
570fn push_styling_findings_compact(
571 lines: &mut Vec<String>,
572 findings: &[fallow_output::StylingFinding],
573 root: &Path,
574) {
575 for finding in findings {
576 let relative = compact_path(Path::new(&finding.path), root);
577 let severity = match finding.effective_severity {
578 fallow_output::StylingFindingSeverity::Error => "error",
579 fallow_output::StylingFindingSeverity::Warn => "warn",
580 };
581 let value = compact_field_value(&finding.value);
582 lines.push(format!(
583 "{}:{}:{}:{}:severity={},value={}",
584 finding.code, relative, finding.line, finding.sub_kind, severity, value
585 ));
586 }
587}
588
589fn compact_field_value(value: &str) -> String {
590 value
591 .replace([':', ',', '\n', '\r'], " ")
592 .split_whitespace()
593 .collect::<Vec<_>>()
594 .join(" ")
595}
596
597fn push_threshold_overrides_compact(
598 lines: &mut Vec<String>,
599 entries: &[fallow_output::ThresholdOverrideState],
600 root: &Path,
601) {
602 for entry in entries {
603 let status = match entry.status {
604 fallow_output::ThresholdOverrideStatus::Active => "active",
605 fallow_output::ThresholdOverrideStatus::Stale => "stale",
606 fallow_output::ThresholdOverrideStatus::Insufficient => "insufficient",
607 fallow_output::ThresholdOverrideStatus::NoMatch => "no_match",
608 };
609 let target = entry.path.as_ref().map_or_else(
610 || "no-match".to_string(),
611 |path| entry.target_label(&compact_path(path, root)),
612 );
613 let dimension = threshold_override_dimension_label(entry.dimension);
614 let metrics = entry.metrics.map_or(String::new(), |metrics| {
615 let crap = metrics
616 .crap
617 .map_or(String::new(), |value| format!(",crap={value:.1}"));
618 let line_count = metrics
619 .line_count
620 .map_or(String::new(), |value| format!(",lines={value}"));
621 format!(
622 ",cyclomatic={},cognitive={}{}{}",
623 metrics.cyclomatic, metrics.cognitive, line_count, crap
624 )
625 });
626 let outstanding = if entry.outstanding.is_empty() {
627 String::new()
628 } else {
629 format!(
630 ",outstanding={}",
631 entry
632 .outstanding
633 .iter()
634 .map(|value| threshold_override_dimension_label(*value))
635 .collect::<Vec<_>>()
636 .join("|")
637 )
638 };
639 lines.push(format!(
640 "threshold-override:{}:{}:{}:{}{}{}",
641 entry.override_index, dimension, status, target, metrics, outstanding
642 ));
643 }
644}
645
646fn threshold_override_dimension_label(
647 dimension: fallow_output::ThresholdOverrideDimension,
648) -> &'static str {
649 match dimension {
650 fallow_output::ThresholdOverrideDimension::Complexity => "complexity",
651 fallow_output::ThresholdOverrideDimension::Crap => "crap",
652 }
653}
654
655fn push_health_score_compact(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
656 if let Some(ref hs) = report.health_score {
657 lines.push(format!("health-score:{:.1}:{}", hs.score, hs.grade));
658 }
659}
660
661fn push_vital_signs_compact(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
662 if let Some(ref vs) = report.vital_signs {
663 let mut parts = Vec::new();
664 if vs.total_loc > 0 {
665 parts.push(format!("total_loc={}", vs.total_loc));
666 }
667 parts.push(format!("avg_cyclomatic={:.1}", vs.avg_cyclomatic));
668 parts.push(format!("p90_cyclomatic={}", vs.p90_cyclomatic));
669 if let Some(v) = vs.dead_file_pct {
670 parts.push(format!("dead_file_pct={v:.1}"));
671 }
672 if let Some(v) = vs.dead_export_pct {
673 parts.push(format!("dead_export_pct={v:.1}"));
674 }
675 if let Some(v) = vs.maintainability_avg {
676 parts.push(format!("maintainability_avg={v:.1}"));
677 }
678 if let Some(v) = vs.hotspot_count {
679 parts.push(format!("hotspot_count={v}"));
680 }
681 if let Some(v) = vs.circular_dep_count {
682 parts.push(format!("circular_dep_count={v}"));
683 }
684 if let Some(v) = vs.unused_dep_count {
685 parts.push(format!("unused_dep_count={v}"));
686 }
687 lines.push(format!("vital-signs:{}", parts.join(",")));
688 }
689}
690
691fn exceeded_compact_label(exceeded: fallow_output::ExceededThreshold) -> &'static str {
693 match exceeded {
694 fallow_output::ExceededThreshold::Cyclomatic => "cyclomatic",
695 fallow_output::ExceededThreshold::Cognitive => "cognitive",
696 fallow_output::ExceededThreshold::Both => "both",
697 fallow_output::ExceededThreshold::Crap => "crap",
698 fallow_output::ExceededThreshold::CyclomaticCrap => "cyclomatic_crap",
699 fallow_output::ExceededThreshold::CognitiveCrap => "cognitive_crap",
700 fallow_output::ExceededThreshold::All => "all",
701 }
702}
703
704fn push_health_findings_compact(
705 lines: &mut Vec<String>,
706 findings: &[fallow_output::HealthFinding],
707 root: &Path,
708) {
709 for finding in findings {
710 let relative = compact_path(&finding.path, root);
711 let severity = match finding.severity {
712 fallow_output::FindingSeverity::Critical => "critical",
713 fallow_output::FindingSeverity::High => "high",
714 fallow_output::FindingSeverity::Moderate => "moderate",
715 };
716 let crap_suffix = match finding.crap {
717 Some(crap) => {
718 let coverage = finding
719 .coverage_pct
720 .map(|pct| format!(",coverage_pct={pct:.1}"))
721 .unwrap_or_default();
722 format!(",crap={crap:.1}{coverage}")
723 }
724 None => String::new(),
725 };
726 lines.push(format!(
731 "high-complexity:{}:{}:{}:cyclomatic={},cognitive={},severity={},exceeded={}{}",
732 relative,
733 finding.line,
734 finding.name,
735 finding.cyclomatic,
736 finding.cognitive,
737 severity,
738 exceeded_compact_label(finding.exceeded),
739 crap_suffix,
740 ));
741 }
742}
743
744fn push_file_scores_compact(
745 lines: &mut Vec<String>,
746 scores: &[fallow_output::FileHealthScore],
747 root: &Path,
748) {
749 for score in scores {
750 let relative = compact_path(&score.path, root);
751 lines.push(format!(
752 "file-score:{}:mi={:.1},fan_in={},fan_out={},dead={:.2},density={:.2},crap_max={:.1},crap_above={}",
753 relative,
754 score.maintainability_index,
755 score.fan_in,
756 score.fan_out,
757 score.dead_code_ratio,
758 score.complexity_density,
759 score.crap_max,
760 score.crap_above_threshold,
761 ));
762 }
763}
764
765fn push_coverage_gaps_compact(
766 lines: &mut Vec<String>,
767 report: &fallow_output::HealthReport,
768 root: &Path,
769) {
770 if let Some(ref gaps) = report.coverage_gaps {
771 lines.push(format!(
772 "coverage-gap-summary:runtime_files={},covered_files={},file_coverage_pct={:.1},untested_files={},untested_exports={}",
773 gaps.summary.runtime_files,
774 gaps.summary.covered_files,
775 gaps.summary.file_coverage_pct,
776 gaps.summary.untested_files,
777 gaps.summary.untested_exports,
778 ));
779 for item in &gaps.files {
780 let relative = compact_path(&item.file.path, root);
781 lines.push(format!(
782 "untested-file:{}:value_exports={}",
783 relative, item.file.value_export_count,
784 ));
785 }
786 for item in &gaps.exports {
787 let relative = compact_path(&item.export.path, root);
788 lines.push(format!(
789 "untested-export:{}:{}:{}",
790 relative, item.export.line, item.export.export_name,
791 ));
792 }
793 }
794}
795
796fn push_runtime_sections_compact(
797 lines: &mut Vec<String>,
798 report: &fallow_output::HealthReport,
799 root: &Path,
800) {
801 if let Some(ref production) = report.runtime_coverage {
802 lines.extend(build_runtime_coverage_compact_lines(production, root));
803 }
804 if let Some(ref intelligence) = report.coverage_intelligence {
805 lines.extend(build_coverage_intelligence_compact_lines(
806 intelligence,
807 root,
808 ));
809 }
810}
811
812fn compact_ownership_suffix(ownership: Option<&fallow_output::OwnershipMetrics>) -> String {
813 ownership.map_or_else(String::new, |o| {
814 let mut parts = vec![
815 format!("bus={}", o.bus_factor),
816 format!("contributors={}", o.contributor_count),
817 format!("top={}", o.top_contributor.identifier),
818 format!("top_share={:.3}", o.top_contributor.share),
819 ];
820 if let Some(owner) = &o.declared_owner {
821 parts.push(format!("owner={owner}"));
822 }
823 if let Some(unowned) = o.unowned {
824 parts.push(format!("unowned={unowned}"));
825 }
826 let state = match o.ownership_state {
827 fallow_output::OwnershipState::Active => "active",
828 fallow_output::OwnershipState::Unowned => "unowned",
829 fallow_output::OwnershipState::DeclaredInactive => "declared_inactive",
830 fallow_output::OwnershipState::Drifting => "drifting",
831 };
832 parts.push(format!("ownership_state={state}"));
833 if o.drift {
834 parts.push("drift=true".to_string());
835 }
836 format!(",{}", parts.join(","))
837 })
838}
839
840fn push_hotspots_compact(
841 lines: &mut Vec<String>,
842 hotspots: &[fallow_output::HotspotFinding],
843 root: &Path,
844) {
845 for entry in hotspots {
846 let relative = compact_path(&entry.path, root);
847 let ownership_suffix = compact_ownership_suffix(entry.ownership.as_ref());
848 lines.push(format!(
849 "hotspot:{}:score={:.1},commits={},churn={},density={:.2},fan_in={},trend={}{}",
850 relative,
851 entry.score,
852 entry.commits,
853 entry.lines_added + entry.lines_deleted,
854 entry.complexity_density,
855 entry.fan_in,
856 entry.trend,
857 ownership_suffix,
858 ));
859 }
860}
861
862fn push_health_trend_compact(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
863 if let Some(ref trend) = report.health_trend {
864 lines.push(format!(
865 "trend:overall:direction={}",
866 trend.overall_direction.label()
867 ));
868 for m in &trend.metrics {
869 lines.push(format!(
870 "trend:{}:previous={:.1},current={:.1},delta={:+.1},direction={}",
871 m.name,
872 m.previous,
873 m.current,
874 m.delta,
875 m.direction.label(),
876 ));
877 }
878 }
879}
880
881fn push_refactoring_targets_compact(
882 lines: &mut Vec<String>,
883 targets: &[fallow_output::RefactoringTargetFinding],
884 root: &Path,
885) {
886 for target in targets {
887 let relative = compact_path(&target.path, root);
888 let category = target.category.compact_label();
889 let effort = target.effort.label();
890 let confidence = target.confidence.label();
891 lines.push(format!(
892 "refactoring-target:{}:priority={:.1},efficiency={:.1},category={},effort={},confidence={}:{}",
893 relative,
894 target.priority,
895 target.efficiency,
896 category,
897 effort,
898 confidence,
899 target.recommendation,
900 ));
901 }
902}
903
904fn build_runtime_coverage_compact_lines(
905 production: &fallow_output::RuntimeCoverageReport,
906 root: &Path,
907) -> Vec<String> {
908 let mut lines = vec![format!(
909 "runtime-coverage-summary:functions_tracked={},functions_hit={},functions_unhit={},functions_untracked={},coverage_percent={:.1},trace_count={},period_days={},deployments_seen={}",
910 production.summary.functions_tracked,
911 production.summary.functions_hit,
912 production.summary.functions_unhit,
913 production.summary.functions_untracked,
914 production.summary.coverage_percent,
915 production.summary.trace_count,
916 production.summary.period_days,
917 production.summary.deployments_seen,
918 )];
919 for finding in &production.findings {
920 let relative = compact_path(&finding.path, root);
921 let invocations = finding
922 .invocations
923 .map_or_else(|| "null".to_owned(), |hits| hits.to_string());
924 lines.push(format!(
925 "runtime-coverage:{}:{}:{}:id={},verdict={},invocations={},confidence={}",
926 relative,
927 finding.line,
928 finding.function,
929 finding.id,
930 finding.verdict,
931 invocations,
932 finding.confidence,
933 ));
934 }
935 for entry in &production.hot_paths {
936 let relative = compact_path(&entry.path, root);
937 lines.push(format!(
938 "production-hot-path:{}:{}:{}:id={},invocations={},percentile={}",
939 relative, entry.line, entry.function, entry.id, entry.invocations, entry.percentile,
940 ));
941 }
942 lines
943}
944
945fn build_coverage_intelligence_compact_lines(
946 intelligence: &fallow_output::CoverageIntelligenceReport,
947 root: &Path,
948) -> Vec<String> {
949 let mut lines = vec![format!(
950 "coverage-intelligence-summary:verdict={},findings={},risky_changes={},high_confidence_deletes={},review_required={},refactor_carefully={},skipped_ambiguous_matches={}",
951 intelligence.verdict,
952 intelligence.summary.findings,
953 intelligence.summary.risky_changes,
954 intelligence.summary.high_confidence_deletes,
955 intelligence.summary.review_required,
956 intelligence.summary.refactor_carefully,
957 intelligence.summary.skipped_ambiguous_matches,
958 )];
959 for finding in &intelligence.findings {
960 let relative = compact_path(&finding.path, root);
961 let identity = finding.identity.as_deref().unwrap_or("-");
962 let signals = finding
963 .signals
964 .iter()
965 .map(ToString::to_string)
966 .collect::<Vec<_>>()
967 .join("+");
968 lines.push(format!(
969 "coverage-intelligence:{}:{}:{}:id={},verdict={},recommendation={},confidence={},signals={}",
970 relative,
971 finding.line,
972 identity,
973 finding.id,
974 finding.verdict,
975 finding.recommendation,
976 finding.confidence,
977 signals,
978 ));
979 }
980 lines
981}
982
983#[must_use]
985pub fn build_duplication_compact_lines(report: &DuplicationReport, root: &Path) -> Vec<String> {
986 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
987 let mut lines = Vec::new();
988 for (index, group) in report.clone_groups.iter().enumerate() {
989 let fingerprint = fingerprints.fingerprint_for_group(group);
990 for instance in &group.instances {
991 lines.push(format!(
992 "code-duplication:{}:{}-{}:fingerprint={},group={},tokens={},lines={},instances={}",
993 compact_path(&instance.file, root),
994 instance.start_line,
995 instance.end_line,
996 fingerprint,
997 index + 1,
998 group.token_count,
999 group.line_count,
1000 group.instances.len(),
1001 ));
1002 }
1003 }
1004 lines
1005}
1006
1007#[cfg(test)]
1008mod tests {
1009 use std::path::PathBuf;
1010
1011 use fallow_types::duplicates::{CloneGroup, CloneInstance, DuplicationStats};
1012 use fallow_types::output_dead_code::UnusedFileFinding;
1013 use fallow_types::results::{AnalysisResults, UnusedFile};
1014
1015 use super::*;
1016
1017 #[test]
1018 fn compact_cycles_preserve_empty_self_loop_and_cross_package_output() {
1019 use fallow_types::output_dead_code::{CircularDependencyFinding, ReExportCycleFinding};
1020 use fallow_types::results::{CircularDependency, ReExportCycle, ReExportCycleKind};
1021
1022 let root = Path::new("/project");
1023 for (files, cross_package, expected) in [
1024 (vec![], false, "circular-dependency::7:"),
1025 (
1026 vec![root.join("src/a.ts")],
1027 false,
1028 "circular-dependency:src/a.ts:7:src/a.ts → src/a.ts",
1029 ),
1030 (
1031 vec![root.join("src/a.ts"), root.join("src/b.ts")],
1032 true,
1033 "circular-dependency:src/a.ts:7:src/a.ts → src/b.ts → src/a.ts (cross-package)",
1034 ),
1035 ] {
1036 let mut results = AnalysisResults::default();
1037 results
1038 .circular_dependencies
1039 .push(CircularDependencyFinding {
1040 cycle: CircularDependency {
1041 length: files.len(),
1042 files,
1043 line: 7,
1044 col: 0,
1045 edges: vec![],
1046 is_cross_package: cross_package,
1047 },
1048 actions: vec![],
1049 introduced: None,
1050 });
1051 assert_eq!(build_compact_lines(&results, root), vec![expected]);
1052 }
1053 for (files, kind, expected) in [
1054 (vec![], ReExportCycleKind::MultiNode, "re-export-cycle::"),
1055 (
1056 vec![root.join("src/a.ts")],
1057 ReExportCycleKind::SelfLoop,
1058 "re-export-cycle:src/a.ts:src/a.ts (self-loop)",
1059 ),
1060 (
1061 vec![root.join("src/a.ts"), root.join("src/b.ts")],
1062 ReExportCycleKind::MultiNode,
1063 "re-export-cycle:src/a.ts:src/a.ts <-> src/b.ts",
1064 ),
1065 ] {
1066 let mut results = AnalysisResults::default();
1067 results.re_export_cycles.push(ReExportCycleFinding {
1068 cycle: ReExportCycle { files, kind },
1069 actions: vec![],
1070 introduced: None,
1071 });
1072 assert_eq!(build_compact_lines(&results, root), vec![expected]);
1073 }
1074 }
1075
1076 #[test]
1077 fn compact_unused_file_format_uses_relative_paths() {
1078 let root = PathBuf::from("/project");
1079 let mut results = AnalysisResults::default();
1080 results
1081 .unused_files
1082 .push(UnusedFileFinding::with_actions(UnusedFile {
1083 path: root.join("src/dead.ts"),
1084 }));
1085
1086 let lines = build_compact_lines(&results, &root);
1087
1088 assert_eq!(lines, vec!["unused-file:src/dead.ts"]);
1089 }
1090
1091 #[test]
1092 fn grouped_compact_prefixes_each_issue_with_group_key() {
1093 let root = PathBuf::from("/project");
1094 let mut results = AnalysisResults::default();
1095 results
1096 .unused_files
1097 .push(UnusedFileFinding::with_actions(UnusedFile {
1098 path: root.join("src/dead.ts"),
1099 }));
1100 let groups = vec![ResultGroup {
1101 key: "team-a".to_owned(),
1102 owners: Some(vec!["@team-a".to_owned()]),
1103 results,
1104 }];
1105
1106 let lines = build_grouped_compact_lines(&groups, &root);
1107
1108 assert_eq!(lines, vec!["team-a\tunused-file:src/dead.ts"]);
1109 }
1110
1111 #[test]
1112 fn duplication_compact_lines_include_stable_group_context() {
1113 let root = PathBuf::from("/project");
1114 let report = DuplicationReport {
1115 clone_groups: vec![CloneGroup {
1116 instances: vec![CloneInstance {
1117 file: root.join("src/a.ts"),
1118 start_line: 2,
1119 end_line: 6,
1120 start_col: 0,
1121 end_col: 10,
1122 fragment: "const duplicated = true;".to_owned(),
1123 }],
1124 token_count: 12,
1125 line_count: 5,
1126 similarity: None,
1127 }],
1128 clone_families: Vec::new(),
1129 mirrored_directories: Vec::new(),
1130 stats: DuplicationStats::default(),
1131 };
1132
1133 let lines = build_duplication_compact_lines(&report, &root);
1134
1135 assert_eq!(lines.len(), 1);
1136 assert!(lines[0].starts_with("code-duplication:src/a.ts:2-6:fingerprint="));
1137 assert!(lines[0].contains(",group=1,tokens=12,lines=5,instances=1"));
1138 }
1139
1140 #[test]
1141 fn health_compact_lines_include_score_and_vital_signs() {
1142 let root = PathBuf::from("/project");
1143 let report = fallow_output::HealthReport {
1144 health_score: Some(fallow_output::HealthScore {
1145 formula_version: 1,
1146 score: 91.2,
1147 grade: "A",
1148 penalties: fallow_output::HealthScorePenalties {
1149 dead_files: None,
1150 dead_exports: None,
1151 complexity: 0.0,
1152 p90_complexity: 0.0,
1153 maintainability: None,
1154 hotspots: None,
1155 unused_deps: None,
1156 circular_deps: None,
1157 unit_size: None,
1158 coupling: None,
1159 duplication: None,
1160 prop_drilling: None,
1161 },
1162 }),
1163 vital_signs: Some(fallow_output::VitalSigns {
1164 total_loc: 120,
1165 avg_cyclomatic: 3.4,
1166 p90_cyclomatic: 8,
1167 ..Default::default()
1168 }),
1169 ..Default::default()
1170 };
1171
1172 let lines = build_health_compact_lines(&report, &root);
1173
1174 assert_eq!(lines[0], "health-score:91.2:A");
1175 assert_eq!(
1176 lines[1],
1177 "vital-signs:total_loc=120,avg_cyclomatic=3.4,p90_cyclomatic=8"
1178 );
1179 }
1180
1181 #[test]
1182 fn health_compact_lines_include_styling_findings() {
1183 let root = PathBuf::from("/project");
1184 let report = fallow_output::HealthReport {
1185 styling_findings: vec![fallow_output::StylingFinding {
1186 code: "css-token-drift".to_string(),
1187 sub_kind: "tailwind-arbitrary-value".to_string(),
1188 path: "/project/src/app.css".to_string(),
1189 line: 6,
1190 value: "--color-brand: rgb(240, 90, 41)".to_string(),
1191 effective_severity: fallow_output::StylingFindingSeverity::Warn,
1192 blast_radius: None,
1193 confidence: None,
1194 agent_disposition: None,
1195 nearest_token: None,
1196 fix_hint: None,
1197 actions: Vec::new(),
1198 }],
1199 ..Default::default()
1200 };
1201
1202 let lines = build_health_compact_lines(&report, &root);
1203
1204 assert_eq!(
1205 lines,
1206 vec![
1207 "css-token-drift:src/app.css:6:tailwind-arbitrary-value:severity=warn,value=--color-brand rgb(240 90 41)"
1208 ]
1209 );
1210 }
1211}