Skip to main content

fallow_cli/report/
json.rs

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