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