Skip to main content

fallow_cli/report/
json.rs

1use std::collections::BTreeMap;
2use std::path::Path;
3use std::process::ExitCode;
4use std::time::Duration;
5
6use fallow_core::duplicates::DuplicationReport;
7use fallow_core::results::AnalysisResults;
8use fallow_types::envelope::{CheckSummary, ElapsedMs, EntryPoints, SchemaVersion, ToolVersion};
9
10use super::{emit_json, normalize_uri};
11use crate::explain;
12use crate::output_dupes::DupesReportPayload;
13use crate::output_envelope::{
14    CheckGroupedEntry, CheckGroupedOutput, CheckOutput, DupesOutput, GroupByMode, HealthOutput,
15};
16use crate::report::grouping::{OwnershipResolver, ResultGroup};
17
18/// Flip the position-0 `add-to-config` `auto_fixable` flag on every
19/// `duplicate_exports` finding when the fix-applier is ready to write into
20/// the user's config. Mirrors the per-instance flips for
21/// `unused_catalog_entries` (handled inside `with_actions`). No-op when
22/// `config_fixable` is `false`, since the typed wrapper already constructs
23/// every finding with the conservative `auto_fixable: false` default.
24fn apply_config_fixable_to_duplicate_exports(results: &mut AnalysisResults, config_fixable: bool) {
25    if !config_fixable {
26        return;
27    }
28    for finding in &mut results.duplicate_exports {
29        finding.set_config_fixable(true);
30    }
31}
32
33pub(super) fn print_json(
34    results: &AnalysisResults,
35    root: &Path,
36    elapsed: Duration,
37    explain: bool,
38    regression: Option<&crate::regression::RegressionOutcome>,
39    baseline_matched: Option<(usize, usize)>,
40    config_fixable: bool,
41) -> ExitCode {
42    match build_json_with_config_fixable(results, root, elapsed, config_fixable) {
43        Ok(mut output) => {
44            if let Some(outcome) = regression
45                && let serde_json::Value::Object(ref mut map) = output
46            {
47                map.insert("regression".to_string(), outcome.to_json());
48            }
49            if let Some((entries, matched)) = baseline_matched
50                && let serde_json::Value::Object(ref mut map) = output
51            {
52                map.insert(
53                    "baseline".to_string(),
54                    serde_json::json!({
55                        "entries": entries,
56                        "matched": matched,
57                    }),
58                );
59            }
60            if explain {
61                insert_meta(&mut output, explain::check_meta());
62            }
63            emit_json(&output, "JSON")
64        }
65        Err(e) => {
66            eprintln!("Error: failed to serialize results: {e}");
67            ExitCode::from(2)
68        }
69    }
70}
71
72/// Render grouped analysis results as a single JSON document.
73///
74/// Produces an envelope with `grouped_by` and `total_issues` at the top level,
75/// then a `groups` array where each element contains the group `key`,
76/// `total_issues`, and all the normal result fields with paths relativized.
77#[must_use]
78pub(super) fn print_grouped_json(
79    groups: &[ResultGroup],
80    original: &AnalysisResults,
81    root: &Path,
82    elapsed: Duration,
83    explain: bool,
84    resolver: &OwnershipResolver,
85    config_fixable: bool,
86) -> ExitCode {
87    let entries: Vec<CheckGroupedEntry> = groups
88        .iter()
89        .map(|group| {
90            let mut results = group.results.clone();
91            apply_config_fixable_to_duplicate_exports(&mut results, config_fixable);
92            CheckGroupedEntry {
93                key: group.key.clone(),
94                owners: group.owners.clone(),
95                total_issues: results.total_issues(),
96                results,
97            }
98        })
99        .collect();
100
101    let envelope = CheckGroupedOutput {
102        schema_version: SchemaVersion(SCHEMA_VERSION),
103        version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
104        elapsed_ms: ElapsedMs(elapsed.as_millis() as u64),
105        grouped_by: group_by_mode_from_label(resolver.mode_label()),
106        total_issues: original.total_issues(),
107        groups: entries,
108        meta: None,
109    };
110
111    let mut output = match serde_json::to_value(&envelope) {
112        Ok(value) => value,
113        Err(e) => {
114            eprintln!("Error: failed to serialize grouped results: {e}");
115            return ExitCode::from(2);
116        }
117    };
118
119    let root_prefix = format!("{}/", root.display());
120    // Strip per group separately. `duplicate_exports` per-instance
121    // `config_fixable` was layered into each group's `AnalysisResults`
122    // above, so the typed `actions` array on every finding already carries
123    // the correct `auto_fixable` bool. Only the suppression harmonizer
124    // still walks the per-group JSON tree to dedupe `// fallow-ignore-next-line`
125    // anchors across same-path+line findings.
126    if let Some(arr) = output.get_mut("groups").and_then(|v| v.as_array_mut()) {
127        for entry in arr {
128            strip_root_prefix(entry, &root_prefix);
129            harmonize_multi_kind_suppress_line_actions(entry);
130        }
131    }
132
133    if explain {
134        insert_meta(&mut output, explain::check_meta());
135    }
136
137    emit_json(&output, "JSON")
138}
139
140/// JSON output schema version as an integer (independent of tool version).
141///
142/// Bump this when the structure of the JSON output changes in a
143/// backwards-incompatible way (removing/renaming fields, changing types).
144/// Adding new fields is always backwards-compatible and does not require a bump.
145#[allow(
146    clippy::redundant_pub_crate,
147    reason = "used through report module re-export by combined.rs, audit.rs, flags.rs"
148)]
149pub(crate) const SCHEMA_VERSION: u32 = 6;
150
151/// Build the JSON output value for analysis results.
152///
153/// Metadata fields (`schema_version`, `version`, `elapsed_ms`, `total_issues`)
154/// appear first in the output for readability. Paths are made relative to `root`.
155///
156/// # Errors
157///
158/// Returns an error if the results cannot be serialized to JSON.
159#[allow(
160    dead_code,
161    reason = "used by the fallow-cli library target for embedders, but dead in the binary target"
162)]
163pub fn build_json(
164    results: &AnalysisResults,
165    root: &Path,
166    elapsed: Duration,
167) -> Result<serde_json::Value, serde_json::Error> {
168    build_json_with_config_fixable(
169        results,
170        root,
171        elapsed,
172        crate::fix::is_config_fixable(root, None),
173    )
174}
175
176/// Build the JSON output value with an explicit config-action fixability signal.
177///
178/// Use this when the caller already knows how the config was loaded, including
179/// explicit `--config` paths that default discovery cannot see.
180pub fn build_json_with_config_fixable(
181    results: &AnalysisResults,
182    root: &Path,
183    elapsed: Duration,
184    config_fixable: bool,
185) -> Result<serde_json::Value, serde_json::Error> {
186    let mut owned_results = results.clone();
187    apply_config_fixable_to_duplicate_exports(&mut owned_results, config_fixable);
188    let envelope = CheckOutput {
189        schema_version: SchemaVersion(SCHEMA_VERSION),
190        version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
191        elapsed_ms: ElapsedMs(elapsed.as_millis() as u64),
192        total_issues: owned_results.total_issues(),
193        entry_points: owned_results
194            .entry_point_summary
195            .as_ref()
196            .map(|ep| EntryPoints {
197                total: ep.total,
198                // Replace spaces with underscores so downstream dashboards can
199                // drill into individual sources by stable keys (e.g.
200                // `package.json`, `next.js`, `config_entry`).
201                sources: ep
202                    .by_source
203                    .iter()
204                    .map(|(k, v)| (k.replace(' ', "_"), *v))
205                    .collect(),
206            }),
207        summary: build_check_summary(&owned_results),
208        results: owned_results,
209        baseline_deltas: None,
210        baseline: None,
211        regression: None,
212        meta: None,
213        workspace_diagnostics: crate::runtime_support::workspace_diagnostics_for(root),
214    };
215
216    let mut output = serde_json::to_value(&envelope)?;
217    let root_prefix = format!("{}/", root.display());
218    // strip_root_prefix runs on the already-serialized envelope where the
219    // typed `actions` arrays are already in place; the stripper skips
220    // non-path string values via the prefix check.
221    strip_root_prefix(&mut output, &root_prefix);
222    harmonize_multi_kind_suppress_line_actions(&mut output);
223    Ok(output)
224}
225
226/// Compute the per-category `CheckSummary` from analysis results.
227///
228/// The `unused_dependencies` field is the COMBINED count across regular,
229/// dev, and optional dependencies (the JSON layer has folded these three
230/// since the schema was first published; the per-section arrays still
231/// live at the top level of `CheckOutput`).
232fn build_check_summary(results: &AnalysisResults) -> CheckSummary {
233    CheckSummary {
234        total_issues: results.total_issues(),
235        unused_files: results.unused_files.len(),
236        unused_exports: results.unused_exports.len(),
237        unused_types: results.unused_types.len(),
238        private_type_leaks: results.private_type_leaks.len(),
239        unused_dependencies: results.unused_dependencies.len()
240            + results.unused_dev_dependencies.len()
241            + results.unused_optional_dependencies.len(),
242        unused_enum_members: results.unused_enum_members.len(),
243        unused_class_members: results.unused_class_members.len(),
244        unresolved_imports: results.unresolved_imports.len(),
245        unlisted_dependencies: results.unlisted_dependencies.len(),
246        duplicate_exports: results.duplicate_exports.len(),
247        type_only_dependencies: results.type_only_dependencies.len(),
248        test_only_dependencies: results.test_only_dependencies.len(),
249        circular_dependencies: results.circular_dependencies.len(),
250        re_export_cycles: results.re_export_cycles.len(),
251        boundary_violations: results.boundary_violations.len(),
252        stale_suppressions: results.stale_suppressions.len(),
253        unused_catalog_entries: results.unused_catalog_entries.len(),
254        empty_catalog_groups: results.empty_catalog_groups.len(),
255        unresolved_catalog_references: results.unresolved_catalog_references.len(),
256        unused_dependency_overrides: results.unused_dependency_overrides.len(),
257        misconfigured_dependency_overrides: results.misconfigured_dependency_overrides.len(),
258    }
259}
260
261/// Recursively strip the root prefix from all string values in the JSON tree.
262///
263/// This converts absolute paths (e.g., `/home/runner/work/repo/repo/src/utils.ts`)
264/// to relative paths (`src/utils.ts`) for all output fields.
265pub fn strip_root_prefix(value: &mut serde_json::Value, prefix: &str) {
266    match value {
267        serde_json::Value::String(s) => {
268            if let Some(rest) = s.strip_prefix(prefix) {
269                *s = rest.to_string();
270            } else {
271                let normalized = normalize_uri(s);
272                let normalized_prefix = normalize_uri(prefix);
273                if let Some(rest) = normalized.strip_prefix(&normalized_prefix) {
274                    *s = rest.to_string();
275                }
276            }
277        }
278        serde_json::Value::Array(arr) => {
279            for item in arr {
280                strip_root_prefix(item, prefix);
281            }
282        }
283        serde_json::Value::Object(map) => {
284            for (_, v) in map.iter_mut() {
285                strip_root_prefix(v, prefix);
286            }
287        }
288        _ => {}
289    }
290}
291
292type SuppressAnchor = (String, u64);
293
294#[allow(
295    clippy::redundant_pub_crate,
296    reason = "used through report module re-export by audit.rs"
297)]
298pub(crate) fn harmonize_multi_kind_suppress_line_actions(output: &mut serde_json::Value) {
299    let mut anchors: BTreeMap<SuppressAnchor, Vec<String>> = BTreeMap::new();
300    collect_suppress_line_anchors(output, &mut anchors);
301
302    anchors.retain(|_, kinds| {
303        sort_suppression_kinds(kinds);
304        kinds.dedup();
305        kinds.len() > 1
306    });
307    if anchors.is_empty() {
308        return;
309    }
310
311    rewrite_suppress_line_actions(output, &anchors);
312}
313
314fn collect_suppress_line_anchors(
315    value: &serde_json::Value,
316    anchors: &mut BTreeMap<SuppressAnchor, Vec<String>>,
317) {
318    match value {
319        serde_json::Value::Object(map) => {
320            if let Some(anchor) = suppression_anchor(map)
321                && let Some(actions) = map.get("actions").and_then(serde_json::Value::as_array)
322            {
323                for action in actions {
324                    if let Some(comment) = suppress_line_comment(action) {
325                        for kind in parse_suppress_line_comment(comment) {
326                            let kinds = anchors.entry(anchor.clone()).or_default();
327                            if !kinds.iter().any(|existing| existing == &kind) {
328                                kinds.push(kind);
329                            }
330                        }
331                    }
332                }
333            }
334
335            for child in map.values() {
336                collect_suppress_line_anchors(child, anchors);
337            }
338        }
339        serde_json::Value::Array(items) => {
340            for item in items {
341                collect_suppress_line_anchors(item, anchors);
342            }
343        }
344        _ => {}
345    }
346}
347
348fn rewrite_suppress_line_actions(
349    value: &mut serde_json::Value,
350    anchors: &BTreeMap<SuppressAnchor, Vec<String>>,
351) {
352    match value {
353        serde_json::Value::Object(map) => {
354            if let Some(anchor) = suppression_anchor(map)
355                && let Some(kinds) = anchors.get(&anchor)
356            {
357                let comment = format!("// fallow-ignore-next-line {}", kinds.join(", "));
358                if let Some(actions) = map
359                    .get_mut("actions")
360                    .and_then(serde_json::Value::as_array_mut)
361                {
362                    for action in actions {
363                        if suppress_line_comment(action).is_some()
364                            && let serde_json::Value::Object(action_map) = action
365                        {
366                            action_map.insert("comment".to_string(), serde_json::json!(comment));
367                        }
368                    }
369                }
370            }
371
372            for child in map.values_mut() {
373                rewrite_suppress_line_actions(child, anchors);
374            }
375        }
376        serde_json::Value::Array(items) => {
377            for item in items {
378                rewrite_suppress_line_actions(item, anchors);
379            }
380        }
381        _ => {}
382    }
383}
384
385fn suppression_anchor(map: &serde_json::Map<String, serde_json::Value>) -> Option<SuppressAnchor> {
386    let path = map
387        .get("path")
388        .or_else(|| map.get("from_path"))
389        .and_then(serde_json::Value::as_str)?;
390    let line = map.get("line").and_then(serde_json::Value::as_u64)?;
391    Some((path.to_string(), line))
392}
393
394fn suppress_line_comment(action: &serde_json::Value) -> Option<&str> {
395    (action.get("type").and_then(serde_json::Value::as_str) == Some("suppress-line"))
396        .then_some(())
397        .and_then(|()| action.get("comment").and_then(serde_json::Value::as_str))
398}
399
400fn parse_suppress_line_comment(comment: &str) -> Vec<String> {
401    comment
402        .strip_prefix("// fallow-ignore-next-line ")
403        .map(|rest| {
404            rest.split(|c: char| c == ',' || c.is_whitespace())
405                .filter(|token| !token.is_empty())
406                .map(str::to_string)
407                .collect()
408        })
409        .unwrap_or_default()
410}
411
412fn sort_suppression_kinds(kinds: &mut [String]) {
413    kinds.sort_by_key(|kind| suppression_kind_rank(kind));
414}
415
416fn suppression_kind_rank(kind: &str) -> usize {
417    match kind {
418        "unused-file" => 0,
419        "unused-export" => 1,
420        "unused-type" => 2,
421        "private-type-leak" => 3,
422        "unused-enum-member" => 4,
423        "unused-class-member" => 5,
424        "unresolved-import" => 6,
425        "unlisted-dependency" => 7,
426        "duplicate-export" => 8,
427        "circular-dependency" => 9,
428        "re-export-cycle" => 10,
429        "boundary-violation" => 11,
430        "code-duplication" => 12,
431        "complexity" => 13,
432        _ => usize::MAX,
433    }
434}
435
436// ── Health action injection ─────────────────────────────────────
437
438/// Build a JSON representation of baseline deltas for the combined JSON envelope.
439///
440/// Accepts a total delta and an iterator of per-category entries to avoid
441/// coupling the report module (compiled in both lib and bin) to the
442/// binary-only `baseline` module.
443pub fn build_baseline_deltas_json<'a>(
444    total_delta: i64,
445    per_category: impl Iterator<Item = (&'a str, usize, usize, i64)>,
446) -> serde_json::Value {
447    let mut per_cat = serde_json::Map::new();
448    for (cat, current, baseline, delta) in per_category {
449        per_cat.insert(
450            cat.to_string(),
451            serde_json::json!({
452                "current": current,
453                "baseline": baseline,
454                "delta": delta,
455            }),
456        );
457    }
458    serde_json::json!({
459        "total_delta": total_delta,
460        "per_category": per_cat
461    })
462}
463
464// Health JSON post-pass action injection has been fully retired.
465//
466// - Complexity findings carry their `actions` natively via the typed
467//   [`HealthFinding`](crate::health_types::HealthFinding) wrapper (PR B2
468//   of issue #384).
469// - Hotspots and refactoring targets carry their typed `actions` arrays
470//   via the [`HotspotFinding`](crate::health_types::HotspotFinding) and
471//   [`RefactoringTargetFinding`](crate::health_types::RefactoringTargetFinding)
472//   wrappers (PR B3 of issue #384).
473// - Coverage gaps flow through
474//   [`UntestedFileFinding`](crate::health_types::UntestedFileFinding) /
475//   [`UntestedExportFinding`](crate::health_types::UntestedExportFinding).
476// - Runtime coverage actions ship from the sidecar via serde directly.
477//
478// The `actions_meta` breadcrumb is set on the typed
479// [`HealthReport`](crate::health_types::HealthReport) at construction
480// time and flows through serde natively, so no post-pass walker is
481// needed for any health output anymore.
482
483// The dupes `actions` arrays are set on the typed `CloneGroupFinding`,
484// `CloneFamilyFinding`, and `AttributedCloneGroupFinding` wrappers in
485// `crate::output_dupes` at construction time and flow through serde
486// natively, so no post-pass walker is needed for any duplication output
487// anymore.
488
489/// Insert a `_meta` key into a JSON object value.
490fn insert_meta(output: &mut serde_json::Value, meta: serde_json::Value) {
491    if let serde_json::Value::Object(map) = output {
492        map.insert("_meta".to_string(), meta);
493    }
494}
495
496/// Build the JSON envelope + health payload shared by `print_health_json` and
497/// the CLI integration test suite. Exposed so snapshot tests can lock the
498/// on-the-wire shape without routing through stdout capture.
499///
500/// # Errors
501///
502/// Returns an error if the report cannot be serialized to JSON.
503pub fn build_health_json(
504    report: &crate::health_types::HealthReport,
505    root: &Path,
506    elapsed: Duration,
507    explain: bool,
508) -> Result<serde_json::Value, serde_json::Error> {
509    let envelope = HealthOutput {
510        schema_version: SchemaVersion(SCHEMA_VERSION),
511        version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
512        elapsed_ms: ElapsedMs(elapsed.as_millis() as u64),
513        report: report.clone(),
514        grouped_by: None,
515        groups: None,
516        meta: None,
517        workspace_diagnostics: crate::runtime_support::workspace_diagnostics_for(root),
518    };
519    let mut output = serde_json::to_value(&envelope)?;
520    let root_prefix = format!("{}/", root.display());
521    strip_root_prefix(&mut output, &root_prefix);
522    if explain {
523        insert_meta(&mut output, explain::health_meta());
524    }
525    Ok(output)
526}
527
528pub(super) fn print_health_json(
529    report: &crate::health_types::HealthReport,
530    root: &Path,
531    elapsed: Duration,
532    explain: bool,
533) -> ExitCode {
534    match build_health_json(report, root, elapsed, explain) {
535        Ok(output) => emit_json(&output, "JSON"),
536        Err(e) => {
537            eprintln!("Error: failed to serialize health report: {e}");
538            ExitCode::from(2)
539        }
540    }
541}
542
543/// Build a grouped health JSON envelope when `--group-by` is active.
544///
545/// The envelope keeps the active run's `summary`, `vital_signs`, and
546/// `health_score` at the top level (so consumers that ignore grouping still
547/// see meaningful aggregates) and adds:
548///
549/// - `grouped_by`: the resolver mode (`"package"`, `"owner"`, etc.).
550/// - `groups`: one entry per resolver bucket. Each entry carries its own
551///   `vital_signs`, `health_score`, `findings`, `file_scores`, `hotspots`,
552///   `large_functions`, `targets`, plus `key`, `owners` (section mode), and
553///   the per-group `files_analyzed` / `functions_above_threshold` counts.
554///
555/// Paths inside groups are relativised the same way as the project-level
556/// payload.
557///
558/// # Errors
559///
560/// Returns an error if either the project report or any group cannot be
561/// serialised to JSON.
562pub fn build_grouped_health_json(
563    report: &crate::health_types::HealthReport,
564    grouping: &crate::health_types::HealthGrouping,
565    root: &Path,
566    elapsed: Duration,
567    explain: bool,
568) -> Result<serde_json::Value, serde_json::Error> {
569    let root_prefix = format!("{}/", root.display());
570    // Per-group sub-envelopes share the project-level suppression state:
571    // baseline-active and config-disabled apply uniformly, so each group's
572    // `actions` array honors the same opts AND each group emits its own
573    // `actions_meta` breadcrumb. The redundancy with the top-level breadcrumb
574    // is intentional: consumers that only walk the `groups` array (e.g.,
575    // per-team dashboards) still see the omission reason without needing to
576    // walk back up to the report root.
577    let envelope = HealthOutput {
578        schema_version: SchemaVersion(SCHEMA_VERSION),
579        version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
580        elapsed_ms: ElapsedMs(elapsed.as_millis() as u64),
581        report: report.clone(),
582        grouped_by: Some(group_by_mode_from_label(grouping.mode)),
583        // `groups` is serialised separately below so each entry can run
584        // through the path-stripping + actions injection passes (which only
585        // walk the top-level map).
586        groups: None,
587        meta: None,
588        workspace_diagnostics: crate::runtime_support::workspace_diagnostics_for(root),
589    };
590    let mut output = serde_json::to_value(&envelope)?;
591    strip_root_prefix(&mut output, &root_prefix);
592
593    let group_values: Vec<serde_json::Value> = grouping
594        .groups
595        .iter()
596        .map(|g| {
597            let mut value = serde_json::to_value(g)?;
598            strip_root_prefix(&mut value, &root_prefix);
599            Ok(value)
600        })
601        .collect::<Result<_, serde_json::Error>>()?;
602
603    if let serde_json::Value::Object(ref mut map) = output {
604        map.insert("groups".to_string(), serde_json::Value::Array(group_values));
605    }
606
607    if explain {
608        insert_meta(&mut output, explain::health_meta());
609    }
610
611    Ok(output)
612}
613
614pub(super) fn print_grouped_health_json(
615    report: &crate::health_types::HealthReport,
616    grouping: &crate::health_types::HealthGrouping,
617    root: &Path,
618    elapsed: Duration,
619    explain: bool,
620) -> ExitCode {
621    match build_grouped_health_json(report, grouping, root, elapsed, explain) {
622        Ok(output) => emit_json(&output, "JSON"),
623        Err(e) => {
624            eprintln!("Error: failed to serialize grouped health report: {e}");
625            ExitCode::from(2)
626        }
627    }
628}
629
630/// Build the JSON envelope + duplication payload shared by `print_duplication_json`
631/// and the programmatic API surface.
632///
633/// # Errors
634///
635/// Returns an error if the report cannot be serialized to JSON.
636pub fn build_duplication_json(
637    report: &DuplicationReport,
638    root: &Path,
639    elapsed: Duration,
640    explain: bool,
641) -> Result<serde_json::Value, serde_json::Error> {
642    let envelope = DupesOutput {
643        schema_version: SchemaVersion(SCHEMA_VERSION),
644        version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
645        elapsed_ms: ElapsedMs(elapsed.as_millis() as u64),
646        report: DupesReportPayload::from_report(report),
647        grouped_by: None,
648        total_issues: None,
649        groups: None,
650        meta: None,
651        workspace_diagnostics: crate::runtime_support::workspace_diagnostics_for(root),
652    };
653    let mut output = serde_json::to_value(&envelope)?;
654    let root_prefix = format!("{}/", root.display());
655    strip_root_prefix(&mut output, &root_prefix);
656
657    if explain {
658        insert_meta(&mut output, explain::dupes_meta());
659    }
660
661    Ok(output)
662}
663
664pub(super) fn print_duplication_json(
665    report: &DuplicationReport,
666    root: &Path,
667    elapsed: Duration,
668    explain: bool,
669) -> ExitCode {
670    match build_duplication_json(report, root, elapsed, explain) {
671        Ok(output) => emit_json(&output, "JSON"),
672        Err(e) => {
673            eprintln!("Error: failed to serialize duplication report: {e}");
674            ExitCode::from(2)
675        }
676    }
677}
678
679/// Build a grouped duplication JSON envelope when `--group-by` is active.
680///
681/// The envelope keeps the project-level duplication payload (`stats`,
682/// `clone_groups`, `clone_families`) at the top level so consumers that ignore
683/// grouping still see project-wide aggregates, and adds:
684///
685/// - `grouped_by`: the resolver mode (`"owner"`, `"directory"`, `"package"`,
686///   `"section"`).
687/// - `groups`: one entry per resolver bucket. Each entry carries its own
688///   per-group `stats` (dedup-aware, computed over the FULL group before
689///   `--top` truncation), `clone_groups` (each tagged with `primary_owner`
690///   and per-instance `owner`), and `clone_families`.
691///
692/// Paths inside groups are relativised the same way as the project-level
693/// payload via `strip_root_prefix`.
694///
695/// # Errors
696///
697/// Returns an error if either the project report or any group cannot be
698/// serialised to JSON.
699pub fn build_grouped_duplication_json(
700    report: &DuplicationReport,
701    grouping: &super::dupes_grouping::DuplicationGrouping,
702    root: &Path,
703    elapsed: Duration,
704    explain: bool,
705) -> Result<serde_json::Value, serde_json::Error> {
706    let root_prefix = format!("{}/", root.display());
707    // Mirror the grouped check / health envelopes which expose `total_issues`
708    // so MCP and CI consumers can read the same key across commands. For
709    // dupes the count is total clone groups (sum is preserved across
710    // grouping; each clone group is attributed to exactly one bucket).
711    let envelope = DupesOutput {
712        schema_version: SchemaVersion(SCHEMA_VERSION),
713        version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
714        elapsed_ms: ElapsedMs(elapsed.as_millis() as u64),
715        report: DupesReportPayload::from_report(report),
716        grouped_by: Some(group_by_mode_from_label(grouping.mode)),
717        total_issues: Some(report.clone_groups.len()),
718        // Per-group buckets are serialized separately below so each carries
719        // its own path-stripping; splice them in via the `Value` post-pass.
720        // Wrapper-level `actions[]` is already typed on each bucket's
721        // `clone_groups` / `clone_families`.
722        groups: None,
723        meta: None,
724        workspace_diagnostics: crate::runtime_support::workspace_diagnostics_for(root),
725    };
726    let mut output = serde_json::to_value(&envelope)?;
727    strip_root_prefix(&mut output, &root_prefix);
728
729    let group_values: Vec<serde_json::Value> = grouping
730        .groups
731        .iter()
732        .map(|g| {
733            let mut value = serde_json::to_value(g)?;
734            strip_root_prefix(&mut value, &root_prefix);
735            Ok(value)
736        })
737        .collect::<Result<_, serde_json::Error>>()?;
738
739    if let serde_json::Value::Object(ref mut map) = output {
740        map.insert("groups".to_string(), serde_json::Value::Array(group_values));
741    }
742
743    if explain {
744        insert_meta(&mut output, explain::dupes_meta());
745    }
746
747    Ok(output)
748}
749
750/// Map a free-form grouping mode label (`"package"`, `"owner"`, ...) to the
751/// typed [`GroupByMode`] enum. Unknown labels fall through to
752/// [`GroupByMode::Owner`] because the upstream grouping pipeline only emits
753/// the four documented modes; the fallback exists to keep this helper
754/// infallible without piping a Result through every grouped envelope builder.
755fn group_by_mode_from_label(label: &str) -> GroupByMode {
756    match label {
757        "directory" => GroupByMode::Directory,
758        "package" => GroupByMode::Package,
759        "section" => GroupByMode::Section,
760        _ => GroupByMode::Owner,
761    }
762}
763
764pub(super) fn print_grouped_duplication_json(
765    report: &DuplicationReport,
766    grouping: &super::dupes_grouping::DuplicationGrouping,
767    root: &Path,
768    elapsed: Duration,
769    explain: bool,
770) -> ExitCode {
771    match build_grouped_duplication_json(report, grouping, root, elapsed, explain) {
772        Ok(output) => emit_json(&output, "JSON"),
773        Err(e) => {
774            eprintln!("Error: failed to serialize grouped duplication report: {e}");
775            ExitCode::from(2)
776        }
777    }
778}
779
780pub(super) fn print_trace_json<T: serde::Serialize>(value: &T) {
781    match serde_json::to_string_pretty(value) {
782        Ok(json) => println!("{json}"),
783        Err(e) => {
784            eprintln!("Error: failed to serialize trace output: {e}");
785            #[expect(
786                clippy::exit,
787                reason = "fatal serialization error requires immediate exit"
788            )]
789            std::process::exit(2);
790        }
791    }
792}
793
794#[cfg(test)]
795mod tests {
796    use super::*;
797    use crate::health_types::{
798        RuntimeCoverageAction, RuntimeCoverageConfidence, RuntimeCoverageDataSource,
799        RuntimeCoverageEvidence, RuntimeCoverageFinding, RuntimeCoverageHotPath,
800        RuntimeCoverageMessage, RuntimeCoverageReport, RuntimeCoverageReportVerdict,
801        RuntimeCoverageSchemaVersion, RuntimeCoverageSummary, RuntimeCoverageVerdict,
802        RuntimeCoverageWatermark,
803    };
804    use crate::report::test_helpers::sample_results;
805    use fallow_core::extract::MemberKind;
806    use fallow_core::results::*;
807    use std::path::PathBuf;
808    use std::time::Duration;
809
810    #[test]
811    fn json_output_has_metadata_fields() {
812        let root = PathBuf::from("/project");
813        let results = AnalysisResults::default();
814        let elapsed = Duration::from_millis(123);
815        let output = build_json(&results, &root, elapsed).expect("should serialize");
816
817        assert_eq!(output["schema_version"], 6);
818        assert!(output["version"].is_string());
819        assert_eq!(output["elapsed_ms"], 123);
820        assert_eq!(output["total_issues"], 0);
821    }
822
823    #[test]
824    fn json_output_includes_issue_arrays() {
825        let root = PathBuf::from("/project");
826        let results = sample_results(&root);
827        let elapsed = Duration::from_millis(50);
828        let output = build_json(&results, &root, elapsed).expect("should serialize");
829
830        assert_eq!(output["unused_files"].as_array().unwrap().len(), 1);
831        assert_eq!(output["unused_exports"].as_array().unwrap().len(), 1);
832        assert_eq!(output["unused_types"].as_array().unwrap().len(), 1);
833        assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 1);
834        assert_eq!(
835            output["unused_dev_dependencies"].as_array().unwrap().len(),
836            1
837        );
838        assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 1);
839        assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 1);
840        assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 1);
841        assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 1);
842        assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 1);
843        assert_eq!(
844            output["type_only_dependencies"].as_array().unwrap().len(),
845            1
846        );
847        assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 1);
848    }
849
850    #[test]
851    fn health_json_includes_runtime_coverage_with_relative_paths_and_actions() {
852        let root = PathBuf::from("/project");
853        let report = crate::health_types::HealthReport {
854            runtime_coverage: Some(RuntimeCoverageReport {
855                schema_version: RuntimeCoverageSchemaVersion::V1,
856                verdict: RuntimeCoverageReportVerdict::ColdCodeDetected,
857                signals: Vec::new(),
858                summary: RuntimeCoverageSummary {
859                    data_source: RuntimeCoverageDataSource::Local,
860                    last_received_at: None,
861                    functions_tracked: 3,
862                    functions_hit: 1,
863                    functions_unhit: 1,
864                    functions_untracked: 1,
865                    coverage_percent: 33.3,
866                    trace_count: 2_847_291,
867                    period_days: 30,
868                    deployments_seen: 14,
869                    capture_quality: Some(crate::health_types::RuntimeCoverageCaptureQuality {
870                        window_seconds: 720,
871                        instances_observed: 1,
872                        lazy_parse_warning: true,
873                        untracked_ratio_percent: 42.5,
874                    }),
875                },
876                findings: vec![RuntimeCoverageFinding {
877                    id: "fallow:prod:deadbeef".to_owned(),
878                    path: root.join("src/cold.ts"),
879                    function: "coldPath".to_owned(),
880                    line: 12,
881                    verdict: RuntimeCoverageVerdict::ReviewRequired,
882                    invocations: Some(0),
883                    confidence: RuntimeCoverageConfidence::Medium,
884                    evidence: RuntimeCoverageEvidence {
885                        static_status: "used".to_owned(),
886                        test_coverage: "not_covered".to_owned(),
887                        v8_tracking: "tracked".to_owned(),
888                        untracked_reason: None,
889                        observation_days: 30,
890                        deployments_observed: 14,
891                    },
892                    actions: vec![RuntimeCoverageAction {
893                        kind: "review-deletion".to_owned(),
894                        description: "Tracked in runtime coverage with zero invocations."
895                            .to_owned(),
896                        auto_fixable: false,
897                    }],
898                }],
899                hot_paths: vec![RuntimeCoverageHotPath {
900                    id: "fallow:hot:cafebabe".to_owned(),
901                    path: root.join("src/hot.ts"),
902                    function: "hotPath".to_owned(),
903                    line: 3,
904                    end_line: 9,
905                    invocations: 250,
906                    percentile: 99,
907                    actions: vec![],
908                }],
909                blast_radius: vec![],
910                importance: vec![],
911                watermark: Some(RuntimeCoverageWatermark::LicenseExpiredGrace),
912                warnings: vec![RuntimeCoverageMessage {
913                    code: "partial-merge".to_owned(),
914                    message: "Merged coverage omitted one chunk.".to_owned(),
915                }],
916            }),
917            ..Default::default()
918        };
919
920        let envelope = HealthOutput {
921            schema_version: SchemaVersion(SCHEMA_VERSION),
922            version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
923            elapsed_ms: ElapsedMs(7),
924            report,
925            grouped_by: None,
926            groups: None,
927            meta: None,
928            workspace_diagnostics: Vec::new(),
929        };
930        let mut output = serde_json::to_value(&envelope).expect("should serialize health envelope");
931        strip_root_prefix(&mut output, "/project/");
932
933        assert_eq!(
934            output["runtime_coverage"]["verdict"],
935            serde_json::Value::String("cold-code-detected".to_owned())
936        );
937        assert_eq!(
938            output["runtime_coverage"]["schema_version"],
939            serde_json::Value::String("1".to_owned())
940        );
941        assert_eq!(
942            output["runtime_coverage"]["summary"]["functions_tracked"],
943            serde_json::Value::from(3)
944        );
945        assert_eq!(
946            output["runtime_coverage"]["summary"]["coverage_percent"],
947            serde_json::Value::from(33.3)
948        );
949        let finding = &output["runtime_coverage"]["findings"][0];
950        assert_eq!(finding["path"], "src/cold.ts");
951        assert_eq!(finding["verdict"], "review_required");
952        assert_eq!(finding["id"], "fallow:prod:deadbeef");
953        assert_eq!(finding["actions"][0]["type"], "review-deletion");
954        let hot_path = &output["runtime_coverage"]["hot_paths"][0];
955        assert_eq!(hot_path["path"], "src/hot.ts");
956        assert_eq!(hot_path["function"], "hotPath");
957        assert_eq!(hot_path["percentile"], 99);
958        assert_eq!(
959            output["runtime_coverage"]["watermark"],
960            serde_json::Value::String("license-expired-grace".to_owned())
961        );
962        assert_eq!(
963            output["runtime_coverage"]["warnings"][0]["code"],
964            serde_json::Value::String("partial-merge".to_owned())
965        );
966    }
967
968    #[test]
969    fn json_metadata_fields_appear_first() {
970        let root = PathBuf::from("/project");
971        let results = AnalysisResults::default();
972        let elapsed = Duration::from_millis(0);
973        let output = build_json(&results, &root, elapsed).expect("should serialize");
974        let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
975        assert_eq!(keys[0], "schema_version");
976        assert_eq!(keys[1], "version");
977        assert_eq!(keys[2], "elapsed_ms");
978        assert_eq!(keys[3], "total_issues");
979    }
980
981    #[test]
982    fn json_total_issues_matches_results() {
983        let root = PathBuf::from("/project");
984        let results = sample_results(&root);
985        let total = results.total_issues();
986        let elapsed = Duration::from_millis(0);
987        let output = build_json(&results, &root, elapsed).expect("should serialize");
988
989        assert_eq!(output["total_issues"], total);
990    }
991
992    #[test]
993    fn json_unused_export_contains_expected_fields() {
994        let root = PathBuf::from("/project");
995        let mut results = AnalysisResults::default();
996        results
997            .unused_exports
998            .push(UnusedExportFinding::with_actions(UnusedExport {
999                path: root.join("src/utils.ts"),
1000                export_name: "helperFn".to_string(),
1001                is_type_only: false,
1002                line: 10,
1003                col: 4,
1004                span_start: 120,
1005                is_re_export: false,
1006            }));
1007        let elapsed = Duration::from_millis(0);
1008        let output = build_json(&results, &root, elapsed).expect("should serialize");
1009
1010        let export = &output["unused_exports"][0];
1011        assert_eq!(export["export_name"], "helperFn");
1012        assert_eq!(export["line"], 10);
1013        assert_eq!(export["col"], 4);
1014        assert_eq!(export["is_type_only"], false);
1015        assert_eq!(export["span_start"], 120);
1016        assert_eq!(export["is_re_export"], false);
1017    }
1018
1019    #[test]
1020    fn json_serializes_to_valid_json() {
1021        let root = PathBuf::from("/project");
1022        let results = sample_results(&root);
1023        let elapsed = Duration::from_millis(42);
1024        let output = build_json(&results, &root, elapsed).expect("should serialize");
1025
1026        let json_str = serde_json::to_string_pretty(&output).expect("should stringify");
1027        let reparsed: serde_json::Value =
1028            serde_json::from_str(&json_str).expect("JSON output should be valid JSON");
1029        assert_eq!(reparsed, output);
1030    }
1031
1032    // ── Empty results ───────────────────────────────────────────────
1033
1034    #[test]
1035    fn json_empty_results_produce_valid_structure() {
1036        let root = PathBuf::from("/project");
1037        let results = AnalysisResults::default();
1038        let elapsed = Duration::from_millis(0);
1039        let output = build_json(&results, &root, elapsed).expect("should serialize");
1040
1041        assert_eq!(output["total_issues"], 0);
1042        assert_eq!(output["unused_files"].as_array().unwrap().len(), 0);
1043        assert_eq!(output["unused_exports"].as_array().unwrap().len(), 0);
1044        assert_eq!(output["unused_types"].as_array().unwrap().len(), 0);
1045        assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 0);
1046        assert_eq!(
1047            output["unused_dev_dependencies"].as_array().unwrap().len(),
1048            0
1049        );
1050        assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 0);
1051        assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 0);
1052        assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 0);
1053        assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 0);
1054        assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 0);
1055        assert_eq!(
1056            output["type_only_dependencies"].as_array().unwrap().len(),
1057            0
1058        );
1059        assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 0);
1060    }
1061
1062    #[test]
1063    fn json_empty_results_round_trips_through_string() {
1064        let root = PathBuf::from("/project");
1065        let results = AnalysisResults::default();
1066        let elapsed = Duration::from_millis(0);
1067        let output = build_json(&results, &root, elapsed).expect("should serialize");
1068
1069        let json_str = serde_json::to_string(&output).expect("should stringify");
1070        let reparsed: serde_json::Value =
1071            serde_json::from_str(&json_str).expect("should parse back");
1072        assert_eq!(reparsed["total_issues"], 0);
1073    }
1074
1075    // ── Path stripping ──────────────────────────────────────────────
1076
1077    #[test]
1078    fn json_paths_are_relative_to_root() {
1079        let root = PathBuf::from("/project");
1080        let mut results = AnalysisResults::default();
1081        results
1082            .unused_files
1083            .push(UnusedFileFinding::with_actions(UnusedFile {
1084                path: root.join("src/deep/nested/file.ts"),
1085            }));
1086        let elapsed = Duration::from_millis(0);
1087        let output = build_json(&results, &root, elapsed).expect("should serialize");
1088
1089        let path = output["unused_files"][0]["path"].as_str().unwrap();
1090        assert_eq!(path, "src/deep/nested/file.ts");
1091        assert!(!path.starts_with("/project"));
1092    }
1093
1094    #[test]
1095    fn json_strips_root_from_nested_locations() {
1096        let root = PathBuf::from("/project");
1097        let mut results = AnalysisResults::default();
1098        results
1099            .unlisted_dependencies
1100            .push(UnlistedDependencyFinding::with_actions(
1101                UnlistedDependency {
1102                    package_name: "chalk".to_string(),
1103                    imported_from: vec![ImportSite {
1104                        path: root.join("src/cli.ts"),
1105                        line: 2,
1106                        col: 0,
1107                    }],
1108                },
1109            ));
1110        let elapsed = Duration::from_millis(0);
1111        let output = build_json(&results, &root, elapsed).expect("should serialize");
1112
1113        let site_path = output["unlisted_dependencies"][0]["imported_from"][0]["path"]
1114            .as_str()
1115            .unwrap();
1116        assert_eq!(site_path, "src/cli.ts");
1117    }
1118
1119    #[test]
1120    fn json_strips_root_from_duplicate_export_locations() {
1121        let root = PathBuf::from("/project");
1122        let mut results = AnalysisResults::default();
1123        results
1124            .duplicate_exports
1125            .push(DuplicateExportFinding::with_actions(DuplicateExport {
1126                export_name: "Config".to_string(),
1127                locations: vec![
1128                    DuplicateLocation {
1129                        path: root.join("src/config.ts"),
1130                        line: 15,
1131                        col: 0,
1132                    },
1133                    DuplicateLocation {
1134                        path: root.join("src/types.ts"),
1135                        line: 30,
1136                        col: 0,
1137                    },
1138                ],
1139            }));
1140        let elapsed = Duration::from_millis(0);
1141        let output = build_json(&results, &root, elapsed).expect("should serialize");
1142
1143        let loc0 = output["duplicate_exports"][0]["locations"][0]["path"]
1144            .as_str()
1145            .unwrap();
1146        let loc1 = output["duplicate_exports"][0]["locations"][1]["path"]
1147            .as_str()
1148            .unwrap();
1149        assert_eq!(loc0, "src/config.ts");
1150        assert_eq!(loc1, "src/types.ts");
1151    }
1152
1153    #[test]
1154    fn json_strips_root_from_circular_dependency_files() {
1155        let root = PathBuf::from("/project");
1156        let mut results = AnalysisResults::default();
1157        results
1158            .circular_dependencies
1159            .push(CircularDependencyFinding::with_actions(
1160                CircularDependency {
1161                    files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1162                    length: 2,
1163                    line: 1,
1164                    col: 0,
1165                    is_cross_package: false,
1166                },
1167            ));
1168        let elapsed = Duration::from_millis(0);
1169        let output = build_json(&results, &root, elapsed).expect("should serialize");
1170
1171        let files = output["circular_dependencies"][0]["files"]
1172            .as_array()
1173            .unwrap();
1174        assert_eq!(files[0].as_str().unwrap(), "src/a.ts");
1175        assert_eq!(files[1].as_str().unwrap(), "src/b.ts");
1176    }
1177
1178    #[test]
1179    fn json_path_outside_root_not_stripped() {
1180        let root = PathBuf::from("/project");
1181        let mut results = AnalysisResults::default();
1182        results
1183            .unused_files
1184            .push(UnusedFileFinding::with_actions(UnusedFile {
1185                path: PathBuf::from("/other/project/src/file.ts"),
1186            }));
1187        let elapsed = Duration::from_millis(0);
1188        let output = build_json(&results, &root, elapsed).expect("should serialize");
1189
1190        let path = output["unused_files"][0]["path"].as_str().unwrap();
1191        assert!(path.contains("/other/project/"));
1192    }
1193
1194    // ── Individual issue type field verification ────────────────────
1195
1196    #[test]
1197    fn json_unused_file_contains_path() {
1198        let root = PathBuf::from("/project");
1199        let mut results = AnalysisResults::default();
1200        results
1201            .unused_files
1202            .push(UnusedFileFinding::with_actions(UnusedFile {
1203                path: root.join("src/orphan.ts"),
1204            }));
1205        let elapsed = Duration::from_millis(0);
1206        let output = build_json(&results, &root, elapsed).expect("should serialize");
1207
1208        let file = &output["unused_files"][0];
1209        assert_eq!(file["path"], "src/orphan.ts");
1210    }
1211
1212    #[test]
1213    fn json_unused_type_contains_expected_fields() {
1214        let root = PathBuf::from("/project");
1215        let mut results = AnalysisResults::default();
1216        results
1217            .unused_types
1218            .push(UnusedTypeFinding::with_actions(UnusedExport {
1219                path: root.join("src/types.ts"),
1220                export_name: "OldInterface".to_string(),
1221                is_type_only: true,
1222                line: 20,
1223                col: 0,
1224                span_start: 300,
1225                is_re_export: false,
1226            }));
1227        let elapsed = Duration::from_millis(0);
1228        let output = build_json(&results, &root, elapsed).expect("should serialize");
1229
1230        let typ = &output["unused_types"][0];
1231        assert_eq!(typ["export_name"], "OldInterface");
1232        assert_eq!(typ["is_type_only"], true);
1233        assert_eq!(typ["line"], 20);
1234        assert_eq!(typ["path"], "src/types.ts");
1235    }
1236
1237    #[test]
1238    fn json_unused_dependency_contains_expected_fields() {
1239        let root = PathBuf::from("/project");
1240        let mut results = AnalysisResults::default();
1241        results
1242            .unused_dependencies
1243            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1244                package_name: "axios".to_string(),
1245                location: DependencyLocation::Dependencies,
1246                path: root.join("package.json"),
1247                line: 10,
1248                used_in_workspaces: Vec::new(),
1249            }));
1250        let elapsed = Duration::from_millis(0);
1251        let output = build_json(&results, &root, elapsed).expect("should serialize");
1252
1253        let dep = &output["unused_dependencies"][0];
1254        assert_eq!(dep["package_name"], "axios");
1255        assert_eq!(dep["line"], 10);
1256        assert!(dep.get("used_in_workspaces").is_none());
1257    }
1258
1259    #[test]
1260    fn json_unused_dependency_includes_cross_workspace_context() {
1261        let root = PathBuf::from("/project");
1262        let mut results = AnalysisResults::default();
1263        results
1264            .unused_dependencies
1265            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1266                package_name: "lodash-es".to_string(),
1267                location: DependencyLocation::Dependencies,
1268                path: root.join("packages/shared/package.json"),
1269                line: 6,
1270                used_in_workspaces: vec![root.join("packages/consumer")],
1271            }));
1272        let elapsed = Duration::from_millis(0);
1273        let output = build_json(&results, &root, elapsed).expect("should serialize");
1274
1275        let dep = &output["unused_dependencies"][0];
1276        assert_eq!(
1277            dep["used_in_workspaces"],
1278            serde_json::json!(["packages/consumer"])
1279        );
1280    }
1281
1282    #[test]
1283    fn json_unused_dev_dependency_contains_expected_fields() {
1284        let root = PathBuf::from("/project");
1285        let mut results = AnalysisResults::default();
1286        results
1287            .unused_dev_dependencies
1288            .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
1289                package_name: "vitest".to_string(),
1290                location: DependencyLocation::DevDependencies,
1291                path: root.join("package.json"),
1292                line: 15,
1293                used_in_workspaces: Vec::new(),
1294            }));
1295        let elapsed = Duration::from_millis(0);
1296        let output = build_json(&results, &root, elapsed).expect("should serialize");
1297
1298        let dep = &output["unused_dev_dependencies"][0];
1299        assert_eq!(dep["package_name"], "vitest");
1300    }
1301
1302    #[test]
1303    fn json_unused_optional_dependency_contains_expected_fields() {
1304        let root = PathBuf::from("/project");
1305        let mut results = AnalysisResults::default();
1306        results
1307            .unused_optional_dependencies
1308            .push(UnusedOptionalDependencyFinding::with_actions(
1309                UnusedDependency {
1310                    package_name: "fsevents".to_string(),
1311                    location: DependencyLocation::OptionalDependencies,
1312                    path: root.join("package.json"),
1313                    line: 12,
1314                    used_in_workspaces: Vec::new(),
1315                },
1316            ));
1317        let elapsed = Duration::from_millis(0);
1318        let output = build_json(&results, &root, elapsed).expect("should serialize");
1319
1320        let dep = &output["unused_optional_dependencies"][0];
1321        assert_eq!(dep["package_name"], "fsevents");
1322        assert_eq!(output["total_issues"], 1);
1323    }
1324
1325    #[test]
1326    fn json_unused_enum_member_contains_expected_fields() {
1327        let root = PathBuf::from("/project");
1328        let mut results = AnalysisResults::default();
1329        results
1330            .unused_enum_members
1331            .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
1332                path: root.join("src/enums.ts"),
1333                parent_name: "Color".to_string(),
1334                member_name: "Purple".to_string(),
1335                kind: MemberKind::EnumMember,
1336                line: 5,
1337                col: 2,
1338            }));
1339        let elapsed = Duration::from_millis(0);
1340        let output = build_json(&results, &root, elapsed).expect("should serialize");
1341
1342        let member = &output["unused_enum_members"][0];
1343        assert_eq!(member["parent_name"], "Color");
1344        assert_eq!(member["member_name"], "Purple");
1345        assert_eq!(member["line"], 5);
1346        assert_eq!(member["path"], "src/enums.ts");
1347    }
1348
1349    #[test]
1350    fn json_unused_class_member_contains_expected_fields() {
1351        let root = PathBuf::from("/project");
1352        let mut results = AnalysisResults::default();
1353        results
1354            .unused_class_members
1355            .push(UnusedClassMemberFinding::with_actions(UnusedMember {
1356                path: root.join("src/api.ts"),
1357                parent_name: "ApiClient".to_string(),
1358                member_name: "deprecatedFetch".to_string(),
1359                kind: MemberKind::ClassMethod,
1360                line: 100,
1361                col: 4,
1362            }));
1363        let elapsed = Duration::from_millis(0);
1364        let output = build_json(&results, &root, elapsed).expect("should serialize");
1365
1366        let member = &output["unused_class_members"][0];
1367        assert_eq!(member["parent_name"], "ApiClient");
1368        assert_eq!(member["member_name"], "deprecatedFetch");
1369        assert_eq!(member["line"], 100);
1370    }
1371
1372    #[test]
1373    fn json_unresolved_import_contains_expected_fields() {
1374        let root = PathBuf::from("/project");
1375        let mut results = AnalysisResults::default();
1376        results
1377            .unresolved_imports
1378            .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
1379                path: root.join("src/app.ts"),
1380                specifier: "@acme/missing-pkg".to_string(),
1381                line: 7,
1382                col: 0,
1383                specifier_col: 0,
1384            }));
1385        let elapsed = Duration::from_millis(0);
1386        let output = build_json(&results, &root, elapsed).expect("should serialize");
1387
1388        let import = &output["unresolved_imports"][0];
1389        assert_eq!(import["specifier"], "@acme/missing-pkg");
1390        assert_eq!(import["line"], 7);
1391        assert_eq!(import["path"], "src/app.ts");
1392    }
1393
1394    #[test]
1395    fn json_unlisted_dependency_contains_import_sites() {
1396        let root = PathBuf::from("/project");
1397        let mut results = AnalysisResults::default();
1398        results
1399            .unlisted_dependencies
1400            .push(UnlistedDependencyFinding::with_actions(
1401                UnlistedDependency {
1402                    package_name: "dotenv".to_string(),
1403                    imported_from: vec![
1404                        ImportSite {
1405                            path: root.join("src/config.ts"),
1406                            line: 1,
1407                            col: 0,
1408                        },
1409                        ImportSite {
1410                            path: root.join("src/server.ts"),
1411                            line: 3,
1412                            col: 0,
1413                        },
1414                    ],
1415                },
1416            ));
1417        let elapsed = Duration::from_millis(0);
1418        let output = build_json(&results, &root, elapsed).expect("should serialize");
1419
1420        let dep = &output["unlisted_dependencies"][0];
1421        assert_eq!(dep["package_name"], "dotenv");
1422        let sites = dep["imported_from"].as_array().unwrap();
1423        assert_eq!(sites.len(), 2);
1424        assert_eq!(sites[0]["path"], "src/config.ts");
1425        assert_eq!(sites[1]["path"], "src/server.ts");
1426    }
1427
1428    #[test]
1429    fn json_duplicate_export_contains_locations() {
1430        let root = PathBuf::from("/project");
1431        let mut results = AnalysisResults::default();
1432        results
1433            .duplicate_exports
1434            .push(DuplicateExportFinding::with_actions(DuplicateExport {
1435                export_name: "Button".to_string(),
1436                locations: vec![
1437                    DuplicateLocation {
1438                        path: root.join("src/ui.ts"),
1439                        line: 10,
1440                        col: 0,
1441                    },
1442                    DuplicateLocation {
1443                        path: root.join("src/components.ts"),
1444                        line: 25,
1445                        col: 0,
1446                    },
1447                ],
1448            }));
1449        let elapsed = Duration::from_millis(0);
1450        let output = build_json(&results, &root, elapsed).expect("should serialize");
1451
1452        let dup = &output["duplicate_exports"][0];
1453        assert_eq!(dup["export_name"], "Button");
1454        let locs = dup["locations"].as_array().unwrap();
1455        assert_eq!(locs.len(), 2);
1456        assert_eq!(locs[0]["line"], 10);
1457        assert_eq!(locs[1]["line"], 25);
1458    }
1459
1460    #[test]
1461    fn duplicate_export_add_to_config_is_auto_fixable_when_config_exists() {
1462        let dir = tempfile::tempdir().unwrap();
1463        let root = dir.path();
1464        std::fs::write(root.join(".fallowrc.json"), "{}\n").unwrap();
1465        let mut results = AnalysisResults::default();
1466        results
1467            .duplicate_exports
1468            .push(DuplicateExportFinding::with_actions(DuplicateExport {
1469                export_name: "Button".to_string(),
1470                locations: vec![
1471                    DuplicateLocation {
1472                        path: root.join("src/ui.ts"),
1473                        line: 10,
1474                        col: 0,
1475                    },
1476                    DuplicateLocation {
1477                        path: root.join("src/components.ts"),
1478                        line: 25,
1479                        col: 0,
1480                    },
1481                ],
1482            }));
1483
1484        let output = build_json(&results, root, Duration::ZERO).unwrap();
1485        let actions = output["duplicate_exports"][0]["actions"]
1486            .as_array()
1487            .unwrap();
1488        assert_eq!(actions[0]["type"], "add-to-config");
1489        assert_eq!(actions[0]["auto_fixable"], true);
1490    }
1491
1492    #[test]
1493    fn duplicate_export_add_to_config_is_auto_fixable_when_create_fallback_allowed() {
1494        // No config file exists, and the dir is NOT inside a monorepo
1495        // subpackage, so `fallow fix` can safely create one. The action
1496        // must surface as auto_fixable: true (per the per-instance flip
1497        // documented on AddToConfigAction).
1498        let dir = tempfile::tempdir().unwrap();
1499        let root = dir.path();
1500        let mut results = AnalysisResults::default();
1501        results
1502            .duplicate_exports
1503            .push(DuplicateExportFinding::with_actions(DuplicateExport {
1504                export_name: "Button".to_string(),
1505                locations: vec![
1506                    DuplicateLocation {
1507                        path: root.join("src/ui.ts"),
1508                        line: 10,
1509                        col: 0,
1510                    },
1511                    DuplicateLocation {
1512                        path: root.join("src/components.ts"),
1513                        line: 25,
1514                        col: 0,
1515                    },
1516                ],
1517            }));
1518
1519        let output = build_json(&results, root, Duration::ZERO).unwrap();
1520        let actions = output["duplicate_exports"][0]["actions"]
1521            .as_array()
1522            .unwrap();
1523        assert_eq!(actions[0]["type"], "add-to-config");
1524        assert_eq!(actions[0]["auto_fixable"], true);
1525    }
1526
1527    #[test]
1528    fn duplicate_export_add_to_config_is_not_auto_fixable_in_monorepo_subpackage() {
1529        // A workspace marker above the invocation directory means
1530        // `fallow fix` would refuse to create a per-subpackage config.
1531        // The action must surface as auto_fixable: false so agents and
1532        // IDEs don't blindly pipe into `fallow fix --yes`.
1533        let dir = tempfile::tempdir().unwrap();
1534        let workspace = dir.path();
1535        std::fs::write(
1536            workspace.join("pnpm-workspace.yaml"),
1537            "packages:\n  - 'packages/*'\n",
1538        )
1539        .unwrap();
1540        let sub = workspace.join("packages/ui");
1541        std::fs::create_dir_all(&sub).unwrap();
1542        let mut results = AnalysisResults::default();
1543        results
1544            .duplicate_exports
1545            .push(DuplicateExportFinding::with_actions(DuplicateExport {
1546                export_name: "Button".to_string(),
1547                locations: vec![
1548                    DuplicateLocation {
1549                        path: sub.join("src/ui.ts"),
1550                        line: 10,
1551                        col: 0,
1552                    },
1553                    DuplicateLocation {
1554                        path: sub.join("src/components.ts"),
1555                        line: 25,
1556                        col: 0,
1557                    },
1558                ],
1559            }));
1560
1561        let output = build_json(&results, &sub, Duration::ZERO).unwrap();
1562        let actions = output["duplicate_exports"][0]["actions"]
1563            .as_array()
1564            .unwrap();
1565        assert_eq!(actions[0]["type"], "add-to-config");
1566        assert_eq!(actions[0]["auto_fixable"], false);
1567    }
1568
1569    #[test]
1570    fn json_type_only_dependency_contains_expected_fields() {
1571        let root = PathBuf::from("/project");
1572        let mut results = AnalysisResults::default();
1573        results
1574            .type_only_dependencies
1575            .push(TypeOnlyDependencyFinding::with_actions(
1576                TypeOnlyDependency {
1577                    package_name: "zod".to_string(),
1578                    path: root.join("package.json"),
1579                    line: 8,
1580                },
1581            ));
1582        let elapsed = Duration::from_millis(0);
1583        let output = build_json(&results, &root, elapsed).expect("should serialize");
1584
1585        let dep = &output["type_only_dependencies"][0];
1586        assert_eq!(dep["package_name"], "zod");
1587        assert_eq!(dep["line"], 8);
1588    }
1589
1590    #[test]
1591    fn json_circular_dependency_contains_expected_fields() {
1592        let root = PathBuf::from("/project");
1593        let mut results = AnalysisResults::default();
1594        results
1595            .circular_dependencies
1596            .push(CircularDependencyFinding::with_actions(
1597                CircularDependency {
1598                    files: vec![
1599                        root.join("src/a.ts"),
1600                        root.join("src/b.ts"),
1601                        root.join("src/c.ts"),
1602                    ],
1603                    length: 3,
1604                    line: 5,
1605                    col: 0,
1606                    is_cross_package: false,
1607                },
1608            ));
1609        let elapsed = Duration::from_millis(0);
1610        let output = build_json(&results, &root, elapsed).expect("should serialize");
1611
1612        let cycle = &output["circular_dependencies"][0];
1613        assert_eq!(cycle["length"], 3);
1614        assert_eq!(cycle["line"], 5);
1615        let files = cycle["files"].as_array().unwrap();
1616        assert_eq!(files.len(), 3);
1617    }
1618
1619    // ── Re-export tagging ───────────────────────────────────────────
1620
1621    #[test]
1622    fn json_re_export_flagged_correctly() {
1623        let root = PathBuf::from("/project");
1624        let mut results = AnalysisResults::default();
1625        results
1626            .unused_exports
1627            .push(UnusedExportFinding::with_actions(UnusedExport {
1628                path: root.join("src/index.ts"),
1629                export_name: "reExported".to_string(),
1630                is_type_only: false,
1631                line: 1,
1632                col: 0,
1633                span_start: 0,
1634                is_re_export: true,
1635            }));
1636        let elapsed = Duration::from_millis(0);
1637        let output = build_json(&results, &root, elapsed).expect("should serialize");
1638
1639        assert_eq!(output["unused_exports"][0]["is_re_export"], true);
1640    }
1641
1642    // ── Schema version stability ────────────────────────────────────
1643
1644    #[test]
1645    fn json_schema_version_is_pinned() {
1646        let root = PathBuf::from("/project");
1647        let results = AnalysisResults::default();
1648        let elapsed = Duration::from_millis(0);
1649        let output = build_json(&results, &root, elapsed).expect("should serialize");
1650
1651        assert_eq!(output["schema_version"], SCHEMA_VERSION);
1652        assert_eq!(output["schema_version"], 6);
1653    }
1654
1655    // ── Version string ──────────────────────────────────────────────
1656
1657    #[test]
1658    fn json_version_matches_cargo_pkg_version() {
1659        let root = PathBuf::from("/project");
1660        let results = AnalysisResults::default();
1661        let elapsed = Duration::from_millis(0);
1662        let output = build_json(&results, &root, elapsed).expect("should serialize");
1663
1664        assert_eq!(output["version"], env!("CARGO_PKG_VERSION"));
1665    }
1666
1667    // ── Elapsed time encoding ───────────────────────────────────────
1668
1669    #[test]
1670    fn json_elapsed_ms_zero_duration() {
1671        let root = PathBuf::from("/project");
1672        let results = AnalysisResults::default();
1673        let output = build_json(&results, &root, Duration::ZERO).expect("should serialize");
1674
1675        assert_eq!(output["elapsed_ms"], 0);
1676    }
1677
1678    #[test]
1679    fn json_elapsed_ms_large_duration() {
1680        let root = PathBuf::from("/project");
1681        let results = AnalysisResults::default();
1682        let elapsed = Duration::from_mins(2);
1683        let output = build_json(&results, &root, elapsed).expect("should serialize");
1684
1685        assert_eq!(output["elapsed_ms"], 120_000);
1686    }
1687
1688    #[test]
1689    fn json_elapsed_ms_sub_millisecond_truncated() {
1690        let root = PathBuf::from("/project");
1691        let results = AnalysisResults::default();
1692        // 500 microseconds = 0 milliseconds (truncated)
1693        let elapsed = Duration::from_micros(500);
1694        let output = build_json(&results, &root, elapsed).expect("should serialize");
1695
1696        assert_eq!(output["elapsed_ms"], 0);
1697    }
1698
1699    // ── Multiple issues of same type ────────────────────────────────
1700
1701    #[test]
1702    fn json_multiple_unused_files() {
1703        let root = PathBuf::from("/project");
1704        let mut results = AnalysisResults::default();
1705        results
1706            .unused_files
1707            .push(UnusedFileFinding::with_actions(UnusedFile {
1708                path: root.join("src/a.ts"),
1709            }));
1710        results
1711            .unused_files
1712            .push(UnusedFileFinding::with_actions(UnusedFile {
1713                path: root.join("src/b.ts"),
1714            }));
1715        results
1716            .unused_files
1717            .push(UnusedFileFinding::with_actions(UnusedFile {
1718                path: root.join("src/c.ts"),
1719            }));
1720        let elapsed = Duration::from_millis(0);
1721        let output = build_json(&results, &root, elapsed).expect("should serialize");
1722
1723        assert_eq!(output["unused_files"].as_array().unwrap().len(), 3);
1724        assert_eq!(output["total_issues"], 3);
1725    }
1726
1727    // ── strip_root_prefix unit tests ────────────────────────────────
1728
1729    #[test]
1730    fn strip_root_prefix_on_string_value() {
1731        let mut value = serde_json::json!("/project/src/file.ts");
1732        strip_root_prefix(&mut value, "/project/");
1733        assert_eq!(value, "src/file.ts");
1734    }
1735
1736    #[test]
1737    fn strip_root_prefix_leaves_non_matching_string() {
1738        let mut value = serde_json::json!("/other/src/file.ts");
1739        strip_root_prefix(&mut value, "/project/");
1740        assert_eq!(value, "/other/src/file.ts");
1741    }
1742
1743    #[test]
1744    fn strip_root_prefix_recurses_into_arrays() {
1745        let mut value = serde_json::json!(["/project/a.ts", "/project/b.ts", "/other/c.ts"]);
1746        strip_root_prefix(&mut value, "/project/");
1747        assert_eq!(value[0], "a.ts");
1748        assert_eq!(value[1], "b.ts");
1749        assert_eq!(value[2], "/other/c.ts");
1750    }
1751
1752    #[test]
1753    fn strip_root_prefix_recurses_into_nested_objects() {
1754        let mut value = serde_json::json!({
1755            "outer": {
1756                "path": "/project/src/nested.ts"
1757            }
1758        });
1759        strip_root_prefix(&mut value, "/project/");
1760        assert_eq!(value["outer"]["path"], "src/nested.ts");
1761    }
1762
1763    #[test]
1764    fn strip_root_prefix_leaves_numbers_and_booleans() {
1765        let mut value = serde_json::json!({
1766            "line": 42,
1767            "is_type_only": false,
1768            "path": "/project/src/file.ts"
1769        });
1770        strip_root_prefix(&mut value, "/project/");
1771        assert_eq!(value["line"], 42);
1772        assert_eq!(value["is_type_only"], false);
1773        assert_eq!(value["path"], "src/file.ts");
1774    }
1775
1776    #[test]
1777    fn strip_root_prefix_normalizes_windows_separators() {
1778        let mut value = serde_json::json!(r"/project\src\file.ts");
1779        strip_root_prefix(&mut value, "/project/");
1780        assert_eq!(value, "src/file.ts");
1781    }
1782
1783    #[test]
1784    fn strip_root_prefix_handles_empty_string_after_strip() {
1785        // Edge case: the string IS the prefix (without trailing content).
1786        // This shouldn't happen in practice but should not panic.
1787        let mut value = serde_json::json!("/project/");
1788        strip_root_prefix(&mut value, "/project/");
1789        assert_eq!(value, "");
1790    }
1791
1792    #[test]
1793    fn strip_root_prefix_deeply_nested_array_of_objects() {
1794        let mut value = serde_json::json!({
1795            "groups": [{
1796                "instances": [{
1797                    "file": "/project/src/a.ts"
1798                }, {
1799                    "file": "/project/src/b.ts"
1800                }]
1801            }]
1802        });
1803        strip_root_prefix(&mut value, "/project/");
1804        assert_eq!(value["groups"][0]["instances"][0]["file"], "src/a.ts");
1805        assert_eq!(value["groups"][0]["instances"][1]["file"], "src/b.ts");
1806    }
1807
1808    // ── Full sample results round-trip ──────────────────────────────
1809
1810    #[test]
1811    fn json_full_sample_results_total_issues_correct() {
1812        let root = PathBuf::from("/project");
1813        let results = sample_results(&root);
1814        let elapsed = Duration::from_millis(100);
1815        let output = build_json(&results, &root, elapsed).expect("should serialize");
1816
1817        // sample_results adds one of each issue type (12 total).
1818        // unused_files + unused_exports + unused_types + unused_dependencies
1819        // + unused_dev_dependencies + unused_enum_members + unused_class_members
1820        // + unresolved_imports + unlisted_dependencies + duplicate_exports
1821        // + type_only_dependencies + circular_dependencies
1822        assert_eq!(output["total_issues"], results.total_issues());
1823    }
1824
1825    #[test]
1826    fn json_full_sample_no_absolute_paths_in_output() {
1827        let root = PathBuf::from("/project");
1828        let results = sample_results(&root);
1829        let elapsed = Duration::from_millis(0);
1830        let output = build_json(&results, &root, elapsed).expect("should serialize");
1831
1832        let json_str = serde_json::to_string(&output).expect("should stringify");
1833        // The root prefix should be stripped from all paths.
1834        assert!(!json_str.contains("/project/src/"));
1835        assert!(!json_str.contains("/project/package.json"));
1836    }
1837
1838    // ── JSON output is deterministic ────────────────────────────────
1839
1840    #[test]
1841    fn json_output_is_deterministic() {
1842        let root = PathBuf::from("/project");
1843        let results = sample_results(&root);
1844        let elapsed = Duration::from_millis(50);
1845
1846        let output1 = build_json(&results, &root, elapsed).expect("first build");
1847        let output2 = build_json(&results, &root, elapsed).expect("second build");
1848
1849        assert_eq!(output1, output2);
1850    }
1851
1852    // ── Metadata not overwritten by results fields ──────────────────
1853
1854    #[test]
1855    fn json_results_fields_do_not_shadow_metadata() {
1856        // Ensure that serialized results don't contain keys like "schema_version"
1857        // that could overwrite the metadata fields we insert first.
1858        let root = PathBuf::from("/project");
1859        let results = AnalysisResults::default();
1860        let elapsed = Duration::from_millis(99);
1861        let output = build_json(&results, &root, elapsed).expect("should serialize");
1862
1863        // Metadata should reflect our explicit values, not anything from AnalysisResults.
1864        assert_eq!(output["schema_version"], 6);
1865        assert_eq!(output["elapsed_ms"], 99);
1866    }
1867
1868    // ── All 14 issue type arrays present ────────────────────────────
1869
1870    #[test]
1871    fn json_all_issue_type_arrays_present_in_empty_results() {
1872        let root = PathBuf::from("/project");
1873        let results = AnalysisResults::default();
1874        let elapsed = Duration::from_millis(0);
1875        let output = build_json(&results, &root, elapsed).expect("should serialize");
1876
1877        let expected_arrays = [
1878            "unused_files",
1879            "unused_exports",
1880            "unused_types",
1881            "unused_dependencies",
1882            "unused_dev_dependencies",
1883            "unused_optional_dependencies",
1884            "unused_enum_members",
1885            "unused_class_members",
1886            "unresolved_imports",
1887            "unlisted_dependencies",
1888            "duplicate_exports",
1889            "type_only_dependencies",
1890            "test_only_dependencies",
1891            "circular_dependencies",
1892        ];
1893        for key in &expected_arrays {
1894            assert!(
1895                output[key].is_array(),
1896                "expected '{key}' to be an array in JSON output"
1897            );
1898        }
1899    }
1900
1901    // ── insert_meta ─────────────────────────────────────────────────
1902
1903    #[test]
1904    fn insert_meta_adds_key_to_object() {
1905        let mut output = serde_json::json!({ "foo": 1 });
1906        let meta = serde_json::json!({ "docs": "https://example.com" });
1907        insert_meta(&mut output, meta.clone());
1908        assert_eq!(output["_meta"], meta);
1909    }
1910
1911    #[test]
1912    fn insert_meta_noop_on_non_object() {
1913        let mut output = serde_json::json!([1, 2, 3]);
1914        let meta = serde_json::json!({ "docs": "https://example.com" });
1915        insert_meta(&mut output, meta);
1916        // Should not panic or add anything
1917        assert!(output.is_array());
1918    }
1919
1920    #[test]
1921    fn insert_meta_overwrites_existing_meta() {
1922        let mut output = serde_json::json!({ "_meta": "old" });
1923        let meta = serde_json::json!({ "new": true });
1924        insert_meta(&mut output, meta.clone());
1925        assert_eq!(output["_meta"], meta);
1926    }
1927
1928    // ── strip_root_prefix with null value ──
1929
1930    #[test]
1931    fn strip_root_prefix_null_unchanged() {
1932        let mut value = serde_json::Value::Null;
1933        strip_root_prefix(&mut value, "/project/");
1934        assert!(value.is_null());
1935    }
1936
1937    // ── strip_root_prefix with empty string ──
1938
1939    #[test]
1940    fn strip_root_prefix_empty_string() {
1941        let mut value = serde_json::json!("");
1942        strip_root_prefix(&mut value, "/project/");
1943        assert_eq!(value, "");
1944    }
1945
1946    // ── strip_root_prefix on mixed nested structure ──
1947
1948    #[test]
1949    fn strip_root_prefix_mixed_types() {
1950        let mut value = serde_json::json!({
1951            "path": "/project/src/file.ts",
1952            "line": 42,
1953            "flag": true,
1954            "nested": {
1955                "items": ["/project/a.ts", 99, null, "/project/b.ts"],
1956                "deep": { "path": "/project/c.ts" }
1957            }
1958        });
1959        strip_root_prefix(&mut value, "/project/");
1960        assert_eq!(value["path"], "src/file.ts");
1961        assert_eq!(value["line"], 42);
1962        assert_eq!(value["flag"], true);
1963        assert_eq!(value["nested"]["items"][0], "a.ts");
1964        assert_eq!(value["nested"]["items"][1], 99);
1965        assert!(value["nested"]["items"][2].is_null());
1966        assert_eq!(value["nested"]["items"][3], "b.ts");
1967        assert_eq!(value["nested"]["deep"]["path"], "c.ts");
1968    }
1969
1970    // ── JSON with explain meta for check ──
1971
1972    #[test]
1973    fn json_check_meta_integrates_correctly() {
1974        let root = PathBuf::from("/project");
1975        let results = AnalysisResults::default();
1976        let elapsed = Duration::from_millis(0);
1977        let mut output = build_json(&results, &root, elapsed).expect("should serialize");
1978        insert_meta(&mut output, crate::explain::check_meta());
1979
1980        assert!(output["_meta"]["docs"].is_string());
1981        assert!(output["_meta"]["rules"].is_object());
1982    }
1983
1984    // ── JSON unused member kind serialization ──
1985
1986    #[test]
1987    fn json_unused_member_kind_serialized() {
1988        let root = PathBuf::from("/project");
1989        let mut results = AnalysisResults::default();
1990        results
1991            .unused_enum_members
1992            .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
1993                path: root.join("src/enums.ts"),
1994                parent_name: "Color".to_string(),
1995                member_name: "Red".to_string(),
1996                kind: MemberKind::EnumMember,
1997                line: 3,
1998                col: 2,
1999            }));
2000        results
2001            .unused_class_members
2002            .push(UnusedClassMemberFinding::with_actions(UnusedMember {
2003                path: root.join("src/class.ts"),
2004                parent_name: "Foo".to_string(),
2005                member_name: "bar".to_string(),
2006                kind: MemberKind::ClassMethod,
2007                line: 10,
2008                col: 4,
2009            }));
2010
2011        let elapsed = Duration::from_millis(0);
2012        let output = build_json(&results, &root, elapsed).expect("should serialize");
2013
2014        let enum_member = &output["unused_enum_members"][0];
2015        assert!(enum_member["kind"].is_string());
2016        let class_member = &output["unused_class_members"][0];
2017        assert!(class_member["kind"].is_string());
2018    }
2019
2020    // ── Actions injection ──────────────────────────────────────────
2021
2022    #[test]
2023    fn json_unused_export_has_actions() {
2024        let root = PathBuf::from("/project");
2025        let mut results = AnalysisResults::default();
2026        results
2027            .unused_exports
2028            .push(UnusedExportFinding::with_actions(UnusedExport {
2029                path: root.join("src/utils.ts"),
2030                export_name: "helperFn".to_string(),
2031                is_type_only: false,
2032                line: 10,
2033                col: 4,
2034                span_start: 120,
2035                is_re_export: false,
2036            }));
2037        let output = build_json(&results, &root, Duration::ZERO).unwrap();
2038
2039        let actions = output["unused_exports"][0]["actions"].as_array().unwrap();
2040        assert_eq!(actions.len(), 2);
2041
2042        // Fix action
2043        assert_eq!(actions[0]["type"], "remove-export");
2044        assert_eq!(actions[0]["auto_fixable"], true);
2045        assert!(actions[0].get("note").is_none());
2046
2047        // Suppress action
2048        assert_eq!(actions[1]["type"], "suppress-line");
2049        assert_eq!(
2050            actions[1]["comment"],
2051            "// fallow-ignore-next-line unused-export"
2052        );
2053    }
2054
2055    #[test]
2056    fn json_same_line_findings_share_multi_kind_suppression_comment() {
2057        let root = PathBuf::from("/project");
2058        let mut results = AnalysisResults::default();
2059        results
2060            .unused_exports
2061            .push(UnusedExportFinding::with_actions(UnusedExport {
2062                path: root.join("src/api.ts"),
2063                export_name: "helperFn".to_string(),
2064                is_type_only: false,
2065                line: 10,
2066                col: 4,
2067                span_start: 120,
2068                is_re_export: false,
2069            }));
2070        results
2071            .unused_types
2072            .push(UnusedTypeFinding::with_actions(UnusedExport {
2073                path: root.join("src/api.ts"),
2074                export_name: "OldType".to_string(),
2075                is_type_only: true,
2076                line: 10,
2077                col: 0,
2078                span_start: 60,
2079                is_re_export: false,
2080            }));
2081        let output = build_json(&results, &root, Duration::ZERO).unwrap();
2082
2083        let export_actions = output["unused_exports"][0]["actions"].as_array().unwrap();
2084        let type_actions = output["unused_types"][0]["actions"].as_array().unwrap();
2085        assert_eq!(
2086            export_actions[1]["comment"],
2087            "// fallow-ignore-next-line unused-export, unused-type"
2088        );
2089        assert_eq!(
2090            type_actions[1]["comment"],
2091            "// fallow-ignore-next-line unused-export, unused-type"
2092        );
2093    }
2094
2095    #[test]
2096    fn audit_like_json_shares_suppression_comment_across_dead_code_and_complexity() {
2097        let mut output = serde_json::json!({
2098            "dead_code": {
2099                "unused_exports": [{
2100                    "path": "src/main.ts",
2101                    "line": 1,
2102                    "actions": [
2103                        { "type": "remove-export", "auto_fixable": true },
2104                        {
2105                            "type": "suppress-line",
2106                            "auto_fixable": false,
2107                            "comment": "// fallow-ignore-next-line unused-export"
2108                        }
2109                    ]
2110                }]
2111            },
2112            "complexity": {
2113                "findings": [{
2114                    "path": "src/main.ts",
2115                    "line": 1,
2116                    "actions": [
2117                        { "type": "refactor-function", "auto_fixable": false },
2118                        {
2119                            "type": "suppress-line",
2120                            "auto_fixable": false,
2121                            "comment": "// fallow-ignore-next-line complexity"
2122                        }
2123                    ]
2124                }]
2125            }
2126        });
2127
2128        harmonize_multi_kind_suppress_line_actions(&mut output);
2129
2130        assert_eq!(
2131            output["dead_code"]["unused_exports"][0]["actions"][1]["comment"],
2132            "// fallow-ignore-next-line unused-export, complexity"
2133        );
2134        assert_eq!(
2135            output["complexity"]["findings"][0]["actions"][1]["comment"],
2136            "// fallow-ignore-next-line unused-export, complexity"
2137        );
2138    }
2139
2140    #[test]
2141    fn json_unused_file_has_file_suppress_and_note() {
2142        let root = PathBuf::from("/project");
2143        let mut results = AnalysisResults::default();
2144        results
2145            .unused_files
2146            .push(UnusedFileFinding::with_actions(UnusedFile {
2147                path: root.join("src/dead.ts"),
2148            }));
2149        let output = build_json(&results, &root, Duration::ZERO).unwrap();
2150
2151        let actions = output["unused_files"][0]["actions"].as_array().unwrap();
2152        assert_eq!(actions[0]["type"], "delete-file");
2153        assert_eq!(actions[0]["auto_fixable"], false);
2154        assert!(actions[0]["note"].is_string());
2155        assert_eq!(actions[1]["type"], "suppress-file");
2156        assert_eq!(actions[1]["comment"], "// fallow-ignore-file unused-file");
2157    }
2158
2159    #[test]
2160    fn json_unused_dependency_has_config_suppress_with_package_name() {
2161        let root = PathBuf::from("/project");
2162        let mut results = AnalysisResults::default();
2163        results
2164            .unused_dependencies
2165            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2166                package_name: "lodash".to_string(),
2167                location: DependencyLocation::Dependencies,
2168                path: root.join("package.json"),
2169                line: 5,
2170                used_in_workspaces: Vec::new(),
2171            }));
2172        let output = build_json(&results, &root, Duration::ZERO).unwrap();
2173
2174        let actions = output["unused_dependencies"][0]["actions"]
2175            .as_array()
2176            .unwrap();
2177        assert_eq!(actions[0]["type"], "remove-dependency");
2178        assert_eq!(actions[0]["auto_fixable"], true);
2179
2180        // Config suppress includes actual package name
2181        assert_eq!(actions[1]["type"], "add-to-config");
2182        assert_eq!(actions[1]["config_key"], "ignoreDependencies");
2183        assert_eq!(actions[1]["value"], "lodash");
2184    }
2185
2186    #[test]
2187    fn json_cross_workspace_dependency_is_not_auto_fixable() {
2188        let root = PathBuf::from("/project");
2189        let mut results = AnalysisResults::default();
2190        results
2191            .unused_dependencies
2192            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2193                package_name: "lodash-es".to_string(),
2194                location: DependencyLocation::Dependencies,
2195                path: root.join("packages/shared/package.json"),
2196                line: 5,
2197                used_in_workspaces: vec![root.join("packages/consumer")],
2198            }));
2199        let output = build_json(&results, &root, Duration::ZERO).unwrap();
2200
2201        let actions = output["unused_dependencies"][0]["actions"]
2202            .as_array()
2203            .unwrap();
2204        assert_eq!(actions[0]["type"], "move-dependency");
2205        assert_eq!(actions[0]["auto_fixable"], false);
2206        assert!(
2207            actions[0]["note"]
2208                .as_str()
2209                .unwrap()
2210                .contains("will not remove")
2211        );
2212        assert_eq!(actions[1]["type"], "add-to-config");
2213    }
2214
2215    #[test]
2216    fn json_empty_results_have_no_actions_in_empty_arrays() {
2217        let root = PathBuf::from("/project");
2218        let results = AnalysisResults::default();
2219        let output = build_json(&results, &root, Duration::ZERO).unwrap();
2220
2221        // Empty arrays should remain empty
2222        assert!(output["unused_exports"].as_array().unwrap().is_empty());
2223        assert!(output["unused_files"].as_array().unwrap().is_empty());
2224    }
2225
2226    #[test]
2227    fn json_all_issue_types_have_actions() {
2228        let root = PathBuf::from("/project");
2229        let results = sample_results(&root);
2230        let output = build_json(&results, &root, Duration::ZERO).unwrap();
2231
2232        let issue_keys = [
2233            "unused_files",
2234            "unused_exports",
2235            "unused_types",
2236            "unused_dependencies",
2237            "unused_dev_dependencies",
2238            "unused_optional_dependencies",
2239            "unused_enum_members",
2240            "unused_class_members",
2241            "unresolved_imports",
2242            "unlisted_dependencies",
2243            "duplicate_exports",
2244            "type_only_dependencies",
2245            "test_only_dependencies",
2246            "circular_dependencies",
2247        ];
2248
2249        for key in &issue_keys {
2250            let arr = output[key].as_array().unwrap();
2251            if !arr.is_empty() {
2252                let actions = arr[0]["actions"].as_array();
2253                assert!(
2254                    actions.is_some() && !actions.unwrap().is_empty(),
2255                    "missing actions for {key}"
2256                );
2257            }
2258        }
2259    }
2260
2261    // ── Health actions injection ───────────────────────────────────
2262
2263    /// Test helper: deserialize a JSON finding shape into a typed
2264    /// [`ComplexityViolation`], run [`HealthFinding::with_actions`] with
2265    /// the supplied thresholds, and return the resulting `actions` array
2266    /// as `serde_json::Value` so existing JSON-shape assertions keep
2267    /// working after PR B2 of #384 moved finding action selection from
2268    /// the JSON post-pass into the typed wrapper.
2269    fn build_actions_for_finding_json(
2270        finding_json: serde_json::Value,
2271        opts: crate::health_types::HealthActionOptions,
2272        max_cyclomatic_threshold: u16,
2273        max_cognitive_threshold: u16,
2274        max_crap_threshold: f64,
2275    ) -> Vec<serde_json::Value> {
2276        let mut value = finding_json;
2277        // The CV struct skip-serializes `Option::None` fields, but the
2278        // shorthand `serde_json::json!({})` test fixtures sometimes omit
2279        // optional fields. Adapt missing defaults so deserialization
2280        // succeeds for synthetic test inputs.
2281        if let Some(map) = value.as_object_mut() {
2282            map.entry("col".to_string())
2283                .or_insert(serde_json::Value::from(0_u32));
2284            map.entry("line_count".to_string())
2285                .or_insert(serde_json::Value::from(0_u32));
2286            map.entry("param_count".to_string())
2287                .or_insert(serde_json::Value::from(0_u8));
2288            map.entry("severity".to_string())
2289                .or_insert(serde_json::Value::String("moderate".to_string()));
2290        }
2291        // ComplexityViolation derives Serialize only; for tests we
2292        // reconstruct it field by field via a small helper that reads
2293        // the JSON shape used by these tests.
2294        let violation = synthesize_complexity_violation(&value);
2295        let ctx = crate::health_types::HealthActionContext {
2296            opts,
2297            max_cyclomatic_threshold,
2298            max_cognitive_threshold,
2299            max_crap_threshold,
2300        };
2301        let finding = crate::health_types::HealthFinding::with_actions(violation, &ctx);
2302        let serialized = serde_json::to_value(&finding).expect("serialize HealthFinding");
2303        serialized["actions"]
2304            .as_array()
2305            .cloned()
2306            .unwrap_or_default()
2307    }
2308
2309    /// Reads a JSON object with finding-shape fields and produces a
2310    /// [`ComplexityViolation`]. Test-only: panics on schema mismatches so
2311    /// authors notice when synthetic fixtures drift from the canonical
2312    /// shape.
2313    fn synthesize_complexity_violation(
2314        value: &serde_json::Value,
2315    ) -> crate::health_types::ComplexityViolation {
2316        use crate::health_types::{
2317            CoverageSource, CoverageTier, ExceededThreshold, FindingSeverity,
2318        };
2319        let exceeded = match value["exceeded"].as_str().unwrap_or("crap") {
2320            "cyclomatic" => ExceededThreshold::Cyclomatic,
2321            "cognitive" => ExceededThreshold::Cognitive,
2322            "both" => ExceededThreshold::Both,
2323            "crap" => ExceededThreshold::Crap,
2324            "cyclomatic_crap" => ExceededThreshold::CyclomaticCrap,
2325            "cognitive_crap" => ExceededThreshold::CognitiveCrap,
2326            "all" => ExceededThreshold::All,
2327            other => panic!("unknown exceeded label: {other}"),
2328        };
2329        let severity = match value["severity"].as_str().unwrap_or("moderate") {
2330            "moderate" => FindingSeverity::Moderate,
2331            "high" => FindingSeverity::High,
2332            "critical" => FindingSeverity::Critical,
2333            other => panic!("unknown severity label: {other}"),
2334        };
2335        let coverage_tier = value
2336            .get("coverage_tier")
2337            .and_then(|v| v.as_str())
2338            .map(|t| match t {
2339                "none" => CoverageTier::None,
2340                "partial" => CoverageTier::Partial,
2341                "high" => CoverageTier::High,
2342                other => panic!("unknown coverage_tier label: {other}"),
2343            });
2344        let coverage_source =
2345            value
2346                .get("coverage_source")
2347                .and_then(|v| v.as_str())
2348                .map(|s| match s {
2349                    "istanbul" => CoverageSource::Istanbul,
2350                    "estimated" => CoverageSource::Estimated,
2351                    "estimated_component_inherited" => CoverageSource::EstimatedComponentInherited,
2352                    other => panic!("unknown coverage_source label: {other}"),
2353                });
2354        crate::health_types::ComplexityViolation {
2355            path: std::path::PathBuf::from(value["path"].as_str().unwrap_or("src/x.ts")),
2356            name: value["name"].as_str().unwrap_or("fn").to_string(),
2357            line: u32::try_from(value["line"].as_u64().unwrap_or(0)).unwrap_or(0),
2358            col: u32::try_from(value["col"].as_u64().unwrap_or(0)).unwrap_or(0),
2359            cyclomatic: u16::try_from(value["cyclomatic"].as_u64().unwrap_or(0)).unwrap_or(0),
2360            cognitive: u16::try_from(value["cognitive"].as_u64().unwrap_or(0)).unwrap_or(0),
2361            line_count: u32::try_from(value["line_count"].as_u64().unwrap_or(0)).unwrap_or(0),
2362            param_count: u8::try_from(value["param_count"].as_u64().unwrap_or(0)).unwrap_or(0),
2363            exceeded,
2364            severity,
2365            crap: value.get("crap").and_then(|v| v.as_f64()),
2366            coverage_pct: value.get("coverage_pct").and_then(|v| v.as_f64()),
2367            coverage_tier,
2368            coverage_source,
2369            inherited_from: value
2370                .get("inherited_from")
2371                .and_then(|v| v.as_str())
2372                .map(std::path::PathBuf::from),
2373            component_rollup: value.get("component_rollup").and_then(|v| {
2374                let map = v.as_object()?;
2375                Some(crate::health_types::ComponentRollup {
2376                    component: map.get("component")?.as_str()?.to_string(),
2377                    class_worst_function: map.get("class_worst_function")?.as_str()?.to_string(),
2378                    class_cyclomatic: u16::try_from(map.get("class_cyclomatic")?.as_u64()?).ok()?,
2379                    class_cognitive: u16::try_from(map.get("class_cognitive")?.as_u64()?).ok()?,
2380                    template_path: std::path::PathBuf::from(map.get("template_path")?.as_str()?),
2381                    template_cyclomatic: u16::try_from(map.get("template_cyclomatic")?.as_u64()?)
2382                        .ok()?,
2383                    template_cognitive: u16::try_from(map.get("template_cognitive")?.as_u64()?)
2384                        .ok()?,
2385                })
2386            }),
2387        }
2388    }
2389
2390    #[test]
2391    fn health_finding_has_actions() {
2392        let actions = build_actions_for_finding_json(
2393            serde_json::json!({
2394                "path": "src/utils.ts",
2395                "name": "processData",
2396                "line": 10,
2397                "col": 0,
2398                "cyclomatic": 25,
2399                "cognitive": 30,
2400                "line_count": 150,
2401                "exceeded": "both"
2402            }),
2403            crate::health_types::HealthActionOptions::default(),
2404            20,
2405            15,
2406            30.0,
2407        );
2408
2409        assert_eq!(actions.len(), 2);
2410        assert_eq!(actions[0]["type"], "refactor-function");
2411        assert_eq!(actions[0]["auto_fixable"], false);
2412        assert!(
2413            actions[0]["description"]
2414                .as_str()
2415                .unwrap()
2416                .contains("processData")
2417        );
2418        assert_eq!(actions[1]["type"], "suppress-line");
2419        assert_eq!(
2420            actions[1]["comment"],
2421            "// fallow-ignore-next-line complexity"
2422        );
2423    }
2424
2425    // Tests for the retired hotspot / target action post-pass now live in
2426    // `crates/cli/src/health_types/finding.rs::hotspot_target_tests`
2427    // alongside the typed `HotspotFinding` and `RefactoringTargetFinding`
2428    // wrappers and the typed `build_hotspot_actions` /
2429    // `build_refactoring_target_actions` helpers they exercise. The four
2430    // direct `suggest_codeowners_pattern` tests moved alongside that helper.
2431
2432    #[test]
2433    fn health_finding_suppress_has_placement() {
2434        let actions = build_actions_for_finding_json(
2435            serde_json::json!({
2436                "path": "src/utils.ts",
2437                "name": "processData",
2438                "line": 10,
2439                "col": 0,
2440                "cyclomatic": 25,
2441                "cognitive": 30,
2442                "line_count": 150,
2443                "exceeded": "both"
2444            }),
2445            crate::health_types::HealthActionOptions::default(),
2446            20,
2447            15,
2448            30.0,
2449        );
2450
2451        assert_eq!(actions[1]["placement"], "above-function-declaration");
2452    }
2453
2454    #[test]
2455    fn html_template_health_finding_uses_html_suppression() {
2456        let actions = build_actions_for_finding_json(
2457            serde_json::json!({
2458                "path": "src/app.component.html",
2459                "name": "<template>",
2460                "line": 1,
2461                "col": 0,
2462                "cyclomatic": 25,
2463                "cognitive": 30,
2464                "line_count": 40,
2465                "exceeded": "both"
2466            }),
2467            crate::health_types::HealthActionOptions::default(),
2468            20,
2469            15,
2470            30.0,
2471        );
2472
2473        let suppress = &actions[1];
2474        assert_eq!(suppress["type"], "suppress-file");
2475        assert_eq!(
2476            suppress["comment"],
2477            "<!-- fallow-ignore-file complexity -->"
2478        );
2479        assert_eq!(suppress["placement"], "top-of-template");
2480    }
2481
2482    #[test]
2483    fn inline_template_health_finding_uses_decorator_suppression() {
2484        let actions = build_actions_for_finding_json(
2485            serde_json::json!({
2486                "path": "src/app.component.ts",
2487                "name": "<template>",
2488                "line": 5,
2489                "col": 0,
2490                "cyclomatic": 25,
2491                "cognitive": 30,
2492                "line_count": 40,
2493                "exceeded": "both"
2494            }),
2495            crate::health_types::HealthActionOptions::default(),
2496            20,
2497            15,
2498            30.0,
2499        );
2500
2501        let refactor = &actions[0];
2502        assert_eq!(refactor["type"], "refactor-function");
2503        assert!(
2504            refactor["description"]
2505                .as_str()
2506                .unwrap()
2507                .contains("template complexity")
2508        );
2509        let suppress = &actions[1];
2510        assert_eq!(suppress["type"], "suppress-line");
2511        assert_eq!(
2512            suppress["description"],
2513            "Suppress with an inline comment above the Angular decorator"
2514        );
2515        assert_eq!(suppress["placement"], "above-angular-decorator");
2516    }
2517
2518    // Duplication action injection tests retired in #409: the wire shape
2519    // now flows through typed wrappers in `crate::output_dupes`; the
2520    // wrappers carry their own unit tests covering the same per-finding
2521    // `actions[]` semantics (extract-shared / apply-suggestion /
2522    // suppress-line position-0 invariants, issue #393 nested groups).
2523
2524    // ── Tier-aware health action emission ──────────────────────────
2525
2526    /// Helper: build a health JSON envelope with a single CRAP-only finding.
2527    /// Default cognitive complexity is 12 (above the cognitive floor at the
2528    /// default `max_cognitive_threshold / 2 = 7.5`); use
2529    /// `crap_only_finding_envelope_with_cognitive` to exercise low-cog cases
2530    /// (flat dispatchers, JSX render maps) where the cognitive floor should
2531    /// suppress the secondary refactor.
2532    fn crap_only_finding_envelope(
2533        coverage_tier: Option<&str>,
2534        cyclomatic: u16,
2535        max_cyclomatic_threshold: u16,
2536    ) -> serde_json::Value {
2537        crap_only_finding_envelope_with_max_crap(
2538            coverage_tier,
2539            cyclomatic,
2540            12,
2541            max_cyclomatic_threshold,
2542            15,
2543            30.0,
2544        )
2545    }
2546
2547    fn crap_only_finding_envelope_with_cognitive(
2548        coverage_tier: Option<&str>,
2549        cyclomatic: u16,
2550        cognitive: u16,
2551        max_cyclomatic_threshold: u16,
2552    ) -> serde_json::Value {
2553        crap_only_finding_envelope_with_max_crap(
2554            coverage_tier,
2555            cyclomatic,
2556            cognitive,
2557            max_cyclomatic_threshold,
2558            15,
2559            30.0,
2560        )
2561    }
2562
2563    /// Build a synthetic health JSON envelope around a single typed
2564    /// [`HealthFinding`] so the existing JSON-shaped assertions in this
2565    /// module keep working after PR B2 of #384 moved action selection from
2566    /// the JSON post-pass into [`HealthFinding::with_actions`]. Defaults to
2567    /// the un-suppressed action context; callers that want to exercise the
2568    /// `omit_suppress_line` path should go through
2569    /// [`build_finding_envelope_with_ctx`].
2570    fn crap_only_finding_envelope_with_max_crap(
2571        coverage_tier: Option<&str>,
2572        cyclomatic: u16,
2573        cognitive: u16,
2574        max_cyclomatic_threshold: u16,
2575        max_cognitive_threshold: u16,
2576        max_crap_threshold: f64,
2577    ) -> serde_json::Value {
2578        build_finding_envelope_with_ctx(
2579            coverage_tier,
2580            cyclomatic,
2581            cognitive,
2582            max_cyclomatic_threshold,
2583            max_cognitive_threshold,
2584            max_crap_threshold,
2585            crate::health_types::HealthActionOptions::default(),
2586        )
2587    }
2588
2589    /// Build a single-finding health JSON envelope with the supplied action
2590    /// context. Used by the suppress-line gating tests to exercise the
2591    /// `baseline-active` / `config-disabled` reasons.
2592    fn build_finding_envelope_with_ctx(
2593        coverage_tier: Option<&str>,
2594        cyclomatic: u16,
2595        cognitive: u16,
2596        max_cyclomatic_threshold: u16,
2597        max_cognitive_threshold: u16,
2598        max_crap_threshold: f64,
2599        action_opts: crate::health_types::HealthActionOptions,
2600    ) -> serde_json::Value {
2601        let tier = coverage_tier.map(|t| match t {
2602            "none" => crate::health_types::CoverageTier::None,
2603            "partial" => crate::health_types::CoverageTier::Partial,
2604            "high" => crate::health_types::CoverageTier::High,
2605            other => panic!("unknown coverage tier label: {other}"),
2606        });
2607        let violation = crate::health_types::ComplexityViolation {
2608            path: std::path::PathBuf::from("src/risk.ts"),
2609            name: "computeScore".to_string(),
2610            line: 12,
2611            col: 0,
2612            cyclomatic,
2613            cognitive,
2614            line_count: 40,
2615            param_count: 0,
2616            exceeded: crate::health_types::ExceededThreshold::Crap,
2617            severity: crate::health_types::FindingSeverity::Moderate,
2618            crap: Some(35.5),
2619            coverage_pct: None,
2620            coverage_tier: tier,
2621            coverage_source: None,
2622            inherited_from: None,
2623            component_rollup: None,
2624        };
2625        let ctx = crate::health_types::HealthActionContext {
2626            opts: action_opts,
2627            max_cyclomatic_threshold,
2628            max_cognitive_threshold,
2629            max_crap_threshold,
2630        };
2631        let finding = crate::health_types::HealthFinding::with_actions(violation, &ctx);
2632        let actions_meta = if action_opts.omit_suppress_line {
2633            Some(serde_json::json!({
2634                "suppression_hints_omitted": true,
2635                "reason": action_opts.omit_reason.unwrap_or("unspecified"),
2636                "scope": "health-findings",
2637            }))
2638        } else {
2639            None
2640        };
2641        let mut envelope = serde_json::json!({
2642            "findings": [serde_json::to_value(&finding).unwrap()],
2643            "summary": {
2644                "max_cyclomatic_threshold": max_cyclomatic_threshold,
2645                "max_cognitive_threshold": max_cognitive_threshold,
2646                "max_crap_threshold": max_crap_threshold,
2647            },
2648        });
2649        if let Some(meta) = actions_meta
2650            && let Some(map) = envelope.as_object_mut()
2651        {
2652            map.insert("actions_meta".to_string(), meta);
2653        }
2654        envelope
2655    }
2656
2657    #[test]
2658    fn crap_only_tier_none_emits_add_tests() {
2659        let output = crap_only_finding_envelope(Some("none"), 6, 20);
2660        let actions = output["findings"][0]["actions"].as_array().unwrap();
2661        assert!(
2662            actions.iter().any(|a| a["type"] == "add-tests"),
2663            "tier=none crap-only must emit add-tests, got {actions:?}"
2664        );
2665        assert!(
2666            !actions.iter().any(|a| a["type"] == "increase-coverage"),
2667            "tier=none must not emit increase-coverage"
2668        );
2669    }
2670
2671    #[test]
2672    fn crap_only_tier_partial_emits_increase_coverage() {
2673        let output = crap_only_finding_envelope(Some("partial"), 6, 20);
2674        let actions = output["findings"][0]["actions"].as_array().unwrap();
2675        assert!(
2676            actions.iter().any(|a| a["type"] == "increase-coverage"),
2677            "tier=partial crap-only must emit increase-coverage, got {actions:?}"
2678        );
2679        assert!(
2680            !actions.iter().any(|a| a["type"] == "add-tests"),
2681            "tier=partial must not emit add-tests"
2682        );
2683    }
2684
2685    #[test]
2686    fn crap_only_tier_high_emits_increase_coverage_when_full_coverage_can_clear_crap() {
2687        // CC=20 at 70% coverage has CRAP 30.8, but at 100% coverage CRAP
2688        // falls to 20.0, below the default max_crap_threshold=30. Coverage
2689        // is therefore still a valid remediation even though tier=high.
2690        let output = crap_only_finding_envelope(Some("high"), 20, 30);
2691        let actions = output["findings"][0]["actions"].as_array().unwrap();
2692        assert!(
2693            actions.iter().any(|a| a["type"] == "increase-coverage"),
2694            "tier=high crap-only must still emit increase-coverage when full coverage can clear CRAP, got {actions:?}"
2695        );
2696        assert!(
2697            !actions.iter().any(|a| a["type"] == "refactor-function"),
2698            "coverage-remediable crap-only findings should not get refactor-function unless near the cyclomatic threshold"
2699        );
2700        assert!(
2701            !actions.iter().any(|a| a["type"] == "add-tests"),
2702            "tier=high must not emit add-tests"
2703        );
2704    }
2705
2706    #[test]
2707    fn crap_only_emits_refactor_when_full_coverage_cannot_clear_crap() {
2708        // At 100% coverage CRAP bottoms out at CC. With CC=35 and a CRAP
2709        // threshold of 30, tests alone can reduce risk but cannot clear the
2710        // finding; the primary action should be complexity reduction.
2711        let output = crap_only_finding_envelope_with_max_crap(Some("high"), 35, 12, 50, 15, 30.0);
2712        let actions = output["findings"][0]["actions"].as_array().unwrap();
2713        assert!(
2714            actions.iter().any(|a| a["type"] == "refactor-function"),
2715            "full-coverage-impossible CRAP-only finding must emit refactor-function, got {actions:?}"
2716        );
2717        assert!(
2718            !actions.iter().any(|a| a["type"] == "increase-coverage"),
2719            "must not emit increase-coverage when even 100% coverage cannot clear CRAP"
2720        );
2721        assert!(
2722            !actions.iter().any(|a| a["type"] == "add-tests"),
2723            "must not emit add-tests when even 100% coverage cannot clear CRAP"
2724        );
2725    }
2726
2727    #[test]
2728    fn crap_only_high_cc_appends_secondary_refactor() {
2729        // CC=16 with threshold=20 => within SECONDARY_REFACTOR_BAND (5)
2730        // of the threshold; refactor is a useful complement to coverage.
2731        let output = crap_only_finding_envelope(Some("none"), 16, 20);
2732        let actions = output["findings"][0]["actions"].as_array().unwrap();
2733        assert!(
2734            actions.iter().any(|a| a["type"] == "add-tests"),
2735            "near-threshold crap-only still emits the primary tier action"
2736        );
2737        assert!(
2738            actions.iter().any(|a| a["type"] == "refactor-function"),
2739            "near-threshold crap-only must also emit secondary refactor-function"
2740        );
2741    }
2742
2743    #[test]
2744    fn crap_only_far_below_threshold_no_secondary_refactor() {
2745        // CC=6 with threshold=20 => far outside the band; refactor not added.
2746        let output = crap_only_finding_envelope(Some("none"), 6, 20);
2747        let actions = output["findings"][0]["actions"].as_array().unwrap();
2748        assert!(
2749            !actions.iter().any(|a| a["type"] == "refactor-function"),
2750            "low-CC crap-only should not get a secondary refactor-function"
2751        );
2752    }
2753
2754    #[test]
2755    fn crap_only_near_threshold_low_cognitive_no_secondary_refactor() {
2756        // Cognitive floor regression. Real-world example from vrs-portals:
2757        // a flat type-tag dispatcher with CC=17 (within SECONDARY_REFACTOR_BAND
2758        // of the default cyclomatic threshold of 20) but cognitive=2 (a single
2759        // switch, no nesting). Suggesting "extract helpers, simplify branching"
2760        // is wrong-target advice for declarative dispatchers; the cognitive
2761        // floor at `max_cognitive_threshold / 2` (default 7) suppresses the
2762        // secondary refactor in this case while still firing it for genuinely
2763        // tangled functions (CC>=15 + cog>=8) where refactor would help.
2764        let output = crap_only_finding_envelope_with_cognitive(Some("none"), 17, 2, 20);
2765        let actions = output["findings"][0]["actions"].as_array().unwrap();
2766        assert!(
2767            actions.iter().any(|a| a["type"] == "add-tests"),
2768            "primary tier action still emits"
2769        );
2770        assert!(
2771            !actions.iter().any(|a| a["type"] == "refactor-function"),
2772            "near-threshold CC with cognitive below floor must NOT emit secondary refactor (got {actions:?})"
2773        );
2774    }
2775
2776    #[test]
2777    fn crap_only_near_threshold_high_cognitive_emits_secondary_refactor() {
2778        // Companion to the cognitive-floor regression: when cognitive is at or
2779        // above the floor, the secondary refactor should still fire. CC=16
2780        // and cognitive=10 (above default floor of 7) is the canonical
2781        // "tangled but near-threshold" function that genuinely benefits from
2782        // both coverage AND refactoring.
2783        let output = crap_only_finding_envelope_with_cognitive(Some("none"), 16, 10, 20);
2784        let actions = output["findings"][0]["actions"].as_array().unwrap();
2785        assert!(
2786            actions.iter().any(|a| a["type"] == "add-tests"),
2787            "primary tier action still emits"
2788        );
2789        assert!(
2790            actions.iter().any(|a| a["type"] == "refactor-function"),
2791            "near-threshold CC with cognitive above floor must emit secondary refactor (got {actions:?})"
2792        );
2793    }
2794
2795    #[test]
2796    fn cyclomatic_only_emits_only_refactor_function() {
2797        let actions = build_actions_for_finding_json(
2798            serde_json::json!({
2799                "path": "src/cyclo.ts",
2800                "name": "branchy",
2801                "line": 5,
2802                "col": 0,
2803                "cyclomatic": 25,
2804                "cognitive": 10,
2805                "line_count": 80,
2806                "exceeded": "cyclomatic",
2807            }),
2808            crate::health_types::HealthActionOptions::default(),
2809            20,
2810            15,
2811            30.0,
2812        );
2813        assert!(
2814            actions.iter().any(|a| a["type"] == "refactor-function"),
2815            "non-CRAP findings emit refactor-function"
2816        );
2817        assert!(
2818            !actions.iter().any(|a| a["type"] == "add-tests"),
2819            "non-CRAP findings must not emit add-tests"
2820        );
2821        assert!(
2822            !actions.iter().any(|a| a["type"] == "increase-coverage"),
2823            "non-CRAP findings must not emit increase-coverage"
2824        );
2825    }
2826
2827    // ── Suppress-line gating ──────────────────────────────────────
2828
2829    #[test]
2830    fn suppress_line_omitted_when_baseline_active() {
2831        let output = build_finding_envelope_with_ctx(
2832            Some("none"),
2833            6,
2834            12,
2835            20,
2836            15,
2837            30.0,
2838            crate::health_types::HealthActionOptions {
2839                omit_suppress_line: true,
2840                omit_reason: Some("baseline-active"),
2841            },
2842        );
2843        let actions = output["findings"][0]["actions"].as_array().unwrap();
2844        assert!(
2845            !actions.iter().any(|a| a["type"] == "suppress-line"),
2846            "baseline-active must not emit suppress-line, got {actions:?}"
2847        );
2848        assert_eq!(
2849            output["actions_meta"]["suppression_hints_omitted"],
2850            serde_json::Value::Bool(true)
2851        );
2852        assert_eq!(output["actions_meta"]["reason"], "baseline-active");
2853        assert_eq!(output["actions_meta"]["scope"], "health-findings");
2854    }
2855
2856    #[test]
2857    fn suppress_line_omitted_when_config_disabled() {
2858        let output = build_finding_envelope_with_ctx(
2859            Some("none"),
2860            6,
2861            12,
2862            20,
2863            15,
2864            30.0,
2865            crate::health_types::HealthActionOptions {
2866                omit_suppress_line: true,
2867                omit_reason: Some("config-disabled"),
2868            },
2869        );
2870        assert_eq!(output["actions_meta"]["reason"], "config-disabled");
2871    }
2872
2873    #[test]
2874    fn suppress_line_emitted_by_default() {
2875        let output = crap_only_finding_envelope(Some("none"), 6, 20);
2876        let actions = output["findings"][0]["actions"].as_array().unwrap();
2877        assert!(
2878            actions.iter().any(|a| a["type"] == "suppress-line"),
2879            "default opts must emit suppress-line"
2880        );
2881        assert!(
2882            output.get("actions_meta").is_none(),
2883            "actions_meta must be absent when no omission occurred"
2884        );
2885    }
2886
2887    /// Drift guard: every action `type` value emitted by the action builder
2888    /// must appear in `docs/output-schema.json`'s `HealthFindingAction.type`
2889    /// enum. Previously the schema listed only `[refactor-function,
2890    /// suppress-line]` while the code emitted `add-tests` for CRAP findings,
2891    /// silently producing schema-invalid output for any consumer using the
2892    /// schema for validation.
2893    #[test]
2894    fn every_emitted_health_action_type_is_in_schema_enum() {
2895        // Exercise every distinct emission path. The list mirrors the match
2896        // in `build_crap_coverage_action` and the surrounding refactor/
2897        // suppress-line emissions in `build_health_finding_actions`.
2898        let cases = [
2899            // (exceeded, coverage_tier, cyclomatic, max_cyclomatic_threshold)
2900            ("crap", Some("none"), 6_u16, 20_u16),
2901            ("crap", Some("partial"), 6, 20),
2902            ("crap", Some("high"), 12, 20),
2903            ("crap", Some("none"), 16, 20), // near threshold => secondary refactor
2904            ("cyclomatic", None, 25, 20),
2905            ("cognitive_crap", Some("partial"), 6, 20),
2906            ("all", Some("none"), 25, 20),
2907        ];
2908
2909        let mut emitted: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2910        for (exceeded, tier, cc, max) in cases {
2911            let mut finding = serde_json::json!({
2912                "path": "src/x.ts",
2913                "name": "fn",
2914                "line": 1,
2915                "col": 0,
2916                "cyclomatic": cc,
2917                "cognitive": 5,
2918                "line_count": 10,
2919                "exceeded": exceeded,
2920                "crap": 35.0,
2921            });
2922            if let Some(t) = tier {
2923                finding["coverage_tier"] = serde_json::Value::String(t.to_owned());
2924            }
2925            let actions = build_actions_for_finding_json(
2926                finding,
2927                crate::health_types::HealthActionOptions::default(),
2928                max,
2929                15,
2930                30.0,
2931            );
2932            for action in &actions {
2933                if let Some(ty) = action["type"].as_str() {
2934                    emitted.insert(ty.to_owned());
2935                }
2936            }
2937        }
2938
2939        // Load the schema enum once. Phase 8 derives `HealthFindingActionType`
2940        // from a Rust enum whose schemars derive produces `oneOf: [{const: ...},
2941        // ...]` (one branch per variant) rather than a flat `enum: [...]`. We
2942        // accept either form here so the drift guard tolerates a future
2943        // schemars-version flip back to the flat shape.
2944        let schema_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2945            .join("..")
2946            .join("..")
2947            .join("docs")
2948            .join("output-schema.json");
2949        let raw = std::fs::read_to_string(&schema_path)
2950            .expect("docs/output-schema.json must be readable for the drift-guard test");
2951        let schema: serde_json::Value = serde_json::from_str(&raw).expect("schema parses");
2952        let type_field = &schema["definitions"]["HealthFindingAction"]["properties"]["type"];
2953        let type_def = if let Some(reference) = type_field.get("$ref").and_then(|r| r.as_str()) {
2954            let name = reference
2955                .strip_prefix("#/definitions/")
2956                .expect("HealthFindingAction.type $ref points into #/definitions/");
2957            &schema["definitions"][name]
2958        } else {
2959            type_field
2960        };
2961        let mut enum_values: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2962        if let Some(arr) = type_def.get("enum").and_then(|e| e.as_array()) {
2963            for v in arr {
2964                if let Some(s) = v.as_str() {
2965                    enum_values.insert(s.to_owned());
2966                }
2967            }
2968        }
2969        if let Some(arr) = type_def.get("oneOf").and_then(|e| e.as_array()) {
2970            for branch in arr {
2971                if let Some(s) = branch.get("const").and_then(|c| c.as_str()) {
2972                    enum_values.insert(s.to_owned());
2973                }
2974            }
2975        }
2976        assert!(
2977            !enum_values.is_empty(),
2978            "could not extract HealthFindingActionType variants from schema (neither `enum` nor `oneOf` with `const` branches)"
2979        );
2980
2981        for ty in &emitted {
2982            assert!(
2983                enum_values.contains(ty),
2984                "build_health_finding_actions emitted action type `{ty}` but \
2985                 docs/output-schema.json HealthFindingAction.type enum does \
2986                 not list it. Add it to the schema (and any downstream \
2987                 typed consumers) when introducing a new action type."
2988            );
2989        }
2990    }
2991
2992    /// Regression for issue #412: prevent reintroduction of the legacy
2993    /// `inject_*` / `augment_*` post-pass pattern in this file. Every
2994    /// JSON `actions[]` array on every finding type should flow from a
2995    /// typed `serde(flatten)` envelope, not from a post-construction
2996    /// mutation of a `serde_json::Value` tree.
2997    ///
2998    /// The allow-list mirrors the `HAND_MAINTAINED_ALLOW_LIST` pattern
2999    /// in `crates/cli/src/bin/schema_emit.rs`: each entry pairs a name
3000    /// with the issue that retires it. It is empty today; any addition
3001    /// needs an issue reference in the same commit. The gate also
3002    /// asserts no STALE entries, so removing a function without
3003    /// removing its allow-list entry fails the test and forces the
3004    /// cleanup commit.
3005    #[test]
3006    fn no_new_post_pass_helpers_in_json_rs() {
3007        const POST_PASS_ALLOW_LIST: &[(&str, &str)] = &[];
3008        let source_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3009            .join("src")
3010            .join("report")
3011            .join("json.rs");
3012        let source = std::fs::read_to_string(&source_path).expect(
3013            "crates/cli/src/report/json.rs must be readable for the post-pass drift-guard test",
3014        );
3015        let mut found: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
3016        for line in source.lines() {
3017            if let Some(name) = extract_post_pass_fn_name(line) {
3018                found.insert(name.to_owned());
3019            }
3020        }
3021        let allow: std::collections::BTreeSet<&'static str> =
3022            POST_PASS_ALLOW_LIST.iter().map(|(name, _)| *name).collect();
3023        let unexpected: Vec<&str> = found
3024            .iter()
3025            .filter(|name| !allow.contains(name.as_str()))
3026            .map(String::as_str)
3027            .collect();
3028        let stale: Vec<&str> = allow
3029            .iter()
3030            .filter(|name| !found.contains(**name))
3031            .copied()
3032            .collect();
3033        assert!(
3034            unexpected.is_empty(),
3035            "new post-pass helper(s) defined in crates/cli/src/report/json.rs are not in \
3036             POST_PASS_ALLOW_LIST: {unexpected:?}.\n\
3037             The typed `serde(flatten)` envelope is the source of truth for `actions[]` on \
3038             every finding. If a new post-pass is genuinely needed, file a tracking issue, \
3039             add the entry to POST_PASS_ALLOW_LIST with the issue link as the reason, and \
3040             reference the issue in the PR body. See issue #412 for context."
3041        );
3042        assert!(
3043            stale.is_empty(),
3044            "stale entries in POST_PASS_ALLOW_LIST (function no longer defined in \
3045             crates/cli/src/report/json.rs): {stale:?}.\n\
3046             Remove them in the same commit that retired the function."
3047        );
3048    }
3049
3050    /// Extracts an `inject_<name>` or `augment_<name>` identifier from a
3051    /// Rust function-definition line, handling `pub`, `pub(...)`,
3052    /// `async`, `const`, and `unsafe` modifiers. Returns `None` for
3053    /// non-definition lines (comments, call sites, doc strings).
3054    fn extract_post_pass_fn_name(line: &str) -> Option<&str> {
3055        let trimmed = line.trim_start();
3056        if trimmed.starts_with("//") {
3057            return None;
3058        }
3059        let mut rest = trimmed;
3060        if let Some(after) = rest.strip_prefix("pub") {
3061            let after = after.trim_start();
3062            rest = if let Some(after) = after.strip_prefix('(') {
3063                let close = after.find(')')?;
3064                after[close + 1..].trim_start()
3065            } else {
3066                after
3067            };
3068        }
3069        for prefix in ["async ", "const ", "unsafe "] {
3070            if let Some(after) = rest.strip_prefix(prefix) {
3071                rest = after.trim_start();
3072            }
3073        }
3074        let after_fn = rest.strip_prefix("fn ")?;
3075        let name_end = after_fn
3076            .find(|c: char| !c.is_alphanumeric() && c != '_')
3077            .unwrap_or(after_fn.len());
3078        let name = &after_fn[..name_end];
3079        if name.starts_with("inject_") || name.starts_with("augment_") {
3080            Some(name)
3081        } else {
3082            None
3083        }
3084    }
3085}