Skip to main content

fallow_cli/report/
json.rs

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