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