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::Insufficient => "insufficient",
608 fallow_output::ThresholdOverrideStatus::NoMatch => "no_match",
609 };
610 let target = entry.path.as_ref().map_or_else(
611 || "no-match".to_string(),
612 |path| entry.target_label(&health_compact_path(path, root)),
613 );
614 let dimension = threshold_override_dimension_label(entry.dimension);
615 let metrics = entry.metrics.map_or(String::new(), |metrics| {
616 let crap = metrics
617 .crap
618 .map_or(String::new(), |value| format!(",crap={value:.1}"));
619 format!(
620 ",cyclomatic={},cognitive={}{}",
621 metrics.cyclomatic, metrics.cognitive, crap
622 )
623 });
624 let outstanding = if entry.outstanding.is_empty() {
625 String::new()
626 } else {
627 format!(
628 ",outstanding={}",
629 entry
630 .outstanding
631 .iter()
632 .map(|value| threshold_override_dimension_label(*value))
633 .collect::<Vec<_>>()
634 .join("|")
635 )
636 };
637 lines.push(format!(
638 "threshold-override:{}:{}:{}:{}{}{}",
639 entry.override_index, dimension, status, target, metrics, outstanding
640 ));
641 }
642}
643
644fn threshold_override_dimension_label(
645 dimension: fallow_output::ThresholdOverrideDimension,
646) -> &'static str {
647 match dimension {
648 fallow_output::ThresholdOverrideDimension::Complexity => "complexity",
649 fallow_output::ThresholdOverrideDimension::Crap => "crap",
650 }
651}
652
653fn push_health_score_compact(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
654 if let Some(ref hs) = report.health_score {
655 lines.push(format!("health-score:{:.1}:{}", hs.score, hs.grade));
656 }
657}
658
659fn push_vital_signs_compact(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
660 if let Some(ref vs) = report.vital_signs {
661 let mut parts = Vec::new();
662 if vs.total_loc > 0 {
663 parts.push(format!("total_loc={}", vs.total_loc));
664 }
665 parts.push(format!("avg_cyclomatic={:.1}", vs.avg_cyclomatic));
666 parts.push(format!("p90_cyclomatic={}", vs.p90_cyclomatic));
667 if let Some(v) = vs.dead_file_pct {
668 parts.push(format!("dead_file_pct={v:.1}"));
669 }
670 if let Some(v) = vs.dead_export_pct {
671 parts.push(format!("dead_export_pct={v:.1}"));
672 }
673 if let Some(v) = vs.maintainability_avg {
674 parts.push(format!("maintainability_avg={v:.1}"));
675 }
676 if let Some(v) = vs.hotspot_count {
677 parts.push(format!("hotspot_count={v}"));
678 }
679 if let Some(v) = vs.circular_dep_count {
680 parts.push(format!("circular_dep_count={v}"));
681 }
682 if let Some(v) = vs.unused_dep_count {
683 parts.push(format!("unused_dep_count={v}"));
684 }
685 lines.push(format!("vital-signs:{}", parts.join(",")));
686 }
687}
688
689fn health_compact_path(path: &Path, root: &Path) -> String {
690 normalize_uri(&relative_path(path, root).display().to_string())
691}
692
693fn exceeded_compact_label(exceeded: fallow_output::ExceededThreshold) -> &'static str {
695 match exceeded {
696 fallow_output::ExceededThreshold::Cyclomatic => "cyclomatic",
697 fallow_output::ExceededThreshold::Cognitive => "cognitive",
698 fallow_output::ExceededThreshold::Both => "both",
699 fallow_output::ExceededThreshold::Crap => "crap",
700 fallow_output::ExceededThreshold::CyclomaticCrap => "cyclomatic_crap",
701 fallow_output::ExceededThreshold::CognitiveCrap => "cognitive_crap",
702 fallow_output::ExceededThreshold::All => "all",
703 }
704}
705
706fn push_health_findings_compact(
707 lines: &mut Vec<String>,
708 findings: &[fallow_output::HealthFinding],
709 root: &Path,
710) {
711 for finding in findings {
712 let relative = health_compact_path(&finding.path, root);
713 let severity = match finding.severity {
714 fallow_output::FindingSeverity::Critical => "critical",
715 fallow_output::FindingSeverity::High => "high",
716 fallow_output::FindingSeverity::Moderate => "moderate",
717 };
718 let crap_suffix = match finding.crap {
719 Some(crap) => {
720 let coverage = finding
721 .coverage_pct
722 .map(|pct| format!(",coverage_pct={pct:.1}"))
723 .unwrap_or_default();
724 format!(",crap={crap:.1}{coverage}")
725 }
726 None => String::new(),
727 };
728 lines.push(format!(
733 "high-complexity:{}:{}:{}:cyclomatic={},cognitive={},severity={},exceeded={}{}",
734 relative,
735 finding.line,
736 finding.name,
737 finding.cyclomatic,
738 finding.cognitive,
739 severity,
740 exceeded_compact_label(finding.exceeded),
741 crap_suffix,
742 ));
743 }
744}
745
746fn push_file_scores_compact(
747 lines: &mut Vec<String>,
748 scores: &[fallow_output::FileHealthScore],
749 root: &Path,
750) {
751 for score in scores {
752 let relative = health_compact_path(&score.path, root);
753 lines.push(format!(
754 "file-score:{}:mi={:.1},fan_in={},fan_out={},dead={:.2},density={:.2},crap_max={:.1},crap_above={}",
755 relative,
756 score.maintainability_index,
757 score.fan_in,
758 score.fan_out,
759 score.dead_code_ratio,
760 score.complexity_density,
761 score.crap_max,
762 score.crap_above_threshold,
763 ));
764 }
765}
766
767fn push_coverage_gaps_compact(
768 lines: &mut Vec<String>,
769 report: &fallow_output::HealthReport,
770 root: &Path,
771) {
772 if let Some(ref gaps) = report.coverage_gaps {
773 lines.push(format!(
774 "coverage-gap-summary:runtime_files={},covered_files={},file_coverage_pct={:.1},untested_files={},untested_exports={}",
775 gaps.summary.runtime_files,
776 gaps.summary.covered_files,
777 gaps.summary.file_coverage_pct,
778 gaps.summary.untested_files,
779 gaps.summary.untested_exports,
780 ));
781 for item in &gaps.files {
782 let relative = health_compact_path(&item.file.path, root);
783 lines.push(format!(
784 "untested-file:{}:value_exports={}",
785 relative, item.file.value_export_count,
786 ));
787 }
788 for item in &gaps.exports {
789 let relative = health_compact_path(&item.export.path, root);
790 lines.push(format!(
791 "untested-export:{}:{}:{}",
792 relative, item.export.line, item.export.export_name,
793 ));
794 }
795 }
796}
797
798fn push_runtime_sections_compact(
799 lines: &mut Vec<String>,
800 report: &fallow_output::HealthReport,
801 root: &Path,
802) {
803 if let Some(ref production) = report.runtime_coverage {
804 lines.extend(build_runtime_coverage_compact_lines(production, root));
805 }
806 if let Some(ref intelligence) = report.coverage_intelligence {
807 lines.extend(build_coverage_intelligence_compact_lines(
808 intelligence,
809 root,
810 ));
811 }
812}
813
814fn compact_ownership_suffix(ownership: Option<&fallow_output::OwnershipMetrics>) -> String {
815 ownership.map_or_else(String::new, |o| {
816 let mut parts = vec![
817 format!("bus={}", o.bus_factor),
818 format!("contributors={}", o.contributor_count),
819 format!("top={}", o.top_contributor.identifier),
820 format!("top_share={:.3}", o.top_contributor.share),
821 ];
822 if let Some(owner) = &o.declared_owner {
823 parts.push(format!("owner={owner}"));
824 }
825 if let Some(unowned) = o.unowned {
826 parts.push(format!("unowned={unowned}"));
827 }
828 let state = match o.ownership_state {
829 fallow_output::OwnershipState::Active => "active",
830 fallow_output::OwnershipState::Unowned => "unowned",
831 fallow_output::OwnershipState::DeclaredInactive => "declared_inactive",
832 fallow_output::OwnershipState::Drifting => "drifting",
833 };
834 parts.push(format!("ownership_state={state}"));
835 if o.drift {
836 parts.push("drift=true".to_string());
837 }
838 format!(",{}", parts.join(","))
839 })
840}
841
842fn push_hotspots_compact(
843 lines: &mut Vec<String>,
844 hotspots: &[fallow_output::HotspotFinding],
845 root: &Path,
846) {
847 for entry in hotspots {
848 let relative = health_compact_path(&entry.path, root);
849 let ownership_suffix = compact_ownership_suffix(entry.ownership.as_ref());
850 lines.push(format!(
851 "hotspot:{}:score={:.1},commits={},churn={},density={:.2},fan_in={},trend={}{}",
852 relative,
853 entry.score,
854 entry.commits,
855 entry.lines_added + entry.lines_deleted,
856 entry.complexity_density,
857 entry.fan_in,
858 entry.trend,
859 ownership_suffix,
860 ));
861 }
862}
863
864fn push_health_trend_compact(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
865 if let Some(ref trend) = report.health_trend {
866 lines.push(format!(
867 "trend:overall:direction={}",
868 trend.overall_direction.label()
869 ));
870 for m in &trend.metrics {
871 lines.push(format!(
872 "trend:{}:previous={:.1},current={:.1},delta={:+.1},direction={}",
873 m.name,
874 m.previous,
875 m.current,
876 m.delta,
877 m.direction.label(),
878 ));
879 }
880 }
881}
882
883fn push_refactoring_targets_compact(
884 lines: &mut Vec<String>,
885 targets: &[fallow_output::RefactoringTargetFinding],
886 root: &Path,
887) {
888 for target in targets {
889 let relative = health_compact_path(&target.path, root);
890 let category = target.category.compact_label();
891 let effort = target.effort.label();
892 let confidence = target.confidence.label();
893 lines.push(format!(
894 "refactoring-target:{}:priority={:.1},efficiency={:.1},category={},effort={},confidence={}:{}",
895 relative,
896 target.priority,
897 target.efficiency,
898 category,
899 effort,
900 confidence,
901 target.recommendation,
902 ));
903 }
904}
905
906fn build_runtime_coverage_compact_lines(
907 production: &fallow_output::RuntimeCoverageReport,
908 root: &Path,
909) -> Vec<String> {
910 let mut lines = vec![format!(
911 "runtime-coverage-summary:functions_tracked={},functions_hit={},functions_unhit={},functions_untracked={},coverage_percent={:.1},trace_count={},period_days={},deployments_seen={}",
912 production.summary.functions_tracked,
913 production.summary.functions_hit,
914 production.summary.functions_unhit,
915 production.summary.functions_untracked,
916 production.summary.coverage_percent,
917 production.summary.trace_count,
918 production.summary.period_days,
919 production.summary.deployments_seen,
920 )];
921 for finding in &production.findings {
922 let relative = normalize_uri(&relative_path(&finding.path, root).display().to_string());
923 let invocations = finding
924 .invocations
925 .map_or_else(|| "null".to_owned(), |hits| hits.to_string());
926 lines.push(format!(
927 "runtime-coverage:{}:{}:{}:id={},verdict={},invocations={},confidence={}",
928 relative,
929 finding.line,
930 finding.function,
931 finding.id,
932 finding.verdict,
933 invocations,
934 finding.confidence,
935 ));
936 }
937 for entry in &production.hot_paths {
938 let relative = normalize_uri(&relative_path(&entry.path, root).display().to_string());
939 lines.push(format!(
940 "production-hot-path:{}:{}:{}:id={},invocations={},percentile={}",
941 relative, entry.line, entry.function, entry.id, entry.invocations, entry.percentile,
942 ));
943 }
944 lines
945}
946
947fn build_coverage_intelligence_compact_lines(
948 intelligence: &fallow_output::CoverageIntelligenceReport,
949 root: &Path,
950) -> Vec<String> {
951 let mut lines = vec![format!(
952 "coverage-intelligence-summary:verdict={},findings={},risky_changes={},high_confidence_deletes={},review_required={},refactor_carefully={},skipped_ambiguous_matches={}",
953 intelligence.verdict,
954 intelligence.summary.findings,
955 intelligence.summary.risky_changes,
956 intelligence.summary.high_confidence_deletes,
957 intelligence.summary.review_required,
958 intelligence.summary.refactor_carefully,
959 intelligence.summary.skipped_ambiguous_matches,
960 )];
961 for finding in &intelligence.findings {
962 let relative = normalize_uri(&relative_path(&finding.path, root).display().to_string());
963 let identity = finding.identity.as_deref().unwrap_or("-");
964 let signals = finding
965 .signals
966 .iter()
967 .map(ToString::to_string)
968 .collect::<Vec<_>>()
969 .join("+");
970 lines.push(format!(
971 "coverage-intelligence:{}:{}:{}:id={},verdict={},recommendation={},confidence={},signals={}",
972 relative,
973 finding.line,
974 identity,
975 finding.id,
976 finding.verdict,
977 finding.recommendation,
978 finding.confidence,
979 signals,
980 ));
981 }
982 lines
983}
984
985#[must_use]
987pub fn build_duplication_compact_lines(report: &DuplicationReport, root: &Path) -> Vec<String> {
988 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
989 let mut lines = Vec::new();
990 for (index, group) in report.clone_groups.iter().enumerate() {
991 let fingerprint = fingerprints.fingerprint_for_group(group);
992 for instance in &group.instances {
993 lines.push(format!(
994 "code-duplication:{}:{}-{}:fingerprint={},group={},tokens={},lines={},instances={}",
995 compact_path(&instance.file, root),
996 instance.start_line,
997 instance.end_line,
998 fingerprint,
999 index + 1,
1000 group.token_count,
1001 group.line_count,
1002 group.instances.len(),
1003 ));
1004 }
1005 }
1006 lines
1007}
1008
1009#[cfg(test)]
1010mod tests {
1011 use std::path::PathBuf;
1012
1013 use fallow_types::duplicates::{CloneGroup, CloneInstance, DuplicationStats};
1014 use fallow_types::output_dead_code::UnusedFileFinding;
1015 use fallow_types::results::{AnalysisResults, UnusedFile};
1016
1017 use super::*;
1018
1019 #[test]
1020 fn compact_unused_file_format_uses_relative_paths() {
1021 let root = PathBuf::from("/project");
1022 let mut results = AnalysisResults::default();
1023 results
1024 .unused_files
1025 .push(UnusedFileFinding::with_actions(UnusedFile {
1026 path: root.join("src/dead.ts"),
1027 }));
1028
1029 let lines = build_compact_lines(&results, &root);
1030
1031 assert_eq!(lines, vec!["unused-file:src/dead.ts"]);
1032 }
1033
1034 #[test]
1035 fn grouped_compact_prefixes_each_issue_with_group_key() {
1036 let root = PathBuf::from("/project");
1037 let mut results = AnalysisResults::default();
1038 results
1039 .unused_files
1040 .push(UnusedFileFinding::with_actions(UnusedFile {
1041 path: root.join("src/dead.ts"),
1042 }));
1043 let groups = vec![ResultGroup {
1044 key: "team-a".to_owned(),
1045 owners: Some(vec!["@team-a".to_owned()]),
1046 results,
1047 }];
1048
1049 let lines = build_grouped_compact_lines(&groups, &root);
1050
1051 assert_eq!(lines, vec!["team-a\tunused-file:src/dead.ts"]);
1052 }
1053
1054 #[test]
1055 fn duplication_compact_lines_include_stable_group_context() {
1056 let root = PathBuf::from("/project");
1057 let report = DuplicationReport {
1058 clone_groups: vec![CloneGroup {
1059 instances: vec![CloneInstance {
1060 file: root.join("src/a.ts"),
1061 start_line: 2,
1062 end_line: 6,
1063 start_col: 0,
1064 end_col: 10,
1065 fragment: "const duplicated = true;".to_owned(),
1066 }],
1067 token_count: 12,
1068 line_count: 5,
1069 similarity: None,
1070 }],
1071 clone_families: Vec::new(),
1072 mirrored_directories: Vec::new(),
1073 stats: DuplicationStats::default(),
1074 };
1075
1076 let lines = build_duplication_compact_lines(&report, &root);
1077
1078 assert_eq!(lines.len(), 1);
1079 assert!(lines[0].starts_with("code-duplication:src/a.ts:2-6:fingerprint="));
1080 assert!(lines[0].contains(",group=1,tokens=12,lines=5,instances=1"));
1081 }
1082
1083 #[test]
1084 fn health_compact_lines_include_score_and_vital_signs() {
1085 let root = PathBuf::from("/project");
1086 let report = fallow_output::HealthReport {
1087 health_score: Some(fallow_output::HealthScore {
1088 formula_version: 1,
1089 score: 91.2,
1090 grade: "A",
1091 penalties: fallow_output::HealthScorePenalties {
1092 dead_files: None,
1093 dead_exports: None,
1094 complexity: 0.0,
1095 p90_complexity: 0.0,
1096 maintainability: None,
1097 hotspots: None,
1098 unused_deps: None,
1099 circular_deps: None,
1100 unit_size: None,
1101 coupling: None,
1102 duplication: None,
1103 prop_drilling: None,
1104 },
1105 }),
1106 vital_signs: Some(fallow_output::VitalSigns {
1107 total_loc: 120,
1108 avg_cyclomatic: 3.4,
1109 p90_cyclomatic: 8,
1110 ..Default::default()
1111 }),
1112 ..Default::default()
1113 };
1114
1115 let lines = build_health_compact_lines(&report, &root);
1116
1117 assert_eq!(lines[0], "health-score:91.2:A");
1118 assert_eq!(
1119 lines[1],
1120 "vital-signs:total_loc=120,avg_cyclomatic=3.4,p90_cyclomatic=8"
1121 );
1122 }
1123
1124 #[test]
1125 fn health_compact_lines_include_styling_findings() {
1126 let root = PathBuf::from("/project");
1127 let report = fallow_output::HealthReport {
1128 styling_findings: vec![fallow_output::StylingFinding {
1129 code: "css-token-drift".to_string(),
1130 sub_kind: "tailwind-arbitrary-value".to_string(),
1131 path: "/project/src/app.css".to_string(),
1132 line: 6,
1133 value: "--color-brand: rgb(240, 90, 41)".to_string(),
1134 effective_severity: fallow_output::StylingFindingSeverity::Warn,
1135 blast_radius: None,
1136 confidence: None,
1137 agent_disposition: None,
1138 nearest_token: None,
1139 fix_hint: None,
1140 actions: Vec::new(),
1141 }],
1142 ..Default::default()
1143 };
1144
1145 let lines = build_health_compact_lines(&report, &root);
1146
1147 assert_eq!(
1148 lines,
1149 vec![
1150 "css-token-drift:src/app.css:6:tailwind-arbitrary-value:severity=warn,value=--color-brand rgb(240 90 41)"
1151 ]
1152 );
1153 }
1154}