Skip to main content

fallow_cli/report/
json.rs

1use std::collections::BTreeMap;
2use std::path::Path;
3use std::process::ExitCode;
4use std::time::Duration;
5
6use fallow_core::duplicates::DuplicationReport;
7use fallow_core::results::AnalysisResults;
8
9use super::shared::NAMESPACE_BARREL_HINT;
10use super::{emit_json, normalize_uri};
11use crate::explain;
12use crate::report::grouping::{OwnershipResolver, ResultGroup};
13
14/// JSON Pointer fragment URL describing the shape of the `value` field on an
15/// `ignoreExports` `add-to-config` action. Agents can fetch and validate the
16/// snippet before writing into a user's config.
17const IGNORE_EXPORTS_VALUE_SCHEMA: &str =
18    "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreExports";
19
20/// JSON Pointer fragment URL describing the shape of the `value` field on an
21/// `ignoreDependencies` `add-to-config` action. Points at the array item schema
22/// (a single string) since the action's `value` is one entry to append.
23const IGNORE_DEPENDENCIES_VALUE_SCHEMA: &str = "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreDependencies/items";
24
25/// JSON Pointer fragment URL describing the shape of the `value` field on an
26/// `ignoreCatalogReferences` `add-to-config` action: one `{ package, catalog?,
27/// consumer? }` entry to append.
28const IGNORE_CATALOG_REFERENCES_VALUE_SCHEMA: &str = "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreCatalogReferences/items";
29
30/// JSON Pointer fragment URL describing the shape of the `value` field on an
31/// `ignoreDependencyOverrides` `add-to-config` action: one `{ package, source? }`
32/// entry to append.
33const IGNORE_DEPENDENCY_OVERRIDES_VALUE_SCHEMA: &str = "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreDependencyOverrides/items";
34
35pub(super) fn print_json(
36    results: &AnalysisResults,
37    root: &Path,
38    elapsed: Duration,
39    explain: bool,
40    regression: Option<&crate::regression::RegressionOutcome>,
41    baseline_matched: Option<(usize, usize)>,
42) -> ExitCode {
43    match build_json(results, root, elapsed) {
44        Ok(mut output) => {
45            if let Some(outcome) = regression
46                && let serde_json::Value::Object(ref mut map) = output
47            {
48                map.insert("regression".to_string(), outcome.to_json());
49            }
50            if let Some((entries, matched)) = baseline_matched
51                && let serde_json::Value::Object(ref mut map) = output
52            {
53                map.insert(
54                    "baseline".to_string(),
55                    serde_json::json!({
56                        "entries": entries,
57                        "matched": matched,
58                    }),
59                );
60            }
61            if explain {
62                insert_meta(&mut output, explain::check_meta());
63            }
64            emit_json(&output, "JSON")
65        }
66        Err(e) => {
67            eprintln!("Error: failed to serialize results: {e}");
68            ExitCode::from(2)
69        }
70    }
71}
72
73/// Render grouped analysis results as a single JSON document.
74///
75/// Produces an envelope with `grouped_by` and `total_issues` at the top level,
76/// then a `groups` array where each element contains the group `key`,
77/// `total_issues`, and all the normal result fields with paths relativized.
78#[must_use]
79pub(super) fn print_grouped_json(
80    groups: &[ResultGroup],
81    original: &AnalysisResults,
82    root: &Path,
83    elapsed: Duration,
84    explain: bool,
85    resolver: &OwnershipResolver,
86) -> ExitCode {
87    let root_prefix = format!("{}/", root.display());
88
89    let group_values: Vec<serde_json::Value> = groups
90        .iter()
91        .filter_map(|group| {
92            let mut value = serde_json::to_value(&group.results).ok()?;
93            strip_root_prefix(&mut value, &root_prefix);
94            inject_actions(&mut value);
95            harmonize_multi_kind_suppress_line_actions(&mut value);
96
97            if let serde_json::Value::Object(ref mut map) = value {
98                // Insert key, owners (section mode), and total_issues at the
99                // front by rebuilding the map.
100                let mut ordered = serde_json::Map::new();
101                ordered.insert("key".to_string(), serde_json::json!(group.key));
102                if let Some(ref owners) = group.owners {
103                    ordered.insert("owners".to_string(), serde_json::json!(owners));
104                }
105                ordered.insert(
106                    "total_issues".to_string(),
107                    serde_json::json!(group.results.total_issues()),
108                );
109                for (k, v) in map.iter() {
110                    ordered.insert(k.clone(), v.clone());
111                }
112                Some(serde_json::Value::Object(ordered))
113            } else {
114                Some(value)
115            }
116        })
117        .collect();
118
119    let mut output = serde_json::json!({
120        "schema_version": SCHEMA_VERSION,
121        "version": env!("CARGO_PKG_VERSION"),
122        "elapsed_ms": elapsed.as_millis() as u64,
123        "grouped_by": resolver.mode_label(),
124        "total_issues": original.total_issues(),
125        "groups": group_values,
126    });
127
128    if explain {
129        insert_meta(&mut output, explain::check_meta());
130    }
131
132    emit_json(&output, "JSON")
133}
134
135/// JSON output schema version as an integer (independent of tool version).
136///
137/// Bump this when the structure of the JSON output changes in a
138/// backwards-incompatible way (removing/renaming fields, changing types).
139/// Adding new fields is always backwards-compatible and does not require a bump.
140#[allow(
141    clippy::redundant_pub_crate,
142    reason = "used through report module re-export by combined.rs, audit.rs, flags.rs"
143)]
144pub(crate) const SCHEMA_VERSION: u32 = 6;
145const RUNTIME_COVERAGE_SCHEMA_VERSION: &str = "1";
146
147/// Build a JSON envelope with standard metadata fields at the top.
148///
149/// Creates a JSON object with `schema_version`, `version`, and `elapsed_ms`,
150/// then merges all fields from `report_value` into the envelope.
151/// Fields from `report_value` appear after the metadata header.
152fn build_json_envelope(report_value: serde_json::Value, elapsed: Duration) -> serde_json::Value {
153    let mut map = serde_json::Map::new();
154    map.insert(
155        "schema_version".to_string(),
156        serde_json::json!(SCHEMA_VERSION),
157    );
158    map.insert(
159        "version".to_string(),
160        serde_json::json!(env!("CARGO_PKG_VERSION")),
161    );
162    map.insert(
163        "elapsed_ms".to_string(),
164        serde_json::json!(elapsed.as_millis()),
165    );
166    if let serde_json::Value::Object(report_map) = report_value {
167        for (key, value) in report_map {
168            map.insert(key, value);
169        }
170    }
171    serde_json::Value::Object(map)
172}
173
174fn inject_runtime_coverage_schema_version(output: &mut serde_json::Value) {
175    let serde_json::Value::Object(map) = output else {
176        return;
177    };
178
179    if let Some(report) = map.get_mut("runtime_coverage") {
180        inject_runtime_coverage_report_schema_version(report);
181    }
182
183    if let Some(serde_json::Value::Array(groups)) = map.get_mut("groups") {
184        for group in groups {
185            if let Some(report) = group
186                .as_object_mut()
187                .and_then(|group| group.get_mut("runtime_coverage"))
188            {
189                inject_runtime_coverage_report_schema_version(report);
190            }
191        }
192    }
193}
194
195fn inject_runtime_coverage_report_schema_version(report: &mut serde_json::Value) {
196    let serde_json::Value::Object(report_map) = report else {
197        return;
198    };
199
200    let mut ordered = serde_json::Map::new();
201    ordered.insert(
202        "schema_version".to_string(),
203        serde_json::json!(RUNTIME_COVERAGE_SCHEMA_VERSION),
204    );
205    for (key, value) in std::mem::take(report_map) {
206        if key != "schema_version" {
207            ordered.insert(key, value);
208        }
209    }
210    *report_map = ordered;
211}
212
213/// Build the JSON output value for analysis results.
214///
215/// Metadata fields (`schema_version`, `version`, `elapsed_ms`, `total_issues`)
216/// appear first in the output for readability. Paths are made relative to `root`.
217///
218/// # Errors
219///
220/// Returns an error if the results cannot be serialized to JSON.
221pub fn build_json(
222    results: &AnalysisResults,
223    root: &Path,
224    elapsed: Duration,
225) -> Result<serde_json::Value, serde_json::Error> {
226    let results_value = serde_json::to_value(results)?;
227
228    let mut map = serde_json::Map::new();
229    map.insert(
230        "schema_version".to_string(),
231        serde_json::json!(SCHEMA_VERSION),
232    );
233    map.insert(
234        "version".to_string(),
235        serde_json::json!(env!("CARGO_PKG_VERSION")),
236    );
237    map.insert(
238        "elapsed_ms".to_string(),
239        serde_json::json!(elapsed.as_millis()),
240    );
241    map.insert(
242        "total_issues".to_string(),
243        serde_json::json!(results.total_issues()),
244    );
245
246    // Entry-point detection summary (metadata, not serialized via serde)
247    if let Some(ref ep) = results.entry_point_summary {
248        let sources: serde_json::Map<String, serde_json::Value> = ep
249            .by_source
250            .iter()
251            .map(|(k, v)| (k.replace(' ', "_"), serde_json::json!(v)))
252            .collect();
253        map.insert(
254            "entry_points".to_string(),
255            serde_json::json!({
256                "total": ep.total,
257                "sources": sources,
258            }),
259        );
260    }
261
262    // Per-category summary counts for CI dashboard consumption
263    let summary = serde_json::json!({
264        "total_issues": results.total_issues(),
265        "unused_files": results.unused_files.len(),
266        "unused_exports": results.unused_exports.len(),
267        "unused_types": results.unused_types.len(),
268        "private_type_leaks": results.private_type_leaks.len(),
269        "unused_dependencies": results.unused_dependencies.len()
270            + results.unused_dev_dependencies.len()
271            + results.unused_optional_dependencies.len(),
272        "unused_enum_members": results.unused_enum_members.len(),
273        "unused_class_members": results.unused_class_members.len(),
274        "unresolved_imports": results.unresolved_imports.len(),
275        "unlisted_dependencies": results.unlisted_dependencies.len(),
276        "duplicate_exports": results.duplicate_exports.len(),
277        "type_only_dependencies": results.type_only_dependencies.len(),
278        "test_only_dependencies": results.test_only_dependencies.len(),
279        "circular_dependencies": results.circular_dependencies.len(),
280        "boundary_violations": results.boundary_violations.len(),
281        "stale_suppressions": results.stale_suppressions.len(),
282        "unused_catalog_entries": results.unused_catalog_entries.len(),
283        "unresolved_catalog_references": results.unresolved_catalog_references.len(),
284        "unused_dependency_overrides": results.unused_dependency_overrides.len(),
285        "misconfigured_dependency_overrides": results.misconfigured_dependency_overrides.len(),
286    });
287    map.insert("summary".to_string(), summary);
288
289    if let serde_json::Value::Object(results_map) = results_value {
290        for (key, value) in results_map {
291            map.insert(key, value);
292        }
293    }
294
295    let mut output = serde_json::Value::Object(map);
296    let root_prefix = format!("{}/", root.display());
297    // strip_root_prefix must run before inject_actions so that injected
298    // action fields (static strings and package names) are not processed
299    // by the path stripper.
300    strip_root_prefix(&mut output, &root_prefix);
301    inject_actions(&mut output);
302    harmonize_multi_kind_suppress_line_actions(&mut output);
303    Ok(output)
304}
305
306/// Recursively strip the root prefix from all string values in the JSON tree.
307///
308/// This converts absolute paths (e.g., `/home/runner/work/repo/repo/src/utils.ts`)
309/// to relative paths (`src/utils.ts`) for all output fields.
310pub fn strip_root_prefix(value: &mut serde_json::Value, prefix: &str) {
311    match value {
312        serde_json::Value::String(s) => {
313            if let Some(rest) = s.strip_prefix(prefix) {
314                *s = rest.to_string();
315            } else {
316                let normalized = normalize_uri(s);
317                let normalized_prefix = normalize_uri(prefix);
318                if let Some(rest) = normalized.strip_prefix(&normalized_prefix) {
319                    *s = rest.to_string();
320                }
321            }
322        }
323        serde_json::Value::Array(arr) => {
324            for item in arr {
325                strip_root_prefix(item, prefix);
326            }
327        }
328        serde_json::Value::Object(map) => {
329            for (_, v) in map.iter_mut() {
330                strip_root_prefix(v, prefix);
331            }
332        }
333        _ => {}
334    }
335}
336
337// ── Fix action injection ────────────────────────────────────────
338
339/// Suppress mechanism for an issue type.
340enum SuppressKind {
341    /// `// fallow-ignore-next-line <type>` on the line before.
342    InlineComment,
343    /// `# fallow-ignore-next-line <type>` on the line before (YAML / shell).
344    YamlComment,
345    /// `// fallow-ignore-file <type>` at the top of the file.
346    FileComment,
347    /// Add to `ignoreDependencies` in fallow config.
348    ConfigIgnoreDep,
349    /// Add to `ignoreCatalogReferences` in fallow config (with optional
350    /// catalog + consumer scope).
351    AddToConfigIgnoreCatalogReferences,
352    /// Add to `ignoreDependencyOverrides` in fallow config (with optional
353    /// source scope).
354    AddToConfigIgnoreDependencyOverrides,
355}
356
357/// Specification for actions to inject per issue type.
358struct ActionSpec {
359    fix_type: &'static str,
360    auto_fixable: bool,
361    description: &'static str,
362    note: Option<&'static str>,
363    suppress: SuppressKind,
364    issue_kind: &'static str,
365}
366
367/// Map an issue array key to its action specification.
368#[expect(
369    clippy::too_many_lines,
370    reason = "one match arm per issue type keeps the action spec table flat and grep-friendly"
371)]
372fn actions_for_issue_type(key: &str) -> Option<ActionSpec> {
373    match key {
374        "unused_files" => Some(ActionSpec {
375            fix_type: "delete-file",
376            auto_fixable: false,
377            description: "Delete this file",
378            note: Some(
379                "File deletion may remove runtime functionality not visible to static analysis",
380            ),
381            suppress: SuppressKind::FileComment,
382            issue_kind: "unused-file",
383        }),
384        "unused_exports" => Some(ActionSpec {
385            fix_type: "remove-export",
386            auto_fixable: true,
387            description: "Remove the unused export from the public API",
388            note: None,
389            suppress: SuppressKind::InlineComment,
390            issue_kind: "unused-export",
391        }),
392        "unused_types" => Some(ActionSpec {
393            fix_type: "remove-export",
394            auto_fixable: true,
395            description: "Remove the `export` (or `export type`) keyword from the type declaration",
396            note: None,
397            suppress: SuppressKind::InlineComment,
398            issue_kind: "unused-type",
399        }),
400        "private_type_leaks" => Some(ActionSpec {
401            fix_type: "export-type",
402            auto_fixable: false,
403            description: "Export the referenced private type by name",
404            note: Some("Keep the type exported while it is part of a public signature"),
405            suppress: SuppressKind::InlineComment,
406            issue_kind: "private-type-leak",
407        }),
408        "unused_dependencies" => Some(ActionSpec {
409            fix_type: "remove-dependency",
410            auto_fixable: true,
411            description: "Remove from dependencies in package.json",
412            note: None,
413            suppress: SuppressKind::ConfigIgnoreDep,
414            issue_kind: "unused-dependency",
415        }),
416        "unused_dev_dependencies" => Some(ActionSpec {
417            fix_type: "remove-dependency",
418            auto_fixable: true,
419            description: "Remove from devDependencies in package.json",
420            note: None,
421            suppress: SuppressKind::ConfigIgnoreDep,
422            issue_kind: "unused-dev-dependency",
423        }),
424        "unused_optional_dependencies" => Some(ActionSpec {
425            fix_type: "remove-dependency",
426            auto_fixable: true,
427            description: "Remove from optionalDependencies in package.json",
428            note: None,
429            suppress: SuppressKind::ConfigIgnoreDep,
430            // No IssueKind variant exists for optional deps — uses config suppress only.
431            issue_kind: "unused-dependency",
432        }),
433        "unused_enum_members" => Some(ActionSpec {
434            fix_type: "remove-enum-member",
435            auto_fixable: true,
436            description: "Remove this enum member",
437            note: None,
438            suppress: SuppressKind::InlineComment,
439            issue_kind: "unused-enum-member",
440        }),
441        "unused_class_members" => Some(ActionSpec {
442            fix_type: "remove-class-member",
443            auto_fixable: false,
444            description: "Remove this class member",
445            note: Some("Class member may be used via dependency injection or decorators"),
446            suppress: SuppressKind::InlineComment,
447            issue_kind: "unused-class-member",
448        }),
449        "unresolved_imports" => Some(ActionSpec {
450            fix_type: "resolve-import",
451            auto_fixable: false,
452            description: "Fix the import specifier or install the missing module",
453            note: Some("Verify the module path and check tsconfig paths configuration"),
454            suppress: SuppressKind::InlineComment,
455            issue_kind: "unresolved-import",
456        }),
457        "unlisted_dependencies" => Some(ActionSpec {
458            fix_type: "install-dependency",
459            auto_fixable: false,
460            description: "Add this package to dependencies in package.json",
461            note: Some("Verify this package should be a direct dependency before adding"),
462            suppress: SuppressKind::ConfigIgnoreDep,
463            issue_kind: "unlisted-dependency",
464        }),
465        "duplicate_exports" => Some(ActionSpec {
466            fix_type: "remove-duplicate",
467            auto_fixable: false,
468            description: "Keep one canonical export location and remove the others",
469            note: Some(NAMESPACE_BARREL_HINT),
470            suppress: SuppressKind::InlineComment,
471            issue_kind: "duplicate-export",
472        }),
473        "type_only_dependencies" => Some(ActionSpec {
474            fix_type: "move-to-dev",
475            auto_fixable: false,
476            description: "Move to devDependencies (only type imports are used)",
477            note: Some(
478                "Type imports are erased at runtime so this dependency is not needed in production",
479            ),
480            suppress: SuppressKind::ConfigIgnoreDep,
481            issue_kind: "type-only-dependency",
482        }),
483        "test_only_dependencies" => Some(ActionSpec {
484            fix_type: "move-to-dev",
485            auto_fixable: false,
486            description: "Move to devDependencies (only test files import this)",
487            note: Some(
488                "Only test files import this package so it does not need to be a production dependency",
489            ),
490            suppress: SuppressKind::ConfigIgnoreDep,
491            issue_kind: "test-only-dependency",
492        }),
493        "circular_dependencies" => Some(ActionSpec {
494            fix_type: "refactor-cycle",
495            auto_fixable: false,
496            description: "Extract shared logic into a separate module to break the cycle",
497            note: Some(
498                "Circular imports can cause initialization issues and make code harder to reason about",
499            ),
500            suppress: SuppressKind::InlineComment,
501            issue_kind: "circular-dependency",
502        }),
503        "boundary_violations" => Some(ActionSpec {
504            fix_type: "refactor-boundary",
505            auto_fixable: false,
506            description: "Move the import through an allowed zone or restructure the dependency",
507            note: Some(
508                "This import crosses an architecture boundary that is not permitted by the configured rules",
509            ),
510            suppress: SuppressKind::InlineComment,
511            issue_kind: "boundary-violation",
512        }),
513        "unused_catalog_entries" => Some(ActionSpec {
514            fix_type: "remove-catalog-entry",
515            auto_fixable: false,
516            description: "Remove the entry from pnpm-workspace.yaml",
517            note: Some(
518                "If any consumer declares the same package with a hardcoded version, switch the consumer to `catalog:` before removing",
519            ),
520            suppress: SuppressKind::YamlComment,
521            issue_kind: "unused-catalog-entry",
522        }),
523        // The primary fix for unresolved-catalog-references is computed in
524        // build_actions because it discriminates on `available_in_catalogs`.
525        // This entry serves as the fallback / suppression spec only.
526        "unresolved_catalog_references" => Some(ActionSpec {
527            fix_type: "remove-catalog-reference",
528            auto_fixable: false,
529            description: "Remove the catalog reference and pin a hardcoded version in package.json",
530            note: Some(
531                "Use only when neither another catalog declares the package nor the named catalog should grow to include it",
532            ),
533            suppress: SuppressKind::AddToConfigIgnoreCatalogReferences,
534            issue_kind: "unresolved-catalog-reference",
535        }),
536        "unused_dependency_overrides" => Some(ActionSpec {
537            fix_type: "remove-dependency-override",
538            auto_fixable: false,
539            description: "Remove the override entry from pnpm-workspace.yaml or pnpm.overrides",
540            note: Some(
541                "Conservative static check; verify against `pnpm install --frozen-lockfile` before removing in case the override targets a transitive dependency (CVE-fix pattern)",
542            ),
543            suppress: SuppressKind::AddToConfigIgnoreDependencyOverrides,
544            issue_kind: "unused-dependency-override",
545        }),
546        "misconfigured_dependency_overrides" => Some(ActionSpec {
547            fix_type: "fix-dependency-override",
548            auto_fixable: false,
549            description: "Fix the override key or value: pnpm refuses to honor entries with an unparsable key or empty value",
550            note: Some(
551                "Common shapes: bare `pkg`, scoped `@scope/pkg`, version-selector `pkg@<2`, parent-chain `parent>child`. Valid values include semver ranges, `-` (removal), `$ref` (self-ref), and `npm:alias@^1`.",
552            ),
553            suppress: SuppressKind::AddToConfigIgnoreDependencyOverrides,
554            issue_kind: "misconfigured-dependency-override",
555        }),
556        _ => None,
557    }
558}
559
560/// Build the discriminated primary action for an `unresolved_catalog_references`
561/// finding. Reads `available_in_catalogs` to decide between the two high-confidence
562/// machine-actionable fixes; the result is appended ahead of the generic
563/// `remove-catalog-reference` fallback in `build_actions`.
564fn build_unresolved_catalog_reference_primary_action(
565    item: &serde_json::Value,
566) -> serde_json::Value {
567    let available: Vec<String> = item
568        .get("available_in_catalogs")
569        .and_then(serde_json::Value::as_array)
570        .map(|values| {
571            values
572                .iter()
573                .filter_map(serde_json::Value::as_str)
574                .map(str::to_owned)
575                .collect()
576        })
577        .unwrap_or_default();
578    let package_name = item
579        .get("entry_name")
580        .and_then(serde_json::Value::as_str)
581        .unwrap_or("package");
582    let current_catalog = item
583        .get("catalog_name")
584        .and_then(serde_json::Value::as_str)
585        .unwrap_or("default");
586    if available.is_empty() {
587        serde_json::json!({
588            "type": "add-catalog-entry",
589            "auto_fixable": false,
590            "description": format!(
591                "Add `{package_name}` to the `{current_catalog}` catalog in pnpm-workspace.yaml",
592            ),
593            "note": "Pin a version that satisfies the consumer's import; no other catalog declares this package today",
594        })
595    } else {
596        // When exactly one alternative catalog declares this package, the fix
597        // is unambiguous: name it as `suggested_target` so deterministic agents
598        // (rule-based, not LLM-driven) can land the edit without picking from
599        // a list. LLM agents that read `available_in_catalogs[0]` arrive at
600        // the same answer; this field just makes the choice machine-explicit.
601        let suggested_target = (available.len() == 1).then(|| available[0].clone());
602        let mut action = serde_json::json!({
603            "type": "update-catalog-reference",
604            "auto_fixable": false,
605            "description": format!(
606                "Switch the reference from `catalog:{current_catalog}` to a catalog that declares `{package_name}`",
607            ),
608            "available_in_catalogs": available,
609        });
610        if let Some(target) = suggested_target
611            && let serde_json::Value::Object(map) = &mut action
612        {
613            map.insert("suggested_target".to_string(), serde_json::json!(target));
614        }
615        action
616    }
617}
618
619/// Build the `actions` array for a single issue item.
620#[expect(
621    clippy::too_many_lines,
622    reason = "One match arm per SuppressKind; the line count grows with new issue types and the structure is clearer than extracting per-arm helpers."
623)]
624fn build_actions(
625    item: &serde_json::Value,
626    issue_key: &str,
627    spec: &ActionSpec,
628) -> serde_json::Value {
629    let mut actions = Vec::with_capacity(2);
630    let cross_workspace_dependency = is_dependency_issue(issue_key)
631        && item
632            .get("used_in_workspaces")
633            .and_then(serde_json::Value::as_array)
634            .is_some_and(|workspaces| !workspaces.is_empty());
635
636    // unresolved-catalog-references emits a discriminated primary action
637    // before the generic remove-catalog-reference fallback below.
638    if issue_key == "unresolved_catalog_references" {
639        actions.push(build_unresolved_catalog_reference_primary_action(item));
640    }
641
642    // duplicate-exports: emit the safe `add-to-config` (ignoreExports) action
643    // FIRST so position-0 (the documented primary slot, see HealthFindingAction
644    // schema description) recommends the non-destructive path for the common
645    // shadcn / Radix / bits-ui namespace-barrel case. The fix action below
646    // (remove-duplicate) stays as the secondary, for findings where the
647    // duplication is genuinely accidental.
648    if issue_key == "duplicate_exports"
649        && let Some(value) = build_duplicate_exports_config_value(item)
650    {
651        actions.push(serde_json::json!({
652            "type": "add-to-config",
653            "auto_fixable": false,
654            "description": "Add an ignoreExports rule so these files are excluded from duplicate-export grouping (use when this duplication is an intentional namespace-barrel API).",
655            "config_key": "ignoreExports",
656            "value": value,
657            "value_schema": IGNORE_EXPORTS_VALUE_SCHEMA,
658        }));
659    }
660
661    // Primary fix action. `auto_fixable` is per-instance for issue types
662    // whose `fallow fix` applier has per-finding guards (e.g.
663    // `unused_catalog_entries` skips entries with non-empty
664    // `hardcoded_consumers`); for those types the ActionSpec carries the
665    // pessimistic default `false` and the per-instance override flips it
666    // to `true` here when the applier would actually run.
667    let auto_fixable = if issue_key == "unused_catalog_entries" {
668        item.get("hardcoded_consumers")
669            .and_then(serde_json::Value::as_array)
670            .is_none_or(Vec::is_empty)
671    } else {
672        spec.auto_fixable
673    };
674    let mut fix_action = if cross_workspace_dependency {
675        serde_json::json!({
676            "type": "move-dependency",
677            "auto_fixable": false,
678            "description": "Move this dependency to the workspace package.json that imports it",
679            "note": "fallow fix will not remove dependencies that are imported by another workspace",
680        })
681    } else {
682        serde_json::json!({
683            "type": spec.fix_type,
684            "auto_fixable": auto_fixable,
685            "description": spec.description,
686        })
687    };
688    if let Some(note) = spec.note {
689        fix_action["note"] = serde_json::json!(note);
690    }
691    // Warn about re-exports that may be part of the public API surface.
692    if (issue_key == "unused_exports" || issue_key == "unused_types")
693        && item
694            .get("is_re_export")
695            .and_then(serde_json::Value::as_bool)
696            == Some(true)
697    {
698        fix_action["note"] = serde_json::json!(
699            "This finding originates from a re-export; verify it is not part of your public API before removing"
700        );
701    }
702    actions.push(fix_action);
703
704    // Suppress action — every action carries `auto_fixable` for uniform filtering.
705    match spec.suppress {
706        SuppressKind::InlineComment => {
707            let mut suppress = serde_json::json!({
708                "type": "suppress-line",
709                "auto_fixable": false,
710                "description": "Suppress with an inline comment above the line",
711                "comment": format!("// fallow-ignore-next-line {}", spec.issue_kind),
712            });
713            // duplicate_exports has N locations, not one — flag multi-location scope.
714            if issue_key == "duplicate_exports" {
715                suppress["scope"] = serde_json::json!("per-location");
716            }
717            actions.push(suppress);
718        }
719        SuppressKind::YamlComment => {
720            actions.push(serde_json::json!({
721                "type": "suppress-line",
722                "auto_fixable": false,
723                "description": "Suppress with a YAML comment above the line",
724                "comment": format!("# fallow-ignore-next-line {}", spec.issue_kind),
725            }));
726        }
727        SuppressKind::FileComment => {
728            actions.push(serde_json::json!({
729                "type": "suppress-file",
730                "auto_fixable": false,
731                "description": "Suppress with a file-level comment at the top of the file",
732                "comment": format!("// fallow-ignore-file {}", spec.issue_kind),
733            }));
734        }
735        SuppressKind::ConfigIgnoreDep => {
736            // Extract the package name from the item for a concrete suggestion.
737            let pkg = item
738                .get("package_name")
739                .and_then(serde_json::Value::as_str)
740                .unwrap_or("package-name");
741            actions.push(serde_json::json!({
742                "type": "add-to-config",
743                "auto_fixable": false,
744                "description": format!("Add \"{pkg}\" to ignoreDependencies in fallow config"),
745                "config_key": "ignoreDependencies",
746                "value": pkg,
747                "value_schema": IGNORE_DEPENDENCIES_VALUE_SCHEMA,
748            }));
749        }
750        SuppressKind::AddToConfigIgnoreCatalogReferences => {
751            let package_name = item
752                .get("entry_name")
753                .and_then(serde_json::Value::as_str)
754                .unwrap_or("package");
755            let catalog_name = item
756                .get("catalog_name")
757                .and_then(serde_json::Value::as_str)
758                .unwrap_or("default");
759            let consumer_path = item
760                .get("path")
761                .and_then(serde_json::Value::as_str)
762                .unwrap_or("");
763            let value = serde_json::json!({
764                "package": package_name,
765                "catalog": catalog_name,
766                "consumer": consumer_path,
767            });
768            actions.push(serde_json::json!({
769                "type": "add-to-config",
770                "auto_fixable": false,
771                "description": "Suppress this reference via ignoreCatalogReferences in fallow config (use when the catalog edit is intentionally landing in a separate PR or the package is a placeholder).",
772                "config_key": "ignoreCatalogReferences",
773                "value": value,
774                "value_schema": IGNORE_CATALOG_REFERENCES_VALUE_SCHEMA,
775            }));
776        }
777        SuppressKind::AddToConfigIgnoreDependencyOverrides => {
778            // `target_package` is set on unused overrides (the key parsed
779            // successfully) but absent on misconfigured ones whose `raw_key`
780            // could not be parsed. Falling back to `raw_key` is unsafe in the
781            // misconfigured case: it may be empty or malformed. Skip the
782            // suppress action entirely when neither field yields a non-empty
783            // name; an `ignoreDependencyOverrides` entry with `package: ""`
784            // would be silently ignored by the config parser.
785            let package_name = item
786                .get("target_package")
787                .and_then(serde_json::Value::as_str)
788                .filter(|s| !s.is_empty())
789                .or_else(|| {
790                    item.get("raw_key")
791                        .and_then(serde_json::Value::as_str)
792                        .filter(|s| !s.is_empty())
793                });
794            if let Some(package_name) = package_name {
795                let source = item
796                    .get("source")
797                    .and_then(serde_json::Value::as_str)
798                    .unwrap_or("pnpm-workspace.yaml");
799                let value = serde_json::json!({
800                    "package": package_name,
801                    "source": source,
802                });
803                actions.push(serde_json::json!({
804                    "type": "add-to-config",
805                    "auto_fixable": false,
806                    "description": "Suppress this override finding via ignoreDependencyOverrides in fallow config (use for CVE-fix overrides that target a purely-transitive package).",
807                    "config_key": "ignoreDependencyOverrides",
808                    "value": value,
809                    "value_schema": IGNORE_DEPENDENCY_OVERRIDES_VALUE_SCHEMA,
810                }));
811            }
812        }
813    }
814
815    serde_json::Value::Array(actions)
816}
817
818/// Build a paste-ready `ignoreExports` config snippet from a duplicate-exports
819/// finding's `locations` array. Returns one `{ file, exports: ["*"] }` entry per
820/// distinct file in the finding, in stable order.
821fn build_duplicate_exports_config_value(item: &serde_json::Value) -> Option<serde_json::Value> {
822    let locations = item.get("locations")?.as_array()?;
823    let mut entries: Vec<serde_json::Value> = Vec::with_capacity(locations.len());
824    let mut seen: rustc_hash::FxHashSet<&str> = rustc_hash::FxHashSet::default();
825    for loc in locations {
826        let Some(path) = loc.get("path").and_then(serde_json::Value::as_str) else {
827            continue;
828        };
829        if !seen.insert(path) {
830            continue;
831        }
832        entries.push(serde_json::json!({
833            "file": path,
834            "exports": ["*"],
835        }));
836    }
837    if entries.is_empty() {
838        return None;
839    }
840    Some(serde_json::Value::Array(entries))
841}
842
843fn is_dependency_issue(issue_key: &str) -> bool {
844    matches!(
845        issue_key,
846        "unused_dependencies" | "unused_dev_dependencies" | "unused_optional_dependencies"
847    )
848}
849
850/// Inject `actions` arrays into every issue item in the JSON output.
851///
852/// Walks each known issue-type array and appends an `actions` field
853/// to every item, providing machine-actionable fix and suppress hints.
854fn inject_actions(output: &mut serde_json::Value) {
855    let Some(map) = output.as_object_mut() else {
856        return;
857    };
858
859    for (key, value) in map.iter_mut() {
860        let Some(spec) = actions_for_issue_type(key) else {
861            continue;
862        };
863        let Some(arr) = value.as_array_mut() else {
864            continue;
865        };
866        for item in arr {
867            let actions = build_actions(item, key, &spec);
868            if let serde_json::Value::Object(obj) = item {
869                obj.insert("actions".to_string(), actions);
870            }
871        }
872    }
873}
874
875type SuppressAnchor = (String, u64);
876
877#[allow(
878    clippy::redundant_pub_crate,
879    reason = "used through report module re-export by audit.rs"
880)]
881pub(crate) fn harmonize_multi_kind_suppress_line_actions(output: &mut serde_json::Value) {
882    let mut anchors: BTreeMap<SuppressAnchor, Vec<String>> = BTreeMap::new();
883    collect_suppress_line_anchors(output, &mut anchors);
884
885    anchors.retain(|_, kinds| {
886        sort_suppression_kinds(kinds);
887        kinds.dedup();
888        kinds.len() > 1
889    });
890    if anchors.is_empty() {
891        return;
892    }
893
894    rewrite_suppress_line_actions(output, &anchors);
895}
896
897fn collect_suppress_line_anchors(
898    value: &serde_json::Value,
899    anchors: &mut BTreeMap<SuppressAnchor, Vec<String>>,
900) {
901    match value {
902        serde_json::Value::Object(map) => {
903            if let Some(anchor) = suppression_anchor(map)
904                && let Some(actions) = map.get("actions").and_then(serde_json::Value::as_array)
905            {
906                for action in actions {
907                    if let Some(comment) = suppress_line_comment(action) {
908                        for kind in parse_suppress_line_comment(comment) {
909                            let kinds = anchors.entry(anchor.clone()).or_default();
910                            if !kinds.iter().any(|existing| existing == &kind) {
911                                kinds.push(kind);
912                            }
913                        }
914                    }
915                }
916            }
917
918            for child in map.values() {
919                collect_suppress_line_anchors(child, anchors);
920            }
921        }
922        serde_json::Value::Array(items) => {
923            for item in items {
924                collect_suppress_line_anchors(item, anchors);
925            }
926        }
927        _ => {}
928    }
929}
930
931fn rewrite_suppress_line_actions(
932    value: &mut serde_json::Value,
933    anchors: &BTreeMap<SuppressAnchor, Vec<String>>,
934) {
935    match value {
936        serde_json::Value::Object(map) => {
937            if let Some(anchor) = suppression_anchor(map)
938                && let Some(kinds) = anchors.get(&anchor)
939            {
940                let comment = format!("// fallow-ignore-next-line {}", kinds.join(", "));
941                if let Some(actions) = map
942                    .get_mut("actions")
943                    .and_then(serde_json::Value::as_array_mut)
944                {
945                    for action in actions {
946                        if suppress_line_comment(action).is_some()
947                            && let serde_json::Value::Object(action_map) = action
948                        {
949                            action_map.insert("comment".to_string(), serde_json::json!(comment));
950                        }
951                    }
952                }
953            }
954
955            for child in map.values_mut() {
956                rewrite_suppress_line_actions(child, anchors);
957            }
958        }
959        serde_json::Value::Array(items) => {
960            for item in items {
961                rewrite_suppress_line_actions(item, anchors);
962            }
963        }
964        _ => {}
965    }
966}
967
968fn suppression_anchor(map: &serde_json::Map<String, serde_json::Value>) -> Option<SuppressAnchor> {
969    let path = map
970        .get("path")
971        .or_else(|| map.get("from_path"))
972        .and_then(serde_json::Value::as_str)?;
973    let line = map.get("line").and_then(serde_json::Value::as_u64)?;
974    Some((path.to_string(), line))
975}
976
977fn suppress_line_comment(action: &serde_json::Value) -> Option<&str> {
978    (action.get("type").and_then(serde_json::Value::as_str) == Some("suppress-line"))
979        .then_some(())
980        .and_then(|()| action.get("comment").and_then(serde_json::Value::as_str))
981}
982
983fn parse_suppress_line_comment(comment: &str) -> Vec<String> {
984    comment
985        .strip_prefix("// fallow-ignore-next-line ")
986        .map(|rest| {
987            rest.split(|c: char| c == ',' || c.is_whitespace())
988                .filter(|token| !token.is_empty())
989                .map(str::to_string)
990                .collect()
991        })
992        .unwrap_or_default()
993}
994
995fn sort_suppression_kinds(kinds: &mut [String]) {
996    kinds.sort_by_key(|kind| suppression_kind_rank(kind));
997}
998
999fn suppression_kind_rank(kind: &str) -> usize {
1000    match kind {
1001        "unused-file" => 0,
1002        "unused-export" => 1,
1003        "unused-type" => 2,
1004        "private-type-leak" => 3,
1005        "unused-enum-member" => 4,
1006        "unused-class-member" => 5,
1007        "unresolved-import" => 6,
1008        "unlisted-dependency" => 7,
1009        "duplicate-export" => 8,
1010        "circular-dependency" => 9,
1011        "boundary-violation" => 10,
1012        "code-duplication" => 11,
1013        "complexity" => 12,
1014        _ => usize::MAX,
1015    }
1016}
1017
1018// ── Health action injection ─────────────────────────────────────
1019
1020/// Build a JSON representation of baseline deltas for the combined JSON envelope.
1021///
1022/// Accepts a total delta and an iterator of per-category entries to avoid
1023/// coupling the report module (compiled in both lib and bin) to the
1024/// binary-only `baseline` module.
1025pub fn build_baseline_deltas_json<'a>(
1026    total_delta: i64,
1027    per_category: impl Iterator<Item = (&'a str, usize, usize, i64)>,
1028) -> serde_json::Value {
1029    let mut per_cat = serde_json::Map::new();
1030    for (cat, current, baseline, delta) in per_category {
1031        per_cat.insert(
1032            cat.to_string(),
1033            serde_json::json!({
1034                "current": current,
1035                "baseline": baseline,
1036                "delta": delta,
1037            }),
1038        );
1039    }
1040    serde_json::json!({
1041        "total_delta": total_delta,
1042        "per_category": per_cat
1043    })
1044}
1045
1046/// Cyclomatic distance from `max_cyclomatic_threshold` at which a
1047/// CRAP-only finding still warrants a secondary `refactor-function` action.
1048///
1049/// Reasoning: a function whose cyclomatic count is within this band of the
1050/// configured threshold is "almost too complex" already, so refactoring is a
1051/// useful complement to the primary coverage action. Keeping the boundary
1052/// expressed as a band (threshold minus N) rather than a ratio links it
1053/// to the existing `health.maxCyclomatic` knob: tightening the threshold
1054/// automatically widens the population that gets the secondary suggestion.
1055const SECONDARY_REFACTOR_BAND: u16 = 5;
1056
1057/// Options controlling how `inject_health_actions` populates JSON output.
1058///
1059/// `omit_suppress_line` skips the `suppress-line` action across every
1060/// health finding. Set when:
1061/// - A baseline is active (`opts.baseline.is_some()` or
1062///   `opts.save_baseline.is_some()`): the baseline file already suppresses
1063///   findings, and adding `// fallow-ignore-next-line` comments on top
1064///   creates dead annotations once the baseline regenerates.
1065/// - The team has opted out via `health.suggestInlineSuppression: false`.
1066///
1067/// When omitted, a top-level `actions_meta` object on the report records
1068/// the omission and the reason so consumers can audit "where did
1069/// health finding suppress-line go?" without having to grep the config
1070/// or CLI history.
1071#[derive(Debug, Clone, Copy, Default)]
1072pub struct HealthActionOptions {
1073    /// Skip emission of `suppress-line` action entries.
1074    pub omit_suppress_line: bool,
1075    /// Human-readable reason surfaced in the `actions_meta` breadcrumb when
1076    /// `omit_suppress_line` is true. Stable codes:
1077    /// - `"baseline-active"`: `--baseline` or `--save-baseline` was passed
1078    /// - `"config-disabled"`: `health.suggestInlineSuppression: false`
1079    pub omit_reason: Option<&'static str>,
1080}
1081
1082/// Inject `actions` arrays into complexity findings in a health JSON output.
1083///
1084/// Walks `findings` and `targets` arrays, appending machine-actionable
1085/// fix and suppress hints to each item. The `opts` argument controls
1086/// whether `suppress-line` actions are emitted; when suppressed, an
1087/// `actions_meta` breadcrumb at the report root records the omission.
1088#[allow(
1089    clippy::redundant_pub_crate,
1090    reason = "pub(crate) needed, used by audit.rs via re-export, but not part of public API"
1091)]
1092pub(crate) fn inject_health_actions(output: &mut serde_json::Value, opts: HealthActionOptions) {
1093    let Some(map) = output.as_object_mut() else {
1094        return;
1095    };
1096
1097    // The complexity thresholds live on `summary.*_threshold`; read once so
1098    // action selection for findings has access without re-walking the envelope.
1099    let max_cyclomatic_threshold = map
1100        .get("summary")
1101        .and_then(|s| s.get("max_cyclomatic_threshold"))
1102        .and_then(serde_json::Value::as_u64)
1103        .and_then(|v| u16::try_from(v).ok())
1104        .unwrap_or(20);
1105    let max_cognitive_threshold = map
1106        .get("summary")
1107        .and_then(|s| s.get("max_cognitive_threshold"))
1108        .and_then(serde_json::Value::as_u64)
1109        .and_then(|v| u16::try_from(v).ok())
1110        .unwrap_or(15);
1111    let max_crap_threshold = map
1112        .get("summary")
1113        .and_then(|s| s.get("max_crap_threshold"))
1114        .and_then(serde_json::Value::as_f64)
1115        .unwrap_or(30.0);
1116
1117    // Complexity findings: refactor the function to reduce complexity
1118    if let Some(findings) = map.get_mut("findings").and_then(|v| v.as_array_mut()) {
1119        for item in findings {
1120            let actions = build_health_finding_actions(
1121                item,
1122                opts,
1123                max_cyclomatic_threshold,
1124                max_cognitive_threshold,
1125                max_crap_threshold,
1126            );
1127            if let serde_json::Value::Object(obj) = item {
1128                obj.insert("actions".to_string(), actions);
1129            }
1130        }
1131    }
1132
1133    // Refactoring targets: apply the recommended refactoring
1134    if let Some(targets) = map.get_mut("targets").and_then(|v| v.as_array_mut()) {
1135        for item in targets {
1136            let actions = build_refactoring_target_actions(item);
1137            if let serde_json::Value::Object(obj) = item {
1138                obj.insert("actions".to_string(), actions);
1139            }
1140        }
1141    }
1142
1143    // Hotspots: files that are both complex and frequently changing
1144    if let Some(hotspots) = map.get_mut("hotspots").and_then(|v| v.as_array_mut()) {
1145        for item in hotspots {
1146            let actions = build_hotspot_actions(item);
1147            if let serde_json::Value::Object(obj) = item {
1148                obj.insert("actions".to_string(), actions);
1149            }
1150        }
1151    }
1152
1153    // Coverage gaps: untested files and exports
1154    if let Some(gaps) = map.get_mut("coverage_gaps").and_then(|v| v.as_object_mut()) {
1155        if let Some(files) = gaps.get_mut("files").and_then(|v| v.as_array_mut()) {
1156            for item in files {
1157                let actions = build_untested_file_actions(item);
1158                if let serde_json::Value::Object(obj) = item {
1159                    obj.insert("actions".to_string(), actions);
1160                }
1161            }
1162        }
1163        if let Some(exports) = gaps.get_mut("exports").and_then(|v| v.as_array_mut()) {
1164            for item in exports {
1165                let actions = build_untested_export_actions(item);
1166                if let serde_json::Value::Object(obj) = item {
1167                    obj.insert("actions".to_string(), actions);
1168                }
1169            }
1170        }
1171    }
1172
1173    // Runtime coverage actions are emitted by the sidecar and serialized
1174    // directly via serde (see `RuntimeCoverageAction` in
1175    // `crates/cli/src/health_types/runtime_coverage.rs`), so no post-hoc
1176    // injection is needed here.
1177
1178    // Auditable breadcrumb: when the suppress-line hint was omitted, record
1179    // it at the report root so consumers don't have to infer the absence.
1180    if opts.omit_suppress_line {
1181        let reason = opts.omit_reason.unwrap_or("unspecified");
1182        map.insert(
1183            "actions_meta".to_string(),
1184            serde_json::json!({
1185                "suppression_hints_omitted": true,
1186                "reason": reason,
1187                "scope": "health-findings",
1188            }),
1189        );
1190    }
1191}
1192
1193/// Build the `actions` array for a single complexity finding.
1194///
1195/// The primary action depends on which thresholds were exceeded and the
1196/// finding's bucketed coverage tier (`none`/`partial`/`high`):
1197///
1198/// - Exceeded cyclomatic/cognitive only (no CRAP): `refactor-function`.
1199/// - Exceeded CRAP, tier `none` or absent: `add-tests` (no test path
1200///   reaches this function; start from scratch).
1201/// - Exceeded CRAP, tier `partial`: `increase-coverage` (file already has
1202///   some test path; add targeted assertions for uncovered branches).
1203/// - Exceeded CRAP, full coverage can clear CRAP: tier-specific coverage
1204///   action (`add-tests` for `none`, `increase-coverage` for `partial`/
1205///   `high`).
1206/// - Exceeded CRAP, full coverage cannot clear CRAP: `refactor-function`
1207///   because reducing cyclomatic complexity is the remaining lever.
1208/// - Exceeded both CRAP and cyclomatic/cognitive: emit BOTH the
1209///   tier-appropriate coverage action AND `refactor-function`.
1210/// - CRAP-only with cyclomatic close to the threshold (within
1211///   `SECONDARY_REFACTOR_BAND`): also append `refactor-function` as a
1212///   secondary action; the function is "almost too complex" already.
1213///
1214/// `suppress-line` is appended last unless `opts.omit_suppress_line` is
1215/// true (baseline active or `health.suggestInlineSuppression: false`).
1216fn build_health_finding_actions(
1217    item: &serde_json::Value,
1218    opts: HealthActionOptions,
1219    max_cyclomatic_threshold: u16,
1220    max_cognitive_threshold: u16,
1221    max_crap_threshold: f64,
1222) -> serde_json::Value {
1223    let name = item
1224        .get("name")
1225        .and_then(serde_json::Value::as_str)
1226        .unwrap_or("function");
1227    let path = item
1228        .get("path")
1229        .and_then(serde_json::Value::as_str)
1230        .unwrap_or("");
1231    let exceeded = item
1232        .get("exceeded")
1233        .and_then(serde_json::Value::as_str)
1234        .unwrap_or("");
1235    let includes_crap = matches!(
1236        exceeded,
1237        "crap" | "cyclomatic_crap" | "cognitive_crap" | "all"
1238    );
1239    let crap_only = exceeded == "crap";
1240    let tier = item
1241        .get("coverage_tier")
1242        .and_then(serde_json::Value::as_str);
1243    let cyclomatic = item
1244        .get("cyclomatic")
1245        .and_then(serde_json::Value::as_u64)
1246        .and_then(|v| u16::try_from(v).ok())
1247        .unwrap_or(0);
1248    let cognitive = item
1249        .get("cognitive")
1250        .and_then(serde_json::Value::as_u64)
1251        .and_then(|v| u16::try_from(v).ok())
1252        .unwrap_or(0);
1253    let full_coverage_can_clear_crap = !includes_crap || f64::from(cyclomatic) < max_crap_threshold;
1254
1255    let mut actions: Vec<serde_json::Value> = Vec::new();
1256
1257    // Coverage-leaning action: only emitted when CRAP contributed.
1258    if includes_crap {
1259        let coverage_action = build_crap_coverage_action(name, tier, full_coverage_can_clear_crap);
1260        if let Some(action) = coverage_action {
1261            actions.push(action);
1262        }
1263    }
1264
1265    // Refactor action conditions:
1266    //   1. Exceeded cyclomatic/cognitive (with or without CRAP), or
1267    //   2. CRAP-only where even full coverage cannot bring CRAP below the
1268    //      configured threshold, so reducing complexity is the remaining
1269    //      lever), or
1270    //   3. CRAP-only with cyclomatic within SECONDARY_REFACTOR_BAND of the
1271    //      threshold AND cognitive complexity past the cognitive floor (the
1272    //      function is almost too complex anyway and the cognitive signal
1273    //      confirms that refactoring would actually help). Without the
1274    //      cognitive floor, flat type-tag dispatchers and JSX render maps
1275    //      (high CC, near-zero cog) get a misleading refactor suggestion.
1276    //
1277    // `build_crap_coverage_action` returns `None` for case 2 instead of
1278    // pushing `refactor-function` itself, so this branch unconditionally
1279    // pushes the refactor entry without needing to dedupe.
1280    let crap_only_needs_complexity_reduction = crap_only && !full_coverage_can_clear_crap;
1281    let cognitive_floor = max_cognitive_threshold / 2;
1282    let near_cyclomatic_threshold = crap_only
1283        && cyclomatic > 0
1284        && cyclomatic >= max_cyclomatic_threshold.saturating_sub(SECONDARY_REFACTOR_BAND)
1285        && cognitive >= cognitive_floor;
1286    let is_template = name == "<template>";
1287    if !crap_only || crap_only_needs_complexity_reduction || near_cyclomatic_threshold {
1288        let (description, note) = if is_template {
1289            (
1290                format!(
1291                    "Refactor `{name}` to reduce template complexity (simplify control flow and bindings)"
1292                ),
1293                "Consider splitting complex template branches into smaller components or simpler bindings",
1294            )
1295        } else {
1296            (
1297                format!(
1298                    "Refactor `{name}` to reduce complexity (extract helper functions, simplify branching)"
1299                ),
1300                "Consider splitting into smaller functions with single responsibilities",
1301            )
1302        };
1303        actions.push(serde_json::json!({
1304            "type": "refactor-function",
1305            "auto_fixable": false,
1306            "description": description,
1307            "note": note,
1308        }));
1309    }
1310
1311    if !opts.omit_suppress_line {
1312        if is_template
1313            && Path::new(path)
1314                .extension()
1315                .is_some_and(|ext| ext.eq_ignore_ascii_case("html"))
1316        {
1317            actions.push(serde_json::json!({
1318                "type": "suppress-file",
1319                "auto_fixable": false,
1320                "description": "Suppress with an HTML comment at the top of the template",
1321                "comment": "<!-- fallow-ignore-file complexity -->",
1322                "placement": "top-of-template",
1323            }));
1324        } else if is_template {
1325            actions.push(serde_json::json!({
1326                "type": "suppress-line",
1327                "auto_fixable": false,
1328                "description": "Suppress with an inline comment above the Angular decorator",
1329                "comment": "// fallow-ignore-next-line complexity",
1330                "placement": "above-angular-decorator",
1331            }));
1332        } else {
1333            actions.push(serde_json::json!({
1334                "type": "suppress-line",
1335                "auto_fixable": false,
1336                "description": "Suppress with an inline comment above the function declaration",
1337                "comment": "// fallow-ignore-next-line complexity",
1338                "placement": "above-function-declaration",
1339            }));
1340        }
1341    }
1342
1343    serde_json::Value::Array(actions)
1344}
1345
1346/// Build the coverage-leaning action for a CRAP-contributing finding.
1347///
1348/// Returns `None` when even 100% coverage could not bring the function below
1349/// the configured CRAP threshold. In that case the primary action becomes
1350/// `refactor-function`, which the caller emits separately.
1351fn build_crap_coverage_action(
1352    name: &str,
1353    tier: Option<&str>,
1354    full_coverage_can_clear_crap: bool,
1355) -> Option<serde_json::Value> {
1356    if !full_coverage_can_clear_crap {
1357        return None;
1358    }
1359
1360    match tier {
1361        // Partial coverage: the file already has some test path. Pivot
1362        // the action description from "add tests" to "increase coverage"
1363        // so agents add targeted assertions for uncovered branches
1364        // instead of scaffolding new tests from scratch.
1365        Some("partial" | "high") => Some(serde_json::json!({
1366            "type": "increase-coverage",
1367            "auto_fixable": false,
1368            "description": format!("Increase test coverage for `{name}` (file is reachable from existing tests; add targeted assertions for uncovered branches)"),
1369            "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",
1370        })),
1371        // None / unknown tier: keep the original "add-tests" message.
1372        _ => Some(serde_json::json!({
1373            "type": "add-tests",
1374            "auto_fixable": false,
1375            "description": format!("Add test coverage for `{name}` to lower its CRAP score (coverage reduces risk even without refactoring)"),
1376            "note": "CRAP = CC^2 * (1 - cov/100)^3 + CC; higher coverage is the fastest way to bring CRAP under threshold",
1377        })),
1378    }
1379}
1380
1381/// Build the `actions` array for a single hotspot entry.
1382fn build_hotspot_actions(item: &serde_json::Value) -> serde_json::Value {
1383    let path = item
1384        .get("path")
1385        .and_then(serde_json::Value::as_str)
1386        .unwrap_or("file");
1387
1388    let mut actions = vec![
1389        serde_json::json!({
1390            "type": "refactor-file",
1391            "auto_fixable": false,
1392            "description": format!("Refactor `{path}`, high complexity combined with frequent changes makes this a maintenance risk"),
1393            "note": "Prioritize extracting complex functions, adding tests, or splitting the module",
1394        }),
1395        serde_json::json!({
1396            "type": "add-tests",
1397            "auto_fixable": false,
1398            "description": format!("Add test coverage for `{path}` to reduce change risk"),
1399            "note": "Frequently changed complex files benefit most from comprehensive test coverage",
1400        }),
1401    ];
1402
1403    if let Some(ownership) = item.get("ownership") {
1404        // Bus factor of 1 is the canonical "single point of failure" signal.
1405        if ownership
1406            .get("bus_factor")
1407            .and_then(serde_json::Value::as_u64)
1408            == Some(1)
1409        {
1410            let top = ownership.get("top_contributor");
1411            let owner = top
1412                .and_then(|t| t.get("identifier"))
1413                .and_then(serde_json::Value::as_str)
1414                .unwrap_or("the sole contributor");
1415            // Soften the note for files with very few commits — calling a
1416            // 3-commit file a "knowledge loss risk" reads as catastrophizing
1417            // for solo maintainers and small teams. Keep the action so
1418            // agents still see the signal, but soften the framing.
1419            let commits = top
1420                .and_then(|t| t.get("commits"))
1421                .and_then(serde_json::Value::as_u64)
1422                .unwrap_or(0);
1423            // File-specific note: name the candidate reviewers from the
1424            // `suggested_reviewers` array when any exist, fall back to
1425            // softened framing for low-commit files, and otherwise omit
1426            // the note entirely (the description already carries the
1427            // actionable ask; adding generic boilerplate wastes tokens).
1428            let suggested: Vec<String> = ownership
1429                .get("suggested_reviewers")
1430                .and_then(serde_json::Value::as_array)
1431                .map(|arr| {
1432                    arr.iter()
1433                        .filter_map(|r| {
1434                            r.get("identifier")
1435                                .and_then(serde_json::Value::as_str)
1436                                .map(String::from)
1437                        })
1438                        .collect()
1439                })
1440                .unwrap_or_default();
1441            let mut low_bus_action = serde_json::json!({
1442                "type": "low-bus-factor",
1443                "auto_fixable": false,
1444                "description": format!(
1445                    "{owner} is the sole recent contributor to `{path}`; adding a second reviewer reduces knowledge-loss risk"
1446                ),
1447            });
1448            if !suggested.is_empty() {
1449                let list = suggested
1450                    .iter()
1451                    .map(|s| format!("@{s}"))
1452                    .collect::<Vec<_>>()
1453                    .join(", ");
1454                low_bus_action["note"] =
1455                    serde_json::Value::String(format!("Candidate reviewers: {list}"));
1456            } else if commits < 5 {
1457                low_bus_action["note"] = serde_json::Value::String(
1458                    "Single recent contributor on a low-commit file. Consider a pair review for major changes."
1459                        .to_string(),
1460                );
1461            }
1462            // else: omit `note` entirely — description already carries the ask.
1463            actions.push(low_bus_action);
1464        }
1465
1466        // Unowned-hotspot: file matches no CODEOWNERS rule. Skip when null
1467        // (no CODEOWNERS file discovered).
1468        if ownership
1469            .get("unowned")
1470            .and_then(serde_json::Value::as_bool)
1471            == Some(true)
1472        {
1473            actions.push(serde_json::json!({
1474                "type": "unowned-hotspot",
1475                "auto_fixable": false,
1476                "description": format!("Add a CODEOWNERS entry for `{path}`"),
1477                "note": "Frequently-changed files without declared owners create review bottlenecks",
1478                "suggested_pattern": suggest_codeowners_pattern(path),
1479                "heuristic": "directory-deepest",
1480            }));
1481        }
1482
1483        // Drift: original author no longer maintains; add a notice action so
1484        // agents can route the next change to the new top contributor.
1485        if ownership.get("drift").and_then(serde_json::Value::as_bool) == Some(true) {
1486            let reason = ownership
1487                .get("drift_reason")
1488                .and_then(serde_json::Value::as_str)
1489                .unwrap_or("ownership has shifted from the original author");
1490            actions.push(serde_json::json!({
1491                "type": "ownership-drift",
1492                "auto_fixable": false,
1493                "description": format!("Update CODEOWNERS for `{path}`: {reason}"),
1494                "note": "Drift suggests the declared or original owner is no longer the right reviewer",
1495            }));
1496        }
1497    }
1498
1499    serde_json::Value::Array(actions)
1500}
1501
1502/// Suggest a CODEOWNERS pattern for an unowned hotspot.
1503///
1504/// Picks the deepest directory containing the file
1505/// (e.g. `src/api/users/handlers.ts` -> `/src/api/users/`) so agents can
1506/// paste a tightly-scoped default. Earlier versions used the first two
1507/// directory levels but that catches too many siblings in monorepos
1508/// (`/src/api/` could span 200 files across 8 sub-domains). The deepest
1509/// directory keeps the suggestion reviewable while still being a directory
1510/// pattern rather than a per-file rule.
1511///
1512/// The action emits this alongside `"heuristic": "directory-deepest"` so
1513/// consumers can branch on the strategy if it evolves.
1514fn suggest_codeowners_pattern(path: &str) -> String {
1515    let normalized = path.replace('\\', "/");
1516    let trimmed = normalized.trim_start_matches('/');
1517    let mut components: Vec<&str> = trimmed.split('/').collect();
1518    components.pop(); // drop the file itself
1519    if components.is_empty() {
1520        return format!("/{trimmed}");
1521    }
1522    format!("/{}/", components.join("/"))
1523}
1524
1525/// Build the `actions` array for a single refactoring target.
1526fn build_refactoring_target_actions(item: &serde_json::Value) -> serde_json::Value {
1527    let recommendation = item
1528        .get("recommendation")
1529        .and_then(serde_json::Value::as_str)
1530        .unwrap_or("Apply the recommended refactoring");
1531
1532    let category = item
1533        .get("category")
1534        .and_then(serde_json::Value::as_str)
1535        .unwrap_or("refactoring");
1536
1537    let mut actions = vec![serde_json::json!({
1538        "type": "apply-refactoring",
1539        "auto_fixable": false,
1540        "description": recommendation,
1541        "category": category,
1542    })];
1543
1544    // Targets with evidence linking to specific functions get a suppress action
1545    if item.get("evidence").is_some() {
1546        actions.push(serde_json::json!({
1547            "type": "suppress-line",
1548            "auto_fixable": false,
1549            "description": "Suppress the underlying complexity finding",
1550            "comment": "// fallow-ignore-next-line complexity",
1551        }));
1552    }
1553
1554    serde_json::Value::Array(actions)
1555}
1556
1557/// Build the `actions` array for an untested file.
1558fn build_untested_file_actions(item: &serde_json::Value) -> serde_json::Value {
1559    let path = item
1560        .get("path")
1561        .and_then(serde_json::Value::as_str)
1562        .unwrap_or("file");
1563
1564    serde_json::Value::Array(vec![
1565        serde_json::json!({
1566            "type": "add-tests",
1567            "auto_fixable": false,
1568            "description": format!("Add test coverage for `{path}`"),
1569            "note": "No test dependency path reaches this runtime file",
1570        }),
1571        serde_json::json!({
1572            "type": "suppress-file",
1573            "auto_fixable": false,
1574            "description": format!("Suppress coverage gap reporting for `{path}`"),
1575            "comment": "// fallow-ignore-file coverage-gaps",
1576        }),
1577    ])
1578}
1579
1580/// Build the `actions` array for an untested export.
1581fn build_untested_export_actions(item: &serde_json::Value) -> serde_json::Value {
1582    let path = item
1583        .get("path")
1584        .and_then(serde_json::Value::as_str)
1585        .unwrap_or("file");
1586    let export_name = item
1587        .get("export_name")
1588        .and_then(serde_json::Value::as_str)
1589        .unwrap_or("export");
1590
1591    serde_json::Value::Array(vec![
1592        serde_json::json!({
1593            "type": "add-test-import",
1594            "auto_fixable": false,
1595            "description": format!("Import and test `{export_name}` from `{path}`"),
1596            "note": "This export is runtime-reachable but no test-reachable module references it",
1597        }),
1598        serde_json::json!({
1599            "type": "suppress-file",
1600            "auto_fixable": false,
1601            "description": format!("Suppress coverage gap reporting for `{path}`"),
1602            "comment": "// fallow-ignore-file coverage-gaps",
1603        }),
1604    ])
1605}
1606
1607// ── Duplication action injection ────────────────────────────────
1608
1609/// Inject `actions` arrays into clone families/groups in a duplication JSON output.
1610///
1611/// Walks `clone_families` and `clone_groups` arrays, appending
1612/// machine-actionable fix and config hints to each item.
1613#[allow(
1614    clippy::redundant_pub_crate,
1615    reason = "pub(crate) needed — used by audit.rs via re-export, but not part of public API"
1616)]
1617pub(crate) fn inject_dupes_actions(output: &mut serde_json::Value) {
1618    let Some(map) = output.as_object_mut() else {
1619        return;
1620    };
1621
1622    // Clone families: extract shared module/function
1623    if let Some(families) = map.get_mut("clone_families").and_then(|v| v.as_array_mut()) {
1624        for item in families {
1625            let actions = build_clone_family_actions(item);
1626            if let serde_json::Value::Object(obj) = item {
1627                obj.insert("actions".to_string(), actions);
1628            }
1629        }
1630    }
1631
1632    // Clone groups: extract shared code
1633    if let Some(groups) = map.get_mut("clone_groups").and_then(|v| v.as_array_mut()) {
1634        for item in groups {
1635            let actions = build_clone_group_actions(item);
1636            if let serde_json::Value::Object(obj) = item {
1637                obj.insert("actions".to_string(), actions);
1638            }
1639        }
1640    }
1641}
1642
1643/// Build the `actions` array for a single clone family.
1644fn build_clone_family_actions(item: &serde_json::Value) -> serde_json::Value {
1645    let group_count = item
1646        .get("groups")
1647        .and_then(|v| v.as_array())
1648        .map_or(0, Vec::len);
1649
1650    let total_lines = item
1651        .get("total_duplicated_lines")
1652        .and_then(serde_json::Value::as_u64)
1653        .unwrap_or(0);
1654
1655    let mut actions = vec![serde_json::json!({
1656        "type": "extract-shared",
1657        "auto_fixable": false,
1658        "description": format!(
1659            "Extract {group_count} duplicated code block{} ({total_lines} lines) into a shared module",
1660            if group_count == 1 { "" } else { "s" }
1661        ),
1662        "note": "These clone groups share the same files, indicating a structural relationship; refactor together",
1663    })];
1664
1665    // Include any refactoring suggestions from the family
1666    if let Some(suggestions) = item.get("suggestions").and_then(|v| v.as_array()) {
1667        for suggestion in suggestions {
1668            if let Some(desc) = suggestion
1669                .get("description")
1670                .and_then(serde_json::Value::as_str)
1671            {
1672                actions.push(serde_json::json!({
1673                    "type": "apply-suggestion",
1674                    "auto_fixable": false,
1675                    "description": desc,
1676                }));
1677            }
1678        }
1679    }
1680
1681    actions.push(serde_json::json!({
1682        "type": "suppress-line",
1683        "auto_fixable": false,
1684        "description": "Suppress with an inline comment above the duplicated code",
1685        "comment": "// fallow-ignore-next-line code-duplication",
1686    }));
1687
1688    serde_json::Value::Array(actions)
1689}
1690
1691/// Build the `actions` array for a single clone group.
1692fn build_clone_group_actions(item: &serde_json::Value) -> serde_json::Value {
1693    let instance_count = item
1694        .get("instances")
1695        .and_then(|v| v.as_array())
1696        .map_or(0, Vec::len);
1697
1698    let line_count = item
1699        .get("line_count")
1700        .and_then(serde_json::Value::as_u64)
1701        .unwrap_or(0);
1702
1703    let actions = vec![
1704        serde_json::json!({
1705            "type": "extract-shared",
1706            "auto_fixable": false,
1707            "description": format!(
1708                "Extract duplicated code ({line_count} lines, {instance_count} instance{}) into a shared function",
1709                if instance_count == 1 { "" } else { "s" }
1710            ),
1711        }),
1712        serde_json::json!({
1713            "type": "suppress-line",
1714            "auto_fixable": false,
1715            "description": "Suppress with an inline comment above the duplicated code",
1716            "comment": "// fallow-ignore-next-line code-duplication",
1717        }),
1718    ];
1719
1720    serde_json::Value::Array(actions)
1721}
1722
1723/// Insert a `_meta` key into a JSON object value.
1724fn insert_meta(output: &mut serde_json::Value, meta: serde_json::Value) {
1725    if let serde_json::Value::Object(map) = output {
1726        map.insert("_meta".to_string(), meta);
1727    }
1728}
1729
1730/// Build the JSON envelope + health payload shared by `print_health_json` and
1731/// the CLI integration test suite. Exposed so snapshot tests can lock the
1732/// on-the-wire shape without routing through stdout capture.
1733///
1734/// # Errors
1735///
1736/// Returns an error if the report cannot be serialized to JSON.
1737pub fn build_health_json(
1738    report: &crate::health_types::HealthReport,
1739    root: &Path,
1740    elapsed: Duration,
1741    explain: bool,
1742    action_opts: HealthActionOptions,
1743) -> Result<serde_json::Value, serde_json::Error> {
1744    let report_value = serde_json::to_value(report)?;
1745    let mut output = build_json_envelope(report_value, elapsed);
1746    let root_prefix = format!("{}/", root.display());
1747    strip_root_prefix(&mut output, &root_prefix);
1748    inject_runtime_coverage_schema_version(&mut output);
1749    inject_health_actions(&mut output, action_opts);
1750    if explain {
1751        insert_meta(&mut output, explain::health_meta());
1752    }
1753    Ok(output)
1754}
1755
1756pub(super) fn print_health_json(
1757    report: &crate::health_types::HealthReport,
1758    root: &Path,
1759    elapsed: Duration,
1760    explain: bool,
1761    action_opts: HealthActionOptions,
1762) -> ExitCode {
1763    match build_health_json(report, root, elapsed, explain, action_opts) {
1764        Ok(output) => emit_json(&output, "JSON"),
1765        Err(e) => {
1766            eprintln!("Error: failed to serialize health report: {e}");
1767            ExitCode::from(2)
1768        }
1769    }
1770}
1771
1772/// Build a grouped health JSON envelope when `--group-by` is active.
1773///
1774/// The envelope keeps the active run's `summary`, `vital_signs`, and
1775/// `health_score` at the top level (so consumers that ignore grouping still
1776/// see meaningful aggregates) and adds:
1777///
1778/// - `grouped_by`: the resolver mode (`"package"`, `"owner"`, etc.).
1779/// - `groups`: one entry per resolver bucket. Each entry carries its own
1780///   `vital_signs`, `health_score`, `findings`, `file_scores`, `hotspots`,
1781///   `large_functions`, `targets`, plus `key`, `owners` (section mode), and
1782///   the per-group `files_analyzed` / `functions_above_threshold` counts.
1783///
1784/// Paths inside groups are relativised the same way as the project-level
1785/// payload.
1786///
1787/// # Errors
1788///
1789/// Returns an error if either the project report or any group cannot be
1790/// serialised to JSON.
1791pub fn build_grouped_health_json(
1792    report: &crate::health_types::HealthReport,
1793    grouping: &crate::health_types::HealthGrouping,
1794    root: &Path,
1795    elapsed: Duration,
1796    explain: bool,
1797    action_opts: HealthActionOptions,
1798) -> Result<serde_json::Value, serde_json::Error> {
1799    let root_prefix = format!("{}/", root.display());
1800    let report_value = serde_json::to_value(report)?;
1801    let mut output = build_json_envelope(report_value, elapsed);
1802    strip_root_prefix(&mut output, &root_prefix);
1803    inject_runtime_coverage_schema_version(&mut output);
1804    inject_health_actions(&mut output, action_opts);
1805
1806    if let serde_json::Value::Object(ref mut map) = output {
1807        map.insert("grouped_by".to_string(), serde_json::json!(grouping.mode));
1808    }
1809
1810    // Per-group sub-envelopes share the project-level suppression state:
1811    // baseline-active and config-disabled apply uniformly, so each group's
1812    // `actions` array honors the same opts AND each group emits its own
1813    // `actions_meta` breadcrumb. The redundancy with the top-level breadcrumb
1814    // is intentional: consumers that only walk the `groups` array (e.g.,
1815    // per-team dashboards) still see the omission reason without needing to
1816    // walk back up to the report root.
1817    let group_values: Vec<serde_json::Value> = grouping
1818        .groups
1819        .iter()
1820        .map(|g| {
1821            let mut value = serde_json::to_value(g)?;
1822            strip_root_prefix(&mut value, &root_prefix);
1823            inject_runtime_coverage_schema_version(&mut value);
1824            inject_health_actions(&mut value, action_opts);
1825            Ok(value)
1826        })
1827        .collect::<Result<_, serde_json::Error>>()?;
1828
1829    if let serde_json::Value::Object(ref mut map) = output {
1830        map.insert("groups".to_string(), serde_json::Value::Array(group_values));
1831    }
1832
1833    if explain {
1834        insert_meta(&mut output, explain::health_meta());
1835    }
1836
1837    Ok(output)
1838}
1839
1840pub(super) fn print_grouped_health_json(
1841    report: &crate::health_types::HealthReport,
1842    grouping: &crate::health_types::HealthGrouping,
1843    root: &Path,
1844    elapsed: Duration,
1845    explain: bool,
1846    action_opts: HealthActionOptions,
1847) -> ExitCode {
1848    match build_grouped_health_json(report, grouping, root, elapsed, explain, action_opts) {
1849        Ok(output) => emit_json(&output, "JSON"),
1850        Err(e) => {
1851            eprintln!("Error: failed to serialize grouped health report: {e}");
1852            ExitCode::from(2)
1853        }
1854    }
1855}
1856
1857/// Build the JSON envelope + duplication payload shared by `print_duplication_json`
1858/// and the programmatic API surface.
1859///
1860/// # Errors
1861///
1862/// Returns an error if the report cannot be serialized to JSON.
1863pub fn build_duplication_json(
1864    report: &DuplicationReport,
1865    root: &Path,
1866    elapsed: Duration,
1867    explain: bool,
1868) -> Result<serde_json::Value, serde_json::Error> {
1869    let report_value = serde_json::to_value(report)?;
1870
1871    let mut output = build_json_envelope(report_value, elapsed);
1872    let root_prefix = format!("{}/", root.display());
1873    strip_root_prefix(&mut output, &root_prefix);
1874    inject_dupes_actions(&mut output);
1875
1876    if explain {
1877        insert_meta(&mut output, explain::dupes_meta());
1878    }
1879
1880    Ok(output)
1881}
1882
1883pub(super) fn print_duplication_json(
1884    report: &DuplicationReport,
1885    root: &Path,
1886    elapsed: Duration,
1887    explain: bool,
1888) -> ExitCode {
1889    match build_duplication_json(report, root, elapsed, explain) {
1890        Ok(output) => emit_json(&output, "JSON"),
1891        Err(e) => {
1892            eprintln!("Error: failed to serialize duplication report: {e}");
1893            ExitCode::from(2)
1894        }
1895    }
1896}
1897
1898/// Build a grouped duplication JSON envelope when `--group-by` is active.
1899///
1900/// The envelope keeps the project-level duplication payload (`stats`,
1901/// `clone_groups`, `clone_families`) at the top level so consumers that ignore
1902/// grouping still see project-wide aggregates, and adds:
1903///
1904/// - `grouped_by`: the resolver mode (`"owner"`, `"directory"`, `"package"`,
1905///   `"section"`).
1906/// - `groups`: one entry per resolver bucket. Each entry carries its own
1907///   per-group `stats` (dedup-aware, computed over the FULL group before
1908///   `--top` truncation), `clone_groups` (each tagged with `primary_owner`
1909///   and per-instance `owner`), and `clone_families`.
1910///
1911/// Paths inside groups are relativised the same way as the project-level
1912/// payload via `strip_root_prefix`.
1913///
1914/// # Errors
1915///
1916/// Returns an error if either the project report or any group cannot be
1917/// serialised to JSON.
1918pub fn build_grouped_duplication_json(
1919    report: &DuplicationReport,
1920    grouping: &super::dupes_grouping::DuplicationGrouping,
1921    root: &Path,
1922    elapsed: Duration,
1923    explain: bool,
1924) -> Result<serde_json::Value, serde_json::Error> {
1925    let report_value = serde_json::to_value(report)?;
1926    let mut output = build_json_envelope(report_value, elapsed);
1927    let root_prefix = format!("{}/", root.display());
1928    strip_root_prefix(&mut output, &root_prefix);
1929    inject_dupes_actions(&mut output);
1930
1931    if let serde_json::Value::Object(ref mut map) = output {
1932        map.insert("grouped_by".to_string(), serde_json::json!(grouping.mode));
1933        // Mirror the grouped check / health envelopes which expose
1934        // `total_issues` so MCP and CI consumers can read the same key
1935        // across all three commands. For dupes the count is total clone
1936        // groups (sum is preserved across grouping; each clone group is
1937        // attributed to exactly one bucket).
1938        map.insert(
1939            "total_issues".to_string(),
1940            serde_json::json!(report.clone_groups.len()),
1941        );
1942    }
1943
1944    let group_values: Vec<serde_json::Value> = grouping
1945        .groups
1946        .iter()
1947        .map(|g| {
1948            let mut value = serde_json::to_value(g)?;
1949            strip_root_prefix(&mut value, &root_prefix);
1950            inject_dupes_actions(&mut value);
1951            Ok(value)
1952        })
1953        .collect::<Result<_, serde_json::Error>>()?;
1954
1955    if let serde_json::Value::Object(ref mut map) = output {
1956        map.insert("groups".to_string(), serde_json::Value::Array(group_values));
1957    }
1958
1959    if explain {
1960        insert_meta(&mut output, explain::dupes_meta());
1961    }
1962
1963    Ok(output)
1964}
1965
1966pub(super) fn print_grouped_duplication_json(
1967    report: &DuplicationReport,
1968    grouping: &super::dupes_grouping::DuplicationGrouping,
1969    root: &Path,
1970    elapsed: Duration,
1971    explain: bool,
1972) -> ExitCode {
1973    match build_grouped_duplication_json(report, grouping, root, elapsed, explain) {
1974        Ok(output) => emit_json(&output, "JSON"),
1975        Err(e) => {
1976            eprintln!("Error: failed to serialize grouped duplication report: {e}");
1977            ExitCode::from(2)
1978        }
1979    }
1980}
1981
1982pub(super) fn print_trace_json<T: serde::Serialize>(value: &T) {
1983    match serde_json::to_string_pretty(value) {
1984        Ok(json) => println!("{json}"),
1985        Err(e) => {
1986            eprintln!("Error: failed to serialize trace output: {e}");
1987            #[expect(
1988                clippy::exit,
1989                reason = "fatal serialization error requires immediate exit"
1990            )]
1991            std::process::exit(2);
1992        }
1993    }
1994}
1995
1996#[cfg(test)]
1997mod tests {
1998    use super::*;
1999    use crate::health_types::{
2000        RuntimeCoverageAction, RuntimeCoverageConfidence, RuntimeCoverageDataSource,
2001        RuntimeCoverageEvidence, RuntimeCoverageFinding, RuntimeCoverageHotPath,
2002        RuntimeCoverageMessage, RuntimeCoverageReport, RuntimeCoverageReportVerdict,
2003        RuntimeCoverageSummary, RuntimeCoverageVerdict, RuntimeCoverageWatermark,
2004    };
2005    use crate::report::test_helpers::sample_results;
2006    use fallow_core::extract::MemberKind;
2007    use fallow_core::results::*;
2008    use std::path::PathBuf;
2009    use std::time::Duration;
2010
2011    #[test]
2012    fn json_output_has_metadata_fields() {
2013        let root = PathBuf::from("/project");
2014        let results = AnalysisResults::default();
2015        let elapsed = Duration::from_millis(123);
2016        let output = build_json(&results, &root, elapsed).expect("should serialize");
2017
2018        assert_eq!(output["schema_version"], 6);
2019        assert!(output["version"].is_string());
2020        assert_eq!(output["elapsed_ms"], 123);
2021        assert_eq!(output["total_issues"], 0);
2022    }
2023
2024    #[test]
2025    fn json_output_includes_issue_arrays() {
2026        let root = PathBuf::from("/project");
2027        let results = sample_results(&root);
2028        let elapsed = Duration::from_millis(50);
2029        let output = build_json(&results, &root, elapsed).expect("should serialize");
2030
2031        assert_eq!(output["unused_files"].as_array().unwrap().len(), 1);
2032        assert_eq!(output["unused_exports"].as_array().unwrap().len(), 1);
2033        assert_eq!(output["unused_types"].as_array().unwrap().len(), 1);
2034        assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 1);
2035        assert_eq!(
2036            output["unused_dev_dependencies"].as_array().unwrap().len(),
2037            1
2038        );
2039        assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 1);
2040        assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 1);
2041        assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 1);
2042        assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 1);
2043        assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 1);
2044        assert_eq!(
2045            output["type_only_dependencies"].as_array().unwrap().len(),
2046            1
2047        );
2048        assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 1);
2049    }
2050
2051    #[test]
2052    fn health_json_includes_runtime_coverage_with_relative_paths_and_actions() {
2053        let root = PathBuf::from("/project");
2054        let report = crate::health_types::HealthReport {
2055            runtime_coverage: Some(RuntimeCoverageReport {
2056                verdict: RuntimeCoverageReportVerdict::ColdCodeDetected,
2057                signals: Vec::new(),
2058                summary: RuntimeCoverageSummary {
2059                    data_source: RuntimeCoverageDataSource::Local,
2060                    last_received_at: None,
2061                    functions_tracked: 3,
2062                    functions_hit: 1,
2063                    functions_unhit: 1,
2064                    functions_untracked: 1,
2065                    coverage_percent: 33.3,
2066                    trace_count: 2_847_291,
2067                    period_days: 30,
2068                    deployments_seen: 14,
2069                    capture_quality: Some(crate::health_types::RuntimeCoverageCaptureQuality {
2070                        window_seconds: 720,
2071                        instances_observed: 1,
2072                        lazy_parse_warning: true,
2073                        untracked_ratio_percent: 42.5,
2074                    }),
2075                },
2076                findings: vec![RuntimeCoverageFinding {
2077                    id: "fallow:prod:deadbeef".to_owned(),
2078                    path: root.join("src/cold.ts"),
2079                    function: "coldPath".to_owned(),
2080                    line: 12,
2081                    verdict: RuntimeCoverageVerdict::ReviewRequired,
2082                    invocations: Some(0),
2083                    confidence: RuntimeCoverageConfidence::Medium,
2084                    evidence: RuntimeCoverageEvidence {
2085                        static_status: "used".to_owned(),
2086                        test_coverage: "not_covered".to_owned(),
2087                        v8_tracking: "tracked".to_owned(),
2088                        untracked_reason: None,
2089                        observation_days: 30,
2090                        deployments_observed: 14,
2091                    },
2092                    actions: vec![RuntimeCoverageAction {
2093                        kind: "review-deletion".to_owned(),
2094                        description: "Tracked in runtime coverage with zero invocations."
2095                            .to_owned(),
2096                        auto_fixable: false,
2097                    }],
2098                }],
2099                hot_paths: vec![RuntimeCoverageHotPath {
2100                    id: "fallow:hot:cafebabe".to_owned(),
2101                    path: root.join("src/hot.ts"),
2102                    function: "hotPath".to_owned(),
2103                    line: 3,
2104                    end_line: 9,
2105                    invocations: 250,
2106                    percentile: 99,
2107                    actions: vec![],
2108                }],
2109                blast_radius: vec![],
2110                importance: vec![],
2111                watermark: Some(RuntimeCoverageWatermark::LicenseExpiredGrace),
2112                warnings: vec![RuntimeCoverageMessage {
2113                    code: "partial-merge".to_owned(),
2114                    message: "Merged coverage omitted one chunk.".to_owned(),
2115                }],
2116            }),
2117            ..Default::default()
2118        };
2119
2120        let report_value = serde_json::to_value(&report).expect("should serialize health report");
2121        let mut output = build_json_envelope(report_value, Duration::from_millis(7));
2122        strip_root_prefix(&mut output, "/project/");
2123        inject_runtime_coverage_schema_version(&mut output);
2124        inject_health_actions(&mut output, HealthActionOptions::default());
2125
2126        assert_eq!(
2127            output["runtime_coverage"]["verdict"],
2128            serde_json::Value::String("cold-code-detected".to_owned())
2129        );
2130        assert_eq!(
2131            output["runtime_coverage"]["schema_version"],
2132            serde_json::Value::String("1".to_owned())
2133        );
2134        assert_eq!(
2135            output["runtime_coverage"]["summary"]["functions_tracked"],
2136            serde_json::Value::from(3)
2137        );
2138        assert_eq!(
2139            output["runtime_coverage"]["summary"]["coverage_percent"],
2140            serde_json::Value::from(33.3)
2141        );
2142        let finding = &output["runtime_coverage"]["findings"][0];
2143        assert_eq!(finding["path"], "src/cold.ts");
2144        assert_eq!(finding["verdict"], "review_required");
2145        assert_eq!(finding["id"], "fallow:prod:deadbeef");
2146        assert_eq!(finding["actions"][0]["type"], "review-deletion");
2147        let hot_path = &output["runtime_coverage"]["hot_paths"][0];
2148        assert_eq!(hot_path["path"], "src/hot.ts");
2149        assert_eq!(hot_path["function"], "hotPath");
2150        assert_eq!(hot_path["percentile"], 99);
2151        assert_eq!(
2152            output["runtime_coverage"]["watermark"],
2153            serde_json::Value::String("license-expired-grace".to_owned())
2154        );
2155        assert_eq!(
2156            output["runtime_coverage"]["warnings"][0]["code"],
2157            serde_json::Value::String("partial-merge".to_owned())
2158        );
2159    }
2160
2161    #[test]
2162    fn json_metadata_fields_appear_first() {
2163        let root = PathBuf::from("/project");
2164        let results = AnalysisResults::default();
2165        let elapsed = Duration::from_millis(0);
2166        let output = build_json(&results, &root, elapsed).expect("should serialize");
2167        let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
2168        assert_eq!(keys[0], "schema_version");
2169        assert_eq!(keys[1], "version");
2170        assert_eq!(keys[2], "elapsed_ms");
2171        assert_eq!(keys[3], "total_issues");
2172    }
2173
2174    #[test]
2175    fn json_total_issues_matches_results() {
2176        let root = PathBuf::from("/project");
2177        let results = sample_results(&root);
2178        let total = results.total_issues();
2179        let elapsed = Duration::from_millis(0);
2180        let output = build_json(&results, &root, elapsed).expect("should serialize");
2181
2182        assert_eq!(output["total_issues"], total);
2183    }
2184
2185    #[test]
2186    fn json_unused_export_contains_expected_fields() {
2187        let root = PathBuf::from("/project");
2188        let mut results = AnalysisResults::default();
2189        results.unused_exports.push(UnusedExport {
2190            path: root.join("src/utils.ts"),
2191            export_name: "helperFn".to_string(),
2192            is_type_only: false,
2193            line: 10,
2194            col: 4,
2195            span_start: 120,
2196            is_re_export: false,
2197        });
2198        let elapsed = Duration::from_millis(0);
2199        let output = build_json(&results, &root, elapsed).expect("should serialize");
2200
2201        let export = &output["unused_exports"][0];
2202        assert_eq!(export["export_name"], "helperFn");
2203        assert_eq!(export["line"], 10);
2204        assert_eq!(export["col"], 4);
2205        assert_eq!(export["is_type_only"], false);
2206        assert_eq!(export["span_start"], 120);
2207        assert_eq!(export["is_re_export"], false);
2208    }
2209
2210    #[test]
2211    fn json_serializes_to_valid_json() {
2212        let root = PathBuf::from("/project");
2213        let results = sample_results(&root);
2214        let elapsed = Duration::from_millis(42);
2215        let output = build_json(&results, &root, elapsed).expect("should serialize");
2216
2217        let json_str = serde_json::to_string_pretty(&output).expect("should stringify");
2218        let reparsed: serde_json::Value =
2219            serde_json::from_str(&json_str).expect("JSON output should be valid JSON");
2220        assert_eq!(reparsed, output);
2221    }
2222
2223    // ── Empty results ───────────────────────────────────────────────
2224
2225    #[test]
2226    fn json_empty_results_produce_valid_structure() {
2227        let root = PathBuf::from("/project");
2228        let results = AnalysisResults::default();
2229        let elapsed = Duration::from_millis(0);
2230        let output = build_json(&results, &root, elapsed).expect("should serialize");
2231
2232        assert_eq!(output["total_issues"], 0);
2233        assert_eq!(output["unused_files"].as_array().unwrap().len(), 0);
2234        assert_eq!(output["unused_exports"].as_array().unwrap().len(), 0);
2235        assert_eq!(output["unused_types"].as_array().unwrap().len(), 0);
2236        assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 0);
2237        assert_eq!(
2238            output["unused_dev_dependencies"].as_array().unwrap().len(),
2239            0
2240        );
2241        assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 0);
2242        assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 0);
2243        assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 0);
2244        assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 0);
2245        assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 0);
2246        assert_eq!(
2247            output["type_only_dependencies"].as_array().unwrap().len(),
2248            0
2249        );
2250        assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 0);
2251    }
2252
2253    #[test]
2254    fn json_empty_results_round_trips_through_string() {
2255        let root = PathBuf::from("/project");
2256        let results = AnalysisResults::default();
2257        let elapsed = Duration::from_millis(0);
2258        let output = build_json(&results, &root, elapsed).expect("should serialize");
2259
2260        let json_str = serde_json::to_string(&output).expect("should stringify");
2261        let reparsed: serde_json::Value =
2262            serde_json::from_str(&json_str).expect("should parse back");
2263        assert_eq!(reparsed["total_issues"], 0);
2264    }
2265
2266    // ── Path stripping ──────────────────────────────────────────────
2267
2268    #[test]
2269    fn json_paths_are_relative_to_root() {
2270        let root = PathBuf::from("/project");
2271        let mut results = AnalysisResults::default();
2272        results.unused_files.push(UnusedFile {
2273            path: root.join("src/deep/nested/file.ts"),
2274        });
2275        let elapsed = Duration::from_millis(0);
2276        let output = build_json(&results, &root, elapsed).expect("should serialize");
2277
2278        let path = output["unused_files"][0]["path"].as_str().unwrap();
2279        assert_eq!(path, "src/deep/nested/file.ts");
2280        assert!(!path.starts_with("/project"));
2281    }
2282
2283    #[test]
2284    fn json_strips_root_from_nested_locations() {
2285        let root = PathBuf::from("/project");
2286        let mut results = AnalysisResults::default();
2287        results.unlisted_dependencies.push(UnlistedDependency {
2288            package_name: "chalk".to_string(),
2289            imported_from: vec![ImportSite {
2290                path: root.join("src/cli.ts"),
2291                line: 2,
2292                col: 0,
2293            }],
2294        });
2295        let elapsed = Duration::from_millis(0);
2296        let output = build_json(&results, &root, elapsed).expect("should serialize");
2297
2298        let site_path = output["unlisted_dependencies"][0]["imported_from"][0]["path"]
2299            .as_str()
2300            .unwrap();
2301        assert_eq!(site_path, "src/cli.ts");
2302    }
2303
2304    #[test]
2305    fn json_strips_root_from_duplicate_export_locations() {
2306        let root = PathBuf::from("/project");
2307        let mut results = AnalysisResults::default();
2308        results.duplicate_exports.push(DuplicateExport {
2309            export_name: "Config".to_string(),
2310            locations: vec![
2311                DuplicateLocation {
2312                    path: root.join("src/config.ts"),
2313                    line: 15,
2314                    col: 0,
2315                },
2316                DuplicateLocation {
2317                    path: root.join("src/types.ts"),
2318                    line: 30,
2319                    col: 0,
2320                },
2321            ],
2322        });
2323        let elapsed = Duration::from_millis(0);
2324        let output = build_json(&results, &root, elapsed).expect("should serialize");
2325
2326        let loc0 = output["duplicate_exports"][0]["locations"][0]["path"]
2327            .as_str()
2328            .unwrap();
2329        let loc1 = output["duplicate_exports"][0]["locations"][1]["path"]
2330            .as_str()
2331            .unwrap();
2332        assert_eq!(loc0, "src/config.ts");
2333        assert_eq!(loc1, "src/types.ts");
2334    }
2335
2336    #[test]
2337    fn json_strips_root_from_circular_dependency_files() {
2338        let root = PathBuf::from("/project");
2339        let mut results = AnalysisResults::default();
2340        results.circular_dependencies.push(CircularDependency {
2341            files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
2342            length: 2,
2343            line: 1,
2344            col: 0,
2345            is_cross_package: false,
2346        });
2347        let elapsed = Duration::from_millis(0);
2348        let output = build_json(&results, &root, elapsed).expect("should serialize");
2349
2350        let files = output["circular_dependencies"][0]["files"]
2351            .as_array()
2352            .unwrap();
2353        assert_eq!(files[0].as_str().unwrap(), "src/a.ts");
2354        assert_eq!(files[1].as_str().unwrap(), "src/b.ts");
2355    }
2356
2357    #[test]
2358    fn json_path_outside_root_not_stripped() {
2359        let root = PathBuf::from("/project");
2360        let mut results = AnalysisResults::default();
2361        results.unused_files.push(UnusedFile {
2362            path: PathBuf::from("/other/project/src/file.ts"),
2363        });
2364        let elapsed = Duration::from_millis(0);
2365        let output = build_json(&results, &root, elapsed).expect("should serialize");
2366
2367        let path = output["unused_files"][0]["path"].as_str().unwrap();
2368        assert!(path.contains("/other/project/"));
2369    }
2370
2371    // ── Individual issue type field verification ────────────────────
2372
2373    #[test]
2374    fn json_unused_file_contains_path() {
2375        let root = PathBuf::from("/project");
2376        let mut results = AnalysisResults::default();
2377        results.unused_files.push(UnusedFile {
2378            path: root.join("src/orphan.ts"),
2379        });
2380        let elapsed = Duration::from_millis(0);
2381        let output = build_json(&results, &root, elapsed).expect("should serialize");
2382
2383        let file = &output["unused_files"][0];
2384        assert_eq!(file["path"], "src/orphan.ts");
2385    }
2386
2387    #[test]
2388    fn json_unused_type_contains_expected_fields() {
2389        let root = PathBuf::from("/project");
2390        let mut results = AnalysisResults::default();
2391        results.unused_types.push(UnusedExport {
2392            path: root.join("src/types.ts"),
2393            export_name: "OldInterface".to_string(),
2394            is_type_only: true,
2395            line: 20,
2396            col: 0,
2397            span_start: 300,
2398            is_re_export: false,
2399        });
2400        let elapsed = Duration::from_millis(0);
2401        let output = build_json(&results, &root, elapsed).expect("should serialize");
2402
2403        let typ = &output["unused_types"][0];
2404        assert_eq!(typ["export_name"], "OldInterface");
2405        assert_eq!(typ["is_type_only"], true);
2406        assert_eq!(typ["line"], 20);
2407        assert_eq!(typ["path"], "src/types.ts");
2408    }
2409
2410    #[test]
2411    fn json_unused_dependency_contains_expected_fields() {
2412        let root = PathBuf::from("/project");
2413        let mut results = AnalysisResults::default();
2414        results.unused_dependencies.push(UnusedDependency {
2415            package_name: "axios".to_string(),
2416            location: DependencyLocation::Dependencies,
2417            path: root.join("package.json"),
2418            line: 10,
2419            used_in_workspaces: Vec::new(),
2420        });
2421        let elapsed = Duration::from_millis(0);
2422        let output = build_json(&results, &root, elapsed).expect("should serialize");
2423
2424        let dep = &output["unused_dependencies"][0];
2425        assert_eq!(dep["package_name"], "axios");
2426        assert_eq!(dep["line"], 10);
2427        assert!(dep.get("used_in_workspaces").is_none());
2428    }
2429
2430    #[test]
2431    fn json_unused_dependency_includes_cross_workspace_context() {
2432        let root = PathBuf::from("/project");
2433        let mut results = AnalysisResults::default();
2434        results.unused_dependencies.push(UnusedDependency {
2435            package_name: "lodash-es".to_string(),
2436            location: DependencyLocation::Dependencies,
2437            path: root.join("packages/shared/package.json"),
2438            line: 6,
2439            used_in_workspaces: vec![root.join("packages/consumer")],
2440        });
2441        let elapsed = Duration::from_millis(0);
2442        let output = build_json(&results, &root, elapsed).expect("should serialize");
2443
2444        let dep = &output["unused_dependencies"][0];
2445        assert_eq!(
2446            dep["used_in_workspaces"],
2447            serde_json::json!(["packages/consumer"])
2448        );
2449    }
2450
2451    #[test]
2452    fn json_unused_dev_dependency_contains_expected_fields() {
2453        let root = PathBuf::from("/project");
2454        let mut results = AnalysisResults::default();
2455        results.unused_dev_dependencies.push(UnusedDependency {
2456            package_name: "vitest".to_string(),
2457            location: DependencyLocation::DevDependencies,
2458            path: root.join("package.json"),
2459            line: 15,
2460            used_in_workspaces: Vec::new(),
2461        });
2462        let elapsed = Duration::from_millis(0);
2463        let output = build_json(&results, &root, elapsed).expect("should serialize");
2464
2465        let dep = &output["unused_dev_dependencies"][0];
2466        assert_eq!(dep["package_name"], "vitest");
2467    }
2468
2469    #[test]
2470    fn json_unused_optional_dependency_contains_expected_fields() {
2471        let root = PathBuf::from("/project");
2472        let mut results = AnalysisResults::default();
2473        results.unused_optional_dependencies.push(UnusedDependency {
2474            package_name: "fsevents".to_string(),
2475            location: DependencyLocation::OptionalDependencies,
2476            path: root.join("package.json"),
2477            line: 12,
2478            used_in_workspaces: Vec::new(),
2479        });
2480        let elapsed = Duration::from_millis(0);
2481        let output = build_json(&results, &root, elapsed).expect("should serialize");
2482
2483        let dep = &output["unused_optional_dependencies"][0];
2484        assert_eq!(dep["package_name"], "fsevents");
2485        assert_eq!(output["total_issues"], 1);
2486    }
2487
2488    #[test]
2489    fn json_unused_enum_member_contains_expected_fields() {
2490        let root = PathBuf::from("/project");
2491        let mut results = AnalysisResults::default();
2492        results.unused_enum_members.push(UnusedMember {
2493            path: root.join("src/enums.ts"),
2494            parent_name: "Color".to_string(),
2495            member_name: "Purple".to_string(),
2496            kind: MemberKind::EnumMember,
2497            line: 5,
2498            col: 2,
2499        });
2500        let elapsed = Duration::from_millis(0);
2501        let output = build_json(&results, &root, elapsed).expect("should serialize");
2502
2503        let member = &output["unused_enum_members"][0];
2504        assert_eq!(member["parent_name"], "Color");
2505        assert_eq!(member["member_name"], "Purple");
2506        assert_eq!(member["line"], 5);
2507        assert_eq!(member["path"], "src/enums.ts");
2508    }
2509
2510    #[test]
2511    fn json_unused_class_member_contains_expected_fields() {
2512        let root = PathBuf::from("/project");
2513        let mut results = AnalysisResults::default();
2514        results.unused_class_members.push(UnusedMember {
2515            path: root.join("src/api.ts"),
2516            parent_name: "ApiClient".to_string(),
2517            member_name: "deprecatedFetch".to_string(),
2518            kind: MemberKind::ClassMethod,
2519            line: 100,
2520            col: 4,
2521        });
2522        let elapsed = Duration::from_millis(0);
2523        let output = build_json(&results, &root, elapsed).expect("should serialize");
2524
2525        let member = &output["unused_class_members"][0];
2526        assert_eq!(member["parent_name"], "ApiClient");
2527        assert_eq!(member["member_name"], "deprecatedFetch");
2528        assert_eq!(member["line"], 100);
2529    }
2530
2531    #[test]
2532    fn json_unresolved_import_contains_expected_fields() {
2533        let root = PathBuf::from("/project");
2534        let mut results = AnalysisResults::default();
2535        results.unresolved_imports.push(UnresolvedImport {
2536            path: root.join("src/app.ts"),
2537            specifier: "@acme/missing-pkg".to_string(),
2538            line: 7,
2539            col: 0,
2540            specifier_col: 0,
2541        });
2542        let elapsed = Duration::from_millis(0);
2543        let output = build_json(&results, &root, elapsed).expect("should serialize");
2544
2545        let import = &output["unresolved_imports"][0];
2546        assert_eq!(import["specifier"], "@acme/missing-pkg");
2547        assert_eq!(import["line"], 7);
2548        assert_eq!(import["path"], "src/app.ts");
2549    }
2550
2551    #[test]
2552    fn json_unlisted_dependency_contains_import_sites() {
2553        let root = PathBuf::from("/project");
2554        let mut results = AnalysisResults::default();
2555        results.unlisted_dependencies.push(UnlistedDependency {
2556            package_name: "dotenv".to_string(),
2557            imported_from: vec![
2558                ImportSite {
2559                    path: root.join("src/config.ts"),
2560                    line: 1,
2561                    col: 0,
2562                },
2563                ImportSite {
2564                    path: root.join("src/server.ts"),
2565                    line: 3,
2566                    col: 0,
2567                },
2568            ],
2569        });
2570        let elapsed = Duration::from_millis(0);
2571        let output = build_json(&results, &root, elapsed).expect("should serialize");
2572
2573        let dep = &output["unlisted_dependencies"][0];
2574        assert_eq!(dep["package_name"], "dotenv");
2575        let sites = dep["imported_from"].as_array().unwrap();
2576        assert_eq!(sites.len(), 2);
2577        assert_eq!(sites[0]["path"], "src/config.ts");
2578        assert_eq!(sites[1]["path"], "src/server.ts");
2579    }
2580
2581    #[test]
2582    fn json_duplicate_export_contains_locations() {
2583        let root = PathBuf::from("/project");
2584        let mut results = AnalysisResults::default();
2585        results.duplicate_exports.push(DuplicateExport {
2586            export_name: "Button".to_string(),
2587            locations: vec![
2588                DuplicateLocation {
2589                    path: root.join("src/ui.ts"),
2590                    line: 10,
2591                    col: 0,
2592                },
2593                DuplicateLocation {
2594                    path: root.join("src/components.ts"),
2595                    line: 25,
2596                    col: 0,
2597                },
2598            ],
2599        });
2600        let elapsed = Duration::from_millis(0);
2601        let output = build_json(&results, &root, elapsed).expect("should serialize");
2602
2603        let dup = &output["duplicate_exports"][0];
2604        assert_eq!(dup["export_name"], "Button");
2605        let locs = dup["locations"].as_array().unwrap();
2606        assert_eq!(locs.len(), 2);
2607        assert_eq!(locs[0]["line"], 10);
2608        assert_eq!(locs[1]["line"], 25);
2609    }
2610
2611    #[test]
2612    fn json_type_only_dependency_contains_expected_fields() {
2613        let root = PathBuf::from("/project");
2614        let mut results = AnalysisResults::default();
2615        results.type_only_dependencies.push(TypeOnlyDependency {
2616            package_name: "zod".to_string(),
2617            path: root.join("package.json"),
2618            line: 8,
2619        });
2620        let elapsed = Duration::from_millis(0);
2621        let output = build_json(&results, &root, elapsed).expect("should serialize");
2622
2623        let dep = &output["type_only_dependencies"][0];
2624        assert_eq!(dep["package_name"], "zod");
2625        assert_eq!(dep["line"], 8);
2626    }
2627
2628    #[test]
2629    fn json_circular_dependency_contains_expected_fields() {
2630        let root = PathBuf::from("/project");
2631        let mut results = AnalysisResults::default();
2632        results.circular_dependencies.push(CircularDependency {
2633            files: vec![
2634                root.join("src/a.ts"),
2635                root.join("src/b.ts"),
2636                root.join("src/c.ts"),
2637            ],
2638            length: 3,
2639            line: 5,
2640            col: 0,
2641            is_cross_package: false,
2642        });
2643        let elapsed = Duration::from_millis(0);
2644        let output = build_json(&results, &root, elapsed).expect("should serialize");
2645
2646        let cycle = &output["circular_dependencies"][0];
2647        assert_eq!(cycle["length"], 3);
2648        assert_eq!(cycle["line"], 5);
2649        let files = cycle["files"].as_array().unwrap();
2650        assert_eq!(files.len(), 3);
2651    }
2652
2653    // ── Re-export tagging ───────────────────────────────────────────
2654
2655    #[test]
2656    fn json_re_export_flagged_correctly() {
2657        let root = PathBuf::from("/project");
2658        let mut results = AnalysisResults::default();
2659        results.unused_exports.push(UnusedExport {
2660            path: root.join("src/index.ts"),
2661            export_name: "reExported".to_string(),
2662            is_type_only: false,
2663            line: 1,
2664            col: 0,
2665            span_start: 0,
2666            is_re_export: true,
2667        });
2668        let elapsed = Duration::from_millis(0);
2669        let output = build_json(&results, &root, elapsed).expect("should serialize");
2670
2671        assert_eq!(output["unused_exports"][0]["is_re_export"], true);
2672    }
2673
2674    // ── Schema version stability ────────────────────────────────────
2675
2676    #[test]
2677    fn json_schema_version_is_pinned() {
2678        let root = PathBuf::from("/project");
2679        let results = AnalysisResults::default();
2680        let elapsed = Duration::from_millis(0);
2681        let output = build_json(&results, &root, elapsed).expect("should serialize");
2682
2683        assert_eq!(output["schema_version"], SCHEMA_VERSION);
2684        assert_eq!(output["schema_version"], 6);
2685    }
2686
2687    // ── Version string ──────────────────────────────────────────────
2688
2689    #[test]
2690    fn json_version_matches_cargo_pkg_version() {
2691        let root = PathBuf::from("/project");
2692        let results = AnalysisResults::default();
2693        let elapsed = Duration::from_millis(0);
2694        let output = build_json(&results, &root, elapsed).expect("should serialize");
2695
2696        assert_eq!(output["version"], env!("CARGO_PKG_VERSION"));
2697    }
2698
2699    // ── Elapsed time encoding ───────────────────────────────────────
2700
2701    #[test]
2702    fn json_elapsed_ms_zero_duration() {
2703        let root = PathBuf::from("/project");
2704        let results = AnalysisResults::default();
2705        let output = build_json(&results, &root, Duration::ZERO).expect("should serialize");
2706
2707        assert_eq!(output["elapsed_ms"], 0);
2708    }
2709
2710    #[test]
2711    fn json_elapsed_ms_large_duration() {
2712        let root = PathBuf::from("/project");
2713        let results = AnalysisResults::default();
2714        let elapsed = Duration::from_mins(2);
2715        let output = build_json(&results, &root, elapsed).expect("should serialize");
2716
2717        assert_eq!(output["elapsed_ms"], 120_000);
2718    }
2719
2720    #[test]
2721    fn json_elapsed_ms_sub_millisecond_truncated() {
2722        let root = PathBuf::from("/project");
2723        let results = AnalysisResults::default();
2724        // 500 microseconds = 0 milliseconds (truncated)
2725        let elapsed = Duration::from_micros(500);
2726        let output = build_json(&results, &root, elapsed).expect("should serialize");
2727
2728        assert_eq!(output["elapsed_ms"], 0);
2729    }
2730
2731    // ── Multiple issues of same type ────────────────────────────────
2732
2733    #[test]
2734    fn json_multiple_unused_files() {
2735        let root = PathBuf::from("/project");
2736        let mut results = AnalysisResults::default();
2737        results.unused_files.push(UnusedFile {
2738            path: root.join("src/a.ts"),
2739        });
2740        results.unused_files.push(UnusedFile {
2741            path: root.join("src/b.ts"),
2742        });
2743        results.unused_files.push(UnusedFile {
2744            path: root.join("src/c.ts"),
2745        });
2746        let elapsed = Duration::from_millis(0);
2747        let output = build_json(&results, &root, elapsed).expect("should serialize");
2748
2749        assert_eq!(output["unused_files"].as_array().unwrap().len(), 3);
2750        assert_eq!(output["total_issues"], 3);
2751    }
2752
2753    // ── strip_root_prefix unit tests ────────────────────────────────
2754
2755    #[test]
2756    fn strip_root_prefix_on_string_value() {
2757        let mut value = serde_json::json!("/project/src/file.ts");
2758        strip_root_prefix(&mut value, "/project/");
2759        assert_eq!(value, "src/file.ts");
2760    }
2761
2762    #[test]
2763    fn strip_root_prefix_leaves_non_matching_string() {
2764        let mut value = serde_json::json!("/other/src/file.ts");
2765        strip_root_prefix(&mut value, "/project/");
2766        assert_eq!(value, "/other/src/file.ts");
2767    }
2768
2769    #[test]
2770    fn strip_root_prefix_recurses_into_arrays() {
2771        let mut value = serde_json::json!(["/project/a.ts", "/project/b.ts", "/other/c.ts"]);
2772        strip_root_prefix(&mut value, "/project/");
2773        assert_eq!(value[0], "a.ts");
2774        assert_eq!(value[1], "b.ts");
2775        assert_eq!(value[2], "/other/c.ts");
2776    }
2777
2778    #[test]
2779    fn strip_root_prefix_recurses_into_nested_objects() {
2780        let mut value = serde_json::json!({
2781            "outer": {
2782                "path": "/project/src/nested.ts"
2783            }
2784        });
2785        strip_root_prefix(&mut value, "/project/");
2786        assert_eq!(value["outer"]["path"], "src/nested.ts");
2787    }
2788
2789    #[test]
2790    fn strip_root_prefix_leaves_numbers_and_booleans() {
2791        let mut value = serde_json::json!({
2792            "line": 42,
2793            "is_type_only": false,
2794            "path": "/project/src/file.ts"
2795        });
2796        strip_root_prefix(&mut value, "/project/");
2797        assert_eq!(value["line"], 42);
2798        assert_eq!(value["is_type_only"], false);
2799        assert_eq!(value["path"], "src/file.ts");
2800    }
2801
2802    #[test]
2803    fn strip_root_prefix_normalizes_windows_separators() {
2804        let mut value = serde_json::json!(r"/project\src\file.ts");
2805        strip_root_prefix(&mut value, "/project/");
2806        assert_eq!(value, "src/file.ts");
2807    }
2808
2809    #[test]
2810    fn strip_root_prefix_handles_empty_string_after_strip() {
2811        // Edge case: the string IS the prefix (without trailing content).
2812        // This shouldn't happen in practice but should not panic.
2813        let mut value = serde_json::json!("/project/");
2814        strip_root_prefix(&mut value, "/project/");
2815        assert_eq!(value, "");
2816    }
2817
2818    #[test]
2819    fn strip_root_prefix_deeply_nested_array_of_objects() {
2820        let mut value = serde_json::json!({
2821            "groups": [{
2822                "instances": [{
2823                    "file": "/project/src/a.ts"
2824                }, {
2825                    "file": "/project/src/b.ts"
2826                }]
2827            }]
2828        });
2829        strip_root_prefix(&mut value, "/project/");
2830        assert_eq!(value["groups"][0]["instances"][0]["file"], "src/a.ts");
2831        assert_eq!(value["groups"][0]["instances"][1]["file"], "src/b.ts");
2832    }
2833
2834    // ── Full sample results round-trip ──────────────────────────────
2835
2836    #[test]
2837    fn json_full_sample_results_total_issues_correct() {
2838        let root = PathBuf::from("/project");
2839        let results = sample_results(&root);
2840        let elapsed = Duration::from_millis(100);
2841        let output = build_json(&results, &root, elapsed).expect("should serialize");
2842
2843        // sample_results adds one of each issue type (12 total).
2844        // unused_files + unused_exports + unused_types + unused_dependencies
2845        // + unused_dev_dependencies + unused_enum_members + unused_class_members
2846        // + unresolved_imports + unlisted_dependencies + duplicate_exports
2847        // + type_only_dependencies + circular_dependencies
2848        assert_eq!(output["total_issues"], results.total_issues());
2849    }
2850
2851    #[test]
2852    fn json_full_sample_no_absolute_paths_in_output() {
2853        let root = PathBuf::from("/project");
2854        let results = sample_results(&root);
2855        let elapsed = Duration::from_millis(0);
2856        let output = build_json(&results, &root, elapsed).expect("should serialize");
2857
2858        let json_str = serde_json::to_string(&output).expect("should stringify");
2859        // The root prefix should be stripped from all paths.
2860        assert!(!json_str.contains("/project/src/"));
2861        assert!(!json_str.contains("/project/package.json"));
2862    }
2863
2864    // ── JSON output is deterministic ────────────────────────────────
2865
2866    #[test]
2867    fn json_output_is_deterministic() {
2868        let root = PathBuf::from("/project");
2869        let results = sample_results(&root);
2870        let elapsed = Duration::from_millis(50);
2871
2872        let output1 = build_json(&results, &root, elapsed).expect("first build");
2873        let output2 = build_json(&results, &root, elapsed).expect("second build");
2874
2875        assert_eq!(output1, output2);
2876    }
2877
2878    // ── Metadata not overwritten by results fields ──────────────────
2879
2880    #[test]
2881    fn json_results_fields_do_not_shadow_metadata() {
2882        // Ensure that serialized results don't contain keys like "schema_version"
2883        // that could overwrite the metadata fields we insert first.
2884        let root = PathBuf::from("/project");
2885        let results = AnalysisResults::default();
2886        let elapsed = Duration::from_millis(99);
2887        let output = build_json(&results, &root, elapsed).expect("should serialize");
2888
2889        // Metadata should reflect our explicit values, not anything from AnalysisResults.
2890        assert_eq!(output["schema_version"], 6);
2891        assert_eq!(output["elapsed_ms"], 99);
2892    }
2893
2894    // ── All 14 issue type arrays present ────────────────────────────
2895
2896    #[test]
2897    fn json_all_issue_type_arrays_present_in_empty_results() {
2898        let root = PathBuf::from("/project");
2899        let results = AnalysisResults::default();
2900        let elapsed = Duration::from_millis(0);
2901        let output = build_json(&results, &root, elapsed).expect("should serialize");
2902
2903        let expected_arrays = [
2904            "unused_files",
2905            "unused_exports",
2906            "unused_types",
2907            "unused_dependencies",
2908            "unused_dev_dependencies",
2909            "unused_optional_dependencies",
2910            "unused_enum_members",
2911            "unused_class_members",
2912            "unresolved_imports",
2913            "unlisted_dependencies",
2914            "duplicate_exports",
2915            "type_only_dependencies",
2916            "test_only_dependencies",
2917            "circular_dependencies",
2918        ];
2919        for key in &expected_arrays {
2920            assert!(
2921                output[key].is_array(),
2922                "expected '{key}' to be an array in JSON output"
2923            );
2924        }
2925    }
2926
2927    // ── insert_meta ─────────────────────────────────────────────────
2928
2929    #[test]
2930    fn insert_meta_adds_key_to_object() {
2931        let mut output = serde_json::json!({ "foo": 1 });
2932        let meta = serde_json::json!({ "docs": "https://example.com" });
2933        insert_meta(&mut output, meta.clone());
2934        assert_eq!(output["_meta"], meta);
2935    }
2936
2937    #[test]
2938    fn insert_meta_noop_on_non_object() {
2939        let mut output = serde_json::json!([1, 2, 3]);
2940        let meta = serde_json::json!({ "docs": "https://example.com" });
2941        insert_meta(&mut output, meta);
2942        // Should not panic or add anything
2943        assert!(output.is_array());
2944    }
2945
2946    #[test]
2947    fn insert_meta_overwrites_existing_meta() {
2948        let mut output = serde_json::json!({ "_meta": "old" });
2949        let meta = serde_json::json!({ "new": true });
2950        insert_meta(&mut output, meta.clone());
2951        assert_eq!(output["_meta"], meta);
2952    }
2953
2954    // ── build_json_envelope ─────────────────────────────────────────
2955
2956    #[test]
2957    fn build_json_envelope_has_metadata_fields() {
2958        let report = serde_json::json!({ "findings": [] });
2959        let elapsed = Duration::from_millis(42);
2960        let output = build_json_envelope(report, elapsed);
2961
2962        assert_eq!(output["schema_version"], 6);
2963        assert!(output["version"].is_string());
2964        assert_eq!(output["elapsed_ms"], 42);
2965        assert!(output["findings"].is_array());
2966    }
2967
2968    #[test]
2969    fn build_json_envelope_metadata_appears_first() {
2970        let report = serde_json::json!({ "data": "value" });
2971        let output = build_json_envelope(report, Duration::from_millis(10));
2972
2973        let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
2974        assert_eq!(keys[0], "schema_version");
2975        assert_eq!(keys[1], "version");
2976        assert_eq!(keys[2], "elapsed_ms");
2977    }
2978
2979    #[test]
2980    fn build_json_envelope_non_object_report() {
2981        // If report_value is not an Object, only metadata fields appear
2982        let report = serde_json::json!("not an object");
2983        let output = build_json_envelope(report, Duration::from_millis(0));
2984
2985        let obj = output.as_object().unwrap();
2986        assert_eq!(obj.len(), 3);
2987        assert!(obj.contains_key("schema_version"));
2988        assert!(obj.contains_key("version"));
2989        assert!(obj.contains_key("elapsed_ms"));
2990    }
2991
2992    // ── strip_root_prefix with null value ──
2993
2994    #[test]
2995    fn strip_root_prefix_null_unchanged() {
2996        let mut value = serde_json::Value::Null;
2997        strip_root_prefix(&mut value, "/project/");
2998        assert!(value.is_null());
2999    }
3000
3001    // ── strip_root_prefix with empty string ──
3002
3003    #[test]
3004    fn strip_root_prefix_empty_string() {
3005        let mut value = serde_json::json!("");
3006        strip_root_prefix(&mut value, "/project/");
3007        assert_eq!(value, "");
3008    }
3009
3010    // ── strip_root_prefix on mixed nested structure ──
3011
3012    #[test]
3013    fn strip_root_prefix_mixed_types() {
3014        let mut value = serde_json::json!({
3015            "path": "/project/src/file.ts",
3016            "line": 42,
3017            "flag": true,
3018            "nested": {
3019                "items": ["/project/a.ts", 99, null, "/project/b.ts"],
3020                "deep": { "path": "/project/c.ts" }
3021            }
3022        });
3023        strip_root_prefix(&mut value, "/project/");
3024        assert_eq!(value["path"], "src/file.ts");
3025        assert_eq!(value["line"], 42);
3026        assert_eq!(value["flag"], true);
3027        assert_eq!(value["nested"]["items"][0], "a.ts");
3028        assert_eq!(value["nested"]["items"][1], 99);
3029        assert!(value["nested"]["items"][2].is_null());
3030        assert_eq!(value["nested"]["items"][3], "b.ts");
3031        assert_eq!(value["nested"]["deep"]["path"], "c.ts");
3032    }
3033
3034    // ── JSON with explain meta for check ──
3035
3036    #[test]
3037    fn json_check_meta_integrates_correctly() {
3038        let root = PathBuf::from("/project");
3039        let results = AnalysisResults::default();
3040        let elapsed = Duration::from_millis(0);
3041        let mut output = build_json(&results, &root, elapsed).expect("should serialize");
3042        insert_meta(&mut output, crate::explain::check_meta());
3043
3044        assert!(output["_meta"]["docs"].is_string());
3045        assert!(output["_meta"]["rules"].is_object());
3046    }
3047
3048    // ── JSON unused member kind serialization ──
3049
3050    #[test]
3051    fn json_unused_member_kind_serialized() {
3052        let root = PathBuf::from("/project");
3053        let mut results = AnalysisResults::default();
3054        results.unused_enum_members.push(UnusedMember {
3055            path: root.join("src/enums.ts"),
3056            parent_name: "Color".to_string(),
3057            member_name: "Red".to_string(),
3058            kind: MemberKind::EnumMember,
3059            line: 3,
3060            col: 2,
3061        });
3062        results.unused_class_members.push(UnusedMember {
3063            path: root.join("src/class.ts"),
3064            parent_name: "Foo".to_string(),
3065            member_name: "bar".to_string(),
3066            kind: MemberKind::ClassMethod,
3067            line: 10,
3068            col: 4,
3069        });
3070
3071        let elapsed = Duration::from_millis(0);
3072        let output = build_json(&results, &root, elapsed).expect("should serialize");
3073
3074        let enum_member = &output["unused_enum_members"][0];
3075        assert!(enum_member["kind"].is_string());
3076        let class_member = &output["unused_class_members"][0];
3077        assert!(class_member["kind"].is_string());
3078    }
3079
3080    // ── Actions injection ──────────────────────────────────────────
3081
3082    #[test]
3083    fn json_unused_export_has_actions() {
3084        let root = PathBuf::from("/project");
3085        let mut results = AnalysisResults::default();
3086        results.unused_exports.push(UnusedExport {
3087            path: root.join("src/utils.ts"),
3088            export_name: "helperFn".to_string(),
3089            is_type_only: false,
3090            line: 10,
3091            col: 4,
3092            span_start: 120,
3093            is_re_export: false,
3094        });
3095        let output = build_json(&results, &root, Duration::ZERO).unwrap();
3096
3097        let actions = output["unused_exports"][0]["actions"].as_array().unwrap();
3098        assert_eq!(actions.len(), 2);
3099
3100        // Fix action
3101        assert_eq!(actions[0]["type"], "remove-export");
3102        assert_eq!(actions[0]["auto_fixable"], true);
3103        assert!(actions[0].get("note").is_none());
3104
3105        // Suppress action
3106        assert_eq!(actions[1]["type"], "suppress-line");
3107        assert_eq!(
3108            actions[1]["comment"],
3109            "// fallow-ignore-next-line unused-export"
3110        );
3111    }
3112
3113    #[test]
3114    fn json_same_line_findings_share_multi_kind_suppression_comment() {
3115        let root = PathBuf::from("/project");
3116        let mut results = AnalysisResults::default();
3117        results.unused_exports.push(UnusedExport {
3118            path: root.join("src/api.ts"),
3119            export_name: "helperFn".to_string(),
3120            is_type_only: false,
3121            line: 10,
3122            col: 4,
3123            span_start: 120,
3124            is_re_export: false,
3125        });
3126        results.unused_types.push(UnusedExport {
3127            path: root.join("src/api.ts"),
3128            export_name: "OldType".to_string(),
3129            is_type_only: true,
3130            line: 10,
3131            col: 0,
3132            span_start: 60,
3133            is_re_export: false,
3134        });
3135        let output = build_json(&results, &root, Duration::ZERO).unwrap();
3136
3137        let export_actions = output["unused_exports"][0]["actions"].as_array().unwrap();
3138        let type_actions = output["unused_types"][0]["actions"].as_array().unwrap();
3139        assert_eq!(
3140            export_actions[1]["comment"],
3141            "// fallow-ignore-next-line unused-export, unused-type"
3142        );
3143        assert_eq!(
3144            type_actions[1]["comment"],
3145            "// fallow-ignore-next-line unused-export, unused-type"
3146        );
3147    }
3148
3149    #[test]
3150    fn audit_like_json_shares_suppression_comment_across_dead_code_and_complexity() {
3151        let mut output = serde_json::json!({
3152            "dead_code": {
3153                "unused_exports": [{
3154                    "path": "src/main.ts",
3155                    "line": 1,
3156                    "actions": [
3157                        { "type": "remove-export", "auto_fixable": true },
3158                        {
3159                            "type": "suppress-line",
3160                            "auto_fixable": false,
3161                            "comment": "// fallow-ignore-next-line unused-export"
3162                        }
3163                    ]
3164                }]
3165            },
3166            "complexity": {
3167                "findings": [{
3168                    "path": "src/main.ts",
3169                    "line": 1,
3170                    "actions": [
3171                        { "type": "refactor-function", "auto_fixable": false },
3172                        {
3173                            "type": "suppress-line",
3174                            "auto_fixable": false,
3175                            "comment": "// fallow-ignore-next-line complexity"
3176                        }
3177                    ]
3178                }]
3179            }
3180        });
3181
3182        harmonize_multi_kind_suppress_line_actions(&mut output);
3183
3184        assert_eq!(
3185            output["dead_code"]["unused_exports"][0]["actions"][1]["comment"],
3186            "// fallow-ignore-next-line unused-export, complexity"
3187        );
3188        assert_eq!(
3189            output["complexity"]["findings"][0]["actions"][1]["comment"],
3190            "// fallow-ignore-next-line unused-export, complexity"
3191        );
3192    }
3193
3194    #[test]
3195    fn json_unused_file_has_file_suppress_and_note() {
3196        let root = PathBuf::from("/project");
3197        let mut results = AnalysisResults::default();
3198        results.unused_files.push(UnusedFile {
3199            path: root.join("src/dead.ts"),
3200        });
3201        let output = build_json(&results, &root, Duration::ZERO).unwrap();
3202
3203        let actions = output["unused_files"][0]["actions"].as_array().unwrap();
3204        assert_eq!(actions[0]["type"], "delete-file");
3205        assert_eq!(actions[0]["auto_fixable"], false);
3206        assert!(actions[0]["note"].is_string());
3207        assert_eq!(actions[1]["type"], "suppress-file");
3208        assert_eq!(actions[1]["comment"], "// fallow-ignore-file unused-file");
3209    }
3210
3211    #[test]
3212    fn json_unused_dependency_has_config_suppress_with_package_name() {
3213        let root = PathBuf::from("/project");
3214        let mut results = AnalysisResults::default();
3215        results.unused_dependencies.push(UnusedDependency {
3216            package_name: "lodash".to_string(),
3217            location: DependencyLocation::Dependencies,
3218            path: root.join("package.json"),
3219            line: 5,
3220            used_in_workspaces: Vec::new(),
3221        });
3222        let output = build_json(&results, &root, Duration::ZERO).unwrap();
3223
3224        let actions = output["unused_dependencies"][0]["actions"]
3225            .as_array()
3226            .unwrap();
3227        assert_eq!(actions[0]["type"], "remove-dependency");
3228        assert_eq!(actions[0]["auto_fixable"], true);
3229
3230        // Config suppress includes actual package name
3231        assert_eq!(actions[1]["type"], "add-to-config");
3232        assert_eq!(actions[1]["config_key"], "ignoreDependencies");
3233        assert_eq!(actions[1]["value"], "lodash");
3234    }
3235
3236    #[test]
3237    fn json_cross_workspace_dependency_is_not_auto_fixable() {
3238        let root = PathBuf::from("/project");
3239        let mut results = AnalysisResults::default();
3240        results.unused_dependencies.push(UnusedDependency {
3241            package_name: "lodash-es".to_string(),
3242            location: DependencyLocation::Dependencies,
3243            path: root.join("packages/shared/package.json"),
3244            line: 5,
3245            used_in_workspaces: vec![root.join("packages/consumer")],
3246        });
3247        let output = build_json(&results, &root, Duration::ZERO).unwrap();
3248
3249        let actions = output["unused_dependencies"][0]["actions"]
3250            .as_array()
3251            .unwrap();
3252        assert_eq!(actions[0]["type"], "move-dependency");
3253        assert_eq!(actions[0]["auto_fixable"], false);
3254        assert!(
3255            actions[0]["note"]
3256                .as_str()
3257                .unwrap()
3258                .contains("will not remove")
3259        );
3260        assert_eq!(actions[1]["type"], "add-to-config");
3261    }
3262
3263    #[test]
3264    fn json_empty_results_have_no_actions_in_empty_arrays() {
3265        let root = PathBuf::from("/project");
3266        let results = AnalysisResults::default();
3267        let output = build_json(&results, &root, Duration::ZERO).unwrap();
3268
3269        // Empty arrays should remain empty
3270        assert!(output["unused_exports"].as_array().unwrap().is_empty());
3271        assert!(output["unused_files"].as_array().unwrap().is_empty());
3272    }
3273
3274    #[test]
3275    fn json_all_issue_types_have_actions() {
3276        let root = PathBuf::from("/project");
3277        let results = sample_results(&root);
3278        let output = build_json(&results, &root, Duration::ZERO).unwrap();
3279
3280        let issue_keys = [
3281            "unused_files",
3282            "unused_exports",
3283            "unused_types",
3284            "unused_dependencies",
3285            "unused_dev_dependencies",
3286            "unused_optional_dependencies",
3287            "unused_enum_members",
3288            "unused_class_members",
3289            "unresolved_imports",
3290            "unlisted_dependencies",
3291            "duplicate_exports",
3292            "type_only_dependencies",
3293            "test_only_dependencies",
3294            "circular_dependencies",
3295        ];
3296
3297        for key in &issue_keys {
3298            let arr = output[key].as_array().unwrap();
3299            if !arr.is_empty() {
3300                let actions = arr[0]["actions"].as_array();
3301                assert!(
3302                    actions.is_some() && !actions.unwrap().is_empty(),
3303                    "missing actions for {key}"
3304                );
3305            }
3306        }
3307    }
3308
3309    // ── Health actions injection ───────────────────────────────────
3310
3311    #[test]
3312    fn health_finding_has_actions() {
3313        let mut output = serde_json::json!({
3314            "findings": [{
3315                "path": "src/utils.ts",
3316                "name": "processData",
3317                "line": 10,
3318                "col": 0,
3319                "cyclomatic": 25,
3320                "cognitive": 30,
3321                "line_count": 150,
3322                "exceeded": "both"
3323            }]
3324        });
3325
3326        inject_health_actions(&mut output, HealthActionOptions::default());
3327
3328        let actions = output["findings"][0]["actions"].as_array().unwrap();
3329        assert_eq!(actions.len(), 2);
3330        assert_eq!(actions[0]["type"], "refactor-function");
3331        assert_eq!(actions[0]["auto_fixable"], false);
3332        assert!(
3333            actions[0]["description"]
3334                .as_str()
3335                .unwrap()
3336                .contains("processData")
3337        );
3338        assert_eq!(actions[1]["type"], "suppress-line");
3339        assert_eq!(
3340            actions[1]["comment"],
3341            "// fallow-ignore-next-line complexity"
3342        );
3343    }
3344
3345    #[test]
3346    fn refactoring_target_has_actions() {
3347        let mut output = serde_json::json!({
3348            "targets": [{
3349                "path": "src/big-module.ts",
3350                "priority": 85.0,
3351                "efficiency": 42.5,
3352                "recommendation": "Split module: 12 exports, 4 unused",
3353                "category": "split_high_impact",
3354                "effort": "medium",
3355                "confidence": "high",
3356                "evidence": { "unused_exports": 4 }
3357            }]
3358        });
3359
3360        inject_health_actions(&mut output, HealthActionOptions::default());
3361
3362        let actions = output["targets"][0]["actions"].as_array().unwrap();
3363        assert_eq!(actions.len(), 2);
3364        assert_eq!(actions[0]["type"], "apply-refactoring");
3365        assert_eq!(
3366            actions[0]["description"],
3367            "Split module: 12 exports, 4 unused"
3368        );
3369        assert_eq!(actions[0]["category"], "split_high_impact");
3370        // Target with evidence gets suppress action
3371        assert_eq!(actions[1]["type"], "suppress-line");
3372    }
3373
3374    #[test]
3375    fn refactoring_target_without_evidence_has_no_suppress() {
3376        let mut output = serde_json::json!({
3377            "targets": [{
3378                "path": "src/simple.ts",
3379                "priority": 30.0,
3380                "efficiency": 15.0,
3381                "recommendation": "Consider extracting helper functions",
3382                "category": "extract_complex_functions",
3383                "effort": "small",
3384                "confidence": "medium"
3385            }]
3386        });
3387
3388        inject_health_actions(&mut output, HealthActionOptions::default());
3389
3390        let actions = output["targets"][0]["actions"].as_array().unwrap();
3391        assert_eq!(actions.len(), 1);
3392        assert_eq!(actions[0]["type"], "apply-refactoring");
3393    }
3394
3395    #[test]
3396    fn health_empty_findings_no_actions() {
3397        let mut output = serde_json::json!({
3398            "findings": [],
3399            "targets": []
3400        });
3401
3402        inject_health_actions(&mut output, HealthActionOptions::default());
3403
3404        assert!(output["findings"].as_array().unwrap().is_empty());
3405        assert!(output["targets"].as_array().unwrap().is_empty());
3406    }
3407
3408    #[test]
3409    fn hotspot_has_actions() {
3410        let mut output = serde_json::json!({
3411            "hotspots": [{
3412                "path": "src/utils.ts",
3413                "complexity_score": 45.0,
3414                "churn_score": 12,
3415                "hotspot_score": 540.0
3416            }]
3417        });
3418
3419        inject_health_actions(&mut output, HealthActionOptions::default());
3420
3421        let actions = output["hotspots"][0]["actions"].as_array().unwrap();
3422        assert_eq!(actions.len(), 2);
3423        assert_eq!(actions[0]["type"], "refactor-file");
3424        assert!(
3425            actions[0]["description"]
3426                .as_str()
3427                .unwrap()
3428                .contains("src/utils.ts")
3429        );
3430        assert_eq!(actions[1]["type"], "add-tests");
3431    }
3432
3433    #[test]
3434    fn hotspot_low_bus_factor_emits_action() {
3435        let mut output = serde_json::json!({
3436            "hotspots": [{
3437                "path": "src/api.ts",
3438                "ownership": {
3439                    "bus_factor": 1,
3440                    "contributor_count": 1,
3441                    "top_contributor": {"identifier": "alice@x", "share": 1.0, "stale_days": 5, "commits": 30},
3442                    "unowned": null,
3443                    "drift": false,
3444                }
3445            }]
3446        });
3447
3448        inject_health_actions(&mut output, HealthActionOptions::default());
3449
3450        let actions = output["hotspots"][0]["actions"].as_array().unwrap();
3451        assert!(
3452            actions
3453                .iter()
3454                .filter_map(|a| a["type"].as_str())
3455                .any(|t| t == "low-bus-factor"),
3456            "low-bus-factor action should be present",
3457        );
3458        let bus = actions
3459            .iter()
3460            .find(|a| a["type"] == "low-bus-factor")
3461            .unwrap();
3462        assert!(bus["description"].as_str().unwrap().contains("alice@x"));
3463    }
3464
3465    #[test]
3466    fn hotspot_unowned_emits_action_with_pattern() {
3467        let mut output = serde_json::json!({
3468            "hotspots": [{
3469                "path": "src/api/users.ts",
3470                "ownership": {
3471                    "bus_factor": 2,
3472                    "contributor_count": 4,
3473                    "top_contributor": {"identifier": "alice@x", "share": 0.5, "stale_days": 5, "commits": 10},
3474                    "unowned": true,
3475                    "drift": false,
3476                }
3477            }]
3478        });
3479
3480        inject_health_actions(&mut output, HealthActionOptions::default());
3481
3482        let actions = output["hotspots"][0]["actions"].as_array().unwrap();
3483        let unowned = actions
3484            .iter()
3485            .find(|a| a["type"] == "unowned-hotspot")
3486            .expect("unowned-hotspot action should be present");
3487        // Deepest directory containing the file -> /src/api/
3488        // (file `users.ts` is at depth 2, so the deepest dir is `/src/api/`).
3489        assert_eq!(unowned["suggested_pattern"], "/src/api/");
3490        assert_eq!(unowned["heuristic"], "directory-deepest");
3491    }
3492
3493    #[test]
3494    fn hotspot_unowned_skipped_when_codeowners_missing() {
3495        let mut output = serde_json::json!({
3496            "hotspots": [{
3497                "path": "src/api.ts",
3498                "ownership": {
3499                    "bus_factor": 2,
3500                    "contributor_count": 4,
3501                    "top_contributor": {"identifier": "alice@x", "share": 0.5, "stale_days": 5, "commits": 10},
3502                    "unowned": null,
3503                    "drift": false,
3504                }
3505            }]
3506        });
3507
3508        inject_health_actions(&mut output, HealthActionOptions::default());
3509
3510        let actions = output["hotspots"][0]["actions"].as_array().unwrap();
3511        assert!(
3512            !actions.iter().any(|a| a["type"] == "unowned-hotspot"),
3513            "unowned action must not fire when CODEOWNERS file is absent"
3514        );
3515    }
3516
3517    #[test]
3518    fn hotspot_drift_emits_action() {
3519        let mut output = serde_json::json!({
3520            "hotspots": [{
3521                "path": "src/old.ts",
3522                "ownership": {
3523                    "bus_factor": 1,
3524                    "contributor_count": 2,
3525                    "top_contributor": {"identifier": "bob@x", "share": 0.9, "stale_days": 1, "commits": 18},
3526                    "unowned": null,
3527                    "drift": true,
3528                    "drift_reason": "original author alice@x has 5% share",
3529                }
3530            }]
3531        });
3532
3533        inject_health_actions(&mut output, HealthActionOptions::default());
3534
3535        let actions = output["hotspots"][0]["actions"].as_array().unwrap();
3536        let drift = actions
3537            .iter()
3538            .find(|a| a["type"] == "ownership-drift")
3539            .expect("ownership-drift action should be present");
3540        assert!(drift["description"].as_str().unwrap().contains("alice@x"));
3541    }
3542
3543    // ── suggest_codeowners_pattern ─────────────────────────────────
3544
3545    #[test]
3546    fn codeowners_pattern_uses_deepest_directory() {
3547        // Deepest dir keeps the suggestion tightly-scoped; the prior
3548        // "first two levels" heuristic over-generalized in monorepos.
3549        assert_eq!(
3550            suggest_codeowners_pattern("src/api/users/handlers.ts"),
3551            "/src/api/users/"
3552        );
3553    }
3554
3555    #[test]
3556    fn codeowners_pattern_for_root_file() {
3557        assert_eq!(suggest_codeowners_pattern("README.md"), "/README.md");
3558    }
3559
3560    #[test]
3561    fn codeowners_pattern_normalizes_backslashes() {
3562        assert_eq!(
3563            suggest_codeowners_pattern("src\\api\\users.ts"),
3564            "/src/api/"
3565        );
3566    }
3567
3568    #[test]
3569    fn codeowners_pattern_two_level_path() {
3570        assert_eq!(suggest_codeowners_pattern("src/foo.ts"), "/src/");
3571    }
3572
3573    #[test]
3574    fn health_finding_suppress_has_placement() {
3575        let mut output = serde_json::json!({
3576            "findings": [{
3577                "path": "src/utils.ts",
3578                "name": "processData",
3579                "line": 10,
3580                "col": 0,
3581                "cyclomatic": 25,
3582                "cognitive": 30,
3583                "line_count": 150,
3584                "exceeded": "both"
3585            }]
3586        });
3587
3588        inject_health_actions(&mut output, HealthActionOptions::default());
3589
3590        let suppress = &output["findings"][0]["actions"][1];
3591        assert_eq!(suppress["placement"], "above-function-declaration");
3592    }
3593
3594    #[test]
3595    fn html_template_health_finding_uses_html_suppression() {
3596        let mut output = serde_json::json!({
3597            "findings": [{
3598                "path": "src/app.component.html",
3599                "name": "<template>",
3600                "line": 1,
3601                "col": 0,
3602                "cyclomatic": 25,
3603                "cognitive": 30,
3604                "line_count": 40,
3605                "exceeded": "both"
3606            }]
3607        });
3608
3609        inject_health_actions(&mut output, HealthActionOptions::default());
3610
3611        let suppress = &output["findings"][0]["actions"][1];
3612        assert_eq!(suppress["type"], "suppress-file");
3613        assert_eq!(
3614            suppress["comment"],
3615            "<!-- fallow-ignore-file complexity -->"
3616        );
3617        assert_eq!(suppress["placement"], "top-of-template");
3618    }
3619
3620    #[test]
3621    fn inline_template_health_finding_uses_decorator_suppression() {
3622        let mut output = serde_json::json!({
3623            "findings": [{
3624                "path": "src/app.component.ts",
3625                "name": "<template>",
3626                "line": 5,
3627                "col": 0,
3628                "cyclomatic": 25,
3629                "cognitive": 30,
3630                "line_count": 40,
3631                "exceeded": "both"
3632            }]
3633        });
3634
3635        inject_health_actions(&mut output, HealthActionOptions::default());
3636
3637        let refactor = &output["findings"][0]["actions"][0];
3638        assert_eq!(refactor["type"], "refactor-function");
3639        assert!(
3640            refactor["description"]
3641                .as_str()
3642                .unwrap()
3643                .contains("template complexity")
3644        );
3645        let suppress = &output["findings"][0]["actions"][1];
3646        assert_eq!(suppress["type"], "suppress-line");
3647        assert_eq!(
3648            suppress["description"],
3649            "Suppress with an inline comment above the Angular decorator"
3650        );
3651        assert_eq!(suppress["placement"], "above-angular-decorator");
3652    }
3653
3654    // ── Duplication actions injection ─────────────────────────────
3655
3656    #[test]
3657    fn clone_family_has_actions() {
3658        let mut output = serde_json::json!({
3659            "clone_families": [{
3660                "files": ["src/a.ts", "src/b.ts"],
3661                "groups": [
3662                    { "instances": [{"file": "src/a.ts"}, {"file": "src/b.ts"}], "token_count": 100, "line_count": 20 }
3663                ],
3664                "total_duplicated_lines": 20,
3665                "total_duplicated_tokens": 100,
3666                "suggestions": [
3667                    { "kind": "ExtractFunction", "description": "Extract shared validation logic", "estimated_savings": 15 }
3668                ]
3669            }]
3670        });
3671
3672        inject_dupes_actions(&mut output);
3673
3674        let actions = output["clone_families"][0]["actions"].as_array().unwrap();
3675        assert_eq!(actions.len(), 3);
3676        assert_eq!(actions[0]["type"], "extract-shared");
3677        assert_eq!(actions[0]["auto_fixable"], false);
3678        assert!(
3679            actions[0]["description"]
3680                .as_str()
3681                .unwrap()
3682                .contains("20 lines")
3683        );
3684        // Suggestion forwarded as action
3685        assert_eq!(actions[1]["type"], "apply-suggestion");
3686        assert!(
3687            actions[1]["description"]
3688                .as_str()
3689                .unwrap()
3690                .contains("validation logic")
3691        );
3692        // Suppress action
3693        assert_eq!(actions[2]["type"], "suppress-line");
3694        assert_eq!(
3695            actions[2]["comment"],
3696            "// fallow-ignore-next-line code-duplication"
3697        );
3698    }
3699
3700    #[test]
3701    fn clone_group_has_actions() {
3702        let mut output = serde_json::json!({
3703            "clone_groups": [{
3704                "instances": [
3705                    {"file": "src/a.ts", "start_line": 1, "end_line": 10},
3706                    {"file": "src/b.ts", "start_line": 5, "end_line": 14}
3707                ],
3708                "token_count": 50,
3709                "line_count": 10
3710            }]
3711        });
3712
3713        inject_dupes_actions(&mut output);
3714
3715        let actions = output["clone_groups"][0]["actions"].as_array().unwrap();
3716        assert_eq!(actions.len(), 2);
3717        assert_eq!(actions[0]["type"], "extract-shared");
3718        assert!(
3719            actions[0]["description"]
3720                .as_str()
3721                .unwrap()
3722                .contains("10 lines")
3723        );
3724        assert!(
3725            actions[0]["description"]
3726                .as_str()
3727                .unwrap()
3728                .contains("2 instances")
3729        );
3730        assert_eq!(actions[1]["type"], "suppress-line");
3731    }
3732
3733    #[test]
3734    fn dupes_empty_results_no_actions() {
3735        let mut output = serde_json::json!({
3736            "clone_families": [],
3737            "clone_groups": []
3738        });
3739
3740        inject_dupes_actions(&mut output);
3741
3742        assert!(output["clone_families"].as_array().unwrap().is_empty());
3743        assert!(output["clone_groups"].as_array().unwrap().is_empty());
3744    }
3745
3746    // ── Tier-aware health action emission ──────────────────────────
3747
3748    /// Helper: build a health JSON envelope with a single CRAP-only finding.
3749    /// Default cognitive complexity is 12 (above the cognitive floor at the
3750    /// default `max_cognitive_threshold / 2 = 7.5`); use
3751    /// `crap_only_finding_envelope_with_cognitive` to exercise low-cog cases
3752    /// (flat dispatchers, JSX render maps) where the cognitive floor should
3753    /// suppress the secondary refactor.
3754    fn crap_only_finding_envelope(
3755        coverage_tier: Option<&str>,
3756        cyclomatic: u16,
3757        max_cyclomatic_threshold: u16,
3758    ) -> serde_json::Value {
3759        crap_only_finding_envelope_with_max_crap(
3760            coverage_tier,
3761            cyclomatic,
3762            12,
3763            max_cyclomatic_threshold,
3764            15,
3765            30.0,
3766        )
3767    }
3768
3769    fn crap_only_finding_envelope_with_cognitive(
3770        coverage_tier: Option<&str>,
3771        cyclomatic: u16,
3772        cognitive: u16,
3773        max_cyclomatic_threshold: u16,
3774    ) -> serde_json::Value {
3775        crap_only_finding_envelope_with_max_crap(
3776            coverage_tier,
3777            cyclomatic,
3778            cognitive,
3779            max_cyclomatic_threshold,
3780            15,
3781            30.0,
3782        )
3783    }
3784
3785    fn crap_only_finding_envelope_with_max_crap(
3786        coverage_tier: Option<&str>,
3787        cyclomatic: u16,
3788        cognitive: u16,
3789        max_cyclomatic_threshold: u16,
3790        max_cognitive_threshold: u16,
3791        max_crap_threshold: f64,
3792    ) -> serde_json::Value {
3793        let mut finding = serde_json::json!({
3794            "path": "src/risk.ts",
3795            "name": "computeScore",
3796            "line": 12,
3797            "col": 0,
3798            "cyclomatic": cyclomatic,
3799            "cognitive": cognitive,
3800            "line_count": 40,
3801            "exceeded": "crap",
3802            "crap": 35.5,
3803        });
3804        if let Some(tier) = coverage_tier {
3805            finding["coverage_tier"] = serde_json::Value::String(tier.to_owned());
3806        }
3807        serde_json::json!({
3808            "findings": [finding],
3809            "summary": {
3810                "max_cyclomatic_threshold": max_cyclomatic_threshold,
3811                "max_cognitive_threshold": max_cognitive_threshold,
3812                "max_crap_threshold": max_crap_threshold,
3813            },
3814        })
3815    }
3816
3817    #[test]
3818    fn crap_only_tier_none_emits_add_tests() {
3819        let mut output = crap_only_finding_envelope(Some("none"), 6, 20);
3820        inject_health_actions(&mut output, HealthActionOptions::default());
3821        let actions = output["findings"][0]["actions"].as_array().unwrap();
3822        assert!(
3823            actions.iter().any(|a| a["type"] == "add-tests"),
3824            "tier=none crap-only must emit add-tests, got {actions:?}"
3825        );
3826        assert!(
3827            !actions.iter().any(|a| a["type"] == "increase-coverage"),
3828            "tier=none must not emit increase-coverage"
3829        );
3830    }
3831
3832    #[test]
3833    fn crap_only_tier_partial_emits_increase_coverage() {
3834        let mut output = crap_only_finding_envelope(Some("partial"), 6, 20);
3835        inject_health_actions(&mut output, HealthActionOptions::default());
3836        let actions = output["findings"][0]["actions"].as_array().unwrap();
3837        assert!(
3838            actions.iter().any(|a| a["type"] == "increase-coverage"),
3839            "tier=partial crap-only must emit increase-coverage, got {actions:?}"
3840        );
3841        assert!(
3842            !actions.iter().any(|a| a["type"] == "add-tests"),
3843            "tier=partial must not emit add-tests"
3844        );
3845    }
3846
3847    #[test]
3848    fn crap_only_tier_high_emits_increase_coverage_when_full_coverage_can_clear_crap() {
3849        // CC=20 at 70% coverage has CRAP 30.8, but at 100% coverage CRAP
3850        // falls to 20.0, below the default max_crap_threshold=30. Coverage
3851        // is therefore still a valid remediation even though tier=high.
3852        let mut output = crap_only_finding_envelope(Some("high"), 20, 30);
3853        inject_health_actions(&mut output, HealthActionOptions::default());
3854        let actions = output["findings"][0]["actions"].as_array().unwrap();
3855        assert!(
3856            actions.iter().any(|a| a["type"] == "increase-coverage"),
3857            "tier=high crap-only must still emit increase-coverage when full coverage can clear CRAP, got {actions:?}"
3858        );
3859        assert!(
3860            !actions.iter().any(|a| a["type"] == "refactor-function"),
3861            "coverage-remediable crap-only findings should not get refactor-function unless near the cyclomatic threshold"
3862        );
3863        assert!(
3864            !actions.iter().any(|a| a["type"] == "add-tests"),
3865            "tier=high must not emit add-tests"
3866        );
3867    }
3868
3869    #[test]
3870    fn crap_only_emits_refactor_when_full_coverage_cannot_clear_crap() {
3871        // At 100% coverage CRAP bottoms out at CC. With CC=35 and a CRAP
3872        // threshold of 30, tests alone can reduce risk but cannot clear the
3873        // finding; the primary action should be complexity reduction.
3874        let mut output =
3875            crap_only_finding_envelope_with_max_crap(Some("high"), 35, 12, 50, 15, 30.0);
3876        inject_health_actions(&mut output, HealthActionOptions::default());
3877        let actions = output["findings"][0]["actions"].as_array().unwrap();
3878        assert!(
3879            actions.iter().any(|a| a["type"] == "refactor-function"),
3880            "full-coverage-impossible CRAP-only finding must emit refactor-function, got {actions:?}"
3881        );
3882        assert!(
3883            !actions.iter().any(|a| a["type"] == "increase-coverage"),
3884            "must not emit increase-coverage when even 100% coverage cannot clear CRAP"
3885        );
3886        assert!(
3887            !actions.iter().any(|a| a["type"] == "add-tests"),
3888            "must not emit add-tests when even 100% coverage cannot clear CRAP"
3889        );
3890    }
3891
3892    #[test]
3893    fn crap_only_high_cc_appends_secondary_refactor() {
3894        // CC=16 with threshold=20 => within SECONDARY_REFACTOR_BAND (5)
3895        // of the threshold; refactor is a useful complement to coverage.
3896        let mut output = crap_only_finding_envelope(Some("none"), 16, 20);
3897        inject_health_actions(&mut output, HealthActionOptions::default());
3898        let actions = output["findings"][0]["actions"].as_array().unwrap();
3899        assert!(
3900            actions.iter().any(|a| a["type"] == "add-tests"),
3901            "near-threshold crap-only still emits the primary tier action"
3902        );
3903        assert!(
3904            actions.iter().any(|a| a["type"] == "refactor-function"),
3905            "near-threshold crap-only must also emit secondary refactor-function"
3906        );
3907    }
3908
3909    #[test]
3910    fn crap_only_far_below_threshold_no_secondary_refactor() {
3911        // CC=6 with threshold=20 => far outside the band; refactor not added.
3912        let mut output = crap_only_finding_envelope(Some("none"), 6, 20);
3913        inject_health_actions(&mut output, HealthActionOptions::default());
3914        let actions = output["findings"][0]["actions"].as_array().unwrap();
3915        assert!(
3916            !actions.iter().any(|a| a["type"] == "refactor-function"),
3917            "low-CC crap-only should not get a secondary refactor-function"
3918        );
3919    }
3920
3921    #[test]
3922    fn crap_only_near_threshold_low_cognitive_no_secondary_refactor() {
3923        // Cognitive floor regression. Real-world example from vrs-portals:
3924        // a flat type-tag dispatcher with CC=17 (within SECONDARY_REFACTOR_BAND
3925        // of the default cyclomatic threshold of 20) but cognitive=2 (a single
3926        // switch, no nesting). Suggesting "extract helpers, simplify branching"
3927        // is wrong-target advice for declarative dispatchers; the cognitive
3928        // floor at `max_cognitive_threshold / 2` (default 7) suppresses the
3929        // secondary refactor in this case while still firing it for genuinely
3930        // tangled functions (CC>=15 + cog>=8) where refactor would help.
3931        let mut output = crap_only_finding_envelope_with_cognitive(Some("none"), 17, 2, 20);
3932        inject_health_actions(&mut output, HealthActionOptions::default());
3933        let actions = output["findings"][0]["actions"].as_array().unwrap();
3934        assert!(
3935            actions.iter().any(|a| a["type"] == "add-tests"),
3936            "primary tier action still emits"
3937        );
3938        assert!(
3939            !actions.iter().any(|a| a["type"] == "refactor-function"),
3940            "near-threshold CC with cognitive below floor must NOT emit secondary refactor (got {actions:?})"
3941        );
3942    }
3943
3944    #[test]
3945    fn crap_only_near_threshold_high_cognitive_emits_secondary_refactor() {
3946        // Companion to the cognitive-floor regression: when cognitive is at or
3947        // above the floor, the secondary refactor should still fire. CC=16
3948        // and cognitive=10 (above default floor of 7) is the canonical
3949        // "tangled but near-threshold" function that genuinely benefits from
3950        // both coverage AND refactoring.
3951        let mut output = crap_only_finding_envelope_with_cognitive(Some("none"), 16, 10, 20);
3952        inject_health_actions(&mut output, HealthActionOptions::default());
3953        let actions = output["findings"][0]["actions"].as_array().unwrap();
3954        assert!(
3955            actions.iter().any(|a| a["type"] == "add-tests"),
3956            "primary tier action still emits"
3957        );
3958        assert!(
3959            actions.iter().any(|a| a["type"] == "refactor-function"),
3960            "near-threshold CC with cognitive above floor must emit secondary refactor (got {actions:?})"
3961        );
3962    }
3963
3964    #[test]
3965    fn cyclomatic_only_emits_only_refactor_function() {
3966        let mut output = serde_json::json!({
3967            "findings": [{
3968                "path": "src/cyclo.ts",
3969                "name": "branchy",
3970                "line": 5,
3971                "col": 0,
3972                "cyclomatic": 25,
3973                "cognitive": 10,
3974                "line_count": 80,
3975                "exceeded": "cyclomatic",
3976            }],
3977            "summary": { "max_cyclomatic_threshold": 20 },
3978        });
3979        inject_health_actions(&mut output, HealthActionOptions::default());
3980        let actions = output["findings"][0]["actions"].as_array().unwrap();
3981        assert!(
3982            actions.iter().any(|a| a["type"] == "refactor-function"),
3983            "non-CRAP findings emit refactor-function"
3984        );
3985        assert!(
3986            !actions.iter().any(|a| a["type"] == "add-tests"),
3987            "non-CRAP findings must not emit add-tests"
3988        );
3989        assert!(
3990            !actions.iter().any(|a| a["type"] == "increase-coverage"),
3991            "non-CRAP findings must not emit increase-coverage"
3992        );
3993    }
3994
3995    // ── Suppress-line gating ──────────────────────────────────────
3996
3997    #[test]
3998    fn suppress_line_omitted_when_baseline_active() {
3999        let mut output = crap_only_finding_envelope(Some("none"), 6, 20);
4000        inject_health_actions(
4001            &mut output,
4002            HealthActionOptions {
4003                omit_suppress_line: true,
4004                omit_reason: Some("baseline-active"),
4005            },
4006        );
4007        let actions = output["findings"][0]["actions"].as_array().unwrap();
4008        assert!(
4009            !actions.iter().any(|a| a["type"] == "suppress-line"),
4010            "baseline-active must not emit suppress-line, got {actions:?}"
4011        );
4012        assert_eq!(
4013            output["actions_meta"]["suppression_hints_omitted"],
4014            serde_json::Value::Bool(true)
4015        );
4016        assert_eq!(output["actions_meta"]["reason"], "baseline-active");
4017        assert_eq!(output["actions_meta"]["scope"], "health-findings");
4018    }
4019
4020    #[test]
4021    fn suppress_line_omitted_when_config_disabled() {
4022        let mut output = crap_only_finding_envelope(Some("none"), 6, 20);
4023        inject_health_actions(
4024            &mut output,
4025            HealthActionOptions {
4026                omit_suppress_line: true,
4027                omit_reason: Some("config-disabled"),
4028            },
4029        );
4030        assert_eq!(output["actions_meta"]["reason"], "config-disabled");
4031    }
4032
4033    #[test]
4034    fn suppress_line_emitted_by_default() {
4035        let mut output = crap_only_finding_envelope(Some("none"), 6, 20);
4036        inject_health_actions(&mut output, HealthActionOptions::default());
4037        let actions = output["findings"][0]["actions"].as_array().unwrap();
4038        assert!(
4039            actions.iter().any(|a| a["type"] == "suppress-line"),
4040            "default opts must emit suppress-line"
4041        );
4042        assert!(
4043            output.get("actions_meta").is_none(),
4044            "actions_meta must be absent when no omission occurred"
4045        );
4046    }
4047
4048    /// Drift guard: every action `type` value emitted by the action builder
4049    /// must appear in `docs/output-schema.json`'s `HealthFindingAction.type`
4050    /// enum. Previously the schema listed only `[refactor-function,
4051    /// suppress-line]` while the code emitted `add-tests` for CRAP findings,
4052    /// silently producing schema-invalid output for any consumer using the
4053    /// schema for validation.
4054    #[test]
4055    fn every_emitted_health_action_type_is_in_schema_enum() {
4056        // Exercise every distinct emission path. The list mirrors the match
4057        // in `build_crap_coverage_action` and the surrounding refactor/
4058        // suppress-line emissions in `build_health_finding_actions`.
4059        let cases = [
4060            // (exceeded, coverage_tier, cyclomatic, max_cyclomatic_threshold)
4061            ("crap", Some("none"), 6_u16, 20_u16),
4062            ("crap", Some("partial"), 6, 20),
4063            ("crap", Some("high"), 12, 20),
4064            ("crap", Some("none"), 16, 20), // near threshold => secondary refactor
4065            ("cyclomatic", None, 25, 20),
4066            ("cognitive_crap", Some("partial"), 6, 20),
4067            ("all", Some("none"), 25, 20),
4068        ];
4069
4070        let mut emitted: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
4071        for (exceeded, tier, cc, max) in cases {
4072            let mut finding = serde_json::json!({
4073                "path": "src/x.ts",
4074                "name": "fn",
4075                "line": 1,
4076                "col": 0,
4077                "cyclomatic": cc,
4078                "cognitive": 5,
4079                "line_count": 10,
4080                "exceeded": exceeded,
4081                "crap": 35.0,
4082            });
4083            if let Some(t) = tier {
4084                finding["coverage_tier"] = serde_json::Value::String(t.to_owned());
4085            }
4086            let mut output = serde_json::json!({
4087                "findings": [finding],
4088                "summary": { "max_cyclomatic_threshold": max },
4089            });
4090            inject_health_actions(&mut output, HealthActionOptions::default());
4091            for action in output["findings"][0]["actions"].as_array().unwrap() {
4092                if let Some(ty) = action["type"].as_str() {
4093                    emitted.insert(ty.to_owned());
4094                }
4095            }
4096        }
4097
4098        // Load the schema enum once.
4099        let schema_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
4100            .join("..")
4101            .join("..")
4102            .join("docs")
4103            .join("output-schema.json");
4104        let raw = std::fs::read_to_string(&schema_path)
4105            .expect("docs/output-schema.json must be readable for the drift-guard test");
4106        let schema: serde_json::Value = serde_json::from_str(&raw).expect("schema parses");
4107        let enum_values: std::collections::BTreeSet<String> =
4108            schema["definitions"]["HealthFindingAction"]["properties"]["type"]["enum"]
4109                .as_array()
4110                .expect("HealthFindingAction.type.enum is an array")
4111                .iter()
4112                .filter_map(|v| v.as_str().map(str::to_owned))
4113                .collect();
4114
4115        for ty in &emitted {
4116            assert!(
4117                enum_values.contains(ty),
4118                "build_health_finding_actions emitted action type `{ty}` but \
4119                 docs/output-schema.json HealthFindingAction.type enum does \
4120                 not list it. Add it to the schema (and any downstream \
4121                 typed consumers) when introducing a new action type."
4122            );
4123        }
4124    }
4125}