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