Skip to main content

fallow_cli/report/
json.rs

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