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) {
587 for finding in &self.results.unused_svelte_events {
588 self.lines.push(format!(
589 "unused-svelte-event:{}:{}:{}",
590 self.rel(&finding.event.path),
591 finding.event.line,
592 finding.event.event_name,
593 ));
594 }
595 for finding in &self.results.unused_server_actions {
596 self.lines.push(format!(
597 "unused-server-action:{}:{}:{}",
598 self.rel(&finding.action.path),
599 finding.action.line,
600 finding.action.action_name,
601 ));
602 }
603 for finding in &self.results.unused_load_data_keys {
604 self.lines.push(format!(
605 "unused-load-data-key:{}:{}:{}",
606 self.rel(&finding.key.path),
607 finding.key.line,
608 finding.key.key_name,
609 ));
610 }
611 }
612
613 fn push_route_lines(&mut self) {
614 for finding in &self.results.route_collisions {
615 self.lines.push(format!(
616 "route-collision:{}:{} (url {})",
617 self.rel(&finding.collision.path),
618 finding.collision.line,
619 finding.collision.url,
620 ));
621 }
622 for finding in &self.results.dynamic_segment_name_conflicts {
623 self.lines.push(format!(
624 "dynamic-segment-name-conflict:{}:{} ({} at {})",
625 self.rel(&finding.conflict.path),
626 finding.conflict.line,
627 finding.conflict.conflicting_segments.join(" vs "),
628 finding.conflict.position,
629 ));
630 }
631 }
632
633 fn push_suppression_lines(&mut self) {
634 for suppression in &self.results.stale_suppressions {
635 self.lines
636 .push(compact_stale_suppression_line(suppression, self.root));
637 }
638 }
639
640 fn push_workspace_lines(&mut self) {
641 for entry in &self.results.unused_catalog_entries {
642 self.lines.push(format!(
643 "unused-catalog-entry:{}:{}:{}:{}",
644 self.rel(&entry.entry.path),
645 entry.entry.line,
646 entry.entry.catalog_name,
647 entry.entry.entry_name,
648 ));
649 }
650 for group in &self.results.empty_catalog_groups {
651 self.lines.push(format!(
652 "empty-catalog-group:{}:{}:{}",
653 self.rel(&group.group.path),
654 group.group.line,
655 group.group.catalog_name,
656 ));
657 }
658 for finding in &self.results.unresolved_catalog_references {
659 self.lines
660 .push(compact_catalog_reference_line(finding, self.root));
661 }
662 for finding in &self.results.unused_dependency_overrides {
663 self.lines
664 .push(compact_unused_override_line(finding, self.root));
665 }
666 for finding in &self.results.misconfigured_dependency_overrides {
667 self.lines
668 .push(compact_misconfigured_override_line(finding, self.root));
669 }
670 }
671}
672
673#[must_use]
677pub fn build_grouped_compact_lines(groups: &[ResultGroup], root: &Path) -> Vec<String> {
678 groups
679 .iter()
680 .flat_map(|group| {
681 build_compact_lines(&group.results, root)
682 .into_iter()
683 .map(|line| format!("{}\t{line}", group.key))
684 })
685 .collect()
686}
687
688#[must_use]
690pub fn build_health_compact_lines(
691 report: &fallow_output::HealthReport,
692 root: &Path,
693) -> Vec<String> {
694 let mut lines = Vec::new();
695 push_health_score_compact(&mut lines, report);
696 push_vital_signs_compact(&mut lines, report);
697 push_health_findings_compact(&mut lines, &report.findings, root);
698 push_styling_findings_compact(&mut lines, &report.styling_findings, root);
699 push_threshold_overrides_compact(&mut lines, &report.threshold_overrides, root);
700 push_file_scores_compact(&mut lines, &report.file_scores, root);
701 push_coverage_gaps_compact(&mut lines, report, root);
702 push_runtime_sections_compact(&mut lines, report, root);
703 push_hotspots_compact(&mut lines, &report.hotspots, root);
704 push_health_trend_compact(&mut lines, report);
705 push_refactoring_targets_compact(&mut lines, &report.targets, root);
706 lines
707}
708
709fn push_styling_findings_compact(
710 lines: &mut Vec<String>,
711 findings: &[fallow_output::StylingFinding],
712 root: &Path,
713) {
714 for finding in findings {
715 let relative = compact_path(Path::new(&finding.path), root);
716 let severity = match finding.effective_severity {
717 fallow_output::StylingFindingSeverity::Error => "error",
718 fallow_output::StylingFindingSeverity::Warn => "warn",
719 };
720 let value = compact_field_value(&finding.value);
721 lines.push(format!(
722 "{}:{}:{}:{}:severity={},value={}",
723 finding.code, relative, finding.line, finding.sub_kind, severity, value
724 ));
725 }
726}
727
728fn compact_field_value(value: &str) -> String {
729 value
730 .replace([':', ',', '\n', '\r'], " ")
731 .split_whitespace()
732 .collect::<Vec<_>>()
733 .join(" ")
734}
735
736fn push_threshold_overrides_compact(
737 lines: &mut Vec<String>,
738 entries: &[fallow_output::ThresholdOverrideState],
739 root: &Path,
740) {
741 for entry in entries {
742 let status = match entry.status {
743 fallow_output::ThresholdOverrideStatus::Active => "active",
744 fallow_output::ThresholdOverrideStatus::Stale => "stale",
745 fallow_output::ThresholdOverrideStatus::Insufficient => "insufficient",
746 fallow_output::ThresholdOverrideStatus::NoMatch => "no_match",
747 };
748 let target = entry.path.as_ref().map_or_else(
749 || "no-match".to_string(),
750 |path| entry.target_label(&compact_path(path, root)),
751 );
752 let dimension = threshold_override_dimension_label(entry.dimension);
753 let metrics = entry.metrics.map_or(String::new(), |metrics| {
754 let crap = metrics
755 .crap
756 .map_or(String::new(), |value| format!(",crap={value:.1}"));
757 let line_count = metrics
758 .line_count
759 .map_or(String::new(), |value| format!(",lines={value}"));
760 format!(
761 ",cyclomatic={},cognitive={}{}{}",
762 metrics.cyclomatic, metrics.cognitive, line_count, crap
763 )
764 });
765 let outstanding = if entry.outstanding.is_empty() {
766 String::new()
767 } else {
768 format!(
769 ",outstanding={}",
770 entry
771 .outstanding
772 .iter()
773 .map(|value| threshold_override_dimension_label(*value))
774 .collect::<Vec<_>>()
775 .join("|")
776 )
777 };
778 lines.push(format!(
779 "threshold-override:{}:{}:{}:{}{}{}",
780 entry.override_index, dimension, status, target, metrics, outstanding
781 ));
782 }
783}
784
785fn threshold_override_dimension_label(
786 dimension: fallow_output::ThresholdOverrideDimension,
787) -> &'static str {
788 match dimension {
789 fallow_output::ThresholdOverrideDimension::Complexity => "complexity",
790 fallow_output::ThresholdOverrideDimension::Crap => "crap",
791 }
792}
793
794fn push_health_score_compact(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
795 if let Some(ref hs) = report.health_score {
796 lines.push(format!("health-score:{:.1}:{}", hs.score, hs.grade));
797 }
798}
799
800fn push_vital_signs_compact(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
801 if let Some(ref vs) = report.vital_signs {
802 let mut parts = Vec::new();
803 if vs.total_loc > 0 {
804 parts.push(format!("total_loc={}", vs.total_loc));
805 }
806 parts.push(format!("avg_cyclomatic={:.1}", vs.avg_cyclomatic));
807 parts.push(format!("p90_cyclomatic={}", vs.p90_cyclomatic));
808 if let Some(v) = vs.dead_file_pct {
809 parts.push(format!("dead_file_pct={v:.1}"));
810 }
811 if let Some(v) = vs.dead_export_pct {
812 parts.push(format!("dead_export_pct={v:.1}"));
813 }
814 if let Some(v) = vs.maintainability_avg {
815 parts.push(format!("maintainability_avg={v:.1}"));
816 }
817 if let Some(v) = vs.hotspot_count {
818 parts.push(format!("hotspot_count={v}"));
819 }
820 if let Some(v) = vs.circular_dep_count {
821 parts.push(format!("circular_dep_count={v}"));
822 }
823 if let Some(v) = vs.unused_dep_count {
824 parts.push(format!("unused_dep_count={v}"));
825 }
826 lines.push(format!("vital-signs:{}", parts.join(",")));
827 push_cyclomatic_population_compact(lines, vs);
828 }
829}
830
831fn push_cyclomatic_population_compact(lines: &mut Vec<String>, vs: &fallow_output::VitalSigns) {
832 let Some(population) = &vs.cyclomatic_population else {
833 return;
834 };
835 for (kind, group) in [
836 ("functions", &population.functions),
837 ("modules", &population.modules),
838 ("templates", &population.templates),
839 ] {
840 let max = group
841 .max
842 .map_or_else(|| "null".to_string(), |value| value.to_string());
843 lines.push(format!(
844 "cyclomatic-population:{kind}:count={},sum={},max={max}",
845 group.count, group.sum
846 ));
847 }
848}
849
850fn exceeded_compact_label(exceeded: fallow_output::ExceededThreshold) -> &'static str {
852 match exceeded {
853 fallow_output::ExceededThreshold::Cyclomatic => "cyclomatic",
854 fallow_output::ExceededThreshold::Cognitive => "cognitive",
855 fallow_output::ExceededThreshold::Both => "both",
856 fallow_output::ExceededThreshold::Crap => "crap",
857 fallow_output::ExceededThreshold::CyclomaticCrap => "cyclomatic_crap",
858 fallow_output::ExceededThreshold::CognitiveCrap => "cognitive_crap",
859 fallow_output::ExceededThreshold::All => "all",
860 }
861}
862
863fn push_health_findings_compact(
864 lines: &mut Vec<String>,
865 findings: &[fallow_output::HealthFinding],
866 root: &Path,
867) {
868 for finding in findings {
869 let relative = compact_path(&finding.path, root);
870 let severity = match finding.severity {
871 fallow_output::FindingSeverity::Critical => "critical",
872 fallow_output::FindingSeverity::High => "high",
873 fallow_output::FindingSeverity::Moderate => "moderate",
874 };
875 let crap_suffix = match finding.crap {
876 Some(crap) => {
877 let coverage = finding
878 .coverage_pct
879 .map(|pct| format!(",coverage_pct={pct:.1}"))
880 .unwrap_or_default();
881 format!(",crap={crap:.1}{coverage}")
882 }
883 None => String::new(),
884 };
885 lines.push(format!(
890 "high-complexity:{}:{}:{}:cyclomatic={},cognitive={},severity={},exceeded={}{}",
891 relative,
892 finding.line,
893 finding.name,
894 finding.cyclomatic,
895 finding.cognitive,
896 severity,
897 exceeded_compact_label(finding.exceeded),
898 crap_suffix,
899 ));
900 }
901}
902
903fn push_file_scores_compact(
904 lines: &mut Vec<String>,
905 scores: &[fallow_output::FileHealthScore],
906 root: &Path,
907) {
908 for score in scores {
909 let relative = compact_path(&score.path, root);
910 lines.push(format!(
911 "file-score:{}:mi={:.1},fan_in={},fan_out={},dead={:.2},density={:.2},crap_max={:.1},crap_above={}",
912 relative,
913 score.maintainability_index,
914 score.fan_in,
915 score.fan_out,
916 score.dead_code_ratio,
917 score.complexity_density,
918 score.crap_max,
919 score.crap_above_threshold,
920 ));
921 }
922}
923
924fn push_coverage_gaps_compact(
925 lines: &mut Vec<String>,
926 report: &fallow_output::HealthReport,
927 root: &Path,
928) {
929 if let Some(ref gaps) = report.coverage_gaps {
930 lines.push(format!(
931 "coverage-gap-summary:runtime_files={},covered_files={},file_coverage_pct={:.1},untested_files={},untested_exports={}",
932 gaps.summary.runtime_files,
933 gaps.summary.covered_files,
934 gaps.summary.file_coverage_pct,
935 gaps.summary.untested_files,
936 gaps.summary.untested_exports,
937 ));
938 for item in &gaps.files {
939 let relative = compact_path(&item.file.path, root);
940 lines.push(format!(
941 "untested-file:{}:value_exports={}",
942 relative, item.file.value_export_count,
943 ));
944 }
945 for item in &gaps.exports {
946 let relative = compact_path(&item.export.path, root);
947 lines.push(format!(
948 "untested-export:{}:{}:{}",
949 relative, item.export.line, item.export.export_name,
950 ));
951 }
952 }
953}
954
955fn push_runtime_sections_compact(
956 lines: &mut Vec<String>,
957 report: &fallow_output::HealthReport,
958 root: &Path,
959) {
960 if let Some(ref production) = report.runtime_coverage {
961 lines.extend(build_runtime_coverage_compact_lines(production, root));
962 }
963 if let Some(ref intelligence) = report.coverage_intelligence {
964 lines.extend(build_coverage_intelligence_compact_lines(
965 intelligence,
966 root,
967 ));
968 }
969}
970
971fn compact_ownership_suffix(ownership: Option<&fallow_output::OwnershipMetrics>) -> String {
972 ownership.map_or_else(String::new, |o| {
973 let mut parts = vec![
974 format!("bus={}", o.bus_factor),
975 format!("contributors={}", o.contributor_count),
976 format!("top={}", o.top_contributor.identifier),
977 format!("top_share={:.3}", o.top_contributor.share),
978 ];
979 if let Some(owner) = &o.declared_owner {
980 parts.push(format!("owner={owner}"));
981 }
982 if let Some(unowned) = o.unowned {
983 parts.push(format!("unowned={unowned}"));
984 }
985 let state = match o.ownership_state {
986 fallow_output::OwnershipState::Active => "active",
987 fallow_output::OwnershipState::Unowned => "unowned",
988 fallow_output::OwnershipState::DeclaredInactive => "declared_inactive",
989 fallow_output::OwnershipState::Drifting => "drifting",
990 };
991 parts.push(format!("ownership_state={state}"));
992 if o.drift {
993 parts.push("drift=true".to_string());
994 }
995 format!(",{}", parts.join(","))
996 })
997}
998
999fn push_hotspots_compact(
1000 lines: &mut Vec<String>,
1001 hotspots: &[fallow_output::HotspotFinding],
1002 root: &Path,
1003) {
1004 for entry in hotspots {
1005 let relative = compact_path(&entry.path, root);
1006 let ownership_suffix = compact_ownership_suffix(entry.ownership.as_ref());
1007 lines.push(format!(
1008 "hotspot:{}:score={:.1},commits={},churn={},density={:.2},fan_in={},trend={}{}",
1009 relative,
1010 entry.score,
1011 entry.commits,
1012 entry.lines_added + entry.lines_deleted,
1013 entry.complexity_density,
1014 entry.fan_in,
1015 entry.trend,
1016 ownership_suffix,
1017 ));
1018 }
1019}
1020
1021fn push_health_trend_compact(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
1022 if let Some(ref trend) = report.health_trend {
1023 if let Some(current) = report.health_score.as_ref()
1024 && trend.compared_to.score.is_some()
1025 && !trend.metrics.iter().any(|metric| metric.name == "score")
1026 && fallow_output::health_score_comparison_note(
1027 trend.compared_to.score_formula_version,
1028 Some(current.formula_version),
1029 )
1030 .is_some()
1031 {
1032 let previous_formula = trend
1033 .compared_to
1034 .score_formula_version
1035 .map_or_else(|| "unknown".to_owned(), |version| version.to_string());
1036 lines.push(format!(
1037 "trend:score-omitted:previous_formula={previous_formula},current_formula={}",
1038 current.formula_version,
1039 ));
1040 }
1041 lines.push(format!(
1042 "trend:overall:direction={}",
1043 trend.overall_direction.label()
1044 ));
1045 for m in &trend.metrics {
1046 lines.push(format!(
1047 "trend:{}:previous={:.1},current={:.1},delta={:+.1},direction={}",
1048 m.name,
1049 m.previous,
1050 m.current,
1051 m.delta,
1052 m.direction.label(),
1053 ));
1054 }
1055 }
1056}
1057
1058fn push_refactoring_targets_compact(
1059 lines: &mut Vec<String>,
1060 targets: &[fallow_output::RefactoringTargetFinding],
1061 root: &Path,
1062) {
1063 for target in targets {
1064 let relative = compact_path(&target.path, root);
1065 let category = target.category.compact_label();
1066 let effort = target.effort.label();
1067 let confidence = target.confidence.label();
1068 lines.push(format!(
1069 "refactoring-target:{}:priority={:.1},efficiency={:.1},category={},effort={},confidence={}:{}",
1070 relative,
1071 target.priority,
1072 target.efficiency,
1073 category,
1074 effort,
1075 confidence,
1076 target.recommendation,
1077 ));
1078 }
1079}
1080
1081fn build_runtime_coverage_compact_lines(
1082 production: &fallow_output::RuntimeCoverageReport,
1083 root: &Path,
1084) -> Vec<String> {
1085 let mut lines = vec![format!(
1086 "runtime-coverage-summary:functions_tracked={},functions_hit={},functions_unhit={},functions_untracked={},coverage_percent={:.1},trace_count={},period_days={},deployments_seen={}",
1087 production.summary.functions_tracked,
1088 production.summary.functions_hit,
1089 production.summary.functions_unhit,
1090 production.summary.functions_untracked,
1091 production.summary.coverage_percent,
1092 production.summary.trace_count,
1093 production.summary.period_days,
1094 production.summary.deployments_seen,
1095 )];
1096 for finding in &production.findings {
1097 let relative = compact_path(&finding.path, root);
1098 let invocations = finding
1099 .invocations
1100 .map_or_else(|| "null".to_owned(), |hits| hits.to_string());
1101 lines.push(format!(
1102 "runtime-coverage:{}:{}:{}:id={},verdict={},invocations={},confidence={}",
1103 relative,
1104 finding.line,
1105 finding.function,
1106 finding.id,
1107 finding.verdict,
1108 invocations,
1109 finding.confidence,
1110 ));
1111 }
1112 for entry in &production.hot_paths {
1113 let relative = compact_path(&entry.path, root);
1114 lines.push(format!(
1115 "production-hot-path:{}:{}:{}:id={},invocations={},percentile={}",
1116 relative, entry.line, entry.function, entry.id, entry.invocations, entry.percentile,
1117 ));
1118 }
1119 lines
1120}
1121
1122fn build_coverage_intelligence_compact_lines(
1123 intelligence: &fallow_output::CoverageIntelligenceReport,
1124 root: &Path,
1125) -> Vec<String> {
1126 let mut lines = vec![format!(
1127 "coverage-intelligence-summary:verdict={},findings={},risky_changes={},high_confidence_deletes={},review_required={},refactor_carefully={},skipped_ambiguous_matches={}",
1128 intelligence.verdict,
1129 intelligence.summary.findings,
1130 intelligence.summary.risky_changes,
1131 intelligence.summary.high_confidence_deletes,
1132 intelligence.summary.review_required,
1133 intelligence.summary.refactor_carefully,
1134 intelligence.summary.skipped_ambiguous_matches,
1135 )];
1136 for finding in &intelligence.findings {
1137 let relative = compact_path(&finding.path, root);
1138 let identity = finding.identity.as_deref().unwrap_or("-");
1139 let signals = finding
1140 .signals
1141 .iter()
1142 .map(ToString::to_string)
1143 .collect::<Vec<_>>()
1144 .join("+");
1145 lines.push(format!(
1146 "coverage-intelligence:{}:{}:{}:id={},verdict={},recommendation={},confidence={},signals={}",
1147 relative,
1148 finding.line,
1149 identity,
1150 finding.id,
1151 finding.verdict,
1152 finding.recommendation,
1153 finding.confidence,
1154 signals,
1155 ));
1156 }
1157 lines
1158}
1159
1160#[must_use]
1162pub fn build_duplication_compact_lines(report: &DuplicationReport, root: &Path) -> Vec<String> {
1163 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
1164 let mut lines = Vec::new();
1165 for (index, group) in report.clone_groups.iter().enumerate() {
1166 let fingerprint = fingerprints.fingerprint_for_group(group);
1167 for instance in &group.instances {
1168 lines.push(format!(
1169 "code-duplication:{}:{}-{}:fingerprint={},group={},tokens={},lines={},instances={}{}",
1170 compact_path(&instance.file, root),
1171 instance.start_line,
1172 instance.end_line,
1173 fingerprint,
1174 index + 1,
1175 group.token_count,
1176 group.line_count,
1177 group.instances.len(),
1178 if instance.is_symlink {
1179 ",symlink=true"
1180 } else {
1181 ""
1182 },
1183 ));
1184 }
1185 }
1186 lines
1187}
1188
1189#[cfg(test)]
1190mod tests {
1191 use std::path::PathBuf;
1192
1193 use fallow_types::duplicates::{CloneGroup, CloneInstance, DuplicationStats};
1194 use fallow_types::output_dead_code::UnusedFileFinding;
1195 use fallow_types::results::{AnalysisResults, UnusedFile};
1196
1197 use super::*;
1198
1199 #[test]
1200 fn compact_cycles_preserve_empty_self_loop_and_cross_package_output() {
1201 use fallow_types::output_dead_code::{CircularDependencyFinding, ReExportCycleFinding};
1202 use fallow_types::results::{CircularDependency, ReExportCycle, ReExportCycleKind};
1203
1204 let root = Path::new("/project");
1205 for (files, cross_package, expected) in [
1206 (vec![], false, "circular-dependency::7:"),
1207 (
1208 vec![root.join("src/a.ts")],
1209 false,
1210 "circular-dependency:src/a.ts:7:src/a.ts → src/a.ts",
1211 ),
1212 (
1213 vec![root.join("src/a.ts"), root.join("src/b.ts")],
1214 true,
1215 "circular-dependency:src/a.ts:7:src/a.ts → src/b.ts → src/a.ts (cross-package)",
1216 ),
1217 ] {
1218 let mut results = AnalysisResults::default();
1219 results
1220 .circular_dependencies
1221 .push(CircularDependencyFinding {
1222 finding_id: None,
1223 cycle: CircularDependency {
1224 length: files.len(),
1225 files,
1226 line: 7,
1227 col: 0,
1228 edges: vec![],
1229 is_cross_package: cross_package,
1230 },
1231 actions: vec![],
1232 introduced: None,
1233 effective_severity: None,
1234 });
1235 assert_eq!(build_compact_lines(&results, root), vec![expected]);
1236 }
1237 for (files, kind, expected) in [
1238 (vec![], ReExportCycleKind::MultiNode, "re-export-cycle::"),
1239 (
1240 vec![root.join("src/a.ts")],
1241 ReExportCycleKind::SelfLoop,
1242 "re-export-cycle:src/a.ts:src/a.ts (self-loop)",
1243 ),
1244 (
1245 vec![root.join("src/a.ts"), root.join("src/b.ts")],
1246 ReExportCycleKind::MultiNode,
1247 "re-export-cycle:src/a.ts:src/a.ts <-> src/b.ts",
1248 ),
1249 ] {
1250 let mut results = AnalysisResults::default();
1251 results.re_export_cycles.push(ReExportCycleFinding {
1252 finding_id: None,
1253 cycle: ReExportCycle { files, kind },
1254 actions: vec![],
1255 introduced: None,
1256 effective_severity: None,
1257 });
1258 assert_eq!(build_compact_lines(&results, root), vec![expected]);
1259 }
1260 }
1261
1262 #[test]
1268 fn a_caveated_finding_carries_a_trailing_caveat_field() {
1269 use fallow_types::extract::MemberKind;
1270 use fallow_types::output_dead_code::{ReachabilityCaveat, UnusedDependencyFinding};
1271 use fallow_types::results::{UnusedDependency, UnusedExport};
1272
1273 let root = PathBuf::from("/project");
1274 let mut results = AnalysisResults::default();
1275
1276 let mut file = UnusedFileFinding::with_actions(UnusedFile {
1277 path: root.join("src/dead.ts"),
1278 });
1279 file.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
1280 results.unused_files.push(file);
1281
1282 let mut export =
1283 fallow_types::output_dead_code::UnusedExportFinding::with_actions(UnusedExport {
1284 path: root.join("src/lib.ts"),
1285 export_name: "needed".to_owned(),
1286 is_type_only: false,
1287 line: 3,
1288 col: 0,
1289 span_start: 0,
1290 is_re_export: false,
1291 deprecated: false,
1292 deprecated_reason: None,
1293 });
1294 export.reachability_caveats = vec![
1295 ReachabilityCaveat::IncompleteFileAnalysis,
1296 ReachabilityCaveat::IncompleteImportGraph,
1297 ];
1298 results.unused_exports.push(export);
1299
1300 let mut dep = UnusedDependencyFinding::with_actions(UnusedDependency {
1301 package_name: "left-pad".to_owned(),
1302 location: fallow_types::results::DependencyLocation::Dependencies,
1303 path: root.join("package.json"),
1304 line: 5,
1305 used_in_workspaces: Vec::new(),
1306 declared_and_imported_in: Vec::new(),
1307 });
1308 dep.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
1309 results.unused_dependencies.push(dep);
1310
1311 let member = |parent: &str, name: &str, kind| fallow_types::results::UnusedMember {
1312 path: root.join("src/lib.ts"),
1313 parent_name: parent.to_owned(),
1314 member_name: name.to_owned(),
1315 kind,
1316 line: 7,
1317 col: 2,
1318 };
1319 let mut enum_member = fallow_types::output_dead_code::UnusedEnumMemberFinding::with_actions(
1320 member("Mode", "Legacy", MemberKind::EnumMember),
1321 );
1322 enum_member.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
1323 results.unused_enum_members.push(enum_member);
1324
1325 let mut class_member =
1326 fallow_types::output_dead_code::UnusedClassMemberFinding::with_actions(member(
1327 "Widget",
1328 "render",
1329 MemberKind::ClassMethod,
1330 ));
1331 class_member.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
1332 results.unused_class_members.push(class_member);
1333
1334 let mut store_member =
1335 fallow_types::output_dead_code::UnusedStoreMemberFinding::with_actions(member(
1336 "useCart",
1337 "subtotal",
1338 MemberKind::StoreMember,
1339 ));
1340 store_member.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
1341 results.unused_store_members.push(store_member);
1342
1343 let lines = build_compact_lines(&results, &root);
1344
1345 assert_eq!(
1346 lines,
1347 vec![
1348 "unused-file:src/dead.ts,caveat=incomplete-import-graph",
1349 "unused-export:src/lib.ts:3:needed,caveat=incomplete-file-analysis+incomplete-import-graph",
1350 "unused-dep:left-pad,caveat=incomplete-import-graph",
1351 "unused-enum-member:src/lib.ts:7:Mode.Legacy,caveat=incomplete-import-graph",
1352 "unused-class-member:src/lib.ts:7:Widget.render,caveat=incomplete-import-graph",
1353 "unused-store-member:src/lib.ts:7:useCart.subtotal,caveat=incomplete-import-graph",
1354 ]
1355 );
1356 }
1357
1358 #[test]
1359 fn compact_unused_file_format_uses_relative_paths() {
1360 let root = PathBuf::from("/project");
1361 let mut results = AnalysisResults::default();
1362 results
1363 .unused_files
1364 .push(UnusedFileFinding::with_actions(UnusedFile {
1365 path: root.join("src/dead.ts"),
1366 }));
1367
1368 let lines = build_compact_lines(&results, &root);
1369
1370 assert_eq!(lines, vec!["unused-file:src/dead.ts"]);
1371 }
1372
1373 #[test]
1374 fn grouped_compact_prefixes_each_issue_with_group_key() {
1375 let root = PathBuf::from("/project");
1376 let mut results = AnalysisResults::default();
1377 results
1378 .unused_files
1379 .push(UnusedFileFinding::with_actions(UnusedFile {
1380 path: root.join("src/dead.ts"),
1381 }));
1382 let groups = vec![ResultGroup {
1383 key: "team-a".to_owned(),
1384 owners: Some(vec!["@team-a".to_owned()]),
1385 results,
1386 }];
1387
1388 let lines = build_grouped_compact_lines(&groups, &root);
1389
1390 assert_eq!(lines, vec!["team-a\tunused-file:src/dead.ts"]);
1391 }
1392
1393 #[test]
1394 fn duplication_compact_lines_include_stable_group_context() {
1395 let root = PathBuf::from("/project");
1396 let report = DuplicationReport {
1397 clone_groups: vec![CloneGroup {
1398 instances: vec![CloneInstance {
1399 is_symlink: false,
1400 file: root.join("src/a.ts"),
1401 start_line: 2,
1402 end_line: 6,
1403 start_col: 0,
1404 end_col: 10,
1405 fragment: "const duplicated = true;".to_owned(),
1406 }],
1407 token_count: 12,
1408 line_count: 5,
1409 similarity: None,
1410 }],
1411 clone_families: Vec::new(),
1412 mirrored_directories: Vec::new(),
1413 stats: DuplicationStats::default(),
1414 };
1415
1416 let lines = build_duplication_compact_lines(&report, &root);
1417
1418 assert_eq!(lines.len(), 1);
1419 assert!(lines[0].starts_with("code-duplication:src/a.ts:2-6:fingerprint="));
1420 assert!(lines[0].contains(",group=1,tokens=12,lines=5,instances=1"));
1421 assert!(!lines[0].contains("symlink"));
1422 }
1423
1424 #[test]
1425 fn duplication_compact_lines_mark_symlinked_instances() {
1426 let root = PathBuf::from("/project");
1427 let instance = |path: &str, is_symlink: bool| CloneInstance {
1428 is_symlink,
1429 file: root.join(path),
1430 start_line: 1,
1431 end_line: 5,
1432 start_col: 0,
1433 end_col: 0,
1434 fragment: String::new(),
1435 };
1436 let report = DuplicationReport {
1437 clone_groups: vec![CloneGroup {
1438 instances: vec![
1439 instance("src/link.ts", true),
1440 instance("src/real.ts", false),
1441 ],
1442 token_count: 12,
1443 line_count: 5,
1444 similarity: None,
1445 }],
1446 clone_families: Vec::new(),
1447 mirrored_directories: Vec::new(),
1448 stats: DuplicationStats::default(),
1449 };
1450
1451 let lines = build_duplication_compact_lines(&report, &root);
1452
1453 assert!(lines[0].starts_with("code-duplication:src/link.ts:1-5:"));
1454 assert!(lines[0].ends_with(",instances=2,symlink=true"));
1455 assert!(lines[1].ends_with(",instances=2"));
1456 }
1457
1458 #[test]
1459 fn health_compact_lines_include_score_and_vital_signs() {
1460 let root = PathBuf::from("/project");
1461 let report = fallow_output::HealthReport {
1462 health_score: Some(fallow_output::HealthScore {
1463 formula_version: 1,
1464 score: 91.2,
1465 grade: "A",
1466 penalties: fallow_output::HealthScorePenalties {
1467 dead_files: None,
1468 dead_exports: None,
1469 complexity: 0.0,
1470 p90_complexity: 0.0,
1471 maintainability: None,
1472 hotspots: None,
1473 unused_deps: None,
1474 circular_deps: None,
1475 unit_size: None,
1476 coupling: None,
1477 duplication: None,
1478 prop_drilling: None,
1479 },
1480 }),
1481 vital_signs: Some(fallow_output::VitalSigns {
1482 total_loc: 120,
1483 avg_cyclomatic: 3.4,
1484 p90_cyclomatic: 8,
1485 ..Default::default()
1486 }),
1487 ..Default::default()
1488 };
1489
1490 let lines = build_health_compact_lines(&report, &root);
1491
1492 assert_eq!(lines[0], "health-score:91.2:A");
1493 assert_eq!(
1494 lines[1],
1495 "vital-signs:total_loc=120,avg_cyclomatic=3.4,p90_cyclomatic=8"
1496 );
1497 assert!(
1498 lines
1499 .iter()
1500 .all(|line| !line.starts_with("cyclomatic-population:"))
1501 );
1502 }
1503
1504 #[test]
1505 fn health_compact_population_rows_preserve_vital_signs_and_order() {
1506 let root = PathBuf::from("/project");
1507 let vitals = fallow_output::VitalSigns {
1508 avg_cyclomatic: 16.0,
1509 p90_cyclomatic: 31,
1510 ..Default::default()
1511 };
1512 let legacy = build_health_compact_lines(
1513 &fallow_output::HealthReport {
1514 vital_signs: Some(vitals.clone()),
1515 ..Default::default()
1516 },
1517 &root,
1518 );
1519 let report = fallow_output::HealthReport {
1520 vital_signs: Some(fallow_output::VitalSigns {
1521 cyclomatic_population: Some(fallow_output::CyclomaticPopulation {
1522 functions: fallow_output::CyclomaticUnitPopulation {
1523 count: 1,
1524 sum: 1,
1525 max: Some(1),
1526 },
1527 modules: fallow_output::CyclomaticUnitPopulation {
1528 count: 1,
1529 sum: 31,
1530 max: Some(31),
1531 },
1532 templates: fallow_output::CyclomaticUnitPopulation::default(),
1533 }),
1534 ..vitals
1535 }),
1536 ..Default::default()
1537 };
1538 let lines = build_health_compact_lines(&report, &root);
1539 assert_eq!(lines[0], legacy[0]);
1540 assert_eq!(
1541 &lines[1..],
1542 [
1543 "cyclomatic-population:functions:count=1,sum=1,max=1",
1544 "cyclomatic-population:modules:count=1,sum=31,max=31",
1545 "cyclomatic-population:templates:count=0,sum=0,max=null",
1546 ]
1547 );
1548 }
1549
1550 #[test]
1551 fn health_compact_measured_empty_populations_keep_null_maxima() {
1552 let report = fallow_output::HealthReport {
1553 vital_signs: Some(fallow_output::VitalSigns {
1554 cyclomatic_population: Some(fallow_output::CyclomaticPopulation::default()),
1555 ..Default::default()
1556 }),
1557 ..Default::default()
1558 };
1559 let lines = build_health_compact_lines(&report, &PathBuf::from("/project"));
1560 assert_eq!(
1561 &lines[1..],
1562 [
1563 "cyclomatic-population:functions:count=0,sum=0,max=null",
1564 "cyclomatic-population:modules:count=0,sum=0,max=null",
1565 "cyclomatic-population:templates:count=0,sum=0,max=null",
1566 ]
1567 );
1568 }
1569
1570 #[test]
1571 fn health_compact_lines_include_styling_findings() {
1572 let root = PathBuf::from("/project");
1573 let report = fallow_output::HealthReport {
1574 styling_findings: vec![fallow_output::StylingFinding {
1575 code: "css-token-drift".to_string(),
1576 sub_kind: "tailwind-arbitrary-value".to_string(),
1577 path: "/project/src/app.css".to_string(),
1578 line: 6,
1579 value: "--color-brand: rgb(240, 90, 41)".to_string(),
1580 effective_severity: fallow_output::StylingFindingSeverity::Warn,
1581 blast_radius: None,
1582 confidence: None,
1583 agent_disposition: None,
1584 nearest_token: None,
1585 fix_hint: None,
1586 actions: Vec::new(),
1587 }],
1588 ..Default::default()
1589 };
1590
1591 let lines = build_health_compact_lines(&report, &root);
1592
1593 assert_eq!(
1594 lines,
1595 vec![
1596 "css-token-drift:src/app.css:6:tailwind-arbitrary-value:severity=warn,value=--color-brand rgb(240 90 41)"
1597 ]
1598 );
1599 }
1600}