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                    edges: Vec::new(),
1040                    is_cross_package: false,
1041                },
1042            ));
1043        let elapsed = Duration::from_millis(0);
1044        let output = build_json(&results, &root, elapsed).expect("should serialize");
1045
1046        let files = output["circular_dependencies"][0]["files"]
1047            .as_array()
1048            .unwrap();
1049        assert_eq!(files[0].as_str().unwrap(), "src/a.ts");
1050        assert_eq!(files[1].as_str().unwrap(), "src/b.ts");
1051    }
1052
1053    #[test]
1054    fn json_path_outside_root_not_stripped() {
1055        let root = PathBuf::from("/project");
1056        let mut results = AnalysisResults::default();
1057        results
1058            .unused_files
1059            .push(UnusedFileFinding::with_actions(UnusedFile {
1060                path: PathBuf::from("/other/project/src/file.ts"),
1061            }));
1062        let elapsed = Duration::from_millis(0);
1063        let output = build_json(&results, &root, elapsed).expect("should serialize");
1064
1065        let path = output["unused_files"][0]["path"].as_str().unwrap();
1066        assert!(path.contains("/other/project/"));
1067    }
1068
1069    #[test]
1070    fn json_unused_file_contains_path() {
1071        let root = PathBuf::from("/project");
1072        let mut results = AnalysisResults::default();
1073        results
1074            .unused_files
1075            .push(UnusedFileFinding::with_actions(UnusedFile {
1076                path: root.join("src/orphan.ts"),
1077            }));
1078        let elapsed = Duration::from_millis(0);
1079        let output = build_json(&results, &root, elapsed).expect("should serialize");
1080
1081        let file = &output["unused_files"][0];
1082        assert_eq!(file["path"], "src/orphan.ts");
1083    }
1084
1085    #[test]
1086    fn json_unused_type_contains_expected_fields() {
1087        let root = PathBuf::from("/project");
1088        let mut results = AnalysisResults::default();
1089        results
1090            .unused_types
1091            .push(UnusedTypeFinding::with_actions(UnusedExport {
1092                path: root.join("src/types.ts"),
1093                export_name: "OldInterface".to_string(),
1094                is_type_only: true,
1095                line: 20,
1096                col: 0,
1097                span_start: 300,
1098                is_re_export: false,
1099            }));
1100        let elapsed = Duration::from_millis(0);
1101        let output = build_json(&results, &root, elapsed).expect("should serialize");
1102
1103        let typ = &output["unused_types"][0];
1104        assert_eq!(typ["export_name"], "OldInterface");
1105        assert_eq!(typ["is_type_only"], true);
1106        assert_eq!(typ["line"], 20);
1107        assert_eq!(typ["path"], "src/types.ts");
1108    }
1109
1110    #[test]
1111    fn json_unused_dependency_contains_expected_fields() {
1112        let root = PathBuf::from("/project");
1113        let mut results = AnalysisResults::default();
1114        results
1115            .unused_dependencies
1116            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1117                package_name: "axios".to_string(),
1118                location: DependencyLocation::Dependencies,
1119                path: root.join("package.json"),
1120                line: 10,
1121                used_in_workspaces: Vec::new(),
1122            }));
1123        let elapsed = Duration::from_millis(0);
1124        let output = build_json(&results, &root, elapsed).expect("should serialize");
1125
1126        let dep = &output["unused_dependencies"][0];
1127        assert_eq!(dep["package_name"], "axios");
1128        assert_eq!(dep["line"], 10);
1129        assert!(dep.get("used_in_workspaces").is_none());
1130    }
1131
1132    #[test]
1133    fn json_unused_dependency_includes_cross_workspace_context() {
1134        let root = PathBuf::from("/project");
1135        let mut results = AnalysisResults::default();
1136        results
1137            .unused_dependencies
1138            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1139                package_name: "lodash-es".to_string(),
1140                location: DependencyLocation::Dependencies,
1141                path: root.join("packages/shared/package.json"),
1142                line: 6,
1143                used_in_workspaces: vec![root.join("packages/consumer")],
1144            }));
1145        let elapsed = Duration::from_millis(0);
1146        let output = build_json(&results, &root, elapsed).expect("should serialize");
1147
1148        let dep = &output["unused_dependencies"][0];
1149        assert_eq!(
1150            dep["used_in_workspaces"],
1151            serde_json::json!(["packages/consumer"])
1152        );
1153    }
1154
1155    #[test]
1156    fn json_unused_dev_dependency_contains_expected_fields() {
1157        let root = PathBuf::from("/project");
1158        let mut results = AnalysisResults::default();
1159        results
1160            .unused_dev_dependencies
1161            .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
1162                package_name: "vitest".to_string(),
1163                location: DependencyLocation::DevDependencies,
1164                path: root.join("package.json"),
1165                line: 15,
1166                used_in_workspaces: Vec::new(),
1167            }));
1168        let elapsed = Duration::from_millis(0);
1169        let output = build_json(&results, &root, elapsed).expect("should serialize");
1170
1171        let dep = &output["unused_dev_dependencies"][0];
1172        assert_eq!(dep["package_name"], "vitest");
1173    }
1174
1175    #[test]
1176    fn json_unused_optional_dependency_contains_expected_fields() {
1177        let root = PathBuf::from("/project");
1178        let mut results = AnalysisResults::default();
1179        results
1180            .unused_optional_dependencies
1181            .push(UnusedOptionalDependencyFinding::with_actions(
1182                UnusedDependency {
1183                    package_name: "fsevents".to_string(),
1184                    location: DependencyLocation::OptionalDependencies,
1185                    path: root.join("package.json"),
1186                    line: 12,
1187                    used_in_workspaces: Vec::new(),
1188                },
1189            ));
1190        let elapsed = Duration::from_millis(0);
1191        let output = build_json(&results, &root, elapsed).expect("should serialize");
1192
1193        let dep = &output["unused_optional_dependencies"][0];
1194        assert_eq!(dep["package_name"], "fsevents");
1195        assert_eq!(output["total_issues"], 1);
1196    }
1197
1198    #[test]
1199    fn json_unused_enum_member_contains_expected_fields() {
1200        let root = PathBuf::from("/project");
1201        let mut results = AnalysisResults::default();
1202        results
1203            .unused_enum_members
1204            .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
1205                path: root.join("src/enums.ts"),
1206                parent_name: "Color".to_string(),
1207                member_name: "Purple".to_string(),
1208                kind: MemberKind::EnumMember,
1209                line: 5,
1210                col: 2,
1211            }));
1212        let elapsed = Duration::from_millis(0);
1213        let output = build_json(&results, &root, elapsed).expect("should serialize");
1214
1215        let member = &output["unused_enum_members"][0];
1216        assert_eq!(member["parent_name"], "Color");
1217        assert_eq!(member["member_name"], "Purple");
1218        assert_eq!(member["line"], 5);
1219        assert_eq!(member["path"], "src/enums.ts");
1220    }
1221
1222    #[test]
1223    fn json_unused_class_member_contains_expected_fields() {
1224        let root = PathBuf::from("/project");
1225        let mut results = AnalysisResults::default();
1226        results
1227            .unused_class_members
1228            .push(UnusedClassMemberFinding::with_actions(UnusedMember {
1229                path: root.join("src/api.ts"),
1230                parent_name: "ApiClient".to_string(),
1231                member_name: "deprecatedFetch".to_string(),
1232                kind: MemberKind::ClassMethod,
1233                line: 100,
1234                col: 4,
1235            }));
1236        let elapsed = Duration::from_millis(0);
1237        let output = build_json(&results, &root, elapsed).expect("should serialize");
1238
1239        let member = &output["unused_class_members"][0];
1240        assert_eq!(member["parent_name"], "ApiClient");
1241        assert_eq!(member["member_name"], "deprecatedFetch");
1242        assert_eq!(member["line"], 100);
1243    }
1244
1245    #[test]
1246    fn json_unresolved_import_contains_expected_fields() {
1247        let root = PathBuf::from("/project");
1248        let mut results = AnalysisResults::default();
1249        results
1250            .unresolved_imports
1251            .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
1252                path: root.join("src/app.ts"),
1253                specifier: "@acme/missing-pkg".to_string(),
1254                line: 7,
1255                col: 0,
1256                specifier_col: 0,
1257            }));
1258        let elapsed = Duration::from_millis(0);
1259        let output = build_json(&results, &root, elapsed).expect("should serialize");
1260
1261        let import = &output["unresolved_imports"][0];
1262        assert_eq!(import["specifier"], "@acme/missing-pkg");
1263        assert_eq!(import["line"], 7);
1264        assert_eq!(import["path"], "src/app.ts");
1265    }
1266
1267    #[test]
1268    fn json_unlisted_dependency_contains_import_sites() {
1269        let root = PathBuf::from("/project");
1270        let mut results = AnalysisResults::default();
1271        results
1272            .unlisted_dependencies
1273            .push(UnlistedDependencyFinding::with_actions(
1274                UnlistedDependency {
1275                    package_name: "dotenv".to_string(),
1276                    imported_from: vec![
1277                        ImportSite {
1278                            path: root.join("src/config.ts"),
1279                            line: 1,
1280                            col: 0,
1281                        },
1282                        ImportSite {
1283                            path: root.join("src/server.ts"),
1284                            line: 3,
1285                            col: 0,
1286                        },
1287                    ],
1288                },
1289            ));
1290        let elapsed = Duration::from_millis(0);
1291        let output = build_json(&results, &root, elapsed).expect("should serialize");
1292
1293        let dep = &output["unlisted_dependencies"][0];
1294        assert_eq!(dep["package_name"], "dotenv");
1295        let sites = dep["imported_from"].as_array().unwrap();
1296        assert_eq!(sites.len(), 2);
1297        assert_eq!(sites[0]["path"], "src/config.ts");
1298        assert_eq!(sites[1]["path"], "src/server.ts");
1299    }
1300
1301    #[test]
1302    fn json_duplicate_export_contains_locations() {
1303        let root = PathBuf::from("/project");
1304        let mut results = AnalysisResults::default();
1305        results
1306            .duplicate_exports
1307            .push(DuplicateExportFinding::with_actions(DuplicateExport {
1308                export_name: "Button".to_string(),
1309                locations: vec![
1310                    DuplicateLocation {
1311                        path: root.join("src/ui.ts"),
1312                        line: 10,
1313                        col: 0,
1314                    },
1315                    DuplicateLocation {
1316                        path: root.join("src/components.ts"),
1317                        line: 25,
1318                        col: 0,
1319                    },
1320                ],
1321            }));
1322        let elapsed = Duration::from_millis(0);
1323        let output = build_json(&results, &root, elapsed).expect("should serialize");
1324
1325        let dup = &output["duplicate_exports"][0];
1326        assert_eq!(dup["export_name"], "Button");
1327        let locs = dup["locations"].as_array().unwrap();
1328        assert_eq!(locs.len(), 2);
1329        assert_eq!(locs[0]["line"], 10);
1330        assert_eq!(locs[1]["line"], 25);
1331    }
1332
1333    #[test]
1334    fn duplicate_export_add_to_config_is_auto_fixable_when_config_exists() {
1335        let dir = tempfile::tempdir().unwrap();
1336        let root = dir.path();
1337        std::fs::write(root.join(".fallowrc.json"), "{}\n").unwrap();
1338        let mut results = AnalysisResults::default();
1339        results
1340            .duplicate_exports
1341            .push(DuplicateExportFinding::with_actions(DuplicateExport {
1342                export_name: "Button".to_string(),
1343                locations: vec![
1344                    DuplicateLocation {
1345                        path: root.join("src/ui.ts"),
1346                        line: 10,
1347                        col: 0,
1348                    },
1349                    DuplicateLocation {
1350                        path: root.join("src/components.ts"),
1351                        line: 25,
1352                        col: 0,
1353                    },
1354                ],
1355            }));
1356
1357        let output = build_json(&results, root, Duration::ZERO).unwrap();
1358        let actions = output["duplicate_exports"][0]["actions"]
1359            .as_array()
1360            .unwrap();
1361        assert_eq!(actions[0]["type"], "add-to-config");
1362        assert_eq!(actions[0]["auto_fixable"], true);
1363    }
1364
1365    #[test]
1366    fn duplicate_export_add_to_config_is_auto_fixable_when_create_fallback_allowed() {
1367        let dir = tempfile::tempdir().unwrap();
1368        let root = dir.path();
1369        let mut results = AnalysisResults::default();
1370        results
1371            .duplicate_exports
1372            .push(DuplicateExportFinding::with_actions(DuplicateExport {
1373                export_name: "Button".to_string(),
1374                locations: vec![
1375                    DuplicateLocation {
1376                        path: root.join("src/ui.ts"),
1377                        line: 10,
1378                        col: 0,
1379                    },
1380                    DuplicateLocation {
1381                        path: root.join("src/components.ts"),
1382                        line: 25,
1383                        col: 0,
1384                    },
1385                ],
1386            }));
1387
1388        let output = build_json(&results, root, Duration::ZERO).unwrap();
1389        let actions = output["duplicate_exports"][0]["actions"]
1390            .as_array()
1391            .unwrap();
1392        assert_eq!(actions[0]["type"], "add-to-config");
1393        assert_eq!(actions[0]["auto_fixable"], true);
1394    }
1395
1396    #[test]
1397    fn duplicate_export_add_to_config_is_not_auto_fixable_in_monorepo_subpackage() {
1398        let dir = tempfile::tempdir().unwrap();
1399        let workspace = dir.path();
1400        std::fs::write(
1401            workspace.join("pnpm-workspace.yaml"),
1402            "packages:\n  - 'packages/*'\n",
1403        )
1404        .unwrap();
1405        let sub = workspace.join("packages/ui");
1406        std::fs::create_dir_all(&sub).unwrap();
1407        let mut results = AnalysisResults::default();
1408        results
1409            .duplicate_exports
1410            .push(DuplicateExportFinding::with_actions(DuplicateExport {
1411                export_name: "Button".to_string(),
1412                locations: vec![
1413                    DuplicateLocation {
1414                        path: sub.join("src/ui.ts"),
1415                        line: 10,
1416                        col: 0,
1417                    },
1418                    DuplicateLocation {
1419                        path: sub.join("src/components.ts"),
1420                        line: 25,
1421                        col: 0,
1422                    },
1423                ],
1424            }));
1425
1426        let output = build_json(&results, &sub, Duration::ZERO).unwrap();
1427        let actions = output["duplicate_exports"][0]["actions"]
1428            .as_array()
1429            .unwrap();
1430        assert_eq!(actions[0]["type"], "add-to-config");
1431        assert_eq!(actions[0]["auto_fixable"], false);
1432    }
1433
1434    #[test]
1435    fn json_type_only_dependency_contains_expected_fields() {
1436        let root = PathBuf::from("/project");
1437        let mut results = AnalysisResults::default();
1438        results
1439            .type_only_dependencies
1440            .push(TypeOnlyDependencyFinding::with_actions(
1441                TypeOnlyDependency {
1442                    package_name: "zod".to_string(),
1443                    path: root.join("package.json"),
1444                    line: 8,
1445                },
1446            ));
1447        let elapsed = Duration::from_millis(0);
1448        let output = build_json(&results, &root, elapsed).expect("should serialize");
1449
1450        let dep = &output["type_only_dependencies"][0];
1451        assert_eq!(dep["package_name"], "zod");
1452        assert_eq!(dep["line"], 8);
1453    }
1454
1455    #[test]
1456    fn json_circular_dependency_contains_expected_fields() {
1457        let root = PathBuf::from("/project");
1458        let mut results = AnalysisResults::default();
1459        results
1460            .circular_dependencies
1461            .push(CircularDependencyFinding::with_actions(
1462                CircularDependency {
1463                    files: vec![
1464                        root.join("src/a.ts"),
1465                        root.join("src/b.ts"),
1466                        root.join("src/c.ts"),
1467                    ],
1468                    length: 3,
1469                    line: 5,
1470                    col: 0,
1471                    edges: Vec::new(),
1472                    is_cross_package: false,
1473                },
1474            ));
1475        let elapsed = Duration::from_millis(0);
1476        let output = build_json(&results, &root, elapsed).expect("should serialize");
1477
1478        let cycle = &output["circular_dependencies"][0];
1479        assert_eq!(cycle["length"], 3);
1480        assert_eq!(cycle["line"], 5);
1481        let files = cycle["files"].as_array().unwrap();
1482        assert_eq!(files.len(), 3);
1483    }
1484
1485    #[test]
1486    fn json_re_export_flagged_correctly() {
1487        let root = PathBuf::from("/project");
1488        let mut results = AnalysisResults::default();
1489        results
1490            .unused_exports
1491            .push(UnusedExportFinding::with_actions(UnusedExport {
1492                path: root.join("src/index.ts"),
1493                export_name: "reExported".to_string(),
1494                is_type_only: false,
1495                line: 1,
1496                col: 0,
1497                span_start: 0,
1498                is_re_export: true,
1499            }));
1500        let elapsed = Duration::from_millis(0);
1501        let output = build_json(&results, &root, elapsed).expect("should serialize");
1502
1503        assert_eq!(output["unused_exports"][0]["is_re_export"], true);
1504    }
1505
1506    #[test]
1507    fn json_schema_version_is_pinned() {
1508        let root = PathBuf::from("/project");
1509        let results = AnalysisResults::default();
1510        let elapsed = Duration::from_millis(0);
1511        let output = build_json(&results, &root, elapsed).expect("should serialize");
1512
1513        assert_eq!(output["schema_version"], SCHEMA_VERSION);
1514        assert_eq!(output["schema_version"], 7);
1515    }
1516
1517    #[test]
1518    fn json_version_matches_cargo_pkg_version() {
1519        let root = PathBuf::from("/project");
1520        let results = AnalysisResults::default();
1521        let elapsed = Duration::from_millis(0);
1522        let output = build_json(&results, &root, elapsed).expect("should serialize");
1523
1524        assert_eq!(output["version"], env!("CARGO_PKG_VERSION"));
1525    }
1526
1527    #[test]
1528    fn json_elapsed_ms_zero_duration() {
1529        let root = PathBuf::from("/project");
1530        let results = AnalysisResults::default();
1531        let output = build_json(&results, &root, Duration::ZERO).expect("should serialize");
1532
1533        assert_eq!(output["elapsed_ms"], 0);
1534    }
1535
1536    #[test]
1537    fn json_elapsed_ms_large_duration() {
1538        let root = PathBuf::from("/project");
1539        let results = AnalysisResults::default();
1540        let elapsed = Duration::from_mins(2);
1541        let output = build_json(&results, &root, elapsed).expect("should serialize");
1542
1543        assert_eq!(output["elapsed_ms"], 120_000);
1544    }
1545
1546    #[test]
1547    fn json_elapsed_ms_sub_millisecond_truncated() {
1548        let root = PathBuf::from("/project");
1549        let results = AnalysisResults::default();
1550        let elapsed = Duration::from_micros(500);
1551        let output = build_json(&results, &root, elapsed).expect("should serialize");
1552
1553        assert_eq!(output["elapsed_ms"], 0);
1554    }
1555
1556    #[test]
1557    fn json_multiple_unused_files() {
1558        let root = PathBuf::from("/project");
1559        let mut results = AnalysisResults::default();
1560        results
1561            .unused_files
1562            .push(UnusedFileFinding::with_actions(UnusedFile {
1563                path: root.join("src/a.ts"),
1564            }));
1565        results
1566            .unused_files
1567            .push(UnusedFileFinding::with_actions(UnusedFile {
1568                path: root.join("src/b.ts"),
1569            }));
1570        results
1571            .unused_files
1572            .push(UnusedFileFinding::with_actions(UnusedFile {
1573                path: root.join("src/c.ts"),
1574            }));
1575        let elapsed = Duration::from_millis(0);
1576        let output = build_json(&results, &root, elapsed).expect("should serialize");
1577
1578        assert_eq!(output["unused_files"].as_array().unwrap().len(), 3);
1579        assert_eq!(output["total_issues"], 3);
1580    }
1581
1582    #[test]
1583    fn strip_root_prefix_on_string_value() {
1584        let mut value = serde_json::json!("/project/src/file.ts");
1585        strip_root_prefix(&mut value, "/project/");
1586        assert_eq!(value, "src/file.ts");
1587    }
1588
1589    #[test]
1590    fn strip_root_prefix_leaves_non_matching_string() {
1591        let mut value = serde_json::json!("/other/src/file.ts");
1592        strip_root_prefix(&mut value, "/project/");
1593        assert_eq!(value, "/other/src/file.ts");
1594    }
1595
1596    #[test]
1597    fn strip_root_prefix_recurses_into_arrays() {
1598        let mut value = serde_json::json!(["/project/a.ts", "/project/b.ts", "/other/c.ts"]);
1599        strip_root_prefix(&mut value, "/project/");
1600        assert_eq!(value[0], "a.ts");
1601        assert_eq!(value[1], "b.ts");
1602        assert_eq!(value[2], "/other/c.ts");
1603    }
1604
1605    #[test]
1606    fn strip_root_prefix_recurses_into_nested_objects() {
1607        let mut value = serde_json::json!({
1608            "outer": {
1609                "path": "/project/src/nested.ts"
1610            }
1611        });
1612        strip_root_prefix(&mut value, "/project/");
1613        assert_eq!(value["outer"]["path"], "src/nested.ts");
1614    }
1615
1616    #[test]
1617    fn strip_root_prefix_leaves_numbers_and_booleans() {
1618        let mut value = serde_json::json!({
1619            "line": 42,
1620            "is_type_only": false,
1621            "path": "/project/src/file.ts"
1622        });
1623        strip_root_prefix(&mut value, "/project/");
1624        assert_eq!(value["line"], 42);
1625        assert_eq!(value["is_type_only"], false);
1626        assert_eq!(value["path"], "src/file.ts");
1627    }
1628
1629    #[test]
1630    fn strip_root_prefix_normalizes_windows_separators() {
1631        let mut value = serde_json::json!(r"/project\src\file.ts");
1632        strip_root_prefix(&mut value, "/project/");
1633        assert_eq!(value, "src/file.ts");
1634    }
1635
1636    #[test]
1637    fn strip_root_prefix_handles_empty_string_after_strip() {
1638        let mut value = serde_json::json!("/project/");
1639        strip_root_prefix(&mut value, "/project/");
1640        assert_eq!(value, "");
1641    }
1642
1643    #[test]
1644    fn strip_root_prefix_deeply_nested_array_of_objects() {
1645        let mut value = serde_json::json!({
1646            "groups": [{
1647                "instances": [{
1648                    "file": "/project/src/a.ts"
1649                }, {
1650                    "file": "/project/src/b.ts"
1651                }]
1652            }]
1653        });
1654        strip_root_prefix(&mut value, "/project/");
1655        assert_eq!(value["groups"][0]["instances"][0]["file"], "src/a.ts");
1656        assert_eq!(value["groups"][0]["instances"][1]["file"], "src/b.ts");
1657    }
1658
1659    #[test]
1660    fn json_full_sample_results_total_issues_correct() {
1661        let root = PathBuf::from("/project");
1662        let results = sample_results(&root);
1663        let elapsed = Duration::from_millis(100);
1664        let output = build_json(&results, &root, elapsed).expect("should serialize");
1665
1666        assert_eq!(output["total_issues"], results.total_issues());
1667    }
1668
1669    #[test]
1670    fn json_full_sample_no_absolute_paths_in_output() {
1671        let root = PathBuf::from("/project");
1672        let results = sample_results(&root);
1673        let elapsed = Duration::from_millis(0);
1674        let output = build_json(&results, &root, elapsed).expect("should serialize");
1675
1676        let json_str = serde_json::to_string(&output).expect("should stringify");
1677        assert!(!json_str.contains("/project/src/"));
1678        assert!(!json_str.contains("/project/package.json"));
1679    }
1680
1681    #[test]
1682    fn json_output_is_deterministic() {
1683        let root = PathBuf::from("/project");
1684        let results = sample_results(&root);
1685        let elapsed = Duration::from_millis(50);
1686
1687        let output1 = build_json(&results, &root, elapsed).expect("first build");
1688        let output2 = build_json(&results, &root, elapsed).expect("second build");
1689
1690        assert_eq!(output1, output2);
1691    }
1692
1693    #[test]
1694    fn json_results_fields_do_not_shadow_metadata() {
1695        let root = PathBuf::from("/project");
1696        let results = AnalysisResults::default();
1697        let elapsed = Duration::from_millis(99);
1698        let output = build_json(&results, &root, elapsed).expect("should serialize");
1699
1700        assert_eq!(output["kind"], "dead-code");
1701        assert_eq!(output["schema_version"], 7);
1702        assert_eq!(output["elapsed_ms"], 99);
1703    }
1704
1705    #[test]
1706    fn json_all_issue_type_arrays_present_in_empty_results() {
1707        let root = PathBuf::from("/project");
1708        let results = AnalysisResults::default();
1709        let elapsed = Duration::from_millis(0);
1710        let output = build_json(&results, &root, elapsed).expect("should serialize");
1711
1712        let expected_arrays = [
1713            "unused_files",
1714            "unused_exports",
1715            "unused_types",
1716            "unused_dependencies",
1717            "unused_dev_dependencies",
1718            "unused_optional_dependencies",
1719            "unused_enum_members",
1720            "unused_class_members",
1721            "unresolved_imports",
1722            "unlisted_dependencies",
1723            "duplicate_exports",
1724            "type_only_dependencies",
1725            "test_only_dependencies",
1726            "circular_dependencies",
1727        ];
1728        for key in &expected_arrays {
1729            assert!(
1730                output[key].is_array(),
1731                "expected '{key}' to be an array in JSON output"
1732            );
1733        }
1734    }
1735
1736    #[test]
1737    fn insert_meta_adds_key_to_object() {
1738        let mut output = serde_json::json!({ "foo": 1 });
1739        let meta = serde_json::json!({ "docs": "https://example.com" });
1740        insert_meta(&mut output, meta.clone());
1741        assert_eq!(output["_meta"], meta);
1742    }
1743
1744    #[test]
1745    fn insert_meta_noop_on_non_object() {
1746        let mut output = serde_json::json!([1, 2, 3]);
1747        let meta = serde_json::json!({ "docs": "https://example.com" });
1748        insert_meta(&mut output, meta);
1749        assert!(output.is_array());
1750    }
1751
1752    #[test]
1753    fn insert_meta_overwrites_existing_meta() {
1754        let mut output = serde_json::json!({ "_meta": "old" });
1755        let meta = serde_json::json!({ "new": true });
1756        insert_meta(&mut output, meta.clone());
1757        assert_eq!(output["_meta"], meta);
1758    }
1759
1760    #[test]
1761    fn strip_root_prefix_null_unchanged() {
1762        let mut value = serde_json::Value::Null;
1763        strip_root_prefix(&mut value, "/project/");
1764        assert!(value.is_null());
1765    }
1766
1767    #[test]
1768    fn strip_root_prefix_empty_string() {
1769        let mut value = serde_json::json!("");
1770        strip_root_prefix(&mut value, "/project/");
1771        assert_eq!(value, "");
1772    }
1773
1774    #[test]
1775    fn strip_root_prefix_mixed_types() {
1776        let mut value = serde_json::json!({
1777            "path": "/project/src/file.ts",
1778            "line": 42,
1779            "flag": true,
1780            "nested": {
1781                "items": ["/project/a.ts", 99, null, "/project/b.ts"],
1782                "deep": { "path": "/project/c.ts" }
1783            }
1784        });
1785        strip_root_prefix(&mut value, "/project/");
1786        assert_eq!(value["path"], "src/file.ts");
1787        assert_eq!(value["line"], 42);
1788        assert_eq!(value["flag"], true);
1789        assert_eq!(value["nested"]["items"][0], "a.ts");
1790        assert_eq!(value["nested"]["items"][1], 99);
1791        assert!(value["nested"]["items"][2].is_null());
1792        assert_eq!(value["nested"]["items"][3], "b.ts");
1793        assert_eq!(value["nested"]["deep"]["path"], "c.ts");
1794    }
1795
1796    #[test]
1797    fn json_check_meta_integrates_correctly() {
1798        let root = PathBuf::from("/project");
1799        let results = AnalysisResults::default();
1800        let elapsed = Duration::from_millis(0);
1801        let mut output = build_json(&results, &root, elapsed).expect("should serialize");
1802        insert_meta(&mut output, crate::explain::check_meta());
1803
1804        assert!(output["_meta"]["docs"].is_string());
1805        assert!(output["_meta"]["rules"].is_object());
1806    }
1807
1808    #[test]
1809    fn json_unused_member_kind_serialized() {
1810        let root = PathBuf::from("/project");
1811        let mut results = AnalysisResults::default();
1812        results
1813            .unused_enum_members
1814            .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
1815                path: root.join("src/enums.ts"),
1816                parent_name: "Color".to_string(),
1817                member_name: "Red".to_string(),
1818                kind: MemberKind::EnumMember,
1819                line: 3,
1820                col: 2,
1821            }));
1822        results
1823            .unused_class_members
1824            .push(UnusedClassMemberFinding::with_actions(UnusedMember {
1825                path: root.join("src/class.ts"),
1826                parent_name: "Foo".to_string(),
1827                member_name: "bar".to_string(),
1828                kind: MemberKind::ClassMethod,
1829                line: 10,
1830                col: 4,
1831            }));
1832
1833        let elapsed = Duration::from_millis(0);
1834        let output = build_json(&results, &root, elapsed).expect("should serialize");
1835
1836        let enum_member = &output["unused_enum_members"][0];
1837        assert!(enum_member["kind"].is_string());
1838        let class_member = &output["unused_class_members"][0];
1839        assert!(class_member["kind"].is_string());
1840    }
1841
1842    #[test]
1843    fn json_unused_export_has_actions() {
1844        let root = PathBuf::from("/project");
1845        let mut results = AnalysisResults::default();
1846        results
1847            .unused_exports
1848            .push(UnusedExportFinding::with_actions(UnusedExport {
1849                path: root.join("src/utils.ts"),
1850                export_name: "helperFn".to_string(),
1851                is_type_only: false,
1852                line: 10,
1853                col: 4,
1854                span_start: 120,
1855                is_re_export: false,
1856            }));
1857        let output = build_json(&results, &root, Duration::ZERO).unwrap();
1858
1859        let actions = output["unused_exports"][0]["actions"].as_array().unwrap();
1860        assert_eq!(actions.len(), 2);
1861
1862        assert_eq!(actions[0]["type"], "remove-export");
1863        assert_eq!(actions[0]["auto_fixable"], true);
1864        assert!(actions[0].get("note").is_none());
1865
1866        assert_eq!(actions[1]["type"], "suppress-line");
1867        assert_eq!(
1868            actions[1]["comment"],
1869            "// fallow-ignore-next-line unused-export"
1870        );
1871    }
1872
1873    #[test]
1874    fn json_same_line_findings_share_multi_kind_suppression_comment() {
1875        let root = PathBuf::from("/project");
1876        let mut results = AnalysisResults::default();
1877        results
1878            .unused_exports
1879            .push(UnusedExportFinding::with_actions(UnusedExport {
1880                path: root.join("src/api.ts"),
1881                export_name: "helperFn".to_string(),
1882                is_type_only: false,
1883                line: 10,
1884                col: 4,
1885                span_start: 120,
1886                is_re_export: false,
1887            }));
1888        results
1889            .unused_types
1890            .push(UnusedTypeFinding::with_actions(UnusedExport {
1891                path: root.join("src/api.ts"),
1892                export_name: "OldType".to_string(),
1893                is_type_only: true,
1894                line: 10,
1895                col: 0,
1896                span_start: 60,
1897                is_re_export: false,
1898            }));
1899        let output = build_json(&results, &root, Duration::ZERO).unwrap();
1900
1901        let export_actions = output["unused_exports"][0]["actions"].as_array().unwrap();
1902        let type_actions = output["unused_types"][0]["actions"].as_array().unwrap();
1903        assert_eq!(
1904            export_actions[1]["comment"],
1905            "// fallow-ignore-next-line unused-export, unused-type"
1906        );
1907        assert_eq!(
1908            type_actions[1]["comment"],
1909            "// fallow-ignore-next-line unused-export, unused-type"
1910        );
1911    }
1912
1913    #[test]
1914    fn audit_like_json_shares_suppression_comment_across_dead_code_and_complexity() {
1915        let mut output = serde_json::json!({
1916            "dead_code": {
1917                "unused_exports": [{
1918                    "path": "src/main.ts",
1919                    "line": 1,
1920                    "actions": [
1921                        { "type": "remove-export", "auto_fixable": true },
1922                        {
1923                            "type": "suppress-line",
1924                            "auto_fixable": false,
1925                            "comment": "// fallow-ignore-next-line unused-export"
1926                        }
1927                    ]
1928                }]
1929            },
1930            "complexity": {
1931                "findings": [{
1932                    "path": "src/main.ts",
1933                    "line": 1,
1934                    "actions": [
1935                        { "type": "refactor-function", "auto_fixable": false },
1936                        {
1937                            "type": "suppress-line",
1938                            "auto_fixable": false,
1939                            "comment": "// fallow-ignore-next-line complexity"
1940                        }
1941                    ]
1942                }]
1943            }
1944        });
1945
1946        harmonize_multi_kind_suppress_line_actions(&mut output);
1947
1948        assert_eq!(
1949            output["dead_code"]["unused_exports"][0]["actions"][1]["comment"],
1950            "// fallow-ignore-next-line unused-export, complexity"
1951        );
1952        assert_eq!(
1953            output["complexity"]["findings"][0]["actions"][1]["comment"],
1954            "// fallow-ignore-next-line unused-export, complexity"
1955        );
1956    }
1957
1958    #[test]
1959    fn json_unused_file_has_file_suppress_and_note() {
1960        let root = PathBuf::from("/project");
1961        let mut results = AnalysisResults::default();
1962        results
1963            .unused_files
1964            .push(UnusedFileFinding::with_actions(UnusedFile {
1965                path: root.join("src/dead.ts"),
1966            }));
1967        let output = build_json(&results, &root, Duration::ZERO).unwrap();
1968
1969        let actions = output["unused_files"][0]["actions"].as_array().unwrap();
1970        assert_eq!(actions[0]["type"], "delete-file");
1971        assert_eq!(actions[0]["auto_fixable"], false);
1972        assert!(actions[0]["note"].is_string());
1973        assert_eq!(actions[1]["type"], "suppress-file");
1974        assert_eq!(actions[1]["comment"], "// fallow-ignore-file unused-file");
1975    }
1976
1977    #[test]
1978    fn json_unused_dependency_has_config_suppress_with_package_name() {
1979        let root = PathBuf::from("/project");
1980        let mut results = AnalysisResults::default();
1981        results
1982            .unused_dependencies
1983            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1984                package_name: "lodash".to_string(),
1985                location: DependencyLocation::Dependencies,
1986                path: root.join("package.json"),
1987                line: 5,
1988                used_in_workspaces: Vec::new(),
1989            }));
1990        let output = build_json(&results, &root, Duration::ZERO).unwrap();
1991
1992        let actions = output["unused_dependencies"][0]["actions"]
1993            .as_array()
1994            .unwrap();
1995        assert_eq!(actions[0]["type"], "remove-dependency");
1996        assert_eq!(actions[0]["auto_fixable"], true);
1997
1998        assert_eq!(actions[1]["type"], "add-to-config");
1999        assert_eq!(actions[1]["config_key"], "ignoreDependencies");
2000        assert_eq!(actions[1]["value"], "lodash");
2001    }
2002
2003    #[test]
2004    fn json_cross_workspace_dependency_is_not_auto_fixable() {
2005        let root = PathBuf::from("/project");
2006        let mut results = AnalysisResults::default();
2007        results
2008            .unused_dependencies
2009            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2010                package_name: "lodash-es".to_string(),
2011                location: DependencyLocation::Dependencies,
2012                path: root.join("packages/shared/package.json"),
2013                line: 5,
2014                used_in_workspaces: vec![root.join("packages/consumer")],
2015            }));
2016        let output = build_json(&results, &root, Duration::ZERO).unwrap();
2017
2018        let actions = output["unused_dependencies"][0]["actions"]
2019            .as_array()
2020            .unwrap();
2021        assert_eq!(actions[0]["type"], "move-dependency");
2022        assert_eq!(actions[0]["auto_fixable"], false);
2023        assert!(
2024            actions[0]["note"]
2025                .as_str()
2026                .unwrap()
2027                .contains("will not remove")
2028        );
2029        assert_eq!(actions[1]["type"], "add-to-config");
2030    }
2031
2032    #[test]
2033    fn json_empty_results_have_no_actions_in_empty_arrays() {
2034        let root = PathBuf::from("/project");
2035        let results = AnalysisResults::default();
2036        let output = build_json(&results, &root, Duration::ZERO).unwrap();
2037
2038        assert!(output["unused_exports"].as_array().unwrap().is_empty());
2039        assert!(output["unused_files"].as_array().unwrap().is_empty());
2040    }
2041
2042    #[test]
2043    fn json_all_issue_types_have_actions() {
2044        let root = PathBuf::from("/project");
2045        let results = sample_results(&root);
2046        let output = build_json(&results, &root, Duration::ZERO).unwrap();
2047
2048        let issue_keys = [
2049            "unused_files",
2050            "unused_exports",
2051            "unused_types",
2052            "unused_dependencies",
2053            "unused_dev_dependencies",
2054            "unused_optional_dependencies",
2055            "unused_enum_members",
2056            "unused_class_members",
2057            "unresolved_imports",
2058            "unlisted_dependencies",
2059            "duplicate_exports",
2060            "type_only_dependencies",
2061            "test_only_dependencies",
2062            "circular_dependencies",
2063        ];
2064
2065        for key in &issue_keys {
2066            let arr = output[key].as_array().unwrap();
2067            if !arr.is_empty() {
2068                let actions = arr[0]["actions"].as_array();
2069                assert!(
2070                    actions.is_some() && !actions.unwrap().is_empty(),
2071                    "missing actions for {key}"
2072                );
2073            }
2074        }
2075    }
2076
2077    /// Test helper: deserialize a JSON finding shape into a typed
2078    /// [`ComplexityViolation`], run [`HealthFinding::with_actions`] with
2079    /// the supplied thresholds, and return the resulting `actions` array
2080    /// as `serde_json::Value` so existing JSON-shape assertions keep
2081    /// working after PR B2 of #384 moved finding action selection from
2082    /// the JSON post-pass into the typed wrapper.
2083    fn build_actions_for_finding_json(
2084        finding_json: serde_json::Value,
2085        opts: crate::health_types::HealthActionOptions,
2086        max_cyclomatic_threshold: u16,
2087        max_cognitive_threshold: u16,
2088        max_crap_threshold: f64,
2089    ) -> Vec<serde_json::Value> {
2090        let mut value = finding_json;
2091        if let Some(map) = value.as_object_mut() {
2092            map.entry("col".to_string())
2093                .or_insert(serde_json::Value::from(0_u32));
2094            map.entry("line_count".to_string())
2095                .or_insert(serde_json::Value::from(0_u32));
2096            map.entry("param_count".to_string())
2097                .or_insert(serde_json::Value::from(0_u8));
2098            map.entry("severity".to_string())
2099                .or_insert(serde_json::Value::String("moderate".to_string()));
2100        }
2101        let violation = synthesize_complexity_violation(&value);
2102        let ctx = crate::health_types::HealthActionContext {
2103            opts,
2104            max_cyclomatic_threshold,
2105            max_cognitive_threshold,
2106            max_crap_threshold,
2107            crap_refactor_band: 5,
2108        };
2109        let finding = crate::health_types::HealthFinding::with_actions(violation, &ctx);
2110        let serialized = serde_json::to_value(&finding).expect("serialize HealthFinding");
2111        serialized["actions"]
2112            .as_array()
2113            .cloned()
2114            .unwrap_or_default()
2115    }
2116
2117    /// Reads a JSON object with finding-shape fields and produces a
2118    /// [`ComplexityViolation`]. Test-only: panics on schema mismatches so
2119    /// authors notice when synthetic fixtures drift from the canonical
2120    /// shape.
2121    fn synthesize_complexity_violation(
2122        value: &serde_json::Value,
2123    ) -> crate::health_types::ComplexityViolation {
2124        use crate::health_types::{
2125            CoverageSource, CoverageTier, ExceededThreshold, FindingSeverity,
2126        };
2127        let exceeded = match value["exceeded"].as_str().unwrap_or("crap") {
2128            "cyclomatic" => ExceededThreshold::Cyclomatic,
2129            "cognitive" => ExceededThreshold::Cognitive,
2130            "both" => ExceededThreshold::Both,
2131            "crap" => ExceededThreshold::Crap,
2132            "cyclomatic_crap" => ExceededThreshold::CyclomaticCrap,
2133            "cognitive_crap" => ExceededThreshold::CognitiveCrap,
2134            "all" => ExceededThreshold::All,
2135            other => panic!("unknown exceeded label: {other}"),
2136        };
2137        let severity = match value["severity"].as_str().unwrap_or("moderate") {
2138            "moderate" => FindingSeverity::Moderate,
2139            "high" => FindingSeverity::High,
2140            "critical" => FindingSeverity::Critical,
2141            other => panic!("unknown severity label: {other}"),
2142        };
2143        let coverage_tier = value
2144            .get("coverage_tier")
2145            .and_then(|v| v.as_str())
2146            .map(|t| match t {
2147                "none" => CoverageTier::None,
2148                "partial" => CoverageTier::Partial,
2149                "high" => CoverageTier::High,
2150                other => panic!("unknown coverage_tier label: {other}"),
2151            });
2152        let coverage_source =
2153            value
2154                .get("coverage_source")
2155                .and_then(|v| v.as_str())
2156                .map(|s| match s {
2157                    "istanbul" => CoverageSource::Istanbul,
2158                    "estimated" => CoverageSource::Estimated,
2159                    "estimated_component_inherited" => CoverageSource::EstimatedComponentInherited,
2160                    other => panic!("unknown coverage_source label: {other}"),
2161                });
2162        crate::health_types::ComplexityViolation {
2163            path: std::path::PathBuf::from(value["path"].as_str().unwrap_or("src/x.ts")),
2164            name: value["name"].as_str().unwrap_or("fn").to_string(),
2165            line: u32::try_from(value["line"].as_u64().unwrap_or(0)).unwrap_or(0),
2166            col: u32::try_from(value["col"].as_u64().unwrap_or(0)).unwrap_or(0),
2167            cyclomatic: u16::try_from(value["cyclomatic"].as_u64().unwrap_or(0)).unwrap_or(0),
2168            cognitive: u16::try_from(value["cognitive"].as_u64().unwrap_or(0)).unwrap_or(0),
2169            line_count: u32::try_from(value["line_count"].as_u64().unwrap_or(0)).unwrap_or(0),
2170            param_count: u8::try_from(value["param_count"].as_u64().unwrap_or(0)).unwrap_or(0),
2171            exceeded,
2172            severity,
2173            crap: value.get("crap").and_then(|v| v.as_f64()),
2174            coverage_pct: value.get("coverage_pct").and_then(|v| v.as_f64()),
2175            coverage_tier,
2176            coverage_source,
2177            inherited_from: value
2178                .get("inherited_from")
2179                .and_then(|v| v.as_str())
2180                .map(std::path::PathBuf::from),
2181            component_rollup: value.get("component_rollup").and_then(|v| {
2182                let map = v.as_object()?;
2183                Some(crate::health_types::ComponentRollup {
2184                    component: map.get("component")?.as_str()?.to_string(),
2185                    class_worst_function: map.get("class_worst_function")?.as_str()?.to_string(),
2186                    class_cyclomatic: u16::try_from(map.get("class_cyclomatic")?.as_u64()?).ok()?,
2187                    class_cognitive: u16::try_from(map.get("class_cognitive")?.as_u64()?).ok()?,
2188                    template_path: std::path::PathBuf::from(map.get("template_path")?.as_str()?),
2189                    template_cyclomatic: u16::try_from(map.get("template_cyclomatic")?.as_u64()?)
2190                        .ok()?,
2191                    template_cognitive: u16::try_from(map.get("template_cognitive")?.as_u64()?)
2192                        .ok()?,
2193                })
2194            }),
2195            contributions: Vec::new(),
2196        }
2197    }
2198
2199    #[test]
2200    fn health_finding_has_actions() {
2201        let actions = build_actions_for_finding_json(
2202            serde_json::json!({
2203                "path": "src/utils.ts",
2204                "name": "processData",
2205                "line": 10,
2206                "col": 0,
2207                "cyclomatic": 25,
2208                "cognitive": 30,
2209                "line_count": 150,
2210                "exceeded": "both"
2211            }),
2212            crate::health_types::HealthActionOptions::default(),
2213            20,
2214            15,
2215            30.0,
2216        );
2217
2218        assert_eq!(actions.len(), 2);
2219        assert_eq!(actions[0]["type"], "refactor-function");
2220        assert_eq!(actions[0]["auto_fixable"], false);
2221        assert!(
2222            actions[0]["description"]
2223                .as_str()
2224                .unwrap()
2225                .contains("processData")
2226        );
2227        assert_eq!(actions[1]["type"], "suppress-line");
2228        assert_eq!(
2229            actions[1]["comment"],
2230            "// fallow-ignore-next-line complexity"
2231        );
2232    }
2233
2234    #[test]
2235    fn health_finding_suppress_has_placement() {
2236        let actions = build_actions_for_finding_json(
2237            serde_json::json!({
2238                "path": "src/utils.ts",
2239                "name": "processData",
2240                "line": 10,
2241                "col": 0,
2242                "cyclomatic": 25,
2243                "cognitive": 30,
2244                "line_count": 150,
2245                "exceeded": "both"
2246            }),
2247            crate::health_types::HealthActionOptions::default(),
2248            20,
2249            15,
2250            30.0,
2251        );
2252
2253        assert_eq!(actions[1]["placement"], "above-function-declaration");
2254    }
2255
2256    #[test]
2257    fn html_template_health_finding_uses_html_suppression() {
2258        let actions = build_actions_for_finding_json(
2259            serde_json::json!({
2260                "path": "src/app.component.html",
2261                "name": "<template>",
2262                "line": 1,
2263                "col": 0,
2264                "cyclomatic": 25,
2265                "cognitive": 30,
2266                "line_count": 40,
2267                "exceeded": "both"
2268            }),
2269            crate::health_types::HealthActionOptions::default(),
2270            20,
2271            15,
2272            30.0,
2273        );
2274
2275        let suppress = &actions[1];
2276        assert_eq!(suppress["type"], "suppress-file");
2277        assert_eq!(
2278            suppress["comment"],
2279            "<!-- fallow-ignore-file complexity -->"
2280        );
2281        assert_eq!(suppress["placement"], "top-of-template");
2282    }
2283
2284    #[test]
2285    fn inline_template_health_finding_uses_decorator_suppression() {
2286        let actions = build_actions_for_finding_json(
2287            serde_json::json!({
2288                "path": "src/app.component.ts",
2289                "name": "<template>",
2290                "line": 5,
2291                "col": 0,
2292                "cyclomatic": 25,
2293                "cognitive": 30,
2294                "line_count": 40,
2295                "exceeded": "both"
2296            }),
2297            crate::health_types::HealthActionOptions::default(),
2298            20,
2299            15,
2300            30.0,
2301        );
2302
2303        let refactor = &actions[0];
2304        assert_eq!(refactor["type"], "refactor-function");
2305        assert!(
2306            refactor["description"]
2307                .as_str()
2308                .unwrap()
2309                .contains("template complexity")
2310        );
2311        let suppress = &actions[1];
2312        assert_eq!(suppress["type"], "suppress-line");
2313        assert_eq!(
2314            suppress["description"],
2315            "Suppress with an inline comment above the Angular decorator"
2316        );
2317        assert_eq!(suppress["placement"], "above-angular-decorator");
2318    }
2319
2320    /// Helper: build a health JSON envelope with a single CRAP-only finding.
2321    /// Default cognitive complexity is 12 (above the cognitive floor at the
2322    /// default `max_cognitive_threshold / 2 = 7.5`); use
2323    /// `crap_only_finding_envelope_with_cognitive` to exercise low-cog cases
2324    /// (flat dispatchers, JSX render maps) where the cognitive floor should
2325    /// suppress the secondary refactor.
2326    fn crap_only_finding_envelope(
2327        coverage_tier: Option<&str>,
2328        cyclomatic: u16,
2329        max_cyclomatic_threshold: u16,
2330    ) -> serde_json::Value {
2331        crap_only_finding_envelope_with_max_crap(
2332            coverage_tier,
2333            cyclomatic,
2334            12,
2335            max_cyclomatic_threshold,
2336            15,
2337            30.0,
2338        )
2339    }
2340
2341    fn crap_only_finding_envelope_with_cognitive(
2342        coverage_tier: Option<&str>,
2343        cyclomatic: u16,
2344        cognitive: u16,
2345        max_cyclomatic_threshold: u16,
2346    ) -> serde_json::Value {
2347        crap_only_finding_envelope_with_max_crap(
2348            coverage_tier,
2349            cyclomatic,
2350            cognitive,
2351            max_cyclomatic_threshold,
2352            15,
2353            30.0,
2354        )
2355    }
2356
2357    /// Build a synthetic health JSON envelope around a single typed
2358    /// [`HealthFinding`] so the existing JSON-shaped assertions in this
2359    /// module keep working after PR B2 of #384 moved action selection from
2360    /// the JSON post-pass into [`HealthFinding::with_actions`]. Defaults to
2361    /// the un-suppressed action context; callers that want to exercise the
2362    /// `omit_suppress_line` path should go through
2363    /// [`build_finding_envelope_with_ctx`].
2364    fn crap_only_finding_envelope_with_max_crap(
2365        coverage_tier: Option<&str>,
2366        cyclomatic: u16,
2367        cognitive: u16,
2368        max_cyclomatic_threshold: u16,
2369        max_cognitive_threshold: u16,
2370        max_crap_threshold: f64,
2371    ) -> serde_json::Value {
2372        build_finding_envelope_with_ctx(
2373            coverage_tier,
2374            cyclomatic,
2375            cognitive,
2376            max_cyclomatic_threshold,
2377            max_cognitive_threshold,
2378            max_crap_threshold,
2379            crate::health_types::HealthActionOptions::default(),
2380        )
2381    }
2382
2383    /// Build a single-finding health JSON envelope with the supplied action
2384    /// context. Used by the suppress-line gating tests to exercise the
2385    /// `baseline-active` / `config-disabled` reasons.
2386    fn build_finding_envelope_with_ctx(
2387        coverage_tier: Option<&str>,
2388        cyclomatic: u16,
2389        cognitive: u16,
2390        max_cyclomatic_threshold: u16,
2391        max_cognitive_threshold: u16,
2392        max_crap_threshold: f64,
2393        action_opts: crate::health_types::HealthActionOptions,
2394    ) -> serde_json::Value {
2395        let tier = coverage_tier.map(|t| match t {
2396            "none" => crate::health_types::CoverageTier::None,
2397            "partial" => crate::health_types::CoverageTier::Partial,
2398            "high" => crate::health_types::CoverageTier::High,
2399            other => panic!("unknown coverage tier label: {other}"),
2400        });
2401        let violation = crate::health_types::ComplexityViolation {
2402            path: std::path::PathBuf::from("src/risk.ts"),
2403            name: "computeScore".to_string(),
2404            line: 12,
2405            col: 0,
2406            cyclomatic,
2407            cognitive,
2408            line_count: 40,
2409            param_count: 0,
2410            exceeded: crate::health_types::ExceededThreshold::Crap,
2411            severity: crate::health_types::FindingSeverity::Moderate,
2412            crap: Some(35.5),
2413            coverage_pct: None,
2414            coverage_tier: tier,
2415            coverage_source: None,
2416            inherited_from: None,
2417            component_rollup: None,
2418            contributions: Vec::new(),
2419        };
2420        let ctx = crate::health_types::HealthActionContext {
2421            opts: action_opts,
2422            max_cyclomatic_threshold,
2423            max_cognitive_threshold,
2424            max_crap_threshold,
2425            crap_refactor_band: 5,
2426        };
2427        let finding = crate::health_types::HealthFinding::with_actions(violation, &ctx);
2428        let actions_meta = if action_opts.omit_suppress_line {
2429            Some(serde_json::json!({
2430                "suppression_hints_omitted": true,
2431                "reason": action_opts.omit_reason.unwrap_or("unspecified"),
2432                "scope": "health-findings",
2433            }))
2434        } else {
2435            None
2436        };
2437        let mut envelope = serde_json::json!({
2438            "findings": [serde_json::to_value(&finding).unwrap()],
2439            "summary": {
2440                "max_cyclomatic_threshold": max_cyclomatic_threshold,
2441                "max_cognitive_threshold": max_cognitive_threshold,
2442                "max_crap_threshold": max_crap_threshold,
2443            },
2444        });
2445        if let Some(meta) = actions_meta
2446            && let Some(map) = envelope.as_object_mut()
2447        {
2448            map.insert("actions_meta".to_string(), meta);
2449        }
2450        envelope
2451    }
2452
2453    #[test]
2454    fn crap_only_tier_none_emits_add_tests() {
2455        let output = crap_only_finding_envelope(Some("none"), 6, 20);
2456        let actions = output["findings"][0]["actions"].as_array().unwrap();
2457        assert!(
2458            actions.iter().any(|a| a["type"] == "add-tests"),
2459            "tier=none crap-only must emit add-tests, got {actions:?}"
2460        );
2461        assert!(
2462            !actions.iter().any(|a| a["type"] == "increase-coverage"),
2463            "tier=none must not emit increase-coverage"
2464        );
2465    }
2466
2467    #[test]
2468    fn crap_only_tier_partial_emits_increase_coverage() {
2469        let output = crap_only_finding_envelope(Some("partial"), 6, 20);
2470        let actions = output["findings"][0]["actions"].as_array().unwrap();
2471        assert!(
2472            actions.iter().any(|a| a["type"] == "increase-coverage"),
2473            "tier=partial crap-only must emit increase-coverage, got {actions:?}"
2474        );
2475        assert!(
2476            !actions.iter().any(|a| a["type"] == "add-tests"),
2477            "tier=partial must not emit add-tests"
2478        );
2479    }
2480
2481    #[test]
2482    fn crap_only_tier_high_emits_increase_coverage_when_full_coverage_can_clear_crap() {
2483        let output = crap_only_finding_envelope(Some("high"), 20, 30);
2484        let actions = output["findings"][0]["actions"].as_array().unwrap();
2485        assert!(
2486            actions.iter().any(|a| a["type"] == "increase-coverage"),
2487            "tier=high crap-only must still emit increase-coverage when full coverage can clear CRAP, got {actions:?}"
2488        );
2489        assert!(
2490            !actions.iter().any(|a| a["type"] == "refactor-function"),
2491            "coverage-remediable crap-only findings should not get refactor-function unless near the cyclomatic threshold"
2492        );
2493        assert!(
2494            !actions.iter().any(|a| a["type"] == "add-tests"),
2495            "tier=high must not emit add-tests"
2496        );
2497    }
2498
2499    #[test]
2500    fn crap_only_emits_refactor_when_full_coverage_cannot_clear_crap() {
2501        let output = crap_only_finding_envelope_with_max_crap(Some("high"), 35, 12, 50, 15, 30.0);
2502        let actions = output["findings"][0]["actions"].as_array().unwrap();
2503        assert!(
2504            actions.iter().any(|a| a["type"] == "refactor-function"),
2505            "full-coverage-impossible CRAP-only finding must emit refactor-function, got {actions:?}"
2506        );
2507        assert!(
2508            !actions.iter().any(|a| a["type"] == "increase-coverage"),
2509            "must not emit increase-coverage when even 100% coverage cannot clear CRAP"
2510        );
2511        assert!(
2512            !actions.iter().any(|a| a["type"] == "add-tests"),
2513            "must not emit add-tests when even 100% coverage cannot clear CRAP"
2514        );
2515    }
2516
2517    #[test]
2518    fn crap_only_high_cc_appends_secondary_refactor() {
2519        let output = crap_only_finding_envelope(Some("none"), 16, 20);
2520        let actions = output["findings"][0]["actions"].as_array().unwrap();
2521        assert!(
2522            actions.iter().any(|a| a["type"] == "add-tests"),
2523            "near-threshold crap-only still emits the primary tier action"
2524        );
2525        assert!(
2526            actions.iter().any(|a| a["type"] == "refactor-function"),
2527            "near-threshold crap-only must also emit secondary refactor-function"
2528        );
2529    }
2530
2531    #[test]
2532    fn crap_only_far_below_threshold_no_secondary_refactor() {
2533        let output = crap_only_finding_envelope(Some("none"), 6, 20);
2534        let actions = output["findings"][0]["actions"].as_array().unwrap();
2535        assert!(
2536            !actions.iter().any(|a| a["type"] == "refactor-function"),
2537            "low-CC crap-only should not get a secondary refactor-function"
2538        );
2539    }
2540
2541    #[test]
2542    fn crap_only_near_threshold_low_cognitive_no_secondary_refactor() {
2543        let output = crap_only_finding_envelope_with_cognitive(Some("none"), 17, 2, 20);
2544        let actions = output["findings"][0]["actions"].as_array().unwrap();
2545        assert!(
2546            actions.iter().any(|a| a["type"] == "add-tests"),
2547            "primary tier action still emits"
2548        );
2549        assert!(
2550            !actions.iter().any(|a| a["type"] == "refactor-function"),
2551            "near-threshold CC with cognitive below floor must NOT emit secondary refactor (got {actions:?})"
2552        );
2553    }
2554
2555    #[test]
2556    fn crap_only_near_threshold_high_cognitive_emits_secondary_refactor() {
2557        let output = crap_only_finding_envelope_with_cognitive(Some("none"), 16, 10, 20);
2558        let actions = output["findings"][0]["actions"].as_array().unwrap();
2559        assert!(
2560            actions.iter().any(|a| a["type"] == "add-tests"),
2561            "primary tier action still emits"
2562        );
2563        assert!(
2564            actions.iter().any(|a| a["type"] == "refactor-function"),
2565            "near-threshold CC with cognitive above floor must emit secondary refactor (got {actions:?})"
2566        );
2567    }
2568
2569    #[test]
2570    fn crap_only_secondary_refactor_respects_configured_band() {
2571        let violation = crate::health_types::ComplexityViolation {
2572            path: std::path::PathBuf::from("src/risk.ts"),
2573            name: "computeScore".to_string(),
2574            line: 12,
2575            col: 0,
2576            cyclomatic: 14,
2577            cognitive: 10,
2578            line_count: 40,
2579            param_count: 0,
2580            exceeded: crate::health_types::ExceededThreshold::Crap,
2581            severity: crate::health_types::FindingSeverity::Moderate,
2582            crap: Some(35.5),
2583            coverage_pct: None,
2584            coverage_tier: Some(crate::health_types::CoverageTier::None),
2585            coverage_source: None,
2586            inherited_from: None,
2587            component_rollup: None,
2588            contributions: Vec::new(),
2589        };
2590        let narrow_ctx = crate::health_types::HealthActionContext {
2591            opts: crate::health_types::HealthActionOptions::default(),
2592            max_cyclomatic_threshold: 20,
2593            max_cognitive_threshold: 15,
2594            max_crap_threshold: 30.0,
2595            crap_refactor_band: 5,
2596        };
2597        let wide_ctx = crate::health_types::HealthActionContext {
2598            crap_refactor_band: 6,
2599            ..narrow_ctx
2600        };
2601
2602        let narrow_actions =
2603            crate::health_types::build_health_finding_actions(&violation, &narrow_ctx);
2604        let wide_actions = crate::health_types::build_health_finding_actions(&violation, &wide_ctx);
2605
2606        assert!(
2607            !narrow_actions.iter().any(|a| {
2608                matches!(
2609                    a.kind,
2610                    fallow_types::output_health::HealthFindingActionType::RefactorFunction
2611                )
2612            }),
2613            "default band should not refactor a CRAP-only finding 6 below max cyclomatic"
2614        );
2615        assert!(
2616            wide_actions.iter().any(|a| {
2617                matches!(
2618                    a.kind,
2619                    fallow_types::output_health::HealthFindingActionType::RefactorFunction
2620                )
2621            }),
2622            "configured wider band should emit the secondary refactor action"
2623        );
2624    }
2625
2626    #[test]
2627    fn cyclomatic_only_emits_only_refactor_function() {
2628        let actions = build_actions_for_finding_json(
2629            serde_json::json!({
2630                "path": "src/cyclo.ts",
2631                "name": "branchy",
2632                "line": 5,
2633                "col": 0,
2634                "cyclomatic": 25,
2635                "cognitive": 10,
2636                "line_count": 80,
2637                "exceeded": "cyclomatic",
2638            }),
2639            crate::health_types::HealthActionOptions::default(),
2640            20,
2641            15,
2642            30.0,
2643        );
2644        assert!(
2645            actions.iter().any(|a| a["type"] == "refactor-function"),
2646            "non-CRAP findings emit refactor-function"
2647        );
2648        assert!(
2649            !actions.iter().any(|a| a["type"] == "add-tests"),
2650            "non-CRAP findings must not emit add-tests"
2651        );
2652        assert!(
2653            !actions.iter().any(|a| a["type"] == "increase-coverage"),
2654            "non-CRAP findings must not emit increase-coverage"
2655        );
2656    }
2657
2658    #[test]
2659    fn suppress_line_omitted_when_baseline_active() {
2660        let output = build_finding_envelope_with_ctx(
2661            Some("none"),
2662            6,
2663            12,
2664            20,
2665            15,
2666            30.0,
2667            crate::health_types::HealthActionOptions {
2668                omit_suppress_line: true,
2669                omit_reason: Some("baseline-active"),
2670            },
2671        );
2672        let actions = output["findings"][0]["actions"].as_array().unwrap();
2673        assert!(
2674            !actions.iter().any(|a| a["type"] == "suppress-line"),
2675            "baseline-active must not emit suppress-line, got {actions:?}"
2676        );
2677        assert_eq!(
2678            output["actions_meta"]["suppression_hints_omitted"],
2679            serde_json::Value::Bool(true)
2680        );
2681        assert_eq!(output["actions_meta"]["reason"], "baseline-active");
2682        assert_eq!(output["actions_meta"]["scope"], "health-findings");
2683    }
2684
2685    #[test]
2686    fn suppress_line_omitted_when_config_disabled() {
2687        let output = build_finding_envelope_with_ctx(
2688            Some("none"),
2689            6,
2690            12,
2691            20,
2692            15,
2693            30.0,
2694            crate::health_types::HealthActionOptions {
2695                omit_suppress_line: true,
2696                omit_reason: Some("config-disabled"),
2697            },
2698        );
2699        assert_eq!(output["actions_meta"]["reason"], "config-disabled");
2700    }
2701
2702    #[test]
2703    fn suppress_line_emitted_by_default() {
2704        let output = crap_only_finding_envelope(Some("none"), 6, 20);
2705        let actions = output["findings"][0]["actions"].as_array().unwrap();
2706        assert!(
2707            actions.iter().any(|a| a["type"] == "suppress-line"),
2708            "default opts must emit suppress-line"
2709        );
2710        assert!(
2711            output.get("actions_meta").is_none(),
2712            "actions_meta must be absent when no omission occurred"
2713        );
2714    }
2715
2716    /// Drift guard: every action `type` value emitted by the action builder
2717    /// must appear in `docs/output-schema.json`'s `HealthFindingAction.type`
2718    /// enum. Previously the schema listed only `[refactor-function,
2719    /// suppress-line]` while the code emitted `add-tests` for CRAP findings,
2720    /// silently producing schema-invalid output for any consumer using the
2721    /// schema for validation.
2722    #[test]
2723    fn every_emitted_health_action_type_is_in_schema_enum() {
2724        let cases = [
2725            ("crap", Some("none"), 6_u16, 20_u16),
2726            ("crap", Some("partial"), 6, 20),
2727            ("crap", Some("high"), 12, 20),
2728            ("crap", Some("none"), 16, 20), // near threshold => secondary refactor
2729            ("cyclomatic", None, 25, 20),
2730            ("cognitive_crap", Some("partial"), 6, 20),
2731            ("all", Some("none"), 25, 20),
2732        ];
2733
2734        let mut emitted: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2735        for (exceeded, tier, cc, max) in cases {
2736            let mut finding = serde_json::json!({
2737                "path": "src/x.ts",
2738                "name": "fn",
2739                "line": 1,
2740                "col": 0,
2741                "cyclomatic": cc,
2742                "cognitive": 5,
2743                "line_count": 10,
2744                "exceeded": exceeded,
2745                "crap": 35.0,
2746            });
2747            if let Some(t) = tier {
2748                finding["coverage_tier"] = serde_json::Value::String(t.to_owned());
2749            }
2750            let actions = build_actions_for_finding_json(
2751                finding,
2752                crate::health_types::HealthActionOptions::default(),
2753                max,
2754                15,
2755                30.0,
2756            );
2757            for action in &actions {
2758                if let Some(ty) = action["type"].as_str() {
2759                    emitted.insert(ty.to_owned());
2760                }
2761            }
2762        }
2763
2764        let schema_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2765            .join("..")
2766            .join("..")
2767            .join("docs")
2768            .join("output-schema.json");
2769        let raw = std::fs::read_to_string(&schema_path)
2770            .expect("docs/output-schema.json must be readable for the drift-guard test");
2771        let schema: serde_json::Value = serde_json::from_str(&raw).expect("schema parses");
2772        let type_field = &schema["definitions"]["HealthFindingAction"]["properties"]["type"];
2773        let type_def = if let Some(reference) = type_field.get("$ref").and_then(|r| r.as_str()) {
2774            let name = reference
2775                .strip_prefix("#/definitions/")
2776                .expect("HealthFindingAction.type $ref points into #/definitions/");
2777            &schema["definitions"][name]
2778        } else {
2779            type_field
2780        };
2781        let mut enum_values: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2782        if let Some(arr) = type_def.get("enum").and_then(|e| e.as_array()) {
2783            for v in arr {
2784                if let Some(s) = v.as_str() {
2785                    enum_values.insert(s.to_owned());
2786                }
2787            }
2788        }
2789        if let Some(arr) = type_def.get("oneOf").and_then(|e| e.as_array()) {
2790            for branch in arr {
2791                if let Some(s) = branch.get("const").and_then(|c| c.as_str()) {
2792                    enum_values.insert(s.to_owned());
2793                }
2794            }
2795        }
2796        assert!(
2797            !enum_values.is_empty(),
2798            "could not extract HealthFindingActionType variants from schema (neither `enum` nor `oneOf` with `const` branches)"
2799        );
2800
2801        for ty in &emitted {
2802            assert!(
2803                enum_values.contains(ty),
2804                "build_health_finding_actions emitted action type `{ty}` but \
2805                 docs/output-schema.json HealthFindingAction.type enum does \
2806                 not list it. Add it to the schema (and any downstream \
2807                 typed consumers) when introducing a new action type."
2808            );
2809        }
2810    }
2811
2812    /// Regression for issue #412: prevent reintroduction of the legacy
2813    /// `inject_*` / `augment_*` post-pass pattern in this file. Every
2814    /// JSON `actions[]` array on every finding type should flow from a
2815    /// typed `serde(flatten)` envelope, not from a post-construction
2816    /// mutation of a `serde_json::Value` tree.
2817    ///
2818    /// The allow-list mirrors the `HAND_MAINTAINED_ALLOW_LIST` pattern
2819    /// in `crates/cli/src/bin/schema_emit.rs`: each entry pairs a name
2820    /// with the issue that retires it. It is empty today; any addition
2821    /// needs an issue reference in the same commit. The gate also
2822    /// asserts no STALE entries, so removing a function without
2823    /// removing its allow-list entry fails the test and forces the
2824    /// cleanup commit.
2825    #[test]
2826    fn no_new_post_pass_helpers_in_json_rs() {
2827        const POST_PASS_ALLOW_LIST: &[(&str, &str)] = &[];
2828        let source_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2829            .join("src")
2830            .join("report")
2831            .join("json.rs");
2832        let source = std::fs::read_to_string(&source_path).expect(
2833            "crates/cli/src/report/json.rs must be readable for the post-pass drift-guard test",
2834        );
2835        let mut found: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2836        for line in source.lines() {
2837            if let Some(name) = extract_post_pass_fn_name(line) {
2838                found.insert(name.to_owned());
2839            }
2840        }
2841        let allow: std::collections::BTreeSet<&'static str> =
2842            POST_PASS_ALLOW_LIST.iter().map(|(name, _)| *name).collect();
2843        let unexpected: Vec<&str> = found
2844            .iter()
2845            .filter(|name| !allow.contains(name.as_str()))
2846            .map(String::as_str)
2847            .collect();
2848        let stale: Vec<&str> = allow
2849            .iter()
2850            .filter(|name| !found.contains(**name))
2851            .copied()
2852            .collect();
2853        assert!(
2854            unexpected.is_empty(),
2855            "new post-pass helper(s) defined in crates/cli/src/report/json.rs are not in \
2856             POST_PASS_ALLOW_LIST: {unexpected:?}.\n\
2857             The typed `serde(flatten)` envelope is the source of truth for `actions[]` on \
2858             every finding. If a new post-pass is genuinely needed, file a tracking issue, \
2859             add the entry to POST_PASS_ALLOW_LIST with the issue link as the reason, and \
2860             reference the issue in the PR body. See issue #412 for context."
2861        );
2862        assert!(
2863            stale.is_empty(),
2864            "stale entries in POST_PASS_ALLOW_LIST (function no longer defined in \
2865             crates/cli/src/report/json.rs): {stale:?}.\n\
2866             Remove them in the same commit that retired the function."
2867        );
2868    }
2869
2870    /// Extracts an `inject_<name>` or `augment_<name>` identifier from a
2871    /// Rust function-definition line, handling `pub`, `pub(...)`,
2872    /// `async`, `const`, and `unsafe` modifiers. Returns `None` for
2873    /// non-definition lines (comments, call sites, doc strings).
2874    fn extract_post_pass_fn_name(line: &str) -> Option<&str> {
2875        let trimmed = line.trim_start();
2876        if trimmed.starts_with("//") {
2877            return None;
2878        }
2879        let mut rest = trimmed;
2880        if let Some(after) = rest.strip_prefix("pub") {
2881            let after = after.trim_start();
2882            rest = if let Some(after) = after.strip_prefix('(') {
2883                let close = after.find(')')?;
2884                after[close + 1..].trim_start()
2885            } else {
2886                after
2887            };
2888        }
2889        for prefix in ["async ", "const ", "unsafe "] {
2890            if let Some(after) = rest.strip_prefix(prefix) {
2891                rest = after.trim_start();
2892            }
2893        }
2894        let after_fn = rest.strip_prefix("fn ")?;
2895        let name_end = after_fn
2896            .find(|c: char| !c.is_alphanumeric() && c != '_')
2897            .unwrap_or(after_fn.len());
2898        let name = &after_fn[..name_end];
2899        if name.starts_with("inject_") || name.starts_with("augment_") {
2900            Some(name)
2901        } else {
2902            None
2903        }
2904    }
2905}