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