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