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;
8
9use super::shared::NAMESPACE_BARREL_HINT;
10use super::{emit_json, normalize_uri};
11use crate::explain;
12use crate::report::grouping::{OwnershipResolver, ResultGroup};
13
14/// JSON Pointer fragment URL describing the shape of the `value` field on an
15/// `ignoreExports` `add-to-config` action. Agents can fetch and validate the
16/// snippet before writing into a user's config.
17const IGNORE_EXPORTS_VALUE_SCHEMA: &str =
18    "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreExports";
19
20/// JSON Pointer fragment URL describing the shape of the `value` field on an
21/// `ignoreDependencies` `add-to-config` action. Points at the array item schema
22/// (a single string) since the action's `value` is one entry to append.
23const IGNORE_DEPENDENCIES_VALUE_SCHEMA: &str = "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreDependencies/items";
24
25pub(super) fn print_json(
26    results: &AnalysisResults,
27    root: &Path,
28    elapsed: Duration,
29    explain: bool,
30    regression: Option<&crate::regression::RegressionOutcome>,
31    baseline_matched: Option<(usize, usize)>,
32) -> ExitCode {
33    match build_json(results, root, elapsed) {
34        Ok(mut output) => {
35            if let Some(outcome) = regression
36                && let serde_json::Value::Object(ref mut map) = output
37            {
38                map.insert("regression".to_string(), outcome.to_json());
39            }
40            if let Some((entries, matched)) = baseline_matched
41                && let serde_json::Value::Object(ref mut map) = output
42            {
43                map.insert(
44                    "baseline".to_string(),
45                    serde_json::json!({
46                        "entries": entries,
47                        "matched": matched,
48                    }),
49                );
50            }
51            if explain {
52                insert_meta(&mut output, explain::check_meta());
53            }
54            emit_json(&output, "JSON")
55        }
56        Err(e) => {
57            eprintln!("Error: failed to serialize results: {e}");
58            ExitCode::from(2)
59        }
60    }
61}
62
63/// Render grouped analysis results as a single JSON document.
64///
65/// Produces an envelope with `grouped_by` and `total_issues` at the top level,
66/// then a `groups` array where each element contains the group `key`,
67/// `total_issues`, and all the normal result fields with paths relativized.
68#[must_use]
69pub(super) fn print_grouped_json(
70    groups: &[ResultGroup],
71    original: &AnalysisResults,
72    root: &Path,
73    elapsed: Duration,
74    explain: bool,
75    resolver: &OwnershipResolver,
76) -> ExitCode {
77    let root_prefix = format!("{}/", root.display());
78
79    let group_values: Vec<serde_json::Value> = groups
80        .iter()
81        .filter_map(|group| {
82            let mut value = serde_json::to_value(&group.results).ok()?;
83            strip_root_prefix(&mut value, &root_prefix);
84            inject_actions(&mut value);
85            harmonize_multi_kind_suppress_line_actions(&mut value);
86
87            if let serde_json::Value::Object(ref mut map) = value {
88                // Insert key, owners (section mode), and total_issues at the
89                // front by rebuilding the map.
90                let mut ordered = serde_json::Map::new();
91                ordered.insert("key".to_string(), serde_json::json!(group.key));
92                if let Some(ref owners) = group.owners {
93                    ordered.insert("owners".to_string(), serde_json::json!(owners));
94                }
95                ordered.insert(
96                    "total_issues".to_string(),
97                    serde_json::json!(group.results.total_issues()),
98                );
99                for (k, v) in map.iter() {
100                    ordered.insert(k.clone(), v.clone());
101                }
102                Some(serde_json::Value::Object(ordered))
103            } else {
104                Some(value)
105            }
106        })
107        .collect();
108
109    let mut output = serde_json::json!({
110        "schema_version": SCHEMA_VERSION,
111        "version": env!("CARGO_PKG_VERSION"),
112        "elapsed_ms": elapsed.as_millis() as u64,
113        "grouped_by": resolver.mode_label(),
114        "total_issues": original.total_issues(),
115        "groups": group_values,
116    });
117
118    if explain {
119        insert_meta(&mut output, explain::check_meta());
120    }
121
122    emit_json(&output, "JSON")
123}
124
125/// JSON output schema version as an integer (independent of tool version).
126///
127/// Bump this when the structure of the JSON output changes in a
128/// backwards-incompatible way (removing/renaming fields, changing types).
129/// Adding new fields is always backwards-compatible and does not require a bump.
130const SCHEMA_VERSION: u32 = 6;
131const RUNTIME_COVERAGE_SCHEMA_VERSION: &str = "1";
132
133/// Build a JSON envelope with standard metadata fields at the top.
134///
135/// Creates a JSON object with `schema_version`, `version`, and `elapsed_ms`,
136/// then merges all fields from `report_value` into the envelope.
137/// Fields from `report_value` appear after the metadata header.
138fn build_json_envelope(report_value: serde_json::Value, elapsed: Duration) -> serde_json::Value {
139    let mut map = serde_json::Map::new();
140    map.insert(
141        "schema_version".to_string(),
142        serde_json::json!(SCHEMA_VERSION),
143    );
144    map.insert(
145        "version".to_string(),
146        serde_json::json!(env!("CARGO_PKG_VERSION")),
147    );
148    map.insert(
149        "elapsed_ms".to_string(),
150        serde_json::json!(elapsed.as_millis()),
151    );
152    if let serde_json::Value::Object(report_map) = report_value {
153        for (key, value) in report_map {
154            map.insert(key, value);
155        }
156    }
157    serde_json::Value::Object(map)
158}
159
160fn inject_runtime_coverage_schema_version(output: &mut serde_json::Value) {
161    let serde_json::Value::Object(map) = output else {
162        return;
163    };
164
165    if let Some(report) = map.get_mut("runtime_coverage") {
166        inject_runtime_coverage_report_schema_version(report);
167    }
168
169    if let Some(serde_json::Value::Array(groups)) = map.get_mut("groups") {
170        for group in groups {
171            if let Some(report) = group
172                .as_object_mut()
173                .and_then(|group| group.get_mut("runtime_coverage"))
174            {
175                inject_runtime_coverage_report_schema_version(report);
176            }
177        }
178    }
179}
180
181fn inject_runtime_coverage_report_schema_version(report: &mut serde_json::Value) {
182    let serde_json::Value::Object(report_map) = report else {
183        return;
184    };
185
186    let mut ordered = serde_json::Map::new();
187    ordered.insert(
188        "schema_version".to_string(),
189        serde_json::json!(RUNTIME_COVERAGE_SCHEMA_VERSION),
190    );
191    for (key, value) in std::mem::take(report_map) {
192        if key != "schema_version" {
193            ordered.insert(key, value);
194        }
195    }
196    *report_map = ordered;
197}
198
199/// Build the JSON output value for analysis results.
200///
201/// Metadata fields (`schema_version`, `version`, `elapsed_ms`, `total_issues`)
202/// appear first in the output for readability. Paths are made relative to `root`.
203///
204/// # Errors
205///
206/// Returns an error if the results cannot be serialized to JSON.
207pub fn build_json(
208    results: &AnalysisResults,
209    root: &Path,
210    elapsed: Duration,
211) -> Result<serde_json::Value, serde_json::Error> {
212    let results_value = serde_json::to_value(results)?;
213
214    let mut map = serde_json::Map::new();
215    map.insert(
216        "schema_version".to_string(),
217        serde_json::json!(SCHEMA_VERSION),
218    );
219    map.insert(
220        "version".to_string(),
221        serde_json::json!(env!("CARGO_PKG_VERSION")),
222    );
223    map.insert(
224        "elapsed_ms".to_string(),
225        serde_json::json!(elapsed.as_millis()),
226    );
227    map.insert(
228        "total_issues".to_string(),
229        serde_json::json!(results.total_issues()),
230    );
231
232    // Entry-point detection summary (metadata, not serialized via serde)
233    if let Some(ref ep) = results.entry_point_summary {
234        let sources: serde_json::Map<String, serde_json::Value> = ep
235            .by_source
236            .iter()
237            .map(|(k, v)| (k.replace(' ', "_"), serde_json::json!(v)))
238            .collect();
239        map.insert(
240            "entry_points".to_string(),
241            serde_json::json!({
242                "total": ep.total,
243                "sources": sources,
244            }),
245        );
246    }
247
248    // Per-category summary counts for CI dashboard consumption
249    let summary = serde_json::json!({
250        "total_issues": results.total_issues(),
251        "unused_files": results.unused_files.len(),
252        "unused_exports": results.unused_exports.len(),
253        "unused_types": results.unused_types.len(),
254        "private_type_leaks": results.private_type_leaks.len(),
255        "unused_dependencies": results.unused_dependencies.len()
256            + results.unused_dev_dependencies.len()
257            + results.unused_optional_dependencies.len(),
258        "unused_enum_members": results.unused_enum_members.len(),
259        "unused_class_members": results.unused_class_members.len(),
260        "unresolved_imports": results.unresolved_imports.len(),
261        "unlisted_dependencies": results.unlisted_dependencies.len(),
262        "duplicate_exports": results.duplicate_exports.len(),
263        "type_only_dependencies": results.type_only_dependencies.len(),
264        "test_only_dependencies": results.test_only_dependencies.len(),
265        "circular_dependencies": results.circular_dependencies.len(),
266        "boundary_violations": results.boundary_violations.len(),
267        "stale_suppressions": results.stale_suppressions.len(),
268        "unused_catalog_entries": results.unused_catalog_entries.len(),
269    });
270    map.insert("summary".to_string(), summary);
271
272    if let serde_json::Value::Object(results_map) = results_value {
273        for (key, value) in results_map {
274            map.insert(key, value);
275        }
276    }
277
278    let mut output = serde_json::Value::Object(map);
279    let root_prefix = format!("{}/", root.display());
280    // strip_root_prefix must run before inject_actions so that injected
281    // action fields (static strings and package names) are not processed
282    // by the path stripper.
283    strip_root_prefix(&mut output, &root_prefix);
284    inject_actions(&mut output);
285    harmonize_multi_kind_suppress_line_actions(&mut output);
286    Ok(output)
287}
288
289/// Recursively strip the root prefix from all string values in the JSON tree.
290///
291/// This converts absolute paths (e.g., `/home/runner/work/repo/repo/src/utils.ts`)
292/// to relative paths (`src/utils.ts`) for all output fields.
293pub fn strip_root_prefix(value: &mut serde_json::Value, prefix: &str) {
294    match value {
295        serde_json::Value::String(s) => {
296            if let Some(rest) = s.strip_prefix(prefix) {
297                *s = rest.to_string();
298            } else {
299                let normalized = normalize_uri(s);
300                let normalized_prefix = normalize_uri(prefix);
301                if let Some(rest) = normalized.strip_prefix(&normalized_prefix) {
302                    *s = rest.to_string();
303                }
304            }
305        }
306        serde_json::Value::Array(arr) => {
307            for item in arr {
308                strip_root_prefix(item, prefix);
309            }
310        }
311        serde_json::Value::Object(map) => {
312            for (_, v) in map.iter_mut() {
313                strip_root_prefix(v, prefix);
314            }
315        }
316        _ => {}
317    }
318}
319
320// ── Fix action injection ────────────────────────────────────────
321
322/// Suppress mechanism for an issue type.
323enum SuppressKind {
324    /// `// fallow-ignore-next-line <type>` on the line before.
325    InlineComment,
326    /// `# fallow-ignore-next-line <type>` on the line before (YAML / shell).
327    YamlComment,
328    /// `// fallow-ignore-file <type>` at the top of the file.
329    FileComment,
330    /// Add to `ignoreDependencies` in fallow config.
331    ConfigIgnoreDep,
332}
333
334/// Specification for actions to inject per issue type.
335struct ActionSpec {
336    fix_type: &'static str,
337    auto_fixable: bool,
338    description: &'static str,
339    note: Option<&'static str>,
340    suppress: SuppressKind,
341    issue_kind: &'static str,
342}
343
344/// Map an issue array key to its action specification.
345#[expect(
346    clippy::too_many_lines,
347    reason = "one match arm per issue type keeps the action spec table flat and grep-friendly"
348)]
349fn actions_for_issue_type(key: &str) -> Option<ActionSpec> {
350    match key {
351        "unused_files" => Some(ActionSpec {
352            fix_type: "delete-file",
353            auto_fixable: false,
354            description: "Delete this file",
355            note: Some(
356                "File deletion may remove runtime functionality not visible to static analysis",
357            ),
358            suppress: SuppressKind::FileComment,
359            issue_kind: "unused-file",
360        }),
361        "unused_exports" => Some(ActionSpec {
362            fix_type: "remove-export",
363            auto_fixable: true,
364            description: "Remove the unused export from the public API",
365            note: None,
366            suppress: SuppressKind::InlineComment,
367            issue_kind: "unused-export",
368        }),
369        "unused_types" => Some(ActionSpec {
370            fix_type: "remove-export",
371            auto_fixable: true,
372            description: "Remove the `export` (or `export type`) keyword from the type declaration",
373            note: None,
374            suppress: SuppressKind::InlineComment,
375            issue_kind: "unused-type",
376        }),
377        "private_type_leaks" => Some(ActionSpec {
378            fix_type: "export-type",
379            auto_fixable: false,
380            description: "Export the referenced private type by name",
381            note: Some("Keep the type exported while it is part of a public signature"),
382            suppress: SuppressKind::InlineComment,
383            issue_kind: "private-type-leak",
384        }),
385        "unused_dependencies" => Some(ActionSpec {
386            fix_type: "remove-dependency",
387            auto_fixable: true,
388            description: "Remove from dependencies in package.json",
389            note: None,
390            suppress: SuppressKind::ConfigIgnoreDep,
391            issue_kind: "unused-dependency",
392        }),
393        "unused_dev_dependencies" => Some(ActionSpec {
394            fix_type: "remove-dependency",
395            auto_fixable: true,
396            description: "Remove from devDependencies in package.json",
397            note: None,
398            suppress: SuppressKind::ConfigIgnoreDep,
399            issue_kind: "unused-dev-dependency",
400        }),
401        "unused_optional_dependencies" => Some(ActionSpec {
402            fix_type: "remove-dependency",
403            auto_fixable: true,
404            description: "Remove from optionalDependencies in package.json",
405            note: None,
406            suppress: SuppressKind::ConfigIgnoreDep,
407            // No IssueKind variant exists for optional deps — uses config suppress only.
408            issue_kind: "unused-dependency",
409        }),
410        "unused_enum_members" => Some(ActionSpec {
411            fix_type: "remove-enum-member",
412            auto_fixable: true,
413            description: "Remove this enum member",
414            note: None,
415            suppress: SuppressKind::InlineComment,
416            issue_kind: "unused-enum-member",
417        }),
418        "unused_class_members" => Some(ActionSpec {
419            fix_type: "remove-class-member",
420            auto_fixable: false,
421            description: "Remove this class member",
422            note: Some("Class member may be used via dependency injection or decorators"),
423            suppress: SuppressKind::InlineComment,
424            issue_kind: "unused-class-member",
425        }),
426        "unresolved_imports" => Some(ActionSpec {
427            fix_type: "resolve-import",
428            auto_fixable: false,
429            description: "Fix the import specifier or install the missing module",
430            note: Some("Verify the module path and check tsconfig paths configuration"),
431            suppress: SuppressKind::InlineComment,
432            issue_kind: "unresolved-import",
433        }),
434        "unlisted_dependencies" => Some(ActionSpec {
435            fix_type: "install-dependency",
436            auto_fixable: false,
437            description: "Add this package to dependencies in package.json",
438            note: Some("Verify this package should be a direct dependency before adding"),
439            suppress: SuppressKind::ConfigIgnoreDep,
440            issue_kind: "unlisted-dependency",
441        }),
442        "duplicate_exports" => Some(ActionSpec {
443            fix_type: "remove-duplicate",
444            auto_fixable: false,
445            description: "Keep one canonical export location and remove the others",
446            note: Some(NAMESPACE_BARREL_HINT),
447            suppress: SuppressKind::InlineComment,
448            issue_kind: "duplicate-export",
449        }),
450        "type_only_dependencies" => Some(ActionSpec {
451            fix_type: "move-to-dev",
452            auto_fixable: false,
453            description: "Move to devDependencies (only type imports are used)",
454            note: Some(
455                "Type imports are erased at runtime so this dependency is not needed in production",
456            ),
457            suppress: SuppressKind::ConfigIgnoreDep,
458            issue_kind: "type-only-dependency",
459        }),
460        "test_only_dependencies" => Some(ActionSpec {
461            fix_type: "move-to-dev",
462            auto_fixable: false,
463            description: "Move to devDependencies (only test files import this)",
464            note: Some(
465                "Only test files import this package so it does not need to be a production dependency",
466            ),
467            suppress: SuppressKind::ConfigIgnoreDep,
468            issue_kind: "test-only-dependency",
469        }),
470        "circular_dependencies" => Some(ActionSpec {
471            fix_type: "refactor-cycle",
472            auto_fixable: false,
473            description: "Extract shared logic into a separate module to break the cycle",
474            note: Some(
475                "Circular imports can cause initialization issues and make code harder to reason about",
476            ),
477            suppress: SuppressKind::InlineComment,
478            issue_kind: "circular-dependency",
479        }),
480        "boundary_violations" => Some(ActionSpec {
481            fix_type: "refactor-boundary",
482            auto_fixable: false,
483            description: "Move the import through an allowed zone or restructure the dependency",
484            note: Some(
485                "This import crosses an architecture boundary that is not permitted by the configured rules",
486            ),
487            suppress: SuppressKind::InlineComment,
488            issue_kind: "boundary-violation",
489        }),
490        "unused_catalog_entries" => Some(ActionSpec {
491            fix_type: "remove-catalog-entry",
492            auto_fixable: false,
493            description: "Remove the entry from pnpm-workspace.yaml",
494            note: Some(
495                "If any consumer declares the same package with a hardcoded version, switch the consumer to `catalog:` before removing",
496            ),
497            suppress: SuppressKind::YamlComment,
498            issue_kind: "unused-catalog-entry",
499        }),
500        _ => None,
501    }
502}
503
504/// Build the `actions` array for a single issue item.
505fn build_actions(
506    item: &serde_json::Value,
507    issue_key: &str,
508    spec: &ActionSpec,
509) -> serde_json::Value {
510    let mut actions = Vec::with_capacity(2);
511    let cross_workspace_dependency = is_dependency_issue(issue_key)
512        && item
513            .get("used_in_workspaces")
514            .and_then(serde_json::Value::as_array)
515            .is_some_and(|workspaces| !workspaces.is_empty());
516
517    // duplicate-exports: emit the safe `add-to-config` (ignoreExports) action
518    // FIRST so position-0 (the documented primary slot, see HealthFindingAction
519    // schema description) recommends the non-destructive path for the common
520    // shadcn / Radix / bits-ui namespace-barrel case. The fix action below
521    // (remove-duplicate) stays as the secondary, for findings where the
522    // duplication is genuinely accidental.
523    if issue_key == "duplicate_exports"
524        && let Some(value) = build_duplicate_exports_config_value(item)
525    {
526        actions.push(serde_json::json!({
527            "type": "add-to-config",
528            "auto_fixable": false,
529            "description": "Add an ignoreExports rule so these files are excluded from duplicate-export grouping (use when this duplication is an intentional namespace-barrel API).",
530            "config_key": "ignoreExports",
531            "value": value,
532            "value_schema": IGNORE_EXPORTS_VALUE_SCHEMA,
533        }));
534    }
535
536    // Primary fix action
537    let mut fix_action = if cross_workspace_dependency {
538        serde_json::json!({
539            "type": "move-dependency",
540            "auto_fixable": false,
541            "description": "Move this dependency to the workspace package.json that imports it",
542            "note": "fallow fix will not remove dependencies that are imported by another workspace",
543        })
544    } else {
545        serde_json::json!({
546            "type": spec.fix_type,
547            "auto_fixable": spec.auto_fixable,
548            "description": spec.description,
549        })
550    };
551    if let Some(note) = spec.note {
552        fix_action["note"] = serde_json::json!(note);
553    }
554    // Warn about re-exports that may be part of the public API surface.
555    if (issue_key == "unused_exports" || issue_key == "unused_types")
556        && item
557            .get("is_re_export")
558            .and_then(serde_json::Value::as_bool)
559            == Some(true)
560    {
561        fix_action["note"] = serde_json::json!(
562            "This finding originates from a re-export; verify it is not part of your public API before removing"
563        );
564    }
565    actions.push(fix_action);
566
567    // Suppress action — every action carries `auto_fixable` for uniform filtering.
568    match spec.suppress {
569        SuppressKind::InlineComment => {
570            let mut suppress = serde_json::json!({
571                "type": "suppress-line",
572                "auto_fixable": false,
573                "description": "Suppress with an inline comment above the line",
574                "comment": format!("// fallow-ignore-next-line {}", spec.issue_kind),
575            });
576            // duplicate_exports has N locations, not one — flag multi-location scope.
577            if issue_key == "duplicate_exports" {
578                suppress["scope"] = serde_json::json!("per-location");
579            }
580            actions.push(suppress);
581        }
582        SuppressKind::YamlComment => {
583            actions.push(serde_json::json!({
584                "type": "suppress-line",
585                "auto_fixable": false,
586                "description": "Suppress with a YAML comment above the line",
587                "comment": format!("# fallow-ignore-next-line {}", spec.issue_kind),
588            }));
589        }
590        SuppressKind::FileComment => {
591            actions.push(serde_json::json!({
592                "type": "suppress-file",
593                "auto_fixable": false,
594                "description": "Suppress with a file-level comment at the top of the file",
595                "comment": format!("// fallow-ignore-file {}", spec.issue_kind),
596            }));
597        }
598        SuppressKind::ConfigIgnoreDep => {
599            // Extract the package name from the item for a concrete suggestion.
600            let pkg = item
601                .get("package_name")
602                .and_then(serde_json::Value::as_str)
603                .unwrap_or("package-name");
604            actions.push(serde_json::json!({
605                "type": "add-to-config",
606                "auto_fixable": false,
607                "description": format!("Add \"{pkg}\" to ignoreDependencies in fallow config"),
608                "config_key": "ignoreDependencies",
609                "value": pkg,
610                "value_schema": IGNORE_DEPENDENCIES_VALUE_SCHEMA,
611            }));
612        }
613    }
614
615    serde_json::Value::Array(actions)
616}
617
618/// Build a paste-ready `ignoreExports` config snippet from a duplicate-exports
619/// finding's `locations` array. Returns one `{ file, exports: ["*"] }` entry per
620/// distinct file in the finding, in stable order.
621fn build_duplicate_exports_config_value(item: &serde_json::Value) -> Option<serde_json::Value> {
622    let locations = item.get("locations")?.as_array()?;
623    let mut entries: Vec<serde_json::Value> = Vec::with_capacity(locations.len());
624    let mut seen: rustc_hash::FxHashSet<&str> = rustc_hash::FxHashSet::default();
625    for loc in locations {
626        let Some(path) = loc.get("path").and_then(serde_json::Value::as_str) else {
627            continue;
628        };
629        if !seen.insert(path) {
630            continue;
631        }
632        entries.push(serde_json::json!({
633            "file": path,
634            "exports": ["*"],
635        }));
636    }
637    if entries.is_empty() {
638        return None;
639    }
640    Some(serde_json::Value::Array(entries))
641}
642
643fn is_dependency_issue(issue_key: &str) -> bool {
644    matches!(
645        issue_key,
646        "unused_dependencies" | "unused_dev_dependencies" | "unused_optional_dependencies"
647    )
648}
649
650/// Inject `actions` arrays into every issue item in the JSON output.
651///
652/// Walks each known issue-type array and appends an `actions` field
653/// to every item, providing machine-actionable fix and suppress hints.
654fn inject_actions(output: &mut serde_json::Value) {
655    let Some(map) = output.as_object_mut() else {
656        return;
657    };
658
659    for (key, value) in map.iter_mut() {
660        let Some(spec) = actions_for_issue_type(key) else {
661            continue;
662        };
663        let Some(arr) = value.as_array_mut() else {
664            continue;
665        };
666        for item in arr {
667            let actions = build_actions(item, key, &spec);
668            if let serde_json::Value::Object(obj) = item {
669                obj.insert("actions".to_string(), actions);
670            }
671        }
672    }
673}
674
675type SuppressAnchor = (String, u64);
676
677#[allow(
678    clippy::redundant_pub_crate,
679    reason = "used through report module re-export by audit.rs"
680)]
681pub(crate) fn harmonize_multi_kind_suppress_line_actions(output: &mut serde_json::Value) {
682    let mut anchors: BTreeMap<SuppressAnchor, Vec<String>> = BTreeMap::new();
683    collect_suppress_line_anchors(output, &mut anchors);
684
685    anchors.retain(|_, kinds| {
686        sort_suppression_kinds(kinds);
687        kinds.dedup();
688        kinds.len() > 1
689    });
690    if anchors.is_empty() {
691        return;
692    }
693
694    rewrite_suppress_line_actions(output, &anchors);
695}
696
697fn collect_suppress_line_anchors(
698    value: &serde_json::Value,
699    anchors: &mut BTreeMap<SuppressAnchor, Vec<String>>,
700) {
701    match value {
702        serde_json::Value::Object(map) => {
703            if let Some(anchor) = suppression_anchor(map)
704                && let Some(actions) = map.get("actions").and_then(serde_json::Value::as_array)
705            {
706                for action in actions {
707                    if let Some(comment) = suppress_line_comment(action) {
708                        for kind in parse_suppress_line_comment(comment) {
709                            let kinds = anchors.entry(anchor.clone()).or_default();
710                            if !kinds.iter().any(|existing| existing == &kind) {
711                                kinds.push(kind);
712                            }
713                        }
714                    }
715                }
716            }
717
718            for child in map.values() {
719                collect_suppress_line_anchors(child, anchors);
720            }
721        }
722        serde_json::Value::Array(items) => {
723            for item in items {
724                collect_suppress_line_anchors(item, anchors);
725            }
726        }
727        _ => {}
728    }
729}
730
731fn rewrite_suppress_line_actions(
732    value: &mut serde_json::Value,
733    anchors: &BTreeMap<SuppressAnchor, Vec<String>>,
734) {
735    match value {
736        serde_json::Value::Object(map) => {
737            if let Some(anchor) = suppression_anchor(map)
738                && let Some(kinds) = anchors.get(&anchor)
739            {
740                let comment = format!("// fallow-ignore-next-line {}", kinds.join(", "));
741                if let Some(actions) = map
742                    .get_mut("actions")
743                    .and_then(serde_json::Value::as_array_mut)
744                {
745                    for action in actions {
746                        if suppress_line_comment(action).is_some()
747                            && let serde_json::Value::Object(action_map) = action
748                        {
749                            action_map.insert("comment".to_string(), serde_json::json!(comment));
750                        }
751                    }
752                }
753            }
754
755            for child in map.values_mut() {
756                rewrite_suppress_line_actions(child, anchors);
757            }
758        }
759        serde_json::Value::Array(items) => {
760            for item in items {
761                rewrite_suppress_line_actions(item, anchors);
762            }
763        }
764        _ => {}
765    }
766}
767
768fn suppression_anchor(map: &serde_json::Map<String, serde_json::Value>) -> Option<SuppressAnchor> {
769    let path = map
770        .get("path")
771        .or_else(|| map.get("from_path"))
772        .and_then(serde_json::Value::as_str)?;
773    let line = map.get("line").and_then(serde_json::Value::as_u64)?;
774    Some((path.to_string(), line))
775}
776
777fn suppress_line_comment(action: &serde_json::Value) -> Option<&str> {
778    (action.get("type").and_then(serde_json::Value::as_str) == Some("suppress-line"))
779        .then_some(())
780        .and_then(|()| action.get("comment").and_then(serde_json::Value::as_str))
781}
782
783fn parse_suppress_line_comment(comment: &str) -> Vec<String> {
784    comment
785        .strip_prefix("// fallow-ignore-next-line ")
786        .map(|rest| {
787            rest.split(|c: char| c == ',' || c.is_whitespace())
788                .filter(|token| !token.is_empty())
789                .map(str::to_string)
790                .collect()
791        })
792        .unwrap_or_default()
793}
794
795fn sort_suppression_kinds(kinds: &mut [String]) {
796    kinds.sort_by_key(|kind| suppression_kind_rank(kind));
797}
798
799fn suppression_kind_rank(kind: &str) -> usize {
800    match kind {
801        "unused-file" => 0,
802        "unused-export" => 1,
803        "unused-type" => 2,
804        "private-type-leak" => 3,
805        "unused-enum-member" => 4,
806        "unused-class-member" => 5,
807        "unresolved-import" => 6,
808        "unlisted-dependency" => 7,
809        "duplicate-export" => 8,
810        "circular-dependency" => 9,
811        "boundary-violation" => 10,
812        "code-duplication" => 11,
813        "complexity" => 12,
814        _ => usize::MAX,
815    }
816}
817
818// ── Health action injection ─────────────────────────────────────
819
820/// Build a JSON representation of baseline deltas for the combined JSON envelope.
821///
822/// Accepts a total delta and an iterator of per-category entries to avoid
823/// coupling the report module (compiled in both lib and bin) to the
824/// binary-only `baseline` module.
825pub fn build_baseline_deltas_json<'a>(
826    total_delta: i64,
827    per_category: impl Iterator<Item = (&'a str, usize, usize, i64)>,
828) -> serde_json::Value {
829    let mut per_cat = serde_json::Map::new();
830    for (cat, current, baseline, delta) in per_category {
831        per_cat.insert(
832            cat.to_string(),
833            serde_json::json!({
834                "current": current,
835                "baseline": baseline,
836                "delta": delta,
837            }),
838        );
839    }
840    serde_json::json!({
841        "total_delta": total_delta,
842        "per_category": per_cat
843    })
844}
845
846/// Cyclomatic distance from `max_cyclomatic_threshold` at which a
847/// CRAP-only finding still warrants a secondary `refactor-function` action.
848///
849/// Reasoning: a function whose cyclomatic count is within this band of the
850/// configured threshold is "almost too complex" already, so refactoring is a
851/// useful complement to the primary coverage action. Keeping the boundary
852/// expressed as a band (threshold minus N) rather than a ratio links it
853/// to the existing `health.maxCyclomatic` knob: tightening the threshold
854/// automatically widens the population that gets the secondary suggestion.
855const SECONDARY_REFACTOR_BAND: u16 = 5;
856
857/// Options controlling how `inject_health_actions` populates JSON output.
858///
859/// `omit_suppress_line` skips the `suppress-line` action across every
860/// health finding. Set when:
861/// - A baseline is active (`opts.baseline.is_some()` or
862///   `opts.save_baseline.is_some()`): the baseline file already suppresses
863///   findings, and adding `// fallow-ignore-next-line` comments on top
864///   creates dead annotations once the baseline regenerates.
865/// - The team has opted out via `health.suggestInlineSuppression: false`.
866///
867/// When omitted, a top-level `actions_meta` object on the report records
868/// the omission and the reason so consumers can audit "where did
869/// health finding suppress-line go?" without having to grep the config
870/// or CLI history.
871#[derive(Debug, Clone, Copy, Default)]
872pub struct HealthActionOptions {
873    /// Skip emission of `suppress-line` action entries.
874    pub omit_suppress_line: bool,
875    /// Human-readable reason surfaced in the `actions_meta` breadcrumb when
876    /// `omit_suppress_line` is true. Stable codes:
877    /// - `"baseline-active"`: `--baseline` or `--save-baseline` was passed
878    /// - `"config-disabled"`: `health.suggestInlineSuppression: false`
879    pub omit_reason: Option<&'static str>,
880}
881
882/// Inject `actions` arrays into complexity findings in a health JSON output.
883///
884/// Walks `findings` and `targets` arrays, appending machine-actionable
885/// fix and suppress hints to each item. The `opts` argument controls
886/// whether `suppress-line` actions are emitted; when suppressed, an
887/// `actions_meta` breadcrumb at the report root records the omission.
888#[allow(
889    clippy::redundant_pub_crate,
890    reason = "pub(crate) needed, used by audit.rs via re-export, but not part of public API"
891)]
892pub(crate) fn inject_health_actions(output: &mut serde_json::Value, opts: HealthActionOptions) {
893    let Some(map) = output.as_object_mut() else {
894        return;
895    };
896
897    // The complexity thresholds live on `summary.*_threshold`; read once so
898    // action selection for findings has access without re-walking the envelope.
899    let max_cyclomatic_threshold = map
900        .get("summary")
901        .and_then(|s| s.get("max_cyclomatic_threshold"))
902        .and_then(serde_json::Value::as_u64)
903        .and_then(|v| u16::try_from(v).ok())
904        .unwrap_or(20);
905    let max_cognitive_threshold = map
906        .get("summary")
907        .and_then(|s| s.get("max_cognitive_threshold"))
908        .and_then(serde_json::Value::as_u64)
909        .and_then(|v| u16::try_from(v).ok())
910        .unwrap_or(15);
911    let max_crap_threshold = map
912        .get("summary")
913        .and_then(|s| s.get("max_crap_threshold"))
914        .and_then(serde_json::Value::as_f64)
915        .unwrap_or(30.0);
916
917    // Complexity findings: refactor the function to reduce complexity
918    if let Some(findings) = map.get_mut("findings").and_then(|v| v.as_array_mut()) {
919        for item in findings {
920            let actions = build_health_finding_actions(
921                item,
922                opts,
923                max_cyclomatic_threshold,
924                max_cognitive_threshold,
925                max_crap_threshold,
926            );
927            if let serde_json::Value::Object(obj) = item {
928                obj.insert("actions".to_string(), actions);
929            }
930        }
931    }
932
933    // Refactoring targets: apply the recommended refactoring
934    if let Some(targets) = map.get_mut("targets").and_then(|v| v.as_array_mut()) {
935        for item in targets {
936            let actions = build_refactoring_target_actions(item);
937            if let serde_json::Value::Object(obj) = item {
938                obj.insert("actions".to_string(), actions);
939            }
940        }
941    }
942
943    // Hotspots: files that are both complex and frequently changing
944    if let Some(hotspots) = map.get_mut("hotspots").and_then(|v| v.as_array_mut()) {
945        for item in hotspots {
946            let actions = build_hotspot_actions(item);
947            if let serde_json::Value::Object(obj) = item {
948                obj.insert("actions".to_string(), actions);
949            }
950        }
951    }
952
953    // Coverage gaps: untested files and exports
954    if let Some(gaps) = map.get_mut("coverage_gaps").and_then(|v| v.as_object_mut()) {
955        if let Some(files) = gaps.get_mut("files").and_then(|v| v.as_array_mut()) {
956            for item in files {
957                let actions = build_untested_file_actions(item);
958                if let serde_json::Value::Object(obj) = item {
959                    obj.insert("actions".to_string(), actions);
960                }
961            }
962        }
963        if let Some(exports) = gaps.get_mut("exports").and_then(|v| v.as_array_mut()) {
964            for item in exports {
965                let actions = build_untested_export_actions(item);
966                if let serde_json::Value::Object(obj) = item {
967                    obj.insert("actions".to_string(), actions);
968                }
969            }
970        }
971    }
972
973    // Runtime coverage actions are emitted by the sidecar and serialized
974    // directly via serde (see `RuntimeCoverageAction` in
975    // `crates/cli/src/health_types/runtime_coverage.rs`), so no post-hoc
976    // injection is needed here.
977
978    // Auditable breadcrumb: when the suppress-line hint was omitted, record
979    // it at the report root so consumers don't have to infer the absence.
980    if opts.omit_suppress_line {
981        let reason = opts.omit_reason.unwrap_or("unspecified");
982        map.insert(
983            "actions_meta".to_string(),
984            serde_json::json!({
985                "suppression_hints_omitted": true,
986                "reason": reason,
987                "scope": "health-findings",
988            }),
989        );
990    }
991}
992
993/// Build the `actions` array for a single complexity finding.
994///
995/// The primary action depends on which thresholds were exceeded and the
996/// finding's bucketed coverage tier (`none`/`partial`/`high`):
997///
998/// - Exceeded cyclomatic/cognitive only (no CRAP): `refactor-function`.
999/// - Exceeded CRAP, tier `none` or absent: `add-tests` (no test path
1000///   reaches this function; start from scratch).
1001/// - Exceeded CRAP, tier `partial`: `increase-coverage` (file already has
1002///   some test path; add targeted assertions for uncovered branches).
1003/// - Exceeded CRAP, full coverage can clear CRAP: tier-specific coverage
1004///   action (`add-tests` for `none`, `increase-coverage` for `partial`/
1005///   `high`).
1006/// - Exceeded CRAP, full coverage cannot clear CRAP: `refactor-function`
1007///   because reducing cyclomatic complexity is the remaining lever.
1008/// - Exceeded both CRAP and cyclomatic/cognitive: emit BOTH the
1009///   tier-appropriate coverage action AND `refactor-function`.
1010/// - CRAP-only with cyclomatic close to the threshold (within
1011///   `SECONDARY_REFACTOR_BAND`): also append `refactor-function` as a
1012///   secondary action; the function is "almost too complex" already.
1013///
1014/// `suppress-line` is appended last unless `opts.omit_suppress_line` is
1015/// true (baseline active or `health.suggestInlineSuppression: false`).
1016fn build_health_finding_actions(
1017    item: &serde_json::Value,
1018    opts: HealthActionOptions,
1019    max_cyclomatic_threshold: u16,
1020    max_cognitive_threshold: u16,
1021    max_crap_threshold: f64,
1022) -> serde_json::Value {
1023    let name = item
1024        .get("name")
1025        .and_then(serde_json::Value::as_str)
1026        .unwrap_or("function");
1027    let path = item
1028        .get("path")
1029        .and_then(serde_json::Value::as_str)
1030        .unwrap_or("");
1031    let exceeded = item
1032        .get("exceeded")
1033        .and_then(serde_json::Value::as_str)
1034        .unwrap_or("");
1035    let includes_crap = matches!(
1036        exceeded,
1037        "crap" | "cyclomatic_crap" | "cognitive_crap" | "all"
1038    );
1039    let crap_only = exceeded == "crap";
1040    let tier = item
1041        .get("coverage_tier")
1042        .and_then(serde_json::Value::as_str);
1043    let cyclomatic = item
1044        .get("cyclomatic")
1045        .and_then(serde_json::Value::as_u64)
1046        .and_then(|v| u16::try_from(v).ok())
1047        .unwrap_or(0);
1048    let cognitive = item
1049        .get("cognitive")
1050        .and_then(serde_json::Value::as_u64)
1051        .and_then(|v| u16::try_from(v).ok())
1052        .unwrap_or(0);
1053    let full_coverage_can_clear_crap = !includes_crap || f64::from(cyclomatic) < max_crap_threshold;
1054
1055    let mut actions: Vec<serde_json::Value> = Vec::new();
1056
1057    // Coverage-leaning action: only emitted when CRAP contributed.
1058    if includes_crap {
1059        let coverage_action = build_crap_coverage_action(name, tier, full_coverage_can_clear_crap);
1060        if let Some(action) = coverage_action {
1061            actions.push(action);
1062        }
1063    }
1064
1065    // Refactor action conditions:
1066    //   1. Exceeded cyclomatic/cognitive (with or without CRAP), or
1067    //   2. CRAP-only where even full coverage cannot bring CRAP below the
1068    //      configured threshold, so reducing complexity is the remaining
1069    //      lever), or
1070    //   3. CRAP-only with cyclomatic within SECONDARY_REFACTOR_BAND of the
1071    //      threshold AND cognitive complexity past the cognitive floor (the
1072    //      function is almost too complex anyway and the cognitive signal
1073    //      confirms that refactoring would actually help). Without the
1074    //      cognitive floor, flat type-tag dispatchers and JSX render maps
1075    //      (high CC, near-zero cog) get a misleading refactor suggestion.
1076    //
1077    // `build_crap_coverage_action` returns `None` for case 2 instead of
1078    // pushing `refactor-function` itself, so this branch unconditionally
1079    // pushes the refactor entry without needing to dedupe.
1080    let crap_only_needs_complexity_reduction = crap_only && !full_coverage_can_clear_crap;
1081    let cognitive_floor = max_cognitive_threshold / 2;
1082    let near_cyclomatic_threshold = crap_only
1083        && cyclomatic > 0
1084        && cyclomatic >= max_cyclomatic_threshold.saturating_sub(SECONDARY_REFACTOR_BAND)
1085        && cognitive >= cognitive_floor;
1086    let is_template = name == "<template>";
1087    if !crap_only || crap_only_needs_complexity_reduction || near_cyclomatic_threshold {
1088        let (description, note) = if is_template {
1089            (
1090                format!(
1091                    "Refactor `{name}` to reduce template complexity (simplify control flow and bindings)"
1092                ),
1093                "Consider splitting complex template branches into smaller components or simpler bindings",
1094            )
1095        } else {
1096            (
1097                format!(
1098                    "Refactor `{name}` to reduce complexity (extract helper functions, simplify branching)"
1099                ),
1100                "Consider splitting into smaller functions with single responsibilities",
1101            )
1102        };
1103        actions.push(serde_json::json!({
1104            "type": "refactor-function",
1105            "auto_fixable": false,
1106            "description": description,
1107            "note": note,
1108        }));
1109    }
1110
1111    if !opts.omit_suppress_line {
1112        if is_template
1113            && Path::new(path)
1114                .extension()
1115                .is_some_and(|ext| ext.eq_ignore_ascii_case("html"))
1116        {
1117            actions.push(serde_json::json!({
1118                "type": "suppress-file",
1119                "auto_fixable": false,
1120                "description": "Suppress with an HTML comment at the top of the template",
1121                "comment": "<!-- fallow-ignore-file complexity -->",
1122                "placement": "top-of-template",
1123            }));
1124        } else if is_template {
1125            actions.push(serde_json::json!({
1126                "type": "suppress-line",
1127                "auto_fixable": false,
1128                "description": "Suppress with an inline comment above the Angular decorator",
1129                "comment": "// fallow-ignore-next-line complexity",
1130                "placement": "above-angular-decorator",
1131            }));
1132        } else {
1133            actions.push(serde_json::json!({
1134                "type": "suppress-line",
1135                "auto_fixable": false,
1136                "description": "Suppress with an inline comment above the function declaration",
1137                "comment": "// fallow-ignore-next-line complexity",
1138                "placement": "above-function-declaration",
1139            }));
1140        }
1141    }
1142
1143    serde_json::Value::Array(actions)
1144}
1145
1146/// Build the coverage-leaning action for a CRAP-contributing finding.
1147///
1148/// Returns `None` when even 100% coverage could not bring the function below
1149/// the configured CRAP threshold. In that case the primary action becomes
1150/// `refactor-function`, which the caller emits separately.
1151fn build_crap_coverage_action(
1152    name: &str,
1153    tier: Option<&str>,
1154    full_coverage_can_clear_crap: bool,
1155) -> Option<serde_json::Value> {
1156    if !full_coverage_can_clear_crap {
1157        return None;
1158    }
1159
1160    match tier {
1161        // Partial coverage: the file already has some test path. Pivot
1162        // the action description from "add tests" to "increase coverage"
1163        // so agents add targeted assertions for uncovered branches
1164        // instead of scaffolding new tests from scratch.
1165        Some("partial" | "high") => Some(serde_json::json!({
1166            "type": "increase-coverage",
1167            "auto_fixable": false,
1168            "description": format!("Increase test coverage for `{name}` (file is reachable from existing tests; add targeted assertions for uncovered branches)"),
1169            "note": "CRAP = CC^2 * (1 - cov/100)^3 + CC; targeted branch coverage is more efficient than scaffolding new test files when the file already has coverage",
1170        })),
1171        // None / unknown tier: keep the original "add-tests" message.
1172        _ => Some(serde_json::json!({
1173            "type": "add-tests",
1174            "auto_fixable": false,
1175            "description": format!("Add test coverage for `{name}` to lower its CRAP score (coverage reduces risk even without refactoring)"),
1176            "note": "CRAP = CC^2 * (1 - cov/100)^3 + CC; higher coverage is the fastest way to bring CRAP under threshold",
1177        })),
1178    }
1179}
1180
1181/// Build the `actions` array for a single hotspot entry.
1182fn build_hotspot_actions(item: &serde_json::Value) -> serde_json::Value {
1183    let path = item
1184        .get("path")
1185        .and_then(serde_json::Value::as_str)
1186        .unwrap_or("file");
1187
1188    let mut actions = vec![
1189        serde_json::json!({
1190            "type": "refactor-file",
1191            "auto_fixable": false,
1192            "description": format!("Refactor `{path}`, high complexity combined with frequent changes makes this a maintenance risk"),
1193            "note": "Prioritize extracting complex functions, adding tests, or splitting the module",
1194        }),
1195        serde_json::json!({
1196            "type": "add-tests",
1197            "auto_fixable": false,
1198            "description": format!("Add test coverage for `{path}` to reduce change risk"),
1199            "note": "Frequently changed complex files benefit most from comprehensive test coverage",
1200        }),
1201    ];
1202
1203    if let Some(ownership) = item.get("ownership") {
1204        // Bus factor of 1 is the canonical "single point of failure" signal.
1205        if ownership
1206            .get("bus_factor")
1207            .and_then(serde_json::Value::as_u64)
1208            == Some(1)
1209        {
1210            let top = ownership.get("top_contributor");
1211            let owner = top
1212                .and_then(|t| t.get("identifier"))
1213                .and_then(serde_json::Value::as_str)
1214                .unwrap_or("the sole contributor");
1215            // Soften the note for files with very few commits — calling a
1216            // 3-commit file a "knowledge loss risk" reads as catastrophizing
1217            // for solo maintainers and small teams. Keep the action so
1218            // agents still see the signal, but soften the framing.
1219            let commits = top
1220                .and_then(|t| t.get("commits"))
1221                .and_then(serde_json::Value::as_u64)
1222                .unwrap_or(0);
1223            // File-specific note: name the candidate reviewers from the
1224            // `suggested_reviewers` array when any exist, fall back to
1225            // softened framing for low-commit files, and otherwise omit
1226            // the note entirely (the description already carries the
1227            // actionable ask; adding generic boilerplate wastes tokens).
1228            let suggested: Vec<String> = ownership
1229                .get("suggested_reviewers")
1230                .and_then(serde_json::Value::as_array)
1231                .map(|arr| {
1232                    arr.iter()
1233                        .filter_map(|r| {
1234                            r.get("identifier")
1235                                .and_then(serde_json::Value::as_str)
1236                                .map(String::from)
1237                        })
1238                        .collect()
1239                })
1240                .unwrap_or_default();
1241            let mut low_bus_action = serde_json::json!({
1242                "type": "low-bus-factor",
1243                "auto_fixable": false,
1244                "description": format!(
1245                    "{owner} is the sole recent contributor to `{path}`; adding a second reviewer reduces knowledge-loss risk"
1246                ),
1247            });
1248            if !suggested.is_empty() {
1249                let list = suggested
1250                    .iter()
1251                    .map(|s| format!("@{s}"))
1252                    .collect::<Vec<_>>()
1253                    .join(", ");
1254                low_bus_action["note"] =
1255                    serde_json::Value::String(format!("Candidate reviewers: {list}"));
1256            } else if commits < 5 {
1257                low_bus_action["note"] = serde_json::Value::String(
1258                    "Single recent contributor on a low-commit file. Consider a pair review for major changes."
1259                        .to_string(),
1260                );
1261            }
1262            // else: omit `note` entirely — description already carries the ask.
1263            actions.push(low_bus_action);
1264        }
1265
1266        // Unowned-hotspot: file matches no CODEOWNERS rule. Skip when null
1267        // (no CODEOWNERS file discovered).
1268        if ownership
1269            .get("unowned")
1270            .and_then(serde_json::Value::as_bool)
1271            == Some(true)
1272        {
1273            actions.push(serde_json::json!({
1274                "type": "unowned-hotspot",
1275                "auto_fixable": false,
1276                "description": format!("Add a CODEOWNERS entry for `{path}`"),
1277                "note": "Frequently-changed files without declared owners create review bottlenecks",
1278                "suggested_pattern": suggest_codeowners_pattern(path),
1279                "heuristic": "directory-deepest",
1280            }));
1281        }
1282
1283        // Drift: original author no longer maintains; add a notice action so
1284        // agents can route the next change to the new top contributor.
1285        if ownership.get("drift").and_then(serde_json::Value::as_bool) == Some(true) {
1286            let reason = ownership
1287                .get("drift_reason")
1288                .and_then(serde_json::Value::as_str)
1289                .unwrap_or("ownership has shifted from the original author");
1290            actions.push(serde_json::json!({
1291                "type": "ownership-drift",
1292                "auto_fixable": false,
1293                "description": format!("Update CODEOWNERS for `{path}`: {reason}"),
1294                "note": "Drift suggests the declared or original owner is no longer the right reviewer",
1295            }));
1296        }
1297    }
1298
1299    serde_json::Value::Array(actions)
1300}
1301
1302/// Suggest a CODEOWNERS pattern for an unowned hotspot.
1303///
1304/// Picks the deepest directory containing the file
1305/// (e.g. `src/api/users/handlers.ts` -> `/src/api/users/`) so agents can
1306/// paste a tightly-scoped default. Earlier versions used the first two
1307/// directory levels but that catches too many siblings in monorepos
1308/// (`/src/api/` could span 200 files across 8 sub-domains). The deepest
1309/// directory keeps the suggestion reviewable while still being a directory
1310/// pattern rather than a per-file rule.
1311///
1312/// The action emits this alongside `"heuristic": "directory-deepest"` so
1313/// consumers can branch on the strategy if it evolves.
1314fn suggest_codeowners_pattern(path: &str) -> String {
1315    let normalized = path.replace('\\', "/");
1316    let trimmed = normalized.trim_start_matches('/');
1317    let mut components: Vec<&str> = trimmed.split('/').collect();
1318    components.pop(); // drop the file itself
1319    if components.is_empty() {
1320        return format!("/{trimmed}");
1321    }
1322    format!("/{}/", components.join("/"))
1323}
1324
1325/// Build the `actions` array for a single refactoring target.
1326fn build_refactoring_target_actions(item: &serde_json::Value) -> serde_json::Value {
1327    let recommendation = item
1328        .get("recommendation")
1329        .and_then(serde_json::Value::as_str)
1330        .unwrap_or("Apply the recommended refactoring");
1331
1332    let category = item
1333        .get("category")
1334        .and_then(serde_json::Value::as_str)
1335        .unwrap_or("refactoring");
1336
1337    let mut actions = vec![serde_json::json!({
1338        "type": "apply-refactoring",
1339        "auto_fixable": false,
1340        "description": recommendation,
1341        "category": category,
1342    })];
1343
1344    // Targets with evidence linking to specific functions get a suppress action
1345    if item.get("evidence").is_some() {
1346        actions.push(serde_json::json!({
1347            "type": "suppress-line",
1348            "auto_fixable": false,
1349            "description": "Suppress the underlying complexity finding",
1350            "comment": "// fallow-ignore-next-line complexity",
1351        }));
1352    }
1353
1354    serde_json::Value::Array(actions)
1355}
1356
1357/// Build the `actions` array for an untested file.
1358fn build_untested_file_actions(item: &serde_json::Value) -> serde_json::Value {
1359    let path = item
1360        .get("path")
1361        .and_then(serde_json::Value::as_str)
1362        .unwrap_or("file");
1363
1364    serde_json::Value::Array(vec![
1365        serde_json::json!({
1366            "type": "add-tests",
1367            "auto_fixable": false,
1368            "description": format!("Add test coverage for `{path}`"),
1369            "note": "No test dependency path reaches this runtime file",
1370        }),
1371        serde_json::json!({
1372            "type": "suppress-file",
1373            "auto_fixable": false,
1374            "description": format!("Suppress coverage gap reporting for `{path}`"),
1375            "comment": "// fallow-ignore-file coverage-gaps",
1376        }),
1377    ])
1378}
1379
1380/// Build the `actions` array for an untested export.
1381fn build_untested_export_actions(item: &serde_json::Value) -> serde_json::Value {
1382    let path = item
1383        .get("path")
1384        .and_then(serde_json::Value::as_str)
1385        .unwrap_or("file");
1386    let export_name = item
1387        .get("export_name")
1388        .and_then(serde_json::Value::as_str)
1389        .unwrap_or("export");
1390
1391    serde_json::Value::Array(vec![
1392        serde_json::json!({
1393            "type": "add-test-import",
1394            "auto_fixable": false,
1395            "description": format!("Import and test `{export_name}` from `{path}`"),
1396            "note": "This export is runtime-reachable but no test-reachable module references it",
1397        }),
1398        serde_json::json!({
1399            "type": "suppress-file",
1400            "auto_fixable": false,
1401            "description": format!("Suppress coverage gap reporting for `{path}`"),
1402            "comment": "// fallow-ignore-file coverage-gaps",
1403        }),
1404    ])
1405}
1406
1407// ── Duplication action injection ────────────────────────────────
1408
1409/// Inject `actions` arrays into clone families/groups in a duplication JSON output.
1410///
1411/// Walks `clone_families` and `clone_groups` arrays, appending
1412/// machine-actionable fix and config hints to each item.
1413#[allow(
1414    clippy::redundant_pub_crate,
1415    reason = "pub(crate) needed — used by audit.rs via re-export, but not part of public API"
1416)]
1417pub(crate) fn inject_dupes_actions(output: &mut serde_json::Value) {
1418    let Some(map) = output.as_object_mut() else {
1419        return;
1420    };
1421
1422    // Clone families: extract shared module/function
1423    if let Some(families) = map.get_mut("clone_families").and_then(|v| v.as_array_mut()) {
1424        for item in families {
1425            let actions = build_clone_family_actions(item);
1426            if let serde_json::Value::Object(obj) = item {
1427                obj.insert("actions".to_string(), actions);
1428            }
1429        }
1430    }
1431
1432    // Clone groups: extract shared code
1433    if let Some(groups) = map.get_mut("clone_groups").and_then(|v| v.as_array_mut()) {
1434        for item in groups {
1435            let actions = build_clone_group_actions(item);
1436            if let serde_json::Value::Object(obj) = item {
1437                obj.insert("actions".to_string(), actions);
1438            }
1439        }
1440    }
1441}
1442
1443/// Build the `actions` array for a single clone family.
1444fn build_clone_family_actions(item: &serde_json::Value) -> serde_json::Value {
1445    let group_count = item
1446        .get("groups")
1447        .and_then(|v| v.as_array())
1448        .map_or(0, Vec::len);
1449
1450    let total_lines = item
1451        .get("total_duplicated_lines")
1452        .and_then(serde_json::Value::as_u64)
1453        .unwrap_or(0);
1454
1455    let mut actions = vec![serde_json::json!({
1456        "type": "extract-shared",
1457        "auto_fixable": false,
1458        "description": format!(
1459            "Extract {group_count} duplicated code block{} ({total_lines} lines) into a shared module",
1460            if group_count == 1 { "" } else { "s" }
1461        ),
1462        "note": "These clone groups share the same files, indicating a structural relationship; refactor together",
1463    })];
1464
1465    // Include any refactoring suggestions from the family
1466    if let Some(suggestions) = item.get("suggestions").and_then(|v| v.as_array()) {
1467        for suggestion in suggestions {
1468            if let Some(desc) = suggestion
1469                .get("description")
1470                .and_then(serde_json::Value::as_str)
1471            {
1472                actions.push(serde_json::json!({
1473                    "type": "apply-suggestion",
1474                    "auto_fixable": false,
1475                    "description": desc,
1476                }));
1477            }
1478        }
1479    }
1480
1481    actions.push(serde_json::json!({
1482        "type": "suppress-line",
1483        "auto_fixable": false,
1484        "description": "Suppress with an inline comment above the duplicated code",
1485        "comment": "// fallow-ignore-next-line code-duplication",
1486    }));
1487
1488    serde_json::Value::Array(actions)
1489}
1490
1491/// Build the `actions` array for a single clone group.
1492fn build_clone_group_actions(item: &serde_json::Value) -> serde_json::Value {
1493    let instance_count = item
1494        .get("instances")
1495        .and_then(|v| v.as_array())
1496        .map_or(0, Vec::len);
1497
1498    let line_count = item
1499        .get("line_count")
1500        .and_then(serde_json::Value::as_u64)
1501        .unwrap_or(0);
1502
1503    let actions = vec![
1504        serde_json::json!({
1505            "type": "extract-shared",
1506            "auto_fixable": false,
1507            "description": format!(
1508                "Extract duplicated code ({line_count} lines, {instance_count} instance{}) into a shared function",
1509                if instance_count == 1 { "" } else { "s" }
1510            ),
1511        }),
1512        serde_json::json!({
1513            "type": "suppress-line",
1514            "auto_fixable": false,
1515            "description": "Suppress with an inline comment above the duplicated code",
1516            "comment": "// fallow-ignore-next-line code-duplication",
1517        }),
1518    ];
1519
1520    serde_json::Value::Array(actions)
1521}
1522
1523/// Insert a `_meta` key into a JSON object value.
1524fn insert_meta(output: &mut serde_json::Value, meta: serde_json::Value) {
1525    if let serde_json::Value::Object(map) = output {
1526        map.insert("_meta".to_string(), meta);
1527    }
1528}
1529
1530/// Build the JSON envelope + health payload shared by `print_health_json` and
1531/// the CLI integration test suite. Exposed so snapshot tests can lock the
1532/// on-the-wire shape without routing through stdout capture.
1533///
1534/// # Errors
1535///
1536/// Returns an error if the report cannot be serialized to JSON.
1537pub fn build_health_json(
1538    report: &crate::health_types::HealthReport,
1539    root: &Path,
1540    elapsed: Duration,
1541    explain: bool,
1542    action_opts: HealthActionOptions,
1543) -> Result<serde_json::Value, serde_json::Error> {
1544    let report_value = serde_json::to_value(report)?;
1545    let mut output = build_json_envelope(report_value, elapsed);
1546    let root_prefix = format!("{}/", root.display());
1547    strip_root_prefix(&mut output, &root_prefix);
1548    inject_runtime_coverage_schema_version(&mut output);
1549    inject_health_actions(&mut output, action_opts);
1550    if explain {
1551        insert_meta(&mut output, explain::health_meta());
1552    }
1553    Ok(output)
1554}
1555
1556pub(super) fn print_health_json(
1557    report: &crate::health_types::HealthReport,
1558    root: &Path,
1559    elapsed: Duration,
1560    explain: bool,
1561    action_opts: HealthActionOptions,
1562) -> ExitCode {
1563    match build_health_json(report, root, elapsed, explain, action_opts) {
1564        Ok(output) => emit_json(&output, "JSON"),
1565        Err(e) => {
1566            eprintln!("Error: failed to serialize health report: {e}");
1567            ExitCode::from(2)
1568        }
1569    }
1570}
1571
1572/// Build a grouped health JSON envelope when `--group-by` is active.
1573///
1574/// The envelope keeps the active run's `summary`, `vital_signs`, and
1575/// `health_score` at the top level (so consumers that ignore grouping still
1576/// see meaningful aggregates) and adds:
1577///
1578/// - `grouped_by`: the resolver mode (`"package"`, `"owner"`, etc.).
1579/// - `groups`: one entry per resolver bucket. Each entry carries its own
1580///   `vital_signs`, `health_score`, `findings`, `file_scores`, `hotspots`,
1581///   `large_functions`, `targets`, plus `key`, `owners` (section mode), and
1582///   the per-group `files_analyzed` / `functions_above_threshold` counts.
1583///
1584/// Paths inside groups are relativised the same way as the project-level
1585/// payload.
1586///
1587/// # Errors
1588///
1589/// Returns an error if either the project report or any group cannot be
1590/// serialised to JSON.
1591pub fn build_grouped_health_json(
1592    report: &crate::health_types::HealthReport,
1593    grouping: &crate::health_types::HealthGrouping,
1594    root: &Path,
1595    elapsed: Duration,
1596    explain: bool,
1597    action_opts: HealthActionOptions,
1598) -> Result<serde_json::Value, serde_json::Error> {
1599    let root_prefix = format!("{}/", root.display());
1600    let report_value = serde_json::to_value(report)?;
1601    let mut output = build_json_envelope(report_value, elapsed);
1602    strip_root_prefix(&mut output, &root_prefix);
1603    inject_runtime_coverage_schema_version(&mut output);
1604    inject_health_actions(&mut output, action_opts);
1605
1606    if let serde_json::Value::Object(ref mut map) = output {
1607        map.insert("grouped_by".to_string(), serde_json::json!(grouping.mode));
1608    }
1609
1610    // Per-group sub-envelopes share the project-level suppression state:
1611    // baseline-active and config-disabled apply uniformly, so each group's
1612    // `actions` array honors the same opts AND each group emits its own
1613    // `actions_meta` breadcrumb. The redundancy with the top-level breadcrumb
1614    // is intentional: consumers that only walk the `groups` array (e.g.,
1615    // per-team dashboards) still see the omission reason without needing to
1616    // walk back up to the report root.
1617    let group_values: Vec<serde_json::Value> = grouping
1618        .groups
1619        .iter()
1620        .map(|g| {
1621            let mut value = serde_json::to_value(g)?;
1622            strip_root_prefix(&mut value, &root_prefix);
1623            inject_runtime_coverage_schema_version(&mut value);
1624            inject_health_actions(&mut value, action_opts);
1625            Ok(value)
1626        })
1627        .collect::<Result<_, serde_json::Error>>()?;
1628
1629    if let serde_json::Value::Object(ref mut map) = output {
1630        map.insert("groups".to_string(), serde_json::Value::Array(group_values));
1631    }
1632
1633    if explain {
1634        insert_meta(&mut output, explain::health_meta());
1635    }
1636
1637    Ok(output)
1638}
1639
1640pub(super) fn print_grouped_health_json(
1641    report: &crate::health_types::HealthReport,
1642    grouping: &crate::health_types::HealthGrouping,
1643    root: &Path,
1644    elapsed: Duration,
1645    explain: bool,
1646    action_opts: HealthActionOptions,
1647) -> ExitCode {
1648    match build_grouped_health_json(report, grouping, root, elapsed, explain, action_opts) {
1649        Ok(output) => emit_json(&output, "JSON"),
1650        Err(e) => {
1651            eprintln!("Error: failed to serialize grouped health report: {e}");
1652            ExitCode::from(2)
1653        }
1654    }
1655}
1656
1657/// Build the JSON envelope + duplication payload shared by `print_duplication_json`
1658/// and the programmatic API surface.
1659///
1660/// # Errors
1661///
1662/// Returns an error if the report cannot be serialized to JSON.
1663pub fn build_duplication_json(
1664    report: &DuplicationReport,
1665    root: &Path,
1666    elapsed: Duration,
1667    explain: bool,
1668) -> Result<serde_json::Value, serde_json::Error> {
1669    let report_value = serde_json::to_value(report)?;
1670
1671    let mut output = build_json_envelope(report_value, elapsed);
1672    let root_prefix = format!("{}/", root.display());
1673    strip_root_prefix(&mut output, &root_prefix);
1674    inject_dupes_actions(&mut output);
1675
1676    if explain {
1677        insert_meta(&mut output, explain::dupes_meta());
1678    }
1679
1680    Ok(output)
1681}
1682
1683pub(super) fn print_duplication_json(
1684    report: &DuplicationReport,
1685    root: &Path,
1686    elapsed: Duration,
1687    explain: bool,
1688) -> ExitCode {
1689    match build_duplication_json(report, root, elapsed, explain) {
1690        Ok(output) => emit_json(&output, "JSON"),
1691        Err(e) => {
1692            eprintln!("Error: failed to serialize duplication report: {e}");
1693            ExitCode::from(2)
1694        }
1695    }
1696}
1697
1698/// Build a grouped duplication JSON envelope when `--group-by` is active.
1699///
1700/// The envelope keeps the project-level duplication payload (`stats`,
1701/// `clone_groups`, `clone_families`) at the top level so consumers that ignore
1702/// grouping still see project-wide aggregates, and adds:
1703///
1704/// - `grouped_by`: the resolver mode (`"owner"`, `"directory"`, `"package"`,
1705///   `"section"`).
1706/// - `groups`: one entry per resolver bucket. Each entry carries its own
1707///   per-group `stats` (dedup-aware, computed over the FULL group before
1708///   `--top` truncation), `clone_groups` (each tagged with `primary_owner`
1709///   and per-instance `owner`), and `clone_families`.
1710///
1711/// Paths inside groups are relativised the same way as the project-level
1712/// payload via `strip_root_prefix`.
1713///
1714/// # Errors
1715///
1716/// Returns an error if either the project report or any group cannot be
1717/// serialised to JSON.
1718pub fn build_grouped_duplication_json(
1719    report: &DuplicationReport,
1720    grouping: &super::dupes_grouping::DuplicationGrouping,
1721    root: &Path,
1722    elapsed: Duration,
1723    explain: bool,
1724) -> Result<serde_json::Value, serde_json::Error> {
1725    let report_value = serde_json::to_value(report)?;
1726    let mut output = build_json_envelope(report_value, elapsed);
1727    let root_prefix = format!("{}/", root.display());
1728    strip_root_prefix(&mut output, &root_prefix);
1729    inject_dupes_actions(&mut output);
1730
1731    if let serde_json::Value::Object(ref mut map) = output {
1732        map.insert("grouped_by".to_string(), serde_json::json!(grouping.mode));
1733        // Mirror the grouped check / health envelopes which expose
1734        // `total_issues` so MCP and CI consumers can read the same key
1735        // across all three commands. For dupes the count is total clone
1736        // groups (sum is preserved across grouping; each clone group is
1737        // attributed to exactly one bucket).
1738        map.insert(
1739            "total_issues".to_string(),
1740            serde_json::json!(report.clone_groups.len()),
1741        );
1742    }
1743
1744    let group_values: Vec<serde_json::Value> = grouping
1745        .groups
1746        .iter()
1747        .map(|g| {
1748            let mut value = serde_json::to_value(g)?;
1749            strip_root_prefix(&mut value, &root_prefix);
1750            inject_dupes_actions(&mut value);
1751            Ok(value)
1752        })
1753        .collect::<Result<_, serde_json::Error>>()?;
1754
1755    if let serde_json::Value::Object(ref mut map) = output {
1756        map.insert("groups".to_string(), serde_json::Value::Array(group_values));
1757    }
1758
1759    if explain {
1760        insert_meta(&mut output, explain::dupes_meta());
1761    }
1762
1763    Ok(output)
1764}
1765
1766pub(super) fn print_grouped_duplication_json(
1767    report: &DuplicationReport,
1768    grouping: &super::dupes_grouping::DuplicationGrouping,
1769    root: &Path,
1770    elapsed: Duration,
1771    explain: bool,
1772) -> ExitCode {
1773    match build_grouped_duplication_json(report, grouping, root, elapsed, explain) {
1774        Ok(output) => emit_json(&output, "JSON"),
1775        Err(e) => {
1776            eprintln!("Error: failed to serialize grouped duplication report: {e}");
1777            ExitCode::from(2)
1778        }
1779    }
1780}
1781
1782pub(super) fn print_trace_json<T: serde::Serialize>(value: &T) {
1783    match serde_json::to_string_pretty(value) {
1784        Ok(json) => println!("{json}"),
1785        Err(e) => {
1786            eprintln!("Error: failed to serialize trace output: {e}");
1787            #[expect(
1788                clippy::exit,
1789                reason = "fatal serialization error requires immediate exit"
1790            )]
1791            std::process::exit(2);
1792        }
1793    }
1794}
1795
1796#[cfg(test)]
1797mod tests {
1798    use super::*;
1799    use crate::health_types::{
1800        RuntimeCoverageAction, RuntimeCoverageConfidence, RuntimeCoverageDataSource,
1801        RuntimeCoverageEvidence, RuntimeCoverageFinding, RuntimeCoverageHotPath,
1802        RuntimeCoverageMessage, RuntimeCoverageReport, RuntimeCoverageReportVerdict,
1803        RuntimeCoverageSummary, RuntimeCoverageVerdict, RuntimeCoverageWatermark,
1804    };
1805    use crate::report::test_helpers::sample_results;
1806    use fallow_core::extract::MemberKind;
1807    use fallow_core::results::*;
1808    use std::path::PathBuf;
1809    use std::time::Duration;
1810
1811    #[test]
1812    fn json_output_has_metadata_fields() {
1813        let root = PathBuf::from("/project");
1814        let results = AnalysisResults::default();
1815        let elapsed = Duration::from_millis(123);
1816        let output = build_json(&results, &root, elapsed).expect("should serialize");
1817
1818        assert_eq!(output["schema_version"], 6);
1819        assert!(output["version"].is_string());
1820        assert_eq!(output["elapsed_ms"], 123);
1821        assert_eq!(output["total_issues"], 0);
1822    }
1823
1824    #[test]
1825    fn json_output_includes_issue_arrays() {
1826        let root = PathBuf::from("/project");
1827        let results = sample_results(&root);
1828        let elapsed = Duration::from_millis(50);
1829        let output = build_json(&results, &root, elapsed).expect("should serialize");
1830
1831        assert_eq!(output["unused_files"].as_array().unwrap().len(), 1);
1832        assert_eq!(output["unused_exports"].as_array().unwrap().len(), 1);
1833        assert_eq!(output["unused_types"].as_array().unwrap().len(), 1);
1834        assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 1);
1835        assert_eq!(
1836            output["unused_dev_dependencies"].as_array().unwrap().len(),
1837            1
1838        );
1839        assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 1);
1840        assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 1);
1841        assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 1);
1842        assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 1);
1843        assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 1);
1844        assert_eq!(
1845            output["type_only_dependencies"].as_array().unwrap().len(),
1846            1
1847        );
1848        assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 1);
1849    }
1850
1851    #[test]
1852    fn health_json_includes_runtime_coverage_with_relative_paths_and_actions() {
1853        let root = PathBuf::from("/project");
1854        let report = crate::health_types::HealthReport {
1855            runtime_coverage: Some(RuntimeCoverageReport {
1856                verdict: RuntimeCoverageReportVerdict::ColdCodeDetected,
1857                signals: Vec::new(),
1858                summary: RuntimeCoverageSummary {
1859                    data_source: RuntimeCoverageDataSource::Local,
1860                    last_received_at: None,
1861                    functions_tracked: 3,
1862                    functions_hit: 1,
1863                    functions_unhit: 1,
1864                    functions_untracked: 1,
1865                    coverage_percent: 33.3,
1866                    trace_count: 2_847_291,
1867                    period_days: 30,
1868                    deployments_seen: 14,
1869                    capture_quality: Some(crate::health_types::RuntimeCoverageCaptureQuality {
1870                        window_seconds: 720,
1871                        instances_observed: 1,
1872                        lazy_parse_warning: true,
1873                        untracked_ratio_percent: 42.5,
1874                    }),
1875                },
1876                findings: vec![RuntimeCoverageFinding {
1877                    id: "fallow:prod:deadbeef".to_owned(),
1878                    path: root.join("src/cold.ts"),
1879                    function: "coldPath".to_owned(),
1880                    line: 12,
1881                    verdict: RuntimeCoverageVerdict::ReviewRequired,
1882                    invocations: Some(0),
1883                    confidence: RuntimeCoverageConfidence::Medium,
1884                    evidence: RuntimeCoverageEvidence {
1885                        static_status: "used".to_owned(),
1886                        test_coverage: "not_covered".to_owned(),
1887                        v8_tracking: "tracked".to_owned(),
1888                        untracked_reason: None,
1889                        observation_days: 30,
1890                        deployments_observed: 14,
1891                    },
1892                    actions: vec![RuntimeCoverageAction {
1893                        kind: "review-deletion".to_owned(),
1894                        description: "Tracked in runtime coverage with zero invocations."
1895                            .to_owned(),
1896                        auto_fixable: false,
1897                    }],
1898                }],
1899                hot_paths: vec![RuntimeCoverageHotPath {
1900                    id: "fallow:hot:cafebabe".to_owned(),
1901                    path: root.join("src/hot.ts"),
1902                    function: "hotPath".to_owned(),
1903                    line: 3,
1904                    end_line: 9,
1905                    invocations: 250,
1906                    percentile: 99,
1907                    actions: vec![],
1908                }],
1909                blast_radius: vec![],
1910                importance: vec![],
1911                watermark: Some(RuntimeCoverageWatermark::LicenseExpiredGrace),
1912                warnings: vec![RuntimeCoverageMessage {
1913                    code: "partial-merge".to_owned(),
1914                    message: "Merged coverage omitted one chunk.".to_owned(),
1915                }],
1916            }),
1917            ..Default::default()
1918        };
1919
1920        let report_value = serde_json::to_value(&report).expect("should serialize health report");
1921        let mut output = build_json_envelope(report_value, Duration::from_millis(7));
1922        strip_root_prefix(&mut output, "/project/");
1923        inject_runtime_coverage_schema_version(&mut output);
1924        inject_health_actions(&mut output, HealthActionOptions::default());
1925
1926        assert_eq!(
1927            output["runtime_coverage"]["verdict"],
1928            serde_json::Value::String("cold-code-detected".to_owned())
1929        );
1930        assert_eq!(
1931            output["runtime_coverage"]["schema_version"],
1932            serde_json::Value::String("1".to_owned())
1933        );
1934        assert_eq!(
1935            output["runtime_coverage"]["summary"]["functions_tracked"],
1936            serde_json::Value::from(3)
1937        );
1938        assert_eq!(
1939            output["runtime_coverage"]["summary"]["coverage_percent"],
1940            serde_json::Value::from(33.3)
1941        );
1942        let finding = &output["runtime_coverage"]["findings"][0];
1943        assert_eq!(finding["path"], "src/cold.ts");
1944        assert_eq!(finding["verdict"], "review_required");
1945        assert_eq!(finding["id"], "fallow:prod:deadbeef");
1946        assert_eq!(finding["actions"][0]["type"], "review-deletion");
1947        let hot_path = &output["runtime_coverage"]["hot_paths"][0];
1948        assert_eq!(hot_path["path"], "src/hot.ts");
1949        assert_eq!(hot_path["function"], "hotPath");
1950        assert_eq!(hot_path["percentile"], 99);
1951        assert_eq!(
1952            output["runtime_coverage"]["watermark"],
1953            serde_json::Value::String("license-expired-grace".to_owned())
1954        );
1955        assert_eq!(
1956            output["runtime_coverage"]["warnings"][0]["code"],
1957            serde_json::Value::String("partial-merge".to_owned())
1958        );
1959    }
1960
1961    #[test]
1962    fn json_metadata_fields_appear_first() {
1963        let root = PathBuf::from("/project");
1964        let results = AnalysisResults::default();
1965        let elapsed = Duration::from_millis(0);
1966        let output = build_json(&results, &root, elapsed).expect("should serialize");
1967        let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
1968        assert_eq!(keys[0], "schema_version");
1969        assert_eq!(keys[1], "version");
1970        assert_eq!(keys[2], "elapsed_ms");
1971        assert_eq!(keys[3], "total_issues");
1972    }
1973
1974    #[test]
1975    fn json_total_issues_matches_results() {
1976        let root = PathBuf::from("/project");
1977        let results = sample_results(&root);
1978        let total = results.total_issues();
1979        let elapsed = Duration::from_millis(0);
1980        let output = build_json(&results, &root, elapsed).expect("should serialize");
1981
1982        assert_eq!(output["total_issues"], total);
1983    }
1984
1985    #[test]
1986    fn json_unused_export_contains_expected_fields() {
1987        let root = PathBuf::from("/project");
1988        let mut results = AnalysisResults::default();
1989        results.unused_exports.push(UnusedExport {
1990            path: root.join("src/utils.ts"),
1991            export_name: "helperFn".to_string(),
1992            is_type_only: false,
1993            line: 10,
1994            col: 4,
1995            span_start: 120,
1996            is_re_export: false,
1997        });
1998        let elapsed = Duration::from_millis(0);
1999        let output = build_json(&results, &root, elapsed).expect("should serialize");
2000
2001        let export = &output["unused_exports"][0];
2002        assert_eq!(export["export_name"], "helperFn");
2003        assert_eq!(export["line"], 10);
2004        assert_eq!(export["col"], 4);
2005        assert_eq!(export["is_type_only"], false);
2006        assert_eq!(export["span_start"], 120);
2007        assert_eq!(export["is_re_export"], false);
2008    }
2009
2010    #[test]
2011    fn json_serializes_to_valid_json() {
2012        let root = PathBuf::from("/project");
2013        let results = sample_results(&root);
2014        let elapsed = Duration::from_millis(42);
2015        let output = build_json(&results, &root, elapsed).expect("should serialize");
2016
2017        let json_str = serde_json::to_string_pretty(&output).expect("should stringify");
2018        let reparsed: serde_json::Value =
2019            serde_json::from_str(&json_str).expect("JSON output should be valid JSON");
2020        assert_eq!(reparsed, output);
2021    }
2022
2023    // ── Empty results ───────────────────────────────────────────────
2024
2025    #[test]
2026    fn json_empty_results_produce_valid_structure() {
2027        let root = PathBuf::from("/project");
2028        let results = AnalysisResults::default();
2029        let elapsed = Duration::from_millis(0);
2030        let output = build_json(&results, &root, elapsed).expect("should serialize");
2031
2032        assert_eq!(output["total_issues"], 0);
2033        assert_eq!(output["unused_files"].as_array().unwrap().len(), 0);
2034        assert_eq!(output["unused_exports"].as_array().unwrap().len(), 0);
2035        assert_eq!(output["unused_types"].as_array().unwrap().len(), 0);
2036        assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 0);
2037        assert_eq!(
2038            output["unused_dev_dependencies"].as_array().unwrap().len(),
2039            0
2040        );
2041        assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 0);
2042        assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 0);
2043        assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 0);
2044        assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 0);
2045        assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 0);
2046        assert_eq!(
2047            output["type_only_dependencies"].as_array().unwrap().len(),
2048            0
2049        );
2050        assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 0);
2051    }
2052
2053    #[test]
2054    fn json_empty_results_round_trips_through_string() {
2055        let root = PathBuf::from("/project");
2056        let results = AnalysisResults::default();
2057        let elapsed = Duration::from_millis(0);
2058        let output = build_json(&results, &root, elapsed).expect("should serialize");
2059
2060        let json_str = serde_json::to_string(&output).expect("should stringify");
2061        let reparsed: serde_json::Value =
2062            serde_json::from_str(&json_str).expect("should parse back");
2063        assert_eq!(reparsed["total_issues"], 0);
2064    }
2065
2066    // ── Path stripping ──────────────────────────────────────────────
2067
2068    #[test]
2069    fn json_paths_are_relative_to_root() {
2070        let root = PathBuf::from("/project");
2071        let mut results = AnalysisResults::default();
2072        results.unused_files.push(UnusedFile {
2073            path: root.join("src/deep/nested/file.ts"),
2074        });
2075        let elapsed = Duration::from_millis(0);
2076        let output = build_json(&results, &root, elapsed).expect("should serialize");
2077
2078        let path = output["unused_files"][0]["path"].as_str().unwrap();
2079        assert_eq!(path, "src/deep/nested/file.ts");
2080        assert!(!path.starts_with("/project"));
2081    }
2082
2083    #[test]
2084    fn json_strips_root_from_nested_locations() {
2085        let root = PathBuf::from("/project");
2086        let mut results = AnalysisResults::default();
2087        results.unlisted_dependencies.push(UnlistedDependency {
2088            package_name: "chalk".to_string(),
2089            imported_from: vec![ImportSite {
2090                path: root.join("src/cli.ts"),
2091                line: 2,
2092                col: 0,
2093            }],
2094        });
2095        let elapsed = Duration::from_millis(0);
2096        let output = build_json(&results, &root, elapsed).expect("should serialize");
2097
2098        let site_path = output["unlisted_dependencies"][0]["imported_from"][0]["path"]
2099            .as_str()
2100            .unwrap();
2101        assert_eq!(site_path, "src/cli.ts");
2102    }
2103
2104    #[test]
2105    fn json_strips_root_from_duplicate_export_locations() {
2106        let root = PathBuf::from("/project");
2107        let mut results = AnalysisResults::default();
2108        results.duplicate_exports.push(DuplicateExport {
2109            export_name: "Config".to_string(),
2110            locations: vec![
2111                DuplicateLocation {
2112                    path: root.join("src/config.ts"),
2113                    line: 15,
2114                    col: 0,
2115                },
2116                DuplicateLocation {
2117                    path: root.join("src/types.ts"),
2118                    line: 30,
2119                    col: 0,
2120                },
2121            ],
2122        });
2123        let elapsed = Duration::from_millis(0);
2124        let output = build_json(&results, &root, elapsed).expect("should serialize");
2125
2126        let loc0 = output["duplicate_exports"][0]["locations"][0]["path"]
2127            .as_str()
2128            .unwrap();
2129        let loc1 = output["duplicate_exports"][0]["locations"][1]["path"]
2130            .as_str()
2131            .unwrap();
2132        assert_eq!(loc0, "src/config.ts");
2133        assert_eq!(loc1, "src/types.ts");
2134    }
2135
2136    #[test]
2137    fn json_strips_root_from_circular_dependency_files() {
2138        let root = PathBuf::from("/project");
2139        let mut results = AnalysisResults::default();
2140        results.circular_dependencies.push(CircularDependency {
2141            files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
2142            length: 2,
2143            line: 1,
2144            col: 0,
2145            is_cross_package: false,
2146        });
2147        let elapsed = Duration::from_millis(0);
2148        let output = build_json(&results, &root, elapsed).expect("should serialize");
2149
2150        let files = output["circular_dependencies"][0]["files"]
2151            .as_array()
2152            .unwrap();
2153        assert_eq!(files[0].as_str().unwrap(), "src/a.ts");
2154        assert_eq!(files[1].as_str().unwrap(), "src/b.ts");
2155    }
2156
2157    #[test]
2158    fn json_path_outside_root_not_stripped() {
2159        let root = PathBuf::from("/project");
2160        let mut results = AnalysisResults::default();
2161        results.unused_files.push(UnusedFile {
2162            path: PathBuf::from("/other/project/src/file.ts"),
2163        });
2164        let elapsed = Duration::from_millis(0);
2165        let output = build_json(&results, &root, elapsed).expect("should serialize");
2166
2167        let path = output["unused_files"][0]["path"].as_str().unwrap();
2168        assert!(path.contains("/other/project/"));
2169    }
2170
2171    // ── Individual issue type field verification ────────────────────
2172
2173    #[test]
2174    fn json_unused_file_contains_path() {
2175        let root = PathBuf::from("/project");
2176        let mut results = AnalysisResults::default();
2177        results.unused_files.push(UnusedFile {
2178            path: root.join("src/orphan.ts"),
2179        });
2180        let elapsed = Duration::from_millis(0);
2181        let output = build_json(&results, &root, elapsed).expect("should serialize");
2182
2183        let file = &output["unused_files"][0];
2184        assert_eq!(file["path"], "src/orphan.ts");
2185    }
2186
2187    #[test]
2188    fn json_unused_type_contains_expected_fields() {
2189        let root = PathBuf::from("/project");
2190        let mut results = AnalysisResults::default();
2191        results.unused_types.push(UnusedExport {
2192            path: root.join("src/types.ts"),
2193            export_name: "OldInterface".to_string(),
2194            is_type_only: true,
2195            line: 20,
2196            col: 0,
2197            span_start: 300,
2198            is_re_export: false,
2199        });
2200        let elapsed = Duration::from_millis(0);
2201        let output = build_json(&results, &root, elapsed).expect("should serialize");
2202
2203        let typ = &output["unused_types"][0];
2204        assert_eq!(typ["export_name"], "OldInterface");
2205        assert_eq!(typ["is_type_only"], true);
2206        assert_eq!(typ["line"], 20);
2207        assert_eq!(typ["path"], "src/types.ts");
2208    }
2209
2210    #[test]
2211    fn json_unused_dependency_contains_expected_fields() {
2212        let root = PathBuf::from("/project");
2213        let mut results = AnalysisResults::default();
2214        results.unused_dependencies.push(UnusedDependency {
2215            package_name: "axios".to_string(),
2216            location: DependencyLocation::Dependencies,
2217            path: root.join("package.json"),
2218            line: 10,
2219            used_in_workspaces: Vec::new(),
2220        });
2221        let elapsed = Duration::from_millis(0);
2222        let output = build_json(&results, &root, elapsed).expect("should serialize");
2223
2224        let dep = &output["unused_dependencies"][0];
2225        assert_eq!(dep["package_name"], "axios");
2226        assert_eq!(dep["line"], 10);
2227        assert!(dep.get("used_in_workspaces").is_none());
2228    }
2229
2230    #[test]
2231    fn json_unused_dependency_includes_cross_workspace_context() {
2232        let root = PathBuf::from("/project");
2233        let mut results = AnalysisResults::default();
2234        results.unused_dependencies.push(UnusedDependency {
2235            package_name: "lodash-es".to_string(),
2236            location: DependencyLocation::Dependencies,
2237            path: root.join("packages/shared/package.json"),
2238            line: 6,
2239            used_in_workspaces: vec![root.join("packages/consumer")],
2240        });
2241        let elapsed = Duration::from_millis(0);
2242        let output = build_json(&results, &root, elapsed).expect("should serialize");
2243
2244        let dep = &output["unused_dependencies"][0];
2245        assert_eq!(
2246            dep["used_in_workspaces"],
2247            serde_json::json!(["packages/consumer"])
2248        );
2249    }
2250
2251    #[test]
2252    fn json_unused_dev_dependency_contains_expected_fields() {
2253        let root = PathBuf::from("/project");
2254        let mut results = AnalysisResults::default();
2255        results.unused_dev_dependencies.push(UnusedDependency {
2256            package_name: "vitest".to_string(),
2257            location: DependencyLocation::DevDependencies,
2258            path: root.join("package.json"),
2259            line: 15,
2260            used_in_workspaces: Vec::new(),
2261        });
2262        let elapsed = Duration::from_millis(0);
2263        let output = build_json(&results, &root, elapsed).expect("should serialize");
2264
2265        let dep = &output["unused_dev_dependencies"][0];
2266        assert_eq!(dep["package_name"], "vitest");
2267    }
2268
2269    #[test]
2270    fn json_unused_optional_dependency_contains_expected_fields() {
2271        let root = PathBuf::from("/project");
2272        let mut results = AnalysisResults::default();
2273        results.unused_optional_dependencies.push(UnusedDependency {
2274            package_name: "fsevents".to_string(),
2275            location: DependencyLocation::OptionalDependencies,
2276            path: root.join("package.json"),
2277            line: 12,
2278            used_in_workspaces: Vec::new(),
2279        });
2280        let elapsed = Duration::from_millis(0);
2281        let output = build_json(&results, &root, elapsed).expect("should serialize");
2282
2283        let dep = &output["unused_optional_dependencies"][0];
2284        assert_eq!(dep["package_name"], "fsevents");
2285        assert_eq!(output["total_issues"], 1);
2286    }
2287
2288    #[test]
2289    fn json_unused_enum_member_contains_expected_fields() {
2290        let root = PathBuf::from("/project");
2291        let mut results = AnalysisResults::default();
2292        results.unused_enum_members.push(UnusedMember {
2293            path: root.join("src/enums.ts"),
2294            parent_name: "Color".to_string(),
2295            member_name: "Purple".to_string(),
2296            kind: MemberKind::EnumMember,
2297            line: 5,
2298            col: 2,
2299        });
2300        let elapsed = Duration::from_millis(0);
2301        let output = build_json(&results, &root, elapsed).expect("should serialize");
2302
2303        let member = &output["unused_enum_members"][0];
2304        assert_eq!(member["parent_name"], "Color");
2305        assert_eq!(member["member_name"], "Purple");
2306        assert_eq!(member["line"], 5);
2307        assert_eq!(member["path"], "src/enums.ts");
2308    }
2309
2310    #[test]
2311    fn json_unused_class_member_contains_expected_fields() {
2312        let root = PathBuf::from("/project");
2313        let mut results = AnalysisResults::default();
2314        results.unused_class_members.push(UnusedMember {
2315            path: root.join("src/api.ts"),
2316            parent_name: "ApiClient".to_string(),
2317            member_name: "deprecatedFetch".to_string(),
2318            kind: MemberKind::ClassMethod,
2319            line: 100,
2320            col: 4,
2321        });
2322        let elapsed = Duration::from_millis(0);
2323        let output = build_json(&results, &root, elapsed).expect("should serialize");
2324
2325        let member = &output["unused_class_members"][0];
2326        assert_eq!(member["parent_name"], "ApiClient");
2327        assert_eq!(member["member_name"], "deprecatedFetch");
2328        assert_eq!(member["line"], 100);
2329    }
2330
2331    #[test]
2332    fn json_unresolved_import_contains_expected_fields() {
2333        let root = PathBuf::from("/project");
2334        let mut results = AnalysisResults::default();
2335        results.unresolved_imports.push(UnresolvedImport {
2336            path: root.join("src/app.ts"),
2337            specifier: "@acme/missing-pkg".to_string(),
2338            line: 7,
2339            col: 0,
2340            specifier_col: 0,
2341        });
2342        let elapsed = Duration::from_millis(0);
2343        let output = build_json(&results, &root, elapsed).expect("should serialize");
2344
2345        let import = &output["unresolved_imports"][0];
2346        assert_eq!(import["specifier"], "@acme/missing-pkg");
2347        assert_eq!(import["line"], 7);
2348        assert_eq!(import["path"], "src/app.ts");
2349    }
2350
2351    #[test]
2352    fn json_unlisted_dependency_contains_import_sites() {
2353        let root = PathBuf::from("/project");
2354        let mut results = AnalysisResults::default();
2355        results.unlisted_dependencies.push(UnlistedDependency {
2356            package_name: "dotenv".to_string(),
2357            imported_from: vec![
2358                ImportSite {
2359                    path: root.join("src/config.ts"),
2360                    line: 1,
2361                    col: 0,
2362                },
2363                ImportSite {
2364                    path: root.join("src/server.ts"),
2365                    line: 3,
2366                    col: 0,
2367                },
2368            ],
2369        });
2370        let elapsed = Duration::from_millis(0);
2371        let output = build_json(&results, &root, elapsed).expect("should serialize");
2372
2373        let dep = &output["unlisted_dependencies"][0];
2374        assert_eq!(dep["package_name"], "dotenv");
2375        let sites = dep["imported_from"].as_array().unwrap();
2376        assert_eq!(sites.len(), 2);
2377        assert_eq!(sites[0]["path"], "src/config.ts");
2378        assert_eq!(sites[1]["path"], "src/server.ts");
2379    }
2380
2381    #[test]
2382    fn json_duplicate_export_contains_locations() {
2383        let root = PathBuf::from("/project");
2384        let mut results = AnalysisResults::default();
2385        results.duplicate_exports.push(DuplicateExport {
2386            export_name: "Button".to_string(),
2387            locations: vec![
2388                DuplicateLocation {
2389                    path: root.join("src/ui.ts"),
2390                    line: 10,
2391                    col: 0,
2392                },
2393                DuplicateLocation {
2394                    path: root.join("src/components.ts"),
2395                    line: 25,
2396                    col: 0,
2397                },
2398            ],
2399        });
2400        let elapsed = Duration::from_millis(0);
2401        let output = build_json(&results, &root, elapsed).expect("should serialize");
2402
2403        let dup = &output["duplicate_exports"][0];
2404        assert_eq!(dup["export_name"], "Button");
2405        let locs = dup["locations"].as_array().unwrap();
2406        assert_eq!(locs.len(), 2);
2407        assert_eq!(locs[0]["line"], 10);
2408        assert_eq!(locs[1]["line"], 25);
2409    }
2410
2411    #[test]
2412    fn json_type_only_dependency_contains_expected_fields() {
2413        let root = PathBuf::from("/project");
2414        let mut results = AnalysisResults::default();
2415        results.type_only_dependencies.push(TypeOnlyDependency {
2416            package_name: "zod".to_string(),
2417            path: root.join("package.json"),
2418            line: 8,
2419        });
2420        let elapsed = Duration::from_millis(0);
2421        let output = build_json(&results, &root, elapsed).expect("should serialize");
2422
2423        let dep = &output["type_only_dependencies"][0];
2424        assert_eq!(dep["package_name"], "zod");
2425        assert_eq!(dep["line"], 8);
2426    }
2427
2428    #[test]
2429    fn json_circular_dependency_contains_expected_fields() {
2430        let root = PathBuf::from("/project");
2431        let mut results = AnalysisResults::default();
2432        results.circular_dependencies.push(CircularDependency {
2433            files: vec![
2434                root.join("src/a.ts"),
2435                root.join("src/b.ts"),
2436                root.join("src/c.ts"),
2437            ],
2438            length: 3,
2439            line: 5,
2440            col: 0,
2441            is_cross_package: false,
2442        });
2443        let elapsed = Duration::from_millis(0);
2444        let output = build_json(&results, &root, elapsed).expect("should serialize");
2445
2446        let cycle = &output["circular_dependencies"][0];
2447        assert_eq!(cycle["length"], 3);
2448        assert_eq!(cycle["line"], 5);
2449        let files = cycle["files"].as_array().unwrap();
2450        assert_eq!(files.len(), 3);
2451    }
2452
2453    // ── Re-export tagging ───────────────────────────────────────────
2454
2455    #[test]
2456    fn json_re_export_flagged_correctly() {
2457        let root = PathBuf::from("/project");
2458        let mut results = AnalysisResults::default();
2459        results.unused_exports.push(UnusedExport {
2460            path: root.join("src/index.ts"),
2461            export_name: "reExported".to_string(),
2462            is_type_only: false,
2463            line: 1,
2464            col: 0,
2465            span_start: 0,
2466            is_re_export: true,
2467        });
2468        let elapsed = Duration::from_millis(0);
2469        let output = build_json(&results, &root, elapsed).expect("should serialize");
2470
2471        assert_eq!(output["unused_exports"][0]["is_re_export"], true);
2472    }
2473
2474    // ── Schema version stability ────────────────────────────────────
2475
2476    #[test]
2477    fn json_schema_version_is_pinned() {
2478        let root = PathBuf::from("/project");
2479        let results = AnalysisResults::default();
2480        let elapsed = Duration::from_millis(0);
2481        let output = build_json(&results, &root, elapsed).expect("should serialize");
2482
2483        assert_eq!(output["schema_version"], SCHEMA_VERSION);
2484        assert_eq!(output["schema_version"], 6);
2485    }
2486
2487    // ── Version string ──────────────────────────────────────────────
2488
2489    #[test]
2490    fn json_version_matches_cargo_pkg_version() {
2491        let root = PathBuf::from("/project");
2492        let results = AnalysisResults::default();
2493        let elapsed = Duration::from_millis(0);
2494        let output = build_json(&results, &root, elapsed).expect("should serialize");
2495
2496        assert_eq!(output["version"], env!("CARGO_PKG_VERSION"));
2497    }
2498
2499    // ── Elapsed time encoding ───────────────────────────────────────
2500
2501    #[test]
2502    fn json_elapsed_ms_zero_duration() {
2503        let root = PathBuf::from("/project");
2504        let results = AnalysisResults::default();
2505        let output = build_json(&results, &root, Duration::ZERO).expect("should serialize");
2506
2507        assert_eq!(output["elapsed_ms"], 0);
2508    }
2509
2510    #[test]
2511    fn json_elapsed_ms_large_duration() {
2512        let root = PathBuf::from("/project");
2513        let results = AnalysisResults::default();
2514        let elapsed = Duration::from_mins(2);
2515        let output = build_json(&results, &root, elapsed).expect("should serialize");
2516
2517        assert_eq!(output["elapsed_ms"], 120_000);
2518    }
2519
2520    #[test]
2521    fn json_elapsed_ms_sub_millisecond_truncated() {
2522        let root = PathBuf::from("/project");
2523        let results = AnalysisResults::default();
2524        // 500 microseconds = 0 milliseconds (truncated)
2525        let elapsed = Duration::from_micros(500);
2526        let output = build_json(&results, &root, elapsed).expect("should serialize");
2527
2528        assert_eq!(output["elapsed_ms"], 0);
2529    }
2530
2531    // ── Multiple issues of same type ────────────────────────────────
2532
2533    #[test]
2534    fn json_multiple_unused_files() {
2535        let root = PathBuf::from("/project");
2536        let mut results = AnalysisResults::default();
2537        results.unused_files.push(UnusedFile {
2538            path: root.join("src/a.ts"),
2539        });
2540        results.unused_files.push(UnusedFile {
2541            path: root.join("src/b.ts"),
2542        });
2543        results.unused_files.push(UnusedFile {
2544            path: root.join("src/c.ts"),
2545        });
2546        let elapsed = Duration::from_millis(0);
2547        let output = build_json(&results, &root, elapsed).expect("should serialize");
2548
2549        assert_eq!(output["unused_files"].as_array().unwrap().len(), 3);
2550        assert_eq!(output["total_issues"], 3);
2551    }
2552
2553    // ── strip_root_prefix unit tests ────────────────────────────────
2554
2555    #[test]
2556    fn strip_root_prefix_on_string_value() {
2557        let mut value = serde_json::json!("/project/src/file.ts");
2558        strip_root_prefix(&mut value, "/project/");
2559        assert_eq!(value, "src/file.ts");
2560    }
2561
2562    #[test]
2563    fn strip_root_prefix_leaves_non_matching_string() {
2564        let mut value = serde_json::json!("/other/src/file.ts");
2565        strip_root_prefix(&mut value, "/project/");
2566        assert_eq!(value, "/other/src/file.ts");
2567    }
2568
2569    #[test]
2570    fn strip_root_prefix_recurses_into_arrays() {
2571        let mut value = serde_json::json!(["/project/a.ts", "/project/b.ts", "/other/c.ts"]);
2572        strip_root_prefix(&mut value, "/project/");
2573        assert_eq!(value[0], "a.ts");
2574        assert_eq!(value[1], "b.ts");
2575        assert_eq!(value[2], "/other/c.ts");
2576    }
2577
2578    #[test]
2579    fn strip_root_prefix_recurses_into_nested_objects() {
2580        let mut value = serde_json::json!({
2581            "outer": {
2582                "path": "/project/src/nested.ts"
2583            }
2584        });
2585        strip_root_prefix(&mut value, "/project/");
2586        assert_eq!(value["outer"]["path"], "src/nested.ts");
2587    }
2588
2589    #[test]
2590    fn strip_root_prefix_leaves_numbers_and_booleans() {
2591        let mut value = serde_json::json!({
2592            "line": 42,
2593            "is_type_only": false,
2594            "path": "/project/src/file.ts"
2595        });
2596        strip_root_prefix(&mut value, "/project/");
2597        assert_eq!(value["line"], 42);
2598        assert_eq!(value["is_type_only"], false);
2599        assert_eq!(value["path"], "src/file.ts");
2600    }
2601
2602    #[test]
2603    fn strip_root_prefix_normalizes_windows_separators() {
2604        let mut value = serde_json::json!(r"/project\src\file.ts");
2605        strip_root_prefix(&mut value, "/project/");
2606        assert_eq!(value, "src/file.ts");
2607    }
2608
2609    #[test]
2610    fn strip_root_prefix_handles_empty_string_after_strip() {
2611        // Edge case: the string IS the prefix (without trailing content).
2612        // This shouldn't happen in practice but should not panic.
2613        let mut value = serde_json::json!("/project/");
2614        strip_root_prefix(&mut value, "/project/");
2615        assert_eq!(value, "");
2616    }
2617
2618    #[test]
2619    fn strip_root_prefix_deeply_nested_array_of_objects() {
2620        let mut value = serde_json::json!({
2621            "groups": [{
2622                "instances": [{
2623                    "file": "/project/src/a.ts"
2624                }, {
2625                    "file": "/project/src/b.ts"
2626                }]
2627            }]
2628        });
2629        strip_root_prefix(&mut value, "/project/");
2630        assert_eq!(value["groups"][0]["instances"][0]["file"], "src/a.ts");
2631        assert_eq!(value["groups"][0]["instances"][1]["file"], "src/b.ts");
2632    }
2633
2634    // ── Full sample results round-trip ──────────────────────────────
2635
2636    #[test]
2637    fn json_full_sample_results_total_issues_correct() {
2638        let root = PathBuf::from("/project");
2639        let results = sample_results(&root);
2640        let elapsed = Duration::from_millis(100);
2641        let output = build_json(&results, &root, elapsed).expect("should serialize");
2642
2643        // sample_results adds one of each issue type (12 total).
2644        // unused_files + unused_exports + unused_types + unused_dependencies
2645        // + unused_dev_dependencies + unused_enum_members + unused_class_members
2646        // + unresolved_imports + unlisted_dependencies + duplicate_exports
2647        // + type_only_dependencies + circular_dependencies
2648        assert_eq!(output["total_issues"], results.total_issues());
2649    }
2650
2651    #[test]
2652    fn json_full_sample_no_absolute_paths_in_output() {
2653        let root = PathBuf::from("/project");
2654        let results = sample_results(&root);
2655        let elapsed = Duration::from_millis(0);
2656        let output = build_json(&results, &root, elapsed).expect("should serialize");
2657
2658        let json_str = serde_json::to_string(&output).expect("should stringify");
2659        // The root prefix should be stripped from all paths.
2660        assert!(!json_str.contains("/project/src/"));
2661        assert!(!json_str.contains("/project/package.json"));
2662    }
2663
2664    // ── JSON output is deterministic ────────────────────────────────
2665
2666    #[test]
2667    fn json_output_is_deterministic() {
2668        let root = PathBuf::from("/project");
2669        let results = sample_results(&root);
2670        let elapsed = Duration::from_millis(50);
2671
2672        let output1 = build_json(&results, &root, elapsed).expect("first build");
2673        let output2 = build_json(&results, &root, elapsed).expect("second build");
2674
2675        assert_eq!(output1, output2);
2676    }
2677
2678    // ── Metadata not overwritten by results fields ──────────────────
2679
2680    #[test]
2681    fn json_results_fields_do_not_shadow_metadata() {
2682        // Ensure that serialized results don't contain keys like "schema_version"
2683        // that could overwrite the metadata fields we insert first.
2684        let root = PathBuf::from("/project");
2685        let results = AnalysisResults::default();
2686        let elapsed = Duration::from_millis(99);
2687        let output = build_json(&results, &root, elapsed).expect("should serialize");
2688
2689        // Metadata should reflect our explicit values, not anything from AnalysisResults.
2690        assert_eq!(output["schema_version"], 6);
2691        assert_eq!(output["elapsed_ms"], 99);
2692    }
2693
2694    // ── All 14 issue type arrays present ────────────────────────────
2695
2696    #[test]
2697    fn json_all_issue_type_arrays_present_in_empty_results() {
2698        let root = PathBuf::from("/project");
2699        let results = AnalysisResults::default();
2700        let elapsed = Duration::from_millis(0);
2701        let output = build_json(&results, &root, elapsed).expect("should serialize");
2702
2703        let expected_arrays = [
2704            "unused_files",
2705            "unused_exports",
2706            "unused_types",
2707            "unused_dependencies",
2708            "unused_dev_dependencies",
2709            "unused_optional_dependencies",
2710            "unused_enum_members",
2711            "unused_class_members",
2712            "unresolved_imports",
2713            "unlisted_dependencies",
2714            "duplicate_exports",
2715            "type_only_dependencies",
2716            "test_only_dependencies",
2717            "circular_dependencies",
2718        ];
2719        for key in &expected_arrays {
2720            assert!(
2721                output[key].is_array(),
2722                "expected '{key}' to be an array in JSON output"
2723            );
2724        }
2725    }
2726
2727    // ── insert_meta ─────────────────────────────────────────────────
2728
2729    #[test]
2730    fn insert_meta_adds_key_to_object() {
2731        let mut output = serde_json::json!({ "foo": 1 });
2732        let meta = serde_json::json!({ "docs": "https://example.com" });
2733        insert_meta(&mut output, meta.clone());
2734        assert_eq!(output["_meta"], meta);
2735    }
2736
2737    #[test]
2738    fn insert_meta_noop_on_non_object() {
2739        let mut output = serde_json::json!([1, 2, 3]);
2740        let meta = serde_json::json!({ "docs": "https://example.com" });
2741        insert_meta(&mut output, meta);
2742        // Should not panic or add anything
2743        assert!(output.is_array());
2744    }
2745
2746    #[test]
2747    fn insert_meta_overwrites_existing_meta() {
2748        let mut output = serde_json::json!({ "_meta": "old" });
2749        let meta = serde_json::json!({ "new": true });
2750        insert_meta(&mut output, meta.clone());
2751        assert_eq!(output["_meta"], meta);
2752    }
2753
2754    // ── build_json_envelope ─────────────────────────────────────────
2755
2756    #[test]
2757    fn build_json_envelope_has_metadata_fields() {
2758        let report = serde_json::json!({ "findings": [] });
2759        let elapsed = Duration::from_millis(42);
2760        let output = build_json_envelope(report, elapsed);
2761
2762        assert_eq!(output["schema_version"], 6);
2763        assert!(output["version"].is_string());
2764        assert_eq!(output["elapsed_ms"], 42);
2765        assert!(output["findings"].is_array());
2766    }
2767
2768    #[test]
2769    fn build_json_envelope_metadata_appears_first() {
2770        let report = serde_json::json!({ "data": "value" });
2771        let output = build_json_envelope(report, Duration::from_millis(10));
2772
2773        let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
2774        assert_eq!(keys[0], "schema_version");
2775        assert_eq!(keys[1], "version");
2776        assert_eq!(keys[2], "elapsed_ms");
2777    }
2778
2779    #[test]
2780    fn build_json_envelope_non_object_report() {
2781        // If report_value is not an Object, only metadata fields appear
2782        let report = serde_json::json!("not an object");
2783        let output = build_json_envelope(report, Duration::from_millis(0));
2784
2785        let obj = output.as_object().unwrap();
2786        assert_eq!(obj.len(), 3);
2787        assert!(obj.contains_key("schema_version"));
2788        assert!(obj.contains_key("version"));
2789        assert!(obj.contains_key("elapsed_ms"));
2790    }
2791
2792    // ── strip_root_prefix with null value ──
2793
2794    #[test]
2795    fn strip_root_prefix_null_unchanged() {
2796        let mut value = serde_json::Value::Null;
2797        strip_root_prefix(&mut value, "/project/");
2798        assert!(value.is_null());
2799    }
2800
2801    // ── strip_root_prefix with empty string ──
2802
2803    #[test]
2804    fn strip_root_prefix_empty_string() {
2805        let mut value = serde_json::json!("");
2806        strip_root_prefix(&mut value, "/project/");
2807        assert_eq!(value, "");
2808    }
2809
2810    // ── strip_root_prefix on mixed nested structure ──
2811
2812    #[test]
2813    fn strip_root_prefix_mixed_types() {
2814        let mut value = serde_json::json!({
2815            "path": "/project/src/file.ts",
2816            "line": 42,
2817            "flag": true,
2818            "nested": {
2819                "items": ["/project/a.ts", 99, null, "/project/b.ts"],
2820                "deep": { "path": "/project/c.ts" }
2821            }
2822        });
2823        strip_root_prefix(&mut value, "/project/");
2824        assert_eq!(value["path"], "src/file.ts");
2825        assert_eq!(value["line"], 42);
2826        assert_eq!(value["flag"], true);
2827        assert_eq!(value["nested"]["items"][0], "a.ts");
2828        assert_eq!(value["nested"]["items"][1], 99);
2829        assert!(value["nested"]["items"][2].is_null());
2830        assert_eq!(value["nested"]["items"][3], "b.ts");
2831        assert_eq!(value["nested"]["deep"]["path"], "c.ts");
2832    }
2833
2834    // ── JSON with explain meta for check ──
2835
2836    #[test]
2837    fn json_check_meta_integrates_correctly() {
2838        let root = PathBuf::from("/project");
2839        let results = AnalysisResults::default();
2840        let elapsed = Duration::from_millis(0);
2841        let mut output = build_json(&results, &root, elapsed).expect("should serialize");
2842        insert_meta(&mut output, crate::explain::check_meta());
2843
2844        assert!(output["_meta"]["docs"].is_string());
2845        assert!(output["_meta"]["rules"].is_object());
2846    }
2847
2848    // ── JSON unused member kind serialization ──
2849
2850    #[test]
2851    fn json_unused_member_kind_serialized() {
2852        let root = PathBuf::from("/project");
2853        let mut results = AnalysisResults::default();
2854        results.unused_enum_members.push(UnusedMember {
2855            path: root.join("src/enums.ts"),
2856            parent_name: "Color".to_string(),
2857            member_name: "Red".to_string(),
2858            kind: MemberKind::EnumMember,
2859            line: 3,
2860            col: 2,
2861        });
2862        results.unused_class_members.push(UnusedMember {
2863            path: root.join("src/class.ts"),
2864            parent_name: "Foo".to_string(),
2865            member_name: "bar".to_string(),
2866            kind: MemberKind::ClassMethod,
2867            line: 10,
2868            col: 4,
2869        });
2870
2871        let elapsed = Duration::from_millis(0);
2872        let output = build_json(&results, &root, elapsed).expect("should serialize");
2873
2874        let enum_member = &output["unused_enum_members"][0];
2875        assert!(enum_member["kind"].is_string());
2876        let class_member = &output["unused_class_members"][0];
2877        assert!(class_member["kind"].is_string());
2878    }
2879
2880    // ── Actions injection ──────────────────────────────────────────
2881
2882    #[test]
2883    fn json_unused_export_has_actions() {
2884        let root = PathBuf::from("/project");
2885        let mut results = AnalysisResults::default();
2886        results.unused_exports.push(UnusedExport {
2887            path: root.join("src/utils.ts"),
2888            export_name: "helperFn".to_string(),
2889            is_type_only: false,
2890            line: 10,
2891            col: 4,
2892            span_start: 120,
2893            is_re_export: false,
2894        });
2895        let output = build_json(&results, &root, Duration::ZERO).unwrap();
2896
2897        let actions = output["unused_exports"][0]["actions"].as_array().unwrap();
2898        assert_eq!(actions.len(), 2);
2899
2900        // Fix action
2901        assert_eq!(actions[0]["type"], "remove-export");
2902        assert_eq!(actions[0]["auto_fixable"], true);
2903        assert!(actions[0].get("note").is_none());
2904
2905        // Suppress action
2906        assert_eq!(actions[1]["type"], "suppress-line");
2907        assert_eq!(
2908            actions[1]["comment"],
2909            "// fallow-ignore-next-line unused-export"
2910        );
2911    }
2912
2913    #[test]
2914    fn json_same_line_findings_share_multi_kind_suppression_comment() {
2915        let root = PathBuf::from("/project");
2916        let mut results = AnalysisResults::default();
2917        results.unused_exports.push(UnusedExport {
2918            path: root.join("src/api.ts"),
2919            export_name: "helperFn".to_string(),
2920            is_type_only: false,
2921            line: 10,
2922            col: 4,
2923            span_start: 120,
2924            is_re_export: false,
2925        });
2926        results.unused_types.push(UnusedExport {
2927            path: root.join("src/api.ts"),
2928            export_name: "OldType".to_string(),
2929            is_type_only: true,
2930            line: 10,
2931            col: 0,
2932            span_start: 60,
2933            is_re_export: false,
2934        });
2935        let output = build_json(&results, &root, Duration::ZERO).unwrap();
2936
2937        let export_actions = output["unused_exports"][0]["actions"].as_array().unwrap();
2938        let type_actions = output["unused_types"][0]["actions"].as_array().unwrap();
2939        assert_eq!(
2940            export_actions[1]["comment"],
2941            "// fallow-ignore-next-line unused-export, unused-type"
2942        );
2943        assert_eq!(
2944            type_actions[1]["comment"],
2945            "// fallow-ignore-next-line unused-export, unused-type"
2946        );
2947    }
2948
2949    #[test]
2950    fn audit_like_json_shares_suppression_comment_across_dead_code_and_complexity() {
2951        let mut output = serde_json::json!({
2952            "dead_code": {
2953                "unused_exports": [{
2954                    "path": "src/main.ts",
2955                    "line": 1,
2956                    "actions": [
2957                        { "type": "remove-export", "auto_fixable": true },
2958                        {
2959                            "type": "suppress-line",
2960                            "auto_fixable": false,
2961                            "comment": "// fallow-ignore-next-line unused-export"
2962                        }
2963                    ]
2964                }]
2965            },
2966            "complexity": {
2967                "findings": [{
2968                    "path": "src/main.ts",
2969                    "line": 1,
2970                    "actions": [
2971                        { "type": "refactor-function", "auto_fixable": false },
2972                        {
2973                            "type": "suppress-line",
2974                            "auto_fixable": false,
2975                            "comment": "// fallow-ignore-next-line complexity"
2976                        }
2977                    ]
2978                }]
2979            }
2980        });
2981
2982        harmonize_multi_kind_suppress_line_actions(&mut output);
2983
2984        assert_eq!(
2985            output["dead_code"]["unused_exports"][0]["actions"][1]["comment"],
2986            "// fallow-ignore-next-line unused-export, complexity"
2987        );
2988        assert_eq!(
2989            output["complexity"]["findings"][0]["actions"][1]["comment"],
2990            "// fallow-ignore-next-line unused-export, complexity"
2991        );
2992    }
2993
2994    #[test]
2995    fn json_unused_file_has_file_suppress_and_note() {
2996        let root = PathBuf::from("/project");
2997        let mut results = AnalysisResults::default();
2998        results.unused_files.push(UnusedFile {
2999            path: root.join("src/dead.ts"),
3000        });
3001        let output = build_json(&results, &root, Duration::ZERO).unwrap();
3002
3003        let actions = output["unused_files"][0]["actions"].as_array().unwrap();
3004        assert_eq!(actions[0]["type"], "delete-file");
3005        assert_eq!(actions[0]["auto_fixable"], false);
3006        assert!(actions[0]["note"].is_string());
3007        assert_eq!(actions[1]["type"], "suppress-file");
3008        assert_eq!(actions[1]["comment"], "// fallow-ignore-file unused-file");
3009    }
3010
3011    #[test]
3012    fn json_unused_dependency_has_config_suppress_with_package_name() {
3013        let root = PathBuf::from("/project");
3014        let mut results = AnalysisResults::default();
3015        results.unused_dependencies.push(UnusedDependency {
3016            package_name: "lodash".to_string(),
3017            location: DependencyLocation::Dependencies,
3018            path: root.join("package.json"),
3019            line: 5,
3020            used_in_workspaces: Vec::new(),
3021        });
3022        let output = build_json(&results, &root, Duration::ZERO).unwrap();
3023
3024        let actions = output["unused_dependencies"][0]["actions"]
3025            .as_array()
3026            .unwrap();
3027        assert_eq!(actions[0]["type"], "remove-dependency");
3028        assert_eq!(actions[0]["auto_fixable"], true);
3029
3030        // Config suppress includes actual package name
3031        assert_eq!(actions[1]["type"], "add-to-config");
3032        assert_eq!(actions[1]["config_key"], "ignoreDependencies");
3033        assert_eq!(actions[1]["value"], "lodash");
3034    }
3035
3036    #[test]
3037    fn json_cross_workspace_dependency_is_not_auto_fixable() {
3038        let root = PathBuf::from("/project");
3039        let mut results = AnalysisResults::default();
3040        results.unused_dependencies.push(UnusedDependency {
3041            package_name: "lodash-es".to_string(),
3042            location: DependencyLocation::Dependencies,
3043            path: root.join("packages/shared/package.json"),
3044            line: 5,
3045            used_in_workspaces: vec![root.join("packages/consumer")],
3046        });
3047        let output = build_json(&results, &root, Duration::ZERO).unwrap();
3048
3049        let actions = output["unused_dependencies"][0]["actions"]
3050            .as_array()
3051            .unwrap();
3052        assert_eq!(actions[0]["type"], "move-dependency");
3053        assert_eq!(actions[0]["auto_fixable"], false);
3054        assert!(
3055            actions[0]["note"]
3056                .as_str()
3057                .unwrap()
3058                .contains("will not remove")
3059        );
3060        assert_eq!(actions[1]["type"], "add-to-config");
3061    }
3062
3063    #[test]
3064    fn json_empty_results_have_no_actions_in_empty_arrays() {
3065        let root = PathBuf::from("/project");
3066        let results = AnalysisResults::default();
3067        let output = build_json(&results, &root, Duration::ZERO).unwrap();
3068
3069        // Empty arrays should remain empty
3070        assert!(output["unused_exports"].as_array().unwrap().is_empty());
3071        assert!(output["unused_files"].as_array().unwrap().is_empty());
3072    }
3073
3074    #[test]
3075    fn json_all_issue_types_have_actions() {
3076        let root = PathBuf::from("/project");
3077        let results = sample_results(&root);
3078        let output = build_json(&results, &root, Duration::ZERO).unwrap();
3079
3080        let issue_keys = [
3081            "unused_files",
3082            "unused_exports",
3083            "unused_types",
3084            "unused_dependencies",
3085            "unused_dev_dependencies",
3086            "unused_optional_dependencies",
3087            "unused_enum_members",
3088            "unused_class_members",
3089            "unresolved_imports",
3090            "unlisted_dependencies",
3091            "duplicate_exports",
3092            "type_only_dependencies",
3093            "test_only_dependencies",
3094            "circular_dependencies",
3095        ];
3096
3097        for key in &issue_keys {
3098            let arr = output[key].as_array().unwrap();
3099            if !arr.is_empty() {
3100                let actions = arr[0]["actions"].as_array();
3101                assert!(
3102                    actions.is_some() && !actions.unwrap().is_empty(),
3103                    "missing actions for {key}"
3104                );
3105            }
3106        }
3107    }
3108
3109    // ── Health actions injection ───────────────────────────────────
3110
3111    #[test]
3112    fn health_finding_has_actions() {
3113        let mut output = serde_json::json!({
3114            "findings": [{
3115                "path": "src/utils.ts",
3116                "name": "processData",
3117                "line": 10,
3118                "col": 0,
3119                "cyclomatic": 25,
3120                "cognitive": 30,
3121                "line_count": 150,
3122                "exceeded": "both"
3123            }]
3124        });
3125
3126        inject_health_actions(&mut output, HealthActionOptions::default());
3127
3128        let actions = output["findings"][0]["actions"].as_array().unwrap();
3129        assert_eq!(actions.len(), 2);
3130        assert_eq!(actions[0]["type"], "refactor-function");
3131        assert_eq!(actions[0]["auto_fixable"], false);
3132        assert!(
3133            actions[0]["description"]
3134                .as_str()
3135                .unwrap()
3136                .contains("processData")
3137        );
3138        assert_eq!(actions[1]["type"], "suppress-line");
3139        assert_eq!(
3140            actions[1]["comment"],
3141            "// fallow-ignore-next-line complexity"
3142        );
3143    }
3144
3145    #[test]
3146    fn refactoring_target_has_actions() {
3147        let mut output = serde_json::json!({
3148            "targets": [{
3149                "path": "src/big-module.ts",
3150                "priority": 85.0,
3151                "efficiency": 42.5,
3152                "recommendation": "Split module: 12 exports, 4 unused",
3153                "category": "split_high_impact",
3154                "effort": "medium",
3155                "confidence": "high",
3156                "evidence": { "unused_exports": 4 }
3157            }]
3158        });
3159
3160        inject_health_actions(&mut output, HealthActionOptions::default());
3161
3162        let actions = output["targets"][0]["actions"].as_array().unwrap();
3163        assert_eq!(actions.len(), 2);
3164        assert_eq!(actions[0]["type"], "apply-refactoring");
3165        assert_eq!(
3166            actions[0]["description"],
3167            "Split module: 12 exports, 4 unused"
3168        );
3169        assert_eq!(actions[0]["category"], "split_high_impact");
3170        // Target with evidence gets suppress action
3171        assert_eq!(actions[1]["type"], "suppress-line");
3172    }
3173
3174    #[test]
3175    fn refactoring_target_without_evidence_has_no_suppress() {
3176        let mut output = serde_json::json!({
3177            "targets": [{
3178                "path": "src/simple.ts",
3179                "priority": 30.0,
3180                "efficiency": 15.0,
3181                "recommendation": "Consider extracting helper functions",
3182                "category": "extract_complex_functions",
3183                "effort": "small",
3184                "confidence": "medium"
3185            }]
3186        });
3187
3188        inject_health_actions(&mut output, HealthActionOptions::default());
3189
3190        let actions = output["targets"][0]["actions"].as_array().unwrap();
3191        assert_eq!(actions.len(), 1);
3192        assert_eq!(actions[0]["type"], "apply-refactoring");
3193    }
3194
3195    #[test]
3196    fn health_empty_findings_no_actions() {
3197        let mut output = serde_json::json!({
3198            "findings": [],
3199            "targets": []
3200        });
3201
3202        inject_health_actions(&mut output, HealthActionOptions::default());
3203
3204        assert!(output["findings"].as_array().unwrap().is_empty());
3205        assert!(output["targets"].as_array().unwrap().is_empty());
3206    }
3207
3208    #[test]
3209    fn hotspot_has_actions() {
3210        let mut output = serde_json::json!({
3211            "hotspots": [{
3212                "path": "src/utils.ts",
3213                "complexity_score": 45.0,
3214                "churn_score": 12,
3215                "hotspot_score": 540.0
3216            }]
3217        });
3218
3219        inject_health_actions(&mut output, HealthActionOptions::default());
3220
3221        let actions = output["hotspots"][0]["actions"].as_array().unwrap();
3222        assert_eq!(actions.len(), 2);
3223        assert_eq!(actions[0]["type"], "refactor-file");
3224        assert!(
3225            actions[0]["description"]
3226                .as_str()
3227                .unwrap()
3228                .contains("src/utils.ts")
3229        );
3230        assert_eq!(actions[1]["type"], "add-tests");
3231    }
3232
3233    #[test]
3234    fn hotspot_low_bus_factor_emits_action() {
3235        let mut output = serde_json::json!({
3236            "hotspots": [{
3237                "path": "src/api.ts",
3238                "ownership": {
3239                    "bus_factor": 1,
3240                    "contributor_count": 1,
3241                    "top_contributor": {"identifier": "alice@x", "share": 1.0, "stale_days": 5, "commits": 30},
3242                    "unowned": null,
3243                    "drift": false,
3244                }
3245            }]
3246        });
3247
3248        inject_health_actions(&mut output, HealthActionOptions::default());
3249
3250        let actions = output["hotspots"][0]["actions"].as_array().unwrap();
3251        assert!(
3252            actions
3253                .iter()
3254                .filter_map(|a| a["type"].as_str())
3255                .any(|t| t == "low-bus-factor"),
3256            "low-bus-factor action should be present",
3257        );
3258        let bus = actions
3259            .iter()
3260            .find(|a| a["type"] == "low-bus-factor")
3261            .unwrap();
3262        assert!(bus["description"].as_str().unwrap().contains("alice@x"));
3263    }
3264
3265    #[test]
3266    fn hotspot_unowned_emits_action_with_pattern() {
3267        let mut output = serde_json::json!({
3268            "hotspots": [{
3269                "path": "src/api/users.ts",
3270                "ownership": {
3271                    "bus_factor": 2,
3272                    "contributor_count": 4,
3273                    "top_contributor": {"identifier": "alice@x", "share": 0.5, "stale_days": 5, "commits": 10},
3274                    "unowned": true,
3275                    "drift": false,
3276                }
3277            }]
3278        });
3279
3280        inject_health_actions(&mut output, HealthActionOptions::default());
3281
3282        let actions = output["hotspots"][0]["actions"].as_array().unwrap();
3283        let unowned = actions
3284            .iter()
3285            .find(|a| a["type"] == "unowned-hotspot")
3286            .expect("unowned-hotspot action should be present");
3287        // Deepest directory containing the file -> /src/api/
3288        // (file `users.ts` is at depth 2, so the deepest dir is `/src/api/`).
3289        assert_eq!(unowned["suggested_pattern"], "/src/api/");
3290        assert_eq!(unowned["heuristic"], "directory-deepest");
3291    }
3292
3293    #[test]
3294    fn hotspot_unowned_skipped_when_codeowners_missing() {
3295        let mut output = serde_json::json!({
3296            "hotspots": [{
3297                "path": "src/api.ts",
3298                "ownership": {
3299                    "bus_factor": 2,
3300                    "contributor_count": 4,
3301                    "top_contributor": {"identifier": "alice@x", "share": 0.5, "stale_days": 5, "commits": 10},
3302                    "unowned": null,
3303                    "drift": false,
3304                }
3305            }]
3306        });
3307
3308        inject_health_actions(&mut output, HealthActionOptions::default());
3309
3310        let actions = output["hotspots"][0]["actions"].as_array().unwrap();
3311        assert!(
3312            !actions.iter().any(|a| a["type"] == "unowned-hotspot"),
3313            "unowned action must not fire when CODEOWNERS file is absent"
3314        );
3315    }
3316
3317    #[test]
3318    fn hotspot_drift_emits_action() {
3319        let mut output = serde_json::json!({
3320            "hotspots": [{
3321                "path": "src/old.ts",
3322                "ownership": {
3323                    "bus_factor": 1,
3324                    "contributor_count": 2,
3325                    "top_contributor": {"identifier": "bob@x", "share": 0.9, "stale_days": 1, "commits": 18},
3326                    "unowned": null,
3327                    "drift": true,
3328                    "drift_reason": "original author alice@x has 5% share",
3329                }
3330            }]
3331        });
3332
3333        inject_health_actions(&mut output, HealthActionOptions::default());
3334
3335        let actions = output["hotspots"][0]["actions"].as_array().unwrap();
3336        let drift = actions
3337            .iter()
3338            .find(|a| a["type"] == "ownership-drift")
3339            .expect("ownership-drift action should be present");
3340        assert!(drift["description"].as_str().unwrap().contains("alice@x"));
3341    }
3342
3343    // ── suggest_codeowners_pattern ─────────────────────────────────
3344
3345    #[test]
3346    fn codeowners_pattern_uses_deepest_directory() {
3347        // Deepest dir keeps the suggestion tightly-scoped; the prior
3348        // "first two levels" heuristic over-generalized in monorepos.
3349        assert_eq!(
3350            suggest_codeowners_pattern("src/api/users/handlers.ts"),
3351            "/src/api/users/"
3352        );
3353    }
3354
3355    #[test]
3356    fn codeowners_pattern_for_root_file() {
3357        assert_eq!(suggest_codeowners_pattern("README.md"), "/README.md");
3358    }
3359
3360    #[test]
3361    fn codeowners_pattern_normalizes_backslashes() {
3362        assert_eq!(
3363            suggest_codeowners_pattern("src\\api\\users.ts"),
3364            "/src/api/"
3365        );
3366    }
3367
3368    #[test]
3369    fn codeowners_pattern_two_level_path() {
3370        assert_eq!(suggest_codeowners_pattern("src/foo.ts"), "/src/");
3371    }
3372
3373    #[test]
3374    fn health_finding_suppress_has_placement() {
3375        let mut output = serde_json::json!({
3376            "findings": [{
3377                "path": "src/utils.ts",
3378                "name": "processData",
3379                "line": 10,
3380                "col": 0,
3381                "cyclomatic": 25,
3382                "cognitive": 30,
3383                "line_count": 150,
3384                "exceeded": "both"
3385            }]
3386        });
3387
3388        inject_health_actions(&mut output, HealthActionOptions::default());
3389
3390        let suppress = &output["findings"][0]["actions"][1];
3391        assert_eq!(suppress["placement"], "above-function-declaration");
3392    }
3393
3394    #[test]
3395    fn html_template_health_finding_uses_html_suppression() {
3396        let mut output = serde_json::json!({
3397            "findings": [{
3398                "path": "src/app.component.html",
3399                "name": "<template>",
3400                "line": 1,
3401                "col": 0,
3402                "cyclomatic": 25,
3403                "cognitive": 30,
3404                "line_count": 40,
3405                "exceeded": "both"
3406            }]
3407        });
3408
3409        inject_health_actions(&mut output, HealthActionOptions::default());
3410
3411        let suppress = &output["findings"][0]["actions"][1];
3412        assert_eq!(suppress["type"], "suppress-file");
3413        assert_eq!(
3414            suppress["comment"],
3415            "<!-- fallow-ignore-file complexity -->"
3416        );
3417        assert_eq!(suppress["placement"], "top-of-template");
3418    }
3419
3420    #[test]
3421    fn inline_template_health_finding_uses_decorator_suppression() {
3422        let mut output = serde_json::json!({
3423            "findings": [{
3424                "path": "src/app.component.ts",
3425                "name": "<template>",
3426                "line": 5,
3427                "col": 0,
3428                "cyclomatic": 25,
3429                "cognitive": 30,
3430                "line_count": 40,
3431                "exceeded": "both"
3432            }]
3433        });
3434
3435        inject_health_actions(&mut output, HealthActionOptions::default());
3436
3437        let refactor = &output["findings"][0]["actions"][0];
3438        assert_eq!(refactor["type"], "refactor-function");
3439        assert!(
3440            refactor["description"]
3441                .as_str()
3442                .unwrap()
3443                .contains("template complexity")
3444        );
3445        let suppress = &output["findings"][0]["actions"][1];
3446        assert_eq!(suppress["type"], "suppress-line");
3447        assert_eq!(
3448            suppress["description"],
3449            "Suppress with an inline comment above the Angular decorator"
3450        );
3451        assert_eq!(suppress["placement"], "above-angular-decorator");
3452    }
3453
3454    // ── Duplication actions injection ─────────────────────────────
3455
3456    #[test]
3457    fn clone_family_has_actions() {
3458        let mut output = serde_json::json!({
3459            "clone_families": [{
3460                "files": ["src/a.ts", "src/b.ts"],
3461                "groups": [
3462                    { "instances": [{"file": "src/a.ts"}, {"file": "src/b.ts"}], "token_count": 100, "line_count": 20 }
3463                ],
3464                "total_duplicated_lines": 20,
3465                "total_duplicated_tokens": 100,
3466                "suggestions": [
3467                    { "kind": "ExtractFunction", "description": "Extract shared validation logic", "estimated_savings": 15 }
3468                ]
3469            }]
3470        });
3471
3472        inject_dupes_actions(&mut output);
3473
3474        let actions = output["clone_families"][0]["actions"].as_array().unwrap();
3475        assert_eq!(actions.len(), 3);
3476        assert_eq!(actions[0]["type"], "extract-shared");
3477        assert_eq!(actions[0]["auto_fixable"], false);
3478        assert!(
3479            actions[0]["description"]
3480                .as_str()
3481                .unwrap()
3482                .contains("20 lines")
3483        );
3484        // Suggestion forwarded as action
3485        assert_eq!(actions[1]["type"], "apply-suggestion");
3486        assert!(
3487            actions[1]["description"]
3488                .as_str()
3489                .unwrap()
3490                .contains("validation logic")
3491        );
3492        // Suppress action
3493        assert_eq!(actions[2]["type"], "suppress-line");
3494        assert_eq!(
3495            actions[2]["comment"],
3496            "// fallow-ignore-next-line code-duplication"
3497        );
3498    }
3499
3500    #[test]
3501    fn clone_group_has_actions() {
3502        let mut output = serde_json::json!({
3503            "clone_groups": [{
3504                "instances": [
3505                    {"file": "src/a.ts", "start_line": 1, "end_line": 10},
3506                    {"file": "src/b.ts", "start_line": 5, "end_line": 14}
3507                ],
3508                "token_count": 50,
3509                "line_count": 10
3510            }]
3511        });
3512
3513        inject_dupes_actions(&mut output);
3514
3515        let actions = output["clone_groups"][0]["actions"].as_array().unwrap();
3516        assert_eq!(actions.len(), 2);
3517        assert_eq!(actions[0]["type"], "extract-shared");
3518        assert!(
3519            actions[0]["description"]
3520                .as_str()
3521                .unwrap()
3522                .contains("10 lines")
3523        );
3524        assert!(
3525            actions[0]["description"]
3526                .as_str()
3527                .unwrap()
3528                .contains("2 instances")
3529        );
3530        assert_eq!(actions[1]["type"], "suppress-line");
3531    }
3532
3533    #[test]
3534    fn dupes_empty_results_no_actions() {
3535        let mut output = serde_json::json!({
3536            "clone_families": [],
3537            "clone_groups": []
3538        });
3539
3540        inject_dupes_actions(&mut output);
3541
3542        assert!(output["clone_families"].as_array().unwrap().is_empty());
3543        assert!(output["clone_groups"].as_array().unwrap().is_empty());
3544    }
3545
3546    // ── Tier-aware health action emission ──────────────────────────
3547
3548    /// Helper: build a health JSON envelope with a single CRAP-only finding.
3549    /// Default cognitive complexity is 12 (above the cognitive floor at the
3550    /// default `max_cognitive_threshold / 2 = 7.5`); use
3551    /// `crap_only_finding_envelope_with_cognitive` to exercise low-cog cases
3552    /// (flat dispatchers, JSX render maps) where the cognitive floor should
3553    /// suppress the secondary refactor.
3554    fn crap_only_finding_envelope(
3555        coverage_tier: Option<&str>,
3556        cyclomatic: u16,
3557        max_cyclomatic_threshold: u16,
3558    ) -> serde_json::Value {
3559        crap_only_finding_envelope_with_max_crap(
3560            coverage_tier,
3561            cyclomatic,
3562            12,
3563            max_cyclomatic_threshold,
3564            15,
3565            30.0,
3566        )
3567    }
3568
3569    fn crap_only_finding_envelope_with_cognitive(
3570        coverage_tier: Option<&str>,
3571        cyclomatic: u16,
3572        cognitive: u16,
3573        max_cyclomatic_threshold: u16,
3574    ) -> serde_json::Value {
3575        crap_only_finding_envelope_with_max_crap(
3576            coverage_tier,
3577            cyclomatic,
3578            cognitive,
3579            max_cyclomatic_threshold,
3580            15,
3581            30.0,
3582        )
3583    }
3584
3585    fn crap_only_finding_envelope_with_max_crap(
3586        coverage_tier: Option<&str>,
3587        cyclomatic: u16,
3588        cognitive: u16,
3589        max_cyclomatic_threshold: u16,
3590        max_cognitive_threshold: u16,
3591        max_crap_threshold: f64,
3592    ) -> serde_json::Value {
3593        let mut finding = serde_json::json!({
3594            "path": "src/risk.ts",
3595            "name": "computeScore",
3596            "line": 12,
3597            "col": 0,
3598            "cyclomatic": cyclomatic,
3599            "cognitive": cognitive,
3600            "line_count": 40,
3601            "exceeded": "crap",
3602            "crap": 35.5,
3603        });
3604        if let Some(tier) = coverage_tier {
3605            finding["coverage_tier"] = serde_json::Value::String(tier.to_owned());
3606        }
3607        serde_json::json!({
3608            "findings": [finding],
3609            "summary": {
3610                "max_cyclomatic_threshold": max_cyclomatic_threshold,
3611                "max_cognitive_threshold": max_cognitive_threshold,
3612                "max_crap_threshold": max_crap_threshold,
3613            },
3614        })
3615    }
3616
3617    #[test]
3618    fn crap_only_tier_none_emits_add_tests() {
3619        let mut output = crap_only_finding_envelope(Some("none"), 6, 20);
3620        inject_health_actions(&mut output, HealthActionOptions::default());
3621        let actions = output["findings"][0]["actions"].as_array().unwrap();
3622        assert!(
3623            actions.iter().any(|a| a["type"] == "add-tests"),
3624            "tier=none crap-only must emit add-tests, got {actions:?}"
3625        );
3626        assert!(
3627            !actions.iter().any(|a| a["type"] == "increase-coverage"),
3628            "tier=none must not emit increase-coverage"
3629        );
3630    }
3631
3632    #[test]
3633    fn crap_only_tier_partial_emits_increase_coverage() {
3634        let mut output = crap_only_finding_envelope(Some("partial"), 6, 20);
3635        inject_health_actions(&mut output, HealthActionOptions::default());
3636        let actions = output["findings"][0]["actions"].as_array().unwrap();
3637        assert!(
3638            actions.iter().any(|a| a["type"] == "increase-coverage"),
3639            "tier=partial crap-only must emit increase-coverage, got {actions:?}"
3640        );
3641        assert!(
3642            !actions.iter().any(|a| a["type"] == "add-tests"),
3643            "tier=partial must not emit add-tests"
3644        );
3645    }
3646
3647    #[test]
3648    fn crap_only_tier_high_emits_increase_coverage_when_full_coverage_can_clear_crap() {
3649        // CC=20 at 70% coverage has CRAP 30.8, but at 100% coverage CRAP
3650        // falls to 20.0, below the default max_crap_threshold=30. Coverage
3651        // is therefore still a valid remediation even though tier=high.
3652        let mut output = crap_only_finding_envelope(Some("high"), 20, 30);
3653        inject_health_actions(&mut output, HealthActionOptions::default());
3654        let actions = output["findings"][0]["actions"].as_array().unwrap();
3655        assert!(
3656            actions.iter().any(|a| a["type"] == "increase-coverage"),
3657            "tier=high crap-only must still emit increase-coverage when full coverage can clear CRAP, got {actions:?}"
3658        );
3659        assert!(
3660            !actions.iter().any(|a| a["type"] == "refactor-function"),
3661            "coverage-remediable crap-only findings should not get refactor-function unless near the cyclomatic threshold"
3662        );
3663        assert!(
3664            !actions.iter().any(|a| a["type"] == "add-tests"),
3665            "tier=high must not emit add-tests"
3666        );
3667    }
3668
3669    #[test]
3670    fn crap_only_emits_refactor_when_full_coverage_cannot_clear_crap() {
3671        // At 100% coverage CRAP bottoms out at CC. With CC=35 and a CRAP
3672        // threshold of 30, tests alone can reduce risk but cannot clear the
3673        // finding; the primary action should be complexity reduction.
3674        let mut output =
3675            crap_only_finding_envelope_with_max_crap(Some("high"), 35, 12, 50, 15, 30.0);
3676        inject_health_actions(&mut output, HealthActionOptions::default());
3677        let actions = output["findings"][0]["actions"].as_array().unwrap();
3678        assert!(
3679            actions.iter().any(|a| a["type"] == "refactor-function"),
3680            "full-coverage-impossible CRAP-only finding must emit refactor-function, got {actions:?}"
3681        );
3682        assert!(
3683            !actions.iter().any(|a| a["type"] == "increase-coverage"),
3684            "must not emit increase-coverage when even 100% coverage cannot clear CRAP"
3685        );
3686        assert!(
3687            !actions.iter().any(|a| a["type"] == "add-tests"),
3688            "must not emit add-tests when even 100% coverage cannot clear CRAP"
3689        );
3690    }
3691
3692    #[test]
3693    fn crap_only_high_cc_appends_secondary_refactor() {
3694        // CC=16 with threshold=20 => within SECONDARY_REFACTOR_BAND (5)
3695        // of the threshold; refactor is a useful complement to coverage.
3696        let mut output = crap_only_finding_envelope(Some("none"), 16, 20);
3697        inject_health_actions(&mut output, HealthActionOptions::default());
3698        let actions = output["findings"][0]["actions"].as_array().unwrap();
3699        assert!(
3700            actions.iter().any(|a| a["type"] == "add-tests"),
3701            "near-threshold crap-only still emits the primary tier action"
3702        );
3703        assert!(
3704            actions.iter().any(|a| a["type"] == "refactor-function"),
3705            "near-threshold crap-only must also emit secondary refactor-function"
3706        );
3707    }
3708
3709    #[test]
3710    fn crap_only_far_below_threshold_no_secondary_refactor() {
3711        // CC=6 with threshold=20 => far outside the band; refactor not added.
3712        let mut output = crap_only_finding_envelope(Some("none"), 6, 20);
3713        inject_health_actions(&mut output, HealthActionOptions::default());
3714        let actions = output["findings"][0]["actions"].as_array().unwrap();
3715        assert!(
3716            !actions.iter().any(|a| a["type"] == "refactor-function"),
3717            "low-CC crap-only should not get a secondary refactor-function"
3718        );
3719    }
3720
3721    #[test]
3722    fn crap_only_near_threshold_low_cognitive_no_secondary_refactor() {
3723        // Cognitive floor regression. Real-world example from vrs-portals:
3724        // a flat type-tag dispatcher with CC=17 (within SECONDARY_REFACTOR_BAND
3725        // of the default cyclomatic threshold of 20) but cognitive=2 (a single
3726        // switch, no nesting). Suggesting "extract helpers, simplify branching"
3727        // is wrong-target advice for declarative dispatchers; the cognitive
3728        // floor at `max_cognitive_threshold / 2` (default 7) suppresses the
3729        // secondary refactor in this case while still firing it for genuinely
3730        // tangled functions (CC>=15 + cog>=8) where refactor would help.
3731        let mut output = crap_only_finding_envelope_with_cognitive(Some("none"), 17, 2, 20);
3732        inject_health_actions(&mut output, HealthActionOptions::default());
3733        let actions = output["findings"][0]["actions"].as_array().unwrap();
3734        assert!(
3735            actions.iter().any(|a| a["type"] == "add-tests"),
3736            "primary tier action still emits"
3737        );
3738        assert!(
3739            !actions.iter().any(|a| a["type"] == "refactor-function"),
3740            "near-threshold CC with cognitive below floor must NOT emit secondary refactor (got {actions:?})"
3741        );
3742    }
3743
3744    #[test]
3745    fn crap_only_near_threshold_high_cognitive_emits_secondary_refactor() {
3746        // Companion to the cognitive-floor regression: when cognitive is at or
3747        // above the floor, the secondary refactor should still fire. CC=16
3748        // and cognitive=10 (above default floor of 7) is the canonical
3749        // "tangled but near-threshold" function that genuinely benefits from
3750        // both coverage AND refactoring.
3751        let mut output = crap_only_finding_envelope_with_cognitive(Some("none"), 16, 10, 20);
3752        inject_health_actions(&mut output, HealthActionOptions::default());
3753        let actions = output["findings"][0]["actions"].as_array().unwrap();
3754        assert!(
3755            actions.iter().any(|a| a["type"] == "add-tests"),
3756            "primary tier action still emits"
3757        );
3758        assert!(
3759            actions.iter().any(|a| a["type"] == "refactor-function"),
3760            "near-threshold CC with cognitive above floor must emit secondary refactor (got {actions:?})"
3761        );
3762    }
3763
3764    #[test]
3765    fn cyclomatic_only_emits_only_refactor_function() {
3766        let mut output = serde_json::json!({
3767            "findings": [{
3768                "path": "src/cyclo.ts",
3769                "name": "branchy",
3770                "line": 5,
3771                "col": 0,
3772                "cyclomatic": 25,
3773                "cognitive": 10,
3774                "line_count": 80,
3775                "exceeded": "cyclomatic",
3776            }],
3777            "summary": { "max_cyclomatic_threshold": 20 },
3778        });
3779        inject_health_actions(&mut output, HealthActionOptions::default());
3780        let actions = output["findings"][0]["actions"].as_array().unwrap();
3781        assert!(
3782            actions.iter().any(|a| a["type"] == "refactor-function"),
3783            "non-CRAP findings emit refactor-function"
3784        );
3785        assert!(
3786            !actions.iter().any(|a| a["type"] == "add-tests"),
3787            "non-CRAP findings must not emit add-tests"
3788        );
3789        assert!(
3790            !actions.iter().any(|a| a["type"] == "increase-coverage"),
3791            "non-CRAP findings must not emit increase-coverage"
3792        );
3793    }
3794
3795    // ── Suppress-line gating ──────────────────────────────────────
3796
3797    #[test]
3798    fn suppress_line_omitted_when_baseline_active() {
3799        let mut output = crap_only_finding_envelope(Some("none"), 6, 20);
3800        inject_health_actions(
3801            &mut output,
3802            HealthActionOptions {
3803                omit_suppress_line: true,
3804                omit_reason: Some("baseline-active"),
3805            },
3806        );
3807        let actions = output["findings"][0]["actions"].as_array().unwrap();
3808        assert!(
3809            !actions.iter().any(|a| a["type"] == "suppress-line"),
3810            "baseline-active must not emit suppress-line, got {actions:?}"
3811        );
3812        assert_eq!(
3813            output["actions_meta"]["suppression_hints_omitted"],
3814            serde_json::Value::Bool(true)
3815        );
3816        assert_eq!(output["actions_meta"]["reason"], "baseline-active");
3817        assert_eq!(output["actions_meta"]["scope"], "health-findings");
3818    }
3819
3820    #[test]
3821    fn suppress_line_omitted_when_config_disabled() {
3822        let mut output = crap_only_finding_envelope(Some("none"), 6, 20);
3823        inject_health_actions(
3824            &mut output,
3825            HealthActionOptions {
3826                omit_suppress_line: true,
3827                omit_reason: Some("config-disabled"),
3828            },
3829        );
3830        assert_eq!(output["actions_meta"]["reason"], "config-disabled");
3831    }
3832
3833    #[test]
3834    fn suppress_line_emitted_by_default() {
3835        let mut output = crap_only_finding_envelope(Some("none"), 6, 20);
3836        inject_health_actions(&mut output, HealthActionOptions::default());
3837        let actions = output["findings"][0]["actions"].as_array().unwrap();
3838        assert!(
3839            actions.iter().any(|a| a["type"] == "suppress-line"),
3840            "default opts must emit suppress-line"
3841        );
3842        assert!(
3843            output.get("actions_meta").is_none(),
3844            "actions_meta must be absent when no omission occurred"
3845        );
3846    }
3847
3848    /// Drift guard: every action `type` value emitted by the action builder
3849    /// must appear in `docs/output-schema.json`'s `HealthFindingAction.type`
3850    /// enum. Previously the schema listed only `[refactor-function,
3851    /// suppress-line]` while the code emitted `add-tests` for CRAP findings,
3852    /// silently producing schema-invalid output for any consumer using the
3853    /// schema for validation.
3854    #[test]
3855    fn every_emitted_health_action_type_is_in_schema_enum() {
3856        // Exercise every distinct emission path. The list mirrors the match
3857        // in `build_crap_coverage_action` and the surrounding refactor/
3858        // suppress-line emissions in `build_health_finding_actions`.
3859        let cases = [
3860            // (exceeded, coverage_tier, cyclomatic, max_cyclomatic_threshold)
3861            ("crap", Some("none"), 6_u16, 20_u16),
3862            ("crap", Some("partial"), 6, 20),
3863            ("crap", Some("high"), 12, 20),
3864            ("crap", Some("none"), 16, 20), // near threshold => secondary refactor
3865            ("cyclomatic", None, 25, 20),
3866            ("cognitive_crap", Some("partial"), 6, 20),
3867            ("all", Some("none"), 25, 20),
3868        ];
3869
3870        let mut emitted: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
3871        for (exceeded, tier, cc, max) in cases {
3872            let mut finding = serde_json::json!({
3873                "path": "src/x.ts",
3874                "name": "fn",
3875                "line": 1,
3876                "col": 0,
3877                "cyclomatic": cc,
3878                "cognitive": 5,
3879                "line_count": 10,
3880                "exceeded": exceeded,
3881                "crap": 35.0,
3882            });
3883            if let Some(t) = tier {
3884                finding["coverage_tier"] = serde_json::Value::String(t.to_owned());
3885            }
3886            let mut output = serde_json::json!({
3887                "findings": [finding],
3888                "summary": { "max_cyclomatic_threshold": max },
3889            });
3890            inject_health_actions(&mut output, HealthActionOptions::default());
3891            for action in output["findings"][0]["actions"].as_array().unwrap() {
3892                if let Some(ty) = action["type"].as_str() {
3893                    emitted.insert(ty.to_owned());
3894                }
3895            }
3896        }
3897
3898        // Load the schema enum once.
3899        let schema_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3900            .join("..")
3901            .join("..")
3902            .join("docs")
3903            .join("output-schema.json");
3904        let raw = std::fs::read_to_string(&schema_path)
3905            .expect("docs/output-schema.json must be readable for the drift-guard test");
3906        let schema: serde_json::Value = serde_json::from_str(&raw).expect("schema parses");
3907        let enum_values: std::collections::BTreeSet<String> =
3908            schema["definitions"]["HealthFindingAction"]["properties"]["type"]["enum"]
3909                .as_array()
3910                .expect("HealthFindingAction.type.enum is an array")
3911                .iter()
3912                .filter_map(|v| v.as_str().map(str::to_owned))
3913                .collect();
3914
3915        for ty in &emitted {
3916            assert!(
3917                enum_values.contains(ty),
3918                "build_health_finding_actions emitted action type `{ty}` but \
3919                 docs/output-schema.json HealthFindingAction.type enum does \
3920                 not list it. Add it to the schema (and any downstream \
3921                 typed consumers) when introducing a new action type."
3922            );
3923        }
3924    }
3925}