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