Skip to main content

fallow_cli/report/
json.rs

1use std::path::Path;
2use std::process::ExitCode;
3use std::time::Duration;
4
5use fallow_core::duplicates::DuplicationReport;
6use fallow_core::results::AnalysisResults;
7
8use super::{emit_json, normalize_uri};
9use crate::explain;
10use crate::report::grouping::{OwnershipResolver, ResultGroup};
11
12pub(super) fn print_json(
13    results: &AnalysisResults,
14    root: &Path,
15    elapsed: Duration,
16    explain: bool,
17    regression: Option<&crate::regression::RegressionOutcome>,
18    baseline_matched: Option<(usize, usize)>,
19) -> ExitCode {
20    match build_json(results, root, elapsed) {
21        Ok(mut output) => {
22            if let Some(outcome) = regression
23                && let serde_json::Value::Object(ref mut map) = output
24            {
25                map.insert("regression".to_string(), outcome.to_json());
26            }
27            if let Some((entries, matched)) = baseline_matched
28                && let serde_json::Value::Object(ref mut map) = output
29            {
30                map.insert(
31                    "baseline".to_string(),
32                    serde_json::json!({
33                        "entries": entries,
34                        "matched": matched,
35                    }),
36                );
37            }
38            if explain {
39                insert_meta(&mut output, explain::check_meta());
40            }
41            emit_json(&output, "JSON")
42        }
43        Err(e) => {
44            eprintln!("Error: failed to serialize results: {e}");
45            ExitCode::from(2)
46        }
47    }
48}
49
50/// Render grouped analysis results as a single JSON document.
51///
52/// Produces an envelope with `grouped_by` and `total_issues` at the top level,
53/// then a `groups` array where each element contains the group `key`,
54/// `total_issues`, and all the normal result fields with paths relativized.
55#[must_use]
56pub(super) fn print_grouped_json(
57    groups: &[ResultGroup],
58    original: &AnalysisResults,
59    root: &Path,
60    elapsed: Duration,
61    explain: bool,
62    resolver: &OwnershipResolver,
63) -> ExitCode {
64    let root_prefix = format!("{}/", root.display());
65
66    let group_values: Vec<serde_json::Value> = groups
67        .iter()
68        .filter_map(|group| {
69            let mut value = serde_json::to_value(&group.results).ok()?;
70            strip_root_prefix(&mut value, &root_prefix);
71            inject_actions(&mut value);
72
73            if let serde_json::Value::Object(ref mut map) = value {
74                // Insert key, owners (section mode), and total_issues at the
75                // front by rebuilding the map.
76                let mut ordered = serde_json::Map::new();
77                ordered.insert("key".to_string(), serde_json::json!(group.key));
78                if let Some(ref owners) = group.owners {
79                    ordered.insert("owners".to_string(), serde_json::json!(owners));
80                }
81                ordered.insert(
82                    "total_issues".to_string(),
83                    serde_json::json!(group.results.total_issues()),
84                );
85                for (k, v) in map.iter() {
86                    ordered.insert(k.clone(), v.clone());
87                }
88                Some(serde_json::Value::Object(ordered))
89            } else {
90                Some(value)
91            }
92        })
93        .collect();
94
95    let mut output = serde_json::json!({
96        "schema_version": SCHEMA_VERSION,
97        "version": env!("CARGO_PKG_VERSION"),
98        "elapsed_ms": elapsed.as_millis() as u64,
99        "grouped_by": resolver.mode_label(),
100        "total_issues": original.total_issues(),
101        "groups": group_values,
102    });
103
104    if explain {
105        insert_meta(&mut output, explain::check_meta());
106    }
107
108    emit_json(&output, "JSON")
109}
110
111/// JSON output schema version as an integer (independent of tool version).
112///
113/// Bump this when the structure of the JSON output changes in a
114/// backwards-incompatible way (removing/renaming fields, changing types).
115/// Adding new fields is always backwards-compatible and does not require a bump.
116const SCHEMA_VERSION: u32 = 4;
117
118/// Build a JSON envelope with standard metadata fields at the top.
119///
120/// Creates a JSON object with `schema_version`, `version`, and `elapsed_ms`,
121/// then merges all fields from `report_value` into the envelope.
122/// Fields from `report_value` appear after the metadata header.
123fn build_json_envelope(report_value: serde_json::Value, elapsed: Duration) -> serde_json::Value {
124    let mut map = serde_json::Map::new();
125    map.insert(
126        "schema_version".to_string(),
127        serde_json::json!(SCHEMA_VERSION),
128    );
129    map.insert(
130        "version".to_string(),
131        serde_json::json!(env!("CARGO_PKG_VERSION")),
132    );
133    map.insert(
134        "elapsed_ms".to_string(),
135        serde_json::json!(elapsed.as_millis()),
136    );
137    if let serde_json::Value::Object(report_map) = report_value {
138        for (key, value) in report_map {
139            map.insert(key, value);
140        }
141    }
142    serde_json::Value::Object(map)
143}
144
145/// Build the JSON output value for analysis results.
146///
147/// Metadata fields (`schema_version`, `version`, `elapsed_ms`, `total_issues`)
148/// appear first in the output for readability. Paths are made relative to `root`.
149///
150/// # Errors
151///
152/// Returns an error if the results cannot be serialized to JSON.
153pub fn build_json(
154    results: &AnalysisResults,
155    root: &Path,
156    elapsed: Duration,
157) -> Result<serde_json::Value, serde_json::Error> {
158    let results_value = serde_json::to_value(results)?;
159
160    let mut map = serde_json::Map::new();
161    map.insert(
162        "schema_version".to_string(),
163        serde_json::json!(SCHEMA_VERSION),
164    );
165    map.insert(
166        "version".to_string(),
167        serde_json::json!(env!("CARGO_PKG_VERSION")),
168    );
169    map.insert(
170        "elapsed_ms".to_string(),
171        serde_json::json!(elapsed.as_millis()),
172    );
173    map.insert(
174        "total_issues".to_string(),
175        serde_json::json!(results.total_issues()),
176    );
177
178    // Entry-point detection summary (metadata, not serialized via serde)
179    if let Some(ref ep) = results.entry_point_summary {
180        let sources: serde_json::Map<String, serde_json::Value> = ep
181            .by_source
182            .iter()
183            .map(|(k, v)| (k.replace(' ', "_"), serde_json::json!(v)))
184            .collect();
185        map.insert(
186            "entry_points".to_string(),
187            serde_json::json!({
188                "total": ep.total,
189                "sources": sources,
190            }),
191        );
192    }
193
194    // Per-category summary counts for CI dashboard consumption
195    let summary = serde_json::json!({
196        "total_issues": results.total_issues(),
197        "unused_files": results.unused_files.len(),
198        "unused_exports": results.unused_exports.len(),
199        "unused_types": results.unused_types.len(),
200        "unused_dependencies": results.unused_dependencies.len()
201            + results.unused_dev_dependencies.len()
202            + results.unused_optional_dependencies.len(),
203        "unused_enum_members": results.unused_enum_members.len(),
204        "unused_class_members": results.unused_class_members.len(),
205        "unresolved_imports": results.unresolved_imports.len(),
206        "unlisted_dependencies": results.unlisted_dependencies.len(),
207        "duplicate_exports": results.duplicate_exports.len(),
208        "type_only_dependencies": results.type_only_dependencies.len(),
209        "test_only_dependencies": results.test_only_dependencies.len(),
210        "circular_dependencies": results.circular_dependencies.len(),
211        "boundary_violations": results.boundary_violations.len(),
212        "stale_suppressions": results.stale_suppressions.len(),
213    });
214    map.insert("summary".to_string(), summary);
215
216    if let serde_json::Value::Object(results_map) = results_value {
217        for (key, value) in results_map {
218            map.insert(key, value);
219        }
220    }
221
222    let mut output = serde_json::Value::Object(map);
223    let root_prefix = format!("{}/", root.display());
224    // strip_root_prefix must run before inject_actions so that injected
225    // action fields (static strings and package names) are not processed
226    // by the path stripper.
227    strip_root_prefix(&mut output, &root_prefix);
228    inject_actions(&mut output);
229    Ok(output)
230}
231
232/// Recursively strip the root prefix from all string values in the JSON tree.
233///
234/// This converts absolute paths (e.g., `/home/runner/work/repo/repo/src/utils.ts`)
235/// to relative paths (`src/utils.ts`) for all output fields.
236pub fn strip_root_prefix(value: &mut serde_json::Value, prefix: &str) {
237    match value {
238        serde_json::Value::String(s) => {
239            if let Some(rest) = s.strip_prefix(prefix) {
240                *s = rest.to_string();
241            } else {
242                let normalized = normalize_uri(s);
243                let normalized_prefix = normalize_uri(prefix);
244                if let Some(rest) = normalized.strip_prefix(&normalized_prefix) {
245                    *s = rest.to_string();
246                }
247            }
248        }
249        serde_json::Value::Array(arr) => {
250            for item in arr {
251                strip_root_prefix(item, prefix);
252            }
253        }
254        serde_json::Value::Object(map) => {
255            for (_, v) in map.iter_mut() {
256                strip_root_prefix(v, prefix);
257            }
258        }
259        _ => {}
260    }
261}
262
263// ── Fix action injection ────────────────────────────────────────
264
265/// Suppress mechanism for an issue type.
266enum SuppressKind {
267    /// `// fallow-ignore-next-line <type>` on the line before.
268    InlineComment,
269    /// `// fallow-ignore-file <type>` at the top of the file.
270    FileComment,
271    /// Add to `ignoreDependencies` in fallow config.
272    ConfigIgnoreDep,
273}
274
275/// Specification for actions to inject per issue type.
276struct ActionSpec {
277    fix_type: &'static str,
278    auto_fixable: bool,
279    description: &'static str,
280    note: Option<&'static str>,
281    suppress: SuppressKind,
282    issue_kind: &'static str,
283}
284
285/// Map an issue array key to its action specification.
286fn actions_for_issue_type(key: &str) -> Option<ActionSpec> {
287    match key {
288        "unused_files" => Some(ActionSpec {
289            fix_type: "delete-file",
290            auto_fixable: false,
291            description: "Delete this file",
292            note: Some(
293                "File deletion may remove runtime functionality not visible to static analysis",
294            ),
295            suppress: SuppressKind::FileComment,
296            issue_kind: "unused-file",
297        }),
298        "unused_exports" => Some(ActionSpec {
299            fix_type: "remove-export",
300            auto_fixable: true,
301            description: "Remove the `export` keyword from the declaration",
302            note: None,
303            suppress: SuppressKind::InlineComment,
304            issue_kind: "unused-export",
305        }),
306        "unused_types" => Some(ActionSpec {
307            fix_type: "remove-export",
308            auto_fixable: true,
309            description: "Remove the `export` (or `export type`) keyword from the type declaration",
310            note: None,
311            suppress: SuppressKind::InlineComment,
312            issue_kind: "unused-type",
313        }),
314        "unused_dependencies" => Some(ActionSpec {
315            fix_type: "remove-dependency",
316            auto_fixable: true,
317            description: "Remove from dependencies in package.json",
318            note: None,
319            suppress: SuppressKind::ConfigIgnoreDep,
320            issue_kind: "unused-dependency",
321        }),
322        "unused_dev_dependencies" => Some(ActionSpec {
323            fix_type: "remove-dependency",
324            auto_fixable: true,
325            description: "Remove from devDependencies in package.json",
326            note: None,
327            suppress: SuppressKind::ConfigIgnoreDep,
328            issue_kind: "unused-dev-dependency",
329        }),
330        "unused_optional_dependencies" => Some(ActionSpec {
331            fix_type: "remove-dependency",
332            auto_fixable: true,
333            description: "Remove from optionalDependencies in package.json",
334            note: None,
335            suppress: SuppressKind::ConfigIgnoreDep,
336            // No IssueKind variant exists for optional deps — uses config suppress only.
337            issue_kind: "unused-dependency",
338        }),
339        "unused_enum_members" => Some(ActionSpec {
340            fix_type: "remove-enum-member",
341            auto_fixable: true,
342            description: "Remove this enum member",
343            note: None,
344            suppress: SuppressKind::InlineComment,
345            issue_kind: "unused-enum-member",
346        }),
347        "unused_class_members" => Some(ActionSpec {
348            fix_type: "remove-class-member",
349            auto_fixable: false,
350            description: "Remove this class member",
351            note: Some("Class member may be used via dependency injection or decorators"),
352            suppress: SuppressKind::InlineComment,
353            issue_kind: "unused-class-member",
354        }),
355        "unresolved_imports" => Some(ActionSpec {
356            fix_type: "resolve-import",
357            auto_fixable: false,
358            description: "Fix the import specifier or install the missing module",
359            note: Some("Verify the module path and check tsconfig paths configuration"),
360            suppress: SuppressKind::InlineComment,
361            issue_kind: "unresolved-import",
362        }),
363        "unlisted_dependencies" => Some(ActionSpec {
364            fix_type: "install-dependency",
365            auto_fixable: false,
366            description: "Add this package to dependencies in package.json",
367            note: Some("Verify this package should be a direct dependency before adding"),
368            suppress: SuppressKind::ConfigIgnoreDep,
369            issue_kind: "unlisted-dependency",
370        }),
371        "duplicate_exports" => Some(ActionSpec {
372            fix_type: "remove-duplicate",
373            auto_fixable: false,
374            description: "Keep one canonical export location and remove the others",
375            note: Some("Review all locations to determine which should be the canonical export"),
376            suppress: SuppressKind::InlineComment,
377            issue_kind: "duplicate-export",
378        }),
379        "type_only_dependencies" => Some(ActionSpec {
380            fix_type: "move-to-dev",
381            auto_fixable: false,
382            description: "Move to devDependencies (only type imports are used)",
383            note: Some(
384                "Type imports are erased at runtime so this dependency is not needed in production",
385            ),
386            suppress: SuppressKind::ConfigIgnoreDep,
387            issue_kind: "type-only-dependency",
388        }),
389        "test_only_dependencies" => Some(ActionSpec {
390            fix_type: "move-to-dev",
391            auto_fixable: false,
392            description: "Move to devDependencies (only test files import this)",
393            note: Some(
394                "Only test files import this package so it does not need to be a production dependency",
395            ),
396            suppress: SuppressKind::ConfigIgnoreDep,
397            issue_kind: "test-only-dependency",
398        }),
399        "circular_dependencies" => Some(ActionSpec {
400            fix_type: "refactor-cycle",
401            auto_fixable: false,
402            description: "Extract shared logic into a separate module to break the cycle",
403            note: Some(
404                "Circular imports can cause initialization issues and make code harder to reason about",
405            ),
406            suppress: SuppressKind::InlineComment,
407            issue_kind: "circular-dependency",
408        }),
409        "boundary_violations" => Some(ActionSpec {
410            fix_type: "refactor-boundary",
411            auto_fixable: false,
412            description: "Move the import through an allowed zone or restructure the dependency",
413            note: Some(
414                "This import crosses an architecture boundary that is not permitted by the configured rules",
415            ),
416            suppress: SuppressKind::InlineComment,
417            issue_kind: "boundary-violation",
418        }),
419        _ => None,
420    }
421}
422
423/// Build the `actions` array for a single issue item.
424fn build_actions(
425    item: &serde_json::Value,
426    issue_key: &str,
427    spec: &ActionSpec,
428) -> serde_json::Value {
429    let mut actions = Vec::with_capacity(2);
430
431    // Primary fix action
432    let mut fix_action = serde_json::json!({
433        "type": spec.fix_type,
434        "auto_fixable": spec.auto_fixable,
435        "description": spec.description,
436    });
437    if let Some(note) = spec.note {
438        fix_action["note"] = serde_json::json!(note);
439    }
440    // Warn about re-exports that may be part of the public API surface.
441    if (issue_key == "unused_exports" || issue_key == "unused_types")
442        && item
443            .get("is_re_export")
444            .and_then(serde_json::Value::as_bool)
445            == Some(true)
446    {
447        fix_action["note"] = serde_json::json!(
448            "This finding originates from a re-export; verify it is not part of your public API before removing"
449        );
450    }
451    actions.push(fix_action);
452
453    // Suppress action — every action carries `auto_fixable` for uniform filtering.
454    match spec.suppress {
455        SuppressKind::InlineComment => {
456            let mut suppress = serde_json::json!({
457                "type": "suppress-line",
458                "auto_fixable": false,
459                "description": "Suppress with an inline comment above the line",
460                "comment": format!("// fallow-ignore-next-line {}", spec.issue_kind),
461            });
462            // duplicate_exports has N locations, not one — flag multi-location scope.
463            if issue_key == "duplicate_exports" {
464                suppress["scope"] = serde_json::json!("per-location");
465            }
466            actions.push(suppress);
467        }
468        SuppressKind::FileComment => {
469            actions.push(serde_json::json!({
470                "type": "suppress-file",
471                "auto_fixable": false,
472                "description": "Suppress with a file-level comment at the top of the file",
473                "comment": format!("// fallow-ignore-file {}", spec.issue_kind),
474            }));
475        }
476        SuppressKind::ConfigIgnoreDep => {
477            // Extract the package name from the item for a concrete suggestion.
478            let pkg = item
479                .get("package_name")
480                .and_then(serde_json::Value::as_str)
481                .unwrap_or("package-name");
482            actions.push(serde_json::json!({
483                "type": "add-to-config",
484                "auto_fixable": false,
485                "description": format!("Add \"{pkg}\" to ignoreDependencies in fallow config"),
486                "config_key": "ignoreDependencies",
487                "value": pkg,
488            }));
489        }
490    }
491
492    serde_json::Value::Array(actions)
493}
494
495/// Inject `actions` arrays into every issue item in the JSON output.
496///
497/// Walks each known issue-type array and appends an `actions` field
498/// to every item, providing machine-actionable fix and suppress hints.
499fn inject_actions(output: &mut serde_json::Value) {
500    let Some(map) = output.as_object_mut() else {
501        return;
502    };
503
504    for (key, value) in map.iter_mut() {
505        let Some(spec) = actions_for_issue_type(key) else {
506            continue;
507        };
508        let Some(arr) = value.as_array_mut() else {
509            continue;
510        };
511        for item in arr {
512            let actions = build_actions(item, key, &spec);
513            if let serde_json::Value::Object(obj) = item {
514                obj.insert("actions".to_string(), actions);
515            }
516        }
517    }
518}
519
520// ── Health action injection ─────────────────────────────────────
521
522/// Build a JSON representation of baseline deltas for the combined JSON envelope.
523///
524/// Accepts a total delta and an iterator of per-category entries to avoid
525/// coupling the report module (compiled in both lib and bin) to the
526/// binary-only `baseline` module.
527pub fn build_baseline_deltas_json<'a>(
528    total_delta: i64,
529    per_category: impl Iterator<Item = (&'a str, usize, usize, i64)>,
530) -> serde_json::Value {
531    let mut per_cat = serde_json::Map::new();
532    for (cat, current, baseline, delta) in per_category {
533        per_cat.insert(
534            cat.to_string(),
535            serde_json::json!({
536                "current": current,
537                "baseline": baseline,
538                "delta": delta,
539            }),
540        );
541    }
542    serde_json::json!({
543        "total_delta": total_delta,
544        "per_category": per_cat
545    })
546}
547
548/// Inject `actions` arrays into complexity findings in a health JSON output.
549///
550/// Walks `findings` and `targets` arrays, appending machine-actionable
551/// fix and suppress hints to each item.
552#[allow(
553    clippy::redundant_pub_crate,
554    reason = "pub(crate) needed — used by audit.rs via re-export, but not part of public API"
555)]
556pub(crate) fn inject_health_actions(output: &mut serde_json::Value) {
557    let Some(map) = output.as_object_mut() else {
558        return;
559    };
560
561    // Complexity findings: refactor the function to reduce complexity
562    if let Some(findings) = map.get_mut("findings").and_then(|v| v.as_array_mut()) {
563        for item in findings {
564            let actions = build_health_finding_actions(item);
565            if let serde_json::Value::Object(obj) = item {
566                obj.insert("actions".to_string(), actions);
567            }
568        }
569    }
570
571    // Refactoring targets: apply the recommended refactoring
572    if let Some(targets) = map.get_mut("targets").and_then(|v| v.as_array_mut()) {
573        for item in targets {
574            let actions = build_refactoring_target_actions(item);
575            if let serde_json::Value::Object(obj) = item {
576                obj.insert("actions".to_string(), actions);
577            }
578        }
579    }
580
581    // Hotspots: files that are both complex and frequently changing
582    if let Some(hotspots) = map.get_mut("hotspots").and_then(|v| v.as_array_mut()) {
583        for item in hotspots {
584            let actions = build_hotspot_actions(item);
585            if let serde_json::Value::Object(obj) = item {
586                obj.insert("actions".to_string(), actions);
587            }
588        }
589    }
590
591    // Coverage gaps: untested files and exports
592    if let Some(gaps) = map.get_mut("coverage_gaps").and_then(|v| v.as_object_mut()) {
593        if let Some(files) = gaps.get_mut("files").and_then(|v| v.as_array_mut()) {
594            for item in files {
595                let actions = build_untested_file_actions(item);
596                if let serde_json::Value::Object(obj) = item {
597                    obj.insert("actions".to_string(), actions);
598                }
599            }
600        }
601        if let Some(exports) = gaps.get_mut("exports").and_then(|v| v.as_array_mut()) {
602            for item in exports {
603                let actions = build_untested_export_actions(item);
604                if let serde_json::Value::Object(obj) = item {
605                    obj.insert("actions".to_string(), actions);
606                }
607            }
608        }
609    }
610
611    // Production coverage actions are emitted by the sidecar and serialized
612    // directly via serde (see `ProductionCoverageAction` in
613    // `crates/cli/src/health_types/production_coverage.rs`), so no post-hoc
614    // injection is needed here.
615}
616
617/// Build the `actions` array for a single complexity finding.
618///
619/// When the finding was triggered by CRAP (alone or alongside complexity),
620/// the primary action switches to `add-tests` because coverage is the
621/// leverage point for lowering CRAP on a given complexity. When only
622/// cyclomatic/cognitive were exceeded, `refactor-function` remains primary.
623fn build_health_finding_actions(item: &serde_json::Value) -> serde_json::Value {
624    let name = item
625        .get("name")
626        .and_then(serde_json::Value::as_str)
627        .unwrap_or("function");
628    let exceeded = item
629        .get("exceeded")
630        .and_then(serde_json::Value::as_str)
631        .unwrap_or("");
632    let includes_crap = matches!(
633        exceeded,
634        "crap" | "cyclomatic_crap" | "cognitive_crap" | "all"
635    );
636    let crap_only = exceeded == "crap";
637
638    let mut actions: Vec<serde_json::Value> = Vec::new();
639    if includes_crap {
640        actions.push(serde_json::json!({
641            "type": "add-tests",
642            "auto_fixable": false,
643            "description": format!("Add test coverage for `{name}` to lower its CRAP score (coverage reduces risk even without refactoring)"),
644            "note": "CRAP = CC^2 * (1 - cov/100)^3 + CC; higher coverage is the fastest way to bring CRAP under threshold",
645        }));
646    }
647    if !crap_only {
648        actions.push(serde_json::json!({
649            "type": "refactor-function",
650            "auto_fixable": false,
651            "description": format!("Refactor `{name}` to reduce complexity (extract helper functions, simplify branching)"),
652            "note": "Consider splitting into smaller functions with single responsibilities",
653        }));
654    }
655
656    actions.push(serde_json::json!({
657        "type": "suppress-line",
658        "auto_fixable": false,
659        "description": "Suppress with an inline comment above the function declaration",
660        "comment": "// fallow-ignore-next-line complexity",
661        "placement": "above-function-declaration",
662    }));
663
664    serde_json::Value::Array(actions)
665}
666
667/// Build the `actions` array for a single hotspot entry.
668fn build_hotspot_actions(item: &serde_json::Value) -> serde_json::Value {
669    let path = item
670        .get("path")
671        .and_then(serde_json::Value::as_str)
672        .unwrap_or("file");
673
674    let mut actions = vec![
675        serde_json::json!({
676            "type": "refactor-file",
677            "auto_fixable": false,
678            "description": format!("Refactor `{path}`, high complexity combined with frequent changes makes this a maintenance risk"),
679            "note": "Prioritize extracting complex functions, adding tests, or splitting the module",
680        }),
681        serde_json::json!({
682            "type": "add-tests",
683            "auto_fixable": false,
684            "description": format!("Add test coverage for `{path}` to reduce change risk"),
685            "note": "Frequently changed complex files benefit most from comprehensive test coverage",
686        }),
687    ];
688
689    if let Some(ownership) = item.get("ownership") {
690        // Bus factor of 1 is the canonical "single point of failure" signal.
691        if ownership
692            .get("bus_factor")
693            .and_then(serde_json::Value::as_u64)
694            == Some(1)
695        {
696            let top = ownership.get("top_contributor");
697            let owner = top
698                .and_then(|t| t.get("identifier"))
699                .and_then(serde_json::Value::as_str)
700                .unwrap_or("the sole contributor");
701            // Soften the note for files with very few commits — calling a
702            // 3-commit file a "knowledge loss risk" reads as catastrophizing
703            // for solo maintainers and small teams. Keep the action so
704            // agents still see the signal, but soften the framing.
705            let commits = top
706                .and_then(|t| t.get("commits"))
707                .and_then(serde_json::Value::as_u64)
708                .unwrap_or(0);
709            // File-specific note: name the candidate reviewers from the
710            // `suggested_reviewers` array when any exist, fall back to
711            // softened framing for low-commit files, and otherwise omit
712            // the note entirely (the description already carries the
713            // actionable ask; adding generic boilerplate wastes tokens).
714            let suggested: Vec<String> = ownership
715                .get("suggested_reviewers")
716                .and_then(serde_json::Value::as_array)
717                .map(|arr| {
718                    arr.iter()
719                        .filter_map(|r| {
720                            r.get("identifier")
721                                .and_then(serde_json::Value::as_str)
722                                .map(String::from)
723                        })
724                        .collect()
725                })
726                .unwrap_or_default();
727            let mut low_bus_action = serde_json::json!({
728                "type": "low-bus-factor",
729                "auto_fixable": false,
730                "description": format!(
731                    "{owner} is the sole recent contributor to `{path}`; adding a second reviewer reduces knowledge-loss risk"
732                ),
733            });
734            if !suggested.is_empty() {
735                let list = suggested
736                    .iter()
737                    .map(|s| format!("@{s}"))
738                    .collect::<Vec<_>>()
739                    .join(", ");
740                low_bus_action["note"] =
741                    serde_json::Value::String(format!("Candidate reviewers: {list}"));
742            } else if commits < 5 {
743                low_bus_action["note"] = serde_json::Value::String(
744                    "Single recent contributor on a low-commit file. Consider a pair review for major changes."
745                        .to_string(),
746                );
747            }
748            // else: omit `note` entirely — description already carries the ask.
749            actions.push(low_bus_action);
750        }
751
752        // Unowned-hotspot: file matches no CODEOWNERS rule. Skip when null
753        // (no CODEOWNERS file discovered).
754        if ownership
755            .get("unowned")
756            .and_then(serde_json::Value::as_bool)
757            == Some(true)
758        {
759            actions.push(serde_json::json!({
760                "type": "unowned-hotspot",
761                "auto_fixable": false,
762                "description": format!("Add a CODEOWNERS entry for `{path}`"),
763                "note": "Frequently-changed files without declared owners create review bottlenecks",
764                "suggested_pattern": suggest_codeowners_pattern(path),
765                "heuristic": "directory-deepest",
766            }));
767        }
768
769        // Drift: original author no longer maintains; add a notice action so
770        // agents can route the next change to the new top contributor.
771        if ownership.get("drift").and_then(serde_json::Value::as_bool) == Some(true) {
772            let reason = ownership
773                .get("drift_reason")
774                .and_then(serde_json::Value::as_str)
775                .unwrap_or("ownership has shifted from the original author");
776            actions.push(serde_json::json!({
777                "type": "ownership-drift",
778                "auto_fixable": false,
779                "description": format!("Update CODEOWNERS for `{path}`: {reason}"),
780                "note": "Drift suggests the declared or original owner is no longer the right reviewer",
781            }));
782        }
783    }
784
785    serde_json::Value::Array(actions)
786}
787
788/// Suggest a CODEOWNERS pattern for an unowned hotspot.
789///
790/// Picks the deepest directory containing the file
791/// (e.g. `src/api/users/handlers.ts` -> `/src/api/users/`) so agents can
792/// paste a tightly-scoped default. Earlier versions used the first two
793/// directory levels but that catches too many siblings in monorepos
794/// (`/src/api/` could span 200 files across 8 sub-domains). The deepest
795/// directory keeps the suggestion reviewable while still being a directory
796/// pattern rather than a per-file rule.
797///
798/// The action emits this alongside `"heuristic": "directory-deepest"` so
799/// consumers can branch on the strategy if it evolves.
800fn suggest_codeowners_pattern(path: &str) -> String {
801    let normalized = path.replace('\\', "/");
802    let trimmed = normalized.trim_start_matches('/');
803    let mut components: Vec<&str> = trimmed.split('/').collect();
804    components.pop(); // drop the file itself
805    if components.is_empty() {
806        return format!("/{trimmed}");
807    }
808    format!("/{}/", components.join("/"))
809}
810
811/// Build the `actions` array for a single refactoring target.
812fn build_refactoring_target_actions(item: &serde_json::Value) -> serde_json::Value {
813    let recommendation = item
814        .get("recommendation")
815        .and_then(serde_json::Value::as_str)
816        .unwrap_or("Apply the recommended refactoring");
817
818    let category = item
819        .get("category")
820        .and_then(serde_json::Value::as_str)
821        .unwrap_or("refactoring");
822
823    let mut actions = vec![serde_json::json!({
824        "type": "apply-refactoring",
825        "auto_fixable": false,
826        "description": recommendation,
827        "category": category,
828    })];
829
830    // Targets with evidence linking to specific functions get a suppress action
831    if item.get("evidence").is_some() {
832        actions.push(serde_json::json!({
833            "type": "suppress-line",
834            "auto_fixable": false,
835            "description": "Suppress the underlying complexity finding",
836            "comment": "// fallow-ignore-next-line complexity",
837        }));
838    }
839
840    serde_json::Value::Array(actions)
841}
842
843/// Build the `actions` array for an untested file.
844fn build_untested_file_actions(item: &serde_json::Value) -> serde_json::Value {
845    let path = item
846        .get("path")
847        .and_then(serde_json::Value::as_str)
848        .unwrap_or("file");
849
850    serde_json::Value::Array(vec![
851        serde_json::json!({
852            "type": "add-tests",
853            "auto_fixable": false,
854            "description": format!("Add test coverage for `{path}`"),
855            "note": "No test dependency path reaches this runtime file",
856        }),
857        serde_json::json!({
858            "type": "suppress-file",
859            "auto_fixable": false,
860            "description": format!("Suppress coverage gap reporting for `{path}`"),
861            "comment": "// fallow-ignore-file coverage-gaps",
862        }),
863    ])
864}
865
866/// Build the `actions` array for an untested export.
867fn build_untested_export_actions(item: &serde_json::Value) -> serde_json::Value {
868    let path = item
869        .get("path")
870        .and_then(serde_json::Value::as_str)
871        .unwrap_or("file");
872    let export_name = item
873        .get("export_name")
874        .and_then(serde_json::Value::as_str)
875        .unwrap_or("export");
876
877    serde_json::Value::Array(vec![
878        serde_json::json!({
879            "type": "add-test-import",
880            "auto_fixable": false,
881            "description": format!("Import and test `{export_name}` from `{path}`"),
882            "note": "This export is runtime-reachable but no test-reachable module references it",
883        }),
884        serde_json::json!({
885            "type": "suppress-file",
886            "auto_fixable": false,
887            "description": format!("Suppress coverage gap reporting for `{path}`"),
888            "comment": "// fallow-ignore-file coverage-gaps",
889        }),
890    ])
891}
892
893// ── Duplication action injection ────────────────────────────────
894
895/// Inject `actions` arrays into clone families/groups in a duplication JSON output.
896///
897/// Walks `clone_families` and `clone_groups` arrays, appending
898/// machine-actionable fix and config hints to each item.
899#[allow(
900    clippy::redundant_pub_crate,
901    reason = "pub(crate) needed — used by audit.rs via re-export, but not part of public API"
902)]
903pub(crate) fn inject_dupes_actions(output: &mut serde_json::Value) {
904    let Some(map) = output.as_object_mut() else {
905        return;
906    };
907
908    // Clone families: extract shared module/function
909    if let Some(families) = map.get_mut("clone_families").and_then(|v| v.as_array_mut()) {
910        for item in families {
911            let actions = build_clone_family_actions(item);
912            if let serde_json::Value::Object(obj) = item {
913                obj.insert("actions".to_string(), actions);
914            }
915        }
916    }
917
918    // Clone groups: extract shared code
919    if let Some(groups) = map.get_mut("clone_groups").and_then(|v| v.as_array_mut()) {
920        for item in groups {
921            let actions = build_clone_group_actions(item);
922            if let serde_json::Value::Object(obj) = item {
923                obj.insert("actions".to_string(), actions);
924            }
925        }
926    }
927}
928
929/// Build the `actions` array for a single clone family.
930fn build_clone_family_actions(item: &serde_json::Value) -> serde_json::Value {
931    let group_count = item
932        .get("groups")
933        .and_then(|v| v.as_array())
934        .map_or(0, Vec::len);
935
936    let total_lines = item
937        .get("total_duplicated_lines")
938        .and_then(serde_json::Value::as_u64)
939        .unwrap_or(0);
940
941    let mut actions = vec![serde_json::json!({
942        "type": "extract-shared",
943        "auto_fixable": false,
944        "description": format!(
945            "Extract {group_count} duplicated code block{} ({total_lines} lines) into a shared module",
946            if group_count == 1 { "" } else { "s" }
947        ),
948        "note": "These clone groups share the same files, indicating a structural relationship — refactor together",
949    })];
950
951    // Include any refactoring suggestions from the family
952    if let Some(suggestions) = item.get("suggestions").and_then(|v| v.as_array()) {
953        for suggestion in suggestions {
954            if let Some(desc) = suggestion
955                .get("description")
956                .and_then(serde_json::Value::as_str)
957            {
958                actions.push(serde_json::json!({
959                    "type": "apply-suggestion",
960                    "auto_fixable": false,
961                    "description": desc,
962                }));
963            }
964        }
965    }
966
967    actions.push(serde_json::json!({
968        "type": "suppress-line",
969        "auto_fixable": false,
970        "description": "Suppress with an inline comment above the duplicated code",
971        "comment": "// fallow-ignore-next-line code-duplication",
972    }));
973
974    serde_json::Value::Array(actions)
975}
976
977/// Build the `actions` array for a single clone group.
978fn build_clone_group_actions(item: &serde_json::Value) -> serde_json::Value {
979    let instance_count = item
980        .get("instances")
981        .and_then(|v| v.as_array())
982        .map_or(0, Vec::len);
983
984    let line_count = item
985        .get("line_count")
986        .and_then(serde_json::Value::as_u64)
987        .unwrap_or(0);
988
989    let actions = vec![
990        serde_json::json!({
991            "type": "extract-shared",
992            "auto_fixable": false,
993            "description": format!(
994                "Extract duplicated code ({line_count} lines, {instance_count} instance{}) into a shared function",
995                if instance_count == 1 { "" } else { "s" }
996            ),
997        }),
998        serde_json::json!({
999            "type": "suppress-line",
1000            "auto_fixable": false,
1001            "description": "Suppress with an inline comment above the duplicated code",
1002            "comment": "// fallow-ignore-next-line code-duplication",
1003        }),
1004    ];
1005
1006    serde_json::Value::Array(actions)
1007}
1008
1009/// Insert a `_meta` key into a JSON object value.
1010fn insert_meta(output: &mut serde_json::Value, meta: serde_json::Value) {
1011    if let serde_json::Value::Object(map) = output {
1012        map.insert("_meta".to_string(), meta);
1013    }
1014}
1015
1016/// Build the JSON envelope + health payload shared by `print_health_json` and
1017/// the CLI integration test suite. Exposed so snapshot tests can lock the
1018/// on-the-wire shape without routing through stdout capture.
1019///
1020/// # Errors
1021///
1022/// Returns an error if the report cannot be serialized to JSON.
1023pub fn build_health_json(
1024    report: &crate::health_types::HealthReport,
1025    root: &Path,
1026    elapsed: Duration,
1027    explain: bool,
1028) -> Result<serde_json::Value, serde_json::Error> {
1029    let report_value = serde_json::to_value(report)?;
1030    let mut output = build_json_envelope(report_value, elapsed);
1031    let root_prefix = format!("{}/", root.display());
1032    strip_root_prefix(&mut output, &root_prefix);
1033    inject_health_actions(&mut output);
1034    if explain {
1035        insert_meta(&mut output, explain::health_meta());
1036    }
1037    Ok(output)
1038}
1039
1040pub(super) fn print_health_json(
1041    report: &crate::health_types::HealthReport,
1042    root: &Path,
1043    elapsed: Duration,
1044    explain: bool,
1045) -> ExitCode {
1046    match build_health_json(report, root, elapsed, explain) {
1047        Ok(output) => emit_json(&output, "JSON"),
1048        Err(e) => {
1049            eprintln!("Error: failed to serialize health report: {e}");
1050            ExitCode::from(2)
1051        }
1052    }
1053}
1054
1055pub(super) fn print_duplication_json(
1056    report: &DuplicationReport,
1057    root: &Path,
1058    elapsed: Duration,
1059    explain: bool,
1060) -> ExitCode {
1061    let report_value = match serde_json::to_value(report) {
1062        Ok(v) => v,
1063        Err(e) => {
1064            eprintln!("Error: failed to serialize duplication report: {e}");
1065            return ExitCode::from(2);
1066        }
1067    };
1068
1069    let mut output = build_json_envelope(report_value, elapsed);
1070    let root_prefix = format!("{}/", root.display());
1071    strip_root_prefix(&mut output, &root_prefix);
1072    inject_dupes_actions(&mut output);
1073
1074    if explain {
1075        insert_meta(&mut output, explain::dupes_meta());
1076    }
1077
1078    emit_json(&output, "JSON")
1079}
1080
1081pub(super) fn print_trace_json<T: serde::Serialize>(value: &T) {
1082    match serde_json::to_string_pretty(value) {
1083        Ok(json) => println!("{json}"),
1084        Err(e) => {
1085            eprintln!("Error: failed to serialize trace output: {e}");
1086            #[expect(
1087                clippy::exit,
1088                reason = "fatal serialization error requires immediate exit"
1089            )]
1090            std::process::exit(2);
1091        }
1092    }
1093}
1094
1095#[cfg(test)]
1096mod tests {
1097    use super::*;
1098    use crate::health_types::{
1099        ProductionCoverageAction, ProductionCoverageConfidence, ProductionCoverageEvidence,
1100        ProductionCoverageFinding, ProductionCoverageHotPath, ProductionCoverageMessage,
1101        ProductionCoverageReport, ProductionCoverageReportVerdict, ProductionCoverageSummary,
1102        ProductionCoverageVerdict, ProductionCoverageWatermark,
1103    };
1104    use crate::report::test_helpers::sample_results;
1105    use fallow_core::extract::MemberKind;
1106    use fallow_core::results::*;
1107    use std::path::PathBuf;
1108    use std::time::Duration;
1109
1110    #[test]
1111    fn json_output_has_metadata_fields() {
1112        let root = PathBuf::from("/project");
1113        let results = AnalysisResults::default();
1114        let elapsed = Duration::from_millis(123);
1115        let output = build_json(&results, &root, elapsed).expect("should serialize");
1116
1117        assert_eq!(output["schema_version"], 4);
1118        assert!(output["version"].is_string());
1119        assert_eq!(output["elapsed_ms"], 123);
1120        assert_eq!(output["total_issues"], 0);
1121    }
1122
1123    #[test]
1124    fn json_output_includes_issue_arrays() {
1125        let root = PathBuf::from("/project");
1126        let results = sample_results(&root);
1127        let elapsed = Duration::from_millis(50);
1128        let output = build_json(&results, &root, elapsed).expect("should serialize");
1129
1130        assert_eq!(output["unused_files"].as_array().unwrap().len(), 1);
1131        assert_eq!(output["unused_exports"].as_array().unwrap().len(), 1);
1132        assert_eq!(output["unused_types"].as_array().unwrap().len(), 1);
1133        assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 1);
1134        assert_eq!(
1135            output["unused_dev_dependencies"].as_array().unwrap().len(),
1136            1
1137        );
1138        assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 1);
1139        assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 1);
1140        assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 1);
1141        assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 1);
1142        assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 1);
1143        assert_eq!(
1144            output["type_only_dependencies"].as_array().unwrap().len(),
1145            1
1146        );
1147        assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 1);
1148    }
1149
1150    #[test]
1151    fn health_json_includes_production_coverage_with_relative_paths_and_actions() {
1152        let root = PathBuf::from("/project");
1153        let report = crate::health_types::HealthReport {
1154            production_coverage: Some(ProductionCoverageReport {
1155                verdict: ProductionCoverageReportVerdict::ColdCodeDetected,
1156                summary: ProductionCoverageSummary {
1157                    functions_tracked: 3,
1158                    functions_hit: 1,
1159                    functions_unhit: 1,
1160                    functions_untracked: 1,
1161                    coverage_percent: 33.3,
1162                    trace_count: 2_847_291,
1163                    period_days: 30,
1164                    deployments_seen: 14,
1165                    capture_quality: Some(crate::health_types::ProductionCoverageCaptureQuality {
1166                        window_seconds: 720,
1167                        instances_observed: 1,
1168                        lazy_parse_warning: true,
1169                        untracked_ratio_percent: 42.5,
1170                    }),
1171                },
1172                findings: vec![ProductionCoverageFinding {
1173                    id: "fallow:prod:deadbeef".to_owned(),
1174                    path: root.join("src/cold.ts"),
1175                    function: "coldPath".to_owned(),
1176                    line: 12,
1177                    verdict: ProductionCoverageVerdict::ReviewRequired,
1178                    invocations: Some(0),
1179                    confidence: ProductionCoverageConfidence::Medium,
1180                    evidence: ProductionCoverageEvidence {
1181                        static_status: "used".to_owned(),
1182                        test_coverage: "not_covered".to_owned(),
1183                        v8_tracking: "tracked".to_owned(),
1184                        untracked_reason: None,
1185                        observation_days: 30,
1186                        deployments_observed: 14,
1187                    },
1188                    actions: vec![ProductionCoverageAction {
1189                        kind: "review-deletion".to_owned(),
1190                        description: "Tracked in production coverage with zero invocations."
1191                            .to_owned(),
1192                        auto_fixable: false,
1193                    }],
1194                }],
1195                hot_paths: vec![ProductionCoverageHotPath {
1196                    id: "fallow:hot:cafebabe".to_owned(),
1197                    path: root.join("src/hot.ts"),
1198                    function: "hotPath".to_owned(),
1199                    line: 3,
1200                    invocations: 250,
1201                    percentile: 99,
1202                    actions: vec![],
1203                }],
1204                watermark: Some(ProductionCoverageWatermark::LicenseExpiredGrace),
1205                warnings: vec![ProductionCoverageMessage {
1206                    code: "partial-merge".to_owned(),
1207                    message: "Merged coverage omitted one chunk.".to_owned(),
1208                }],
1209            }),
1210            ..Default::default()
1211        };
1212
1213        let report_value = serde_json::to_value(&report).expect("should serialize health report");
1214        let mut output = build_json_envelope(report_value, Duration::from_millis(7));
1215        strip_root_prefix(&mut output, "/project/");
1216        inject_health_actions(&mut output);
1217
1218        assert_eq!(
1219            output["production_coverage"]["verdict"],
1220            serde_json::Value::String("cold-code-detected".to_owned())
1221        );
1222        assert_eq!(
1223            output["production_coverage"]["summary"]["functions_tracked"],
1224            serde_json::Value::from(3)
1225        );
1226        assert_eq!(
1227            output["production_coverage"]["summary"]["coverage_percent"],
1228            serde_json::Value::from(33.3)
1229        );
1230        let finding = &output["production_coverage"]["findings"][0];
1231        assert_eq!(finding["path"], "src/cold.ts");
1232        assert_eq!(finding["verdict"], "review_required");
1233        assert_eq!(finding["id"], "fallow:prod:deadbeef");
1234        assert_eq!(finding["actions"][0]["type"], "review-deletion");
1235        let hot_path = &output["production_coverage"]["hot_paths"][0];
1236        assert_eq!(hot_path["path"], "src/hot.ts");
1237        assert_eq!(hot_path["function"], "hotPath");
1238        assert_eq!(hot_path["percentile"], 99);
1239        assert_eq!(
1240            output["production_coverage"]["watermark"],
1241            serde_json::Value::String("license-expired-grace".to_owned())
1242        );
1243        assert_eq!(
1244            output["production_coverage"]["warnings"][0]["code"],
1245            serde_json::Value::String("partial-merge".to_owned())
1246        );
1247    }
1248
1249    #[test]
1250    fn json_metadata_fields_appear_first() {
1251        let root = PathBuf::from("/project");
1252        let results = AnalysisResults::default();
1253        let elapsed = Duration::from_millis(0);
1254        let output = build_json(&results, &root, elapsed).expect("should serialize");
1255        let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
1256        assert_eq!(keys[0], "schema_version");
1257        assert_eq!(keys[1], "version");
1258        assert_eq!(keys[2], "elapsed_ms");
1259        assert_eq!(keys[3], "total_issues");
1260    }
1261
1262    #[test]
1263    fn json_total_issues_matches_results() {
1264        let root = PathBuf::from("/project");
1265        let results = sample_results(&root);
1266        let total = results.total_issues();
1267        let elapsed = Duration::from_millis(0);
1268        let output = build_json(&results, &root, elapsed).expect("should serialize");
1269
1270        assert_eq!(output["total_issues"], total);
1271    }
1272
1273    #[test]
1274    fn json_unused_export_contains_expected_fields() {
1275        let root = PathBuf::from("/project");
1276        let mut results = AnalysisResults::default();
1277        results.unused_exports.push(UnusedExport {
1278            path: root.join("src/utils.ts"),
1279            export_name: "helperFn".to_string(),
1280            is_type_only: false,
1281            line: 10,
1282            col: 4,
1283            span_start: 120,
1284            is_re_export: false,
1285        });
1286        let elapsed = Duration::from_millis(0);
1287        let output = build_json(&results, &root, elapsed).expect("should serialize");
1288
1289        let export = &output["unused_exports"][0];
1290        assert_eq!(export["export_name"], "helperFn");
1291        assert_eq!(export["line"], 10);
1292        assert_eq!(export["col"], 4);
1293        assert_eq!(export["is_type_only"], false);
1294        assert_eq!(export["span_start"], 120);
1295        assert_eq!(export["is_re_export"], false);
1296    }
1297
1298    #[test]
1299    fn json_serializes_to_valid_json() {
1300        let root = PathBuf::from("/project");
1301        let results = sample_results(&root);
1302        let elapsed = Duration::from_millis(42);
1303        let output = build_json(&results, &root, elapsed).expect("should serialize");
1304
1305        let json_str = serde_json::to_string_pretty(&output).expect("should stringify");
1306        let reparsed: serde_json::Value =
1307            serde_json::from_str(&json_str).expect("JSON output should be valid JSON");
1308        assert_eq!(reparsed, output);
1309    }
1310
1311    // ── Empty results ───────────────────────────────────────────────
1312
1313    #[test]
1314    fn json_empty_results_produce_valid_structure() {
1315        let root = PathBuf::from("/project");
1316        let results = AnalysisResults::default();
1317        let elapsed = Duration::from_millis(0);
1318        let output = build_json(&results, &root, elapsed).expect("should serialize");
1319
1320        assert_eq!(output["total_issues"], 0);
1321        assert_eq!(output["unused_files"].as_array().unwrap().len(), 0);
1322        assert_eq!(output["unused_exports"].as_array().unwrap().len(), 0);
1323        assert_eq!(output["unused_types"].as_array().unwrap().len(), 0);
1324        assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 0);
1325        assert_eq!(
1326            output["unused_dev_dependencies"].as_array().unwrap().len(),
1327            0
1328        );
1329        assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 0);
1330        assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 0);
1331        assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 0);
1332        assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 0);
1333        assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 0);
1334        assert_eq!(
1335            output["type_only_dependencies"].as_array().unwrap().len(),
1336            0
1337        );
1338        assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 0);
1339    }
1340
1341    #[test]
1342    fn json_empty_results_round_trips_through_string() {
1343        let root = PathBuf::from("/project");
1344        let results = AnalysisResults::default();
1345        let elapsed = Duration::from_millis(0);
1346        let output = build_json(&results, &root, elapsed).expect("should serialize");
1347
1348        let json_str = serde_json::to_string(&output).expect("should stringify");
1349        let reparsed: serde_json::Value =
1350            serde_json::from_str(&json_str).expect("should parse back");
1351        assert_eq!(reparsed["total_issues"], 0);
1352    }
1353
1354    // ── Path stripping ──────────────────────────────────────────────
1355
1356    #[test]
1357    fn json_paths_are_relative_to_root() {
1358        let root = PathBuf::from("/project");
1359        let mut results = AnalysisResults::default();
1360        results.unused_files.push(UnusedFile {
1361            path: root.join("src/deep/nested/file.ts"),
1362        });
1363        let elapsed = Duration::from_millis(0);
1364        let output = build_json(&results, &root, elapsed).expect("should serialize");
1365
1366        let path = output["unused_files"][0]["path"].as_str().unwrap();
1367        assert_eq!(path, "src/deep/nested/file.ts");
1368        assert!(!path.starts_with("/project"));
1369    }
1370
1371    #[test]
1372    fn json_strips_root_from_nested_locations() {
1373        let root = PathBuf::from("/project");
1374        let mut results = AnalysisResults::default();
1375        results.unlisted_dependencies.push(UnlistedDependency {
1376            package_name: "chalk".to_string(),
1377            imported_from: vec![ImportSite {
1378                path: root.join("src/cli.ts"),
1379                line: 2,
1380                col: 0,
1381            }],
1382        });
1383        let elapsed = Duration::from_millis(0);
1384        let output = build_json(&results, &root, elapsed).expect("should serialize");
1385
1386        let site_path = output["unlisted_dependencies"][0]["imported_from"][0]["path"]
1387            .as_str()
1388            .unwrap();
1389        assert_eq!(site_path, "src/cli.ts");
1390    }
1391
1392    #[test]
1393    fn json_strips_root_from_duplicate_export_locations() {
1394        let root = PathBuf::from("/project");
1395        let mut results = AnalysisResults::default();
1396        results.duplicate_exports.push(DuplicateExport {
1397            export_name: "Config".to_string(),
1398            locations: vec![
1399                DuplicateLocation {
1400                    path: root.join("src/config.ts"),
1401                    line: 15,
1402                    col: 0,
1403                },
1404                DuplicateLocation {
1405                    path: root.join("src/types.ts"),
1406                    line: 30,
1407                    col: 0,
1408                },
1409            ],
1410        });
1411        let elapsed = Duration::from_millis(0);
1412        let output = build_json(&results, &root, elapsed).expect("should serialize");
1413
1414        let loc0 = output["duplicate_exports"][0]["locations"][0]["path"]
1415            .as_str()
1416            .unwrap();
1417        let loc1 = output["duplicate_exports"][0]["locations"][1]["path"]
1418            .as_str()
1419            .unwrap();
1420        assert_eq!(loc0, "src/config.ts");
1421        assert_eq!(loc1, "src/types.ts");
1422    }
1423
1424    #[test]
1425    fn json_strips_root_from_circular_dependency_files() {
1426        let root = PathBuf::from("/project");
1427        let mut results = AnalysisResults::default();
1428        results.circular_dependencies.push(CircularDependency {
1429            files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1430            length: 2,
1431            line: 1,
1432            col: 0,
1433            is_cross_package: false,
1434        });
1435        let elapsed = Duration::from_millis(0);
1436        let output = build_json(&results, &root, elapsed).expect("should serialize");
1437
1438        let files = output["circular_dependencies"][0]["files"]
1439            .as_array()
1440            .unwrap();
1441        assert_eq!(files[0].as_str().unwrap(), "src/a.ts");
1442        assert_eq!(files[1].as_str().unwrap(), "src/b.ts");
1443    }
1444
1445    #[test]
1446    fn json_path_outside_root_not_stripped() {
1447        let root = PathBuf::from("/project");
1448        let mut results = AnalysisResults::default();
1449        results.unused_files.push(UnusedFile {
1450            path: PathBuf::from("/other/project/src/file.ts"),
1451        });
1452        let elapsed = Duration::from_millis(0);
1453        let output = build_json(&results, &root, elapsed).expect("should serialize");
1454
1455        let path = output["unused_files"][0]["path"].as_str().unwrap();
1456        assert!(path.contains("/other/project/"));
1457    }
1458
1459    // ── Individual issue type field verification ────────────────────
1460
1461    #[test]
1462    fn json_unused_file_contains_path() {
1463        let root = PathBuf::from("/project");
1464        let mut results = AnalysisResults::default();
1465        results.unused_files.push(UnusedFile {
1466            path: root.join("src/orphan.ts"),
1467        });
1468        let elapsed = Duration::from_millis(0);
1469        let output = build_json(&results, &root, elapsed).expect("should serialize");
1470
1471        let file = &output["unused_files"][0];
1472        assert_eq!(file["path"], "src/orphan.ts");
1473    }
1474
1475    #[test]
1476    fn json_unused_type_contains_expected_fields() {
1477        let root = PathBuf::from("/project");
1478        let mut results = AnalysisResults::default();
1479        results.unused_types.push(UnusedExport {
1480            path: root.join("src/types.ts"),
1481            export_name: "OldInterface".to_string(),
1482            is_type_only: true,
1483            line: 20,
1484            col: 0,
1485            span_start: 300,
1486            is_re_export: false,
1487        });
1488        let elapsed = Duration::from_millis(0);
1489        let output = build_json(&results, &root, elapsed).expect("should serialize");
1490
1491        let typ = &output["unused_types"][0];
1492        assert_eq!(typ["export_name"], "OldInterface");
1493        assert_eq!(typ["is_type_only"], true);
1494        assert_eq!(typ["line"], 20);
1495        assert_eq!(typ["path"], "src/types.ts");
1496    }
1497
1498    #[test]
1499    fn json_unused_dependency_contains_expected_fields() {
1500        let root = PathBuf::from("/project");
1501        let mut results = AnalysisResults::default();
1502        results.unused_dependencies.push(UnusedDependency {
1503            package_name: "axios".to_string(),
1504            location: DependencyLocation::Dependencies,
1505            path: root.join("package.json"),
1506            line: 10,
1507        });
1508        let elapsed = Duration::from_millis(0);
1509        let output = build_json(&results, &root, elapsed).expect("should serialize");
1510
1511        let dep = &output["unused_dependencies"][0];
1512        assert_eq!(dep["package_name"], "axios");
1513        assert_eq!(dep["line"], 10);
1514    }
1515
1516    #[test]
1517    fn json_unused_dev_dependency_contains_expected_fields() {
1518        let root = PathBuf::from("/project");
1519        let mut results = AnalysisResults::default();
1520        results.unused_dev_dependencies.push(UnusedDependency {
1521            package_name: "vitest".to_string(),
1522            location: DependencyLocation::DevDependencies,
1523            path: root.join("package.json"),
1524            line: 15,
1525        });
1526        let elapsed = Duration::from_millis(0);
1527        let output = build_json(&results, &root, elapsed).expect("should serialize");
1528
1529        let dep = &output["unused_dev_dependencies"][0];
1530        assert_eq!(dep["package_name"], "vitest");
1531    }
1532
1533    #[test]
1534    fn json_unused_optional_dependency_contains_expected_fields() {
1535        let root = PathBuf::from("/project");
1536        let mut results = AnalysisResults::default();
1537        results.unused_optional_dependencies.push(UnusedDependency {
1538            package_name: "fsevents".to_string(),
1539            location: DependencyLocation::OptionalDependencies,
1540            path: root.join("package.json"),
1541            line: 12,
1542        });
1543        let elapsed = Duration::from_millis(0);
1544        let output = build_json(&results, &root, elapsed).expect("should serialize");
1545
1546        let dep = &output["unused_optional_dependencies"][0];
1547        assert_eq!(dep["package_name"], "fsevents");
1548        assert_eq!(output["total_issues"], 1);
1549    }
1550
1551    #[test]
1552    fn json_unused_enum_member_contains_expected_fields() {
1553        let root = PathBuf::from("/project");
1554        let mut results = AnalysisResults::default();
1555        results.unused_enum_members.push(UnusedMember {
1556            path: root.join("src/enums.ts"),
1557            parent_name: "Color".to_string(),
1558            member_name: "Purple".to_string(),
1559            kind: MemberKind::EnumMember,
1560            line: 5,
1561            col: 2,
1562        });
1563        let elapsed = Duration::from_millis(0);
1564        let output = build_json(&results, &root, elapsed).expect("should serialize");
1565
1566        let member = &output["unused_enum_members"][0];
1567        assert_eq!(member["parent_name"], "Color");
1568        assert_eq!(member["member_name"], "Purple");
1569        assert_eq!(member["line"], 5);
1570        assert_eq!(member["path"], "src/enums.ts");
1571    }
1572
1573    #[test]
1574    fn json_unused_class_member_contains_expected_fields() {
1575        let root = PathBuf::from("/project");
1576        let mut results = AnalysisResults::default();
1577        results.unused_class_members.push(UnusedMember {
1578            path: root.join("src/api.ts"),
1579            parent_name: "ApiClient".to_string(),
1580            member_name: "deprecatedFetch".to_string(),
1581            kind: MemberKind::ClassMethod,
1582            line: 100,
1583            col: 4,
1584        });
1585        let elapsed = Duration::from_millis(0);
1586        let output = build_json(&results, &root, elapsed).expect("should serialize");
1587
1588        let member = &output["unused_class_members"][0];
1589        assert_eq!(member["parent_name"], "ApiClient");
1590        assert_eq!(member["member_name"], "deprecatedFetch");
1591        assert_eq!(member["line"], 100);
1592    }
1593
1594    #[test]
1595    fn json_unresolved_import_contains_expected_fields() {
1596        let root = PathBuf::from("/project");
1597        let mut results = AnalysisResults::default();
1598        results.unresolved_imports.push(UnresolvedImport {
1599            path: root.join("src/app.ts"),
1600            specifier: "@acme/missing-pkg".to_string(),
1601            line: 7,
1602            col: 0,
1603            specifier_col: 0,
1604        });
1605        let elapsed = Duration::from_millis(0);
1606        let output = build_json(&results, &root, elapsed).expect("should serialize");
1607
1608        let import = &output["unresolved_imports"][0];
1609        assert_eq!(import["specifier"], "@acme/missing-pkg");
1610        assert_eq!(import["line"], 7);
1611        assert_eq!(import["path"], "src/app.ts");
1612    }
1613
1614    #[test]
1615    fn json_unlisted_dependency_contains_import_sites() {
1616        let root = PathBuf::from("/project");
1617        let mut results = AnalysisResults::default();
1618        results.unlisted_dependencies.push(UnlistedDependency {
1619            package_name: "dotenv".to_string(),
1620            imported_from: vec![
1621                ImportSite {
1622                    path: root.join("src/config.ts"),
1623                    line: 1,
1624                    col: 0,
1625                },
1626                ImportSite {
1627                    path: root.join("src/server.ts"),
1628                    line: 3,
1629                    col: 0,
1630                },
1631            ],
1632        });
1633        let elapsed = Duration::from_millis(0);
1634        let output = build_json(&results, &root, elapsed).expect("should serialize");
1635
1636        let dep = &output["unlisted_dependencies"][0];
1637        assert_eq!(dep["package_name"], "dotenv");
1638        let sites = dep["imported_from"].as_array().unwrap();
1639        assert_eq!(sites.len(), 2);
1640        assert_eq!(sites[0]["path"], "src/config.ts");
1641        assert_eq!(sites[1]["path"], "src/server.ts");
1642    }
1643
1644    #[test]
1645    fn json_duplicate_export_contains_locations() {
1646        let root = PathBuf::from("/project");
1647        let mut results = AnalysisResults::default();
1648        results.duplicate_exports.push(DuplicateExport {
1649            export_name: "Button".to_string(),
1650            locations: vec![
1651                DuplicateLocation {
1652                    path: root.join("src/ui.ts"),
1653                    line: 10,
1654                    col: 0,
1655                },
1656                DuplicateLocation {
1657                    path: root.join("src/components.ts"),
1658                    line: 25,
1659                    col: 0,
1660                },
1661            ],
1662        });
1663        let elapsed = Duration::from_millis(0);
1664        let output = build_json(&results, &root, elapsed).expect("should serialize");
1665
1666        let dup = &output["duplicate_exports"][0];
1667        assert_eq!(dup["export_name"], "Button");
1668        let locs = dup["locations"].as_array().unwrap();
1669        assert_eq!(locs.len(), 2);
1670        assert_eq!(locs[0]["line"], 10);
1671        assert_eq!(locs[1]["line"], 25);
1672    }
1673
1674    #[test]
1675    fn json_type_only_dependency_contains_expected_fields() {
1676        let root = PathBuf::from("/project");
1677        let mut results = AnalysisResults::default();
1678        results.type_only_dependencies.push(TypeOnlyDependency {
1679            package_name: "zod".to_string(),
1680            path: root.join("package.json"),
1681            line: 8,
1682        });
1683        let elapsed = Duration::from_millis(0);
1684        let output = build_json(&results, &root, elapsed).expect("should serialize");
1685
1686        let dep = &output["type_only_dependencies"][0];
1687        assert_eq!(dep["package_name"], "zod");
1688        assert_eq!(dep["line"], 8);
1689    }
1690
1691    #[test]
1692    fn json_circular_dependency_contains_expected_fields() {
1693        let root = PathBuf::from("/project");
1694        let mut results = AnalysisResults::default();
1695        results.circular_dependencies.push(CircularDependency {
1696            files: vec![
1697                root.join("src/a.ts"),
1698                root.join("src/b.ts"),
1699                root.join("src/c.ts"),
1700            ],
1701            length: 3,
1702            line: 5,
1703            col: 0,
1704            is_cross_package: false,
1705        });
1706        let elapsed = Duration::from_millis(0);
1707        let output = build_json(&results, &root, elapsed).expect("should serialize");
1708
1709        let cycle = &output["circular_dependencies"][0];
1710        assert_eq!(cycle["length"], 3);
1711        assert_eq!(cycle["line"], 5);
1712        let files = cycle["files"].as_array().unwrap();
1713        assert_eq!(files.len(), 3);
1714    }
1715
1716    // ── Re-export tagging ───────────────────────────────────────────
1717
1718    #[test]
1719    fn json_re_export_flagged_correctly() {
1720        let root = PathBuf::from("/project");
1721        let mut results = AnalysisResults::default();
1722        results.unused_exports.push(UnusedExport {
1723            path: root.join("src/index.ts"),
1724            export_name: "reExported".to_string(),
1725            is_type_only: false,
1726            line: 1,
1727            col: 0,
1728            span_start: 0,
1729            is_re_export: true,
1730        });
1731        let elapsed = Duration::from_millis(0);
1732        let output = build_json(&results, &root, elapsed).expect("should serialize");
1733
1734        assert_eq!(output["unused_exports"][0]["is_re_export"], true);
1735    }
1736
1737    // ── Schema version stability ────────────────────────────────────
1738
1739    #[test]
1740    fn json_schema_version_is_4() {
1741        let root = PathBuf::from("/project");
1742        let results = AnalysisResults::default();
1743        let elapsed = Duration::from_millis(0);
1744        let output = build_json(&results, &root, elapsed).expect("should serialize");
1745
1746        assert_eq!(output["schema_version"], SCHEMA_VERSION);
1747        assert_eq!(output["schema_version"], 4);
1748    }
1749
1750    // ── Version string ──────────────────────────────────────────────
1751
1752    #[test]
1753    fn json_version_matches_cargo_pkg_version() {
1754        let root = PathBuf::from("/project");
1755        let results = AnalysisResults::default();
1756        let elapsed = Duration::from_millis(0);
1757        let output = build_json(&results, &root, elapsed).expect("should serialize");
1758
1759        assert_eq!(output["version"], env!("CARGO_PKG_VERSION"));
1760    }
1761
1762    // ── Elapsed time encoding ───────────────────────────────────────
1763
1764    #[test]
1765    fn json_elapsed_ms_zero_duration() {
1766        let root = PathBuf::from("/project");
1767        let results = AnalysisResults::default();
1768        let output = build_json(&results, &root, Duration::ZERO).expect("should serialize");
1769
1770        assert_eq!(output["elapsed_ms"], 0);
1771    }
1772
1773    #[test]
1774    fn json_elapsed_ms_large_duration() {
1775        let root = PathBuf::from("/project");
1776        let results = AnalysisResults::default();
1777        let elapsed = Duration::from_mins(2);
1778        let output = build_json(&results, &root, elapsed).expect("should serialize");
1779
1780        assert_eq!(output["elapsed_ms"], 120_000);
1781    }
1782
1783    #[test]
1784    fn json_elapsed_ms_sub_millisecond_truncated() {
1785        let root = PathBuf::from("/project");
1786        let results = AnalysisResults::default();
1787        // 500 microseconds = 0 milliseconds (truncated)
1788        let elapsed = Duration::from_micros(500);
1789        let output = build_json(&results, &root, elapsed).expect("should serialize");
1790
1791        assert_eq!(output["elapsed_ms"], 0);
1792    }
1793
1794    // ── Multiple issues of same type ────────────────────────────────
1795
1796    #[test]
1797    fn json_multiple_unused_files() {
1798        let root = PathBuf::from("/project");
1799        let mut results = AnalysisResults::default();
1800        results.unused_files.push(UnusedFile {
1801            path: root.join("src/a.ts"),
1802        });
1803        results.unused_files.push(UnusedFile {
1804            path: root.join("src/b.ts"),
1805        });
1806        results.unused_files.push(UnusedFile {
1807            path: root.join("src/c.ts"),
1808        });
1809        let elapsed = Duration::from_millis(0);
1810        let output = build_json(&results, &root, elapsed).expect("should serialize");
1811
1812        assert_eq!(output["unused_files"].as_array().unwrap().len(), 3);
1813        assert_eq!(output["total_issues"], 3);
1814    }
1815
1816    // ── strip_root_prefix unit tests ────────────────────────────────
1817
1818    #[test]
1819    fn strip_root_prefix_on_string_value() {
1820        let mut value = serde_json::json!("/project/src/file.ts");
1821        strip_root_prefix(&mut value, "/project/");
1822        assert_eq!(value, "src/file.ts");
1823    }
1824
1825    #[test]
1826    fn strip_root_prefix_leaves_non_matching_string() {
1827        let mut value = serde_json::json!("/other/src/file.ts");
1828        strip_root_prefix(&mut value, "/project/");
1829        assert_eq!(value, "/other/src/file.ts");
1830    }
1831
1832    #[test]
1833    fn strip_root_prefix_recurses_into_arrays() {
1834        let mut value = serde_json::json!(["/project/a.ts", "/project/b.ts", "/other/c.ts"]);
1835        strip_root_prefix(&mut value, "/project/");
1836        assert_eq!(value[0], "a.ts");
1837        assert_eq!(value[1], "b.ts");
1838        assert_eq!(value[2], "/other/c.ts");
1839    }
1840
1841    #[test]
1842    fn strip_root_prefix_recurses_into_nested_objects() {
1843        let mut value = serde_json::json!({
1844            "outer": {
1845                "path": "/project/src/nested.ts"
1846            }
1847        });
1848        strip_root_prefix(&mut value, "/project/");
1849        assert_eq!(value["outer"]["path"], "src/nested.ts");
1850    }
1851
1852    #[test]
1853    fn strip_root_prefix_leaves_numbers_and_booleans() {
1854        let mut value = serde_json::json!({
1855            "line": 42,
1856            "is_type_only": false,
1857            "path": "/project/src/file.ts"
1858        });
1859        strip_root_prefix(&mut value, "/project/");
1860        assert_eq!(value["line"], 42);
1861        assert_eq!(value["is_type_only"], false);
1862        assert_eq!(value["path"], "src/file.ts");
1863    }
1864
1865    #[test]
1866    fn strip_root_prefix_normalizes_windows_separators() {
1867        let mut value = serde_json::json!(r"/project\src\file.ts");
1868        strip_root_prefix(&mut value, "/project/");
1869        assert_eq!(value, "src/file.ts");
1870    }
1871
1872    #[test]
1873    fn strip_root_prefix_handles_empty_string_after_strip() {
1874        // Edge case: the string IS the prefix (without trailing content).
1875        // This shouldn't happen in practice but should not panic.
1876        let mut value = serde_json::json!("/project/");
1877        strip_root_prefix(&mut value, "/project/");
1878        assert_eq!(value, "");
1879    }
1880
1881    #[test]
1882    fn strip_root_prefix_deeply_nested_array_of_objects() {
1883        let mut value = serde_json::json!({
1884            "groups": [{
1885                "instances": [{
1886                    "file": "/project/src/a.ts"
1887                }, {
1888                    "file": "/project/src/b.ts"
1889                }]
1890            }]
1891        });
1892        strip_root_prefix(&mut value, "/project/");
1893        assert_eq!(value["groups"][0]["instances"][0]["file"], "src/a.ts");
1894        assert_eq!(value["groups"][0]["instances"][1]["file"], "src/b.ts");
1895    }
1896
1897    // ── Full sample results round-trip ──────────────────────────────
1898
1899    #[test]
1900    fn json_full_sample_results_total_issues_correct() {
1901        let root = PathBuf::from("/project");
1902        let results = sample_results(&root);
1903        let elapsed = Duration::from_millis(100);
1904        let output = build_json(&results, &root, elapsed).expect("should serialize");
1905
1906        // sample_results adds one of each issue type (12 total).
1907        // unused_files + unused_exports + unused_types + unused_dependencies
1908        // + unused_dev_dependencies + unused_enum_members + unused_class_members
1909        // + unresolved_imports + unlisted_dependencies + duplicate_exports
1910        // + type_only_dependencies + circular_dependencies
1911        assert_eq!(output["total_issues"], results.total_issues());
1912    }
1913
1914    #[test]
1915    fn json_full_sample_no_absolute_paths_in_output() {
1916        let root = PathBuf::from("/project");
1917        let results = sample_results(&root);
1918        let elapsed = Duration::from_millis(0);
1919        let output = build_json(&results, &root, elapsed).expect("should serialize");
1920
1921        let json_str = serde_json::to_string(&output).expect("should stringify");
1922        // The root prefix should be stripped from all paths.
1923        assert!(!json_str.contains("/project/src/"));
1924        assert!(!json_str.contains("/project/package.json"));
1925    }
1926
1927    // ── JSON output is deterministic ────────────────────────────────
1928
1929    #[test]
1930    fn json_output_is_deterministic() {
1931        let root = PathBuf::from("/project");
1932        let results = sample_results(&root);
1933        let elapsed = Duration::from_millis(50);
1934
1935        let output1 = build_json(&results, &root, elapsed).expect("first build");
1936        let output2 = build_json(&results, &root, elapsed).expect("second build");
1937
1938        assert_eq!(output1, output2);
1939    }
1940
1941    // ── Metadata not overwritten by results fields ──────────────────
1942
1943    #[test]
1944    fn json_results_fields_do_not_shadow_metadata() {
1945        // Ensure that serialized results don't contain keys like "schema_version"
1946        // that could overwrite the metadata fields we insert first.
1947        let root = PathBuf::from("/project");
1948        let results = AnalysisResults::default();
1949        let elapsed = Duration::from_millis(99);
1950        let output = build_json(&results, &root, elapsed).expect("should serialize");
1951
1952        // Metadata should reflect our explicit values, not anything from AnalysisResults.
1953        assert_eq!(output["schema_version"], 4);
1954        assert_eq!(output["elapsed_ms"], 99);
1955    }
1956
1957    // ── All 14 issue type arrays present ────────────────────────────
1958
1959    #[test]
1960    fn json_all_issue_type_arrays_present_in_empty_results() {
1961        let root = PathBuf::from("/project");
1962        let results = AnalysisResults::default();
1963        let elapsed = Duration::from_millis(0);
1964        let output = build_json(&results, &root, elapsed).expect("should serialize");
1965
1966        let expected_arrays = [
1967            "unused_files",
1968            "unused_exports",
1969            "unused_types",
1970            "unused_dependencies",
1971            "unused_dev_dependencies",
1972            "unused_optional_dependencies",
1973            "unused_enum_members",
1974            "unused_class_members",
1975            "unresolved_imports",
1976            "unlisted_dependencies",
1977            "duplicate_exports",
1978            "type_only_dependencies",
1979            "test_only_dependencies",
1980            "circular_dependencies",
1981        ];
1982        for key in &expected_arrays {
1983            assert!(
1984                output[key].is_array(),
1985                "expected '{key}' to be an array in JSON output"
1986            );
1987        }
1988    }
1989
1990    // ── insert_meta ─────────────────────────────────────────────────
1991
1992    #[test]
1993    fn insert_meta_adds_key_to_object() {
1994        let mut output = serde_json::json!({ "foo": 1 });
1995        let meta = serde_json::json!({ "docs": "https://example.com" });
1996        insert_meta(&mut output, meta.clone());
1997        assert_eq!(output["_meta"], meta);
1998    }
1999
2000    #[test]
2001    fn insert_meta_noop_on_non_object() {
2002        let mut output = serde_json::json!([1, 2, 3]);
2003        let meta = serde_json::json!({ "docs": "https://example.com" });
2004        insert_meta(&mut output, meta);
2005        // Should not panic or add anything
2006        assert!(output.is_array());
2007    }
2008
2009    #[test]
2010    fn insert_meta_overwrites_existing_meta() {
2011        let mut output = serde_json::json!({ "_meta": "old" });
2012        let meta = serde_json::json!({ "new": true });
2013        insert_meta(&mut output, meta.clone());
2014        assert_eq!(output["_meta"], meta);
2015    }
2016
2017    // ── build_json_envelope ─────────────────────────────────────────
2018
2019    #[test]
2020    fn build_json_envelope_has_metadata_fields() {
2021        let report = serde_json::json!({ "findings": [] });
2022        let elapsed = Duration::from_millis(42);
2023        let output = build_json_envelope(report, elapsed);
2024
2025        assert_eq!(output["schema_version"], 4);
2026        assert!(output["version"].is_string());
2027        assert_eq!(output["elapsed_ms"], 42);
2028        assert!(output["findings"].is_array());
2029    }
2030
2031    #[test]
2032    fn build_json_envelope_metadata_appears_first() {
2033        let report = serde_json::json!({ "data": "value" });
2034        let output = build_json_envelope(report, Duration::from_millis(10));
2035
2036        let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
2037        assert_eq!(keys[0], "schema_version");
2038        assert_eq!(keys[1], "version");
2039        assert_eq!(keys[2], "elapsed_ms");
2040    }
2041
2042    #[test]
2043    fn build_json_envelope_non_object_report() {
2044        // If report_value is not an Object, only metadata fields appear
2045        let report = serde_json::json!("not an object");
2046        let output = build_json_envelope(report, Duration::from_millis(0));
2047
2048        let obj = output.as_object().unwrap();
2049        assert_eq!(obj.len(), 3);
2050        assert!(obj.contains_key("schema_version"));
2051        assert!(obj.contains_key("version"));
2052        assert!(obj.contains_key("elapsed_ms"));
2053    }
2054
2055    // ── strip_root_prefix with null value ──
2056
2057    #[test]
2058    fn strip_root_prefix_null_unchanged() {
2059        let mut value = serde_json::Value::Null;
2060        strip_root_prefix(&mut value, "/project/");
2061        assert!(value.is_null());
2062    }
2063
2064    // ── strip_root_prefix with empty string ──
2065
2066    #[test]
2067    fn strip_root_prefix_empty_string() {
2068        let mut value = serde_json::json!("");
2069        strip_root_prefix(&mut value, "/project/");
2070        assert_eq!(value, "");
2071    }
2072
2073    // ── strip_root_prefix on mixed nested structure ──
2074
2075    #[test]
2076    fn strip_root_prefix_mixed_types() {
2077        let mut value = serde_json::json!({
2078            "path": "/project/src/file.ts",
2079            "line": 42,
2080            "flag": true,
2081            "nested": {
2082                "items": ["/project/a.ts", 99, null, "/project/b.ts"],
2083                "deep": { "path": "/project/c.ts" }
2084            }
2085        });
2086        strip_root_prefix(&mut value, "/project/");
2087        assert_eq!(value["path"], "src/file.ts");
2088        assert_eq!(value["line"], 42);
2089        assert_eq!(value["flag"], true);
2090        assert_eq!(value["nested"]["items"][0], "a.ts");
2091        assert_eq!(value["nested"]["items"][1], 99);
2092        assert!(value["nested"]["items"][2].is_null());
2093        assert_eq!(value["nested"]["items"][3], "b.ts");
2094        assert_eq!(value["nested"]["deep"]["path"], "c.ts");
2095    }
2096
2097    // ── JSON with explain meta for check ──
2098
2099    #[test]
2100    fn json_check_meta_integrates_correctly() {
2101        let root = PathBuf::from("/project");
2102        let results = AnalysisResults::default();
2103        let elapsed = Duration::from_millis(0);
2104        let mut output = build_json(&results, &root, elapsed).expect("should serialize");
2105        insert_meta(&mut output, crate::explain::check_meta());
2106
2107        assert!(output["_meta"]["docs"].is_string());
2108        assert!(output["_meta"]["rules"].is_object());
2109    }
2110
2111    // ── JSON unused member kind serialization ──
2112
2113    #[test]
2114    fn json_unused_member_kind_serialized() {
2115        let root = PathBuf::from("/project");
2116        let mut results = AnalysisResults::default();
2117        results.unused_enum_members.push(UnusedMember {
2118            path: root.join("src/enums.ts"),
2119            parent_name: "Color".to_string(),
2120            member_name: "Red".to_string(),
2121            kind: MemberKind::EnumMember,
2122            line: 3,
2123            col: 2,
2124        });
2125        results.unused_class_members.push(UnusedMember {
2126            path: root.join("src/class.ts"),
2127            parent_name: "Foo".to_string(),
2128            member_name: "bar".to_string(),
2129            kind: MemberKind::ClassMethod,
2130            line: 10,
2131            col: 4,
2132        });
2133
2134        let elapsed = Duration::from_millis(0);
2135        let output = build_json(&results, &root, elapsed).expect("should serialize");
2136
2137        let enum_member = &output["unused_enum_members"][0];
2138        assert!(enum_member["kind"].is_string());
2139        let class_member = &output["unused_class_members"][0];
2140        assert!(class_member["kind"].is_string());
2141    }
2142
2143    // ── Actions injection ──────────────────────────────────────────
2144
2145    #[test]
2146    fn json_unused_export_has_actions() {
2147        let root = PathBuf::from("/project");
2148        let mut results = AnalysisResults::default();
2149        results.unused_exports.push(UnusedExport {
2150            path: root.join("src/utils.ts"),
2151            export_name: "helperFn".to_string(),
2152            is_type_only: false,
2153            line: 10,
2154            col: 4,
2155            span_start: 120,
2156            is_re_export: false,
2157        });
2158        let output = build_json(&results, &root, Duration::ZERO).unwrap();
2159
2160        let actions = output["unused_exports"][0]["actions"].as_array().unwrap();
2161        assert_eq!(actions.len(), 2);
2162
2163        // Fix action
2164        assert_eq!(actions[0]["type"], "remove-export");
2165        assert_eq!(actions[0]["auto_fixable"], true);
2166        assert!(actions[0].get("note").is_none());
2167
2168        // Suppress action
2169        assert_eq!(actions[1]["type"], "suppress-line");
2170        assert_eq!(
2171            actions[1]["comment"],
2172            "// fallow-ignore-next-line unused-export"
2173        );
2174    }
2175
2176    #[test]
2177    fn json_unused_file_has_file_suppress_and_note() {
2178        let root = PathBuf::from("/project");
2179        let mut results = AnalysisResults::default();
2180        results.unused_files.push(UnusedFile {
2181            path: root.join("src/dead.ts"),
2182        });
2183        let output = build_json(&results, &root, Duration::ZERO).unwrap();
2184
2185        let actions = output["unused_files"][0]["actions"].as_array().unwrap();
2186        assert_eq!(actions[0]["type"], "delete-file");
2187        assert_eq!(actions[0]["auto_fixable"], false);
2188        assert!(actions[0]["note"].is_string());
2189        assert_eq!(actions[1]["type"], "suppress-file");
2190        assert_eq!(actions[1]["comment"], "// fallow-ignore-file unused-file");
2191    }
2192
2193    #[test]
2194    fn json_unused_dependency_has_config_suppress_with_package_name() {
2195        let root = PathBuf::from("/project");
2196        let mut results = AnalysisResults::default();
2197        results.unused_dependencies.push(UnusedDependency {
2198            package_name: "lodash".to_string(),
2199            location: DependencyLocation::Dependencies,
2200            path: root.join("package.json"),
2201            line: 5,
2202        });
2203        let output = build_json(&results, &root, Duration::ZERO).unwrap();
2204
2205        let actions = output["unused_dependencies"][0]["actions"]
2206            .as_array()
2207            .unwrap();
2208        assert_eq!(actions[0]["type"], "remove-dependency");
2209        assert_eq!(actions[0]["auto_fixable"], true);
2210
2211        // Config suppress includes actual package name
2212        assert_eq!(actions[1]["type"], "add-to-config");
2213        assert_eq!(actions[1]["config_key"], "ignoreDependencies");
2214        assert_eq!(actions[1]["value"], "lodash");
2215    }
2216
2217    #[test]
2218    fn json_empty_results_have_no_actions_in_empty_arrays() {
2219        let root = PathBuf::from("/project");
2220        let results = AnalysisResults::default();
2221        let output = build_json(&results, &root, Duration::ZERO).unwrap();
2222
2223        // Empty arrays should remain empty
2224        assert!(output["unused_exports"].as_array().unwrap().is_empty());
2225        assert!(output["unused_files"].as_array().unwrap().is_empty());
2226    }
2227
2228    #[test]
2229    fn json_all_issue_types_have_actions() {
2230        let root = PathBuf::from("/project");
2231        let results = sample_results(&root);
2232        let output = build_json(&results, &root, Duration::ZERO).unwrap();
2233
2234        let issue_keys = [
2235            "unused_files",
2236            "unused_exports",
2237            "unused_types",
2238            "unused_dependencies",
2239            "unused_dev_dependencies",
2240            "unused_optional_dependencies",
2241            "unused_enum_members",
2242            "unused_class_members",
2243            "unresolved_imports",
2244            "unlisted_dependencies",
2245            "duplicate_exports",
2246            "type_only_dependencies",
2247            "test_only_dependencies",
2248            "circular_dependencies",
2249        ];
2250
2251        for key in &issue_keys {
2252            let arr = output[key].as_array().unwrap();
2253            if !arr.is_empty() {
2254                let actions = arr[0]["actions"].as_array();
2255                assert!(
2256                    actions.is_some() && !actions.unwrap().is_empty(),
2257                    "missing actions for {key}"
2258                );
2259            }
2260        }
2261    }
2262
2263    // ── Health actions injection ───────────────────────────────────
2264
2265    #[test]
2266    fn health_finding_has_actions() {
2267        let mut output = serde_json::json!({
2268            "findings": [{
2269                "path": "src/utils.ts",
2270                "name": "processData",
2271                "line": 10,
2272                "col": 0,
2273                "cyclomatic": 25,
2274                "cognitive": 30,
2275                "line_count": 150,
2276                "exceeded": "both"
2277            }]
2278        });
2279
2280        inject_health_actions(&mut output);
2281
2282        let actions = output["findings"][0]["actions"].as_array().unwrap();
2283        assert_eq!(actions.len(), 2);
2284        assert_eq!(actions[0]["type"], "refactor-function");
2285        assert_eq!(actions[0]["auto_fixable"], false);
2286        assert!(
2287            actions[0]["description"]
2288                .as_str()
2289                .unwrap()
2290                .contains("processData")
2291        );
2292        assert_eq!(actions[1]["type"], "suppress-line");
2293        assert_eq!(
2294            actions[1]["comment"],
2295            "// fallow-ignore-next-line complexity"
2296        );
2297    }
2298
2299    #[test]
2300    fn refactoring_target_has_actions() {
2301        let mut output = serde_json::json!({
2302            "targets": [{
2303                "path": "src/big-module.ts",
2304                "priority": 85.0,
2305                "efficiency": 42.5,
2306                "recommendation": "Split module: 12 exports, 4 unused",
2307                "category": "split_high_impact",
2308                "effort": "medium",
2309                "confidence": "high",
2310                "evidence": { "unused_exports": 4 }
2311            }]
2312        });
2313
2314        inject_health_actions(&mut output);
2315
2316        let actions = output["targets"][0]["actions"].as_array().unwrap();
2317        assert_eq!(actions.len(), 2);
2318        assert_eq!(actions[0]["type"], "apply-refactoring");
2319        assert_eq!(
2320            actions[0]["description"],
2321            "Split module: 12 exports, 4 unused"
2322        );
2323        assert_eq!(actions[0]["category"], "split_high_impact");
2324        // Target with evidence gets suppress action
2325        assert_eq!(actions[1]["type"], "suppress-line");
2326    }
2327
2328    #[test]
2329    fn refactoring_target_without_evidence_has_no_suppress() {
2330        let mut output = serde_json::json!({
2331            "targets": [{
2332                "path": "src/simple.ts",
2333                "priority": 30.0,
2334                "efficiency": 15.0,
2335                "recommendation": "Consider extracting helper functions",
2336                "category": "extract_complex_functions",
2337                "effort": "small",
2338                "confidence": "medium"
2339            }]
2340        });
2341
2342        inject_health_actions(&mut output);
2343
2344        let actions = output["targets"][0]["actions"].as_array().unwrap();
2345        assert_eq!(actions.len(), 1);
2346        assert_eq!(actions[0]["type"], "apply-refactoring");
2347    }
2348
2349    #[test]
2350    fn health_empty_findings_no_actions() {
2351        let mut output = serde_json::json!({
2352            "findings": [],
2353            "targets": []
2354        });
2355
2356        inject_health_actions(&mut output);
2357
2358        assert!(output["findings"].as_array().unwrap().is_empty());
2359        assert!(output["targets"].as_array().unwrap().is_empty());
2360    }
2361
2362    #[test]
2363    fn hotspot_has_actions() {
2364        let mut output = serde_json::json!({
2365            "hotspots": [{
2366                "path": "src/utils.ts",
2367                "complexity_score": 45.0,
2368                "churn_score": 12,
2369                "hotspot_score": 540.0
2370            }]
2371        });
2372
2373        inject_health_actions(&mut output);
2374
2375        let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2376        assert_eq!(actions.len(), 2);
2377        assert_eq!(actions[0]["type"], "refactor-file");
2378        assert!(
2379            actions[0]["description"]
2380                .as_str()
2381                .unwrap()
2382                .contains("src/utils.ts")
2383        );
2384        assert_eq!(actions[1]["type"], "add-tests");
2385    }
2386
2387    #[test]
2388    fn hotspot_low_bus_factor_emits_action() {
2389        let mut output = serde_json::json!({
2390            "hotspots": [{
2391                "path": "src/api.ts",
2392                "ownership": {
2393                    "bus_factor": 1,
2394                    "contributor_count": 1,
2395                    "top_contributor": {"identifier": "alice@x", "share": 1.0, "stale_days": 5, "commits": 30},
2396                    "unowned": null,
2397                    "drift": false,
2398                }
2399            }]
2400        });
2401
2402        inject_health_actions(&mut output);
2403
2404        let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2405        assert!(
2406            actions
2407                .iter()
2408                .filter_map(|a| a["type"].as_str())
2409                .any(|t| t == "low-bus-factor"),
2410            "low-bus-factor action should be present",
2411        );
2412        let bus = actions
2413            .iter()
2414            .find(|a| a["type"] == "low-bus-factor")
2415            .unwrap();
2416        assert!(bus["description"].as_str().unwrap().contains("alice@x"));
2417    }
2418
2419    #[test]
2420    fn hotspot_unowned_emits_action_with_pattern() {
2421        let mut output = serde_json::json!({
2422            "hotspots": [{
2423                "path": "src/api/users.ts",
2424                "ownership": {
2425                    "bus_factor": 2,
2426                    "contributor_count": 4,
2427                    "top_contributor": {"identifier": "alice@x", "share": 0.5, "stale_days": 5, "commits": 10},
2428                    "unowned": true,
2429                    "drift": false,
2430                }
2431            }]
2432        });
2433
2434        inject_health_actions(&mut output);
2435
2436        let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2437        let unowned = actions
2438            .iter()
2439            .find(|a| a["type"] == "unowned-hotspot")
2440            .expect("unowned-hotspot action should be present");
2441        // Deepest directory containing the file -> /src/api/
2442        // (file `users.ts` is at depth 2, so the deepest dir is `/src/api/`).
2443        assert_eq!(unowned["suggested_pattern"], "/src/api/");
2444        assert_eq!(unowned["heuristic"], "directory-deepest");
2445    }
2446
2447    #[test]
2448    fn hotspot_unowned_skipped_when_codeowners_missing() {
2449        let mut output = serde_json::json!({
2450            "hotspots": [{
2451                "path": "src/api.ts",
2452                "ownership": {
2453                    "bus_factor": 2,
2454                    "contributor_count": 4,
2455                    "top_contributor": {"identifier": "alice@x", "share": 0.5, "stale_days": 5, "commits": 10},
2456                    "unowned": null,
2457                    "drift": false,
2458                }
2459            }]
2460        });
2461
2462        inject_health_actions(&mut output);
2463
2464        let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2465        assert!(
2466            !actions.iter().any(|a| a["type"] == "unowned-hotspot"),
2467            "unowned action must not fire when CODEOWNERS file is absent"
2468        );
2469    }
2470
2471    #[test]
2472    fn hotspot_drift_emits_action() {
2473        let mut output = serde_json::json!({
2474            "hotspots": [{
2475                "path": "src/old.ts",
2476                "ownership": {
2477                    "bus_factor": 1,
2478                    "contributor_count": 2,
2479                    "top_contributor": {"identifier": "bob@x", "share": 0.9, "stale_days": 1, "commits": 18},
2480                    "unowned": null,
2481                    "drift": true,
2482                    "drift_reason": "original author alice@x has 5% share",
2483                }
2484            }]
2485        });
2486
2487        inject_health_actions(&mut output);
2488
2489        let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2490        let drift = actions
2491            .iter()
2492            .find(|a| a["type"] == "ownership-drift")
2493            .expect("ownership-drift action should be present");
2494        assert!(drift["description"].as_str().unwrap().contains("alice@x"));
2495    }
2496
2497    // ── suggest_codeowners_pattern ─────────────────────────────────
2498
2499    #[test]
2500    fn codeowners_pattern_uses_deepest_directory() {
2501        // Deepest dir keeps the suggestion tightly-scoped; the prior
2502        // "first two levels" heuristic over-generalized in monorepos.
2503        assert_eq!(
2504            suggest_codeowners_pattern("src/api/users/handlers.ts"),
2505            "/src/api/users/"
2506        );
2507    }
2508
2509    #[test]
2510    fn codeowners_pattern_for_root_file() {
2511        assert_eq!(suggest_codeowners_pattern("README.md"), "/README.md");
2512    }
2513
2514    #[test]
2515    fn codeowners_pattern_normalizes_backslashes() {
2516        assert_eq!(
2517            suggest_codeowners_pattern("src\\api\\users.ts"),
2518            "/src/api/"
2519        );
2520    }
2521
2522    #[test]
2523    fn codeowners_pattern_two_level_path() {
2524        assert_eq!(suggest_codeowners_pattern("src/foo.ts"), "/src/");
2525    }
2526
2527    #[test]
2528    fn health_finding_suppress_has_placement() {
2529        let mut output = serde_json::json!({
2530            "findings": [{
2531                "path": "src/utils.ts",
2532                "name": "processData",
2533                "line": 10,
2534                "col": 0,
2535                "cyclomatic": 25,
2536                "cognitive": 30,
2537                "line_count": 150,
2538                "exceeded": "both"
2539            }]
2540        });
2541
2542        inject_health_actions(&mut output);
2543
2544        let suppress = &output["findings"][0]["actions"][1];
2545        assert_eq!(suppress["placement"], "above-function-declaration");
2546    }
2547
2548    // ── Duplication actions injection ─────────────────────────────
2549
2550    #[test]
2551    fn clone_family_has_actions() {
2552        let mut output = serde_json::json!({
2553            "clone_families": [{
2554                "files": ["src/a.ts", "src/b.ts"],
2555                "groups": [
2556                    { "instances": [{"file": "src/a.ts"}, {"file": "src/b.ts"}], "token_count": 100, "line_count": 20 }
2557                ],
2558                "total_duplicated_lines": 20,
2559                "total_duplicated_tokens": 100,
2560                "suggestions": [
2561                    { "kind": "ExtractFunction", "description": "Extract shared validation logic", "estimated_savings": 15 }
2562                ]
2563            }]
2564        });
2565
2566        inject_dupes_actions(&mut output);
2567
2568        let actions = output["clone_families"][0]["actions"].as_array().unwrap();
2569        assert_eq!(actions.len(), 3);
2570        assert_eq!(actions[0]["type"], "extract-shared");
2571        assert_eq!(actions[0]["auto_fixable"], false);
2572        assert!(
2573            actions[0]["description"]
2574                .as_str()
2575                .unwrap()
2576                .contains("20 lines")
2577        );
2578        // Suggestion forwarded as action
2579        assert_eq!(actions[1]["type"], "apply-suggestion");
2580        assert!(
2581            actions[1]["description"]
2582                .as_str()
2583                .unwrap()
2584                .contains("validation logic")
2585        );
2586        // Suppress action
2587        assert_eq!(actions[2]["type"], "suppress-line");
2588        assert_eq!(
2589            actions[2]["comment"],
2590            "// fallow-ignore-next-line code-duplication"
2591        );
2592    }
2593
2594    #[test]
2595    fn clone_group_has_actions() {
2596        let mut output = serde_json::json!({
2597            "clone_groups": [{
2598                "instances": [
2599                    {"file": "src/a.ts", "start_line": 1, "end_line": 10},
2600                    {"file": "src/b.ts", "start_line": 5, "end_line": 14}
2601                ],
2602                "token_count": 50,
2603                "line_count": 10
2604            }]
2605        });
2606
2607        inject_dupes_actions(&mut output);
2608
2609        let actions = output["clone_groups"][0]["actions"].as_array().unwrap();
2610        assert_eq!(actions.len(), 2);
2611        assert_eq!(actions[0]["type"], "extract-shared");
2612        assert!(
2613            actions[0]["description"]
2614                .as_str()
2615                .unwrap()
2616                .contains("10 lines")
2617        );
2618        assert!(
2619            actions[0]["description"]
2620                .as_str()
2621                .unwrap()
2622                .contains("2 instances")
2623        );
2624        assert_eq!(actions[1]["type"], "suppress-line");
2625    }
2626
2627    #[test]
2628    fn dupes_empty_results_no_actions() {
2629        let mut output = serde_json::json!({
2630            "clone_families": [],
2631            "clone_groups": []
2632        });
2633
2634        inject_dupes_actions(&mut output);
2635
2636        assert!(output["clone_families"].as_array().unwrap().is_empty());
2637        assert!(output["clone_groups"].as_array().unwrap().is_empty());
2638    }
2639}