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