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