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