1use std::path::Path;
2
3use fallow_engine::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_threshold_overrides_compact(&mut lines, &report.threshold_overrides, root);
557 push_file_scores_compact(&mut lines, &report.file_scores, root);
558 push_coverage_gaps_compact(&mut lines, report, root);
559 push_runtime_sections_compact(&mut lines, report, root);
560 push_hotspots_compact(&mut lines, &report.hotspots, root);
561 push_health_trend_compact(&mut lines, report);
562 push_refactoring_targets_compact(&mut lines, &report.targets, root);
563 lines
564}
565
566fn push_threshold_overrides_compact(
567 lines: &mut Vec<String>,
568 entries: &[fallow_output::ThresholdOverrideState],
569 root: &Path,
570) {
571 for entry in entries {
572 let status = match entry.status {
573 fallow_output::ThresholdOverrideStatus::Active => "active",
574 fallow_output::ThresholdOverrideStatus::Stale => "stale",
575 fallow_output::ThresholdOverrideStatus::NoMatch => "no_match",
576 };
577 let target = entry.path.as_ref().map_or_else(
578 || "no-match".to_string(),
579 |path| {
580 let display = health_compact_path(path, root);
581 entry
582 .function
583 .as_ref()
584 .map_or_else(|| display.clone(), |name| format!("{display}:{name}"))
585 },
586 );
587 let metrics = entry.metrics.map_or(String::new(), |metrics| {
588 let crap = metrics
589 .crap
590 .map_or(String::new(), |value| format!(",crap={value:.1}"));
591 format!(
592 ",cyclomatic={},cognitive={}{}",
593 metrics.cyclomatic, metrics.cognitive, crap
594 )
595 });
596 lines.push(format!(
597 "threshold-override:{}:{}:{}{}",
598 entry.override_index, status, target, metrics
599 ));
600 }
601}
602
603fn push_health_score_compact(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
604 if let Some(ref hs) = report.health_score {
605 lines.push(format!("health-score:{:.1}:{}", hs.score, hs.grade));
606 }
607}
608
609fn push_vital_signs_compact(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
610 if let Some(ref vs) = report.vital_signs {
611 let mut parts = Vec::new();
612 if vs.total_loc > 0 {
613 parts.push(format!("total_loc={}", vs.total_loc));
614 }
615 parts.push(format!("avg_cyclomatic={:.1}", vs.avg_cyclomatic));
616 parts.push(format!("p90_cyclomatic={}", vs.p90_cyclomatic));
617 if let Some(v) = vs.dead_file_pct {
618 parts.push(format!("dead_file_pct={v:.1}"));
619 }
620 if let Some(v) = vs.dead_export_pct {
621 parts.push(format!("dead_export_pct={v:.1}"));
622 }
623 if let Some(v) = vs.maintainability_avg {
624 parts.push(format!("maintainability_avg={v:.1}"));
625 }
626 if let Some(v) = vs.hotspot_count {
627 parts.push(format!("hotspot_count={v}"));
628 }
629 if let Some(v) = vs.circular_dep_count {
630 parts.push(format!("circular_dep_count={v}"));
631 }
632 if let Some(v) = vs.unused_dep_count {
633 parts.push(format!("unused_dep_count={v}"));
634 }
635 lines.push(format!("vital-signs:{}", parts.join(",")));
636 }
637}
638
639fn health_compact_path(path: &Path, root: &Path) -> String {
640 normalize_uri(&relative_path(path, root).display().to_string())
641}
642
643fn push_health_findings_compact(
644 lines: &mut Vec<String>,
645 findings: &[fallow_output::HealthFinding],
646 root: &Path,
647) {
648 for finding in findings {
649 let relative = health_compact_path(&finding.path, root);
650 let severity = match finding.severity {
651 fallow_output::FindingSeverity::Critical => "critical",
652 fallow_output::FindingSeverity::High => "high",
653 fallow_output::FindingSeverity::Moderate => "moderate",
654 };
655 let crap_suffix = match finding.crap {
656 Some(crap) => {
657 let coverage = finding
658 .coverage_pct
659 .map(|pct| format!(",coverage_pct={pct:.1}"))
660 .unwrap_or_default();
661 format!(",crap={crap:.1}{coverage}")
662 }
663 None => String::new(),
664 };
665 lines.push(format!(
666 "high-complexity:{}:{}:{}:cyclomatic={},cognitive={},severity={}{}",
667 relative,
668 finding.line,
669 finding.name,
670 finding.cyclomatic,
671 finding.cognitive,
672 severity,
673 crap_suffix,
674 ));
675 }
676}
677
678fn push_file_scores_compact(
679 lines: &mut Vec<String>,
680 scores: &[fallow_output::FileHealthScore],
681 root: &Path,
682) {
683 for score in scores {
684 let relative = health_compact_path(&score.path, root);
685 lines.push(format!(
686 "file-score:{}:mi={:.1},fan_in={},fan_out={},dead={:.2},density={:.2},crap_max={:.1},crap_above={}",
687 relative,
688 score.maintainability_index,
689 score.fan_in,
690 score.fan_out,
691 score.dead_code_ratio,
692 score.complexity_density,
693 score.crap_max,
694 score.crap_above_threshold,
695 ));
696 }
697}
698
699fn push_coverage_gaps_compact(
700 lines: &mut Vec<String>,
701 report: &fallow_output::HealthReport,
702 root: &Path,
703) {
704 if let Some(ref gaps) = report.coverage_gaps {
705 lines.push(format!(
706 "coverage-gap-summary:runtime_files={},covered_files={},file_coverage_pct={:.1},untested_files={},untested_exports={}",
707 gaps.summary.runtime_files,
708 gaps.summary.covered_files,
709 gaps.summary.file_coverage_pct,
710 gaps.summary.untested_files,
711 gaps.summary.untested_exports,
712 ));
713 for item in &gaps.files {
714 let relative = health_compact_path(&item.file.path, root);
715 lines.push(format!(
716 "untested-file:{}:value_exports={}",
717 relative, item.file.value_export_count,
718 ));
719 }
720 for item in &gaps.exports {
721 let relative = health_compact_path(&item.export.path, root);
722 lines.push(format!(
723 "untested-export:{}:{}:{}",
724 relative, item.export.line, item.export.export_name,
725 ));
726 }
727 }
728}
729
730fn push_runtime_sections_compact(
731 lines: &mut Vec<String>,
732 report: &fallow_output::HealthReport,
733 root: &Path,
734) {
735 if let Some(ref production) = report.runtime_coverage {
736 lines.extend(build_runtime_coverage_compact_lines(production, root));
737 }
738 if let Some(ref intelligence) = report.coverage_intelligence {
739 lines.extend(build_coverage_intelligence_compact_lines(
740 intelligence,
741 root,
742 ));
743 }
744}
745
746fn compact_ownership_suffix(ownership: Option<&fallow_output::OwnershipMetrics>) -> String {
747 ownership.map_or_else(String::new, |o| {
748 let mut parts = vec![
749 format!("bus={}", o.bus_factor),
750 format!("contributors={}", o.contributor_count),
751 format!("top={}", o.top_contributor.identifier),
752 format!("top_share={:.3}", o.top_contributor.share),
753 ];
754 if let Some(owner) = &o.declared_owner {
755 parts.push(format!("owner={owner}"));
756 }
757 if let Some(unowned) = o.unowned {
758 parts.push(format!("unowned={unowned}"));
759 }
760 let state = match o.ownership_state {
761 fallow_output::OwnershipState::Active => "active",
762 fallow_output::OwnershipState::Unowned => "unowned",
763 fallow_output::OwnershipState::DeclaredInactive => "declared_inactive",
764 fallow_output::OwnershipState::Drifting => "drifting",
765 };
766 parts.push(format!("ownership_state={state}"));
767 if o.drift {
768 parts.push("drift=true".to_string());
769 }
770 format!(",{}", parts.join(","))
771 })
772}
773
774fn push_hotspots_compact(
775 lines: &mut Vec<String>,
776 hotspots: &[fallow_output::HotspotFinding],
777 root: &Path,
778) {
779 for entry in hotspots {
780 let relative = health_compact_path(&entry.path, root);
781 let ownership_suffix = compact_ownership_suffix(entry.ownership.as_ref());
782 lines.push(format!(
783 "hotspot:{}:score={:.1},commits={},churn={},density={:.2},fan_in={},trend={}{}",
784 relative,
785 entry.score,
786 entry.commits,
787 entry.lines_added + entry.lines_deleted,
788 entry.complexity_density,
789 entry.fan_in,
790 entry.trend,
791 ownership_suffix,
792 ));
793 }
794}
795
796fn push_health_trend_compact(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
797 if let Some(ref trend) = report.health_trend {
798 lines.push(format!(
799 "trend:overall:direction={}",
800 trend.overall_direction.label()
801 ));
802 for m in &trend.metrics {
803 lines.push(format!(
804 "trend:{}:previous={:.1},current={:.1},delta={:+.1},direction={}",
805 m.name,
806 m.previous,
807 m.current,
808 m.delta,
809 m.direction.label(),
810 ));
811 }
812 }
813}
814
815fn push_refactoring_targets_compact(
816 lines: &mut Vec<String>,
817 targets: &[fallow_output::RefactoringTargetFinding],
818 root: &Path,
819) {
820 for target in targets {
821 let relative = health_compact_path(&target.path, root);
822 let category = target.category.compact_label();
823 let effort = target.effort.label();
824 let confidence = target.confidence.label();
825 lines.push(format!(
826 "refactoring-target:{}:priority={:.1},efficiency={:.1},category={},effort={},confidence={}:{}",
827 relative,
828 target.priority,
829 target.efficiency,
830 category,
831 effort,
832 confidence,
833 target.recommendation,
834 ));
835 }
836}
837
838fn build_runtime_coverage_compact_lines(
839 production: &fallow_output::RuntimeCoverageReport,
840 root: &Path,
841) -> Vec<String> {
842 let mut lines = vec![format!(
843 "runtime-coverage-summary:functions_tracked={},functions_hit={},functions_unhit={},functions_untracked={},coverage_percent={:.1},trace_count={},period_days={},deployments_seen={}",
844 production.summary.functions_tracked,
845 production.summary.functions_hit,
846 production.summary.functions_unhit,
847 production.summary.functions_untracked,
848 production.summary.coverage_percent,
849 production.summary.trace_count,
850 production.summary.period_days,
851 production.summary.deployments_seen,
852 )];
853 for finding in &production.findings {
854 let relative = normalize_uri(&relative_path(&finding.path, root).display().to_string());
855 let invocations = finding
856 .invocations
857 .map_or_else(|| "null".to_owned(), |hits| hits.to_string());
858 lines.push(format!(
859 "runtime-coverage:{}:{}:{}:id={},verdict={},invocations={},confidence={}",
860 relative,
861 finding.line,
862 finding.function,
863 finding.id,
864 finding.verdict,
865 invocations,
866 finding.confidence,
867 ));
868 }
869 for entry in &production.hot_paths {
870 let relative = normalize_uri(&relative_path(&entry.path, root).display().to_string());
871 lines.push(format!(
872 "production-hot-path:{}:{}:{}:id={},invocations={},percentile={}",
873 relative, entry.line, entry.function, entry.id, entry.invocations, entry.percentile,
874 ));
875 }
876 lines
877}
878
879fn build_coverage_intelligence_compact_lines(
880 intelligence: &fallow_output::CoverageIntelligenceReport,
881 root: &Path,
882) -> Vec<String> {
883 let mut lines = vec![format!(
884 "coverage-intelligence-summary:verdict={},findings={},risky_changes={},high_confidence_deletes={},review_required={},refactor_carefully={},skipped_ambiguous_matches={}",
885 intelligence.verdict,
886 intelligence.summary.findings,
887 intelligence.summary.risky_changes,
888 intelligence.summary.high_confidence_deletes,
889 intelligence.summary.review_required,
890 intelligence.summary.refactor_carefully,
891 intelligence.summary.skipped_ambiguous_matches,
892 )];
893 for finding in &intelligence.findings {
894 let relative = normalize_uri(&relative_path(&finding.path, root).display().to_string());
895 let identity = finding.identity.as_deref().unwrap_or("-");
896 let signals = finding
897 .signals
898 .iter()
899 .map(ToString::to_string)
900 .collect::<Vec<_>>()
901 .join("+");
902 lines.push(format!(
903 "coverage-intelligence:{}:{}:{}:id={},verdict={},recommendation={},confidence={},signals={}",
904 relative,
905 finding.line,
906 identity,
907 finding.id,
908 finding.verdict,
909 finding.recommendation,
910 finding.confidence,
911 signals,
912 ));
913 }
914 lines
915}
916
917#[must_use]
919pub fn build_duplication_compact_lines(report: &DuplicationReport, root: &Path) -> Vec<String> {
920 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
921 let mut lines = Vec::new();
922 for (index, group) in report.clone_groups.iter().enumerate() {
923 let fingerprint = fingerprints.fingerprint_for_group(group);
924 for instance in &group.instances {
925 lines.push(format!(
926 "code-duplication:{}:{}-{}:fingerprint={},group={},tokens={},lines={},instances={}",
927 compact_path(&instance.file, root),
928 instance.start_line,
929 instance.end_line,
930 fingerprint,
931 index + 1,
932 group.token_count,
933 group.line_count,
934 group.instances.len(),
935 ));
936 }
937 }
938 lines
939}
940
941#[cfg(test)]
942mod tests {
943 use std::path::PathBuf;
944
945 use fallow_types::duplicates::{CloneGroup, CloneInstance, DuplicationStats};
946 use fallow_types::output_dead_code::UnusedFileFinding;
947 use fallow_types::results::{AnalysisResults, UnusedFile};
948
949 use super::*;
950
951 #[test]
952 fn compact_unused_file_format_uses_relative_paths() {
953 let root = PathBuf::from("/project");
954 let mut results = AnalysisResults::default();
955 results
956 .unused_files
957 .push(UnusedFileFinding::with_actions(UnusedFile {
958 path: root.join("src/dead.ts"),
959 }));
960
961 let lines = build_compact_lines(&results, &root);
962
963 assert_eq!(lines, vec!["unused-file:src/dead.ts"]);
964 }
965
966 #[test]
967 fn grouped_compact_prefixes_each_issue_with_group_key() {
968 let root = PathBuf::from("/project");
969 let mut results = AnalysisResults::default();
970 results
971 .unused_files
972 .push(UnusedFileFinding::with_actions(UnusedFile {
973 path: root.join("src/dead.ts"),
974 }));
975 let groups = vec![ResultGroup {
976 key: "team-a".to_owned(),
977 owners: Some(vec!["@team-a".to_owned()]),
978 results,
979 }];
980
981 let lines = build_grouped_compact_lines(&groups, &root);
982
983 assert_eq!(lines, vec!["team-a\tunused-file:src/dead.ts"]);
984 }
985
986 #[test]
987 fn duplication_compact_lines_include_stable_group_context() {
988 let root = PathBuf::from("/project");
989 let report = DuplicationReport {
990 clone_groups: vec![CloneGroup {
991 instances: vec![CloneInstance {
992 file: root.join("src/a.ts"),
993 start_line: 2,
994 end_line: 6,
995 start_col: 0,
996 end_col: 10,
997 fragment: "const duplicated = true;".to_owned(),
998 }],
999 token_count: 12,
1000 line_count: 5,
1001 }],
1002 clone_families: Vec::new(),
1003 mirrored_directories: Vec::new(),
1004 stats: DuplicationStats::default(),
1005 };
1006
1007 let lines = build_duplication_compact_lines(&report, &root);
1008
1009 assert_eq!(lines.len(), 1);
1010 assert!(lines[0].starts_with("code-duplication:src/a.ts:2-6:fingerprint="));
1011 assert!(lines[0].contains(",group=1,tokens=12,lines=5,instances=1"));
1012 }
1013
1014 #[test]
1015 fn health_compact_lines_include_score_and_vital_signs() {
1016 let root = PathBuf::from("/project");
1017 let report = fallow_output::HealthReport {
1018 health_score: Some(fallow_output::HealthScore {
1019 formula_version: 1,
1020 score: 91.2,
1021 grade: "A",
1022 penalties: fallow_output::HealthScorePenalties {
1023 dead_files: None,
1024 dead_exports: None,
1025 complexity: 0.0,
1026 p90_complexity: 0.0,
1027 maintainability: None,
1028 hotspots: None,
1029 unused_deps: None,
1030 circular_deps: None,
1031 unit_size: None,
1032 coupling: None,
1033 duplication: None,
1034 prop_drilling: None,
1035 },
1036 }),
1037 vital_signs: Some(fallow_output::VitalSigns {
1038 total_loc: 120,
1039 avg_cyclomatic: 3.4,
1040 p90_cyclomatic: 8,
1041 ..Default::default()
1042 }),
1043 ..Default::default()
1044 };
1045
1046 let lines = build_health_compact_lines(&report, &root);
1047
1048 assert_eq!(lines[0], "health-score:91.2:A");
1049 assert_eq!(
1050 lines[1],
1051 "vital-signs:total_loc=120,avg_cyclomatic=3.4,p90_cyclomatic=8"
1052 );
1053 }
1054}