Skip to main content

fallow_cli/report/
json.rs

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