Skip to main content

fallow_api/
compact_output.rs

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
19/// The trailing `,caveat=<tokens>` field a compact record carries when the
20/// verdict behind it rests on a file this run never fully analyzed, or an empty
21/// string when it does not.
22///
23/// Compact records are not fixed at four colon-separated fields: duplication
24/// already appends `,fingerprint=...,group=...,tokens=...` after its location,
25/// so a trailing `,key=value` is the format's existing extension point and a
26/// parser that splits on the leading `:` fields is unaffected. Wire tokens are
27/// used rather than the prose labels the human report shows, because compact is
28/// consumed by scripts: `caveat=incomplete-import-graph` greps exactly.
29/// Multiple caveats join with `+`, since `,` already separates fields.
30fn 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
203/// Build compact output lines for analysis results.
204/// Each issue is represented as a single `prefix:details` line.
205pub 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    /// Push compact lines for the opt-in component health signals. They do not
490    /// count toward `total_issues`, but the other full reports list them.
491    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    /// Push compact lines for unrendered components, props, emits, inputs, and outputs.
533    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    /// Push compact lines for Svelte events, server actions, and load-data keys.
577    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/// Build grouped compact output lines, each prefixed with the group key.
665///
666/// Format: `group-key\tissue-tag:details`
667#[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/// Build compact output lines for health results.
680#[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
841/// Serde-name mirror of `ExceededThreshold` for the compact line grammar.
842fn 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        // The `high-complexity:` row prefix is deliberately kept even for a
877        // CRAP-only breach (other machine formats route that to
878        // `fallow/high-crap-score`): renaming a line prefix breaks grep-based CI
879        // consumers, and `exceeded=` carries the dimension instead (issue #2163).
880        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/// Build compact output lines for duplication results.
1134#[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    /// A compact record is what a CI script greps to decide what to delete, so
1231    /// a verdict resting on a file the run never read has to say so on the same
1232    /// line. The field rides in the trailing `,key=value` slot duplication
1233    /// records already use, so the leading colon-separated fields a parser
1234    /// splits on are untouched.
1235    #[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}