Skip to main content

fallow_cli/report/
json.rs

1use std::collections::BTreeMap;
2use std::path::Path;
3use std::process::ExitCode;
4use std::time::Duration;
5
6use fallow_core::duplicates::DuplicationReport;
7use fallow_core::results::AnalysisResults;
8use fallow_types::envelope::{CheckSummary, ElapsedMs, EntryPoints, SchemaVersion, ToolVersion};
9
10use super::{emit_json, normalize_uri};
11use crate::explain;
12use crate::output_dupes::DupesReportPayload;
13use crate::output_envelope::{
14    CheckGroupedEntry, CheckGroupedOutput, CheckOutput, DupesOutput, GroupByMode, HealthOutput,
15};
16use crate::report::grouping::{OwnershipResolver, ResultGroup};
17
18/// Flip the position-0 `add-to-config` `auto_fixable` flag on every
19/// `duplicate_exports` finding when the fix-applier is ready to write into
20/// the user's config. Mirrors the per-instance flips for
21/// `unused_catalog_entries` (handled inside `with_actions`). No-op when
22/// `config_fixable` is `false`, since the typed wrapper already constructs
23/// every finding with the conservative `auto_fixable: false` default.
24fn apply_config_fixable_to_duplicate_exports(results: &mut AnalysisResults, config_fixable: bool) {
25    if !config_fixable {
26        return;
27    }
28    for finding in &mut results.duplicate_exports {
29        finding.set_config_fixable(true);
30    }
31}
32
33pub(super) fn print_json(
34    results: &AnalysisResults,
35    root: &Path,
36    elapsed: Duration,
37    explain: bool,
38    regression: Option<&crate::regression::RegressionOutcome>,
39    baseline_matched: Option<(usize, usize)>,
40    config_fixable: bool,
41) -> ExitCode {
42    match build_json_with_config_fixable(results, root, elapsed, config_fixable) {
43        Ok(mut output) => {
44            if let Some(outcome) = regression
45                && let serde_json::Value::Object(ref mut map) = output
46            {
47                map.insert("regression".to_string(), outcome.to_json());
48            }
49            if let Some((entries, matched)) = baseline_matched
50                && let serde_json::Value::Object(ref mut map) = output
51            {
52                map.insert(
53                    "baseline".to_string(),
54                    serde_json::json!({
55                        "entries": entries,
56                        "matched": matched,
57                    }),
58                );
59            }
60            if explain {
61                insert_meta(&mut output, explain::check_meta());
62            }
63            emit_json(&output, "JSON")
64        }
65        Err(e) => {
66            eprintln!("Error: failed to serialize results: {e}");
67            ExitCode::from(2)
68        }
69    }
70}
71
72/// Render grouped analysis results as a single JSON document.
73///
74/// Produces an envelope with `grouped_by` and `total_issues` at the top level,
75/// then a `groups` array where each element contains the group `key`,
76/// `total_issues`, and all the normal result fields with paths relativized.
77#[must_use]
78pub(super) fn print_grouped_json(
79    groups: &[ResultGroup],
80    original: &AnalysisResults,
81    root: &Path,
82    elapsed: Duration,
83    explain: bool,
84    resolver: &OwnershipResolver,
85    config_fixable: bool,
86) -> ExitCode {
87    let entries: Vec<CheckGroupedEntry> = groups
88        .iter()
89        .map(|group| {
90            let mut results = group.results.clone();
91            apply_config_fixable_to_duplicate_exports(&mut results, config_fixable);
92            CheckGroupedEntry {
93                key: group.key.clone(),
94                owners: group.owners.clone(),
95                total_issues: results.total_issues(),
96                results,
97            }
98        })
99        .collect();
100
101    let envelope = CheckGroupedOutput {
102        schema_version: SchemaVersion(SCHEMA_VERSION),
103        version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
104        elapsed_ms: ElapsedMs(elapsed.as_millis() as u64),
105        grouped_by: group_by_mode_from_label(resolver.mode_label()),
106        total_issues: original.total_issues(),
107        groups: entries,
108        meta: None,
109    };
110
111    let mut output = match serde_json::to_value(&envelope) {
112        Ok(value) => value,
113        Err(e) => {
114            eprintln!("Error: failed to serialize grouped results: {e}");
115            return ExitCode::from(2);
116        }
117    };
118
119    let root_prefix = format!("{}/", root.display());
120    // Strip per group separately. `duplicate_exports` per-instance
121    // `config_fixable` was layered into each group's `AnalysisResults`
122    // above, so the typed `actions` array on every finding already carries
123    // the correct `auto_fixable` bool. Only the suppression harmonizer
124    // still walks the per-group JSON tree to dedupe `// fallow-ignore-next-line`
125    // anchors across same-path+line findings.
126    if let Some(arr) = output.get_mut("groups").and_then(|v| v.as_array_mut()) {
127        for entry in arr {
128            strip_root_prefix(entry, &root_prefix);
129            harmonize_multi_kind_suppress_line_actions(entry);
130        }
131    }
132
133    if explain {
134        insert_meta(&mut output, explain::check_meta());
135    }
136
137    emit_json(&output, "JSON")
138}
139
140/// JSON output schema version as an integer (independent of tool version).
141///
142/// Bump this when the structure of the JSON output changes in a
143/// backwards-incompatible way (removing/renaming fields, changing types).
144/// Adding new fields is always backwards-compatible and does not require a bump.
145#[allow(
146    clippy::redundant_pub_crate,
147    reason = "used through report module re-export by combined.rs, audit.rs, flags.rs"
148)]
149pub(crate) const SCHEMA_VERSION: u32 = 6;
150
151/// Build the JSON output value for analysis results.
152///
153/// Metadata fields (`schema_version`, `version`, `elapsed_ms`, `total_issues`)
154/// appear first in the output for readability. Paths are made relative to `root`.
155///
156/// # Errors
157///
158/// Returns an error if the results cannot be serialized to JSON.
159#[allow(
160    dead_code,
161    reason = "used by the fallow-cli library target for embedders, but dead in the binary target"
162)]
163pub fn build_json(
164    results: &AnalysisResults,
165    root: &Path,
166    elapsed: Duration,
167) -> Result<serde_json::Value, serde_json::Error> {
168    build_json_with_config_fixable(
169        results,
170        root,
171        elapsed,
172        crate::fix::is_config_fixable(root, None),
173    )
174}
175
176/// Build the JSON output value with an explicit config-action fixability signal.
177///
178/// Use this when the caller already knows how the config was loaded, including
179/// explicit `--config` paths that default discovery cannot see.
180pub fn build_json_with_config_fixable(
181    results: &AnalysisResults,
182    root: &Path,
183    elapsed: Duration,
184    config_fixable: bool,
185) -> Result<serde_json::Value, serde_json::Error> {
186    let mut owned_results = results.clone();
187    apply_config_fixable_to_duplicate_exports(&mut owned_results, config_fixable);
188    let envelope = CheckOutput {
189        schema_version: SchemaVersion(SCHEMA_VERSION),
190        version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
191        elapsed_ms: ElapsedMs(elapsed.as_millis() as u64),
192        total_issues: owned_results.total_issues(),
193        entry_points: owned_results
194            .entry_point_summary
195            .as_ref()
196            .map(|ep| EntryPoints {
197                total: ep.total,
198                // Replace spaces with underscores so downstream dashboards can
199                // drill into individual sources by stable keys (e.g.
200                // `package.json`, `next.js`, `config_entry`).
201                sources: ep
202                    .by_source
203                    .iter()
204                    .map(|(k, v)| (k.replace(' ', "_"), *v))
205                    .collect(),
206            }),
207        summary: build_check_summary(&owned_results),
208        results: owned_results,
209        baseline_deltas: None,
210        baseline: None,
211        regression: None,
212        meta: None,
213        workspace_diagnostics: crate::runtime_support::workspace_diagnostics_for(root),
214    };
215
216    let mut output = serde_json::to_value(&envelope)?;
217    let root_prefix = format!("{}/", root.display());
218    // strip_root_prefix runs on the already-serialized envelope where the
219    // typed `actions` arrays are already in place; the stripper skips
220    // non-path string values via the prefix check.
221    strip_root_prefix(&mut output, &root_prefix);
222    harmonize_multi_kind_suppress_line_actions(&mut output);
223    Ok(output)
224}
225
226/// Compute the per-category `CheckSummary` from analysis results.
227///
228/// The `unused_dependencies` field is the COMBINED count across regular,
229/// dev, and optional dependencies (the JSON layer has folded these three
230/// since the schema was first published; the per-section arrays still
231/// live at the top level of `CheckOutput`).
232fn build_check_summary(results: &AnalysisResults) -> CheckSummary {
233    CheckSummary {
234        total_issues: results.total_issues(),
235        unused_files: results.unused_files.len(),
236        unused_exports: results.unused_exports.len(),
237        unused_types: results.unused_types.len(),
238        private_type_leaks: results.private_type_leaks.len(),
239        unused_dependencies: results.unused_dependencies.len()
240            + results.unused_dev_dependencies.len()
241            + results.unused_optional_dependencies.len(),
242        unused_enum_members: results.unused_enum_members.len(),
243        unused_class_members: results.unused_class_members.len(),
244        unresolved_imports: results.unresolved_imports.len(),
245        unlisted_dependencies: results.unlisted_dependencies.len(),
246        duplicate_exports: results.duplicate_exports.len(),
247        type_only_dependencies: results.type_only_dependencies.len(),
248        test_only_dependencies: results.test_only_dependencies.len(),
249        circular_dependencies: results.circular_dependencies.len(),
250        re_export_cycles: results.re_export_cycles.len(),
251        boundary_violations: results.boundary_violations.len(),
252        stale_suppressions: results.stale_suppressions.len(),
253        unused_catalog_entries: results.unused_catalog_entries.len(),
254        empty_catalog_groups: results.empty_catalog_groups.len(),
255        unresolved_catalog_references: results.unresolved_catalog_references.len(),
256        unused_dependency_overrides: results.unused_dependency_overrides.len(),
257        misconfigured_dependency_overrides: results.misconfigured_dependency_overrides.len(),
258    }
259}
260
261/// Recursively strip the root prefix from all string values in the JSON tree.
262///
263/// This converts absolute paths (e.g., `/home/runner/work/repo/repo/src/utils.ts`)
264/// to relative paths (`src/utils.ts`) for all output fields.
265pub fn strip_root_prefix(value: &mut serde_json::Value, prefix: &str) {
266    match value {
267        serde_json::Value::String(s) => {
268            if let Some(rest) = s.strip_prefix(prefix) {
269                *s = rest.to_string();
270            } else {
271                let normalized = normalize_uri(s);
272                let normalized_prefix = normalize_uri(prefix);
273                if let Some(rest) = normalized.strip_prefix(&normalized_prefix) {
274                    *s = rest.to_string();
275                }
276            }
277        }
278        serde_json::Value::Array(arr) => {
279            for item in arr {
280                strip_root_prefix(item, prefix);
281            }
282        }
283        serde_json::Value::Object(map) => {
284            for (_, v) in map.iter_mut() {
285                strip_root_prefix(v, prefix);
286            }
287        }
288        _ => {}
289    }
290}
291
292type SuppressAnchor = (String, u64);
293
294#[allow(
295    clippy::redundant_pub_crate,
296    reason = "used through report module re-export by audit.rs"
297)]
298pub(crate) fn harmonize_multi_kind_suppress_line_actions(output: &mut serde_json::Value) {
299    let mut anchors: BTreeMap<SuppressAnchor, Vec<String>> = BTreeMap::new();
300    collect_suppress_line_anchors(output, &mut anchors);
301
302    anchors.retain(|_, kinds| {
303        sort_suppression_kinds(kinds);
304        kinds.dedup();
305        kinds.len() > 1
306    });
307    if anchors.is_empty() {
308        return;
309    }
310
311    rewrite_suppress_line_actions(output, &anchors);
312}
313
314fn collect_suppress_line_anchors(
315    value: &serde_json::Value,
316    anchors: &mut BTreeMap<SuppressAnchor, Vec<String>>,
317) {
318    match value {
319        serde_json::Value::Object(map) => {
320            if let Some(anchor) = suppression_anchor(map)
321                && let Some(actions) = map.get("actions").and_then(serde_json::Value::as_array)
322            {
323                for action in actions {
324                    if let Some(comment) = suppress_line_comment(action) {
325                        for kind in parse_suppress_line_comment(comment) {
326                            let kinds = anchors.entry(anchor.clone()).or_default();
327                            if !kinds.iter().any(|existing| existing == &kind) {
328                                kinds.push(kind);
329                            }
330                        }
331                    }
332                }
333            }
334
335            for child in map.values() {
336                collect_suppress_line_anchors(child, anchors);
337            }
338        }
339        serde_json::Value::Array(items) => {
340            for item in items {
341                collect_suppress_line_anchors(item, anchors);
342            }
343        }
344        _ => {}
345    }
346}
347
348fn rewrite_suppress_line_actions(
349    value: &mut serde_json::Value,
350    anchors: &BTreeMap<SuppressAnchor, Vec<String>>,
351) {
352    match value {
353        serde_json::Value::Object(map) => {
354            if let Some(anchor) = suppression_anchor(map)
355                && let Some(kinds) = anchors.get(&anchor)
356            {
357                let comment = format!("// fallow-ignore-next-line {}", kinds.join(", "));
358                if let Some(actions) = map
359                    .get_mut("actions")
360                    .and_then(serde_json::Value::as_array_mut)
361                {
362                    for action in actions {
363                        if suppress_line_comment(action).is_some()
364                            && let serde_json::Value::Object(action_map) = action
365                        {
366                            action_map.insert("comment".to_string(), serde_json::json!(comment));
367                        }
368                    }
369                }
370            }
371
372            for child in map.values_mut() {
373                rewrite_suppress_line_actions(child, anchors);
374            }
375        }
376        serde_json::Value::Array(items) => {
377            for item in items {
378                rewrite_suppress_line_actions(item, anchors);
379            }
380        }
381        _ => {}
382    }
383}
384
385fn suppression_anchor(map: &serde_json::Map<String, serde_json::Value>) -> Option<SuppressAnchor> {
386    let path = map
387        .get("path")
388        .or_else(|| map.get("from_path"))
389        .and_then(serde_json::Value::as_str)?;
390    let line = map.get("line").and_then(serde_json::Value::as_u64)?;
391    Some((path.to_string(), line))
392}
393
394fn suppress_line_comment(action: &serde_json::Value) -> Option<&str> {
395    (action.get("type").and_then(serde_json::Value::as_str) == Some("suppress-line"))
396        .then_some(())
397        .and_then(|()| action.get("comment").and_then(serde_json::Value::as_str))
398}
399
400fn parse_suppress_line_comment(comment: &str) -> Vec<String> {
401    comment
402        .strip_prefix("// fallow-ignore-next-line ")
403        .map(|rest| {
404            rest.split(|c: char| c == ',' || c.is_whitespace())
405                .filter(|token| !token.is_empty())
406                .map(str::to_string)
407                .collect()
408        })
409        .unwrap_or_default()
410}
411
412fn sort_suppression_kinds(kinds: &mut [String]) {
413    kinds.sort_by_key(|kind| suppression_kind_rank(kind));
414}
415
416fn suppression_kind_rank(kind: &str) -> usize {
417    match kind {
418        "unused-file" => 0,
419        "unused-export" => 1,
420        "unused-type" => 2,
421        "private-type-leak" => 3,
422        "unused-enum-member" => 4,
423        "unused-class-member" => 5,
424        "unresolved-import" => 6,
425        "unlisted-dependency" => 7,
426        "duplicate-export" => 8,
427        "circular-dependency" => 9,
428        "re-export-cycle" => 10,
429        "boundary-violation" => 11,
430        "code-duplication" => 12,
431        "complexity" => 13,
432        _ => usize::MAX,
433    }
434}
435
436// ── Health action injection ─────────────────────────────────────
437
438/// Build a JSON representation of baseline deltas for the combined JSON envelope.
439///
440/// Accepts a total delta and an iterator of per-category entries to avoid
441/// coupling the report module (compiled in both lib and bin) to the
442/// binary-only `baseline` module.
443pub fn build_baseline_deltas_json<'a>(
444    total_delta: i64,
445    per_category: impl Iterator<Item = (&'a str, usize, usize, i64)>,
446) -> serde_json::Value {
447    let mut per_cat = serde_json::Map::new();
448    for (cat, current, baseline, delta) in per_category {
449        per_cat.insert(
450            cat.to_string(),
451            serde_json::json!({
452                "current": current,
453                "baseline": baseline,
454                "delta": delta,
455            }),
456        );
457    }
458    serde_json::json!({
459        "total_delta": total_delta,
460        "per_category": per_cat
461    })
462}
463
464// Health JSON post-pass action injection has been fully retired.
465//
466// - Complexity findings carry their `actions` natively via the typed
467//   [`HealthFinding`](crate::health_types::HealthFinding) wrapper (PR B2
468//   of issue #384).
469// - Hotspots and refactoring targets carry their typed `actions` arrays
470//   via the [`HotspotFinding`](crate::health_types::HotspotFinding) and
471//   [`RefactoringTargetFinding`](crate::health_types::RefactoringTargetFinding)
472//   wrappers (PR B3 of issue #384).
473// - Coverage gaps flow through
474//   [`UntestedFileFinding`](crate::health_types::UntestedFileFinding) /
475//   [`UntestedExportFinding`](crate::health_types::UntestedExportFinding).
476// - Runtime coverage actions ship from the sidecar via serde directly.
477//
478// The `actions_meta` breadcrumb is set on the typed
479// [`HealthReport`](crate::health_types::HealthReport) at construction
480// time and flows through serde natively, so no post-pass walker is
481// needed for any health output anymore.
482
483// The dupes `actions` arrays are set on the typed `CloneGroupFinding`,
484// `CloneFamilyFinding`, and `AttributedCloneGroupFinding` wrappers in
485// `crate::output_dupes` at construction time and flow through serde
486// natively, so no post-pass walker is needed for any duplication output
487// anymore.
488
489/// Insert a `_meta` key into a JSON object value.
490fn insert_meta(output: &mut serde_json::Value, meta: serde_json::Value) {
491    if let serde_json::Value::Object(map) = output {
492        map.insert("_meta".to_string(), meta);
493    }
494}
495
496/// Build the JSON envelope + health payload shared by `print_health_json` and
497/// the CLI integration test suite. Exposed so snapshot tests can lock the
498/// on-the-wire shape without routing through stdout capture.
499///
500/// # Errors
501///
502/// Returns an error if the report cannot be serialized to JSON.
503pub fn build_health_json(
504    report: &crate::health_types::HealthReport,
505    root: &Path,
506    elapsed: Duration,
507    explain: bool,
508) -> Result<serde_json::Value, serde_json::Error> {
509    let envelope = HealthOutput {
510        schema_version: SchemaVersion(SCHEMA_VERSION),
511        version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
512        elapsed_ms: ElapsedMs(elapsed.as_millis() as u64),
513        report: report.clone(),
514        grouped_by: None,
515        groups: None,
516        meta: None,
517        workspace_diagnostics: crate::runtime_support::workspace_diagnostics_for(root),
518    };
519    let mut output = serde_json::to_value(&envelope)?;
520    let root_prefix = format!("{}/", root.display());
521    strip_root_prefix(&mut output, &root_prefix);
522    if explain {
523        insert_meta(&mut output, explain::health_meta());
524    }
525    Ok(output)
526}
527
528pub(super) fn print_health_json(
529    report: &crate::health_types::HealthReport,
530    root: &Path,
531    elapsed: Duration,
532    explain: bool,
533) -> ExitCode {
534    match build_health_json(report, root, elapsed, explain) {
535        Ok(output) => emit_json(&output, "JSON"),
536        Err(e) => {
537            eprintln!("Error: failed to serialize health report: {e}");
538            ExitCode::from(2)
539        }
540    }
541}
542
543/// Build a grouped health JSON envelope when `--group-by` is active.
544///
545/// The envelope keeps the active run's `summary`, `vital_signs`, and
546/// `health_score` at the top level (so consumers that ignore grouping still
547/// see meaningful aggregates) and adds:
548///
549/// - `grouped_by`: the resolver mode (`"package"`, `"owner"`, etc.).
550/// - `groups`: one entry per resolver bucket. Each entry carries its own
551///   `vital_signs`, `health_score`, `findings`, `file_scores` sorted by
552///   risk-aware triage concern, `hotspots`, `large_functions`, `targets`, plus
553///   `key`, `owners` (section mode), and the per-group `files_analyzed` /
554///   `functions_above_threshold` counts.
555///
556/// Paths inside groups are relativised the same way as the project-level
557/// payload.
558///
559/// # Errors
560///
561/// Returns an error if either the project report or any group cannot be
562/// serialised to JSON.
563pub fn build_grouped_health_json(
564    report: &crate::health_types::HealthReport,
565    grouping: &crate::health_types::HealthGrouping,
566    root: &Path,
567    elapsed: Duration,
568    explain: bool,
569) -> Result<serde_json::Value, serde_json::Error> {
570    let root_prefix = format!("{}/", root.display());
571    // Per-group sub-envelopes share the project-level suppression state:
572    // baseline-active and config-disabled apply uniformly, so each group's
573    // `actions` array honors the same opts AND each group emits its own
574    // `actions_meta` breadcrumb. The redundancy with the top-level breadcrumb
575    // is intentional: consumers that only walk the `groups` array (e.g.,
576    // per-team dashboards) still see the omission reason without needing to
577    // walk back up to the report root.
578    let envelope = HealthOutput {
579        schema_version: SchemaVersion(SCHEMA_VERSION),
580        version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
581        elapsed_ms: ElapsedMs(elapsed.as_millis() as u64),
582        report: report.clone(),
583        grouped_by: Some(group_by_mode_from_label(grouping.mode)),
584        // `groups` is serialised separately below so each entry can run
585        // through the path-stripping + actions injection passes (which only
586        // walk the top-level map).
587        groups: None,
588        meta: None,
589        workspace_diagnostics: crate::runtime_support::workspace_diagnostics_for(root),
590    };
591    let mut output = serde_json::to_value(&envelope)?;
592    strip_root_prefix(&mut output, &root_prefix);
593
594    let group_values: Vec<serde_json::Value> = grouping
595        .groups
596        .iter()
597        .map(|g| {
598            let mut value = serde_json::to_value(g)?;
599            strip_root_prefix(&mut value, &root_prefix);
600            Ok(value)
601        })
602        .collect::<Result<_, serde_json::Error>>()?;
603
604    if let serde_json::Value::Object(ref mut map) = output {
605        map.insert("groups".to_string(), serde_json::Value::Array(group_values));
606    }
607
608    if explain {
609        insert_meta(&mut output, explain::health_meta());
610    }
611
612    Ok(output)
613}
614
615pub(super) fn print_grouped_health_json(
616    report: &crate::health_types::HealthReport,
617    grouping: &crate::health_types::HealthGrouping,
618    root: &Path,
619    elapsed: Duration,
620    explain: bool,
621) -> ExitCode {
622    match build_grouped_health_json(report, grouping, root, elapsed, explain) {
623        Ok(output) => emit_json(&output, "JSON"),
624        Err(e) => {
625            eprintln!("Error: failed to serialize grouped health report: {e}");
626            ExitCode::from(2)
627        }
628    }
629}
630
631/// Build the JSON envelope + duplication payload shared by `print_duplication_json`
632/// and the programmatic API surface.
633///
634/// # Errors
635///
636/// Returns an error if the report cannot be serialized to JSON.
637pub fn build_duplication_json(
638    report: &DuplicationReport,
639    root: &Path,
640    elapsed: Duration,
641    explain: bool,
642) -> Result<serde_json::Value, serde_json::Error> {
643    let envelope = DupesOutput {
644        schema_version: SchemaVersion(SCHEMA_VERSION),
645        version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
646        elapsed_ms: ElapsedMs(elapsed.as_millis() as u64),
647        report: DupesReportPayload::from_report(report),
648        grouped_by: None,
649        total_issues: None,
650        groups: None,
651        meta: None,
652        workspace_diagnostics: crate::runtime_support::workspace_diagnostics_for(root),
653    };
654    let mut output = serde_json::to_value(&envelope)?;
655    let root_prefix = format!("{}/", root.display());
656    strip_root_prefix(&mut output, &root_prefix);
657
658    if explain {
659        insert_meta(&mut output, explain::dupes_meta());
660    }
661
662    Ok(output)
663}
664
665pub(super) fn print_duplication_json(
666    report: &DuplicationReport,
667    root: &Path,
668    elapsed: Duration,
669    explain: bool,
670) -> ExitCode {
671    match build_duplication_json(report, root, elapsed, explain) {
672        Ok(output) => emit_json(&output, "JSON"),
673        Err(e) => {
674            eprintln!("Error: failed to serialize duplication report: {e}");
675            ExitCode::from(2)
676        }
677    }
678}
679
680/// Build a grouped duplication JSON envelope when `--group-by` is active.
681///
682/// The envelope keeps the project-level duplication payload (`stats`,
683/// `clone_groups`, `clone_families`) at the top level so consumers that ignore
684/// grouping still see project-wide aggregates, and adds:
685///
686/// - `grouped_by`: the resolver mode (`"owner"`, `"directory"`, `"package"`,
687///   `"section"`).
688/// - `groups`: one entry per resolver bucket. Each entry carries its own
689///   per-group `stats` (dedup-aware, computed over the FULL group before
690///   `--top` truncation), `clone_groups` (each tagged with `primary_owner`
691///   and per-instance `owner`), and `clone_families`.
692///
693/// Paths inside groups are relativised the same way as the project-level
694/// payload via `strip_root_prefix`.
695///
696/// # Errors
697///
698/// Returns an error if either the project report or any group cannot be
699/// serialised to JSON.
700pub fn build_grouped_duplication_json(
701    report: &DuplicationReport,
702    grouping: &super::dupes_grouping::DuplicationGrouping,
703    root: &Path,
704    elapsed: Duration,
705    explain: bool,
706) -> Result<serde_json::Value, serde_json::Error> {
707    let root_prefix = format!("{}/", root.display());
708    // Mirror the grouped check / health envelopes which expose `total_issues`
709    // so MCP and CI consumers can read the same key across commands. For
710    // dupes the count is total clone groups (sum is preserved across
711    // grouping; each clone group is attributed to exactly one bucket).
712    let envelope = DupesOutput {
713        schema_version: SchemaVersion(SCHEMA_VERSION),
714        version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
715        elapsed_ms: ElapsedMs(elapsed.as_millis() as u64),
716        report: DupesReportPayload::from_report(report),
717        grouped_by: Some(group_by_mode_from_label(grouping.mode)),
718        total_issues: Some(report.clone_groups.len()),
719        // Per-group buckets are serialized separately below so each carries
720        // its own path-stripping; splice them in via the `Value` post-pass.
721        // Wrapper-level `actions[]` is already typed on each bucket's
722        // `clone_groups` / `clone_families`.
723        groups: None,
724        meta: None,
725        workspace_diagnostics: crate::runtime_support::workspace_diagnostics_for(root),
726    };
727    let mut output = serde_json::to_value(&envelope)?;
728    strip_root_prefix(&mut output, &root_prefix);
729
730    let group_values: Vec<serde_json::Value> = grouping
731        .groups
732        .iter()
733        .map(|g| {
734            let mut value = serde_json::to_value(g)?;
735            strip_root_prefix(&mut value, &root_prefix);
736            Ok(value)
737        })
738        .collect::<Result<_, serde_json::Error>>()?;
739
740    if let serde_json::Value::Object(ref mut map) = output {
741        map.insert("groups".to_string(), serde_json::Value::Array(group_values));
742    }
743
744    if explain {
745        insert_meta(&mut output, explain::dupes_meta());
746    }
747
748    Ok(output)
749}
750
751/// Map a free-form grouping mode label (`"package"`, `"owner"`, ...) to the
752/// typed [`GroupByMode`] enum. Unknown labels fall through to
753/// [`GroupByMode::Owner`] because the upstream grouping pipeline only emits
754/// the four documented modes; the fallback exists to keep this helper
755/// infallible without piping a Result through every grouped envelope builder.
756fn group_by_mode_from_label(label: &str) -> GroupByMode {
757    match label {
758        "directory" => GroupByMode::Directory,
759        "package" => GroupByMode::Package,
760        "section" => GroupByMode::Section,
761        _ => GroupByMode::Owner,
762    }
763}
764
765pub(super) fn print_grouped_duplication_json(
766    report: &DuplicationReport,
767    grouping: &super::dupes_grouping::DuplicationGrouping,
768    root: &Path,
769    elapsed: Duration,
770    explain: bool,
771) -> ExitCode {
772    match build_grouped_duplication_json(report, grouping, root, elapsed, explain) {
773        Ok(output) => emit_json(&output, "JSON"),
774        Err(e) => {
775            eprintln!("Error: failed to serialize grouped duplication report: {e}");
776            ExitCode::from(2)
777        }
778    }
779}
780
781pub(super) fn print_trace_json<T: serde::Serialize>(value: &T) {
782    match serde_json::to_string_pretty(value) {
783        Ok(json) => println!("{json}"),
784        Err(e) => {
785            eprintln!("Error: failed to serialize trace output: {e}");
786            #[expect(
787                clippy::exit,
788                reason = "fatal serialization error requires immediate exit"
789            )]
790            std::process::exit(2);
791        }
792    }
793}
794
795#[cfg(test)]
796mod tests {
797    use super::*;
798    use crate::health_types::{
799        RuntimeCoverageAction, RuntimeCoverageConfidence, RuntimeCoverageDataSource,
800        RuntimeCoverageEvidence, RuntimeCoverageFinding, RuntimeCoverageHotPath,
801        RuntimeCoverageMessage, RuntimeCoverageReport, RuntimeCoverageReportVerdict,
802        RuntimeCoverageSchemaVersion, RuntimeCoverageSummary, RuntimeCoverageVerdict,
803        RuntimeCoverageWatermark,
804    };
805    use crate::report::test_helpers::sample_results;
806    use fallow_core::extract::MemberKind;
807    use fallow_core::results::*;
808    use std::path::PathBuf;
809    use std::time::Duration;
810
811    #[test]
812    fn json_output_has_metadata_fields() {
813        let root = PathBuf::from("/project");
814        let results = AnalysisResults::default();
815        let elapsed = Duration::from_millis(123);
816        let output = build_json(&results, &root, elapsed).expect("should serialize");
817
818        assert_eq!(output["schema_version"], 6);
819        assert!(output["version"].is_string());
820        assert_eq!(output["elapsed_ms"], 123);
821        assert_eq!(output["total_issues"], 0);
822    }
823
824    #[test]
825    fn json_output_includes_issue_arrays() {
826        let root = PathBuf::from("/project");
827        let results = sample_results(&root);
828        let elapsed = Duration::from_millis(50);
829        let output = build_json(&results, &root, elapsed).expect("should serialize");
830
831        assert_eq!(output["unused_files"].as_array().unwrap().len(), 1);
832        assert_eq!(output["unused_exports"].as_array().unwrap().len(), 1);
833        assert_eq!(output["unused_types"].as_array().unwrap().len(), 1);
834        assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 1);
835        assert_eq!(
836            output["unused_dev_dependencies"].as_array().unwrap().len(),
837            1
838        );
839        assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 1);
840        assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 1);
841        assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 1);
842        assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 1);
843        assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 1);
844        assert_eq!(
845            output["type_only_dependencies"].as_array().unwrap().len(),
846            1
847        );
848        assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 1);
849    }
850
851    #[test]
852    fn health_json_includes_runtime_coverage_with_relative_paths_and_actions() {
853        let root = PathBuf::from("/project");
854        let report = crate::health_types::HealthReport {
855            runtime_coverage: Some(RuntimeCoverageReport {
856                schema_version: RuntimeCoverageSchemaVersion::V1,
857                verdict: RuntimeCoverageReportVerdict::ColdCodeDetected,
858                signals: Vec::new(),
859                summary: RuntimeCoverageSummary {
860                    data_source: RuntimeCoverageDataSource::Local,
861                    last_received_at: None,
862                    functions_tracked: 3,
863                    functions_hit: 1,
864                    functions_unhit: 1,
865                    functions_untracked: 1,
866                    coverage_percent: 33.3,
867                    trace_count: 2_847_291,
868                    period_days: 30,
869                    deployments_seen: 14,
870                    capture_quality: Some(crate::health_types::RuntimeCoverageCaptureQuality {
871                        window_seconds: 720,
872                        instances_observed: 1,
873                        lazy_parse_warning: true,
874                        untracked_ratio_percent: 42.5,
875                    }),
876                },
877                findings: vec![RuntimeCoverageFinding {
878                    id: "fallow:prod:deadbeef".to_owned(),
879                    stable_id: None,
880                    path: root.join("src/cold.ts"),
881                    function: "coldPath".to_owned(),
882                    line: 12,
883                    verdict: RuntimeCoverageVerdict::ReviewRequired,
884                    invocations: Some(0),
885                    confidence: RuntimeCoverageConfidence::Medium,
886                    evidence: RuntimeCoverageEvidence {
887                        static_status: "used".to_owned(),
888                        test_coverage: "not_covered".to_owned(),
889                        v8_tracking: "tracked".to_owned(),
890                        untracked_reason: None,
891                        observation_days: 30,
892                        deployments_observed: 14,
893                    },
894                    actions: vec![RuntimeCoverageAction {
895                        kind: "review-deletion".to_owned(),
896                        description: "Tracked in runtime coverage with zero invocations."
897                            .to_owned(),
898                        auto_fixable: false,
899                    }],
900                    source_hash: None,
901                }],
902                hot_paths: vec![RuntimeCoverageHotPath {
903                    id: "fallow:hot:cafebabe".to_owned(),
904                    stable_id: None,
905                    path: root.join("src/hot.ts"),
906                    function: "hotPath".to_owned(),
907                    line: 3,
908                    end_line: 9,
909                    invocations: 250,
910                    percentile: 99,
911                    actions: vec![],
912                }],
913                blast_radius: vec![],
914                importance: vec![],
915                watermark: Some(RuntimeCoverageWatermark::LicenseExpiredGrace),
916                warnings: vec![RuntimeCoverageMessage {
917                    code: "partial-merge".to_owned(),
918                    message: "Merged coverage omitted one chunk.".to_owned(),
919                }],
920            }),
921            ..Default::default()
922        };
923
924        let envelope = HealthOutput {
925            schema_version: SchemaVersion(SCHEMA_VERSION),
926            version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
927            elapsed_ms: ElapsedMs(7),
928            report,
929            grouped_by: None,
930            groups: None,
931            meta: None,
932            workspace_diagnostics: Vec::new(),
933        };
934        let mut output = serde_json::to_value(&envelope).expect("should serialize health envelope");
935        strip_root_prefix(&mut output, "/project/");
936
937        assert_eq!(
938            output["runtime_coverage"]["verdict"],
939            serde_json::Value::String("cold-code-detected".to_owned())
940        );
941        assert_eq!(
942            output["runtime_coverage"]["schema_version"],
943            serde_json::Value::String("1".to_owned())
944        );
945        assert_eq!(
946            output["runtime_coverage"]["summary"]["functions_tracked"],
947            serde_json::Value::from(3)
948        );
949        assert_eq!(
950            output["runtime_coverage"]["summary"]["coverage_percent"],
951            serde_json::Value::from(33.3)
952        );
953        let finding = &output["runtime_coverage"]["findings"][0];
954        assert_eq!(finding["path"], "src/cold.ts");
955        assert_eq!(finding["verdict"], "review_required");
956        assert_eq!(finding["id"], "fallow:prod:deadbeef");
957        assert_eq!(finding["actions"][0]["type"], "review-deletion");
958        let hot_path = &output["runtime_coverage"]["hot_paths"][0];
959        assert_eq!(hot_path["path"], "src/hot.ts");
960        assert_eq!(hot_path["function"], "hotPath");
961        assert_eq!(hot_path["percentile"], 99);
962        assert_eq!(
963            output["runtime_coverage"]["watermark"],
964            serde_json::Value::String("license-expired-grace".to_owned())
965        );
966        assert_eq!(
967            output["runtime_coverage"]["warnings"][0]["code"],
968            serde_json::Value::String("partial-merge".to_owned())
969        );
970    }
971
972    #[test]
973    fn json_metadata_fields_appear_first() {
974        let root = PathBuf::from("/project");
975        let results = AnalysisResults::default();
976        let elapsed = Duration::from_millis(0);
977        let output = build_json(&results, &root, elapsed).expect("should serialize");
978        let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
979        assert_eq!(keys[0], "schema_version");
980        assert_eq!(keys[1], "version");
981        assert_eq!(keys[2], "elapsed_ms");
982        assert_eq!(keys[3], "total_issues");
983    }
984
985    #[test]
986    fn json_total_issues_matches_results() {
987        let root = PathBuf::from("/project");
988        let results = sample_results(&root);
989        let total = results.total_issues();
990        let elapsed = Duration::from_millis(0);
991        let output = build_json(&results, &root, elapsed).expect("should serialize");
992
993        assert_eq!(output["total_issues"], total);
994    }
995
996    #[test]
997    fn json_unused_export_contains_expected_fields() {
998        let root = PathBuf::from("/project");
999        let mut results = AnalysisResults::default();
1000        results
1001            .unused_exports
1002            .push(UnusedExportFinding::with_actions(UnusedExport {
1003                path: root.join("src/utils.ts"),
1004                export_name: "helperFn".to_string(),
1005                is_type_only: false,
1006                line: 10,
1007                col: 4,
1008                span_start: 120,
1009                is_re_export: false,
1010            }));
1011        let elapsed = Duration::from_millis(0);
1012        let output = build_json(&results, &root, elapsed).expect("should serialize");
1013
1014        let export = &output["unused_exports"][0];
1015        assert_eq!(export["export_name"], "helperFn");
1016        assert_eq!(export["line"], 10);
1017        assert_eq!(export["col"], 4);
1018        assert_eq!(export["is_type_only"], false);
1019        assert_eq!(export["span_start"], 120);
1020        assert_eq!(export["is_re_export"], false);
1021    }
1022
1023    #[test]
1024    fn json_serializes_to_valid_json() {
1025        let root = PathBuf::from("/project");
1026        let results = sample_results(&root);
1027        let elapsed = Duration::from_millis(42);
1028        let output = build_json(&results, &root, elapsed).expect("should serialize");
1029
1030        let json_str = serde_json::to_string_pretty(&output).expect("should stringify");
1031        let reparsed: serde_json::Value =
1032            serde_json::from_str(&json_str).expect("JSON output should be valid JSON");
1033        assert_eq!(reparsed, output);
1034    }
1035
1036    // ── Empty results ───────────────────────────────────────────────
1037
1038    #[test]
1039    fn json_empty_results_produce_valid_structure() {
1040        let root = PathBuf::from("/project");
1041        let results = AnalysisResults::default();
1042        let elapsed = Duration::from_millis(0);
1043        let output = build_json(&results, &root, elapsed).expect("should serialize");
1044
1045        assert_eq!(output["total_issues"], 0);
1046        assert_eq!(output["unused_files"].as_array().unwrap().len(), 0);
1047        assert_eq!(output["unused_exports"].as_array().unwrap().len(), 0);
1048        assert_eq!(output["unused_types"].as_array().unwrap().len(), 0);
1049        assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 0);
1050        assert_eq!(
1051            output["unused_dev_dependencies"].as_array().unwrap().len(),
1052            0
1053        );
1054        assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 0);
1055        assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 0);
1056        assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 0);
1057        assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 0);
1058        assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 0);
1059        assert_eq!(
1060            output["type_only_dependencies"].as_array().unwrap().len(),
1061            0
1062        );
1063        assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 0);
1064    }
1065
1066    #[test]
1067    fn json_empty_results_round_trips_through_string() {
1068        let root = PathBuf::from("/project");
1069        let results = AnalysisResults::default();
1070        let elapsed = Duration::from_millis(0);
1071        let output = build_json(&results, &root, elapsed).expect("should serialize");
1072
1073        let json_str = serde_json::to_string(&output).expect("should stringify");
1074        let reparsed: serde_json::Value =
1075            serde_json::from_str(&json_str).expect("should parse back");
1076        assert_eq!(reparsed["total_issues"], 0);
1077    }
1078
1079    // ── Path stripping ──────────────────────────────────────────────
1080
1081    #[test]
1082    fn json_paths_are_relative_to_root() {
1083        let root = PathBuf::from("/project");
1084        let mut results = AnalysisResults::default();
1085        results
1086            .unused_files
1087            .push(UnusedFileFinding::with_actions(UnusedFile {
1088                path: root.join("src/deep/nested/file.ts"),
1089            }));
1090        let elapsed = Duration::from_millis(0);
1091        let output = build_json(&results, &root, elapsed).expect("should serialize");
1092
1093        let path = output["unused_files"][0]["path"].as_str().unwrap();
1094        assert_eq!(path, "src/deep/nested/file.ts");
1095        assert!(!path.starts_with("/project"));
1096    }
1097
1098    #[test]
1099    fn json_strips_root_from_nested_locations() {
1100        let root = PathBuf::from("/project");
1101        let mut results = AnalysisResults::default();
1102        results
1103            .unlisted_dependencies
1104            .push(UnlistedDependencyFinding::with_actions(
1105                UnlistedDependency {
1106                    package_name: "chalk".to_string(),
1107                    imported_from: vec![ImportSite {
1108                        path: root.join("src/cli.ts"),
1109                        line: 2,
1110                        col: 0,
1111                    }],
1112                },
1113            ));
1114        let elapsed = Duration::from_millis(0);
1115        let output = build_json(&results, &root, elapsed).expect("should serialize");
1116
1117        let site_path = output["unlisted_dependencies"][0]["imported_from"][0]["path"]
1118            .as_str()
1119            .unwrap();
1120        assert_eq!(site_path, "src/cli.ts");
1121    }
1122
1123    #[test]
1124    fn json_strips_root_from_duplicate_export_locations() {
1125        let root = PathBuf::from("/project");
1126        let mut results = AnalysisResults::default();
1127        results
1128            .duplicate_exports
1129            .push(DuplicateExportFinding::with_actions(DuplicateExport {
1130                export_name: "Config".to_string(),
1131                locations: vec![
1132                    DuplicateLocation {
1133                        path: root.join("src/config.ts"),
1134                        line: 15,
1135                        col: 0,
1136                    },
1137                    DuplicateLocation {
1138                        path: root.join("src/types.ts"),
1139                        line: 30,
1140                        col: 0,
1141                    },
1142                ],
1143            }));
1144        let elapsed = Duration::from_millis(0);
1145        let output = build_json(&results, &root, elapsed).expect("should serialize");
1146
1147        let loc0 = output["duplicate_exports"][0]["locations"][0]["path"]
1148            .as_str()
1149            .unwrap();
1150        let loc1 = output["duplicate_exports"][0]["locations"][1]["path"]
1151            .as_str()
1152            .unwrap();
1153        assert_eq!(loc0, "src/config.ts");
1154        assert_eq!(loc1, "src/types.ts");
1155    }
1156
1157    #[test]
1158    fn json_strips_root_from_circular_dependency_files() {
1159        let root = PathBuf::from("/project");
1160        let mut results = AnalysisResults::default();
1161        results
1162            .circular_dependencies
1163            .push(CircularDependencyFinding::with_actions(
1164                CircularDependency {
1165                    files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1166                    length: 2,
1167                    line: 1,
1168                    col: 0,
1169                    is_cross_package: false,
1170                },
1171            ));
1172        let elapsed = Duration::from_millis(0);
1173        let output = build_json(&results, &root, elapsed).expect("should serialize");
1174
1175        let files = output["circular_dependencies"][0]["files"]
1176            .as_array()
1177            .unwrap();
1178        assert_eq!(files[0].as_str().unwrap(), "src/a.ts");
1179        assert_eq!(files[1].as_str().unwrap(), "src/b.ts");
1180    }
1181
1182    #[test]
1183    fn json_path_outside_root_not_stripped() {
1184        let root = PathBuf::from("/project");
1185        let mut results = AnalysisResults::default();
1186        results
1187            .unused_files
1188            .push(UnusedFileFinding::with_actions(UnusedFile {
1189                path: PathBuf::from("/other/project/src/file.ts"),
1190            }));
1191        let elapsed = Duration::from_millis(0);
1192        let output = build_json(&results, &root, elapsed).expect("should serialize");
1193
1194        let path = output["unused_files"][0]["path"].as_str().unwrap();
1195        assert!(path.contains("/other/project/"));
1196    }
1197
1198    // ── Individual issue type field verification ────────────────────
1199
1200    #[test]
1201    fn json_unused_file_contains_path() {
1202        let root = PathBuf::from("/project");
1203        let mut results = AnalysisResults::default();
1204        results
1205            .unused_files
1206            .push(UnusedFileFinding::with_actions(UnusedFile {
1207                path: root.join("src/orphan.ts"),
1208            }));
1209        let elapsed = Duration::from_millis(0);
1210        let output = build_json(&results, &root, elapsed).expect("should serialize");
1211
1212        let file = &output["unused_files"][0];
1213        assert_eq!(file["path"], "src/orphan.ts");
1214    }
1215
1216    #[test]
1217    fn json_unused_type_contains_expected_fields() {
1218        let root = PathBuf::from("/project");
1219        let mut results = AnalysisResults::default();
1220        results
1221            .unused_types
1222            .push(UnusedTypeFinding::with_actions(UnusedExport {
1223                path: root.join("src/types.ts"),
1224                export_name: "OldInterface".to_string(),
1225                is_type_only: true,
1226                line: 20,
1227                col: 0,
1228                span_start: 300,
1229                is_re_export: false,
1230            }));
1231        let elapsed = Duration::from_millis(0);
1232        let output = build_json(&results, &root, elapsed).expect("should serialize");
1233
1234        let typ = &output["unused_types"][0];
1235        assert_eq!(typ["export_name"], "OldInterface");
1236        assert_eq!(typ["is_type_only"], true);
1237        assert_eq!(typ["line"], 20);
1238        assert_eq!(typ["path"], "src/types.ts");
1239    }
1240
1241    #[test]
1242    fn json_unused_dependency_contains_expected_fields() {
1243        let root = PathBuf::from("/project");
1244        let mut results = AnalysisResults::default();
1245        results
1246            .unused_dependencies
1247            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1248                package_name: "axios".to_string(),
1249                location: DependencyLocation::Dependencies,
1250                path: root.join("package.json"),
1251                line: 10,
1252                used_in_workspaces: Vec::new(),
1253            }));
1254        let elapsed = Duration::from_millis(0);
1255        let output = build_json(&results, &root, elapsed).expect("should serialize");
1256
1257        let dep = &output["unused_dependencies"][0];
1258        assert_eq!(dep["package_name"], "axios");
1259        assert_eq!(dep["line"], 10);
1260        assert!(dep.get("used_in_workspaces").is_none());
1261    }
1262
1263    #[test]
1264    fn json_unused_dependency_includes_cross_workspace_context() {
1265        let root = PathBuf::from("/project");
1266        let mut results = AnalysisResults::default();
1267        results
1268            .unused_dependencies
1269            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1270                package_name: "lodash-es".to_string(),
1271                location: DependencyLocation::Dependencies,
1272                path: root.join("packages/shared/package.json"),
1273                line: 6,
1274                used_in_workspaces: vec![root.join("packages/consumer")],
1275            }));
1276        let elapsed = Duration::from_millis(0);
1277        let output = build_json(&results, &root, elapsed).expect("should serialize");
1278
1279        let dep = &output["unused_dependencies"][0];
1280        assert_eq!(
1281            dep["used_in_workspaces"],
1282            serde_json::json!(["packages/consumer"])
1283        );
1284    }
1285
1286    #[test]
1287    fn json_unused_dev_dependency_contains_expected_fields() {
1288        let root = PathBuf::from("/project");
1289        let mut results = AnalysisResults::default();
1290        results
1291            .unused_dev_dependencies
1292            .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
1293                package_name: "vitest".to_string(),
1294                location: DependencyLocation::DevDependencies,
1295                path: root.join("package.json"),
1296                line: 15,
1297                used_in_workspaces: Vec::new(),
1298            }));
1299        let elapsed = Duration::from_millis(0);
1300        let output = build_json(&results, &root, elapsed).expect("should serialize");
1301
1302        let dep = &output["unused_dev_dependencies"][0];
1303        assert_eq!(dep["package_name"], "vitest");
1304    }
1305
1306    #[test]
1307    fn json_unused_optional_dependency_contains_expected_fields() {
1308        let root = PathBuf::from("/project");
1309        let mut results = AnalysisResults::default();
1310        results
1311            .unused_optional_dependencies
1312            .push(UnusedOptionalDependencyFinding::with_actions(
1313                UnusedDependency {
1314                    package_name: "fsevents".to_string(),
1315                    location: DependencyLocation::OptionalDependencies,
1316                    path: root.join("package.json"),
1317                    line: 12,
1318                    used_in_workspaces: Vec::new(),
1319                },
1320            ));
1321        let elapsed = Duration::from_millis(0);
1322        let output = build_json(&results, &root, elapsed).expect("should serialize");
1323
1324        let dep = &output["unused_optional_dependencies"][0];
1325        assert_eq!(dep["package_name"], "fsevents");
1326        assert_eq!(output["total_issues"], 1);
1327    }
1328
1329    #[test]
1330    fn json_unused_enum_member_contains_expected_fields() {
1331        let root = PathBuf::from("/project");
1332        let mut results = AnalysisResults::default();
1333        results
1334            .unused_enum_members
1335            .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
1336                path: root.join("src/enums.ts"),
1337                parent_name: "Color".to_string(),
1338                member_name: "Purple".to_string(),
1339                kind: MemberKind::EnumMember,
1340                line: 5,
1341                col: 2,
1342            }));
1343        let elapsed = Duration::from_millis(0);
1344        let output = build_json(&results, &root, elapsed).expect("should serialize");
1345
1346        let member = &output["unused_enum_members"][0];
1347        assert_eq!(member["parent_name"], "Color");
1348        assert_eq!(member["member_name"], "Purple");
1349        assert_eq!(member["line"], 5);
1350        assert_eq!(member["path"], "src/enums.ts");
1351    }
1352
1353    #[test]
1354    fn json_unused_class_member_contains_expected_fields() {
1355        let root = PathBuf::from("/project");
1356        let mut results = AnalysisResults::default();
1357        results
1358            .unused_class_members
1359            .push(UnusedClassMemberFinding::with_actions(UnusedMember {
1360                path: root.join("src/api.ts"),
1361                parent_name: "ApiClient".to_string(),
1362                member_name: "deprecatedFetch".to_string(),
1363                kind: MemberKind::ClassMethod,
1364                line: 100,
1365                col: 4,
1366            }));
1367        let elapsed = Duration::from_millis(0);
1368        let output = build_json(&results, &root, elapsed).expect("should serialize");
1369
1370        let member = &output["unused_class_members"][0];
1371        assert_eq!(member["parent_name"], "ApiClient");
1372        assert_eq!(member["member_name"], "deprecatedFetch");
1373        assert_eq!(member["line"], 100);
1374    }
1375
1376    #[test]
1377    fn json_unresolved_import_contains_expected_fields() {
1378        let root = PathBuf::from("/project");
1379        let mut results = AnalysisResults::default();
1380        results
1381            .unresolved_imports
1382            .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
1383                path: root.join("src/app.ts"),
1384                specifier: "@acme/missing-pkg".to_string(),
1385                line: 7,
1386                col: 0,
1387                specifier_col: 0,
1388            }));
1389        let elapsed = Duration::from_millis(0);
1390        let output = build_json(&results, &root, elapsed).expect("should serialize");
1391
1392        let import = &output["unresolved_imports"][0];
1393        assert_eq!(import["specifier"], "@acme/missing-pkg");
1394        assert_eq!(import["line"], 7);
1395        assert_eq!(import["path"], "src/app.ts");
1396    }
1397
1398    #[test]
1399    fn json_unlisted_dependency_contains_import_sites() {
1400        let root = PathBuf::from("/project");
1401        let mut results = AnalysisResults::default();
1402        results
1403            .unlisted_dependencies
1404            .push(UnlistedDependencyFinding::with_actions(
1405                UnlistedDependency {
1406                    package_name: "dotenv".to_string(),
1407                    imported_from: vec![
1408                        ImportSite {
1409                            path: root.join("src/config.ts"),
1410                            line: 1,
1411                            col: 0,
1412                        },
1413                        ImportSite {
1414                            path: root.join("src/server.ts"),
1415                            line: 3,
1416                            col: 0,
1417                        },
1418                    ],
1419                },
1420            ));
1421        let elapsed = Duration::from_millis(0);
1422        let output = build_json(&results, &root, elapsed).expect("should serialize");
1423
1424        let dep = &output["unlisted_dependencies"][0];
1425        assert_eq!(dep["package_name"], "dotenv");
1426        let sites = dep["imported_from"].as_array().unwrap();
1427        assert_eq!(sites.len(), 2);
1428        assert_eq!(sites[0]["path"], "src/config.ts");
1429        assert_eq!(sites[1]["path"], "src/server.ts");
1430    }
1431
1432    #[test]
1433    fn json_duplicate_export_contains_locations() {
1434        let root = PathBuf::from("/project");
1435        let mut results = AnalysisResults::default();
1436        results
1437            .duplicate_exports
1438            .push(DuplicateExportFinding::with_actions(DuplicateExport {
1439                export_name: "Button".to_string(),
1440                locations: vec![
1441                    DuplicateLocation {
1442                        path: root.join("src/ui.ts"),
1443                        line: 10,
1444                        col: 0,
1445                    },
1446                    DuplicateLocation {
1447                        path: root.join("src/components.ts"),
1448                        line: 25,
1449                        col: 0,
1450                    },
1451                ],
1452            }));
1453        let elapsed = Duration::from_millis(0);
1454        let output = build_json(&results, &root, elapsed).expect("should serialize");
1455
1456        let dup = &output["duplicate_exports"][0];
1457        assert_eq!(dup["export_name"], "Button");
1458        let locs = dup["locations"].as_array().unwrap();
1459        assert_eq!(locs.len(), 2);
1460        assert_eq!(locs[0]["line"], 10);
1461        assert_eq!(locs[1]["line"], 25);
1462    }
1463
1464    #[test]
1465    fn duplicate_export_add_to_config_is_auto_fixable_when_config_exists() {
1466        let dir = tempfile::tempdir().unwrap();
1467        let root = dir.path();
1468        std::fs::write(root.join(".fallowrc.json"), "{}\n").unwrap();
1469        let mut results = AnalysisResults::default();
1470        results
1471            .duplicate_exports
1472            .push(DuplicateExportFinding::with_actions(DuplicateExport {
1473                export_name: "Button".to_string(),
1474                locations: vec![
1475                    DuplicateLocation {
1476                        path: root.join("src/ui.ts"),
1477                        line: 10,
1478                        col: 0,
1479                    },
1480                    DuplicateLocation {
1481                        path: root.join("src/components.ts"),
1482                        line: 25,
1483                        col: 0,
1484                    },
1485                ],
1486            }));
1487
1488        let output = build_json(&results, root, Duration::ZERO).unwrap();
1489        let actions = output["duplicate_exports"][0]["actions"]
1490            .as_array()
1491            .unwrap();
1492        assert_eq!(actions[0]["type"], "add-to-config");
1493        assert_eq!(actions[0]["auto_fixable"], true);
1494    }
1495
1496    #[test]
1497    fn duplicate_export_add_to_config_is_auto_fixable_when_create_fallback_allowed() {
1498        // No config file exists, and the dir is NOT inside a monorepo
1499        // subpackage, so `fallow fix` can safely create one. The action
1500        // must surface as auto_fixable: true (per the per-instance flip
1501        // documented on AddToConfigAction).
1502        let dir = tempfile::tempdir().unwrap();
1503        let root = dir.path();
1504        let mut results = AnalysisResults::default();
1505        results
1506            .duplicate_exports
1507            .push(DuplicateExportFinding::with_actions(DuplicateExport {
1508                export_name: "Button".to_string(),
1509                locations: vec![
1510                    DuplicateLocation {
1511                        path: root.join("src/ui.ts"),
1512                        line: 10,
1513                        col: 0,
1514                    },
1515                    DuplicateLocation {
1516                        path: root.join("src/components.ts"),
1517                        line: 25,
1518                        col: 0,
1519                    },
1520                ],
1521            }));
1522
1523        let output = build_json(&results, root, Duration::ZERO).unwrap();
1524        let actions = output["duplicate_exports"][0]["actions"]
1525            .as_array()
1526            .unwrap();
1527        assert_eq!(actions[0]["type"], "add-to-config");
1528        assert_eq!(actions[0]["auto_fixable"], true);
1529    }
1530
1531    #[test]
1532    fn duplicate_export_add_to_config_is_not_auto_fixable_in_monorepo_subpackage() {
1533        // A workspace marker above the invocation directory means
1534        // `fallow fix` would refuse to create a per-subpackage config.
1535        // The action must surface as auto_fixable: false so agents and
1536        // IDEs don't blindly pipe into `fallow fix --yes`.
1537        let dir = tempfile::tempdir().unwrap();
1538        let workspace = dir.path();
1539        std::fs::write(
1540            workspace.join("pnpm-workspace.yaml"),
1541            "packages:\n  - 'packages/*'\n",
1542        )
1543        .unwrap();
1544        let sub = workspace.join("packages/ui");
1545        std::fs::create_dir_all(&sub).unwrap();
1546        let mut results = AnalysisResults::default();
1547        results
1548            .duplicate_exports
1549            .push(DuplicateExportFinding::with_actions(DuplicateExport {
1550                export_name: "Button".to_string(),
1551                locations: vec![
1552                    DuplicateLocation {
1553                        path: sub.join("src/ui.ts"),
1554                        line: 10,
1555                        col: 0,
1556                    },
1557                    DuplicateLocation {
1558                        path: sub.join("src/components.ts"),
1559                        line: 25,
1560                        col: 0,
1561                    },
1562                ],
1563            }));
1564
1565        let output = build_json(&results, &sub, Duration::ZERO).unwrap();
1566        let actions = output["duplicate_exports"][0]["actions"]
1567            .as_array()
1568            .unwrap();
1569        assert_eq!(actions[0]["type"], "add-to-config");
1570        assert_eq!(actions[0]["auto_fixable"], false);
1571    }
1572
1573    #[test]
1574    fn json_type_only_dependency_contains_expected_fields() {
1575        let root = PathBuf::from("/project");
1576        let mut results = AnalysisResults::default();
1577        results
1578            .type_only_dependencies
1579            .push(TypeOnlyDependencyFinding::with_actions(
1580                TypeOnlyDependency {
1581                    package_name: "zod".to_string(),
1582                    path: root.join("package.json"),
1583                    line: 8,
1584                },
1585            ));
1586        let elapsed = Duration::from_millis(0);
1587        let output = build_json(&results, &root, elapsed).expect("should serialize");
1588
1589        let dep = &output["type_only_dependencies"][0];
1590        assert_eq!(dep["package_name"], "zod");
1591        assert_eq!(dep["line"], 8);
1592    }
1593
1594    #[test]
1595    fn json_circular_dependency_contains_expected_fields() {
1596        let root = PathBuf::from("/project");
1597        let mut results = AnalysisResults::default();
1598        results
1599            .circular_dependencies
1600            .push(CircularDependencyFinding::with_actions(
1601                CircularDependency {
1602                    files: vec![
1603                        root.join("src/a.ts"),
1604                        root.join("src/b.ts"),
1605                        root.join("src/c.ts"),
1606                    ],
1607                    length: 3,
1608                    line: 5,
1609                    col: 0,
1610                    is_cross_package: false,
1611                },
1612            ));
1613        let elapsed = Duration::from_millis(0);
1614        let output = build_json(&results, &root, elapsed).expect("should serialize");
1615
1616        let cycle = &output["circular_dependencies"][0];
1617        assert_eq!(cycle["length"], 3);
1618        assert_eq!(cycle["line"], 5);
1619        let files = cycle["files"].as_array().unwrap();
1620        assert_eq!(files.len(), 3);
1621    }
1622
1623    // ── Re-export tagging ───────────────────────────────────────────
1624
1625    #[test]
1626    fn json_re_export_flagged_correctly() {
1627        let root = PathBuf::from("/project");
1628        let mut results = AnalysisResults::default();
1629        results
1630            .unused_exports
1631            .push(UnusedExportFinding::with_actions(UnusedExport {
1632                path: root.join("src/index.ts"),
1633                export_name: "reExported".to_string(),
1634                is_type_only: false,
1635                line: 1,
1636                col: 0,
1637                span_start: 0,
1638                is_re_export: true,
1639            }));
1640        let elapsed = Duration::from_millis(0);
1641        let output = build_json(&results, &root, elapsed).expect("should serialize");
1642
1643        assert_eq!(output["unused_exports"][0]["is_re_export"], true);
1644    }
1645
1646    // ── Schema version stability ────────────────────────────────────
1647
1648    #[test]
1649    fn json_schema_version_is_pinned() {
1650        let root = PathBuf::from("/project");
1651        let results = AnalysisResults::default();
1652        let elapsed = Duration::from_millis(0);
1653        let output = build_json(&results, &root, elapsed).expect("should serialize");
1654
1655        assert_eq!(output["schema_version"], SCHEMA_VERSION);
1656        assert_eq!(output["schema_version"], 6);
1657    }
1658
1659    // ── Version string ──────────────────────────────────────────────
1660
1661    #[test]
1662    fn json_version_matches_cargo_pkg_version() {
1663        let root = PathBuf::from("/project");
1664        let results = AnalysisResults::default();
1665        let elapsed = Duration::from_millis(0);
1666        let output = build_json(&results, &root, elapsed).expect("should serialize");
1667
1668        assert_eq!(output["version"], env!("CARGO_PKG_VERSION"));
1669    }
1670
1671    // ── Elapsed time encoding ───────────────────────────────────────
1672
1673    #[test]
1674    fn json_elapsed_ms_zero_duration() {
1675        let root = PathBuf::from("/project");
1676        let results = AnalysisResults::default();
1677        let output = build_json(&results, &root, Duration::ZERO).expect("should serialize");
1678
1679        assert_eq!(output["elapsed_ms"], 0);
1680    }
1681
1682    #[test]
1683    fn json_elapsed_ms_large_duration() {
1684        let root = PathBuf::from("/project");
1685        let results = AnalysisResults::default();
1686        let elapsed = Duration::from_mins(2);
1687        let output = build_json(&results, &root, elapsed).expect("should serialize");
1688
1689        assert_eq!(output["elapsed_ms"], 120_000);
1690    }
1691
1692    #[test]
1693    fn json_elapsed_ms_sub_millisecond_truncated() {
1694        let root = PathBuf::from("/project");
1695        let results = AnalysisResults::default();
1696        // 500 microseconds = 0 milliseconds (truncated)
1697        let elapsed = Duration::from_micros(500);
1698        let output = build_json(&results, &root, elapsed).expect("should serialize");
1699
1700        assert_eq!(output["elapsed_ms"], 0);
1701    }
1702
1703    // ── Multiple issues of same type ────────────────────────────────
1704
1705    #[test]
1706    fn json_multiple_unused_files() {
1707        let root = PathBuf::from("/project");
1708        let mut results = AnalysisResults::default();
1709        results
1710            .unused_files
1711            .push(UnusedFileFinding::with_actions(UnusedFile {
1712                path: root.join("src/a.ts"),
1713            }));
1714        results
1715            .unused_files
1716            .push(UnusedFileFinding::with_actions(UnusedFile {
1717                path: root.join("src/b.ts"),
1718            }));
1719        results
1720            .unused_files
1721            .push(UnusedFileFinding::with_actions(UnusedFile {
1722                path: root.join("src/c.ts"),
1723            }));
1724        let elapsed = Duration::from_millis(0);
1725        let output = build_json(&results, &root, elapsed).expect("should serialize");
1726
1727        assert_eq!(output["unused_files"].as_array().unwrap().len(), 3);
1728        assert_eq!(output["total_issues"], 3);
1729    }
1730
1731    // ── strip_root_prefix unit tests ────────────────────────────────
1732
1733    #[test]
1734    fn strip_root_prefix_on_string_value() {
1735        let mut value = serde_json::json!("/project/src/file.ts");
1736        strip_root_prefix(&mut value, "/project/");
1737        assert_eq!(value, "src/file.ts");
1738    }
1739
1740    #[test]
1741    fn strip_root_prefix_leaves_non_matching_string() {
1742        let mut value = serde_json::json!("/other/src/file.ts");
1743        strip_root_prefix(&mut value, "/project/");
1744        assert_eq!(value, "/other/src/file.ts");
1745    }
1746
1747    #[test]
1748    fn strip_root_prefix_recurses_into_arrays() {
1749        let mut value = serde_json::json!(["/project/a.ts", "/project/b.ts", "/other/c.ts"]);
1750        strip_root_prefix(&mut value, "/project/");
1751        assert_eq!(value[0], "a.ts");
1752        assert_eq!(value[1], "b.ts");
1753        assert_eq!(value[2], "/other/c.ts");
1754    }
1755
1756    #[test]
1757    fn strip_root_prefix_recurses_into_nested_objects() {
1758        let mut value = serde_json::json!({
1759            "outer": {
1760                "path": "/project/src/nested.ts"
1761            }
1762        });
1763        strip_root_prefix(&mut value, "/project/");
1764        assert_eq!(value["outer"]["path"], "src/nested.ts");
1765    }
1766
1767    #[test]
1768    fn strip_root_prefix_leaves_numbers_and_booleans() {
1769        let mut value = serde_json::json!({
1770            "line": 42,
1771            "is_type_only": false,
1772            "path": "/project/src/file.ts"
1773        });
1774        strip_root_prefix(&mut value, "/project/");
1775        assert_eq!(value["line"], 42);
1776        assert_eq!(value["is_type_only"], false);
1777        assert_eq!(value["path"], "src/file.ts");
1778    }
1779
1780    #[test]
1781    fn strip_root_prefix_normalizes_windows_separators() {
1782        let mut value = serde_json::json!(r"/project\src\file.ts");
1783        strip_root_prefix(&mut value, "/project/");
1784        assert_eq!(value, "src/file.ts");
1785    }
1786
1787    #[test]
1788    fn strip_root_prefix_handles_empty_string_after_strip() {
1789        // Edge case: the string IS the prefix (without trailing content).
1790        // This shouldn't happen in practice but should not panic.
1791        let mut value = serde_json::json!("/project/");
1792        strip_root_prefix(&mut value, "/project/");
1793        assert_eq!(value, "");
1794    }
1795
1796    #[test]
1797    fn strip_root_prefix_deeply_nested_array_of_objects() {
1798        let mut value = serde_json::json!({
1799            "groups": [{
1800                "instances": [{
1801                    "file": "/project/src/a.ts"
1802                }, {
1803                    "file": "/project/src/b.ts"
1804                }]
1805            }]
1806        });
1807        strip_root_prefix(&mut value, "/project/");
1808        assert_eq!(value["groups"][0]["instances"][0]["file"], "src/a.ts");
1809        assert_eq!(value["groups"][0]["instances"][1]["file"], "src/b.ts");
1810    }
1811
1812    // ── Full sample results round-trip ──────────────────────────────
1813
1814    #[test]
1815    fn json_full_sample_results_total_issues_correct() {
1816        let root = PathBuf::from("/project");
1817        let results = sample_results(&root);
1818        let elapsed = Duration::from_millis(100);
1819        let output = build_json(&results, &root, elapsed).expect("should serialize");
1820
1821        // sample_results adds one of each issue type (12 total).
1822        // unused_files + unused_exports + unused_types + unused_dependencies
1823        // + unused_dev_dependencies + unused_enum_members + unused_class_members
1824        // + unresolved_imports + unlisted_dependencies + duplicate_exports
1825        // + type_only_dependencies + circular_dependencies
1826        assert_eq!(output["total_issues"], results.total_issues());
1827    }
1828
1829    #[test]
1830    fn json_full_sample_no_absolute_paths_in_output() {
1831        let root = PathBuf::from("/project");
1832        let results = sample_results(&root);
1833        let elapsed = Duration::from_millis(0);
1834        let output = build_json(&results, &root, elapsed).expect("should serialize");
1835
1836        let json_str = serde_json::to_string(&output).expect("should stringify");
1837        // The root prefix should be stripped from all paths.
1838        assert!(!json_str.contains("/project/src/"));
1839        assert!(!json_str.contains("/project/package.json"));
1840    }
1841
1842    // ── JSON output is deterministic ────────────────────────────────
1843
1844    #[test]
1845    fn json_output_is_deterministic() {
1846        let root = PathBuf::from("/project");
1847        let results = sample_results(&root);
1848        let elapsed = Duration::from_millis(50);
1849
1850        let output1 = build_json(&results, &root, elapsed).expect("first build");
1851        let output2 = build_json(&results, &root, elapsed).expect("second build");
1852
1853        assert_eq!(output1, output2);
1854    }
1855
1856    // ── Metadata not overwritten by results fields ──────────────────
1857
1858    #[test]
1859    fn json_results_fields_do_not_shadow_metadata() {
1860        // Ensure that serialized results don't contain keys like "schema_version"
1861        // that could overwrite the metadata fields we insert first.
1862        let root = PathBuf::from("/project");
1863        let results = AnalysisResults::default();
1864        let elapsed = Duration::from_millis(99);
1865        let output = build_json(&results, &root, elapsed).expect("should serialize");
1866
1867        // Metadata should reflect our explicit values, not anything from AnalysisResults.
1868        assert_eq!(output["schema_version"], 6);
1869        assert_eq!(output["elapsed_ms"], 99);
1870    }
1871
1872    // ── All 14 issue type arrays present ────────────────────────────
1873
1874    #[test]
1875    fn json_all_issue_type_arrays_present_in_empty_results() {
1876        let root = PathBuf::from("/project");
1877        let results = AnalysisResults::default();
1878        let elapsed = Duration::from_millis(0);
1879        let output = build_json(&results, &root, elapsed).expect("should serialize");
1880
1881        let expected_arrays = [
1882            "unused_files",
1883            "unused_exports",
1884            "unused_types",
1885            "unused_dependencies",
1886            "unused_dev_dependencies",
1887            "unused_optional_dependencies",
1888            "unused_enum_members",
1889            "unused_class_members",
1890            "unresolved_imports",
1891            "unlisted_dependencies",
1892            "duplicate_exports",
1893            "type_only_dependencies",
1894            "test_only_dependencies",
1895            "circular_dependencies",
1896        ];
1897        for key in &expected_arrays {
1898            assert!(
1899                output[key].is_array(),
1900                "expected '{key}' to be an array in JSON output"
1901            );
1902        }
1903    }
1904
1905    // ── insert_meta ─────────────────────────────────────────────────
1906
1907    #[test]
1908    fn insert_meta_adds_key_to_object() {
1909        let mut output = serde_json::json!({ "foo": 1 });
1910        let meta = serde_json::json!({ "docs": "https://example.com" });
1911        insert_meta(&mut output, meta.clone());
1912        assert_eq!(output["_meta"], meta);
1913    }
1914
1915    #[test]
1916    fn insert_meta_noop_on_non_object() {
1917        let mut output = serde_json::json!([1, 2, 3]);
1918        let meta = serde_json::json!({ "docs": "https://example.com" });
1919        insert_meta(&mut output, meta);
1920        // Should not panic or add anything
1921        assert!(output.is_array());
1922    }
1923
1924    #[test]
1925    fn insert_meta_overwrites_existing_meta() {
1926        let mut output = serde_json::json!({ "_meta": "old" });
1927        let meta = serde_json::json!({ "new": true });
1928        insert_meta(&mut output, meta.clone());
1929        assert_eq!(output["_meta"], meta);
1930    }
1931
1932    // ── strip_root_prefix with null value ──
1933
1934    #[test]
1935    fn strip_root_prefix_null_unchanged() {
1936        let mut value = serde_json::Value::Null;
1937        strip_root_prefix(&mut value, "/project/");
1938        assert!(value.is_null());
1939    }
1940
1941    // ── strip_root_prefix with empty string ──
1942
1943    #[test]
1944    fn strip_root_prefix_empty_string() {
1945        let mut value = serde_json::json!("");
1946        strip_root_prefix(&mut value, "/project/");
1947        assert_eq!(value, "");
1948    }
1949
1950    // ── strip_root_prefix on mixed nested structure ──
1951
1952    #[test]
1953    fn strip_root_prefix_mixed_types() {
1954        let mut value = serde_json::json!({
1955            "path": "/project/src/file.ts",
1956            "line": 42,
1957            "flag": true,
1958            "nested": {
1959                "items": ["/project/a.ts", 99, null, "/project/b.ts"],
1960                "deep": { "path": "/project/c.ts" }
1961            }
1962        });
1963        strip_root_prefix(&mut value, "/project/");
1964        assert_eq!(value["path"], "src/file.ts");
1965        assert_eq!(value["line"], 42);
1966        assert_eq!(value["flag"], true);
1967        assert_eq!(value["nested"]["items"][0], "a.ts");
1968        assert_eq!(value["nested"]["items"][1], 99);
1969        assert!(value["nested"]["items"][2].is_null());
1970        assert_eq!(value["nested"]["items"][3], "b.ts");
1971        assert_eq!(value["nested"]["deep"]["path"], "c.ts");
1972    }
1973
1974    // ── JSON with explain meta for check ──
1975
1976    #[test]
1977    fn json_check_meta_integrates_correctly() {
1978        let root = PathBuf::from("/project");
1979        let results = AnalysisResults::default();
1980        let elapsed = Duration::from_millis(0);
1981        let mut output = build_json(&results, &root, elapsed).expect("should serialize");
1982        insert_meta(&mut output, crate::explain::check_meta());
1983
1984        assert!(output["_meta"]["docs"].is_string());
1985        assert!(output["_meta"]["rules"].is_object());
1986    }
1987
1988    // ── JSON unused member kind serialization ──
1989
1990    #[test]
1991    fn json_unused_member_kind_serialized() {
1992        let root = PathBuf::from("/project");
1993        let mut results = AnalysisResults::default();
1994        results
1995            .unused_enum_members
1996            .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
1997                path: root.join("src/enums.ts"),
1998                parent_name: "Color".to_string(),
1999                member_name: "Red".to_string(),
2000                kind: MemberKind::EnumMember,
2001                line: 3,
2002                col: 2,
2003            }));
2004        results
2005            .unused_class_members
2006            .push(UnusedClassMemberFinding::with_actions(UnusedMember {
2007                path: root.join("src/class.ts"),
2008                parent_name: "Foo".to_string(),
2009                member_name: "bar".to_string(),
2010                kind: MemberKind::ClassMethod,
2011                line: 10,
2012                col: 4,
2013            }));
2014
2015        let elapsed = Duration::from_millis(0);
2016        let output = build_json(&results, &root, elapsed).expect("should serialize");
2017
2018        let enum_member = &output["unused_enum_members"][0];
2019        assert!(enum_member["kind"].is_string());
2020        let class_member = &output["unused_class_members"][0];
2021        assert!(class_member["kind"].is_string());
2022    }
2023
2024    // ── Actions injection ──────────────────────────────────────────
2025
2026    #[test]
2027    fn json_unused_export_has_actions() {
2028        let root = PathBuf::from("/project");
2029        let mut results = AnalysisResults::default();
2030        results
2031            .unused_exports
2032            .push(UnusedExportFinding::with_actions(UnusedExport {
2033                path: root.join("src/utils.ts"),
2034                export_name: "helperFn".to_string(),
2035                is_type_only: false,
2036                line: 10,
2037                col: 4,
2038                span_start: 120,
2039                is_re_export: false,
2040            }));
2041        let output = build_json(&results, &root, Duration::ZERO).unwrap();
2042
2043        let actions = output["unused_exports"][0]["actions"].as_array().unwrap();
2044        assert_eq!(actions.len(), 2);
2045
2046        // Fix action
2047        assert_eq!(actions[0]["type"], "remove-export");
2048        assert_eq!(actions[0]["auto_fixable"], true);
2049        assert!(actions[0].get("note").is_none());
2050
2051        // Suppress action
2052        assert_eq!(actions[1]["type"], "suppress-line");
2053        assert_eq!(
2054            actions[1]["comment"],
2055            "// fallow-ignore-next-line unused-export"
2056        );
2057    }
2058
2059    #[test]
2060    fn json_same_line_findings_share_multi_kind_suppression_comment() {
2061        let root = PathBuf::from("/project");
2062        let mut results = AnalysisResults::default();
2063        results
2064            .unused_exports
2065            .push(UnusedExportFinding::with_actions(UnusedExport {
2066                path: root.join("src/api.ts"),
2067                export_name: "helperFn".to_string(),
2068                is_type_only: false,
2069                line: 10,
2070                col: 4,
2071                span_start: 120,
2072                is_re_export: false,
2073            }));
2074        results
2075            .unused_types
2076            .push(UnusedTypeFinding::with_actions(UnusedExport {
2077                path: root.join("src/api.ts"),
2078                export_name: "OldType".to_string(),
2079                is_type_only: true,
2080                line: 10,
2081                col: 0,
2082                span_start: 60,
2083                is_re_export: false,
2084            }));
2085        let output = build_json(&results, &root, Duration::ZERO).unwrap();
2086
2087        let export_actions = output["unused_exports"][0]["actions"].as_array().unwrap();
2088        let type_actions = output["unused_types"][0]["actions"].as_array().unwrap();
2089        assert_eq!(
2090            export_actions[1]["comment"],
2091            "// fallow-ignore-next-line unused-export, unused-type"
2092        );
2093        assert_eq!(
2094            type_actions[1]["comment"],
2095            "// fallow-ignore-next-line unused-export, unused-type"
2096        );
2097    }
2098
2099    #[test]
2100    fn audit_like_json_shares_suppression_comment_across_dead_code_and_complexity() {
2101        let mut output = serde_json::json!({
2102            "dead_code": {
2103                "unused_exports": [{
2104                    "path": "src/main.ts",
2105                    "line": 1,
2106                    "actions": [
2107                        { "type": "remove-export", "auto_fixable": true },
2108                        {
2109                            "type": "suppress-line",
2110                            "auto_fixable": false,
2111                            "comment": "// fallow-ignore-next-line unused-export"
2112                        }
2113                    ]
2114                }]
2115            },
2116            "complexity": {
2117                "findings": [{
2118                    "path": "src/main.ts",
2119                    "line": 1,
2120                    "actions": [
2121                        { "type": "refactor-function", "auto_fixable": false },
2122                        {
2123                            "type": "suppress-line",
2124                            "auto_fixable": false,
2125                            "comment": "// fallow-ignore-next-line complexity"
2126                        }
2127                    ]
2128                }]
2129            }
2130        });
2131
2132        harmonize_multi_kind_suppress_line_actions(&mut output);
2133
2134        assert_eq!(
2135            output["dead_code"]["unused_exports"][0]["actions"][1]["comment"],
2136            "// fallow-ignore-next-line unused-export, complexity"
2137        );
2138        assert_eq!(
2139            output["complexity"]["findings"][0]["actions"][1]["comment"],
2140            "// fallow-ignore-next-line unused-export, complexity"
2141        );
2142    }
2143
2144    #[test]
2145    fn json_unused_file_has_file_suppress_and_note() {
2146        let root = PathBuf::from("/project");
2147        let mut results = AnalysisResults::default();
2148        results
2149            .unused_files
2150            .push(UnusedFileFinding::with_actions(UnusedFile {
2151                path: root.join("src/dead.ts"),
2152            }));
2153        let output = build_json(&results, &root, Duration::ZERO).unwrap();
2154
2155        let actions = output["unused_files"][0]["actions"].as_array().unwrap();
2156        assert_eq!(actions[0]["type"], "delete-file");
2157        assert_eq!(actions[0]["auto_fixable"], false);
2158        assert!(actions[0]["note"].is_string());
2159        assert_eq!(actions[1]["type"], "suppress-file");
2160        assert_eq!(actions[1]["comment"], "// fallow-ignore-file unused-file");
2161    }
2162
2163    #[test]
2164    fn json_unused_dependency_has_config_suppress_with_package_name() {
2165        let root = PathBuf::from("/project");
2166        let mut results = AnalysisResults::default();
2167        results
2168            .unused_dependencies
2169            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2170                package_name: "lodash".to_string(),
2171                location: DependencyLocation::Dependencies,
2172                path: root.join("package.json"),
2173                line: 5,
2174                used_in_workspaces: Vec::new(),
2175            }));
2176        let output = build_json(&results, &root, Duration::ZERO).unwrap();
2177
2178        let actions = output["unused_dependencies"][0]["actions"]
2179            .as_array()
2180            .unwrap();
2181        assert_eq!(actions[0]["type"], "remove-dependency");
2182        assert_eq!(actions[0]["auto_fixable"], true);
2183
2184        // Config suppress includes actual package name
2185        assert_eq!(actions[1]["type"], "add-to-config");
2186        assert_eq!(actions[1]["config_key"], "ignoreDependencies");
2187        assert_eq!(actions[1]["value"], "lodash");
2188    }
2189
2190    #[test]
2191    fn json_cross_workspace_dependency_is_not_auto_fixable() {
2192        let root = PathBuf::from("/project");
2193        let mut results = AnalysisResults::default();
2194        results
2195            .unused_dependencies
2196            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2197                package_name: "lodash-es".to_string(),
2198                location: DependencyLocation::Dependencies,
2199                path: root.join("packages/shared/package.json"),
2200                line: 5,
2201                used_in_workspaces: vec![root.join("packages/consumer")],
2202            }));
2203        let output = build_json(&results, &root, Duration::ZERO).unwrap();
2204
2205        let actions = output["unused_dependencies"][0]["actions"]
2206            .as_array()
2207            .unwrap();
2208        assert_eq!(actions[0]["type"], "move-dependency");
2209        assert_eq!(actions[0]["auto_fixable"], false);
2210        assert!(
2211            actions[0]["note"]
2212                .as_str()
2213                .unwrap()
2214                .contains("will not remove")
2215        );
2216        assert_eq!(actions[1]["type"], "add-to-config");
2217    }
2218
2219    #[test]
2220    fn json_empty_results_have_no_actions_in_empty_arrays() {
2221        let root = PathBuf::from("/project");
2222        let results = AnalysisResults::default();
2223        let output = build_json(&results, &root, Duration::ZERO).unwrap();
2224
2225        // Empty arrays should remain empty
2226        assert!(output["unused_exports"].as_array().unwrap().is_empty());
2227        assert!(output["unused_files"].as_array().unwrap().is_empty());
2228    }
2229
2230    #[test]
2231    fn json_all_issue_types_have_actions() {
2232        let root = PathBuf::from("/project");
2233        let results = sample_results(&root);
2234        let output = build_json(&results, &root, Duration::ZERO).unwrap();
2235
2236        let issue_keys = [
2237            "unused_files",
2238            "unused_exports",
2239            "unused_types",
2240            "unused_dependencies",
2241            "unused_dev_dependencies",
2242            "unused_optional_dependencies",
2243            "unused_enum_members",
2244            "unused_class_members",
2245            "unresolved_imports",
2246            "unlisted_dependencies",
2247            "duplicate_exports",
2248            "type_only_dependencies",
2249            "test_only_dependencies",
2250            "circular_dependencies",
2251        ];
2252
2253        for key in &issue_keys {
2254            let arr = output[key].as_array().unwrap();
2255            if !arr.is_empty() {
2256                let actions = arr[0]["actions"].as_array();
2257                assert!(
2258                    actions.is_some() && !actions.unwrap().is_empty(),
2259                    "missing actions for {key}"
2260                );
2261            }
2262        }
2263    }
2264
2265    // ── Health actions injection ───────────────────────────────────
2266
2267    /// Test helper: deserialize a JSON finding shape into a typed
2268    /// [`ComplexityViolation`], run [`HealthFinding::with_actions`] with
2269    /// the supplied thresholds, and return the resulting `actions` array
2270    /// as `serde_json::Value` so existing JSON-shape assertions keep
2271    /// working after PR B2 of #384 moved finding action selection from
2272    /// the JSON post-pass into the typed wrapper.
2273    fn build_actions_for_finding_json(
2274        finding_json: serde_json::Value,
2275        opts: crate::health_types::HealthActionOptions,
2276        max_cyclomatic_threshold: u16,
2277        max_cognitive_threshold: u16,
2278        max_crap_threshold: f64,
2279    ) -> Vec<serde_json::Value> {
2280        let mut value = finding_json;
2281        // The CV struct skip-serializes `Option::None` fields, but the
2282        // shorthand `serde_json::json!({})` test fixtures sometimes omit
2283        // optional fields. Adapt missing defaults so deserialization
2284        // succeeds for synthetic test inputs.
2285        if let Some(map) = value.as_object_mut() {
2286            map.entry("col".to_string())
2287                .or_insert(serde_json::Value::from(0_u32));
2288            map.entry("line_count".to_string())
2289                .or_insert(serde_json::Value::from(0_u32));
2290            map.entry("param_count".to_string())
2291                .or_insert(serde_json::Value::from(0_u8));
2292            map.entry("severity".to_string())
2293                .or_insert(serde_json::Value::String("moderate".to_string()));
2294        }
2295        // ComplexityViolation derives Serialize only; for tests we
2296        // reconstruct it field by field via a small helper that reads
2297        // the JSON shape used by these tests.
2298        let violation = synthesize_complexity_violation(&value);
2299        let ctx = crate::health_types::HealthActionContext {
2300            opts,
2301            max_cyclomatic_threshold,
2302            max_cognitive_threshold,
2303            max_crap_threshold,
2304        };
2305        let finding = crate::health_types::HealthFinding::with_actions(violation, &ctx);
2306        let serialized = serde_json::to_value(&finding).expect("serialize HealthFinding");
2307        serialized["actions"]
2308            .as_array()
2309            .cloned()
2310            .unwrap_or_default()
2311    }
2312
2313    /// Reads a JSON object with finding-shape fields and produces a
2314    /// [`ComplexityViolation`]. Test-only: panics on schema mismatches so
2315    /// authors notice when synthetic fixtures drift from the canonical
2316    /// shape.
2317    fn synthesize_complexity_violation(
2318        value: &serde_json::Value,
2319    ) -> crate::health_types::ComplexityViolation {
2320        use crate::health_types::{
2321            CoverageSource, CoverageTier, ExceededThreshold, FindingSeverity,
2322        };
2323        let exceeded = match value["exceeded"].as_str().unwrap_or("crap") {
2324            "cyclomatic" => ExceededThreshold::Cyclomatic,
2325            "cognitive" => ExceededThreshold::Cognitive,
2326            "both" => ExceededThreshold::Both,
2327            "crap" => ExceededThreshold::Crap,
2328            "cyclomatic_crap" => ExceededThreshold::CyclomaticCrap,
2329            "cognitive_crap" => ExceededThreshold::CognitiveCrap,
2330            "all" => ExceededThreshold::All,
2331            other => panic!("unknown exceeded label: {other}"),
2332        };
2333        let severity = match value["severity"].as_str().unwrap_or("moderate") {
2334            "moderate" => FindingSeverity::Moderate,
2335            "high" => FindingSeverity::High,
2336            "critical" => FindingSeverity::Critical,
2337            other => panic!("unknown severity label: {other}"),
2338        };
2339        let coverage_tier = value
2340            .get("coverage_tier")
2341            .and_then(|v| v.as_str())
2342            .map(|t| match t {
2343                "none" => CoverageTier::None,
2344                "partial" => CoverageTier::Partial,
2345                "high" => CoverageTier::High,
2346                other => panic!("unknown coverage_tier label: {other}"),
2347            });
2348        let coverage_source =
2349            value
2350                .get("coverage_source")
2351                .and_then(|v| v.as_str())
2352                .map(|s| match s {
2353                    "istanbul" => CoverageSource::Istanbul,
2354                    "estimated" => CoverageSource::Estimated,
2355                    "estimated_component_inherited" => CoverageSource::EstimatedComponentInherited,
2356                    other => panic!("unknown coverage_source label: {other}"),
2357                });
2358        crate::health_types::ComplexityViolation {
2359            path: std::path::PathBuf::from(value["path"].as_str().unwrap_or("src/x.ts")),
2360            name: value["name"].as_str().unwrap_or("fn").to_string(),
2361            line: u32::try_from(value["line"].as_u64().unwrap_or(0)).unwrap_or(0),
2362            col: u32::try_from(value["col"].as_u64().unwrap_or(0)).unwrap_or(0),
2363            cyclomatic: u16::try_from(value["cyclomatic"].as_u64().unwrap_or(0)).unwrap_or(0),
2364            cognitive: u16::try_from(value["cognitive"].as_u64().unwrap_or(0)).unwrap_or(0),
2365            line_count: u32::try_from(value["line_count"].as_u64().unwrap_or(0)).unwrap_or(0),
2366            param_count: u8::try_from(value["param_count"].as_u64().unwrap_or(0)).unwrap_or(0),
2367            exceeded,
2368            severity,
2369            crap: value.get("crap").and_then(|v| v.as_f64()),
2370            coverage_pct: value.get("coverage_pct").and_then(|v| v.as_f64()),
2371            coverage_tier,
2372            coverage_source,
2373            inherited_from: value
2374                .get("inherited_from")
2375                .and_then(|v| v.as_str())
2376                .map(std::path::PathBuf::from),
2377            component_rollup: value.get("component_rollup").and_then(|v| {
2378                let map = v.as_object()?;
2379                Some(crate::health_types::ComponentRollup {
2380                    component: map.get("component")?.as_str()?.to_string(),
2381                    class_worst_function: map.get("class_worst_function")?.as_str()?.to_string(),
2382                    class_cyclomatic: u16::try_from(map.get("class_cyclomatic")?.as_u64()?).ok()?,
2383                    class_cognitive: u16::try_from(map.get("class_cognitive")?.as_u64()?).ok()?,
2384                    template_path: std::path::PathBuf::from(map.get("template_path")?.as_str()?),
2385                    template_cyclomatic: u16::try_from(map.get("template_cyclomatic")?.as_u64()?)
2386                        .ok()?,
2387                    template_cognitive: u16::try_from(map.get("template_cognitive")?.as_u64()?)
2388                        .ok()?,
2389                })
2390            }),
2391        }
2392    }
2393
2394    #[test]
2395    fn health_finding_has_actions() {
2396        let actions = build_actions_for_finding_json(
2397            serde_json::json!({
2398                "path": "src/utils.ts",
2399                "name": "processData",
2400                "line": 10,
2401                "col": 0,
2402                "cyclomatic": 25,
2403                "cognitive": 30,
2404                "line_count": 150,
2405                "exceeded": "both"
2406            }),
2407            crate::health_types::HealthActionOptions::default(),
2408            20,
2409            15,
2410            30.0,
2411        );
2412
2413        assert_eq!(actions.len(), 2);
2414        assert_eq!(actions[0]["type"], "refactor-function");
2415        assert_eq!(actions[0]["auto_fixable"], false);
2416        assert!(
2417            actions[0]["description"]
2418                .as_str()
2419                .unwrap()
2420                .contains("processData")
2421        );
2422        assert_eq!(actions[1]["type"], "suppress-line");
2423        assert_eq!(
2424            actions[1]["comment"],
2425            "// fallow-ignore-next-line complexity"
2426        );
2427    }
2428
2429    // Tests for the retired hotspot / target action post-pass now live in
2430    // `crates/cli/src/health_types/finding.rs::hotspot_target_tests`
2431    // alongside the typed `HotspotFinding` and `RefactoringTargetFinding`
2432    // wrappers and the typed `build_hotspot_actions` /
2433    // `build_refactoring_target_actions` helpers they exercise. The four
2434    // direct `suggest_codeowners_pattern` tests moved alongside that helper.
2435
2436    #[test]
2437    fn health_finding_suppress_has_placement() {
2438        let actions = build_actions_for_finding_json(
2439            serde_json::json!({
2440                "path": "src/utils.ts",
2441                "name": "processData",
2442                "line": 10,
2443                "col": 0,
2444                "cyclomatic": 25,
2445                "cognitive": 30,
2446                "line_count": 150,
2447                "exceeded": "both"
2448            }),
2449            crate::health_types::HealthActionOptions::default(),
2450            20,
2451            15,
2452            30.0,
2453        );
2454
2455        assert_eq!(actions[1]["placement"], "above-function-declaration");
2456    }
2457
2458    #[test]
2459    fn html_template_health_finding_uses_html_suppression() {
2460        let actions = build_actions_for_finding_json(
2461            serde_json::json!({
2462                "path": "src/app.component.html",
2463                "name": "<template>",
2464                "line": 1,
2465                "col": 0,
2466                "cyclomatic": 25,
2467                "cognitive": 30,
2468                "line_count": 40,
2469                "exceeded": "both"
2470            }),
2471            crate::health_types::HealthActionOptions::default(),
2472            20,
2473            15,
2474            30.0,
2475        );
2476
2477        let suppress = &actions[1];
2478        assert_eq!(suppress["type"], "suppress-file");
2479        assert_eq!(
2480            suppress["comment"],
2481            "<!-- fallow-ignore-file complexity -->"
2482        );
2483        assert_eq!(suppress["placement"], "top-of-template");
2484    }
2485
2486    #[test]
2487    fn inline_template_health_finding_uses_decorator_suppression() {
2488        let actions = build_actions_for_finding_json(
2489            serde_json::json!({
2490                "path": "src/app.component.ts",
2491                "name": "<template>",
2492                "line": 5,
2493                "col": 0,
2494                "cyclomatic": 25,
2495                "cognitive": 30,
2496                "line_count": 40,
2497                "exceeded": "both"
2498            }),
2499            crate::health_types::HealthActionOptions::default(),
2500            20,
2501            15,
2502            30.0,
2503        );
2504
2505        let refactor = &actions[0];
2506        assert_eq!(refactor["type"], "refactor-function");
2507        assert!(
2508            refactor["description"]
2509                .as_str()
2510                .unwrap()
2511                .contains("template complexity")
2512        );
2513        let suppress = &actions[1];
2514        assert_eq!(suppress["type"], "suppress-line");
2515        assert_eq!(
2516            suppress["description"],
2517            "Suppress with an inline comment above the Angular decorator"
2518        );
2519        assert_eq!(suppress["placement"], "above-angular-decorator");
2520    }
2521
2522    // Duplication action injection tests retired in #409: the wire shape
2523    // now flows through typed wrappers in `crate::output_dupes`; the
2524    // wrappers carry their own unit tests covering the same per-finding
2525    // `actions[]` semantics (extract-shared / apply-suggestion /
2526    // suppress-line position-0 invariants, issue #393 nested groups).
2527
2528    // ── Tier-aware health action emission ──────────────────────────
2529
2530    /// Helper: build a health JSON envelope with a single CRAP-only finding.
2531    /// Default cognitive complexity is 12 (above the cognitive floor at the
2532    /// default `max_cognitive_threshold / 2 = 7.5`); use
2533    /// `crap_only_finding_envelope_with_cognitive` to exercise low-cog cases
2534    /// (flat dispatchers, JSX render maps) where the cognitive floor should
2535    /// suppress the secondary refactor.
2536    fn crap_only_finding_envelope(
2537        coverage_tier: Option<&str>,
2538        cyclomatic: u16,
2539        max_cyclomatic_threshold: u16,
2540    ) -> serde_json::Value {
2541        crap_only_finding_envelope_with_max_crap(
2542            coverage_tier,
2543            cyclomatic,
2544            12,
2545            max_cyclomatic_threshold,
2546            15,
2547            30.0,
2548        )
2549    }
2550
2551    fn crap_only_finding_envelope_with_cognitive(
2552        coverage_tier: Option<&str>,
2553        cyclomatic: u16,
2554        cognitive: u16,
2555        max_cyclomatic_threshold: u16,
2556    ) -> serde_json::Value {
2557        crap_only_finding_envelope_with_max_crap(
2558            coverage_tier,
2559            cyclomatic,
2560            cognitive,
2561            max_cyclomatic_threshold,
2562            15,
2563            30.0,
2564        )
2565    }
2566
2567    /// Build a synthetic health JSON envelope around a single typed
2568    /// [`HealthFinding`] so the existing JSON-shaped assertions in this
2569    /// module keep working after PR B2 of #384 moved action selection from
2570    /// the JSON post-pass into [`HealthFinding::with_actions`]. Defaults to
2571    /// the un-suppressed action context; callers that want to exercise the
2572    /// `omit_suppress_line` path should go through
2573    /// [`build_finding_envelope_with_ctx`].
2574    fn crap_only_finding_envelope_with_max_crap(
2575        coverage_tier: Option<&str>,
2576        cyclomatic: u16,
2577        cognitive: u16,
2578        max_cyclomatic_threshold: u16,
2579        max_cognitive_threshold: u16,
2580        max_crap_threshold: f64,
2581    ) -> serde_json::Value {
2582        build_finding_envelope_with_ctx(
2583            coverage_tier,
2584            cyclomatic,
2585            cognitive,
2586            max_cyclomatic_threshold,
2587            max_cognitive_threshold,
2588            max_crap_threshold,
2589            crate::health_types::HealthActionOptions::default(),
2590        )
2591    }
2592
2593    /// Build a single-finding health JSON envelope with the supplied action
2594    /// context. Used by the suppress-line gating tests to exercise the
2595    /// `baseline-active` / `config-disabled` reasons.
2596    fn build_finding_envelope_with_ctx(
2597        coverage_tier: Option<&str>,
2598        cyclomatic: u16,
2599        cognitive: u16,
2600        max_cyclomatic_threshold: u16,
2601        max_cognitive_threshold: u16,
2602        max_crap_threshold: f64,
2603        action_opts: crate::health_types::HealthActionOptions,
2604    ) -> serde_json::Value {
2605        let tier = coverage_tier.map(|t| match t {
2606            "none" => crate::health_types::CoverageTier::None,
2607            "partial" => crate::health_types::CoverageTier::Partial,
2608            "high" => crate::health_types::CoverageTier::High,
2609            other => panic!("unknown coverage tier label: {other}"),
2610        });
2611        let violation = crate::health_types::ComplexityViolation {
2612            path: std::path::PathBuf::from("src/risk.ts"),
2613            name: "computeScore".to_string(),
2614            line: 12,
2615            col: 0,
2616            cyclomatic,
2617            cognitive,
2618            line_count: 40,
2619            param_count: 0,
2620            exceeded: crate::health_types::ExceededThreshold::Crap,
2621            severity: crate::health_types::FindingSeverity::Moderate,
2622            crap: Some(35.5),
2623            coverage_pct: None,
2624            coverage_tier: tier,
2625            coverage_source: None,
2626            inherited_from: None,
2627            component_rollup: None,
2628        };
2629        let ctx = crate::health_types::HealthActionContext {
2630            opts: action_opts,
2631            max_cyclomatic_threshold,
2632            max_cognitive_threshold,
2633            max_crap_threshold,
2634        };
2635        let finding = crate::health_types::HealthFinding::with_actions(violation, &ctx);
2636        let actions_meta = if action_opts.omit_suppress_line {
2637            Some(serde_json::json!({
2638                "suppression_hints_omitted": true,
2639                "reason": action_opts.omit_reason.unwrap_or("unspecified"),
2640                "scope": "health-findings",
2641            }))
2642        } else {
2643            None
2644        };
2645        let mut envelope = serde_json::json!({
2646            "findings": [serde_json::to_value(&finding).unwrap()],
2647            "summary": {
2648                "max_cyclomatic_threshold": max_cyclomatic_threshold,
2649                "max_cognitive_threshold": max_cognitive_threshold,
2650                "max_crap_threshold": max_crap_threshold,
2651            },
2652        });
2653        if let Some(meta) = actions_meta
2654            && let Some(map) = envelope.as_object_mut()
2655        {
2656            map.insert("actions_meta".to_string(), meta);
2657        }
2658        envelope
2659    }
2660
2661    #[test]
2662    fn crap_only_tier_none_emits_add_tests() {
2663        let output = crap_only_finding_envelope(Some("none"), 6, 20);
2664        let actions = output["findings"][0]["actions"].as_array().unwrap();
2665        assert!(
2666            actions.iter().any(|a| a["type"] == "add-tests"),
2667            "tier=none crap-only must emit add-tests, got {actions:?}"
2668        );
2669        assert!(
2670            !actions.iter().any(|a| a["type"] == "increase-coverage"),
2671            "tier=none must not emit increase-coverage"
2672        );
2673    }
2674
2675    #[test]
2676    fn crap_only_tier_partial_emits_increase_coverage() {
2677        let output = crap_only_finding_envelope(Some("partial"), 6, 20);
2678        let actions = output["findings"][0]["actions"].as_array().unwrap();
2679        assert!(
2680            actions.iter().any(|a| a["type"] == "increase-coverage"),
2681            "tier=partial crap-only must emit increase-coverage, got {actions:?}"
2682        );
2683        assert!(
2684            !actions.iter().any(|a| a["type"] == "add-tests"),
2685            "tier=partial must not emit add-tests"
2686        );
2687    }
2688
2689    #[test]
2690    fn crap_only_tier_high_emits_increase_coverage_when_full_coverage_can_clear_crap() {
2691        // CC=20 at 70% coverage has CRAP 30.8, but at 100% coverage CRAP
2692        // falls to 20.0, below the default max_crap_threshold=30. Coverage
2693        // is therefore still a valid remediation even though tier=high.
2694        let output = crap_only_finding_envelope(Some("high"), 20, 30);
2695        let actions = output["findings"][0]["actions"].as_array().unwrap();
2696        assert!(
2697            actions.iter().any(|a| a["type"] == "increase-coverage"),
2698            "tier=high crap-only must still emit increase-coverage when full coverage can clear CRAP, got {actions:?}"
2699        );
2700        assert!(
2701            !actions.iter().any(|a| a["type"] == "refactor-function"),
2702            "coverage-remediable crap-only findings should not get refactor-function unless near the cyclomatic threshold"
2703        );
2704        assert!(
2705            !actions.iter().any(|a| a["type"] == "add-tests"),
2706            "tier=high must not emit add-tests"
2707        );
2708    }
2709
2710    #[test]
2711    fn crap_only_emits_refactor_when_full_coverage_cannot_clear_crap() {
2712        // At 100% coverage CRAP bottoms out at CC. With CC=35 and a CRAP
2713        // threshold of 30, tests alone can reduce risk but cannot clear the
2714        // finding; the primary action should be complexity reduction.
2715        let output = crap_only_finding_envelope_with_max_crap(Some("high"), 35, 12, 50, 15, 30.0);
2716        let actions = output["findings"][0]["actions"].as_array().unwrap();
2717        assert!(
2718            actions.iter().any(|a| a["type"] == "refactor-function"),
2719            "full-coverage-impossible CRAP-only finding must emit refactor-function, got {actions:?}"
2720        );
2721        assert!(
2722            !actions.iter().any(|a| a["type"] == "increase-coverage"),
2723            "must not emit increase-coverage when even 100% coverage cannot clear CRAP"
2724        );
2725        assert!(
2726            !actions.iter().any(|a| a["type"] == "add-tests"),
2727            "must not emit add-tests when even 100% coverage cannot clear CRAP"
2728        );
2729    }
2730
2731    #[test]
2732    fn crap_only_high_cc_appends_secondary_refactor() {
2733        // CC=16 with threshold=20 => within SECONDARY_REFACTOR_BAND (5)
2734        // of the threshold; refactor is a useful complement to coverage.
2735        let output = crap_only_finding_envelope(Some("none"), 16, 20);
2736        let actions = output["findings"][0]["actions"].as_array().unwrap();
2737        assert!(
2738            actions.iter().any(|a| a["type"] == "add-tests"),
2739            "near-threshold crap-only still emits the primary tier action"
2740        );
2741        assert!(
2742            actions.iter().any(|a| a["type"] == "refactor-function"),
2743            "near-threshold crap-only must also emit secondary refactor-function"
2744        );
2745    }
2746
2747    #[test]
2748    fn crap_only_far_below_threshold_no_secondary_refactor() {
2749        // CC=6 with threshold=20 => far outside the band; refactor not added.
2750        let output = crap_only_finding_envelope(Some("none"), 6, 20);
2751        let actions = output["findings"][0]["actions"].as_array().unwrap();
2752        assert!(
2753            !actions.iter().any(|a| a["type"] == "refactor-function"),
2754            "low-CC crap-only should not get a secondary refactor-function"
2755        );
2756    }
2757
2758    #[test]
2759    fn crap_only_near_threshold_low_cognitive_no_secondary_refactor() {
2760        // Cognitive floor regression. Real-world example from vrs-portals:
2761        // a flat type-tag dispatcher with CC=17 (within SECONDARY_REFACTOR_BAND
2762        // of the default cyclomatic threshold of 20) but cognitive=2 (a single
2763        // switch, no nesting). Suggesting "extract helpers, simplify branching"
2764        // is wrong-target advice for declarative dispatchers; the cognitive
2765        // floor at `max_cognitive_threshold / 2` (default 7) suppresses the
2766        // secondary refactor in this case while still firing it for genuinely
2767        // tangled functions (CC>=15 + cog>=8) where refactor would help.
2768        let output = crap_only_finding_envelope_with_cognitive(Some("none"), 17, 2, 20);
2769        let actions = output["findings"][0]["actions"].as_array().unwrap();
2770        assert!(
2771            actions.iter().any(|a| a["type"] == "add-tests"),
2772            "primary tier action still emits"
2773        );
2774        assert!(
2775            !actions.iter().any(|a| a["type"] == "refactor-function"),
2776            "near-threshold CC with cognitive below floor must NOT emit secondary refactor (got {actions:?})"
2777        );
2778    }
2779
2780    #[test]
2781    fn crap_only_near_threshold_high_cognitive_emits_secondary_refactor() {
2782        // Companion to the cognitive-floor regression: when cognitive is at or
2783        // above the floor, the secondary refactor should still fire. CC=16
2784        // and cognitive=10 (above default floor of 7) is the canonical
2785        // "tangled but near-threshold" function that genuinely benefits from
2786        // both coverage AND refactoring.
2787        let output = crap_only_finding_envelope_with_cognitive(Some("none"), 16, 10, 20);
2788        let actions = output["findings"][0]["actions"].as_array().unwrap();
2789        assert!(
2790            actions.iter().any(|a| a["type"] == "add-tests"),
2791            "primary tier action still emits"
2792        );
2793        assert!(
2794            actions.iter().any(|a| a["type"] == "refactor-function"),
2795            "near-threshold CC with cognitive above floor must emit secondary refactor (got {actions:?})"
2796        );
2797    }
2798
2799    #[test]
2800    fn cyclomatic_only_emits_only_refactor_function() {
2801        let actions = build_actions_for_finding_json(
2802            serde_json::json!({
2803                "path": "src/cyclo.ts",
2804                "name": "branchy",
2805                "line": 5,
2806                "col": 0,
2807                "cyclomatic": 25,
2808                "cognitive": 10,
2809                "line_count": 80,
2810                "exceeded": "cyclomatic",
2811            }),
2812            crate::health_types::HealthActionOptions::default(),
2813            20,
2814            15,
2815            30.0,
2816        );
2817        assert!(
2818            actions.iter().any(|a| a["type"] == "refactor-function"),
2819            "non-CRAP findings emit refactor-function"
2820        );
2821        assert!(
2822            !actions.iter().any(|a| a["type"] == "add-tests"),
2823            "non-CRAP findings must not emit add-tests"
2824        );
2825        assert!(
2826            !actions.iter().any(|a| a["type"] == "increase-coverage"),
2827            "non-CRAP findings must not emit increase-coverage"
2828        );
2829    }
2830
2831    // ── Suppress-line gating ──────────────────────────────────────
2832
2833    #[test]
2834    fn suppress_line_omitted_when_baseline_active() {
2835        let output = build_finding_envelope_with_ctx(
2836            Some("none"),
2837            6,
2838            12,
2839            20,
2840            15,
2841            30.0,
2842            crate::health_types::HealthActionOptions {
2843                omit_suppress_line: true,
2844                omit_reason: Some("baseline-active"),
2845            },
2846        );
2847        let actions = output["findings"][0]["actions"].as_array().unwrap();
2848        assert!(
2849            !actions.iter().any(|a| a["type"] == "suppress-line"),
2850            "baseline-active must not emit suppress-line, got {actions:?}"
2851        );
2852        assert_eq!(
2853            output["actions_meta"]["suppression_hints_omitted"],
2854            serde_json::Value::Bool(true)
2855        );
2856        assert_eq!(output["actions_meta"]["reason"], "baseline-active");
2857        assert_eq!(output["actions_meta"]["scope"], "health-findings");
2858    }
2859
2860    #[test]
2861    fn suppress_line_omitted_when_config_disabled() {
2862        let output = build_finding_envelope_with_ctx(
2863            Some("none"),
2864            6,
2865            12,
2866            20,
2867            15,
2868            30.0,
2869            crate::health_types::HealthActionOptions {
2870                omit_suppress_line: true,
2871                omit_reason: Some("config-disabled"),
2872            },
2873        );
2874        assert_eq!(output["actions_meta"]["reason"], "config-disabled");
2875    }
2876
2877    #[test]
2878    fn suppress_line_emitted_by_default() {
2879        let output = crap_only_finding_envelope(Some("none"), 6, 20);
2880        let actions = output["findings"][0]["actions"].as_array().unwrap();
2881        assert!(
2882            actions.iter().any(|a| a["type"] == "suppress-line"),
2883            "default opts must emit suppress-line"
2884        );
2885        assert!(
2886            output.get("actions_meta").is_none(),
2887            "actions_meta must be absent when no omission occurred"
2888        );
2889    }
2890
2891    /// Drift guard: every action `type` value emitted by the action builder
2892    /// must appear in `docs/output-schema.json`'s `HealthFindingAction.type`
2893    /// enum. Previously the schema listed only `[refactor-function,
2894    /// suppress-line]` while the code emitted `add-tests` for CRAP findings,
2895    /// silently producing schema-invalid output for any consumer using the
2896    /// schema for validation.
2897    #[test]
2898    fn every_emitted_health_action_type_is_in_schema_enum() {
2899        // Exercise every distinct emission path. The list mirrors the match
2900        // in `build_crap_coverage_action` and the surrounding refactor/
2901        // suppress-line emissions in `build_health_finding_actions`.
2902        let cases = [
2903            // (exceeded, coverage_tier, cyclomatic, max_cyclomatic_threshold)
2904            ("crap", Some("none"), 6_u16, 20_u16),
2905            ("crap", Some("partial"), 6, 20),
2906            ("crap", Some("high"), 12, 20),
2907            ("crap", Some("none"), 16, 20), // near threshold => secondary refactor
2908            ("cyclomatic", None, 25, 20),
2909            ("cognitive_crap", Some("partial"), 6, 20),
2910            ("all", Some("none"), 25, 20),
2911        ];
2912
2913        let mut emitted: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2914        for (exceeded, tier, cc, max) in cases {
2915            let mut finding = serde_json::json!({
2916                "path": "src/x.ts",
2917                "name": "fn",
2918                "line": 1,
2919                "col": 0,
2920                "cyclomatic": cc,
2921                "cognitive": 5,
2922                "line_count": 10,
2923                "exceeded": exceeded,
2924                "crap": 35.0,
2925            });
2926            if let Some(t) = tier {
2927                finding["coverage_tier"] = serde_json::Value::String(t.to_owned());
2928            }
2929            let actions = build_actions_for_finding_json(
2930                finding,
2931                crate::health_types::HealthActionOptions::default(),
2932                max,
2933                15,
2934                30.0,
2935            );
2936            for action in &actions {
2937                if let Some(ty) = action["type"].as_str() {
2938                    emitted.insert(ty.to_owned());
2939                }
2940            }
2941        }
2942
2943        // Load the schema enum once. Phase 8 derives `HealthFindingActionType`
2944        // from a Rust enum whose schemars derive produces `oneOf: [{const: ...},
2945        // ...]` (one branch per variant) rather than a flat `enum: [...]`. We
2946        // accept either form here so the drift guard tolerates a future
2947        // schemars-version flip back to the flat shape.
2948        let schema_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2949            .join("..")
2950            .join("..")
2951            .join("docs")
2952            .join("output-schema.json");
2953        let raw = std::fs::read_to_string(&schema_path)
2954            .expect("docs/output-schema.json must be readable for the drift-guard test");
2955        let schema: serde_json::Value = serde_json::from_str(&raw).expect("schema parses");
2956        let type_field = &schema["definitions"]["HealthFindingAction"]["properties"]["type"];
2957        let type_def = if let Some(reference) = type_field.get("$ref").and_then(|r| r.as_str()) {
2958            let name = reference
2959                .strip_prefix("#/definitions/")
2960                .expect("HealthFindingAction.type $ref points into #/definitions/");
2961            &schema["definitions"][name]
2962        } else {
2963            type_field
2964        };
2965        let mut enum_values: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2966        if let Some(arr) = type_def.get("enum").and_then(|e| e.as_array()) {
2967            for v in arr {
2968                if let Some(s) = v.as_str() {
2969                    enum_values.insert(s.to_owned());
2970                }
2971            }
2972        }
2973        if let Some(arr) = type_def.get("oneOf").and_then(|e| e.as_array()) {
2974            for branch in arr {
2975                if let Some(s) = branch.get("const").and_then(|c| c.as_str()) {
2976                    enum_values.insert(s.to_owned());
2977                }
2978            }
2979        }
2980        assert!(
2981            !enum_values.is_empty(),
2982            "could not extract HealthFindingActionType variants from schema (neither `enum` nor `oneOf` with `const` branches)"
2983        );
2984
2985        for ty in &emitted {
2986            assert!(
2987                enum_values.contains(ty),
2988                "build_health_finding_actions emitted action type `{ty}` but \
2989                 docs/output-schema.json HealthFindingAction.type enum does \
2990                 not list it. Add it to the schema (and any downstream \
2991                 typed consumers) when introducing a new action type."
2992            );
2993        }
2994    }
2995
2996    /// Regression for issue #412: prevent reintroduction of the legacy
2997    /// `inject_*` / `augment_*` post-pass pattern in this file. Every
2998    /// JSON `actions[]` array on every finding type should flow from a
2999    /// typed `serde(flatten)` envelope, not from a post-construction
3000    /// mutation of a `serde_json::Value` tree.
3001    ///
3002    /// The allow-list mirrors the `HAND_MAINTAINED_ALLOW_LIST` pattern
3003    /// in `crates/cli/src/bin/schema_emit.rs`: each entry pairs a name
3004    /// with the issue that retires it. It is empty today; any addition
3005    /// needs an issue reference in the same commit. The gate also
3006    /// asserts no STALE entries, so removing a function without
3007    /// removing its allow-list entry fails the test and forces the
3008    /// cleanup commit.
3009    #[test]
3010    fn no_new_post_pass_helpers_in_json_rs() {
3011        const POST_PASS_ALLOW_LIST: &[(&str, &str)] = &[];
3012        let source_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3013            .join("src")
3014            .join("report")
3015            .join("json.rs");
3016        let source = std::fs::read_to_string(&source_path).expect(
3017            "crates/cli/src/report/json.rs must be readable for the post-pass drift-guard test",
3018        );
3019        let mut found: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
3020        for line in source.lines() {
3021            if let Some(name) = extract_post_pass_fn_name(line) {
3022                found.insert(name.to_owned());
3023            }
3024        }
3025        let allow: std::collections::BTreeSet<&'static str> =
3026            POST_PASS_ALLOW_LIST.iter().map(|(name, _)| *name).collect();
3027        let unexpected: Vec<&str> = found
3028            .iter()
3029            .filter(|name| !allow.contains(name.as_str()))
3030            .map(String::as_str)
3031            .collect();
3032        let stale: Vec<&str> = allow
3033            .iter()
3034            .filter(|name| !found.contains(**name))
3035            .copied()
3036            .collect();
3037        assert!(
3038            unexpected.is_empty(),
3039            "new post-pass helper(s) defined in crates/cli/src/report/json.rs are not in \
3040             POST_PASS_ALLOW_LIST: {unexpected:?}.\n\
3041             The typed `serde(flatten)` envelope is the source of truth for `actions[]` on \
3042             every finding. If a new post-pass is genuinely needed, file a tracking issue, \
3043             add the entry to POST_PASS_ALLOW_LIST with the issue link as the reason, and \
3044             reference the issue in the PR body. See issue #412 for context."
3045        );
3046        assert!(
3047            stale.is_empty(),
3048            "stale entries in POST_PASS_ALLOW_LIST (function no longer defined in \
3049             crates/cli/src/report/json.rs): {stale:?}.\n\
3050             Remove them in the same commit that retired the function."
3051        );
3052    }
3053
3054    /// Extracts an `inject_<name>` or `augment_<name>` identifier from a
3055    /// Rust function-definition line, handling `pub`, `pub(...)`,
3056    /// `async`, `const`, and `unsafe` modifiers. Returns `None` for
3057    /// non-definition lines (comments, call sites, doc strings).
3058    fn extract_post_pass_fn_name(line: &str) -> Option<&str> {
3059        let trimmed = line.trim_start();
3060        if trimmed.starts_with("//") {
3061            return None;
3062        }
3063        let mut rest = trimmed;
3064        if let Some(after) = rest.strip_prefix("pub") {
3065            let after = after.trim_start();
3066            rest = if let Some(after) = after.strip_prefix('(') {
3067                let close = after.find(')')?;
3068                after[close + 1..].trim_start()
3069            } else {
3070                after
3071            };
3072        }
3073        for prefix in ["async ", "const ", "unsafe "] {
3074            if let Some(after) = rest.strip_prefix(prefix) {
3075                rest = after.trim_start();
3076            }
3077        }
3078        let after_fn = rest.strip_prefix("fn ")?;
3079        let name_end = after_fn
3080            .find(|c: char| !c.is_alphanumeric() && c != '_')
3081            .unwrap_or(after_fn.len());
3082        let name = &after_fn[..name_end];
3083        if name.starts_with("inject_") || name.starts_with("augment_") {
3084            Some(name)
3085        } else {
3086            None
3087        }
3088    }
3089}