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