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