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