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