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