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 for dep in &self.results.dev_dependencies_in_production {
315 self.lines
316 .push(format!("dev-dep-in-prod:{}", dep.dep.package_name));
317 }
318 }
319
320 fn push_graph_lines(&mut self) {
321 self.push_structure_lines();
322 self.push_framework_lines();
323 self.push_component_lines();
324 self.push_route_lines();
325 self.push_suppression_lines();
326 }
327
328 fn push_structure_lines(&mut self) {
329 for cycle in &self.results.circular_dependencies {
330 self.lines
331 .push(compact_circular_dependency_line(cycle, self.root));
332 }
333 for cycle in &self.results.re_export_cycles {
334 self.lines
335 .push(compact_re_export_cycle_line(cycle, self.root));
336 }
337 for violation in &self.results.boundary_violations {
338 self.lines
339 .push(compact_boundary_violation_line(violation, self.root));
340 }
341 for violation in &self.results.boundary_coverage_violations {
342 self.lines
343 .push(compact_boundary_coverage_line(violation, self.root));
344 }
345 for violation in &self.results.boundary_call_violations {
346 self.lines
347 .push(compact_boundary_call_line(violation, self.root));
348 }
349 for violation in &self.results.policy_violations {
350 self.lines.push(format!(
351 "policy-violation:{}:{}:{} banned by {}/{}",
352 self.rel(&violation.violation.path),
353 violation.violation.line,
354 violation.violation.matched,
355 violation.violation.pack,
356 violation.violation.rule_id,
357 ));
358 }
359 }
360
361 fn push_framework_lines(&mut self) {
362 for finding in &self.results.invalid_client_exports {
363 self.lines.push(format!(
364 "invalid-client-export:{}:{}:{} (from \"{}\")",
365 self.rel(&finding.export.path),
366 finding.export.line,
367 finding.export.export_name,
368 finding.export.directive,
369 ));
370 }
371 for finding in &self.results.mixed_client_server_barrels {
372 self.lines.push(format!(
373 "mixed-client-server-barrel:{}:{}:{} (server-only \"{}\")",
374 self.rel(&finding.barrel.path),
375 finding.barrel.line,
376 finding.barrel.client_origin,
377 finding.barrel.server_origin,
378 ));
379 }
380 for finding in &self.results.misplaced_directives {
381 self.lines.push(format!(
382 "misplaced-directive:{}:{}:{}",
383 self.rel(&finding.directive_site.path),
384 finding.directive_site.line,
385 finding.directive_site.directive,
386 ));
387 }
388 for finding in &self.results.unprovided_injects {
389 self.lines.push(format!(
390 "unprovided-inject:{}:{}:{}",
391 self.rel(&finding.inject.path),
392 finding.inject.line,
393 finding.inject.key_name,
394 ));
395 }
396 }
397
398 fn push_component_lines(&mut self) {
399 self.push_component_member_lines();
400 self.push_component_framework_lines();
401 }
402
403 fn push_component_member_lines(&mut self) {
405 for finding in &self.results.unrendered_components {
406 self.lines.push(format!(
407 "unrendered-component:{}:{}:{}",
408 self.rel(&finding.component.path),
409 finding.component.line,
410 finding.component.component_name,
411 ));
412 }
413 for finding in &self.results.unused_component_props {
414 self.lines.push(format!(
415 "unused-component-prop:{}:{}:{}",
416 self.rel(&finding.prop.path),
417 finding.prop.line,
418 finding.prop.prop_name,
419 ));
420 }
421 for finding in &self.results.unused_component_emits {
422 self.lines.push(format!(
423 "unused-component-emit:{}:{}:{}",
424 self.rel(&finding.emit.path),
425 finding.emit.line,
426 finding.emit.emit_name,
427 ));
428 }
429 for finding in &self.results.unused_component_inputs {
430 self.lines.push(format!(
431 "unused-component-input:{}:{}:{}",
432 self.rel(&finding.input.path),
433 finding.input.line,
434 finding.input.input_name,
435 ));
436 }
437 for finding in &self.results.unused_component_outputs {
438 self.lines.push(format!(
439 "unused-component-output:{}:{}:{}",
440 self.rel(&finding.output.path),
441 finding.output.line,
442 finding.output.output_name,
443 ));
444 }
445 }
446
447 fn push_component_framework_lines(&mut self) {
449 for finding in &self.results.unused_svelte_events {
450 self.lines.push(format!(
451 "unused-svelte-event:{}:{}:{}",
452 self.rel(&finding.event.path),
453 finding.event.line,
454 finding.event.event_name,
455 ));
456 }
457 for finding in &self.results.unused_server_actions {
458 self.lines.push(format!(
459 "unused-server-action:{}:{}:{}",
460 self.rel(&finding.action.path),
461 finding.action.line,
462 finding.action.action_name,
463 ));
464 }
465 for finding in &self.results.unused_load_data_keys {
466 self.lines.push(format!(
467 "unused-load-data-key:{}:{}:{}",
468 self.rel(&finding.key.path),
469 finding.key.line,
470 finding.key.key_name,
471 ));
472 }
473 }
474
475 fn push_route_lines(&mut self) {
476 for finding in &self.results.route_collisions {
477 self.lines.push(format!(
478 "route-collision:{}:{} (url {})",
479 self.rel(&finding.collision.path),
480 finding.collision.line,
481 finding.collision.url,
482 ));
483 }
484 for finding in &self.results.dynamic_segment_name_conflicts {
485 self.lines.push(format!(
486 "dynamic-segment-name-conflict:{}:{} ({} at {})",
487 self.rel(&finding.conflict.path),
488 finding.conflict.line,
489 finding.conflict.conflicting_segments.join(" vs "),
490 finding.conflict.position,
491 ));
492 }
493 }
494
495 fn push_suppression_lines(&mut self) {
496 for suppression in &self.results.stale_suppressions {
497 self.lines
498 .push(compact_stale_suppression_line(suppression, self.root));
499 }
500 }
501
502 fn push_workspace_lines(&mut self) {
503 for entry in &self.results.unused_catalog_entries {
504 self.lines.push(format!(
505 "unused-catalog-entry:{}:{}:{}:{}",
506 self.rel(&entry.entry.path),
507 entry.entry.line,
508 entry.entry.catalog_name,
509 entry.entry.entry_name,
510 ));
511 }
512 for group in &self.results.empty_catalog_groups {
513 self.lines.push(format!(
514 "empty-catalog-group:{}:{}:{}",
515 self.rel(&group.group.path),
516 group.group.line,
517 group.group.catalog_name,
518 ));
519 }
520 for finding in &self.results.unresolved_catalog_references {
521 self.lines
522 .push(compact_catalog_reference_line(finding, self.root));
523 }
524 for finding in &self.results.unused_dependency_overrides {
525 self.lines
526 .push(compact_unused_override_line(finding, self.root));
527 }
528 for finding in &self.results.misconfigured_dependency_overrides {
529 self.lines
530 .push(compact_misconfigured_override_line(finding, self.root));
531 }
532 }
533}
534
535#[must_use]
539pub fn build_grouped_compact_lines(groups: &[ResultGroup], root: &Path) -> Vec<String> {
540 groups
541 .iter()
542 .flat_map(|group| {
543 build_compact_lines(&group.results, root)
544 .into_iter()
545 .map(|line| format!("{}\t{line}", group.key))
546 })
547 .collect()
548}
549
550#[must_use]
552pub fn build_health_compact_lines(
553 report: &fallow_output::HealthReport,
554 root: &Path,
555) -> Vec<String> {
556 let mut lines = Vec::new();
557 push_health_score_compact(&mut lines, report);
558 push_vital_signs_compact(&mut lines, report);
559 push_health_findings_compact(&mut lines, &report.findings, root);
560 push_styling_findings_compact(&mut lines, &report.styling_findings, root);
561 push_threshold_overrides_compact(&mut lines, &report.threshold_overrides, root);
562 push_file_scores_compact(&mut lines, &report.file_scores, root);
563 push_coverage_gaps_compact(&mut lines, report, root);
564 push_runtime_sections_compact(&mut lines, report, root);
565 push_hotspots_compact(&mut lines, &report.hotspots, root);
566 push_health_trend_compact(&mut lines, report);
567 push_refactoring_targets_compact(&mut lines, &report.targets, root);
568 lines
569}
570
571fn push_styling_findings_compact(
572 lines: &mut Vec<String>,
573 findings: &[fallow_output::StylingFinding],
574 root: &Path,
575) {
576 for finding in findings {
577 let relative = health_compact_path(Path::new(&finding.path), root);
578 let severity = match finding.effective_severity {
579 fallow_output::StylingFindingSeverity::Error => "error",
580 fallow_output::StylingFindingSeverity::Warn => "warn",
581 };
582 let value = compact_field_value(&finding.value);
583 lines.push(format!(
584 "{}:{}:{}:{}:severity={},value={}",
585 finding.code, relative, finding.line, finding.sub_kind, severity, value
586 ));
587 }
588}
589
590fn compact_field_value(value: &str) -> String {
591 value
592 .replace([':', ',', '\n', '\r'], " ")
593 .split_whitespace()
594 .collect::<Vec<_>>()
595 .join(" ")
596}
597
598fn push_threshold_overrides_compact(
599 lines: &mut Vec<String>,
600 entries: &[fallow_output::ThresholdOverrideState],
601 root: &Path,
602) {
603 for entry in entries {
604 let status = match entry.status {
605 fallow_output::ThresholdOverrideStatus::Active => "active",
606 fallow_output::ThresholdOverrideStatus::Stale => "stale",
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| {
612 let display = health_compact_path(path, root);
613 entry
614 .function
615 .as_ref()
616 .map_or_else(|| display.clone(), |name| format!("{display}:{name}"))
617 },
618 );
619 let metrics = entry.metrics.map_or(String::new(), |metrics| {
620 let crap = metrics
621 .crap
622 .map_or(String::new(), |value| format!(",crap={value:.1}"));
623 format!(
624 ",cyclomatic={},cognitive={}{}",
625 metrics.cyclomatic, metrics.cognitive, crap
626 )
627 });
628 lines.push(format!(
629 "threshold-override:{}:{}:{}{}",
630 entry.override_index, status, target, metrics
631 ));
632 }
633}
634
635fn push_health_score_compact(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
636 if let Some(ref hs) = report.health_score {
637 lines.push(format!("health-score:{:.1}:{}", hs.score, hs.grade));
638 }
639}
640
641fn push_vital_signs_compact(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
642 if let Some(ref vs) = report.vital_signs {
643 let mut parts = Vec::new();
644 if vs.total_loc > 0 {
645 parts.push(format!("total_loc={}", vs.total_loc));
646 }
647 parts.push(format!("avg_cyclomatic={:.1}", vs.avg_cyclomatic));
648 parts.push(format!("p90_cyclomatic={}", vs.p90_cyclomatic));
649 if let Some(v) = vs.dead_file_pct {
650 parts.push(format!("dead_file_pct={v:.1}"));
651 }
652 if let Some(v) = vs.dead_export_pct {
653 parts.push(format!("dead_export_pct={v:.1}"));
654 }
655 if let Some(v) = vs.maintainability_avg {
656 parts.push(format!("maintainability_avg={v:.1}"));
657 }
658 if let Some(v) = vs.hotspot_count {
659 parts.push(format!("hotspot_count={v}"));
660 }
661 if let Some(v) = vs.circular_dep_count {
662 parts.push(format!("circular_dep_count={v}"));
663 }
664 if let Some(v) = vs.unused_dep_count {
665 parts.push(format!("unused_dep_count={v}"));
666 }
667 lines.push(format!("vital-signs:{}", parts.join(",")));
668 }
669}
670
671fn health_compact_path(path: &Path, root: &Path) -> String {
672 normalize_uri(&relative_path(path, root).display().to_string())
673}
674
675fn push_health_findings_compact(
676 lines: &mut Vec<String>,
677 findings: &[fallow_output::HealthFinding],
678 root: &Path,
679) {
680 for finding in findings {
681 let relative = health_compact_path(&finding.path, root);
682 let severity = match finding.severity {
683 fallow_output::FindingSeverity::Critical => "critical",
684 fallow_output::FindingSeverity::High => "high",
685 fallow_output::FindingSeverity::Moderate => "moderate",
686 };
687 let crap_suffix = match finding.crap {
688 Some(crap) => {
689 let coverage = finding
690 .coverage_pct
691 .map(|pct| format!(",coverage_pct={pct:.1}"))
692 .unwrap_or_default();
693 format!(",crap={crap:.1}{coverage}")
694 }
695 None => String::new(),
696 };
697 lines.push(format!(
698 "high-complexity:{}:{}:{}:cyclomatic={},cognitive={},severity={}{}",
699 relative,
700 finding.line,
701 finding.name,
702 finding.cyclomatic,
703 finding.cognitive,
704 severity,
705 crap_suffix,
706 ));
707 }
708}
709
710fn push_file_scores_compact(
711 lines: &mut Vec<String>,
712 scores: &[fallow_output::FileHealthScore],
713 root: &Path,
714) {
715 for score in scores {
716 let relative = health_compact_path(&score.path, root);
717 lines.push(format!(
718 "file-score:{}:mi={:.1},fan_in={},fan_out={},dead={:.2},density={:.2},crap_max={:.1},crap_above={}",
719 relative,
720 score.maintainability_index,
721 score.fan_in,
722 score.fan_out,
723 score.dead_code_ratio,
724 score.complexity_density,
725 score.crap_max,
726 score.crap_above_threshold,
727 ));
728 }
729}
730
731fn push_coverage_gaps_compact(
732 lines: &mut Vec<String>,
733 report: &fallow_output::HealthReport,
734 root: &Path,
735) {
736 if let Some(ref gaps) = report.coverage_gaps {
737 lines.push(format!(
738 "coverage-gap-summary:runtime_files={},covered_files={},file_coverage_pct={:.1},untested_files={},untested_exports={}",
739 gaps.summary.runtime_files,
740 gaps.summary.covered_files,
741 gaps.summary.file_coverage_pct,
742 gaps.summary.untested_files,
743 gaps.summary.untested_exports,
744 ));
745 for item in &gaps.files {
746 let relative = health_compact_path(&item.file.path, root);
747 lines.push(format!(
748 "untested-file:{}:value_exports={}",
749 relative, item.file.value_export_count,
750 ));
751 }
752 for item in &gaps.exports {
753 let relative = health_compact_path(&item.export.path, root);
754 lines.push(format!(
755 "untested-export:{}:{}:{}",
756 relative, item.export.line, item.export.export_name,
757 ));
758 }
759 }
760}
761
762fn push_runtime_sections_compact(
763 lines: &mut Vec<String>,
764 report: &fallow_output::HealthReport,
765 root: &Path,
766) {
767 if let Some(ref production) = report.runtime_coverage {
768 lines.extend(build_runtime_coverage_compact_lines(production, root));
769 }
770 if let Some(ref intelligence) = report.coverage_intelligence {
771 lines.extend(build_coverage_intelligence_compact_lines(
772 intelligence,
773 root,
774 ));
775 }
776}
777
778fn compact_ownership_suffix(ownership: Option<&fallow_output::OwnershipMetrics>) -> String {
779 ownership.map_or_else(String::new, |o| {
780 let mut parts = vec![
781 format!("bus={}", o.bus_factor),
782 format!("contributors={}", o.contributor_count),
783 format!("top={}", o.top_contributor.identifier),
784 format!("top_share={:.3}", o.top_contributor.share),
785 ];
786 if let Some(owner) = &o.declared_owner {
787 parts.push(format!("owner={owner}"));
788 }
789 if let Some(unowned) = o.unowned {
790 parts.push(format!("unowned={unowned}"));
791 }
792 let state = match o.ownership_state {
793 fallow_output::OwnershipState::Active => "active",
794 fallow_output::OwnershipState::Unowned => "unowned",
795 fallow_output::OwnershipState::DeclaredInactive => "declared_inactive",
796 fallow_output::OwnershipState::Drifting => "drifting",
797 };
798 parts.push(format!("ownership_state={state}"));
799 if o.drift {
800 parts.push("drift=true".to_string());
801 }
802 format!(",{}", parts.join(","))
803 })
804}
805
806fn push_hotspots_compact(
807 lines: &mut Vec<String>,
808 hotspots: &[fallow_output::HotspotFinding],
809 root: &Path,
810) {
811 for entry in hotspots {
812 let relative = health_compact_path(&entry.path, root);
813 let ownership_suffix = compact_ownership_suffix(entry.ownership.as_ref());
814 lines.push(format!(
815 "hotspot:{}:score={:.1},commits={},churn={},density={:.2},fan_in={},trend={}{}",
816 relative,
817 entry.score,
818 entry.commits,
819 entry.lines_added + entry.lines_deleted,
820 entry.complexity_density,
821 entry.fan_in,
822 entry.trend,
823 ownership_suffix,
824 ));
825 }
826}
827
828fn push_health_trend_compact(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
829 if let Some(ref trend) = report.health_trend {
830 lines.push(format!(
831 "trend:overall:direction={}",
832 trend.overall_direction.label()
833 ));
834 for m in &trend.metrics {
835 lines.push(format!(
836 "trend:{}:previous={:.1},current={:.1},delta={:+.1},direction={}",
837 m.name,
838 m.previous,
839 m.current,
840 m.delta,
841 m.direction.label(),
842 ));
843 }
844 }
845}
846
847fn push_refactoring_targets_compact(
848 lines: &mut Vec<String>,
849 targets: &[fallow_output::RefactoringTargetFinding],
850 root: &Path,
851) {
852 for target in targets {
853 let relative = health_compact_path(&target.path, root);
854 let category = target.category.compact_label();
855 let effort = target.effort.label();
856 let confidence = target.confidence.label();
857 lines.push(format!(
858 "refactoring-target:{}:priority={:.1},efficiency={:.1},category={},effort={},confidence={}:{}",
859 relative,
860 target.priority,
861 target.efficiency,
862 category,
863 effort,
864 confidence,
865 target.recommendation,
866 ));
867 }
868}
869
870fn build_runtime_coverage_compact_lines(
871 production: &fallow_output::RuntimeCoverageReport,
872 root: &Path,
873) -> Vec<String> {
874 let mut lines = vec![format!(
875 "runtime-coverage-summary:functions_tracked={},functions_hit={},functions_unhit={},functions_untracked={},coverage_percent={:.1},trace_count={},period_days={},deployments_seen={}",
876 production.summary.functions_tracked,
877 production.summary.functions_hit,
878 production.summary.functions_unhit,
879 production.summary.functions_untracked,
880 production.summary.coverage_percent,
881 production.summary.trace_count,
882 production.summary.period_days,
883 production.summary.deployments_seen,
884 )];
885 for finding in &production.findings {
886 let relative = normalize_uri(&relative_path(&finding.path, root).display().to_string());
887 let invocations = finding
888 .invocations
889 .map_or_else(|| "null".to_owned(), |hits| hits.to_string());
890 lines.push(format!(
891 "runtime-coverage:{}:{}:{}:id={},verdict={},invocations={},confidence={}",
892 relative,
893 finding.line,
894 finding.function,
895 finding.id,
896 finding.verdict,
897 invocations,
898 finding.confidence,
899 ));
900 }
901 for entry in &production.hot_paths {
902 let relative = normalize_uri(&relative_path(&entry.path, root).display().to_string());
903 lines.push(format!(
904 "production-hot-path:{}:{}:{}:id={},invocations={},percentile={}",
905 relative, entry.line, entry.function, entry.id, entry.invocations, entry.percentile,
906 ));
907 }
908 lines
909}
910
911fn build_coverage_intelligence_compact_lines(
912 intelligence: &fallow_output::CoverageIntelligenceReport,
913 root: &Path,
914) -> Vec<String> {
915 let mut lines = vec![format!(
916 "coverage-intelligence-summary:verdict={},findings={},risky_changes={},high_confidence_deletes={},review_required={},refactor_carefully={},skipped_ambiguous_matches={}",
917 intelligence.verdict,
918 intelligence.summary.findings,
919 intelligence.summary.risky_changes,
920 intelligence.summary.high_confidence_deletes,
921 intelligence.summary.review_required,
922 intelligence.summary.refactor_carefully,
923 intelligence.summary.skipped_ambiguous_matches,
924 )];
925 for finding in &intelligence.findings {
926 let relative = normalize_uri(&relative_path(&finding.path, root).display().to_string());
927 let identity = finding.identity.as_deref().unwrap_or("-");
928 let signals = finding
929 .signals
930 .iter()
931 .map(ToString::to_string)
932 .collect::<Vec<_>>()
933 .join("+");
934 lines.push(format!(
935 "coverage-intelligence:{}:{}:{}:id={},verdict={},recommendation={},confidence={},signals={}",
936 relative,
937 finding.line,
938 identity,
939 finding.id,
940 finding.verdict,
941 finding.recommendation,
942 finding.confidence,
943 signals,
944 ));
945 }
946 lines
947}
948
949#[must_use]
951pub fn build_duplication_compact_lines(report: &DuplicationReport, root: &Path) -> Vec<String> {
952 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
953 let mut lines = Vec::new();
954 for (index, group) in report.clone_groups.iter().enumerate() {
955 let fingerprint = fingerprints.fingerprint_for_group(group);
956 for instance in &group.instances {
957 lines.push(format!(
958 "code-duplication:{}:{}-{}:fingerprint={},group={},tokens={},lines={},instances={}",
959 compact_path(&instance.file, root),
960 instance.start_line,
961 instance.end_line,
962 fingerprint,
963 index + 1,
964 group.token_count,
965 group.line_count,
966 group.instances.len(),
967 ));
968 }
969 }
970 lines
971}
972
973#[cfg(test)]
974mod tests {
975 use std::path::PathBuf;
976
977 use fallow_types::duplicates::{CloneGroup, CloneInstance, DuplicationStats};
978 use fallow_types::output_dead_code::UnusedFileFinding;
979 use fallow_types::results::{AnalysisResults, UnusedFile};
980
981 use super::*;
982
983 #[test]
984 fn compact_unused_file_format_uses_relative_paths() {
985 let root = PathBuf::from("/project");
986 let mut results = AnalysisResults::default();
987 results
988 .unused_files
989 .push(UnusedFileFinding::with_actions(UnusedFile {
990 path: root.join("src/dead.ts"),
991 }));
992
993 let lines = build_compact_lines(&results, &root);
994
995 assert_eq!(lines, vec!["unused-file:src/dead.ts"]);
996 }
997
998 #[test]
999 fn grouped_compact_prefixes_each_issue_with_group_key() {
1000 let root = PathBuf::from("/project");
1001 let mut results = AnalysisResults::default();
1002 results
1003 .unused_files
1004 .push(UnusedFileFinding::with_actions(UnusedFile {
1005 path: root.join("src/dead.ts"),
1006 }));
1007 let groups = vec![ResultGroup {
1008 key: "team-a".to_owned(),
1009 owners: Some(vec!["@team-a".to_owned()]),
1010 results,
1011 }];
1012
1013 let lines = build_grouped_compact_lines(&groups, &root);
1014
1015 assert_eq!(lines, vec!["team-a\tunused-file:src/dead.ts"]);
1016 }
1017
1018 #[test]
1019 fn duplication_compact_lines_include_stable_group_context() {
1020 let root = PathBuf::from("/project");
1021 let report = DuplicationReport {
1022 clone_groups: vec![CloneGroup {
1023 instances: vec![CloneInstance {
1024 file: root.join("src/a.ts"),
1025 start_line: 2,
1026 end_line: 6,
1027 start_col: 0,
1028 end_col: 10,
1029 fragment: "const duplicated = true;".to_owned(),
1030 }],
1031 token_count: 12,
1032 line_count: 5,
1033 }],
1034 clone_families: Vec::new(),
1035 mirrored_directories: Vec::new(),
1036 stats: DuplicationStats::default(),
1037 };
1038
1039 let lines = build_duplication_compact_lines(&report, &root);
1040
1041 assert_eq!(lines.len(), 1);
1042 assert!(lines[0].starts_with("code-duplication:src/a.ts:2-6:fingerprint="));
1043 assert!(lines[0].contains(",group=1,tokens=12,lines=5,instances=1"));
1044 }
1045
1046 #[test]
1047 fn health_compact_lines_include_score_and_vital_signs() {
1048 let root = PathBuf::from("/project");
1049 let report = fallow_output::HealthReport {
1050 health_score: Some(fallow_output::HealthScore {
1051 formula_version: 1,
1052 score: 91.2,
1053 grade: "A",
1054 penalties: fallow_output::HealthScorePenalties {
1055 dead_files: None,
1056 dead_exports: None,
1057 complexity: 0.0,
1058 p90_complexity: 0.0,
1059 maintainability: None,
1060 hotspots: None,
1061 unused_deps: None,
1062 circular_deps: None,
1063 unit_size: None,
1064 coupling: None,
1065 duplication: None,
1066 prop_drilling: None,
1067 },
1068 }),
1069 vital_signs: Some(fallow_output::VitalSigns {
1070 total_loc: 120,
1071 avg_cyclomatic: 3.4,
1072 p90_cyclomatic: 8,
1073 ..Default::default()
1074 }),
1075 ..Default::default()
1076 };
1077
1078 let lines = build_health_compact_lines(&report, &root);
1079
1080 assert_eq!(lines[0], "health-score:91.2:A");
1081 assert_eq!(
1082 lines[1],
1083 "vital-signs:total_loc=120,avg_cyclomatic=3.4,p90_cyclomatic=8"
1084 );
1085 }
1086
1087 #[test]
1088 fn health_compact_lines_include_styling_findings() {
1089 let root = PathBuf::from("/project");
1090 let report = fallow_output::HealthReport {
1091 styling_findings: vec![fallow_output::StylingFinding {
1092 code: "css-token-drift".to_string(),
1093 sub_kind: "tailwind-arbitrary-value".to_string(),
1094 path: "/project/src/app.css".to_string(),
1095 line: 6,
1096 value: "--color-brand: rgb(240, 90, 41)".to_string(),
1097 effective_severity: fallow_output::StylingFindingSeverity::Warn,
1098 blast_radius: None,
1099 confidence: None,
1100 agent_disposition: None,
1101 nearest_token: None,
1102 fix_hint: None,
1103 actions: Vec::new(),
1104 }],
1105 ..Default::default()
1106 };
1107
1108 let lines = build_health_compact_lines(&report, &root);
1109
1110 assert_eq!(
1111 lines,
1112 vec![
1113 "css-token-drift:src/app.css:6:tailwind-arbitrary-value:severity=warn,value=--color-brand rgb(240 90 41)"
1114 ]
1115 );
1116 }
1117}