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 component health findings. Advisory findings stay
490    /// outside `total_issues`; explicit prop enforcement contributes to it.
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, Effect findings, server actions,
586    /// and load-data keys.
587    fn push_component_framework_lines(&mut self) {
588        for finding in &self.results.unused_svelte_events {
589            self.lines.push(format!(
590                "unused-svelte-event:{}:{}:{}",
591                self.rel(&finding.event.path),
592                finding.event.line,
593                finding.event.event_name,
594            ));
595        }
596        for finding in &self.results.unused_effect_layers {
597            self.lines.push(format!(
598                "unused-effect-layer:{}:{}:{}",
599                self.rel(&finding.layer.path),
600                finding.layer.line,
601                finding.layer.name,
602            ));
603        }
604        for finding in &self.results.unused_tagged_errors {
605            self.lines.push(format!(
606                "unused-tagged-error:{}:{}:{}",
607                self.rel(&finding.error.path),
608                finding.error.line,
609                finding.error.name,
610            ));
611        }
612        for finding in &self.results.unproduced_catch_tags {
613            self.lines.push(format!(
614                "unproduced-catch-tag:{}:{}:{}",
615                self.rel(&finding.catch_tag.path),
616                finding.catch_tag.line,
617                finding.catch_tag.tag,
618            ));
619        }
620        for finding in &self.results.unused_server_actions {
621            self.lines.push(format!(
622                "unused-server-action:{}:{}:{}",
623                self.rel(&finding.action.path),
624                finding.action.line,
625                finding.action.action_name,
626            ));
627        }
628        for finding in &self.results.unused_load_data_keys {
629            self.lines.push(format!(
630                "unused-load-data-key:{}:{}:{}",
631                self.rel(&finding.key.path),
632                finding.key.line,
633                finding.key.key_name,
634            ));
635        }
636    }
637
638    fn push_route_lines(&mut self) {
639        for finding in &self.results.route_collisions {
640            self.lines.push(format!(
641                "route-collision:{}:{} (url {})",
642                self.rel(&finding.collision.path),
643                finding.collision.line,
644                finding.collision.url,
645            ));
646        }
647        for finding in &self.results.dynamic_segment_name_conflicts {
648            self.lines.push(format!(
649                "dynamic-segment-name-conflict:{}:{} ({} at {})",
650                self.rel(&finding.conflict.path),
651                finding.conflict.line,
652                finding.conflict.conflicting_segments.join(" vs "),
653                finding.conflict.position,
654            ));
655        }
656    }
657
658    fn push_suppression_lines(&mut self) {
659        for suppression in &self.results.stale_suppressions {
660            self.lines
661                .push(compact_stale_suppression_line(suppression, self.root));
662        }
663    }
664
665    fn push_workspace_lines(&mut self) {
666        for entry in &self.results.unused_catalog_entries {
667            self.lines.push(format!(
668                "unused-catalog-entry:{}:{}:{}:{}",
669                self.rel(&entry.entry.path),
670                entry.entry.line,
671                entry.entry.catalog_name,
672                entry.entry.entry_name,
673            ));
674        }
675        for group in &self.results.empty_catalog_groups {
676            self.lines.push(format!(
677                "empty-catalog-group:{}:{}:{}",
678                self.rel(&group.group.path),
679                group.group.line,
680                group.group.catalog_name,
681            ));
682        }
683        for finding in &self.results.unresolved_catalog_references {
684            self.lines
685                .push(compact_catalog_reference_line(finding, self.root));
686        }
687        for finding in &self.results.unused_dependency_overrides {
688            self.lines
689                .push(compact_unused_override_line(finding, self.root));
690        }
691        for finding in &self.results.misconfigured_dependency_overrides {
692            self.lines
693                .push(compact_misconfigured_override_line(finding, self.root));
694        }
695    }
696}
697
698/// Build grouped compact output lines, each prefixed with the group key.
699///
700/// Format: `group-key\tissue-tag:details`
701#[must_use]
702pub fn build_grouped_compact_lines(groups: &[ResultGroup], root: &Path) -> Vec<String> {
703    groups
704        .iter()
705        .flat_map(|group| {
706            build_compact_lines(&group.results, root)
707                .into_iter()
708                .map(|line| format!("{}\t{line}", group.key))
709        })
710        .collect()
711}
712
713/// Build compact output lines for health results.
714#[must_use]
715pub fn build_health_compact_lines(
716    report: &fallow_output::HealthReport,
717    root: &Path,
718) -> Vec<String> {
719    let mut lines = Vec::new();
720    push_health_score_compact(&mut lines, report);
721    push_vital_signs_compact(&mut lines, report);
722    push_health_findings_compact(&mut lines, &report.findings, root);
723    push_styling_findings_compact(&mut lines, &report.styling_findings, root);
724    push_threshold_overrides_compact(&mut lines, &report.threshold_overrides, root);
725    push_file_scores_compact(&mut lines, &report.file_scores, root);
726    push_coverage_gaps_compact(&mut lines, report, root);
727    push_runtime_sections_compact(&mut lines, report, root);
728    push_hotspots_compact(&mut lines, &report.hotspots, root);
729    push_change_coupling_compact(&mut lines, report.change_coupling.as_ref(), root);
730    push_health_trend_compact(&mut lines, report);
731    push_refactoring_targets_compact(&mut lines, &report.targets, root);
732    lines
733}
734
735fn push_styling_findings_compact(
736    lines: &mut Vec<String>,
737    findings: &[fallow_output::StylingFinding],
738    root: &Path,
739) {
740    for finding in findings {
741        let relative = compact_path(Path::new(&finding.path), root);
742        let severity = match finding.effective_severity {
743            fallow_output::StylingFindingSeverity::Error => "error",
744            fallow_output::StylingFindingSeverity::Warn => "warn",
745        };
746        let value = compact_field_value(&finding.value);
747        lines.push(format!(
748            "{}:{}:{}:{}:severity={},value={}",
749            finding.code, relative, finding.line, finding.sub_kind, severity, value
750        ));
751    }
752}
753
754/// Compact records for the diff-risk findings of `fallow audit`.
755///
756/// Format: `<rule>:<path>:<line>:<evidence>,severity=<severity>[,kind=<kind>]`.
757/// The paths are already root-relative. The line is `0` when the finding is
758/// about the whole file.
759#[must_use]
760pub fn build_diff_risk_compact_lines(report: &fallow_output::DiffRiskReport) -> Vec<String> {
761    report
762        .findings
763        .iter()
764        .map(|finding| {
765            let kind = finding
766                .kind
767                .map_or_else(String::new, |kind| format!(",kind={}", kind.code()));
768            format!(
769                "{}:{}:{}:{},severity={}{kind}",
770                finding.rule.code(),
771                normalize_uri(&finding.path),
772                finding.line.unwrap_or(0),
773                compact_field_value(&finding.evidence),
774                finding.severity.code(),
775            )
776        })
777        .collect()
778}
779
780fn compact_field_value(value: &str) -> String {
781    value
782        .replace([':', ',', '\n', '\r'], " ")
783        .split_whitespace()
784        .collect::<Vec<_>>()
785        .join(" ")
786}
787
788fn push_threshold_overrides_compact(
789    lines: &mut Vec<String>,
790    entries: &[fallow_output::ThresholdOverrideState],
791    root: &Path,
792) {
793    for entry in entries {
794        let status = match entry.status {
795            fallow_output::ThresholdOverrideStatus::Active => "active",
796            fallow_output::ThresholdOverrideStatus::Stale => "stale",
797            fallow_output::ThresholdOverrideStatus::Insufficient => "insufficient",
798            fallow_output::ThresholdOverrideStatus::NoMatch => "no_match",
799        };
800        let target = entry.path.as_ref().map_or_else(
801            || "no-match".to_string(),
802            |path| entry.target_label(&compact_path(path, root)),
803        );
804        let dimension = threshold_override_dimension_label(entry.dimension);
805        let metrics = entry.metrics.map_or(String::new(), |metrics| {
806            let crap = metrics
807                .crap
808                .map_or(String::new(), |value| format!(",crap={value:.1}"));
809            let line_count = metrics
810                .line_count
811                .map_or(String::new(), |value| format!(",lines={value}"));
812            format!(
813                ",cyclomatic={},cognitive={}{}{}",
814                metrics.cyclomatic, metrics.cognitive, line_count, crap
815            )
816        });
817        let outstanding = if entry.outstanding.is_empty() {
818            String::new()
819        } else {
820            format!(
821                ",outstanding={}",
822                entry
823                    .outstanding
824                    .iter()
825                    .map(|value| threshold_override_dimension_label(*value))
826                    .collect::<Vec<_>>()
827                    .join("|")
828            )
829        };
830        lines.push(format!(
831            "threshold-override:{}:{}:{}:{}{}{}",
832            entry.override_index, dimension, status, target, metrics, outstanding
833        ));
834    }
835}
836
837fn threshold_override_dimension_label(
838    dimension: fallow_output::ThresholdOverrideDimension,
839) -> &'static str {
840    match dimension {
841        fallow_output::ThresholdOverrideDimension::Complexity => "complexity",
842        fallow_output::ThresholdOverrideDimension::Crap => "crap",
843    }
844}
845
846fn push_health_score_compact(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
847    if let Some(ref hs) = report.health_score {
848        lines.push(format!("health-score:{:.1}:{}", hs.score, hs.grade));
849    }
850}
851
852fn push_vital_signs_compact(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
853    if let Some(ref vs) = report.vital_signs {
854        let mut parts = Vec::new();
855        if vs.total_loc > 0 {
856            parts.push(format!("total_loc={}", vs.total_loc));
857        }
858        parts.push(format!("avg_cyclomatic={:.1}", vs.avg_cyclomatic));
859        parts.push(format!("p90_cyclomatic={}", vs.p90_cyclomatic));
860        if let Some(v) = vs.dead_file_pct {
861            parts.push(format!("dead_file_pct={v:.1}"));
862        }
863        if let Some(v) = vs.dead_export_pct {
864            parts.push(format!("dead_export_pct={v:.1}"));
865        }
866        if let Some(v) = vs.maintainability_avg {
867            parts.push(format!("maintainability_avg={v:.1}"));
868        }
869        if let Some(v) = vs.hotspot_count {
870            parts.push(format!("hotspot_count={v}"));
871        }
872        if let Some(v) = vs.circular_dep_count {
873            parts.push(format!("circular_dep_count={v}"));
874        }
875        if let Some(v) = vs.unused_dep_count {
876            parts.push(format!("unused_dep_count={v}"));
877        }
878        lines.push(format!("vital-signs:{}", parts.join(",")));
879        push_cyclomatic_population_compact(lines, vs);
880    }
881}
882
883fn push_cyclomatic_population_compact(lines: &mut Vec<String>, vs: &fallow_output::VitalSigns) {
884    let Some(population) = &vs.cyclomatic_population else {
885        return;
886    };
887    for (kind, group) in [
888        ("functions", &population.functions),
889        ("modules", &population.modules),
890        ("templates", &population.templates),
891    ] {
892        let max = group
893            .max
894            .map_or_else(|| "null".to_string(), |value| value.to_string());
895        lines.push(format!(
896            "cyclomatic-population:{kind}:count={},sum={},max={max}",
897            group.count, group.sum
898        ));
899    }
900}
901
902/// Serde-name mirror of `ExceededThreshold` for the compact line grammar.
903fn exceeded_compact_label(exceeded: fallow_output::ExceededThreshold) -> &'static str {
904    match exceeded {
905        fallow_output::ExceededThreshold::Cyclomatic => "cyclomatic",
906        fallow_output::ExceededThreshold::Cognitive => "cognitive",
907        fallow_output::ExceededThreshold::Both => "both",
908        fallow_output::ExceededThreshold::Crap => "crap",
909        fallow_output::ExceededThreshold::CyclomaticCrap => "cyclomatic_crap",
910        fallow_output::ExceededThreshold::CognitiveCrap => "cognitive_crap",
911        fallow_output::ExceededThreshold::All => "all",
912    }
913}
914
915fn push_health_findings_compact(
916    lines: &mut Vec<String>,
917    findings: &[fallow_output::HealthFinding],
918    root: &Path,
919) {
920    for finding in findings {
921        let relative = compact_path(&finding.path, root);
922        let severity = match finding.severity {
923            fallow_output::FindingSeverity::Critical => "critical",
924            fallow_output::FindingSeverity::High => "high",
925            fallow_output::FindingSeverity::Moderate => "moderate",
926        };
927        let crap_suffix = match finding.crap {
928            Some(crap) => {
929                let coverage = finding
930                    .coverage_pct
931                    .map(|pct| format!(",coverage_pct={pct:.1}"))
932                    .unwrap_or_default();
933                format!(",crap={crap:.1}{coverage}")
934            }
935            None => String::new(),
936        };
937        // The `high-complexity:` row prefix is deliberately kept even for a
938        // CRAP-only breach (other machine formats route that to
939        // `fallow/high-crap-score`): renaming a line prefix breaks grep-based CI
940        // consumers, and `exceeded=` carries the dimension instead (issue #2163).
941        lines.push(format!(
942            "high-complexity:{}:{}:{}:cyclomatic={},cognitive={},severity={},exceeded={}{}",
943            relative,
944            finding.line,
945            finding.name,
946            finding.cyclomatic,
947            finding.cognitive,
948            severity,
949            exceeded_compact_label(finding.exceeded),
950            crap_suffix,
951        ));
952    }
953}
954
955fn push_file_scores_compact(
956    lines: &mut Vec<String>,
957    scores: &[fallow_output::FileHealthScore],
958    root: &Path,
959) {
960    for score in scores {
961        let relative = compact_path(&score.path, root);
962        lines.push(format!(
963            "file-score:{}:mi={:.1},fan_in={},fan_out={},dead={:.2},density={:.2},crap_max={:.1},crap_above={}",
964            relative,
965            score.maintainability_index,
966            score.fan_in,
967            score.fan_out,
968            score.dead_code_ratio,
969            score.complexity_density,
970            score.crap_max,
971            score.crap_above_threshold,
972        ));
973    }
974}
975
976fn push_coverage_gaps_compact(
977    lines: &mut Vec<String>,
978    report: &fallow_output::HealthReport,
979    root: &Path,
980) {
981    if let Some(ref gaps) = report.coverage_gaps {
982        lines.push(format!(
983            "coverage-gap-summary:runtime_files={},covered_files={},file_coverage_pct={:.1},untested_files={},untested_exports={}",
984            gaps.summary.runtime_files,
985            gaps.summary.covered_files,
986            gaps.summary.file_coverage_pct,
987            gaps.summary.untested_files,
988            gaps.summary.untested_exports,
989        ));
990        for item in &gaps.files {
991            let relative = compact_path(&item.file.path, root);
992            lines.push(format!(
993                "untested-file:{}:value_exports={}",
994                relative, item.file.value_export_count,
995            ));
996        }
997        for item in &gaps.exports {
998            let relative = compact_path(&item.export.path, root);
999            lines.push(format!(
1000                "untested-export:{}:{}:{}",
1001                relative, item.export.line, item.export.export_name,
1002            ));
1003        }
1004    }
1005}
1006
1007fn push_runtime_sections_compact(
1008    lines: &mut Vec<String>,
1009    report: &fallow_output::HealthReport,
1010    root: &Path,
1011) {
1012    if let Some(ref production) = report.runtime_coverage {
1013        lines.extend(build_runtime_coverage_compact_lines(production, root));
1014    }
1015    if let Some(ref intelligence) = report.coverage_intelligence {
1016        lines.extend(build_coverage_intelligence_compact_lines(
1017            intelligence,
1018            root,
1019        ));
1020    }
1021}
1022
1023fn compact_ownership_suffix(ownership: Option<&fallow_output::OwnershipMetrics>) -> String {
1024    ownership.map_or_else(String::new, |o| {
1025        let mut parts = vec![
1026            format!("bus={}", o.bus_factor),
1027            format!("contributors={}", o.contributor_count),
1028            format!("top={}", o.top_contributor.identifier),
1029            format!("top_share={:.3}", o.top_contributor.share),
1030        ];
1031        if let Some(owner) = &o.declared_owner {
1032            parts.push(format!("owner={owner}"));
1033        }
1034        if let Some(unowned) = o.unowned {
1035            parts.push(format!("unowned={unowned}"));
1036        }
1037        let state = match o.ownership_state {
1038            fallow_output::OwnershipState::Active => "active",
1039            fallow_output::OwnershipState::Unowned => "unowned",
1040            fallow_output::OwnershipState::DeclaredInactive => "declared_inactive",
1041            fallow_output::OwnershipState::Drifting => "drifting",
1042        };
1043        parts.push(format!("ownership_state={state}"));
1044        if o.drift {
1045            parts.push("drift=true".to_string());
1046        }
1047        format!(",{}", parts.join(","))
1048    })
1049}
1050
1051fn push_hotspots_compact(
1052    lines: &mut Vec<String>,
1053    hotspots: &[fallow_output::HotspotFinding],
1054    root: &Path,
1055) {
1056    for entry in hotspots {
1057        let relative = compact_path(&entry.path, root);
1058        let ownership_suffix = compact_ownership_suffix(entry.ownership.as_ref());
1059        lines.push(format!(
1060            "hotspot:{}:score={:.1},commits={},churn={},density={:.2},fan_in={},trend={}{}",
1061            relative,
1062            entry.score,
1063            entry.commits,
1064            entry.lines_added + entry.lines_deleted,
1065            entry.complexity_density,
1066            entry.fan_in,
1067            entry.trend,
1068            ownership_suffix,
1069        ));
1070    }
1071}
1072
1073/// One line per hidden change-coupling pair:
1074/// `change-coupling:<path_a>:<path_b>:degree=..,shared=..,commits_a=..,commits_b=..,strong=..`.
1075fn push_change_coupling_compact(
1076    lines: &mut Vec<String>,
1077    coupling: Option<&fallow_output::ChangeCouplingReport>,
1078    root: &Path,
1079) {
1080    let Some(coupling) = coupling else {
1081        return;
1082    };
1083    for pair in &coupling.pairs {
1084        lines.push(format!(
1085            "change-coupling:{}:{}:degree={:.2},shared={},commits_a={},commits_b={},strong={}",
1086            compact_path(&pair.path_a, root),
1087            compact_path(&pair.path_b, root),
1088            pair.degree,
1089            pair.shared_revisions,
1090            pair.revisions_a,
1091            pair.revisions_b,
1092            pair.strong,
1093        ));
1094    }
1095}
1096
1097fn push_health_trend_compact(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
1098    if let Some(ref trend) = report.health_trend {
1099        if let Some(current) = report.health_score.as_ref()
1100            && trend.compared_to.score.is_some()
1101            && !trend.metrics.iter().any(|metric| metric.name == "score")
1102            && fallow_output::health_score_comparison_note(
1103                trend.compared_to.score_formula_version,
1104                Some(current.formula_version),
1105            )
1106            .is_some()
1107        {
1108            let previous_formula = trend
1109                .compared_to
1110                .score_formula_version
1111                .map_or_else(|| "unknown".to_owned(), |version| version.to_string());
1112            lines.push(format!(
1113                "trend:score-omitted:previous_formula={previous_formula},current_formula={}",
1114                current.formula_version,
1115            ));
1116        }
1117        lines.push(format!(
1118            "trend:overall:direction={}",
1119            trend.overall_direction.label()
1120        ));
1121        for m in &trend.metrics {
1122            lines.push(format!(
1123                "trend:{}:previous={:.1},current={:.1},delta={:+.1},direction={}",
1124                m.name,
1125                m.previous,
1126                m.current,
1127                m.delta,
1128                m.direction.label(),
1129            ));
1130        }
1131    }
1132}
1133
1134fn push_refactoring_targets_compact(
1135    lines: &mut Vec<String>,
1136    targets: &[fallow_output::RefactoringTargetFinding],
1137    root: &Path,
1138) {
1139    for target in targets {
1140        let relative = compact_path(&target.path, root);
1141        let category = target.category.compact_label();
1142        let effort = target.effort.label();
1143        let confidence = target.confidence.label();
1144        lines.push(format!(
1145            "refactoring-target:{}:priority={:.1},efficiency={:.1},category={},effort={},confidence={}:{}",
1146            relative,
1147            target.priority,
1148            target.efficiency,
1149            category,
1150            effort,
1151            confidence,
1152            target.recommendation,
1153        ));
1154    }
1155}
1156
1157fn build_runtime_coverage_compact_lines(
1158    production: &fallow_output::RuntimeCoverageReport,
1159    root: &Path,
1160) -> Vec<String> {
1161    let mut lines = vec![format!(
1162        "runtime-coverage-summary:functions_tracked={},functions_hit={},functions_unhit={},functions_untracked={},coverage_percent={:.1},trace_count={},period_days={},deployments_seen={}",
1163        production.summary.functions_tracked,
1164        production.summary.functions_hit,
1165        production.summary.functions_unhit,
1166        production.summary.functions_untracked,
1167        production.summary.coverage_percent,
1168        production.summary.trace_count,
1169        production.summary.period_days,
1170        production.summary.deployments_seen,
1171    )];
1172    for finding in &production.findings {
1173        let relative = compact_path(&finding.path, root);
1174        let invocations = finding
1175            .invocations
1176            .map_or_else(|| "null".to_owned(), |hits| hits.to_string());
1177        lines.push(format!(
1178            "runtime-coverage:{}:{}:{}:id={},verdict={},invocations={},confidence={}",
1179            relative,
1180            finding.line,
1181            finding.function,
1182            finding.id,
1183            finding.verdict,
1184            invocations,
1185            finding.confidence,
1186        ));
1187    }
1188    for entry in &production.hot_paths {
1189        let relative = compact_path(&entry.path, root);
1190        lines.push(format!(
1191            "production-hot-path:{}:{}:{}:id={},invocations={},percentile={}",
1192            relative, entry.line, entry.function, entry.id, entry.invocations, entry.percentile,
1193        ));
1194    }
1195    lines
1196}
1197
1198fn build_coverage_intelligence_compact_lines(
1199    intelligence: &fallow_output::CoverageIntelligenceReport,
1200    root: &Path,
1201) -> Vec<String> {
1202    let mut lines = vec![format!(
1203        "coverage-intelligence-summary:verdict={},findings={},risky_changes={},high_confidence_deletes={},review_required={},refactor_carefully={},skipped_ambiguous_matches={}",
1204        intelligence.verdict,
1205        intelligence.summary.findings,
1206        intelligence.summary.risky_changes,
1207        intelligence.summary.high_confidence_deletes,
1208        intelligence.summary.review_required,
1209        intelligence.summary.refactor_carefully,
1210        intelligence.summary.skipped_ambiguous_matches,
1211    )];
1212    for finding in &intelligence.findings {
1213        let relative = compact_path(&finding.path, root);
1214        let identity = finding.identity.as_deref().unwrap_or("-");
1215        let signals = finding
1216            .signals
1217            .iter()
1218            .map(ToString::to_string)
1219            .collect::<Vec<_>>()
1220            .join("+");
1221        lines.push(format!(
1222            "coverage-intelligence:{}:{}:{}:id={},verdict={},recommendation={},confidence={},signals={}",
1223            relative,
1224            finding.line,
1225            identity,
1226            finding.id,
1227            finding.verdict,
1228            finding.recommendation,
1229            finding.confidence,
1230            signals,
1231        ));
1232    }
1233    lines
1234}
1235
1236/// Build compact output lines for duplication results.
1237#[must_use]
1238pub fn build_duplication_compact_lines(report: &DuplicationReport, root: &Path) -> Vec<String> {
1239    let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
1240    let mut lines = Vec::new();
1241    for (index, group) in report.clone_groups.iter().enumerate() {
1242        let fingerprint = fingerprints.fingerprint_for_group(group);
1243        for instance in &group.instances {
1244            lines.push(format!(
1245                "code-duplication:{}:{}-{}:fingerprint={},group={},tokens={},lines={},instances={}{}",
1246                compact_path(&instance.file, root),
1247                instance.start_line,
1248                instance.end_line,
1249                fingerprint,
1250                index + 1,
1251                group.token_count,
1252                group.line_count,
1253                group.instances.len(),
1254                if instance.is_symlink {
1255                    ",symlink=true"
1256                } else {
1257                    ""
1258                },
1259            ));
1260        }
1261    }
1262    lines
1263}
1264
1265#[cfg(test)]
1266mod tests {
1267    use std::path::PathBuf;
1268
1269    use fallow_types::duplicates::{CloneGroup, CloneInstance, DuplicationStats};
1270    use fallow_types::output_dead_code::UnusedFileFinding;
1271    use fallow_types::results::{AnalysisResults, UnusedFile};
1272
1273    use super::*;
1274
1275    fn diff_risk_finding(
1276        rule: fallow_output::DiffRiskRule,
1277        kind: Option<fallow_output::WeakenedTestsKind>,
1278        path: &str,
1279        line: Option<u32>,
1280        evidence: &str,
1281        severity: fallow_output::DiffRiskSeverity,
1282    ) -> fallow_output::DiffRiskFinding {
1283        fallow_output::DiffRiskFinding {
1284            rule,
1285            kind,
1286            path: path.to_owned(),
1287            line,
1288            message: "m".to_owned(),
1289            evidence: evidence.to_owned(),
1290            severity,
1291            introduced: true,
1292            related: Vec::new(),
1293            actions: Vec::new(),
1294        }
1295    }
1296
1297    #[test]
1298    fn diff_risk_compact_lines_follow_the_tag_path_line_subject_grammar() {
1299        let report = fallow_output::DiffRiskReport {
1300            findings: vec![
1301                diff_risk_finding(
1302                    fallow_output::DiffRiskRule::WeakenedTests,
1303                    Some(fallow_output::WeakenedTestsKind::SkipOnly),
1304                    "src/a.test.ts",
1305                    Some(4),
1306                    "it.skip",
1307                    fallow_output::DiffRiskSeverity::Warn,
1308                ),
1309                diff_risk_finding(
1310                    fallow_output::DiffRiskRule::WeakenedTests,
1311                    Some(fallow_output::WeakenedTestsKind::RemovedTests),
1312                    "src/b.test.ts",
1313                    None,
1314                    "test cases: 3 -> 1",
1315                    fallow_output::DiffRiskSeverity::Error,
1316                ),
1317                diff_risk_finding(
1318                    fallow_output::DiffRiskRule::AddedDependency,
1319                    None,
1320                    "package.json",
1321                    Some(7),
1322                    "left-pad@^1.3.0 (dependencies)",
1323                    fallow_output::DiffRiskSeverity::Warn,
1324                ),
1325            ],
1326        };
1327        assert_eq!(
1328            build_diff_risk_compact_lines(&report),
1329            vec![
1330                "weakened-tests:src/a.test.ts:4:it.skip,severity=warn,kind=skip-only".to_owned(),
1331                "weakened-tests:src/b.test.ts:0:test cases 3 -> 1,severity=error,kind=removed-tests"
1332                    .to_owned(),
1333                "added-dependency:package.json:7:left-pad@^1.3.0 (dependencies),severity=warn"
1334                    .to_owned(),
1335            ]
1336        );
1337    }
1338
1339    #[test]
1340    fn compact_cycles_preserve_empty_self_loop_and_cross_package_output() {
1341        use fallow_types::output_dead_code::{CircularDependencyFinding, ReExportCycleFinding};
1342        use fallow_types::results::{CircularDependency, ReExportCycle, ReExportCycleKind};
1343
1344        let root = Path::new("/project");
1345        for (files, cross_package, expected) in [
1346            (vec![], false, "circular-dependency::7:"),
1347            (
1348                vec![root.join("src/a.ts")],
1349                false,
1350                "circular-dependency:src/a.ts:7:src/a.ts → src/a.ts",
1351            ),
1352            (
1353                vec![root.join("src/a.ts"), root.join("src/b.ts")],
1354                true,
1355                "circular-dependency:src/a.ts:7:src/a.ts → src/b.ts → src/a.ts (cross-package)",
1356            ),
1357        ] {
1358            let mut results = AnalysisResults::default();
1359            results
1360                .circular_dependencies
1361                .push(CircularDependencyFinding {
1362                    finding_id: None,
1363                    cycle: CircularDependency {
1364                        length: files.len(),
1365                        files,
1366                        line: 7,
1367                        col: 0,
1368                        edges: vec![],
1369                        is_cross_package: cross_package,
1370                    },
1371                    actions: vec![],
1372                    introduced: None,
1373                    effective_severity: None,
1374                });
1375            assert_eq!(build_compact_lines(&results, root), vec![expected]);
1376        }
1377        for (files, kind, expected) in [
1378            (vec![], ReExportCycleKind::MultiNode, "re-export-cycle::"),
1379            (
1380                vec![root.join("src/a.ts")],
1381                ReExportCycleKind::SelfLoop,
1382                "re-export-cycle:src/a.ts:src/a.ts (self-loop)",
1383            ),
1384            (
1385                vec![root.join("src/a.ts"), root.join("src/b.ts")],
1386                ReExportCycleKind::MultiNode,
1387                "re-export-cycle:src/a.ts:src/a.ts <-> src/b.ts",
1388            ),
1389        ] {
1390            let mut results = AnalysisResults::default();
1391            results.re_export_cycles.push(ReExportCycleFinding {
1392                finding_id: None,
1393                cycle: ReExportCycle { files, kind },
1394                actions: vec![],
1395                introduced: None,
1396                effective_severity: None,
1397            });
1398            assert_eq!(build_compact_lines(&results, root), vec![expected]);
1399        }
1400    }
1401
1402    /// A compact record is what a CI script greps to decide what to delete, so
1403    /// a verdict resting on a file the run never read has to say so on the same
1404    /// line. The field rides in the trailing `,key=value` slot duplication
1405    /// records already use, so the leading colon-separated fields a parser
1406    /// splits on are untouched.
1407    #[test]
1408    fn a_caveated_finding_carries_a_trailing_caveat_field() {
1409        use fallow_types::extract::MemberKind;
1410        use fallow_types::output_dead_code::{ReachabilityCaveat, UnusedDependencyFinding};
1411        use fallow_types::results::{UnusedDependency, UnusedExport};
1412
1413        let root = PathBuf::from("/project");
1414        let mut results = AnalysisResults::default();
1415
1416        let mut file = UnusedFileFinding::with_actions(UnusedFile {
1417            path: root.join("src/dead.ts"),
1418        });
1419        file.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
1420        results.unused_files.push(file);
1421
1422        let mut export =
1423            fallow_types::output_dead_code::UnusedExportFinding::with_actions(UnusedExport {
1424                path: root.join("src/lib.ts"),
1425                export_name: "needed".to_owned(),
1426                is_type_only: false,
1427                line: 3,
1428                col: 0,
1429                span_start: 0,
1430                is_re_export: false,
1431                deprecated: false,
1432                deprecated_reason: None,
1433            });
1434        export.reachability_caveats = vec![
1435            ReachabilityCaveat::IncompleteFileAnalysis,
1436            ReachabilityCaveat::IncompleteImportGraph,
1437        ];
1438        results.unused_exports.push(export);
1439
1440        let mut dep = UnusedDependencyFinding::with_actions(UnusedDependency {
1441            package_name: "left-pad".to_owned(),
1442            location: fallow_types::results::DependencyLocation::Dependencies,
1443            path: root.join("package.json"),
1444            line: 5,
1445            used_in_workspaces: Vec::new(),
1446            declared_and_imported_in: Vec::new(),
1447        });
1448        dep.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
1449        results.unused_dependencies.push(dep);
1450
1451        let member = |parent: &str, name: &str, kind| fallow_types::results::UnusedMember {
1452            path: root.join("src/lib.ts"),
1453            parent_name: parent.to_owned(),
1454            member_name: name.to_owned(),
1455            kind,
1456            line: 7,
1457            col: 2,
1458        };
1459        let mut enum_member = fallow_types::output_dead_code::UnusedEnumMemberFinding::with_actions(
1460            member("Mode", "Legacy", MemberKind::EnumMember),
1461        );
1462        enum_member.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
1463        results.unused_enum_members.push(enum_member);
1464
1465        let mut class_member =
1466            fallow_types::output_dead_code::UnusedClassMemberFinding::with_actions(member(
1467                "Widget",
1468                "render",
1469                MemberKind::ClassMethod,
1470            ));
1471        class_member.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
1472        results.unused_class_members.push(class_member);
1473
1474        let mut store_member =
1475            fallow_types::output_dead_code::UnusedStoreMemberFinding::with_actions(member(
1476                "useCart",
1477                "subtotal",
1478                MemberKind::StoreMember,
1479            ));
1480        store_member.reachability_caveats = vec![ReachabilityCaveat::IncompleteImportGraph];
1481        results.unused_store_members.push(store_member);
1482
1483        let lines = build_compact_lines(&results, &root);
1484
1485        assert_eq!(
1486            lines,
1487            vec![
1488                "unused-file:src/dead.ts,caveat=incomplete-import-graph",
1489                "unused-export:src/lib.ts:3:needed,caveat=incomplete-file-analysis+incomplete-import-graph",
1490                "unused-dep:left-pad,caveat=incomplete-import-graph",
1491                "unused-enum-member:src/lib.ts:7:Mode.Legacy,caveat=incomplete-import-graph",
1492                "unused-class-member:src/lib.ts:7:Widget.render,caveat=incomplete-import-graph",
1493                "unused-store-member:src/lib.ts:7:useCart.subtotal,caveat=incomplete-import-graph",
1494            ]
1495        );
1496    }
1497
1498    #[test]
1499    fn compact_unused_file_format_uses_relative_paths() {
1500        let root = PathBuf::from("/project");
1501        let mut results = AnalysisResults::default();
1502        results
1503            .unused_files
1504            .push(UnusedFileFinding::with_actions(UnusedFile {
1505                path: root.join("src/dead.ts"),
1506            }));
1507
1508        let lines = build_compact_lines(&results, &root);
1509
1510        assert_eq!(lines, vec!["unused-file:src/dead.ts"]);
1511    }
1512
1513    #[test]
1514    fn grouped_compact_prefixes_each_issue_with_group_key() {
1515        let root = PathBuf::from("/project");
1516        let mut results = AnalysisResults::default();
1517        results
1518            .unused_files
1519            .push(UnusedFileFinding::with_actions(UnusedFile {
1520                path: root.join("src/dead.ts"),
1521            }));
1522        let groups = vec![ResultGroup {
1523            key: "team-a".to_owned(),
1524            owners: Some(vec!["@team-a".to_owned()]),
1525            results,
1526        }];
1527
1528        let lines = build_grouped_compact_lines(&groups, &root);
1529
1530        assert_eq!(lines, vec!["team-a\tunused-file:src/dead.ts"]);
1531    }
1532
1533    #[test]
1534    fn duplication_compact_lines_include_stable_group_context() {
1535        let root = PathBuf::from("/project");
1536        let report = DuplicationReport {
1537            clone_groups: vec![CloneGroup {
1538                instances: vec![CloneInstance {
1539                    is_symlink: false,
1540                    file: root.join("src/a.ts"),
1541                    start_line: 2,
1542                    end_line: 6,
1543                    start_col: 0,
1544                    end_col: 10,
1545                    fragment: "const duplicated = true;".to_owned(),
1546                }],
1547                token_count: 12,
1548                line_count: 5,
1549                similarity: None,
1550            }],
1551            clone_families: Vec::new(),
1552            mirrored_directories: Vec::new(),
1553            stats: DuplicationStats::default(),
1554        };
1555
1556        let lines = build_duplication_compact_lines(&report, &root);
1557
1558        assert_eq!(lines.len(), 1);
1559        assert!(lines[0].starts_with("code-duplication:src/a.ts:2-6:fingerprint="));
1560        assert!(lines[0].contains(",group=1,tokens=12,lines=5,instances=1"));
1561        assert!(!lines[0].contains("symlink"));
1562    }
1563
1564    #[test]
1565    fn duplication_compact_lines_mark_symlinked_instances() {
1566        let root = PathBuf::from("/project");
1567        let instance = |path: &str, is_symlink: bool| CloneInstance {
1568            is_symlink,
1569            file: root.join(path),
1570            start_line: 1,
1571            end_line: 5,
1572            start_col: 0,
1573            end_col: 0,
1574            fragment: String::new(),
1575        };
1576        let report = DuplicationReport {
1577            clone_groups: vec![CloneGroup {
1578                instances: vec![
1579                    instance("src/link.ts", true),
1580                    instance("src/real.ts", false),
1581                ],
1582                token_count: 12,
1583                line_count: 5,
1584                similarity: None,
1585            }],
1586            clone_families: Vec::new(),
1587            mirrored_directories: Vec::new(),
1588            stats: DuplicationStats::default(),
1589        };
1590
1591        let lines = build_duplication_compact_lines(&report, &root);
1592
1593        assert!(lines[0].starts_with("code-duplication:src/link.ts:1-5:"));
1594        assert!(lines[0].ends_with(",instances=2,symlink=true"));
1595        assert!(lines[1].ends_with(",instances=2"));
1596    }
1597
1598    #[test]
1599    fn health_compact_lines_include_score_and_vital_signs() {
1600        let root = PathBuf::from("/project");
1601        let report = fallow_output::HealthReport {
1602            health_score: Some(fallow_output::HealthScore {
1603                formula_version: 1,
1604                score: 91.2,
1605                grade: "A",
1606                penalties: fallow_output::HealthScorePenalties {
1607                    dead_files: None,
1608                    dead_exports: None,
1609                    complexity: 0.0,
1610                    p90_complexity: 0.0,
1611                    maintainability: None,
1612                    hotspots: None,
1613                    unused_deps: None,
1614                    circular_deps: None,
1615                    unit_size: None,
1616                    coupling: None,
1617                    duplication: None,
1618                    prop_drilling: None,
1619                },
1620            }),
1621            vital_signs: Some(fallow_output::VitalSigns {
1622                total_loc: 120,
1623                avg_cyclomatic: 3.4,
1624                p90_cyclomatic: 8,
1625                ..Default::default()
1626            }),
1627            ..Default::default()
1628        };
1629
1630        let lines = build_health_compact_lines(&report, &root);
1631
1632        assert_eq!(lines[0], "health-score:91.2:A");
1633        assert_eq!(
1634            lines[1],
1635            "vital-signs:total_loc=120,avg_cyclomatic=3.4,p90_cyclomatic=8"
1636        );
1637        assert!(
1638            lines
1639                .iter()
1640                .all(|line| !line.starts_with("cyclomatic-population:"))
1641        );
1642    }
1643
1644    #[test]
1645    fn health_compact_population_rows_preserve_vital_signs_and_order() {
1646        let root = PathBuf::from("/project");
1647        let vitals = fallow_output::VitalSigns {
1648            avg_cyclomatic: 16.0,
1649            p90_cyclomatic: 31,
1650            ..Default::default()
1651        };
1652        let legacy = build_health_compact_lines(
1653            &fallow_output::HealthReport {
1654                vital_signs: Some(vitals.clone()),
1655                ..Default::default()
1656            },
1657            &root,
1658        );
1659        let report = fallow_output::HealthReport {
1660            vital_signs: Some(fallow_output::VitalSigns {
1661                cyclomatic_population: Some(fallow_output::CyclomaticPopulation {
1662                    functions: fallow_output::CyclomaticUnitPopulation {
1663                        count: 1,
1664                        sum: 1,
1665                        max: Some(1),
1666                    },
1667                    modules: fallow_output::CyclomaticUnitPopulation {
1668                        count: 1,
1669                        sum: 31,
1670                        max: Some(31),
1671                    },
1672                    templates: fallow_output::CyclomaticUnitPopulation::default(),
1673                }),
1674                ..vitals
1675            }),
1676            ..Default::default()
1677        };
1678        let lines = build_health_compact_lines(&report, &root);
1679        assert_eq!(lines[0], legacy[0]);
1680        assert_eq!(
1681            &lines[1..],
1682            [
1683                "cyclomatic-population:functions:count=1,sum=1,max=1",
1684                "cyclomatic-population:modules:count=1,sum=31,max=31",
1685                "cyclomatic-population:templates:count=0,sum=0,max=null",
1686            ]
1687        );
1688    }
1689
1690    #[test]
1691    fn health_compact_measured_empty_populations_keep_null_maxima() {
1692        let report = fallow_output::HealthReport {
1693            vital_signs: Some(fallow_output::VitalSigns {
1694                cyclomatic_population: Some(fallow_output::CyclomaticPopulation::default()),
1695                ..Default::default()
1696            }),
1697            ..Default::default()
1698        };
1699        let lines = build_health_compact_lines(&report, &PathBuf::from("/project"));
1700        assert_eq!(
1701            &lines[1..],
1702            [
1703                "cyclomatic-population:functions:count=0,sum=0,max=null",
1704                "cyclomatic-population:modules:count=0,sum=0,max=null",
1705                "cyclomatic-population:templates:count=0,sum=0,max=null",
1706            ]
1707        );
1708    }
1709
1710    #[test]
1711    fn health_compact_lines_include_styling_findings() {
1712        let root = PathBuf::from("/project");
1713        let report = fallow_output::HealthReport {
1714            styling_findings: vec![fallow_output::StylingFinding {
1715                code: "css-token-drift".to_string(),
1716                sub_kind: "tailwind-arbitrary-value".to_string(),
1717                path: "/project/src/app.css".to_string(),
1718                line: 6,
1719                value: "--color-brand: rgb(240, 90, 41)".to_string(),
1720                effective_severity: fallow_output::StylingFindingSeverity::Warn,
1721                blast_radius: None,
1722                confidence: None,
1723                agent_disposition: None,
1724                nearest_token: None,
1725                fix_hint: None,
1726                actions: Vec::new(),
1727            }],
1728            ..Default::default()
1729        };
1730
1731        let lines = build_health_compact_lines(&report, &root);
1732
1733        assert_eq!(
1734            lines,
1735            vec![
1736                "css-token-drift:src/app.css:6:tailwind-arbitrary-value:severity=warn,value=--color-brand rgb(240 90 41)"
1737            ]
1738        );
1739    }
1740}