Skip to main content

fallow_cli/report/
json.rs

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