Skip to main content

fallow_cli/report/
json.rs

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