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