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