Skip to main content

fallow_api/
compact_output.rs

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