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
1055/// Build the JSON envelope + duplication payload shared by `print_duplication_json`
1056/// and the programmatic API surface.
1057///
1058/// # Errors
1059///
1060/// Returns an error if the report cannot be serialized to JSON.
1061pub fn build_duplication_json(
1062    report: &DuplicationReport,
1063    root: &Path,
1064    elapsed: Duration,
1065    explain: bool,
1066) -> Result<serde_json::Value, serde_json::Error> {
1067    let report_value = serde_json::to_value(report)?;
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    Ok(output)
1079}
1080
1081pub(super) fn print_duplication_json(
1082    report: &DuplicationReport,
1083    root: &Path,
1084    elapsed: Duration,
1085    explain: bool,
1086) -> ExitCode {
1087    match build_duplication_json(report, root, elapsed, explain) {
1088        Ok(output) => emit_json(&output, "JSON"),
1089        Err(e) => {
1090            eprintln!("Error: failed to serialize duplication report: {e}");
1091            ExitCode::from(2)
1092        }
1093    }
1094}
1095
1096pub(super) fn print_trace_json<T: serde::Serialize>(value: &T) {
1097    match serde_json::to_string_pretty(value) {
1098        Ok(json) => println!("{json}"),
1099        Err(e) => {
1100            eprintln!("Error: failed to serialize trace output: {e}");
1101            #[expect(
1102                clippy::exit,
1103                reason = "fatal serialization error requires immediate exit"
1104            )]
1105            std::process::exit(2);
1106        }
1107    }
1108}
1109
1110#[cfg(test)]
1111mod tests {
1112    use super::*;
1113    use crate::health_types::{
1114        ProductionCoverageAction, ProductionCoverageConfidence, ProductionCoverageEvidence,
1115        ProductionCoverageFinding, ProductionCoverageHotPath, ProductionCoverageMessage,
1116        ProductionCoverageReport, ProductionCoverageReportVerdict, ProductionCoverageSummary,
1117        ProductionCoverageVerdict, ProductionCoverageWatermark,
1118    };
1119    use crate::report::test_helpers::sample_results;
1120    use fallow_core::extract::MemberKind;
1121    use fallow_core::results::*;
1122    use std::path::PathBuf;
1123    use std::time::Duration;
1124
1125    #[test]
1126    fn json_output_has_metadata_fields() {
1127        let root = PathBuf::from("/project");
1128        let results = AnalysisResults::default();
1129        let elapsed = Duration::from_millis(123);
1130        let output = build_json(&results, &root, elapsed).expect("should serialize");
1131
1132        assert_eq!(output["schema_version"], 4);
1133        assert!(output["version"].is_string());
1134        assert_eq!(output["elapsed_ms"], 123);
1135        assert_eq!(output["total_issues"], 0);
1136    }
1137
1138    #[test]
1139    fn json_output_includes_issue_arrays() {
1140        let root = PathBuf::from("/project");
1141        let results = sample_results(&root);
1142        let elapsed = Duration::from_millis(50);
1143        let output = build_json(&results, &root, elapsed).expect("should serialize");
1144
1145        assert_eq!(output["unused_files"].as_array().unwrap().len(), 1);
1146        assert_eq!(output["unused_exports"].as_array().unwrap().len(), 1);
1147        assert_eq!(output["unused_types"].as_array().unwrap().len(), 1);
1148        assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 1);
1149        assert_eq!(
1150            output["unused_dev_dependencies"].as_array().unwrap().len(),
1151            1
1152        );
1153        assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 1);
1154        assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 1);
1155        assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 1);
1156        assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 1);
1157        assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 1);
1158        assert_eq!(
1159            output["type_only_dependencies"].as_array().unwrap().len(),
1160            1
1161        );
1162        assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 1);
1163    }
1164
1165    #[test]
1166    fn health_json_includes_production_coverage_with_relative_paths_and_actions() {
1167        let root = PathBuf::from("/project");
1168        let report = crate::health_types::HealthReport {
1169            production_coverage: Some(ProductionCoverageReport {
1170                verdict: ProductionCoverageReportVerdict::ColdCodeDetected,
1171                summary: ProductionCoverageSummary {
1172                    functions_tracked: 3,
1173                    functions_hit: 1,
1174                    functions_unhit: 1,
1175                    functions_untracked: 1,
1176                    coverage_percent: 33.3,
1177                    trace_count: 2_847_291,
1178                    period_days: 30,
1179                    deployments_seen: 14,
1180                    capture_quality: Some(crate::health_types::ProductionCoverageCaptureQuality {
1181                        window_seconds: 720,
1182                        instances_observed: 1,
1183                        lazy_parse_warning: true,
1184                        untracked_ratio_percent: 42.5,
1185                    }),
1186                },
1187                findings: vec![ProductionCoverageFinding {
1188                    id: "fallow:prod:deadbeef".to_owned(),
1189                    path: root.join("src/cold.ts"),
1190                    function: "coldPath".to_owned(),
1191                    line: 12,
1192                    verdict: ProductionCoverageVerdict::ReviewRequired,
1193                    invocations: Some(0),
1194                    confidence: ProductionCoverageConfidence::Medium,
1195                    evidence: ProductionCoverageEvidence {
1196                        static_status: "used".to_owned(),
1197                        test_coverage: "not_covered".to_owned(),
1198                        v8_tracking: "tracked".to_owned(),
1199                        untracked_reason: None,
1200                        observation_days: 30,
1201                        deployments_observed: 14,
1202                    },
1203                    actions: vec![ProductionCoverageAction {
1204                        kind: "review-deletion".to_owned(),
1205                        description: "Tracked in production coverage with zero invocations."
1206                            .to_owned(),
1207                        auto_fixable: false,
1208                    }],
1209                }],
1210                hot_paths: vec![ProductionCoverageHotPath {
1211                    id: "fallow:hot:cafebabe".to_owned(),
1212                    path: root.join("src/hot.ts"),
1213                    function: "hotPath".to_owned(),
1214                    line: 3,
1215                    invocations: 250,
1216                    percentile: 99,
1217                    actions: vec![],
1218                }],
1219                watermark: Some(ProductionCoverageWatermark::LicenseExpiredGrace),
1220                warnings: vec![ProductionCoverageMessage {
1221                    code: "partial-merge".to_owned(),
1222                    message: "Merged coverage omitted one chunk.".to_owned(),
1223                }],
1224            }),
1225            ..Default::default()
1226        };
1227
1228        let report_value = serde_json::to_value(&report).expect("should serialize health report");
1229        let mut output = build_json_envelope(report_value, Duration::from_millis(7));
1230        strip_root_prefix(&mut output, "/project/");
1231        inject_health_actions(&mut output);
1232
1233        assert_eq!(
1234            output["production_coverage"]["verdict"],
1235            serde_json::Value::String("cold-code-detected".to_owned())
1236        );
1237        assert_eq!(
1238            output["production_coverage"]["summary"]["functions_tracked"],
1239            serde_json::Value::from(3)
1240        );
1241        assert_eq!(
1242            output["production_coverage"]["summary"]["coverage_percent"],
1243            serde_json::Value::from(33.3)
1244        );
1245        let finding = &output["production_coverage"]["findings"][0];
1246        assert_eq!(finding["path"], "src/cold.ts");
1247        assert_eq!(finding["verdict"], "review_required");
1248        assert_eq!(finding["id"], "fallow:prod:deadbeef");
1249        assert_eq!(finding["actions"][0]["type"], "review-deletion");
1250        let hot_path = &output["production_coverage"]["hot_paths"][0];
1251        assert_eq!(hot_path["path"], "src/hot.ts");
1252        assert_eq!(hot_path["function"], "hotPath");
1253        assert_eq!(hot_path["percentile"], 99);
1254        assert_eq!(
1255            output["production_coverage"]["watermark"],
1256            serde_json::Value::String("license-expired-grace".to_owned())
1257        );
1258        assert_eq!(
1259            output["production_coverage"]["warnings"][0]["code"],
1260            serde_json::Value::String("partial-merge".to_owned())
1261        );
1262    }
1263
1264    #[test]
1265    fn json_metadata_fields_appear_first() {
1266        let root = PathBuf::from("/project");
1267        let results = AnalysisResults::default();
1268        let elapsed = Duration::from_millis(0);
1269        let output = build_json(&results, &root, elapsed).expect("should serialize");
1270        let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
1271        assert_eq!(keys[0], "schema_version");
1272        assert_eq!(keys[1], "version");
1273        assert_eq!(keys[2], "elapsed_ms");
1274        assert_eq!(keys[3], "total_issues");
1275    }
1276
1277    #[test]
1278    fn json_total_issues_matches_results() {
1279        let root = PathBuf::from("/project");
1280        let results = sample_results(&root);
1281        let total = results.total_issues();
1282        let elapsed = Duration::from_millis(0);
1283        let output = build_json(&results, &root, elapsed).expect("should serialize");
1284
1285        assert_eq!(output["total_issues"], total);
1286    }
1287
1288    #[test]
1289    fn json_unused_export_contains_expected_fields() {
1290        let root = PathBuf::from("/project");
1291        let mut results = AnalysisResults::default();
1292        results.unused_exports.push(UnusedExport {
1293            path: root.join("src/utils.ts"),
1294            export_name: "helperFn".to_string(),
1295            is_type_only: false,
1296            line: 10,
1297            col: 4,
1298            span_start: 120,
1299            is_re_export: false,
1300        });
1301        let elapsed = Duration::from_millis(0);
1302        let output = build_json(&results, &root, elapsed).expect("should serialize");
1303
1304        let export = &output["unused_exports"][0];
1305        assert_eq!(export["export_name"], "helperFn");
1306        assert_eq!(export["line"], 10);
1307        assert_eq!(export["col"], 4);
1308        assert_eq!(export["is_type_only"], false);
1309        assert_eq!(export["span_start"], 120);
1310        assert_eq!(export["is_re_export"], false);
1311    }
1312
1313    #[test]
1314    fn json_serializes_to_valid_json() {
1315        let root = PathBuf::from("/project");
1316        let results = sample_results(&root);
1317        let elapsed = Duration::from_millis(42);
1318        let output = build_json(&results, &root, elapsed).expect("should serialize");
1319
1320        let json_str = serde_json::to_string_pretty(&output).expect("should stringify");
1321        let reparsed: serde_json::Value =
1322            serde_json::from_str(&json_str).expect("JSON output should be valid JSON");
1323        assert_eq!(reparsed, output);
1324    }
1325
1326    // ── Empty results ───────────────────────────────────────────────
1327
1328    #[test]
1329    fn json_empty_results_produce_valid_structure() {
1330        let root = PathBuf::from("/project");
1331        let results = AnalysisResults::default();
1332        let elapsed = Duration::from_millis(0);
1333        let output = build_json(&results, &root, elapsed).expect("should serialize");
1334
1335        assert_eq!(output["total_issues"], 0);
1336        assert_eq!(output["unused_files"].as_array().unwrap().len(), 0);
1337        assert_eq!(output["unused_exports"].as_array().unwrap().len(), 0);
1338        assert_eq!(output["unused_types"].as_array().unwrap().len(), 0);
1339        assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 0);
1340        assert_eq!(
1341            output["unused_dev_dependencies"].as_array().unwrap().len(),
1342            0
1343        );
1344        assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 0);
1345        assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 0);
1346        assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 0);
1347        assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 0);
1348        assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 0);
1349        assert_eq!(
1350            output["type_only_dependencies"].as_array().unwrap().len(),
1351            0
1352        );
1353        assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 0);
1354    }
1355
1356    #[test]
1357    fn json_empty_results_round_trips_through_string() {
1358        let root = PathBuf::from("/project");
1359        let results = AnalysisResults::default();
1360        let elapsed = Duration::from_millis(0);
1361        let output = build_json(&results, &root, elapsed).expect("should serialize");
1362
1363        let json_str = serde_json::to_string(&output).expect("should stringify");
1364        let reparsed: serde_json::Value =
1365            serde_json::from_str(&json_str).expect("should parse back");
1366        assert_eq!(reparsed["total_issues"], 0);
1367    }
1368
1369    // ── Path stripping ──────────────────────────────────────────────
1370
1371    #[test]
1372    fn json_paths_are_relative_to_root() {
1373        let root = PathBuf::from("/project");
1374        let mut results = AnalysisResults::default();
1375        results.unused_files.push(UnusedFile {
1376            path: root.join("src/deep/nested/file.ts"),
1377        });
1378        let elapsed = Duration::from_millis(0);
1379        let output = build_json(&results, &root, elapsed).expect("should serialize");
1380
1381        let path = output["unused_files"][0]["path"].as_str().unwrap();
1382        assert_eq!(path, "src/deep/nested/file.ts");
1383        assert!(!path.starts_with("/project"));
1384    }
1385
1386    #[test]
1387    fn json_strips_root_from_nested_locations() {
1388        let root = PathBuf::from("/project");
1389        let mut results = AnalysisResults::default();
1390        results.unlisted_dependencies.push(UnlistedDependency {
1391            package_name: "chalk".to_string(),
1392            imported_from: vec![ImportSite {
1393                path: root.join("src/cli.ts"),
1394                line: 2,
1395                col: 0,
1396            }],
1397        });
1398        let elapsed = Duration::from_millis(0);
1399        let output = build_json(&results, &root, elapsed).expect("should serialize");
1400
1401        let site_path = output["unlisted_dependencies"][0]["imported_from"][0]["path"]
1402            .as_str()
1403            .unwrap();
1404        assert_eq!(site_path, "src/cli.ts");
1405    }
1406
1407    #[test]
1408    fn json_strips_root_from_duplicate_export_locations() {
1409        let root = PathBuf::from("/project");
1410        let mut results = AnalysisResults::default();
1411        results.duplicate_exports.push(DuplicateExport {
1412            export_name: "Config".to_string(),
1413            locations: vec![
1414                DuplicateLocation {
1415                    path: root.join("src/config.ts"),
1416                    line: 15,
1417                    col: 0,
1418                },
1419                DuplicateLocation {
1420                    path: root.join("src/types.ts"),
1421                    line: 30,
1422                    col: 0,
1423                },
1424            ],
1425        });
1426        let elapsed = Duration::from_millis(0);
1427        let output = build_json(&results, &root, elapsed).expect("should serialize");
1428
1429        let loc0 = output["duplicate_exports"][0]["locations"][0]["path"]
1430            .as_str()
1431            .unwrap();
1432        let loc1 = output["duplicate_exports"][0]["locations"][1]["path"]
1433            .as_str()
1434            .unwrap();
1435        assert_eq!(loc0, "src/config.ts");
1436        assert_eq!(loc1, "src/types.ts");
1437    }
1438
1439    #[test]
1440    fn json_strips_root_from_circular_dependency_files() {
1441        let root = PathBuf::from("/project");
1442        let mut results = AnalysisResults::default();
1443        results.circular_dependencies.push(CircularDependency {
1444            files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1445            length: 2,
1446            line: 1,
1447            col: 0,
1448            is_cross_package: false,
1449        });
1450        let elapsed = Duration::from_millis(0);
1451        let output = build_json(&results, &root, elapsed).expect("should serialize");
1452
1453        let files = output["circular_dependencies"][0]["files"]
1454            .as_array()
1455            .unwrap();
1456        assert_eq!(files[0].as_str().unwrap(), "src/a.ts");
1457        assert_eq!(files[1].as_str().unwrap(), "src/b.ts");
1458    }
1459
1460    #[test]
1461    fn json_path_outside_root_not_stripped() {
1462        let root = PathBuf::from("/project");
1463        let mut results = AnalysisResults::default();
1464        results.unused_files.push(UnusedFile {
1465            path: PathBuf::from("/other/project/src/file.ts"),
1466        });
1467        let elapsed = Duration::from_millis(0);
1468        let output = build_json(&results, &root, elapsed).expect("should serialize");
1469
1470        let path = output["unused_files"][0]["path"].as_str().unwrap();
1471        assert!(path.contains("/other/project/"));
1472    }
1473
1474    // ── Individual issue type field verification ────────────────────
1475
1476    #[test]
1477    fn json_unused_file_contains_path() {
1478        let root = PathBuf::from("/project");
1479        let mut results = AnalysisResults::default();
1480        results.unused_files.push(UnusedFile {
1481            path: root.join("src/orphan.ts"),
1482        });
1483        let elapsed = Duration::from_millis(0);
1484        let output = build_json(&results, &root, elapsed).expect("should serialize");
1485
1486        let file = &output["unused_files"][0];
1487        assert_eq!(file["path"], "src/orphan.ts");
1488    }
1489
1490    #[test]
1491    fn json_unused_type_contains_expected_fields() {
1492        let root = PathBuf::from("/project");
1493        let mut results = AnalysisResults::default();
1494        results.unused_types.push(UnusedExport {
1495            path: root.join("src/types.ts"),
1496            export_name: "OldInterface".to_string(),
1497            is_type_only: true,
1498            line: 20,
1499            col: 0,
1500            span_start: 300,
1501            is_re_export: false,
1502        });
1503        let elapsed = Duration::from_millis(0);
1504        let output = build_json(&results, &root, elapsed).expect("should serialize");
1505
1506        let typ = &output["unused_types"][0];
1507        assert_eq!(typ["export_name"], "OldInterface");
1508        assert_eq!(typ["is_type_only"], true);
1509        assert_eq!(typ["line"], 20);
1510        assert_eq!(typ["path"], "src/types.ts");
1511    }
1512
1513    #[test]
1514    fn json_unused_dependency_contains_expected_fields() {
1515        let root = PathBuf::from("/project");
1516        let mut results = AnalysisResults::default();
1517        results.unused_dependencies.push(UnusedDependency {
1518            package_name: "axios".to_string(),
1519            location: DependencyLocation::Dependencies,
1520            path: root.join("package.json"),
1521            line: 10,
1522        });
1523        let elapsed = Duration::from_millis(0);
1524        let output = build_json(&results, &root, elapsed).expect("should serialize");
1525
1526        let dep = &output["unused_dependencies"][0];
1527        assert_eq!(dep["package_name"], "axios");
1528        assert_eq!(dep["line"], 10);
1529    }
1530
1531    #[test]
1532    fn json_unused_dev_dependency_contains_expected_fields() {
1533        let root = PathBuf::from("/project");
1534        let mut results = AnalysisResults::default();
1535        results.unused_dev_dependencies.push(UnusedDependency {
1536            package_name: "vitest".to_string(),
1537            location: DependencyLocation::DevDependencies,
1538            path: root.join("package.json"),
1539            line: 15,
1540        });
1541        let elapsed = Duration::from_millis(0);
1542        let output = build_json(&results, &root, elapsed).expect("should serialize");
1543
1544        let dep = &output["unused_dev_dependencies"][0];
1545        assert_eq!(dep["package_name"], "vitest");
1546    }
1547
1548    #[test]
1549    fn json_unused_optional_dependency_contains_expected_fields() {
1550        let root = PathBuf::from("/project");
1551        let mut results = AnalysisResults::default();
1552        results.unused_optional_dependencies.push(UnusedDependency {
1553            package_name: "fsevents".to_string(),
1554            location: DependencyLocation::OptionalDependencies,
1555            path: root.join("package.json"),
1556            line: 12,
1557        });
1558        let elapsed = Duration::from_millis(0);
1559        let output = build_json(&results, &root, elapsed).expect("should serialize");
1560
1561        let dep = &output["unused_optional_dependencies"][0];
1562        assert_eq!(dep["package_name"], "fsevents");
1563        assert_eq!(output["total_issues"], 1);
1564    }
1565
1566    #[test]
1567    fn json_unused_enum_member_contains_expected_fields() {
1568        let root = PathBuf::from("/project");
1569        let mut results = AnalysisResults::default();
1570        results.unused_enum_members.push(UnusedMember {
1571            path: root.join("src/enums.ts"),
1572            parent_name: "Color".to_string(),
1573            member_name: "Purple".to_string(),
1574            kind: MemberKind::EnumMember,
1575            line: 5,
1576            col: 2,
1577        });
1578        let elapsed = Duration::from_millis(0);
1579        let output = build_json(&results, &root, elapsed).expect("should serialize");
1580
1581        let member = &output["unused_enum_members"][0];
1582        assert_eq!(member["parent_name"], "Color");
1583        assert_eq!(member["member_name"], "Purple");
1584        assert_eq!(member["line"], 5);
1585        assert_eq!(member["path"], "src/enums.ts");
1586    }
1587
1588    #[test]
1589    fn json_unused_class_member_contains_expected_fields() {
1590        let root = PathBuf::from("/project");
1591        let mut results = AnalysisResults::default();
1592        results.unused_class_members.push(UnusedMember {
1593            path: root.join("src/api.ts"),
1594            parent_name: "ApiClient".to_string(),
1595            member_name: "deprecatedFetch".to_string(),
1596            kind: MemberKind::ClassMethod,
1597            line: 100,
1598            col: 4,
1599        });
1600        let elapsed = Duration::from_millis(0);
1601        let output = build_json(&results, &root, elapsed).expect("should serialize");
1602
1603        let member = &output["unused_class_members"][0];
1604        assert_eq!(member["parent_name"], "ApiClient");
1605        assert_eq!(member["member_name"], "deprecatedFetch");
1606        assert_eq!(member["line"], 100);
1607    }
1608
1609    #[test]
1610    fn json_unresolved_import_contains_expected_fields() {
1611        let root = PathBuf::from("/project");
1612        let mut results = AnalysisResults::default();
1613        results.unresolved_imports.push(UnresolvedImport {
1614            path: root.join("src/app.ts"),
1615            specifier: "@acme/missing-pkg".to_string(),
1616            line: 7,
1617            col: 0,
1618            specifier_col: 0,
1619        });
1620        let elapsed = Duration::from_millis(0);
1621        let output = build_json(&results, &root, elapsed).expect("should serialize");
1622
1623        let import = &output["unresolved_imports"][0];
1624        assert_eq!(import["specifier"], "@acme/missing-pkg");
1625        assert_eq!(import["line"], 7);
1626        assert_eq!(import["path"], "src/app.ts");
1627    }
1628
1629    #[test]
1630    fn json_unlisted_dependency_contains_import_sites() {
1631        let root = PathBuf::from("/project");
1632        let mut results = AnalysisResults::default();
1633        results.unlisted_dependencies.push(UnlistedDependency {
1634            package_name: "dotenv".to_string(),
1635            imported_from: vec![
1636                ImportSite {
1637                    path: root.join("src/config.ts"),
1638                    line: 1,
1639                    col: 0,
1640                },
1641                ImportSite {
1642                    path: root.join("src/server.ts"),
1643                    line: 3,
1644                    col: 0,
1645                },
1646            ],
1647        });
1648        let elapsed = Duration::from_millis(0);
1649        let output = build_json(&results, &root, elapsed).expect("should serialize");
1650
1651        let dep = &output["unlisted_dependencies"][0];
1652        assert_eq!(dep["package_name"], "dotenv");
1653        let sites = dep["imported_from"].as_array().unwrap();
1654        assert_eq!(sites.len(), 2);
1655        assert_eq!(sites[0]["path"], "src/config.ts");
1656        assert_eq!(sites[1]["path"], "src/server.ts");
1657    }
1658
1659    #[test]
1660    fn json_duplicate_export_contains_locations() {
1661        let root = PathBuf::from("/project");
1662        let mut results = AnalysisResults::default();
1663        results.duplicate_exports.push(DuplicateExport {
1664            export_name: "Button".to_string(),
1665            locations: vec![
1666                DuplicateLocation {
1667                    path: root.join("src/ui.ts"),
1668                    line: 10,
1669                    col: 0,
1670                },
1671                DuplicateLocation {
1672                    path: root.join("src/components.ts"),
1673                    line: 25,
1674                    col: 0,
1675                },
1676            ],
1677        });
1678        let elapsed = Duration::from_millis(0);
1679        let output = build_json(&results, &root, elapsed).expect("should serialize");
1680
1681        let dup = &output["duplicate_exports"][0];
1682        assert_eq!(dup["export_name"], "Button");
1683        let locs = dup["locations"].as_array().unwrap();
1684        assert_eq!(locs.len(), 2);
1685        assert_eq!(locs[0]["line"], 10);
1686        assert_eq!(locs[1]["line"], 25);
1687    }
1688
1689    #[test]
1690    fn json_type_only_dependency_contains_expected_fields() {
1691        let root = PathBuf::from("/project");
1692        let mut results = AnalysisResults::default();
1693        results.type_only_dependencies.push(TypeOnlyDependency {
1694            package_name: "zod".to_string(),
1695            path: root.join("package.json"),
1696            line: 8,
1697        });
1698        let elapsed = Duration::from_millis(0);
1699        let output = build_json(&results, &root, elapsed).expect("should serialize");
1700
1701        let dep = &output["type_only_dependencies"][0];
1702        assert_eq!(dep["package_name"], "zod");
1703        assert_eq!(dep["line"], 8);
1704    }
1705
1706    #[test]
1707    fn json_circular_dependency_contains_expected_fields() {
1708        let root = PathBuf::from("/project");
1709        let mut results = AnalysisResults::default();
1710        results.circular_dependencies.push(CircularDependency {
1711            files: vec![
1712                root.join("src/a.ts"),
1713                root.join("src/b.ts"),
1714                root.join("src/c.ts"),
1715            ],
1716            length: 3,
1717            line: 5,
1718            col: 0,
1719            is_cross_package: false,
1720        });
1721        let elapsed = Duration::from_millis(0);
1722        let output = build_json(&results, &root, elapsed).expect("should serialize");
1723
1724        let cycle = &output["circular_dependencies"][0];
1725        assert_eq!(cycle["length"], 3);
1726        assert_eq!(cycle["line"], 5);
1727        let files = cycle["files"].as_array().unwrap();
1728        assert_eq!(files.len(), 3);
1729    }
1730
1731    // ── Re-export tagging ───────────────────────────────────────────
1732
1733    #[test]
1734    fn json_re_export_flagged_correctly() {
1735        let root = PathBuf::from("/project");
1736        let mut results = AnalysisResults::default();
1737        results.unused_exports.push(UnusedExport {
1738            path: root.join("src/index.ts"),
1739            export_name: "reExported".to_string(),
1740            is_type_only: false,
1741            line: 1,
1742            col: 0,
1743            span_start: 0,
1744            is_re_export: true,
1745        });
1746        let elapsed = Duration::from_millis(0);
1747        let output = build_json(&results, &root, elapsed).expect("should serialize");
1748
1749        assert_eq!(output["unused_exports"][0]["is_re_export"], true);
1750    }
1751
1752    // ── Schema version stability ────────────────────────────────────
1753
1754    #[test]
1755    fn json_schema_version_is_4() {
1756        let root = PathBuf::from("/project");
1757        let results = AnalysisResults::default();
1758        let elapsed = Duration::from_millis(0);
1759        let output = build_json(&results, &root, elapsed).expect("should serialize");
1760
1761        assert_eq!(output["schema_version"], SCHEMA_VERSION);
1762        assert_eq!(output["schema_version"], 4);
1763    }
1764
1765    // ── Version string ──────────────────────────────────────────────
1766
1767    #[test]
1768    fn json_version_matches_cargo_pkg_version() {
1769        let root = PathBuf::from("/project");
1770        let results = AnalysisResults::default();
1771        let elapsed = Duration::from_millis(0);
1772        let output = build_json(&results, &root, elapsed).expect("should serialize");
1773
1774        assert_eq!(output["version"], env!("CARGO_PKG_VERSION"));
1775    }
1776
1777    // ── Elapsed time encoding ───────────────────────────────────────
1778
1779    #[test]
1780    fn json_elapsed_ms_zero_duration() {
1781        let root = PathBuf::from("/project");
1782        let results = AnalysisResults::default();
1783        let output = build_json(&results, &root, Duration::ZERO).expect("should serialize");
1784
1785        assert_eq!(output["elapsed_ms"], 0);
1786    }
1787
1788    #[test]
1789    fn json_elapsed_ms_large_duration() {
1790        let root = PathBuf::from("/project");
1791        let results = AnalysisResults::default();
1792        let elapsed = Duration::from_mins(2);
1793        let output = build_json(&results, &root, elapsed).expect("should serialize");
1794
1795        assert_eq!(output["elapsed_ms"], 120_000);
1796    }
1797
1798    #[test]
1799    fn json_elapsed_ms_sub_millisecond_truncated() {
1800        let root = PathBuf::from("/project");
1801        let results = AnalysisResults::default();
1802        // 500 microseconds = 0 milliseconds (truncated)
1803        let elapsed = Duration::from_micros(500);
1804        let output = build_json(&results, &root, elapsed).expect("should serialize");
1805
1806        assert_eq!(output["elapsed_ms"], 0);
1807    }
1808
1809    // ── Multiple issues of same type ────────────────────────────────
1810
1811    #[test]
1812    fn json_multiple_unused_files() {
1813        let root = PathBuf::from("/project");
1814        let mut results = AnalysisResults::default();
1815        results.unused_files.push(UnusedFile {
1816            path: root.join("src/a.ts"),
1817        });
1818        results.unused_files.push(UnusedFile {
1819            path: root.join("src/b.ts"),
1820        });
1821        results.unused_files.push(UnusedFile {
1822            path: root.join("src/c.ts"),
1823        });
1824        let elapsed = Duration::from_millis(0);
1825        let output = build_json(&results, &root, elapsed).expect("should serialize");
1826
1827        assert_eq!(output["unused_files"].as_array().unwrap().len(), 3);
1828        assert_eq!(output["total_issues"], 3);
1829    }
1830
1831    // ── strip_root_prefix unit tests ────────────────────────────────
1832
1833    #[test]
1834    fn strip_root_prefix_on_string_value() {
1835        let mut value = serde_json::json!("/project/src/file.ts");
1836        strip_root_prefix(&mut value, "/project/");
1837        assert_eq!(value, "src/file.ts");
1838    }
1839
1840    #[test]
1841    fn strip_root_prefix_leaves_non_matching_string() {
1842        let mut value = serde_json::json!("/other/src/file.ts");
1843        strip_root_prefix(&mut value, "/project/");
1844        assert_eq!(value, "/other/src/file.ts");
1845    }
1846
1847    #[test]
1848    fn strip_root_prefix_recurses_into_arrays() {
1849        let mut value = serde_json::json!(["/project/a.ts", "/project/b.ts", "/other/c.ts"]);
1850        strip_root_prefix(&mut value, "/project/");
1851        assert_eq!(value[0], "a.ts");
1852        assert_eq!(value[1], "b.ts");
1853        assert_eq!(value[2], "/other/c.ts");
1854    }
1855
1856    #[test]
1857    fn strip_root_prefix_recurses_into_nested_objects() {
1858        let mut value = serde_json::json!({
1859            "outer": {
1860                "path": "/project/src/nested.ts"
1861            }
1862        });
1863        strip_root_prefix(&mut value, "/project/");
1864        assert_eq!(value["outer"]["path"], "src/nested.ts");
1865    }
1866
1867    #[test]
1868    fn strip_root_prefix_leaves_numbers_and_booleans() {
1869        let mut value = serde_json::json!({
1870            "line": 42,
1871            "is_type_only": false,
1872            "path": "/project/src/file.ts"
1873        });
1874        strip_root_prefix(&mut value, "/project/");
1875        assert_eq!(value["line"], 42);
1876        assert_eq!(value["is_type_only"], false);
1877        assert_eq!(value["path"], "src/file.ts");
1878    }
1879
1880    #[test]
1881    fn strip_root_prefix_normalizes_windows_separators() {
1882        let mut value = serde_json::json!(r"/project\src\file.ts");
1883        strip_root_prefix(&mut value, "/project/");
1884        assert_eq!(value, "src/file.ts");
1885    }
1886
1887    #[test]
1888    fn strip_root_prefix_handles_empty_string_after_strip() {
1889        // Edge case: the string IS the prefix (without trailing content).
1890        // This shouldn't happen in practice but should not panic.
1891        let mut value = serde_json::json!("/project/");
1892        strip_root_prefix(&mut value, "/project/");
1893        assert_eq!(value, "");
1894    }
1895
1896    #[test]
1897    fn strip_root_prefix_deeply_nested_array_of_objects() {
1898        let mut value = serde_json::json!({
1899            "groups": [{
1900                "instances": [{
1901                    "file": "/project/src/a.ts"
1902                }, {
1903                    "file": "/project/src/b.ts"
1904                }]
1905            }]
1906        });
1907        strip_root_prefix(&mut value, "/project/");
1908        assert_eq!(value["groups"][0]["instances"][0]["file"], "src/a.ts");
1909        assert_eq!(value["groups"][0]["instances"][1]["file"], "src/b.ts");
1910    }
1911
1912    // ── Full sample results round-trip ──────────────────────────────
1913
1914    #[test]
1915    fn json_full_sample_results_total_issues_correct() {
1916        let root = PathBuf::from("/project");
1917        let results = sample_results(&root);
1918        let elapsed = Duration::from_millis(100);
1919        let output = build_json(&results, &root, elapsed).expect("should serialize");
1920
1921        // sample_results adds one of each issue type (12 total).
1922        // unused_files + unused_exports + unused_types + unused_dependencies
1923        // + unused_dev_dependencies + unused_enum_members + unused_class_members
1924        // + unresolved_imports + unlisted_dependencies + duplicate_exports
1925        // + type_only_dependencies + circular_dependencies
1926        assert_eq!(output["total_issues"], results.total_issues());
1927    }
1928
1929    #[test]
1930    fn json_full_sample_no_absolute_paths_in_output() {
1931        let root = PathBuf::from("/project");
1932        let results = sample_results(&root);
1933        let elapsed = Duration::from_millis(0);
1934        let output = build_json(&results, &root, elapsed).expect("should serialize");
1935
1936        let json_str = serde_json::to_string(&output).expect("should stringify");
1937        // The root prefix should be stripped from all paths.
1938        assert!(!json_str.contains("/project/src/"));
1939        assert!(!json_str.contains("/project/package.json"));
1940    }
1941
1942    // ── JSON output is deterministic ────────────────────────────────
1943
1944    #[test]
1945    fn json_output_is_deterministic() {
1946        let root = PathBuf::from("/project");
1947        let results = sample_results(&root);
1948        let elapsed = Duration::from_millis(50);
1949
1950        let output1 = build_json(&results, &root, elapsed).expect("first build");
1951        let output2 = build_json(&results, &root, elapsed).expect("second build");
1952
1953        assert_eq!(output1, output2);
1954    }
1955
1956    // ── Metadata not overwritten by results fields ──────────────────
1957
1958    #[test]
1959    fn json_results_fields_do_not_shadow_metadata() {
1960        // Ensure that serialized results don't contain keys like "schema_version"
1961        // that could overwrite the metadata fields we insert first.
1962        let root = PathBuf::from("/project");
1963        let results = AnalysisResults::default();
1964        let elapsed = Duration::from_millis(99);
1965        let output = build_json(&results, &root, elapsed).expect("should serialize");
1966
1967        // Metadata should reflect our explicit values, not anything from AnalysisResults.
1968        assert_eq!(output["schema_version"], 4);
1969        assert_eq!(output["elapsed_ms"], 99);
1970    }
1971
1972    // ── All 14 issue type arrays present ────────────────────────────
1973
1974    #[test]
1975    fn json_all_issue_type_arrays_present_in_empty_results() {
1976        let root = PathBuf::from("/project");
1977        let results = AnalysisResults::default();
1978        let elapsed = Duration::from_millis(0);
1979        let output = build_json(&results, &root, elapsed).expect("should serialize");
1980
1981        let expected_arrays = [
1982            "unused_files",
1983            "unused_exports",
1984            "unused_types",
1985            "unused_dependencies",
1986            "unused_dev_dependencies",
1987            "unused_optional_dependencies",
1988            "unused_enum_members",
1989            "unused_class_members",
1990            "unresolved_imports",
1991            "unlisted_dependencies",
1992            "duplicate_exports",
1993            "type_only_dependencies",
1994            "test_only_dependencies",
1995            "circular_dependencies",
1996        ];
1997        for key in &expected_arrays {
1998            assert!(
1999                output[key].is_array(),
2000                "expected '{key}' to be an array in JSON output"
2001            );
2002        }
2003    }
2004
2005    // ── insert_meta ─────────────────────────────────────────────────
2006
2007    #[test]
2008    fn insert_meta_adds_key_to_object() {
2009        let mut output = serde_json::json!({ "foo": 1 });
2010        let meta = serde_json::json!({ "docs": "https://example.com" });
2011        insert_meta(&mut output, meta.clone());
2012        assert_eq!(output["_meta"], meta);
2013    }
2014
2015    #[test]
2016    fn insert_meta_noop_on_non_object() {
2017        let mut output = serde_json::json!([1, 2, 3]);
2018        let meta = serde_json::json!({ "docs": "https://example.com" });
2019        insert_meta(&mut output, meta);
2020        // Should not panic or add anything
2021        assert!(output.is_array());
2022    }
2023
2024    #[test]
2025    fn insert_meta_overwrites_existing_meta() {
2026        let mut output = serde_json::json!({ "_meta": "old" });
2027        let meta = serde_json::json!({ "new": true });
2028        insert_meta(&mut output, meta.clone());
2029        assert_eq!(output["_meta"], meta);
2030    }
2031
2032    // ── build_json_envelope ─────────────────────────────────────────
2033
2034    #[test]
2035    fn build_json_envelope_has_metadata_fields() {
2036        let report = serde_json::json!({ "findings": [] });
2037        let elapsed = Duration::from_millis(42);
2038        let output = build_json_envelope(report, elapsed);
2039
2040        assert_eq!(output["schema_version"], 4);
2041        assert!(output["version"].is_string());
2042        assert_eq!(output["elapsed_ms"], 42);
2043        assert!(output["findings"].is_array());
2044    }
2045
2046    #[test]
2047    fn build_json_envelope_metadata_appears_first() {
2048        let report = serde_json::json!({ "data": "value" });
2049        let output = build_json_envelope(report, Duration::from_millis(10));
2050
2051        let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
2052        assert_eq!(keys[0], "schema_version");
2053        assert_eq!(keys[1], "version");
2054        assert_eq!(keys[2], "elapsed_ms");
2055    }
2056
2057    #[test]
2058    fn build_json_envelope_non_object_report() {
2059        // If report_value is not an Object, only metadata fields appear
2060        let report = serde_json::json!("not an object");
2061        let output = build_json_envelope(report, Duration::from_millis(0));
2062
2063        let obj = output.as_object().unwrap();
2064        assert_eq!(obj.len(), 3);
2065        assert!(obj.contains_key("schema_version"));
2066        assert!(obj.contains_key("version"));
2067        assert!(obj.contains_key("elapsed_ms"));
2068    }
2069
2070    // ── strip_root_prefix with null value ──
2071
2072    #[test]
2073    fn strip_root_prefix_null_unchanged() {
2074        let mut value = serde_json::Value::Null;
2075        strip_root_prefix(&mut value, "/project/");
2076        assert!(value.is_null());
2077    }
2078
2079    // ── strip_root_prefix with empty string ──
2080
2081    #[test]
2082    fn strip_root_prefix_empty_string() {
2083        let mut value = serde_json::json!("");
2084        strip_root_prefix(&mut value, "/project/");
2085        assert_eq!(value, "");
2086    }
2087
2088    // ── strip_root_prefix on mixed nested structure ──
2089
2090    #[test]
2091    fn strip_root_prefix_mixed_types() {
2092        let mut value = serde_json::json!({
2093            "path": "/project/src/file.ts",
2094            "line": 42,
2095            "flag": true,
2096            "nested": {
2097                "items": ["/project/a.ts", 99, null, "/project/b.ts"],
2098                "deep": { "path": "/project/c.ts" }
2099            }
2100        });
2101        strip_root_prefix(&mut value, "/project/");
2102        assert_eq!(value["path"], "src/file.ts");
2103        assert_eq!(value["line"], 42);
2104        assert_eq!(value["flag"], true);
2105        assert_eq!(value["nested"]["items"][0], "a.ts");
2106        assert_eq!(value["nested"]["items"][1], 99);
2107        assert!(value["nested"]["items"][2].is_null());
2108        assert_eq!(value["nested"]["items"][3], "b.ts");
2109        assert_eq!(value["nested"]["deep"]["path"], "c.ts");
2110    }
2111
2112    // ── JSON with explain meta for check ──
2113
2114    #[test]
2115    fn json_check_meta_integrates_correctly() {
2116        let root = PathBuf::from("/project");
2117        let results = AnalysisResults::default();
2118        let elapsed = Duration::from_millis(0);
2119        let mut output = build_json(&results, &root, elapsed).expect("should serialize");
2120        insert_meta(&mut output, crate::explain::check_meta());
2121
2122        assert!(output["_meta"]["docs"].is_string());
2123        assert!(output["_meta"]["rules"].is_object());
2124    }
2125
2126    // ── JSON unused member kind serialization ──
2127
2128    #[test]
2129    fn json_unused_member_kind_serialized() {
2130        let root = PathBuf::from("/project");
2131        let mut results = AnalysisResults::default();
2132        results.unused_enum_members.push(UnusedMember {
2133            path: root.join("src/enums.ts"),
2134            parent_name: "Color".to_string(),
2135            member_name: "Red".to_string(),
2136            kind: MemberKind::EnumMember,
2137            line: 3,
2138            col: 2,
2139        });
2140        results.unused_class_members.push(UnusedMember {
2141            path: root.join("src/class.ts"),
2142            parent_name: "Foo".to_string(),
2143            member_name: "bar".to_string(),
2144            kind: MemberKind::ClassMethod,
2145            line: 10,
2146            col: 4,
2147        });
2148
2149        let elapsed = Duration::from_millis(0);
2150        let output = build_json(&results, &root, elapsed).expect("should serialize");
2151
2152        let enum_member = &output["unused_enum_members"][0];
2153        assert!(enum_member["kind"].is_string());
2154        let class_member = &output["unused_class_members"][0];
2155        assert!(class_member["kind"].is_string());
2156    }
2157
2158    // ── Actions injection ──────────────────────────────────────────
2159
2160    #[test]
2161    fn json_unused_export_has_actions() {
2162        let root = PathBuf::from("/project");
2163        let mut results = AnalysisResults::default();
2164        results.unused_exports.push(UnusedExport {
2165            path: root.join("src/utils.ts"),
2166            export_name: "helperFn".to_string(),
2167            is_type_only: false,
2168            line: 10,
2169            col: 4,
2170            span_start: 120,
2171            is_re_export: false,
2172        });
2173        let output = build_json(&results, &root, Duration::ZERO).unwrap();
2174
2175        let actions = output["unused_exports"][0]["actions"].as_array().unwrap();
2176        assert_eq!(actions.len(), 2);
2177
2178        // Fix action
2179        assert_eq!(actions[0]["type"], "remove-export");
2180        assert_eq!(actions[0]["auto_fixable"], true);
2181        assert!(actions[0].get("note").is_none());
2182
2183        // Suppress action
2184        assert_eq!(actions[1]["type"], "suppress-line");
2185        assert_eq!(
2186            actions[1]["comment"],
2187            "// fallow-ignore-next-line unused-export"
2188        );
2189    }
2190
2191    #[test]
2192    fn json_unused_file_has_file_suppress_and_note() {
2193        let root = PathBuf::from("/project");
2194        let mut results = AnalysisResults::default();
2195        results.unused_files.push(UnusedFile {
2196            path: root.join("src/dead.ts"),
2197        });
2198        let output = build_json(&results, &root, Duration::ZERO).unwrap();
2199
2200        let actions = output["unused_files"][0]["actions"].as_array().unwrap();
2201        assert_eq!(actions[0]["type"], "delete-file");
2202        assert_eq!(actions[0]["auto_fixable"], false);
2203        assert!(actions[0]["note"].is_string());
2204        assert_eq!(actions[1]["type"], "suppress-file");
2205        assert_eq!(actions[1]["comment"], "// fallow-ignore-file unused-file");
2206    }
2207
2208    #[test]
2209    fn json_unused_dependency_has_config_suppress_with_package_name() {
2210        let root = PathBuf::from("/project");
2211        let mut results = AnalysisResults::default();
2212        results.unused_dependencies.push(UnusedDependency {
2213            package_name: "lodash".to_string(),
2214            location: DependencyLocation::Dependencies,
2215            path: root.join("package.json"),
2216            line: 5,
2217        });
2218        let output = build_json(&results, &root, Duration::ZERO).unwrap();
2219
2220        let actions = output["unused_dependencies"][0]["actions"]
2221            .as_array()
2222            .unwrap();
2223        assert_eq!(actions[0]["type"], "remove-dependency");
2224        assert_eq!(actions[0]["auto_fixable"], true);
2225
2226        // Config suppress includes actual package name
2227        assert_eq!(actions[1]["type"], "add-to-config");
2228        assert_eq!(actions[1]["config_key"], "ignoreDependencies");
2229        assert_eq!(actions[1]["value"], "lodash");
2230    }
2231
2232    #[test]
2233    fn json_empty_results_have_no_actions_in_empty_arrays() {
2234        let root = PathBuf::from("/project");
2235        let results = AnalysisResults::default();
2236        let output = build_json(&results, &root, Duration::ZERO).unwrap();
2237
2238        // Empty arrays should remain empty
2239        assert!(output["unused_exports"].as_array().unwrap().is_empty());
2240        assert!(output["unused_files"].as_array().unwrap().is_empty());
2241    }
2242
2243    #[test]
2244    fn json_all_issue_types_have_actions() {
2245        let root = PathBuf::from("/project");
2246        let results = sample_results(&root);
2247        let output = build_json(&results, &root, Duration::ZERO).unwrap();
2248
2249        let issue_keys = [
2250            "unused_files",
2251            "unused_exports",
2252            "unused_types",
2253            "unused_dependencies",
2254            "unused_dev_dependencies",
2255            "unused_optional_dependencies",
2256            "unused_enum_members",
2257            "unused_class_members",
2258            "unresolved_imports",
2259            "unlisted_dependencies",
2260            "duplicate_exports",
2261            "type_only_dependencies",
2262            "test_only_dependencies",
2263            "circular_dependencies",
2264        ];
2265
2266        for key in &issue_keys {
2267            let arr = output[key].as_array().unwrap();
2268            if !arr.is_empty() {
2269                let actions = arr[0]["actions"].as_array();
2270                assert!(
2271                    actions.is_some() && !actions.unwrap().is_empty(),
2272                    "missing actions for {key}"
2273                );
2274            }
2275        }
2276    }
2277
2278    // ── Health actions injection ───────────────────────────────────
2279
2280    #[test]
2281    fn health_finding_has_actions() {
2282        let mut output = serde_json::json!({
2283            "findings": [{
2284                "path": "src/utils.ts",
2285                "name": "processData",
2286                "line": 10,
2287                "col": 0,
2288                "cyclomatic": 25,
2289                "cognitive": 30,
2290                "line_count": 150,
2291                "exceeded": "both"
2292            }]
2293        });
2294
2295        inject_health_actions(&mut output);
2296
2297        let actions = output["findings"][0]["actions"].as_array().unwrap();
2298        assert_eq!(actions.len(), 2);
2299        assert_eq!(actions[0]["type"], "refactor-function");
2300        assert_eq!(actions[0]["auto_fixable"], false);
2301        assert!(
2302            actions[0]["description"]
2303                .as_str()
2304                .unwrap()
2305                .contains("processData")
2306        );
2307        assert_eq!(actions[1]["type"], "suppress-line");
2308        assert_eq!(
2309            actions[1]["comment"],
2310            "// fallow-ignore-next-line complexity"
2311        );
2312    }
2313
2314    #[test]
2315    fn refactoring_target_has_actions() {
2316        let mut output = serde_json::json!({
2317            "targets": [{
2318                "path": "src/big-module.ts",
2319                "priority": 85.0,
2320                "efficiency": 42.5,
2321                "recommendation": "Split module: 12 exports, 4 unused",
2322                "category": "split_high_impact",
2323                "effort": "medium",
2324                "confidence": "high",
2325                "evidence": { "unused_exports": 4 }
2326            }]
2327        });
2328
2329        inject_health_actions(&mut output);
2330
2331        let actions = output["targets"][0]["actions"].as_array().unwrap();
2332        assert_eq!(actions.len(), 2);
2333        assert_eq!(actions[0]["type"], "apply-refactoring");
2334        assert_eq!(
2335            actions[0]["description"],
2336            "Split module: 12 exports, 4 unused"
2337        );
2338        assert_eq!(actions[0]["category"], "split_high_impact");
2339        // Target with evidence gets suppress action
2340        assert_eq!(actions[1]["type"], "suppress-line");
2341    }
2342
2343    #[test]
2344    fn refactoring_target_without_evidence_has_no_suppress() {
2345        let mut output = serde_json::json!({
2346            "targets": [{
2347                "path": "src/simple.ts",
2348                "priority": 30.0,
2349                "efficiency": 15.0,
2350                "recommendation": "Consider extracting helper functions",
2351                "category": "extract_complex_functions",
2352                "effort": "small",
2353                "confidence": "medium"
2354            }]
2355        });
2356
2357        inject_health_actions(&mut output);
2358
2359        let actions = output["targets"][0]["actions"].as_array().unwrap();
2360        assert_eq!(actions.len(), 1);
2361        assert_eq!(actions[0]["type"], "apply-refactoring");
2362    }
2363
2364    #[test]
2365    fn health_empty_findings_no_actions() {
2366        let mut output = serde_json::json!({
2367            "findings": [],
2368            "targets": []
2369        });
2370
2371        inject_health_actions(&mut output);
2372
2373        assert!(output["findings"].as_array().unwrap().is_empty());
2374        assert!(output["targets"].as_array().unwrap().is_empty());
2375    }
2376
2377    #[test]
2378    fn hotspot_has_actions() {
2379        let mut output = serde_json::json!({
2380            "hotspots": [{
2381                "path": "src/utils.ts",
2382                "complexity_score": 45.0,
2383                "churn_score": 12,
2384                "hotspot_score": 540.0
2385            }]
2386        });
2387
2388        inject_health_actions(&mut output);
2389
2390        let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2391        assert_eq!(actions.len(), 2);
2392        assert_eq!(actions[0]["type"], "refactor-file");
2393        assert!(
2394            actions[0]["description"]
2395                .as_str()
2396                .unwrap()
2397                .contains("src/utils.ts")
2398        );
2399        assert_eq!(actions[1]["type"], "add-tests");
2400    }
2401
2402    #[test]
2403    fn hotspot_low_bus_factor_emits_action() {
2404        let mut output = serde_json::json!({
2405            "hotspots": [{
2406                "path": "src/api.ts",
2407                "ownership": {
2408                    "bus_factor": 1,
2409                    "contributor_count": 1,
2410                    "top_contributor": {"identifier": "alice@x", "share": 1.0, "stale_days": 5, "commits": 30},
2411                    "unowned": null,
2412                    "drift": false,
2413                }
2414            }]
2415        });
2416
2417        inject_health_actions(&mut output);
2418
2419        let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2420        assert!(
2421            actions
2422                .iter()
2423                .filter_map(|a| a["type"].as_str())
2424                .any(|t| t == "low-bus-factor"),
2425            "low-bus-factor action should be present",
2426        );
2427        let bus = actions
2428            .iter()
2429            .find(|a| a["type"] == "low-bus-factor")
2430            .unwrap();
2431        assert!(bus["description"].as_str().unwrap().contains("alice@x"));
2432    }
2433
2434    #[test]
2435    fn hotspot_unowned_emits_action_with_pattern() {
2436        let mut output = serde_json::json!({
2437            "hotspots": [{
2438                "path": "src/api/users.ts",
2439                "ownership": {
2440                    "bus_factor": 2,
2441                    "contributor_count": 4,
2442                    "top_contributor": {"identifier": "alice@x", "share": 0.5, "stale_days": 5, "commits": 10},
2443                    "unowned": true,
2444                    "drift": false,
2445                }
2446            }]
2447        });
2448
2449        inject_health_actions(&mut output);
2450
2451        let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2452        let unowned = actions
2453            .iter()
2454            .find(|a| a["type"] == "unowned-hotspot")
2455            .expect("unowned-hotspot action should be present");
2456        // Deepest directory containing the file -> /src/api/
2457        // (file `users.ts` is at depth 2, so the deepest dir is `/src/api/`).
2458        assert_eq!(unowned["suggested_pattern"], "/src/api/");
2459        assert_eq!(unowned["heuristic"], "directory-deepest");
2460    }
2461
2462    #[test]
2463    fn hotspot_unowned_skipped_when_codeowners_missing() {
2464        let mut output = serde_json::json!({
2465            "hotspots": [{
2466                "path": "src/api.ts",
2467                "ownership": {
2468                    "bus_factor": 2,
2469                    "contributor_count": 4,
2470                    "top_contributor": {"identifier": "alice@x", "share": 0.5, "stale_days": 5, "commits": 10},
2471                    "unowned": null,
2472                    "drift": false,
2473                }
2474            }]
2475        });
2476
2477        inject_health_actions(&mut output);
2478
2479        let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2480        assert!(
2481            !actions.iter().any(|a| a["type"] == "unowned-hotspot"),
2482            "unowned action must not fire when CODEOWNERS file is absent"
2483        );
2484    }
2485
2486    #[test]
2487    fn hotspot_drift_emits_action() {
2488        let mut output = serde_json::json!({
2489            "hotspots": [{
2490                "path": "src/old.ts",
2491                "ownership": {
2492                    "bus_factor": 1,
2493                    "contributor_count": 2,
2494                    "top_contributor": {"identifier": "bob@x", "share": 0.9, "stale_days": 1, "commits": 18},
2495                    "unowned": null,
2496                    "drift": true,
2497                    "drift_reason": "original author alice@x has 5% share",
2498                }
2499            }]
2500        });
2501
2502        inject_health_actions(&mut output);
2503
2504        let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2505        let drift = actions
2506            .iter()
2507            .find(|a| a["type"] == "ownership-drift")
2508            .expect("ownership-drift action should be present");
2509        assert!(drift["description"].as_str().unwrap().contains("alice@x"));
2510    }
2511
2512    // ── suggest_codeowners_pattern ─────────────────────────────────
2513
2514    #[test]
2515    fn codeowners_pattern_uses_deepest_directory() {
2516        // Deepest dir keeps the suggestion tightly-scoped; the prior
2517        // "first two levels" heuristic over-generalized in monorepos.
2518        assert_eq!(
2519            suggest_codeowners_pattern("src/api/users/handlers.ts"),
2520            "/src/api/users/"
2521        );
2522    }
2523
2524    #[test]
2525    fn codeowners_pattern_for_root_file() {
2526        assert_eq!(suggest_codeowners_pattern("README.md"), "/README.md");
2527    }
2528
2529    #[test]
2530    fn codeowners_pattern_normalizes_backslashes() {
2531        assert_eq!(
2532            suggest_codeowners_pattern("src\\api\\users.ts"),
2533            "/src/api/"
2534        );
2535    }
2536
2537    #[test]
2538    fn codeowners_pattern_two_level_path() {
2539        assert_eq!(suggest_codeowners_pattern("src/foo.ts"), "/src/");
2540    }
2541
2542    #[test]
2543    fn health_finding_suppress_has_placement() {
2544        let mut output = serde_json::json!({
2545            "findings": [{
2546                "path": "src/utils.ts",
2547                "name": "processData",
2548                "line": 10,
2549                "col": 0,
2550                "cyclomatic": 25,
2551                "cognitive": 30,
2552                "line_count": 150,
2553                "exceeded": "both"
2554            }]
2555        });
2556
2557        inject_health_actions(&mut output);
2558
2559        let suppress = &output["findings"][0]["actions"][1];
2560        assert_eq!(suppress["placement"], "above-function-declaration");
2561    }
2562
2563    // ── Duplication actions injection ─────────────────────────────
2564
2565    #[test]
2566    fn clone_family_has_actions() {
2567        let mut output = serde_json::json!({
2568            "clone_families": [{
2569                "files": ["src/a.ts", "src/b.ts"],
2570                "groups": [
2571                    { "instances": [{"file": "src/a.ts"}, {"file": "src/b.ts"}], "token_count": 100, "line_count": 20 }
2572                ],
2573                "total_duplicated_lines": 20,
2574                "total_duplicated_tokens": 100,
2575                "suggestions": [
2576                    { "kind": "ExtractFunction", "description": "Extract shared validation logic", "estimated_savings": 15 }
2577                ]
2578            }]
2579        });
2580
2581        inject_dupes_actions(&mut output);
2582
2583        let actions = output["clone_families"][0]["actions"].as_array().unwrap();
2584        assert_eq!(actions.len(), 3);
2585        assert_eq!(actions[0]["type"], "extract-shared");
2586        assert_eq!(actions[0]["auto_fixable"], false);
2587        assert!(
2588            actions[0]["description"]
2589                .as_str()
2590                .unwrap()
2591                .contains("20 lines")
2592        );
2593        // Suggestion forwarded as action
2594        assert_eq!(actions[1]["type"], "apply-suggestion");
2595        assert!(
2596            actions[1]["description"]
2597                .as_str()
2598                .unwrap()
2599                .contains("validation logic")
2600        );
2601        // Suppress action
2602        assert_eq!(actions[2]["type"], "suppress-line");
2603        assert_eq!(
2604            actions[2]["comment"],
2605            "// fallow-ignore-next-line code-duplication"
2606        );
2607    }
2608
2609    #[test]
2610    fn clone_group_has_actions() {
2611        let mut output = serde_json::json!({
2612            "clone_groups": [{
2613                "instances": [
2614                    {"file": "src/a.ts", "start_line": 1, "end_line": 10},
2615                    {"file": "src/b.ts", "start_line": 5, "end_line": 14}
2616                ],
2617                "token_count": 50,
2618                "line_count": 10
2619            }]
2620        });
2621
2622        inject_dupes_actions(&mut output);
2623
2624        let actions = output["clone_groups"][0]["actions"].as_array().unwrap();
2625        assert_eq!(actions.len(), 2);
2626        assert_eq!(actions[0]["type"], "extract-shared");
2627        assert!(
2628            actions[0]["description"]
2629                .as_str()
2630                .unwrap()
2631                .contains("10 lines")
2632        );
2633        assert!(
2634            actions[0]["description"]
2635                .as_str()
2636                .unwrap()
2637                .contains("2 instances")
2638        );
2639        assert_eq!(actions[1]["type"], "suppress-line");
2640    }
2641
2642    #[test]
2643    fn dupes_empty_results_no_actions() {
2644        let mut output = serde_json::json!({
2645            "clone_families": [],
2646            "clone_groups": []
2647        });
2648
2649        inject_dupes_actions(&mut output);
2650
2651        assert!(output["clone_families"].as_array().unwrap().is_empty());
2652        assert!(output["clone_groups"].as_array().unwrap().is_empty());
2653    }
2654}