1use std::path::Path;
2
3use fallow_engine::duplicates::CloneFingerprintSet;
4use fallow_output::normalize_uri;
5use fallow_types::duplicates::DuplicationReport;
6use fallow_types::output_dead_code::ReachabilityCaveat;
7use fallow_types::results::{AnalysisResults, UnusedExport, UnusedMember};
8
9use crate::ResultGroup;
10
11fn relative_path<'a>(path: &'a Path, root: &Path) -> &'a Path {
12 path.strip_prefix(root).unwrap_or(path)
13}
14
15fn compact_path(path: &Path, root: &Path) -> String {
16 normalize_uri(&relative_path(path, root).display().to_string())
17}
18
19fn compact_caveat_field(caveats: &[ReachabilityCaveat]) -> String {
31 if caveats.is_empty() {
32 return String::new();
33 }
34 let tokens: Vec<&str> = caveats
35 .iter()
36 .map(|caveat| ReachabilityCaveat::token(*caveat))
37 .collect();
38 format!(",caveat={}", tokens.join("+"))
39}
40
41fn compact_circular_dependency_line(
42 cycle: &fallow_types::output_dead_code::CircularDependencyFinding,
43 root: &Path,
44) -> String {
45 let mut display_chain: Vec<String> = cycle
46 .cycle
47 .files
48 .iter()
49 .map(|path| compact_path(path, root))
50 .collect();
51 if let Some(first) = display_chain.first() {
52 display_chain.push(first.clone());
53 }
54 let first_file = display_chain.first().map_or("", String::as_str);
55 let cross_pkg_tag = if cycle.cycle.is_cross_package {
56 " (cross-package)"
57 } else {
58 ""
59 };
60 format!(
61 "circular-dependency:{}:{}:{}{}",
62 first_file,
63 cycle.cycle.line,
64 display_chain.join(" \u{2192} "),
65 cross_pkg_tag
66 )
67}
68
69fn compact_re_export_cycle_line(
70 cycle: &fallow_types::output_dead_code::ReExportCycleFinding,
71 root: &Path,
72) -> String {
73 let chain: Vec<String> = cycle
74 .cycle
75 .files
76 .iter()
77 .map(|path| compact_path(path, root))
78 .collect();
79 let first_file = chain.first().map_or("", String::as_str);
80 let kind_tag = match cycle.cycle.kind {
81 fallow_types::results::ReExportCycleKind::SelfLoop => " (self-loop)",
82 fallow_types::results::ReExportCycleKind::MultiNode => "",
83 };
84 format!(
85 "re-export-cycle:{}:{}{}",
86 first_file,
87 chain.join(" <-> "),
88 kind_tag
89 )
90}
91
92fn compact_boundary_violation_line(
93 item: &fallow_types::output_dead_code::BoundaryViolationFinding,
94 root: &Path,
95) -> String {
96 format!(
97 "boundary-violation:{}:{}:{} -> {} ({} -> {})",
98 compact_path(&item.violation.from_path, root),
99 item.violation.line,
100 compact_path(&item.violation.from_path, root),
101 compact_path(&item.violation.to_path, root),
102 item.violation.from_zone,
103 item.violation.to_zone,
104 )
105}
106
107fn compact_boundary_coverage_line(
108 item: &fallow_types::output_dead_code::BoundaryCoverageViolationFinding,
109 root: &Path,
110) -> String {
111 format!(
112 "boundary-coverage:{}:{}:no matching boundary zone",
113 compact_path(&item.violation.path, root),
114 item.violation.line,
115 )
116}
117
118fn compact_boundary_call_line(
119 item: &fallow_types::output_dead_code::BoundaryCallViolationFinding,
120 root: &Path,
121) -> String {
122 format!(
123 "boundary-call:{}:{}:{} forbidden in zone {} (pattern {})",
124 compact_path(&item.violation.path, root),
125 item.violation.line,
126 item.violation.callee,
127 item.violation.zone,
128 item.violation.pattern,
129 )
130}
131
132fn compact_stale_suppression_line(
133 item: &fallow_types::results::StaleSuppression,
134 root: &Path,
135) -> String {
136 format!(
137 "stale-suppression:{}:{}:{}",
138 compact_path(&item.path, root),
139 item.line,
140 item.display_message(),
141 )
142}
143
144fn compact_catalog_reference_line(
145 item: &fallow_types::output_dead_code::UnresolvedCatalogReferenceFinding,
146 root: &Path,
147) -> String {
148 format!(
149 "unresolved-catalog-reference:{}:{}:{}:{}",
150 compact_path(&item.reference.path, root),
151 item.reference.line,
152 item.reference.catalog_name,
153 item.reference.entry_name,
154 )
155}
156
157fn compact_unused_override_line(
158 item: &fallow_types::output_dead_code::UnusedDependencyOverrideFinding,
159 root: &Path,
160) -> String {
161 format!(
162 "unused-dependency-override:{}:{}:{}:{}",
163 compact_path(&item.entry.path, root),
164 item.entry.line,
165 item.entry.source.as_label(),
166 item.entry.raw_key,
167 )
168}
169
170fn compact_misconfigured_override_line(
171 item: &fallow_types::output_dead_code::MisconfiguredDependencyOverrideFinding,
172 root: &Path,
173) -> String {
174 format!(
175 "misconfigured-dependency-override:{}:{}:{}:{}",
176 compact_path(&item.entry.path, root),
177 item.entry.line,
178 item.entry.source.as_label(),
179 item.entry.raw_key,
180 )
181}
182
183pub fn build_compact_lines(results: &AnalysisResults, root: &Path) -> Vec<String> {
186 CompactLineBuilder::new(results, root).build()
187}
188
189struct CompactLineBuilder<'a> {
190 lines: Vec<String>,
191 results: &'a AnalysisResults,
192 root: &'a Path,
193}
194
195impl<'a> CompactLineBuilder<'a> {
196 fn new(results: &'a AnalysisResults, root: &'a Path) -> Self {
197 Self {
198 lines: Vec::new(),
199 results,
200 root,
201 }
202 }
203
204 fn build(mut self) -> Vec<String> {
205 self.push_core_lines();
206 self.push_unused_dependency_lines();
207 self.push_member_lines();
208 self.push_secondary_dependency_lines();
209 self.push_graph_lines();
210 self.push_workspace_lines();
211 self.lines
212 }
213
214 fn rel(&self, path: &Path) -> String {
215 compact_path(path, self.root)
216 }
217
218 fn unused_export_line(&self, export: &UnusedExport, caveats: &[ReachabilityCaveat]) -> String {
219 let tag = if export.is_re_export {
220 "unused-re-export"
221 } else {
222 "unused-export"
223 };
224 format!(
225 "{}:{}:{}:{}{}",
226 tag,
227 self.rel(&export.path),
228 export.line,
229 export.export_name,
230 compact_caveat_field(caveats)
231 )
232 }
233
234 fn unused_type_line(&self, export: &UnusedExport, caveats: &[ReachabilityCaveat]) -> String {
235 let tag = if export.is_re_export {
236 "unused-re-export-type"
237 } else {
238 "unused-type"
239 };
240 format!(
241 "{}:{}:{}:{}{}",
242 tag,
243 self.rel(&export.path),
244 export.line,
245 export.export_name,
246 compact_caveat_field(caveats)
247 )
248 }
249
250 fn compact_member(
251 &self,
252 member: &UnusedMember,
253 kind: &str,
254 caveats: &[ReachabilityCaveat],
255 ) -> String {
256 format!(
257 "{}:{}:{}:{}.{}{}",
258 kind,
259 self.rel(&member.path),
260 member.line,
261 member.parent_name,
262 member.member_name,
263 compact_caveat_field(caveats)
264 )
265 }
266
267 fn push_core_lines(&mut self) {
268 for file in &self.results.unused_files {
269 self.lines.push(format!(
270 "unused-file:{}{}",
271 self.rel(&file.file.path),
272 compact_caveat_field(&file.reachability_caveats)
273 ));
274 }
275 for export in &self.results.unused_exports {
276 self.lines
277 .push(self.unused_export_line(&export.export, &export.reachability_caveats));
278 }
279 for export in &self.results.unused_types {
280 self.lines
281 .push(self.unused_type_line(&export.export, &export.reachability_caveats));
282 }
283 for leak in &self.results.private_type_leaks {
284 self.lines.push(format!(
285 "private-type-leak:{}:{}:{}->{}",
286 self.rel(&leak.leak.path),
287 leak.leak.line,
288 leak.leak.export_name,
289 leak.leak.type_name
290 ));
291 }
292 }
293
294 fn push_unused_dependency_lines(&mut self) {
295 for dep in &self.results.unused_dependencies {
296 self.lines.push(format!(
297 "unused-dep:{}{}",
298 dep.dep.package_name,
299 compact_caveat_field(&dep.reachability_caveats)
300 ));
301 }
302 for dep in &self.results.unused_dev_dependencies {
303 self.lines.push(format!(
304 "unused-devdep:{}{}",
305 dep.dep.package_name,
306 compact_caveat_field(&dep.reachability_caveats)
307 ));
308 }
309 for dep in &self.results.unused_optional_dependencies {
310 self.lines.push(format!(
311 "unused-optionaldep:{}{}",
312 dep.dep.package_name,
313 compact_caveat_field(&dep.reachability_caveats)
314 ));
315 }
316 }
317
318 fn push_member_lines(&mut self) {
319 for member in &self.results.unused_enum_members {
320 self.lines.push(self.compact_member(
321 &member.member,
322 "unused-enum-member",
323 &member.reachability_caveats,
324 ));
325 }
326 for member in &self.results.unused_class_members {
327 self.lines.push(self.compact_member(
328 &member.member,
329 "unused-class-member",
330 &member.reachability_caveats,
331 ));
332 }
333 for member in &self.results.unused_store_members {
334 self.lines.push(self.compact_member(
335 &member.member,
336 "unused-store-member",
337 &member.reachability_caveats,
338 ));
339 }
340 for import in &self.results.unresolved_imports {
341 self.lines.push(format!(
342 "unresolved-import:{}:{}:{}",
343 self.rel(&import.import.path),
344 import.import.line,
345 import.import.specifier
346 ));
347 }
348 }
349
350 fn push_secondary_dependency_lines(&mut self) {
351 for dep in &self.results.unlisted_dependencies {
352 self.lines
353 .push(format!("unlisted-dep:{}", dep.dep.package_name));
354 }
355 for dup in &self.results.duplicate_exports {
356 self.lines
357 .push(format!("duplicate-export:{}", dup.export.export_name));
358 }
359 for dep in &self.results.type_only_dependencies {
360 self.lines
361 .push(format!("type-only-dep:{}", dep.dep.package_name));
362 }
363 for dep in &self.results.test_only_dependencies {
364 self.lines
365 .push(format!("test-only-dep:{}", dep.dep.package_name));
366 }
367 for dep in &self.results.dev_dependencies_in_production {
368 self.lines
369 .push(format!("dev-dep-in-prod:{}", dep.dep.package_name));
370 }
371 }
372
373 fn push_graph_lines(&mut self) {
374 self.push_structure_lines();
375 self.push_framework_lines();
376 self.push_component_lines();
377 self.push_route_lines();
378 self.push_suppression_lines();
379 }
380
381 fn push_structure_lines(&mut self) {
382 for cycle in &self.results.circular_dependencies {
383 self.lines
384 .push(compact_circular_dependency_line(cycle, self.root));
385 }
386 for cycle in &self.results.re_export_cycles {
387 self.lines
388 .push(compact_re_export_cycle_line(cycle, self.root));
389 }
390 for violation in &self.results.boundary_violations {
391 self.lines
392 .push(compact_boundary_violation_line(violation, self.root));
393 }
394 for violation in &self.results.boundary_coverage_violations {
395 self.lines
396 .push(compact_boundary_coverage_line(violation, self.root));
397 }
398 for violation in &self.results.boundary_call_violations {
399 self.lines
400 .push(compact_boundary_call_line(violation, self.root));
401 }
402 for violation in &self.results.policy_violations {
403 self.lines.push(format!(
404 "policy-violation:{}:{}:{} banned by {}/{}",
405 self.rel(&violation.violation.path),
406 violation.violation.line,
407 violation.violation.matched,
408 violation.violation.pack,
409 violation.violation.rule_id,
410 ));
411 }
412 }
413
414 fn push_framework_lines(&mut self) {
415 for finding in &self.results.invalid_client_exports {
416 self.lines.push(format!(
417 "invalid-client-export:{}:{}:{} (from \"{}\")",
418 self.rel(&finding.export.path),
419 finding.export.line,
420 finding.export.export_name,
421 finding.export.directive,
422 ));
423 }
424 for finding in &self.results.mixed_client_server_barrels {
425 self.lines.push(format!(
426 "mixed-client-server-barrel:{}:{}:{} (server-only \"{}\")",
427 self.rel(&finding.barrel.path),
428 finding.barrel.line,
429 finding.barrel.client_origin,
430 finding.barrel.server_origin,
431 ));
432 }
433 for finding in &self.results.misplaced_directives {
434 self.lines.push(format!(
435 "misplaced-directive:{}:{}:{}",
436 self.rel(&finding.directive_site.path),
437 finding.directive_site.line,
438 finding.directive_site.directive,
439 ));
440 }
441 for finding in &self.results.unprovided_injects {
442 self.lines.push(format!(
443 "unprovided-inject:{}:{}:{}",
444 self.rel(&finding.inject.path),
445 finding.inject.line,
446 finding.inject.key_name,
447 ));
448 }
449 }
450
451 fn push_component_lines(&mut self) {
452 self.push_component_member_lines();
453 self.push_component_framework_lines();
454 }
455
456 fn push_component_member_lines(&mut self) {
458 for finding in &self.results.unrendered_components {
459 self.lines.push(format!(
460 "unrendered-component:{}:{}:{}",
461 self.rel(&finding.component.path),
462 finding.component.line,
463 finding.component.component_name,
464 ));
465 }
466 for finding in &self.results.unused_component_props {
467 self.lines.push(format!(
468 "unused-component-prop:{}:{}:{}",
469 self.rel(&finding.prop.path),
470 finding.prop.line,
471 finding.prop.prop_name,
472 ));
473 }
474 for finding in &self.results.unused_component_emits {
475 self.lines.push(format!(
476 "unused-component-emit:{}:{}:{}",
477 self.rel(&finding.emit.path),
478 finding.emit.line,
479 finding.emit.emit_name,
480 ));
481 }
482 for finding in &self.results.unused_component_inputs {
483 self.lines.push(format!(
484 "unused-component-input:{}:{}:{}",
485 self.rel(&finding.input.path),
486 finding.input.line,
487 finding.input.input_name,
488 ));
489 }
490 for finding in &self.results.unused_component_outputs {
491 self.lines.push(format!(
492 "unused-component-output:{}:{}:{}",
493 self.rel(&finding.output.path),
494 finding.output.line,
495 finding.output.output_name,
496 ));
497 }
498 }
499
500 fn push_component_framework_lines(&mut self) {
502 for finding in &self.results.unused_svelte_events {
503 self.lines.push(format!(
504 "unused-svelte-event:{}:{}:{}",
505 self.rel(&finding.event.path),
506 finding.event.line,
507 finding.event.event_name,
508 ));
509 }
510 for finding in &self.results.unused_server_actions {
511 self.lines.push(format!(
512 "unused-server-action:{}:{}:{}",
513 self.rel(&finding.action.path),
514 finding.action.line,
515 finding.action.action_name,
516 ));
517 }
518 for finding in &self.results.unused_load_data_keys {
519 self.lines.push(format!(
520 "unused-load-data-key:{}:{}:{}",
521 self.rel(&finding.key.path),
522 finding.key.line,
523 finding.key.key_name,
524 ));
525 }
526 }
527
528 fn push_route_lines(&mut self) {
529 for finding in &self.results.route_collisions {
530 self.lines.push(format!(
531 "route-collision:{}:{} (url {})",
532 self.rel(&finding.collision.path),
533 finding.collision.line,
534 finding.collision.url,
535 ));
536 }
537 for finding in &self.results.dynamic_segment_name_conflicts {
538 self.lines.push(format!(
539 "dynamic-segment-name-conflict:{}:{} ({} at {})",
540 self.rel(&finding.conflict.path),
541 finding.conflict.line,
542 finding.conflict.conflicting_segments.join(" vs "),
543 finding.conflict.position,
544 ));
545 }
546 }
547
548 fn push_suppression_lines(&mut self) {
549 for suppression in &self.results.stale_suppressions {
550 self.lines
551 .push(compact_stale_suppression_line(suppression, self.root));
552 }
553 }
554
555 fn push_workspace_lines(&mut self) {
556 for entry in &self.results.unused_catalog_entries {
557 self.lines.push(format!(
558 "unused-catalog-entry:{}:{}:{}:{}",
559 self.rel(&entry.entry.path),
560 entry.entry.line,
561 entry.entry.catalog_name,
562 entry.entry.entry_name,
563 ));
564 }
565 for group in &self.results.empty_catalog_groups {
566 self.lines.push(format!(
567 "empty-catalog-group:{}:{}:{}",
568 self.rel(&group.group.path),
569 group.group.line,
570 group.group.catalog_name,
571 ));
572 }
573 for finding in &self.results.unresolved_catalog_references {
574 self.lines
575 .push(compact_catalog_reference_line(finding, self.root));
576 }
577 for finding in &self.results.unused_dependency_overrides {
578 self.lines
579 .push(compact_unused_override_line(finding, self.root));
580 }
581 for finding in &self.results.misconfigured_dependency_overrides {
582 self.lines
583 .push(compact_misconfigured_override_line(finding, self.root));
584 }
585 }
586}
587
588#[must_use]
592pub fn build_grouped_compact_lines(groups: &[ResultGroup], root: &Path) -> Vec<String> {
593 groups
594 .iter()
595 .flat_map(|group| {
596 build_compact_lines(&group.results, root)
597 .into_iter()
598 .map(|line| format!("{}\t{line}", group.key))
599 })
600 .collect()
601}
602
603#[must_use]
605pub fn build_health_compact_lines(
606 report: &fallow_output::HealthReport,
607 root: &Path,
608) -> Vec<String> {
609 let mut lines = Vec::new();
610 push_health_score_compact(&mut lines, report);
611 push_vital_signs_compact(&mut lines, report);
612 push_health_findings_compact(&mut lines, &report.findings, root);
613 push_styling_findings_compact(&mut lines, &report.styling_findings, root);
614 push_threshold_overrides_compact(&mut lines, &report.threshold_overrides, root);
615 push_file_scores_compact(&mut lines, &report.file_scores, root);
616 push_coverage_gaps_compact(&mut lines, report, root);
617 push_runtime_sections_compact(&mut lines, report, root);
618 push_hotspots_compact(&mut lines, &report.hotspots, root);
619 push_health_trend_compact(&mut lines, report);
620 push_refactoring_targets_compact(&mut lines, &report.targets, root);
621 lines
622}
623
624fn push_styling_findings_compact(
625 lines: &mut Vec<String>,
626 findings: &[fallow_output::StylingFinding],
627 root: &Path,
628) {
629 for finding in findings {
630 let relative = compact_path(Path::new(&finding.path), root);
631 let severity = match finding.effective_severity {
632 fallow_output::StylingFindingSeverity::Error => "error",
633 fallow_output::StylingFindingSeverity::Warn => "warn",
634 };
635 let value = compact_field_value(&finding.value);
636 lines.push(format!(
637 "{}:{}:{}:{}:severity={},value={}",
638 finding.code, relative, finding.line, finding.sub_kind, severity, value
639 ));
640 }
641}
642
643fn compact_field_value(value: &str) -> String {
644 value
645 .replace([':', ',', '\n', '\r'], " ")
646 .split_whitespace()
647 .collect::<Vec<_>>()
648 .join(" ")
649}
650
651fn push_threshold_overrides_compact(
652 lines: &mut Vec<String>,
653 entries: &[fallow_output::ThresholdOverrideState],
654 root: &Path,
655) {
656 for entry in entries {
657 let status = match entry.status {
658 fallow_output::ThresholdOverrideStatus::Active => "active",
659 fallow_output::ThresholdOverrideStatus::Stale => "stale",
660 fallow_output::ThresholdOverrideStatus::Insufficient => "insufficient",
661 fallow_output::ThresholdOverrideStatus::NoMatch => "no_match",
662 };
663 let target = entry.path.as_ref().map_or_else(
664 || "no-match".to_string(),
665 |path| entry.target_label(&compact_path(path, root)),
666 );
667 let dimension = threshold_override_dimension_label(entry.dimension);
668 let metrics = entry.metrics.map_or(String::new(), |metrics| {
669 let crap = metrics
670 .crap
671 .map_or(String::new(), |value| format!(",crap={value:.1}"));
672 let line_count = metrics
673 .line_count
674 .map_or(String::new(), |value| format!(",lines={value}"));
675 format!(
676 ",cyclomatic={},cognitive={}{}{}",
677 metrics.cyclomatic, metrics.cognitive, line_count, crap
678 )
679 });
680 let outstanding = if entry.outstanding.is_empty() {
681 String::new()
682 } else {
683 format!(
684 ",outstanding={}",
685 entry
686 .outstanding
687 .iter()
688 .map(|value| threshold_override_dimension_label(*value))
689 .collect::<Vec<_>>()
690 .join("|")
691 )
692 };
693 lines.push(format!(
694 "threshold-override:{}:{}:{}:{}{}{}",
695 entry.override_index, dimension, status, target, metrics, outstanding
696 ));
697 }
698}
699
700fn threshold_override_dimension_label(
701 dimension: fallow_output::ThresholdOverrideDimension,
702) -> &'static str {
703 match dimension {
704 fallow_output::ThresholdOverrideDimension::Complexity => "complexity",
705 fallow_output::ThresholdOverrideDimension::Crap => "crap",
706 }
707}
708
709fn push_health_score_compact(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
710 if let Some(ref hs) = report.health_score {
711 lines.push(format!("health-score:{:.1}:{}", hs.score, hs.grade));
712 }
713}
714
715fn push_vital_signs_compact(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
716 if let Some(ref vs) = report.vital_signs {
717 let mut parts = Vec::new();
718 if vs.total_loc > 0 {
719 parts.push(format!("total_loc={}", vs.total_loc));
720 }
721 parts.push(format!("avg_cyclomatic={:.1}", vs.avg_cyclomatic));
722 parts.push(format!("p90_cyclomatic={}", vs.p90_cyclomatic));
723 if let Some(v) = vs.dead_file_pct {
724 parts.push(format!("dead_file_pct={v:.1}"));
725 }
726 if let Some(v) = vs.dead_export_pct {
727 parts.push(format!("dead_export_pct={v:.1}"));
728 }
729 if let Some(v) = vs.maintainability_avg {
730 parts.push(format!("maintainability_avg={v:.1}"));
731 }
732 if let Some(v) = vs.hotspot_count {
733 parts.push(format!("hotspot_count={v}"));
734 }
735 if let Some(v) = vs.circular_dep_count {
736 parts.push(format!("circular_dep_count={v}"));
737 }
738 if let Some(v) = vs.unused_dep_count {
739 parts.push(format!("unused_dep_count={v}"));
740 }
741 lines.push(format!("vital-signs:{}", parts.join(",")));
742 push_cyclomatic_population_compact(lines, vs);
743 }
744}
745
746fn push_cyclomatic_population_compact(lines: &mut Vec<String>, vs: &fallow_output::VitalSigns) {
747 let Some(population) = &vs.cyclomatic_population else {
748 return;
749 };
750 for (kind, group) in [
751 ("functions", &population.functions),
752 ("modules", &population.modules),
753 ("templates", &population.templates),
754 ] {
755 let max = group
756 .max
757 .map_or_else(|| "null".to_string(), |value| value.to_string());
758 lines.push(format!(
759 "cyclomatic-population:{kind}:count={},sum={},max={max}",
760 group.count, group.sum
761 ));
762 }
763}
764
765fn exceeded_compact_label(exceeded: fallow_output::ExceededThreshold) -> &'static str {
767 match exceeded {
768 fallow_output::ExceededThreshold::Cyclomatic => "cyclomatic",
769 fallow_output::ExceededThreshold::Cognitive => "cognitive",
770 fallow_output::ExceededThreshold::Both => "both",
771 fallow_output::ExceededThreshold::Crap => "crap",
772 fallow_output::ExceededThreshold::CyclomaticCrap => "cyclomatic_crap",
773 fallow_output::ExceededThreshold::CognitiveCrap => "cognitive_crap",
774 fallow_output::ExceededThreshold::All => "all",
775 }
776}
777
778fn push_health_findings_compact(
779 lines: &mut Vec<String>,
780 findings: &[fallow_output::HealthFinding],
781 root: &Path,
782) {
783 for finding in findings {
784 let relative = compact_path(&finding.path, root);
785 let severity = match finding.severity {
786 fallow_output::FindingSeverity::Critical => "critical",
787 fallow_output::FindingSeverity::High => "high",
788 fallow_output::FindingSeverity::Moderate => "moderate",
789 };
790 let crap_suffix = match finding.crap {
791 Some(crap) => {
792 let coverage = finding
793 .coverage_pct
794 .map(|pct| format!(",coverage_pct={pct:.1}"))
795 .unwrap_or_default();
796 format!(",crap={crap:.1}{coverage}")
797 }
798 None => String::new(),
799 };
800 lines.push(format!(
805 "high-complexity:{}:{}:{}:cyclomatic={},cognitive={},severity={},exceeded={}{}",
806 relative,
807 finding.line,
808 finding.name,
809 finding.cyclomatic,
810 finding.cognitive,
811 severity,
812 exceeded_compact_label(finding.exceeded),
813 crap_suffix,
814 ));
815 }
816}
817
818fn push_file_scores_compact(
819 lines: &mut Vec<String>,
820 scores: &[fallow_output::FileHealthScore],
821 root: &Path,
822) {
823 for score in scores {
824 let relative = compact_path(&score.path, root);
825 lines.push(format!(
826 "file-score:{}:mi={:.1},fan_in={},fan_out={},dead={:.2},density={:.2},crap_max={:.1},crap_above={}",
827 relative,
828 score.maintainability_index,
829 score.fan_in,
830 score.fan_out,
831 score.dead_code_ratio,
832 score.complexity_density,
833 score.crap_max,
834 score.crap_above_threshold,
835 ));
836 }
837}
838
839fn push_coverage_gaps_compact(
840 lines: &mut Vec<String>,
841 report: &fallow_output::HealthReport,
842 root: &Path,
843) {
844 if let Some(ref gaps) = report.coverage_gaps {
845 lines.push(format!(
846 "coverage-gap-summary:runtime_files={},covered_files={},file_coverage_pct={:.1},untested_files={},untested_exports={}",
847 gaps.summary.runtime_files,
848 gaps.summary.covered_files,
849 gaps.summary.file_coverage_pct,
850 gaps.summary.untested_files,
851 gaps.summary.untested_exports,
852 ));
853 for item in &gaps.files {
854 let relative = compact_path(&item.file.path, root);
855 lines.push(format!(
856 "untested-file:{}:value_exports={}",
857 relative, item.file.value_export_count,
858 ));
859 }
860 for item in &gaps.exports {
861 let relative = compact_path(&item.export.path, root);
862 lines.push(format!(
863 "untested-export:{}:{}:{}",
864 relative, item.export.line, item.export.export_name,
865 ));
866 }
867 }
868}
869
870fn push_runtime_sections_compact(
871 lines: &mut Vec<String>,
872 report: &fallow_output::HealthReport,
873 root: &Path,
874) {
875 if let Some(ref production) = report.runtime_coverage {
876 lines.extend(build_runtime_coverage_compact_lines(production, root));
877 }
878 if let Some(ref intelligence) = report.coverage_intelligence {
879 lines.extend(build_coverage_intelligence_compact_lines(
880 intelligence,
881 root,
882 ));
883 }
884}
885
886fn compact_ownership_suffix(ownership: Option<&fallow_output::OwnershipMetrics>) -> String {
887 ownership.map_or_else(String::new, |o| {
888 let mut parts = vec![
889 format!("bus={}", o.bus_factor),
890 format!("contributors={}", o.contributor_count),
891 format!("top={}", o.top_contributor.identifier),
892 format!("top_share={:.3}", o.top_contributor.share),
893 ];
894 if let Some(owner) = &o.declared_owner {
895 parts.push(format!("owner={owner}"));
896 }
897 if let Some(unowned) = o.unowned {
898 parts.push(format!("unowned={unowned}"));
899 }
900 let state = match o.ownership_state {
901 fallow_output::OwnershipState::Active => "active",
902 fallow_output::OwnershipState::Unowned => "unowned",
903 fallow_output::OwnershipState::DeclaredInactive => "declared_inactive",
904 fallow_output::OwnershipState::Drifting => "drifting",
905 };
906 parts.push(format!("ownership_state={state}"));
907 if o.drift {
908 parts.push("drift=true".to_string());
909 }
910 format!(",{}", parts.join(","))
911 })
912}
913
914fn push_hotspots_compact(
915 lines: &mut Vec<String>,
916 hotspots: &[fallow_output::HotspotFinding],
917 root: &Path,
918) {
919 for entry in hotspots {
920 let relative = compact_path(&entry.path, root);
921 let ownership_suffix = compact_ownership_suffix(entry.ownership.as_ref());
922 lines.push(format!(
923 "hotspot:{}:score={:.1},commits={},churn={},density={:.2},fan_in={},trend={}{}",
924 relative,
925 entry.score,
926 entry.commits,
927 entry.lines_added + entry.lines_deleted,
928 entry.complexity_density,
929 entry.fan_in,
930 entry.trend,
931 ownership_suffix,
932 ));
933 }
934}
935
936fn push_health_trend_compact(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
937 if let Some(ref trend) = report.health_trend {
938 lines.push(format!(
939 "trend:overall:direction={}",
940 trend.overall_direction.label()
941 ));
942 for m in &trend.metrics {
943 lines.push(format!(
944 "trend:{}:previous={:.1},current={:.1},delta={:+.1},direction={}",
945 m.name,
946 m.previous,
947 m.current,
948 m.delta,
949 m.direction.label(),
950 ));
951 }
952 }
953}
954
955fn push_refactoring_targets_compact(
956 lines: &mut Vec<String>,
957 targets: &[fallow_output::RefactoringTargetFinding],
958 root: &Path,
959) {
960 for target in targets {
961 let relative = compact_path(&target.path, root);
962 let category = target.category.compact_label();
963 let effort = target.effort.label();
964 let confidence = target.confidence.label();
965 lines.push(format!(
966 "refactoring-target:{}:priority={:.1},efficiency={:.1},category={},effort={},confidence={}:{}",
967 relative,
968 target.priority,
969 target.efficiency,
970 category,
971 effort,
972 confidence,
973 target.recommendation,
974 ));
975 }
976}
977
978fn build_runtime_coverage_compact_lines(
979 production: &fallow_output::RuntimeCoverageReport,
980 root: &Path,
981) -> Vec<String> {
982 let mut lines = vec![format!(
983 "runtime-coverage-summary:functions_tracked={},functions_hit={},functions_unhit={},functions_untracked={},coverage_percent={:.1},trace_count={},period_days={},deployments_seen={}",
984 production.summary.functions_tracked,
985 production.summary.functions_hit,
986 production.summary.functions_unhit,
987 production.summary.functions_untracked,
988 production.summary.coverage_percent,
989 production.summary.trace_count,
990 production.summary.period_days,
991 production.summary.deployments_seen,
992 )];
993 for finding in &production.findings {
994 let relative = compact_path(&finding.path, root);
995 let invocations = finding
996 .invocations
997 .map_or_else(|| "null".to_owned(), |hits| hits.to_string());
998 lines.push(format!(
999 "runtime-coverage:{}:{}:{}:id={},verdict={},invocations={},confidence={}",
1000 relative,
1001 finding.line,
1002 finding.function,
1003 finding.id,
1004 finding.verdict,
1005 invocations,
1006 finding.confidence,
1007 ));
1008 }
1009 for entry in &production.hot_paths {
1010 let relative = compact_path(&entry.path, root);
1011 lines.push(format!(
1012 "production-hot-path:{}:{}:{}:id={},invocations={},percentile={}",
1013 relative, entry.line, entry.function, entry.id, entry.invocations, entry.percentile,
1014 ));
1015 }
1016 lines
1017}
1018
1019fn build_coverage_intelligence_compact_lines(
1020 intelligence: &fallow_output::CoverageIntelligenceReport,
1021 root: &Path,
1022) -> Vec<String> {
1023 let mut lines = vec![format!(
1024 "coverage-intelligence-summary:verdict={},findings={},risky_changes={},high_confidence_deletes={},review_required={},refactor_carefully={},skipped_ambiguous_matches={}",
1025 intelligence.verdict,
1026 intelligence.summary.findings,
1027 intelligence.summary.risky_changes,
1028 intelligence.summary.high_confidence_deletes,
1029 intelligence.summary.review_required,
1030 intelligence.summary.refactor_carefully,
1031 intelligence.summary.skipped_ambiguous_matches,
1032 )];
1033 for finding in &intelligence.findings {
1034 let relative = compact_path(&finding.path, root);
1035 let identity = finding.identity.as_deref().unwrap_or("-");
1036 let signals = finding
1037 .signals
1038 .iter()
1039 .map(ToString::to_string)
1040 .collect::<Vec<_>>()
1041 .join("+");
1042 lines.push(format!(
1043 "coverage-intelligence:{}:{}:{}:id={},verdict={},recommendation={},confidence={},signals={}",
1044 relative,
1045 finding.line,
1046 identity,
1047 finding.id,
1048 finding.verdict,
1049 finding.recommendation,
1050 finding.confidence,
1051 signals,
1052 ));
1053 }
1054 lines
1055}
1056
1057#[must_use]
1059pub fn build_duplication_compact_lines(report: &DuplicationReport, root: &Path) -> Vec<String> {
1060 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
1061 let mut lines = Vec::new();
1062 for (index, group) in report.clone_groups.iter().enumerate() {
1063 let fingerprint = fingerprints.fingerprint_for_group(group);
1064 for instance in &group.instances {
1065 lines.push(format!(
1066 "code-duplication:{}:{}-{}:fingerprint={},group={},tokens={},lines={},instances={}",
1067 compact_path(&instance.file, root),
1068 instance.start_line,
1069 instance.end_line,
1070 fingerprint,
1071 index + 1,
1072 group.token_count,
1073 group.line_count,
1074 group.instances.len(),
1075 ));
1076 }
1077 }
1078 lines
1079}
1080
1081#[cfg(test)]
1082mod tests {
1083 use std::path::PathBuf;
1084
1085 use fallow_types::duplicates::{CloneGroup, CloneInstance, DuplicationStats};
1086 use fallow_types::output_dead_code::UnusedFileFinding;
1087 use fallow_types::results::{AnalysisResults, UnusedFile};
1088
1089 use super::*;
1090
1091 #[test]
1092 fn compact_cycles_preserve_empty_self_loop_and_cross_package_output() {
1093 use fallow_types::output_dead_code::{CircularDependencyFinding, ReExportCycleFinding};
1094 use fallow_types::results::{CircularDependency, ReExportCycle, ReExportCycleKind};
1095
1096 let root = Path::new("/project");
1097 for (files, cross_package, expected) in [
1098 (vec![], false, "circular-dependency::7:"),
1099 (
1100 vec![root.join("src/a.ts")],
1101 false,
1102 "circular-dependency:src/a.ts:7:src/a.ts → src/a.ts",
1103 ),
1104 (
1105 vec![root.join("src/a.ts"), root.join("src/b.ts")],
1106 true,
1107 "circular-dependency:src/a.ts:7:src/a.ts → src/b.ts → src/a.ts (cross-package)",
1108 ),
1109 ] {
1110 let mut results = AnalysisResults::default();
1111 results
1112 .circular_dependencies
1113 .push(CircularDependencyFinding {
1114 cycle: CircularDependency {
1115 length: files.len(),
1116 files,
1117 line: 7,
1118 col: 0,
1119 edges: vec![],
1120 is_cross_package: cross_package,
1121 },
1122 actions: vec![],
1123 introduced: None,
1124 });
1125 assert_eq!(build_compact_lines(&results, root), vec![expected]);
1126 }
1127 for (files, kind, expected) in [
1128 (vec![], ReExportCycleKind::MultiNode, "re-export-cycle::"),
1129 (
1130 vec![root.join("src/a.ts")],
1131 ReExportCycleKind::SelfLoop,
1132 "re-export-cycle:src/a.ts:src/a.ts (self-loop)",
1133 ),
1134 (
1135 vec![root.join("src/a.ts"), root.join("src/b.ts")],
1136 ReExportCycleKind::MultiNode,
1137 "re-export-cycle:src/a.ts:src/a.ts <-> src/b.ts",
1138 ),
1139 ] {
1140 let mut results = AnalysisResults::default();
1141 results.re_export_cycles.push(ReExportCycleFinding {
1142 cycle: ReExportCycle { files, kind },
1143 actions: vec![],
1144 introduced: None,
1145 });
1146 assert_eq!(build_compact_lines(&results, root), vec![expected]);
1147 }
1148 }
1149
1150 #[test]
1156 fn a_caveated_finding_carries_a_trailing_caveat_field() {
1157 use fallow_types::extract::MemberKind;
1158 use fallow_types::output_dead_code::{ReachabilityCaveat, UnusedDependencyFinding};
1159 use fallow_types::results::{UnusedDependency, UnusedExport};
1160
1161 let root = PathBuf::from("/project");
1162 let mut results = AnalysisResults::default();
1163
1164 let mut file = UnusedFileFinding::with_actions(UnusedFile {
1165 path: root.join("src/dead.ts"),
1166 });
1167 file.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
1168 results.unused_files.push(file);
1169
1170 let mut export =
1171 fallow_types::output_dead_code::UnusedExportFinding::with_actions(UnusedExport {
1172 path: root.join("src/lib.ts"),
1173 export_name: "needed".to_owned(),
1174 is_type_only: false,
1175 line: 3,
1176 col: 0,
1177 span_start: 0,
1178 is_re_export: false,
1179 });
1180 export.reachability_caveats = vec![
1181 ReachabilityCaveat::IncompleteFileAnalysis,
1182 ReachabilityCaveat::IncompleteImportGraph,
1183 ];
1184 results.unused_exports.push(export);
1185
1186 let mut dep = UnusedDependencyFinding::with_actions(UnusedDependency {
1187 package_name: "left-pad".to_owned(),
1188 location: fallow_types::results::DependencyLocation::Dependencies,
1189 path: root.join("package.json"),
1190 line: 5,
1191 used_in_workspaces: Vec::new(),
1192 });
1193 dep.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
1194 results.unused_dependencies.push(dep);
1195
1196 let member = |parent: &str, name: &str, kind| fallow_types::results::UnusedMember {
1197 path: root.join("src/lib.ts"),
1198 parent_name: parent.to_owned(),
1199 member_name: name.to_owned(),
1200 kind,
1201 line: 7,
1202 col: 2,
1203 };
1204 let mut enum_member = fallow_types::output_dead_code::UnusedEnumMemberFinding::with_actions(
1205 member("Mode", "Legacy", MemberKind::EnumMember),
1206 );
1207 enum_member.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
1208 results.unused_enum_members.push(enum_member);
1209
1210 let mut class_member =
1211 fallow_types::output_dead_code::UnusedClassMemberFinding::with_actions(member(
1212 "Widget",
1213 "render",
1214 MemberKind::ClassMethod,
1215 ));
1216 class_member.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
1217 results.unused_class_members.push(class_member);
1218
1219 let mut store_member =
1220 fallow_types::output_dead_code::UnusedStoreMemberFinding::with_actions(member(
1221 "useCart",
1222 "subtotal",
1223 MemberKind::StoreMember,
1224 ));
1225 store_member.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
1226 results.unused_store_members.push(store_member);
1227
1228 let lines = build_compact_lines(&results, &root);
1229
1230 assert_eq!(
1231 lines,
1232 vec![
1233 "unused-file:src/dead.ts,caveat=incomplete-import-graph",
1234 "unused-export:src/lib.ts:3:needed,caveat=incomplete-file-analysis+incomplete-import-graph",
1235 "unused-dep:left-pad,caveat=incomplete-import-graph",
1236 "unused-enum-member:src/lib.ts:7:Mode.Legacy,caveat=incomplete-import-graph",
1237 "unused-class-member:src/lib.ts:7:Widget.render,caveat=incomplete-import-graph",
1238 "unused-store-member:src/lib.ts:7:useCart.subtotal,caveat=incomplete-import-graph",
1239 ]
1240 );
1241 }
1242
1243 #[test]
1244 fn compact_unused_file_format_uses_relative_paths() {
1245 let root = PathBuf::from("/project");
1246 let mut results = AnalysisResults::default();
1247 results
1248 .unused_files
1249 .push(UnusedFileFinding::with_actions(UnusedFile {
1250 path: root.join("src/dead.ts"),
1251 }));
1252
1253 let lines = build_compact_lines(&results, &root);
1254
1255 assert_eq!(lines, vec!["unused-file:src/dead.ts"]);
1256 }
1257
1258 #[test]
1259 fn grouped_compact_prefixes_each_issue_with_group_key() {
1260 let root = PathBuf::from("/project");
1261 let mut results = AnalysisResults::default();
1262 results
1263 .unused_files
1264 .push(UnusedFileFinding::with_actions(UnusedFile {
1265 path: root.join("src/dead.ts"),
1266 }));
1267 let groups = vec![ResultGroup {
1268 key: "team-a".to_owned(),
1269 owners: Some(vec!["@team-a".to_owned()]),
1270 results,
1271 }];
1272
1273 let lines = build_grouped_compact_lines(&groups, &root);
1274
1275 assert_eq!(lines, vec!["team-a\tunused-file:src/dead.ts"]);
1276 }
1277
1278 #[test]
1279 fn duplication_compact_lines_include_stable_group_context() {
1280 let root = PathBuf::from("/project");
1281 let report = DuplicationReport {
1282 clone_groups: vec![CloneGroup {
1283 instances: vec![CloneInstance {
1284 file: root.join("src/a.ts"),
1285 start_line: 2,
1286 end_line: 6,
1287 start_col: 0,
1288 end_col: 10,
1289 fragment: "const duplicated = true;".to_owned(),
1290 }],
1291 token_count: 12,
1292 line_count: 5,
1293 similarity: None,
1294 }],
1295 clone_families: Vec::new(),
1296 mirrored_directories: Vec::new(),
1297 stats: DuplicationStats::default(),
1298 };
1299
1300 let lines = build_duplication_compact_lines(&report, &root);
1301
1302 assert_eq!(lines.len(), 1);
1303 assert!(lines[0].starts_with("code-duplication:src/a.ts:2-6:fingerprint="));
1304 assert!(lines[0].contains(",group=1,tokens=12,lines=5,instances=1"));
1305 }
1306
1307 #[test]
1308 fn health_compact_lines_include_score_and_vital_signs() {
1309 let root = PathBuf::from("/project");
1310 let report = fallow_output::HealthReport {
1311 health_score: Some(fallow_output::HealthScore {
1312 formula_version: 1,
1313 score: 91.2,
1314 grade: "A",
1315 penalties: fallow_output::HealthScorePenalties {
1316 dead_files: None,
1317 dead_exports: None,
1318 complexity: 0.0,
1319 p90_complexity: 0.0,
1320 maintainability: None,
1321 hotspots: None,
1322 unused_deps: None,
1323 circular_deps: None,
1324 unit_size: None,
1325 coupling: None,
1326 duplication: None,
1327 prop_drilling: None,
1328 },
1329 }),
1330 vital_signs: Some(fallow_output::VitalSigns {
1331 total_loc: 120,
1332 avg_cyclomatic: 3.4,
1333 p90_cyclomatic: 8,
1334 ..Default::default()
1335 }),
1336 ..Default::default()
1337 };
1338
1339 let lines = build_health_compact_lines(&report, &root);
1340
1341 assert_eq!(lines[0], "health-score:91.2:A");
1342 assert_eq!(
1343 lines[1],
1344 "vital-signs:total_loc=120,avg_cyclomatic=3.4,p90_cyclomatic=8"
1345 );
1346 assert!(
1347 lines
1348 .iter()
1349 .all(|line| !line.starts_with("cyclomatic-population:"))
1350 );
1351 }
1352
1353 #[test]
1354 fn health_compact_population_rows_preserve_vital_signs_and_order() {
1355 let root = PathBuf::from("/project");
1356 let vitals = fallow_output::VitalSigns {
1357 avg_cyclomatic: 16.0,
1358 p90_cyclomatic: 31,
1359 ..Default::default()
1360 };
1361 let legacy = build_health_compact_lines(
1362 &fallow_output::HealthReport {
1363 vital_signs: Some(vitals.clone()),
1364 ..Default::default()
1365 },
1366 &root,
1367 );
1368 let report = fallow_output::HealthReport {
1369 vital_signs: Some(fallow_output::VitalSigns {
1370 cyclomatic_population: Some(fallow_output::CyclomaticPopulation {
1371 functions: fallow_output::CyclomaticUnitPopulation {
1372 count: 1,
1373 sum: 1,
1374 max: Some(1),
1375 },
1376 modules: fallow_output::CyclomaticUnitPopulation {
1377 count: 1,
1378 sum: 31,
1379 max: Some(31),
1380 },
1381 templates: fallow_output::CyclomaticUnitPopulation::default(),
1382 }),
1383 ..vitals
1384 }),
1385 ..Default::default()
1386 };
1387 let lines = build_health_compact_lines(&report, &root);
1388 assert_eq!(lines[0], legacy[0]);
1389 assert_eq!(
1390 &lines[1..],
1391 [
1392 "cyclomatic-population:functions:count=1,sum=1,max=1",
1393 "cyclomatic-population:modules:count=1,sum=31,max=31",
1394 "cyclomatic-population:templates:count=0,sum=0,max=null",
1395 ]
1396 );
1397 }
1398
1399 #[test]
1400 fn health_compact_measured_empty_populations_keep_null_maxima() {
1401 let report = fallow_output::HealthReport {
1402 vital_signs: Some(fallow_output::VitalSigns {
1403 cyclomatic_population: Some(fallow_output::CyclomaticPopulation::default()),
1404 ..Default::default()
1405 }),
1406 ..Default::default()
1407 };
1408 let lines = build_health_compact_lines(&report, &PathBuf::from("/project"));
1409 assert_eq!(
1410 &lines[1..],
1411 [
1412 "cyclomatic-population:functions:count=0,sum=0,max=null",
1413 "cyclomatic-population:modules:count=0,sum=0,max=null",
1414 "cyclomatic-population:templates:count=0,sum=0,max=null",
1415 ]
1416 );
1417 }
1418
1419 #[test]
1420 fn health_compact_lines_include_styling_findings() {
1421 let root = PathBuf::from("/project");
1422 let report = fallow_output::HealthReport {
1423 styling_findings: vec![fallow_output::StylingFinding {
1424 code: "css-token-drift".to_string(),
1425 sub_kind: "tailwind-arbitrary-value".to_string(),
1426 path: "/project/src/app.css".to_string(),
1427 line: 6,
1428 value: "--color-brand: rgb(240, 90, 41)".to_string(),
1429 effective_severity: fallow_output::StylingFindingSeverity::Warn,
1430 blast_radius: None,
1431 confidence: None,
1432 agent_disposition: None,
1433 nearest_token: None,
1434 fix_hint: None,
1435 actions: Vec::new(),
1436 }],
1437 ..Default::default()
1438 };
1439
1440 let lines = build_health_compact_lines(&report, &root);
1441
1442 assert_eq!(
1443 lines,
1444 vec![
1445 "css-token-drift:src/app.css:6:tailwind-arbitrary-value:severity=warn,value=--color-brand rgb(240 90 41)"
1446 ]
1447 );
1448 }
1449}