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