Skip to main content

fallow_cli/report/
json.rs

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