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_package_cycle_line(
93 cycle: &fallow_types::output_dead_code::PackageCycleFinding,
94 root: &Path,
95) -> String {
96 let (anchor, line) = cycle.cycle.edges.first().map_or_else(
97 || (String::new(), 0),
98 |edge| (compact_path(&edge.path, root), edge.line),
99 );
100 let chain = cycle.cycle.chain(" \u{2192} ");
101 let note = if cycle.cycle.group_truncated {
102 format!(
103 " ({})",
104 fallow_types::results::PackageCycle::GROUP_TRUNCATED_NOTE
105 )
106 } else {
107 String::new()
108 };
109 format!("package-cycle:{anchor}:{line}:{chain}{note}")
110}
111
112fn compact_boundary_violation_line(
113 item: &fallow_types::output_dead_code::BoundaryViolationFinding,
114 root: &Path,
115) -> String {
116 format!(
117 "boundary-violation:{}:{}:{} -> {} ({} -> {})",
118 compact_path(&item.violation.from_path, root),
119 item.violation.line,
120 compact_path(&item.violation.from_path, root),
121 compact_path(&item.violation.to_path, root),
122 item.violation.from_zone,
123 item.violation.to_zone,
124 )
125}
126
127fn compact_boundary_coverage_line(
128 item: &fallow_types::output_dead_code::BoundaryCoverageViolationFinding,
129 root: &Path,
130) -> String {
131 format!(
132 "boundary-coverage:{}:{}:no matching boundary zone",
133 compact_path(&item.violation.path, root),
134 item.violation.line,
135 )
136}
137
138fn compact_boundary_call_line(
139 item: &fallow_types::output_dead_code::BoundaryCallViolationFinding,
140 root: &Path,
141) -> String {
142 format!(
143 "boundary-call:{}:{}:{} forbidden in zone {} (pattern {})",
144 compact_path(&item.violation.path, root),
145 item.violation.line,
146 item.violation.callee,
147 item.violation.zone,
148 item.violation.pattern,
149 )
150}
151
152fn compact_stale_suppression_line(
153 item: &fallow_types::results::StaleSuppression,
154 root: &Path,
155) -> String {
156 format!(
157 "stale-suppression:{}:{}:{}",
158 compact_path(&item.path, root),
159 item.line,
160 item.display_message(),
161 )
162}
163
164fn compact_catalog_reference_line(
165 item: &fallow_types::output_dead_code::UnresolvedCatalogReferenceFinding,
166 root: &Path,
167) -> String {
168 format!(
169 "unresolved-catalog-reference:{}:{}:{}:{}",
170 compact_path(&item.reference.path, root),
171 item.reference.line,
172 item.reference.catalog_name,
173 item.reference.entry_name,
174 )
175}
176
177fn compact_unused_override_line(
178 item: &fallow_types::output_dead_code::UnusedDependencyOverrideFinding,
179 root: &Path,
180) -> String {
181 format!(
182 "unused-dependency-override:{}:{}:{}:{}",
183 compact_path(&item.entry.path, root),
184 item.entry.line,
185 item.entry.source.as_label(),
186 item.entry.raw_key,
187 )
188}
189
190fn compact_misconfigured_override_line(
191 item: &fallow_types::output_dead_code::MisconfiguredDependencyOverrideFinding,
192 root: &Path,
193) -> String {
194 format!(
195 "misconfigured-dependency-override:{}:{}:{}:{}",
196 compact_path(&item.entry.path, root),
197 item.entry.line,
198 item.entry.source.as_label(),
199 item.entry.raw_key,
200 )
201}
202
203pub fn build_compact_lines(results: &AnalysisResults, root: &Path) -> Vec<String> {
206 CompactLineBuilder::new(results, root).build()
207}
208
209struct CompactLineBuilder<'a> {
210 lines: Vec<String>,
211 results: &'a AnalysisResults,
212 root: &'a Path,
213}
214
215impl<'a> CompactLineBuilder<'a> {
216 fn new(results: &'a AnalysisResults, root: &'a Path) -> Self {
217 Self {
218 lines: Vec::new(),
219 results,
220 root,
221 }
222 }
223
224 fn build(mut self) -> Vec<String> {
225 self.push_core_lines();
226 self.push_unused_dependency_lines();
227 self.push_member_lines();
228 self.push_secondary_dependency_lines();
229 self.push_graph_lines();
230 self.push_workspace_lines();
231 self.lines
232 }
233
234 fn rel(&self, path: &Path) -> String {
235 compact_path(path, self.root)
236 }
237
238 fn unused_export_line(&self, export: &UnusedExport, caveats: &[ReachabilityCaveat]) -> String {
239 let tag = if export.is_re_export {
240 "unused-re-export"
241 } else {
242 "unused-export"
243 };
244 format!(
245 "{}:{}:{}:{}{}",
246 tag,
247 self.rel(&export.path),
248 export.line,
249 export.export_name,
250 compact_caveat_field(caveats)
251 )
252 }
253
254 fn unused_type_line(&self, export: &UnusedExport, caveats: &[ReachabilityCaveat]) -> String {
255 let tag = if export.is_re_export {
256 "unused-re-export-type"
257 } else {
258 "unused-type"
259 };
260 format!(
261 "{}:{}:{}:{}{}",
262 tag,
263 self.rel(&export.path),
264 export.line,
265 export.export_name,
266 compact_caveat_field(caveats)
267 )
268 }
269
270 fn compact_member(
271 &self,
272 member: &UnusedMember,
273 kind: &str,
274 caveats: &[ReachabilityCaveat],
275 ) -> String {
276 format!(
277 "{}:{}:{}:{}.{}{}",
278 kind,
279 self.rel(&member.path),
280 member.line,
281 member.parent_name,
282 member.member_name,
283 compact_caveat_field(caveats)
284 )
285 }
286
287 fn push_core_lines(&mut self) {
288 for file in &self.results.unused_files {
289 self.lines.push(format!(
290 "unused-file:{}{}",
291 self.rel(&file.file.path),
292 compact_caveat_field(&file.reachability_caveats)
293 ));
294 }
295 for export in &self.results.unused_exports {
296 self.lines
297 .push(self.unused_export_line(&export.export, &export.reachability_caveats));
298 }
299 for export in &self.results.unused_types {
300 self.lines
301 .push(self.unused_type_line(&export.export, &export.reachability_caveats));
302 }
303 for leak in &self.results.private_type_leaks {
304 self.lines.push(format!(
305 "private-type-leak:{}:{}:{}->{}",
306 self.rel(&leak.leak.path),
307 leak.leak.line,
308 leak.leak.export_name,
309 leak.leak.type_name
310 ));
311 }
312 for finding in &self.results.deprecated_exports_in_use {
313 self.lines.push(format!(
314 "deprecated-export-in-use:{}:{}:{}",
315 self.rel(&finding.export.path),
316 finding.export.line,
317 finding.export.export_name
318 ));
319 }
320 }
321
322 fn push_unused_dependency_lines(&mut self) {
323 for dep in &self.results.unused_dependencies {
324 self.lines.push(format!(
325 "unused-dep:{}{}",
326 dep.dep.package_name,
327 compact_caveat_field(&dep.reachability_caveats)
328 ));
329 }
330 for dep in &self.results.unused_dev_dependencies {
331 self.lines.push(format!(
332 "unused-devdep:{}{}",
333 dep.dep.package_name,
334 compact_caveat_field(&dep.reachability_caveats)
335 ));
336 }
337 for dep in &self.results.unused_optional_dependencies {
338 self.lines.push(format!(
339 "unused-optionaldep:{}{}",
340 dep.dep.package_name,
341 compact_caveat_field(&dep.reachability_caveats)
342 ));
343 }
344 }
345
346 fn push_member_lines(&mut self) {
347 for member in &self.results.unused_enum_members {
348 self.lines.push(self.compact_member(
349 &member.member,
350 "unused-enum-member",
351 &member.reachability_caveats,
352 ));
353 }
354 for member in &self.results.unused_class_members {
355 self.lines.push(self.compact_member(
356 &member.member,
357 "unused-class-member",
358 &member.reachability_caveats,
359 ));
360 }
361 for member in &self.results.unused_store_members {
362 self.lines.push(self.compact_member(
363 &member.member,
364 "unused-store-member",
365 &member.reachability_caveats,
366 ));
367 }
368 for import in &self.results.unresolved_imports {
369 self.lines.push(format!(
370 "unresolved-import:{}:{}:{}",
371 self.rel(&import.import.path),
372 import.import.line,
373 import.import.specifier
374 ));
375 }
376 }
377
378 fn push_secondary_dependency_lines(&mut self) {
379 for dep in &self.results.unlisted_dependencies {
380 self.lines
381 .push(format!("unlisted-dep:{}", dep.dep.package_name));
382 }
383 for dup in &self.results.duplicate_exports {
384 self.lines
385 .push(format!("duplicate-export:{}", dup.export.export_name));
386 }
387 for dep in &self.results.type_only_dependencies {
388 self.lines
389 .push(format!("type-only-dep:{}", dep.dep.package_name));
390 }
391 for dep in &self.results.test_only_dependencies {
392 self.lines
393 .push(format!("test-only-dep:{}", dep.dep.package_name));
394 }
395 for dep in &self.results.dev_dependencies_in_production {
396 self.lines
397 .push(format!("dev-dep-in-prod:{}", dep.dep.package_name));
398 }
399 }
400
401 fn push_graph_lines(&mut self) {
402 self.push_structure_lines();
403 self.push_framework_lines();
404 self.push_component_lines();
405 self.push_route_lines();
406 self.push_suppression_lines();
407 }
408
409 fn push_structure_lines(&mut self) {
410 for cycle in &self.results.circular_dependencies {
411 self.lines
412 .push(compact_circular_dependency_line(cycle, self.root));
413 }
414 for cycle in &self.results.re_export_cycles {
415 self.lines
416 .push(compact_re_export_cycle_line(cycle, self.root));
417 }
418 for cycle in &self.results.package_cycles {
419 self.lines
420 .push(compact_package_cycle_line(cycle, self.root));
421 }
422 for violation in &self.results.boundary_violations {
423 self.lines
424 .push(compact_boundary_violation_line(violation, self.root));
425 }
426 for violation in &self.results.boundary_coverage_violations {
427 self.lines
428 .push(compact_boundary_coverage_line(violation, self.root));
429 }
430 for violation in &self.results.boundary_call_violations {
431 self.lines
432 .push(compact_boundary_call_line(violation, self.root));
433 }
434 for violation in &self.results.policy_violations {
435 self.lines.push(format!(
436 "policy-violation:{}:{}:{} banned by {}/{}",
437 self.rel(&violation.violation.path),
438 violation.violation.line,
439 violation.violation.matched,
440 violation.violation.pack,
441 violation.violation.rule_id,
442 ));
443 }
444 }
445
446 fn push_framework_lines(&mut self) {
447 for finding in &self.results.invalid_client_exports {
448 self.lines.push(format!(
449 "invalid-client-export:{}:{}:{} (from \"{}\")",
450 self.rel(&finding.export.path),
451 finding.export.line,
452 finding.export.export_name,
453 finding.export.directive,
454 ));
455 }
456 for finding in &self.results.mixed_client_server_barrels {
457 self.lines.push(format!(
458 "mixed-client-server-barrel:{}:{}:{} (server-only \"{}\")",
459 self.rel(&finding.barrel.path),
460 finding.barrel.line,
461 finding.barrel.client_origin,
462 finding.barrel.server_origin,
463 ));
464 }
465 for finding in &self.results.misplaced_directives {
466 self.lines.push(format!(
467 "misplaced-directive:{}:{}:{}",
468 self.rel(&finding.directive_site.path),
469 finding.directive_site.line,
470 finding.directive_site.directive,
471 ));
472 }
473 for finding in &self.results.unprovided_injects {
474 self.lines.push(format!(
475 "unprovided-inject:{}:{}:{}",
476 self.rel(&finding.inject.path),
477 finding.inject.line,
478 finding.inject.key_name,
479 ));
480 }
481 }
482
483 fn push_component_lines(&mut self) {
484 self.push_component_member_lines();
485 self.push_component_framework_lines();
486 self.push_component_health_lines();
487 }
488
489 fn push_component_health_lines(&mut self) {
492 for finding in &self.results.prop_drilling_chains {
493 let chain = &finding.chain;
494 let (path, line) = chain
495 .hops
496 .first()
497 .map_or((String::new(), 0), |hop| (self.rel(&hop.file), hop.line));
498 let trail = chain
499 .hops
500 .iter()
501 .map(|hop| hop.component.as_str())
502 .collect::<Vec<_>>()
503 .join(" -> ");
504 self.lines.push(format!(
505 "prop-drilling:{path}:{line}:{} (through {trail}, depth {})",
506 chain.prop, chain.depth,
507 ));
508 }
509 for finding in &self.results.thin_wrappers {
510 let wrapper = &finding.wrapper;
511 self.lines.push(format!(
512 "thin-wrapper:{}:{}:{} (wraps {})",
513 self.rel(&wrapper.file),
514 wrapper.line,
515 wrapper.component,
516 wrapper.child_component,
517 ));
518 }
519 for finding in &self.results.duplicate_prop_shapes {
520 let shape = &finding.shape;
521 self.lines.push(format!(
522 "duplicate-prop-shape:{}:{}:{} (shape {{{}}} shared with {} other components)",
523 self.rel(&shape.file),
524 shape.line,
525 shape.component,
526 shape.shape.join(", "),
527 shape.group_size.saturating_sub(1),
528 ));
529 }
530 }
531
532 fn push_component_member_lines(&mut self) {
534 for finding in &self.results.unrendered_components {
535 self.lines.push(format!(
536 "unrendered-component:{}:{}:{}",
537 self.rel(&finding.component.path),
538 finding.component.line,
539 finding.component.component_name,
540 ));
541 }
542 for finding in &self.results.absent_component_props {
543 self.lines.push(format!(
544 "absent-component-prop:{}:{}:{}.{} (review candidate)",
545 self.rel(&finding.prop.path),
546 finding.prop.line,
547 finding.prop.component_name,
548 finding.prop.prop_name
549 ));
550 }
551 for finding in &self.results.unused_component_props {
552 self.lines.push(format!(
553 "unused-component-prop:{}:{}:{}",
554 self.rel(&finding.prop.path),
555 finding.prop.line,
556 finding.prop.prop_name,
557 ));
558 }
559 for finding in &self.results.unused_component_emits {
560 self.lines.push(format!(
561 "unused-component-emit:{}:{}:{}",
562 self.rel(&finding.emit.path),
563 finding.emit.line,
564 finding.emit.emit_name,
565 ));
566 }
567 for finding in &self.results.unused_component_inputs {
568 self.lines.push(format!(
569 "unused-component-input:{}:{}:{}",
570 self.rel(&finding.input.path),
571 finding.input.line,
572 finding.input.input_name,
573 ));
574 }
575 for finding in &self.results.unused_component_outputs {
576 self.lines.push(format!(
577 "unused-component-output:{}:{}:{}",
578 self.rel(&finding.output.path),
579 finding.output.line,
580 finding.output.output_name,
581 ));
582 }
583 }
584
585 fn push_component_framework_lines(&mut self) {
588 for finding in &self.results.unused_svelte_events {
589 self.lines.push(format!(
590 "unused-svelte-event:{}:{}:{}",
591 self.rel(&finding.event.path),
592 finding.event.line,
593 finding.event.event_name,
594 ));
595 }
596 for finding in &self.results.unused_effect_layers {
597 self.lines.push(format!(
598 "unused-effect-layer:{}:{}:{}",
599 self.rel(&finding.layer.path),
600 finding.layer.line,
601 finding.layer.name,
602 ));
603 }
604 for finding in &self.results.unused_tagged_errors {
605 self.lines.push(format!(
606 "unused-tagged-error:{}:{}:{}",
607 self.rel(&finding.error.path),
608 finding.error.line,
609 finding.error.name,
610 ));
611 }
612 for finding in &self.results.unproduced_catch_tags {
613 self.lines.push(format!(
614 "unproduced-catch-tag:{}:{}:{}",
615 self.rel(&finding.catch_tag.path),
616 finding.catch_tag.line,
617 finding.catch_tag.tag,
618 ));
619 }
620 for finding in &self.results.unused_server_actions {
621 self.lines.push(format!(
622 "unused-server-action:{}:{}:{}",
623 self.rel(&finding.action.path),
624 finding.action.line,
625 finding.action.action_name,
626 ));
627 }
628 for finding in &self.results.unused_load_data_keys {
629 self.lines.push(format!(
630 "unused-load-data-key:{}:{}:{}",
631 self.rel(&finding.key.path),
632 finding.key.line,
633 finding.key.key_name,
634 ));
635 }
636 }
637
638 fn push_route_lines(&mut self) {
639 for finding in &self.results.route_collisions {
640 self.lines.push(format!(
641 "route-collision:{}:{} (url {})",
642 self.rel(&finding.collision.path),
643 finding.collision.line,
644 finding.collision.url,
645 ));
646 }
647 for finding in &self.results.dynamic_segment_name_conflicts {
648 self.lines.push(format!(
649 "dynamic-segment-name-conflict:{}:{} ({} at {})",
650 self.rel(&finding.conflict.path),
651 finding.conflict.line,
652 finding.conflict.conflicting_segments.join(" vs "),
653 finding.conflict.position,
654 ));
655 }
656 }
657
658 fn push_suppression_lines(&mut self) {
659 for suppression in &self.results.stale_suppressions {
660 self.lines
661 .push(compact_stale_suppression_line(suppression, self.root));
662 }
663 }
664
665 fn push_workspace_lines(&mut self) {
666 for entry in &self.results.unused_catalog_entries {
667 self.lines.push(format!(
668 "unused-catalog-entry:{}:{}:{}:{}",
669 self.rel(&entry.entry.path),
670 entry.entry.line,
671 entry.entry.catalog_name,
672 entry.entry.entry_name,
673 ));
674 }
675 for group in &self.results.empty_catalog_groups {
676 self.lines.push(format!(
677 "empty-catalog-group:{}:{}:{}",
678 self.rel(&group.group.path),
679 group.group.line,
680 group.group.catalog_name,
681 ));
682 }
683 for finding in &self.results.unresolved_catalog_references {
684 self.lines
685 .push(compact_catalog_reference_line(finding, self.root));
686 }
687 for finding in &self.results.unused_dependency_overrides {
688 self.lines
689 .push(compact_unused_override_line(finding, self.root));
690 }
691 for finding in &self.results.misconfigured_dependency_overrides {
692 self.lines
693 .push(compact_misconfigured_override_line(finding, self.root));
694 }
695 }
696}
697
698#[must_use]
702pub fn build_grouped_compact_lines(groups: &[ResultGroup], root: &Path) -> Vec<String> {
703 groups
704 .iter()
705 .flat_map(|group| {
706 build_compact_lines(&group.results, root)
707 .into_iter()
708 .map(|line| format!("{}\t{line}", group.key))
709 })
710 .collect()
711}
712
713#[must_use]
715pub fn build_health_compact_lines(
716 report: &fallow_output::HealthReport,
717 root: &Path,
718) -> Vec<String> {
719 let mut lines = Vec::new();
720 push_health_score_compact(&mut lines, report);
721 push_vital_signs_compact(&mut lines, report);
722 push_health_findings_compact(&mut lines, &report.findings, root);
723 push_styling_findings_compact(&mut lines, &report.styling_findings, root);
724 push_threshold_overrides_compact(&mut lines, &report.threshold_overrides, root);
725 push_file_scores_compact(&mut lines, &report.file_scores, root);
726 push_coverage_gaps_compact(&mut lines, report, root);
727 push_runtime_sections_compact(&mut lines, report, root);
728 push_hotspots_compact(&mut lines, &report.hotspots, root);
729 push_change_coupling_compact(&mut lines, report.change_coupling.as_ref(), root);
730 push_health_trend_compact(&mut lines, report);
731 push_refactoring_targets_compact(&mut lines, &report.targets, root);
732 lines
733}
734
735fn push_styling_findings_compact(
736 lines: &mut Vec<String>,
737 findings: &[fallow_output::StylingFinding],
738 root: &Path,
739) {
740 for finding in findings {
741 let relative = compact_path(Path::new(&finding.path), root);
742 let severity = match finding.effective_severity {
743 fallow_output::StylingFindingSeverity::Error => "error",
744 fallow_output::StylingFindingSeverity::Warn => "warn",
745 };
746 let value = compact_field_value(&finding.value);
747 lines.push(format!(
748 "{}:{}:{}:{}:severity={},value={}",
749 finding.code, relative, finding.line, finding.sub_kind, severity, value
750 ));
751 }
752}
753
754#[must_use]
760pub fn build_diff_risk_compact_lines(report: &fallow_output::DiffRiskReport) -> Vec<String> {
761 report
762 .findings
763 .iter()
764 .map(|finding| {
765 let kind = finding
766 .kind
767 .map_or_else(String::new, |kind| format!(",kind={}", kind.code()));
768 format!(
769 "{}:{}:{}:{},severity={}{kind}",
770 finding.rule.code(),
771 normalize_uri(&finding.path),
772 finding.line.unwrap_or(0),
773 compact_field_value(&finding.evidence),
774 finding.severity.code(),
775 )
776 })
777 .collect()
778}
779
780fn compact_field_value(value: &str) -> String {
781 value
782 .replace([':', ',', '\n', '\r'], " ")
783 .split_whitespace()
784 .collect::<Vec<_>>()
785 .join(" ")
786}
787
788fn push_threshold_overrides_compact(
789 lines: &mut Vec<String>,
790 entries: &[fallow_output::ThresholdOverrideState],
791 root: &Path,
792) {
793 for entry in entries {
794 let status = match entry.status {
795 fallow_output::ThresholdOverrideStatus::Active => "active",
796 fallow_output::ThresholdOverrideStatus::Stale => "stale",
797 fallow_output::ThresholdOverrideStatus::Insufficient => "insufficient",
798 fallow_output::ThresholdOverrideStatus::NoMatch => "no_match",
799 };
800 let target = entry.path.as_ref().map_or_else(
801 || "no-match".to_string(),
802 |path| entry.target_label(&compact_path(path, root)),
803 );
804 let dimension = threshold_override_dimension_label(entry.dimension);
805 let metrics = entry.metrics.map_or(String::new(), |metrics| {
806 let crap = metrics
807 .crap
808 .map_or(String::new(), |value| format!(",crap={value:.1}"));
809 let line_count = metrics
810 .line_count
811 .map_or(String::new(), |value| format!(",lines={value}"));
812 format!(
813 ",cyclomatic={},cognitive={}{}{}",
814 metrics.cyclomatic, metrics.cognitive, line_count, crap
815 )
816 });
817 let outstanding = if entry.outstanding.is_empty() {
818 String::new()
819 } else {
820 format!(
821 ",outstanding={}",
822 entry
823 .outstanding
824 .iter()
825 .map(|value| threshold_override_dimension_label(*value))
826 .collect::<Vec<_>>()
827 .join("|")
828 )
829 };
830 lines.push(format!(
831 "threshold-override:{}:{}:{}:{}{}{}",
832 entry.override_index, dimension, status, target, metrics, outstanding
833 ));
834 }
835}
836
837fn threshold_override_dimension_label(
838 dimension: fallow_output::ThresholdOverrideDimension,
839) -> &'static str {
840 match dimension {
841 fallow_output::ThresholdOverrideDimension::Complexity => "complexity",
842 fallow_output::ThresholdOverrideDimension::Crap => "crap",
843 }
844}
845
846fn push_health_score_compact(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
847 if let Some(ref hs) = report.health_score {
848 lines.push(format!("health-score:{:.1}:{}", hs.score, hs.grade));
849 }
850}
851
852fn push_vital_signs_compact(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
853 if let Some(ref vs) = report.vital_signs {
854 let mut parts = Vec::new();
855 if vs.total_loc > 0 {
856 parts.push(format!("total_loc={}", vs.total_loc));
857 }
858 parts.push(format!("avg_cyclomatic={:.1}", vs.avg_cyclomatic));
859 parts.push(format!("p90_cyclomatic={}", vs.p90_cyclomatic));
860 if let Some(v) = vs.dead_file_pct {
861 parts.push(format!("dead_file_pct={v:.1}"));
862 }
863 if let Some(v) = vs.dead_export_pct {
864 parts.push(format!("dead_export_pct={v:.1}"));
865 }
866 if let Some(v) = vs.maintainability_avg {
867 parts.push(format!("maintainability_avg={v:.1}"));
868 }
869 if let Some(v) = vs.hotspot_count {
870 parts.push(format!("hotspot_count={v}"));
871 }
872 if let Some(v) = vs.circular_dep_count {
873 parts.push(format!("circular_dep_count={v}"));
874 }
875 if let Some(v) = vs.unused_dep_count {
876 parts.push(format!("unused_dep_count={v}"));
877 }
878 lines.push(format!("vital-signs:{}", parts.join(",")));
879 push_cyclomatic_population_compact(lines, vs);
880 }
881}
882
883fn push_cyclomatic_population_compact(lines: &mut Vec<String>, vs: &fallow_output::VitalSigns) {
884 let Some(population) = &vs.cyclomatic_population else {
885 return;
886 };
887 for (kind, group) in [
888 ("functions", &population.functions),
889 ("modules", &population.modules),
890 ("templates", &population.templates),
891 ] {
892 let max = group
893 .max
894 .map_or_else(|| "null".to_string(), |value| value.to_string());
895 lines.push(format!(
896 "cyclomatic-population:{kind}:count={},sum={},max={max}",
897 group.count, group.sum
898 ));
899 }
900}
901
902fn exceeded_compact_label(exceeded: fallow_output::ExceededThreshold) -> &'static str {
904 match exceeded {
905 fallow_output::ExceededThreshold::Cyclomatic => "cyclomatic",
906 fallow_output::ExceededThreshold::Cognitive => "cognitive",
907 fallow_output::ExceededThreshold::Both => "both",
908 fallow_output::ExceededThreshold::Crap => "crap",
909 fallow_output::ExceededThreshold::CyclomaticCrap => "cyclomatic_crap",
910 fallow_output::ExceededThreshold::CognitiveCrap => "cognitive_crap",
911 fallow_output::ExceededThreshold::All => "all",
912 }
913}
914
915fn push_health_findings_compact(
916 lines: &mut Vec<String>,
917 findings: &[fallow_output::HealthFinding],
918 root: &Path,
919) {
920 for finding in findings {
921 let relative = compact_path(&finding.path, root);
922 let severity = match finding.severity {
923 fallow_output::FindingSeverity::Critical => "critical",
924 fallow_output::FindingSeverity::High => "high",
925 fallow_output::FindingSeverity::Moderate => "moderate",
926 };
927 let crap_suffix = match finding.crap {
928 Some(crap) => {
929 let coverage = finding
930 .coverage_pct
931 .map(|pct| format!(",coverage_pct={pct:.1}"))
932 .unwrap_or_default();
933 format!(",crap={crap:.1}{coverage}")
934 }
935 None => String::new(),
936 };
937 lines.push(format!(
942 "high-complexity:{}:{}:{}:cyclomatic={},cognitive={},severity={},exceeded={}{}",
943 relative,
944 finding.line,
945 finding.name,
946 finding.cyclomatic,
947 finding.cognitive,
948 severity,
949 exceeded_compact_label(finding.exceeded),
950 crap_suffix,
951 ));
952 }
953}
954
955fn push_file_scores_compact(
956 lines: &mut Vec<String>,
957 scores: &[fallow_output::FileHealthScore],
958 root: &Path,
959) {
960 for score in scores {
961 let relative = compact_path(&score.path, root);
962 lines.push(format!(
963 "file-score:{}:mi={:.1},fan_in={},fan_out={},dead={:.2},density={:.2},crap_max={:.1},crap_above={}",
964 relative,
965 score.maintainability_index,
966 score.fan_in,
967 score.fan_out,
968 score.dead_code_ratio,
969 score.complexity_density,
970 score.crap_max,
971 score.crap_above_threshold,
972 ));
973 }
974}
975
976fn push_coverage_gaps_compact(
977 lines: &mut Vec<String>,
978 report: &fallow_output::HealthReport,
979 root: &Path,
980) {
981 if let Some(ref gaps) = report.coverage_gaps {
982 lines.push(format!(
983 "coverage-gap-summary:runtime_files={},covered_files={},file_coverage_pct={:.1},untested_files={},untested_exports={}",
984 gaps.summary.runtime_files,
985 gaps.summary.covered_files,
986 gaps.summary.file_coverage_pct,
987 gaps.summary.untested_files,
988 gaps.summary.untested_exports,
989 ));
990 for item in &gaps.files {
991 let relative = compact_path(&item.file.path, root);
992 lines.push(format!(
993 "untested-file:{}:value_exports={}",
994 relative, item.file.value_export_count,
995 ));
996 }
997 for item in &gaps.exports {
998 let relative = compact_path(&item.export.path, root);
999 lines.push(format!(
1000 "untested-export:{}:{}:{}",
1001 relative, item.export.line, item.export.export_name,
1002 ));
1003 }
1004 }
1005}
1006
1007fn push_runtime_sections_compact(
1008 lines: &mut Vec<String>,
1009 report: &fallow_output::HealthReport,
1010 root: &Path,
1011) {
1012 if let Some(ref production) = report.runtime_coverage {
1013 lines.extend(build_runtime_coverage_compact_lines(production, root));
1014 }
1015 if let Some(ref intelligence) = report.coverage_intelligence {
1016 lines.extend(build_coverage_intelligence_compact_lines(
1017 intelligence,
1018 root,
1019 ));
1020 }
1021}
1022
1023fn compact_ownership_suffix(ownership: Option<&fallow_output::OwnershipMetrics>) -> String {
1024 ownership.map_or_else(String::new, |o| {
1025 let mut parts = vec![
1026 format!("bus={}", o.bus_factor),
1027 format!("contributors={}", o.contributor_count),
1028 format!("top={}", o.top_contributor.identifier),
1029 format!("top_share={:.3}", o.top_contributor.share),
1030 ];
1031 if let Some(owner) = &o.declared_owner {
1032 parts.push(format!("owner={owner}"));
1033 }
1034 if let Some(unowned) = o.unowned {
1035 parts.push(format!("unowned={unowned}"));
1036 }
1037 let state = match o.ownership_state {
1038 fallow_output::OwnershipState::Active => "active",
1039 fallow_output::OwnershipState::Unowned => "unowned",
1040 fallow_output::OwnershipState::DeclaredInactive => "declared_inactive",
1041 fallow_output::OwnershipState::Drifting => "drifting",
1042 };
1043 parts.push(format!("ownership_state={state}"));
1044 if o.drift {
1045 parts.push("drift=true".to_string());
1046 }
1047 format!(",{}", parts.join(","))
1048 })
1049}
1050
1051fn push_hotspots_compact(
1052 lines: &mut Vec<String>,
1053 hotspots: &[fallow_output::HotspotFinding],
1054 root: &Path,
1055) {
1056 for entry in hotspots {
1057 let relative = compact_path(&entry.path, root);
1058 let ownership_suffix = compact_ownership_suffix(entry.ownership.as_ref());
1059 lines.push(format!(
1060 "hotspot:{}:score={:.1},commits={},churn={},density={:.2},fan_in={},trend={}{}",
1061 relative,
1062 entry.score,
1063 entry.commits,
1064 entry.lines_added + entry.lines_deleted,
1065 entry.complexity_density,
1066 entry.fan_in,
1067 entry.trend,
1068 ownership_suffix,
1069 ));
1070 }
1071}
1072
1073fn push_change_coupling_compact(
1076 lines: &mut Vec<String>,
1077 coupling: Option<&fallow_output::ChangeCouplingReport>,
1078 root: &Path,
1079) {
1080 let Some(coupling) = coupling else {
1081 return;
1082 };
1083 for pair in &coupling.pairs {
1084 lines.push(format!(
1085 "change-coupling:{}:{}:degree={:.2},shared={},commits_a={},commits_b={},strong={}",
1086 compact_path(&pair.path_a, root),
1087 compact_path(&pair.path_b, root),
1088 pair.degree,
1089 pair.shared_revisions,
1090 pair.revisions_a,
1091 pair.revisions_b,
1092 pair.strong,
1093 ));
1094 }
1095}
1096
1097fn push_health_trend_compact(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
1098 if let Some(ref trend) = report.health_trend {
1099 if let Some(current) = report.health_score.as_ref()
1100 && trend.compared_to.score.is_some()
1101 && !trend.metrics.iter().any(|metric| metric.name == "score")
1102 && fallow_output::health_score_comparison_note(
1103 trend.compared_to.score_formula_version,
1104 Some(current.formula_version),
1105 )
1106 .is_some()
1107 {
1108 let previous_formula = trend
1109 .compared_to
1110 .score_formula_version
1111 .map_or_else(|| "unknown".to_owned(), |version| version.to_string());
1112 lines.push(format!(
1113 "trend:score-omitted:previous_formula={previous_formula},current_formula={}",
1114 current.formula_version,
1115 ));
1116 }
1117 lines.push(format!(
1118 "trend:overall:direction={}",
1119 trend.overall_direction.label()
1120 ));
1121 for m in &trend.metrics {
1122 lines.push(format!(
1123 "trend:{}:previous={:.1},current={:.1},delta={:+.1},direction={}",
1124 m.name,
1125 m.previous,
1126 m.current,
1127 m.delta,
1128 m.direction.label(),
1129 ));
1130 }
1131 }
1132}
1133
1134fn push_refactoring_targets_compact(
1135 lines: &mut Vec<String>,
1136 targets: &[fallow_output::RefactoringTargetFinding],
1137 root: &Path,
1138) {
1139 for target in targets {
1140 let relative = compact_path(&target.path, root);
1141 let category = target.category.compact_label();
1142 let effort = target.effort.label();
1143 let confidence = target.confidence.label();
1144 lines.push(format!(
1145 "refactoring-target:{}:priority={:.1},efficiency={:.1},category={},effort={},confidence={}:{}",
1146 relative,
1147 target.priority,
1148 target.efficiency,
1149 category,
1150 effort,
1151 confidence,
1152 target.recommendation,
1153 ));
1154 }
1155}
1156
1157fn build_runtime_coverage_compact_lines(
1158 production: &fallow_output::RuntimeCoverageReport,
1159 root: &Path,
1160) -> Vec<String> {
1161 let mut lines = vec![format!(
1162 "runtime-coverage-summary:functions_tracked={},functions_hit={},functions_unhit={},functions_untracked={},coverage_percent={:.1},trace_count={},period_days={},deployments_seen={}",
1163 production.summary.functions_tracked,
1164 production.summary.functions_hit,
1165 production.summary.functions_unhit,
1166 production.summary.functions_untracked,
1167 production.summary.coverage_percent,
1168 production.summary.trace_count,
1169 production.summary.period_days,
1170 production.summary.deployments_seen,
1171 )];
1172 for finding in &production.findings {
1173 let relative = compact_path(&finding.path, root);
1174 let invocations = finding
1175 .invocations
1176 .map_or_else(|| "null".to_owned(), |hits| hits.to_string());
1177 lines.push(format!(
1178 "runtime-coverage:{}:{}:{}:id={},verdict={},invocations={},confidence={}",
1179 relative,
1180 finding.line,
1181 finding.function,
1182 finding.id,
1183 finding.verdict,
1184 invocations,
1185 finding.confidence,
1186 ));
1187 }
1188 for entry in &production.hot_paths {
1189 let relative = compact_path(&entry.path, root);
1190 lines.push(format!(
1191 "production-hot-path:{}:{}:{}:id={},invocations={},percentile={}",
1192 relative, entry.line, entry.function, entry.id, entry.invocations, entry.percentile,
1193 ));
1194 }
1195 lines
1196}
1197
1198fn build_coverage_intelligence_compact_lines(
1199 intelligence: &fallow_output::CoverageIntelligenceReport,
1200 root: &Path,
1201) -> Vec<String> {
1202 let mut lines = vec![format!(
1203 "coverage-intelligence-summary:verdict={},findings={},risky_changes={},high_confidence_deletes={},review_required={},refactor_carefully={},skipped_ambiguous_matches={}",
1204 intelligence.verdict,
1205 intelligence.summary.findings,
1206 intelligence.summary.risky_changes,
1207 intelligence.summary.high_confidence_deletes,
1208 intelligence.summary.review_required,
1209 intelligence.summary.refactor_carefully,
1210 intelligence.summary.skipped_ambiguous_matches,
1211 )];
1212 for finding in &intelligence.findings {
1213 let relative = compact_path(&finding.path, root);
1214 let identity = finding.identity.as_deref().unwrap_or("-");
1215 let signals = finding
1216 .signals
1217 .iter()
1218 .map(ToString::to_string)
1219 .collect::<Vec<_>>()
1220 .join("+");
1221 lines.push(format!(
1222 "coverage-intelligence:{}:{}:{}:id={},verdict={},recommendation={},confidence={},signals={}",
1223 relative,
1224 finding.line,
1225 identity,
1226 finding.id,
1227 finding.verdict,
1228 finding.recommendation,
1229 finding.confidence,
1230 signals,
1231 ));
1232 }
1233 lines
1234}
1235
1236#[must_use]
1238pub fn build_duplication_compact_lines(report: &DuplicationReport, root: &Path) -> Vec<String> {
1239 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
1240 let mut lines = Vec::new();
1241 for (index, group) in report.clone_groups.iter().enumerate() {
1242 let fingerprint = fingerprints.fingerprint_for_group(group);
1243 for instance in &group.instances {
1244 lines.push(format!(
1245 "code-duplication:{}:{}-{}:fingerprint={},group={},tokens={},lines={},instances={}{}",
1246 compact_path(&instance.file, root),
1247 instance.start_line,
1248 instance.end_line,
1249 fingerprint,
1250 index + 1,
1251 group.token_count,
1252 group.line_count,
1253 group.instances.len(),
1254 if instance.is_symlink {
1255 ",symlink=true"
1256 } else {
1257 ""
1258 },
1259 ));
1260 }
1261 }
1262 lines
1263}
1264
1265#[cfg(test)]
1266mod tests {
1267 use std::path::PathBuf;
1268
1269 use fallow_types::duplicates::{CloneGroup, CloneInstance, DuplicationStats};
1270 use fallow_types::output_dead_code::UnusedFileFinding;
1271 use fallow_types::results::{AnalysisResults, UnusedFile};
1272
1273 use super::*;
1274
1275 fn diff_risk_finding(
1276 rule: fallow_output::DiffRiskRule,
1277 kind: Option<fallow_output::WeakenedTestsKind>,
1278 path: &str,
1279 line: Option<u32>,
1280 evidence: &str,
1281 severity: fallow_output::DiffRiskSeverity,
1282 ) -> fallow_output::DiffRiskFinding {
1283 fallow_output::DiffRiskFinding {
1284 rule,
1285 kind,
1286 path: path.to_owned(),
1287 line,
1288 message: "m".to_owned(),
1289 evidence: evidence.to_owned(),
1290 severity,
1291 introduced: true,
1292 related: Vec::new(),
1293 actions: Vec::new(),
1294 }
1295 }
1296
1297 #[test]
1298 fn diff_risk_compact_lines_follow_the_tag_path_line_subject_grammar() {
1299 let report = fallow_output::DiffRiskReport {
1300 findings: vec![
1301 diff_risk_finding(
1302 fallow_output::DiffRiskRule::WeakenedTests,
1303 Some(fallow_output::WeakenedTestsKind::SkipOnly),
1304 "src/a.test.ts",
1305 Some(4),
1306 "it.skip",
1307 fallow_output::DiffRiskSeverity::Warn,
1308 ),
1309 diff_risk_finding(
1310 fallow_output::DiffRiskRule::WeakenedTests,
1311 Some(fallow_output::WeakenedTestsKind::RemovedTests),
1312 "src/b.test.ts",
1313 None,
1314 "test cases: 3 -> 1",
1315 fallow_output::DiffRiskSeverity::Error,
1316 ),
1317 diff_risk_finding(
1318 fallow_output::DiffRiskRule::AddedDependency,
1319 None,
1320 "package.json",
1321 Some(7),
1322 "left-pad@^1.3.0 (dependencies)",
1323 fallow_output::DiffRiskSeverity::Warn,
1324 ),
1325 ],
1326 };
1327 assert_eq!(
1328 build_diff_risk_compact_lines(&report),
1329 vec![
1330 "weakened-tests:src/a.test.ts:4:it.skip,severity=warn,kind=skip-only".to_owned(),
1331 "weakened-tests:src/b.test.ts:0:test cases 3 -> 1,severity=error,kind=removed-tests"
1332 .to_owned(),
1333 "added-dependency:package.json:7:left-pad@^1.3.0 (dependencies),severity=warn"
1334 .to_owned(),
1335 ]
1336 );
1337 }
1338
1339 #[test]
1340 fn compact_cycles_preserve_empty_self_loop_and_cross_package_output() {
1341 use fallow_types::output_dead_code::{CircularDependencyFinding, ReExportCycleFinding};
1342 use fallow_types::results::{CircularDependency, ReExportCycle, ReExportCycleKind};
1343
1344 let root = Path::new("/project");
1345 for (files, cross_package, expected) in [
1346 (vec![], false, "circular-dependency::7:"),
1347 (
1348 vec![root.join("src/a.ts")],
1349 false,
1350 "circular-dependency:src/a.ts:7:src/a.ts → src/a.ts",
1351 ),
1352 (
1353 vec![root.join("src/a.ts"), root.join("src/b.ts")],
1354 true,
1355 "circular-dependency:src/a.ts:7:src/a.ts → src/b.ts → src/a.ts (cross-package)",
1356 ),
1357 ] {
1358 let mut results = AnalysisResults::default();
1359 results
1360 .circular_dependencies
1361 .push(CircularDependencyFinding {
1362 finding_id: None,
1363 cycle: CircularDependency {
1364 length: files.len(),
1365 files,
1366 line: 7,
1367 col: 0,
1368 edges: vec![],
1369 is_cross_package: cross_package,
1370 },
1371 actions: vec![],
1372 introduced: None,
1373 effective_severity: None,
1374 });
1375 assert_eq!(build_compact_lines(&results, root), vec![expected]);
1376 }
1377 for (files, kind, expected) in [
1378 (vec![], ReExportCycleKind::MultiNode, "re-export-cycle::"),
1379 (
1380 vec![root.join("src/a.ts")],
1381 ReExportCycleKind::SelfLoop,
1382 "re-export-cycle:src/a.ts:src/a.ts (self-loop)",
1383 ),
1384 (
1385 vec![root.join("src/a.ts"), root.join("src/b.ts")],
1386 ReExportCycleKind::MultiNode,
1387 "re-export-cycle:src/a.ts:src/a.ts <-> src/b.ts",
1388 ),
1389 ] {
1390 let mut results = AnalysisResults::default();
1391 results.re_export_cycles.push(ReExportCycleFinding {
1392 finding_id: None,
1393 cycle: ReExportCycle { files, kind },
1394 actions: vec![],
1395 introduced: None,
1396 effective_severity: None,
1397 });
1398 assert_eq!(build_compact_lines(&results, root), vec![expected]);
1399 }
1400 }
1401
1402 #[test]
1408 fn a_caveated_finding_carries_a_trailing_caveat_field() {
1409 use fallow_types::extract::MemberKind;
1410 use fallow_types::output_dead_code::{ReachabilityCaveat, UnusedDependencyFinding};
1411 use fallow_types::results::{UnusedDependency, UnusedExport};
1412
1413 let root = PathBuf::from("/project");
1414 let mut results = AnalysisResults::default();
1415
1416 let mut file = UnusedFileFinding::with_actions(UnusedFile {
1417 path: root.join("src/dead.ts"),
1418 });
1419 file.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
1420 results.unused_files.push(file);
1421
1422 let mut export =
1423 fallow_types::output_dead_code::UnusedExportFinding::with_actions(UnusedExport {
1424 path: root.join("src/lib.ts"),
1425 export_name: "needed".to_owned(),
1426 is_type_only: false,
1427 line: 3,
1428 col: 0,
1429 span_start: 0,
1430 is_re_export: false,
1431 deprecated: false,
1432 deprecated_reason: None,
1433 });
1434 export.reachability_caveats = vec![
1435 ReachabilityCaveat::IncompleteFileAnalysis,
1436 ReachabilityCaveat::IncompleteImportGraph,
1437 ];
1438 results.unused_exports.push(export);
1439
1440 let mut dep = UnusedDependencyFinding::with_actions(UnusedDependency {
1441 package_name: "left-pad".to_owned(),
1442 location: fallow_types::results::DependencyLocation::Dependencies,
1443 path: root.join("package.json"),
1444 line: 5,
1445 used_in_workspaces: Vec::new(),
1446 declared_and_imported_in: Vec::new(),
1447 });
1448 dep.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
1449 results.unused_dependencies.push(dep);
1450
1451 let member = |parent: &str, name: &str, kind| fallow_types::results::UnusedMember {
1452 path: root.join("src/lib.ts"),
1453 parent_name: parent.to_owned(),
1454 member_name: name.to_owned(),
1455 kind,
1456 line: 7,
1457 col: 2,
1458 };
1459 let mut enum_member = fallow_types::output_dead_code::UnusedEnumMemberFinding::with_actions(
1460 member("Mode", "Legacy", MemberKind::EnumMember),
1461 );
1462 enum_member.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
1463 results.unused_enum_members.push(enum_member);
1464
1465 let mut class_member =
1466 fallow_types::output_dead_code::UnusedClassMemberFinding::with_actions(member(
1467 "Widget",
1468 "render",
1469 MemberKind::ClassMethod,
1470 ));
1471 class_member.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
1472 results.unused_class_members.push(class_member);
1473
1474 let mut store_member =
1475 fallow_types::output_dead_code::UnusedStoreMemberFinding::with_actions(member(
1476 "useCart",
1477 "subtotal",
1478 MemberKind::StoreMember,
1479 ));
1480 store_member.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
1481 results.unused_store_members.push(store_member);
1482
1483 let lines = build_compact_lines(&results, &root);
1484
1485 assert_eq!(
1486 lines,
1487 vec![
1488 "unused-file:src/dead.ts,caveat=incomplete-import-graph",
1489 "unused-export:src/lib.ts:3:needed,caveat=incomplete-file-analysis+incomplete-import-graph",
1490 "unused-dep:left-pad,caveat=incomplete-import-graph",
1491 "unused-enum-member:src/lib.ts:7:Mode.Legacy,caveat=incomplete-import-graph",
1492 "unused-class-member:src/lib.ts:7:Widget.render,caveat=incomplete-import-graph",
1493 "unused-store-member:src/lib.ts:7:useCart.subtotal,caveat=incomplete-import-graph",
1494 ]
1495 );
1496 }
1497
1498 #[test]
1499 fn compact_unused_file_format_uses_relative_paths() {
1500 let root = PathBuf::from("/project");
1501 let mut results = AnalysisResults::default();
1502 results
1503 .unused_files
1504 .push(UnusedFileFinding::with_actions(UnusedFile {
1505 path: root.join("src/dead.ts"),
1506 }));
1507
1508 let lines = build_compact_lines(&results, &root);
1509
1510 assert_eq!(lines, vec!["unused-file:src/dead.ts"]);
1511 }
1512
1513 #[test]
1514 fn grouped_compact_prefixes_each_issue_with_group_key() {
1515 let root = PathBuf::from("/project");
1516 let mut results = AnalysisResults::default();
1517 results
1518 .unused_files
1519 .push(UnusedFileFinding::with_actions(UnusedFile {
1520 path: root.join("src/dead.ts"),
1521 }));
1522 let groups = vec![ResultGroup {
1523 key: "team-a".to_owned(),
1524 owners: Some(vec!["@team-a".to_owned()]),
1525 results,
1526 }];
1527
1528 let lines = build_grouped_compact_lines(&groups, &root);
1529
1530 assert_eq!(lines, vec!["team-a\tunused-file:src/dead.ts"]);
1531 }
1532
1533 #[test]
1534 fn duplication_compact_lines_include_stable_group_context() {
1535 let root = PathBuf::from("/project");
1536 let report = DuplicationReport {
1537 clone_groups: vec![CloneGroup {
1538 instances: vec![CloneInstance {
1539 is_symlink: false,
1540 file: root.join("src/a.ts"),
1541 start_line: 2,
1542 end_line: 6,
1543 start_col: 0,
1544 end_col: 10,
1545 fragment: "const duplicated = true;".to_owned(),
1546 }],
1547 token_count: 12,
1548 line_count: 5,
1549 similarity: None,
1550 }],
1551 clone_families: Vec::new(),
1552 mirrored_directories: Vec::new(),
1553 stats: DuplicationStats::default(),
1554 };
1555
1556 let lines = build_duplication_compact_lines(&report, &root);
1557
1558 assert_eq!(lines.len(), 1);
1559 assert!(lines[0].starts_with("code-duplication:src/a.ts:2-6:fingerprint="));
1560 assert!(lines[0].contains(",group=1,tokens=12,lines=5,instances=1"));
1561 assert!(!lines[0].contains("symlink"));
1562 }
1563
1564 #[test]
1565 fn duplication_compact_lines_mark_symlinked_instances() {
1566 let root = PathBuf::from("/project");
1567 let instance = |path: &str, is_symlink: bool| CloneInstance {
1568 is_symlink,
1569 file: root.join(path),
1570 start_line: 1,
1571 end_line: 5,
1572 start_col: 0,
1573 end_col: 0,
1574 fragment: String::new(),
1575 };
1576 let report = DuplicationReport {
1577 clone_groups: vec![CloneGroup {
1578 instances: vec![
1579 instance("src/link.ts", true),
1580 instance("src/real.ts", false),
1581 ],
1582 token_count: 12,
1583 line_count: 5,
1584 similarity: None,
1585 }],
1586 clone_families: Vec::new(),
1587 mirrored_directories: Vec::new(),
1588 stats: DuplicationStats::default(),
1589 };
1590
1591 let lines = build_duplication_compact_lines(&report, &root);
1592
1593 assert!(lines[0].starts_with("code-duplication:src/link.ts:1-5:"));
1594 assert!(lines[0].ends_with(",instances=2,symlink=true"));
1595 assert!(lines[1].ends_with(",instances=2"));
1596 }
1597
1598 #[test]
1599 fn health_compact_lines_include_score_and_vital_signs() {
1600 let root = PathBuf::from("/project");
1601 let report = fallow_output::HealthReport {
1602 health_score: Some(fallow_output::HealthScore {
1603 formula_version: 1,
1604 score: 91.2,
1605 grade: "A",
1606 penalties: fallow_output::HealthScorePenalties {
1607 dead_files: None,
1608 dead_exports: None,
1609 complexity: 0.0,
1610 p90_complexity: 0.0,
1611 maintainability: None,
1612 hotspots: None,
1613 unused_deps: None,
1614 circular_deps: None,
1615 unit_size: None,
1616 coupling: None,
1617 duplication: None,
1618 prop_drilling: None,
1619 },
1620 }),
1621 vital_signs: Some(fallow_output::VitalSigns {
1622 total_loc: 120,
1623 avg_cyclomatic: 3.4,
1624 p90_cyclomatic: 8,
1625 ..Default::default()
1626 }),
1627 ..Default::default()
1628 };
1629
1630 let lines = build_health_compact_lines(&report, &root);
1631
1632 assert_eq!(lines[0], "health-score:91.2:A");
1633 assert_eq!(
1634 lines[1],
1635 "vital-signs:total_loc=120,avg_cyclomatic=3.4,p90_cyclomatic=8"
1636 );
1637 assert!(
1638 lines
1639 .iter()
1640 .all(|line| !line.starts_with("cyclomatic-population:"))
1641 );
1642 }
1643
1644 #[test]
1645 fn health_compact_population_rows_preserve_vital_signs_and_order() {
1646 let root = PathBuf::from("/project");
1647 let vitals = fallow_output::VitalSigns {
1648 avg_cyclomatic: 16.0,
1649 p90_cyclomatic: 31,
1650 ..Default::default()
1651 };
1652 let legacy = build_health_compact_lines(
1653 &fallow_output::HealthReport {
1654 vital_signs: Some(vitals.clone()),
1655 ..Default::default()
1656 },
1657 &root,
1658 );
1659 let report = fallow_output::HealthReport {
1660 vital_signs: Some(fallow_output::VitalSigns {
1661 cyclomatic_population: Some(fallow_output::CyclomaticPopulation {
1662 functions: fallow_output::CyclomaticUnitPopulation {
1663 count: 1,
1664 sum: 1,
1665 max: Some(1),
1666 },
1667 modules: fallow_output::CyclomaticUnitPopulation {
1668 count: 1,
1669 sum: 31,
1670 max: Some(31),
1671 },
1672 templates: fallow_output::CyclomaticUnitPopulation::default(),
1673 }),
1674 ..vitals
1675 }),
1676 ..Default::default()
1677 };
1678 let lines = build_health_compact_lines(&report, &root);
1679 assert_eq!(lines[0], legacy[0]);
1680 assert_eq!(
1681 &lines[1..],
1682 [
1683 "cyclomatic-population:functions:count=1,sum=1,max=1",
1684 "cyclomatic-population:modules:count=1,sum=31,max=31",
1685 "cyclomatic-population:templates:count=0,sum=0,max=null",
1686 ]
1687 );
1688 }
1689
1690 #[test]
1691 fn health_compact_measured_empty_populations_keep_null_maxima() {
1692 let report = fallow_output::HealthReport {
1693 vital_signs: Some(fallow_output::VitalSigns {
1694 cyclomatic_population: Some(fallow_output::CyclomaticPopulation::default()),
1695 ..Default::default()
1696 }),
1697 ..Default::default()
1698 };
1699 let lines = build_health_compact_lines(&report, &PathBuf::from("/project"));
1700 assert_eq!(
1701 &lines[1..],
1702 [
1703 "cyclomatic-population:functions:count=0,sum=0,max=null",
1704 "cyclomatic-population:modules:count=0,sum=0,max=null",
1705 "cyclomatic-population:templates:count=0,sum=0,max=null",
1706 ]
1707 );
1708 }
1709
1710 #[test]
1711 fn health_compact_lines_include_styling_findings() {
1712 let root = PathBuf::from("/project");
1713 let report = fallow_output::HealthReport {
1714 styling_findings: vec![fallow_output::StylingFinding {
1715 code: "css-token-drift".to_string(),
1716 sub_kind: "tailwind-arbitrary-value".to_string(),
1717 path: "/project/src/app.css".to_string(),
1718 line: 6,
1719 value: "--color-brand: rgb(240, 90, 41)".to_string(),
1720 effective_severity: fallow_output::StylingFindingSeverity::Warn,
1721 blast_radius: None,
1722 confidence: None,
1723 agent_disposition: None,
1724 nearest_token: None,
1725 fix_hint: None,
1726 actions: Vec::new(),
1727 }],
1728 ..Default::default()
1729 };
1730
1731 let lines = build_health_compact_lines(&report, &root);
1732
1733 assert_eq!(
1734 lines,
1735 vec![
1736 "css-token-drift:src/app.css:6:tailwind-arbitrary-value:severity=warn,value=--color-brand rgb(240 90 41)"
1737 ]
1738 );
1739 }
1740}