Skip to main content

fallow_api/
runtime_json.rs

1//! JSON protocol serializers for typed programmatic runtime output.
2//!
3//! Runtime entry points return typed output from [`crate::runtime`]. CLI, MCP,
4//! NAPI, and other protocol surfaces call these serializers at their JSON
5//! boundary.
6
7use crate::{
8    ProgrammaticError,
9    runtime::{
10        AuditProgrammaticOutput, BoundaryViolationsProgrammaticOutput,
11        CircularDependenciesProgrammaticOutput, CombinedProgrammaticOutput,
12        DeadCodeProgrammaticOutput, DecisionSurfaceProgrammaticOutput,
13        DuplicationProgrammaticOutput, FeatureFlagsProgrammaticOutput, HealthJsonReportInput,
14        HealthProgrammaticOutput, TraceCloneProgrammaticOutput, TraceDependencyProgrammaticOutput,
15        TraceErrorProgrammaticOutput, TraceExportProgrammaticOutput, TraceFileProgrammaticOutput,
16        TraceImportPathProgrammaticOutput, serialize_health_report_json,
17    },
18};
19use fallow_output::{
20    AUDIT_SCHEMA_VERSION, CheckOutput, GroupByMode, RootEnvelopeMode,
21    build_decision_surface_output, serialize_check_json_output,
22    serialize_decision_surface_json_output, serialize_dupes_json_output,
23    serialize_feature_flags_json_output, strip_root_prefix,
24};
25use fallow_types::envelope::{ElapsedMs, SchemaVersion, ToolVersion};
26use fallow_types::workspace::{WorkspaceDiagnostic, merge_workspace_diagnostics};
27use serde::Serialize;
28use std::path::Path;
29use std::time::Duration;
30
31type ProgrammaticResult<T> = Result<T, ProgrammaticError>;
32
33/// Serialize typed combined output into the stable JSON compatibility contract.
34///
35/// # Errors
36///
37/// Returns a structured error if one of the combined sections cannot serialize.
38pub fn serialize_combined_programmatic_json(
39    output: CombinedProgrammaticOutput,
40) -> ProgrammaticResult<serde_json::Value> {
41    let CombinedProgrammaticOutput {
42        dead_code,
43        duplication,
44        health,
45        root,
46        elapsed,
47        explain,
48        next_steps,
49        envelope_mode,
50        telemetry_analysis_run_id,
51    } = output;
52    let workspace_diagnostics =
53        combined_workspace_diagnostics(dead_code.as_ref(), health.as_ref(), duplication.as_ref());
54    crate::serialize_combined_json(crate::CombinedJsonOutputInput {
55        gate_outcomes: None,
56        request_outcomes: None,
57        check: dead_code
58            .as_ref()
59            .map(|dead_code| crate::CombinedCheckJsonSection {
60                results: &dead_code.output.results,
61                root: &dead_code.root,
62                elapsed: Duration::from_millis(dead_code.output.elapsed_ms.0),
63                config_fixable: dead_code.config_fixable,
64                extras: crate::CheckJsonExtraOutputs::default(),
65            }),
66        dupes: duplication
67            .as_ref()
68            .map(|duplication| &duplication.output.report),
69        health: health.as_ref().map(|health| &health.report),
70        root: &root,
71        elapsed,
72        explain,
73        type_aware: None,
74        workspace_diagnostics,
75        next_steps,
76        envelope_mode,
77        telemetry_analysis_run_id: telemetry_analysis_run_id.as_deref(),
78    })
79    .map_err(|err| {
80        ProgrammaticError::new(format!("failed to serialize combined report: {err}"), 2)
81            .with_code("FALLOW_SERIALIZE_COMBINED_REPORT")
82            .with_context("combined")
83    })
84}
85
86/// Union the combined run's workspace diagnostics across its typed sections.
87///
88/// Each section captured the list as of the moment its own analysis finished,
89/// and those lists can differ: a combined run walks the project once per
90/// analysis, per-analysis `production` modes can give those walks different
91/// file sets, and each walk clears the previous walk's source-discovery
92/// entries. No single section therefore holds everything the run recorded, so
93/// the root carries the deduplicated union in section order (dead code, then
94/// health, then duplication) and a run missing a section (`--skip check`,
95/// `--only health`, `--only dupes`) still reports what its remaining analyses
96/// recorded.
97///
98/// Each section carries the diagnostics owned by its run. The combined root
99/// unions those values without importing process-global history.
100fn combined_workspace_diagnostics(
101    dead_code: Option<&DeadCodeProgrammaticOutput>,
102    health: Option<&HealthProgrammaticOutput>,
103    duplication: Option<&DuplicationProgrammaticOutput>,
104) -> Vec<WorkspaceDiagnostic> {
105    let merged = merge_workspace_diagnostics(
106        dead_code.map_or_else(Vec::new, |dead_code| {
107            dead_code.output.workspace_diagnostics.clone()
108        }),
109        health.map_or_else(Vec::new, |health| health.workspace_diagnostics.clone()),
110    );
111    merge_workspace_diagnostics(
112        merged,
113        duplication.map_or_else(Vec::new, |duplication| {
114            duplication.output.workspace_diagnostics.clone()
115        }),
116    )
117}
118
119/// Serialize typed decision-surface output into the stable JSON contract.
120///
121/// # Errors
122///
123/// Returns a structured error if the decision-surface payload cannot serialize.
124pub fn serialize_decision_surface_programmatic_json(
125    output: DecisionSurfaceProgrammaticOutput,
126) -> ProgrammaticResult<serde_json::Value> {
127    let DecisionSurfaceProgrammaticOutput {
128        surface,
129        elapsed: _,
130        envelope_mode,
131        telemetry_analysis_run_id,
132    } = output;
133    let payload = build_decision_surface_output(&surface);
134    serialize_decision_surface_json_output(
135        payload,
136        envelope_mode,
137        telemetry_analysis_run_id.as_deref(),
138    )
139    .map_err(|err| {
140        ProgrammaticError::new(format!("failed to serialize decision surface: {err}"), 2)
141            .with_code("FALLOW_SERIALIZE_DECISION_SURFACE")
142            .with_context("decision-surface")
143    })
144}
145
146/// Serialize typed audit output into the stable JSON compatibility contract.
147///
148/// # Errors
149///
150/// Returns a structured error if one of the audit sections cannot serialize.
151pub fn serialize_audit_programmatic_json(
152    output: AuditProgrammaticOutput,
153) -> ProgrammaticResult<serde_json::Value> {
154    let base_snapshot = output.base_snapshot.as_ref();
155    let dead_code = output
156        .dead_code
157        .as_ref()
158        .map(|dead_code| serialize_audit_dead_code(dead_code, base_snapshot))
159        .transpose()?;
160    let duplication = output
161        .duplication
162        .as_ref()
163        .map(|duplication| serialize_audit_duplication(duplication, base_snapshot))
164        .transpose()?;
165    let complexity = output
166        .complexity
167        .as_ref()
168        .map(|complexity| serialize_audit_complexity(complexity, base_snapshot))
169        .transpose()?;
170
171    crate::serialize_audit_json(
172        crate::AuditJsonOutputInput {
173            gate_outcomes: None,
174            header: crate::AuditJsonHeaderInput {
175                schema_version: SchemaVersion(AUDIT_SCHEMA_VERSION),
176                version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
177                verdict: output.verdict,
178                changed_files_count: u32::try_from(output.changed_files_count).unwrap_or(u32::MAX),
179                base_ref: output.base_ref,
180                base_description: output.base_description,
181                head_sha: output.head_sha,
182                elapsed_ms: ElapsedMs(
183                    u64::try_from(output.elapsed.as_millis()).unwrap_or(u64::MAX),
184                ),
185                base_snapshot_skipped: output.base_snapshot_skipped,
186                summary: output.summary,
187                attribution: output.attribution,
188            },
189            meta: None,
190            dead_code,
191            duplication,
192            complexity,
193            next_steps: output.next_steps,
194        },
195        output.envelope_mode,
196        output.telemetry_analysis_run_id.as_deref(),
197    )
198    .map_err(|err| {
199        ProgrammaticError::new(format!("failed to serialize audit report: {err}"), 2)
200            .with_code("FALLOW_SERIALIZE_AUDIT_REPORT")
201            .with_context("audit")
202    })
203}
204
205/// Serialize the audit envelope's dead-code sub-result.
206///
207/// The sub-result is a `CheckOutput` body, so it carries the run's
208/// `workspace_diagnostics[]` the way the standalone `dead-code` envelope and
209/// the combined `check` section do. The two audit routes agree on everything
210/// the dead-code analysis records. The programmatic route serializes the typed
211/// output's by-value session snapshot, while the CLI route serializes the same
212/// snapshot from `CheckResult`. Neither route rereads process-global diagnostic
213/// history while building the envelope, so output is independent of call order
214/// and of later analysis walks in the same process.
215fn serialize_audit_dead_code(
216    output: &DeadCodeProgrammaticOutput,
217    base_snapshot: Option<&crate::AuditProgrammaticKeySnapshot>,
218) -> ProgrammaticResult<serde_json::Value> {
219    let mut json = crate::serialize_check_json_payload(crate::CheckJsonPayloadInput {
220        results: &output.output.results,
221        root: &output.root,
222        elapsed: Duration::from_millis(output.output.elapsed_ms.0),
223        config_fixable: output.config_fixable,
224        extras: crate::CheckJsonExtraOutputs::default(),
225        workspace_diagnostics: output.output.workspace_diagnostics.clone(),
226    })
227    .map_err(|err| {
228        ProgrammaticError::new(format!("failed to serialize audit dead-code: {err}"), 2)
229            .with_code("FALLOW_SERIALIZE_AUDIT_DEAD_CODE")
230            .with_context("audit.deadCode")
231    })?;
232    if let Some(base) = base_snapshot {
233        if has_persisted_introduced_flags(&json) {
234            crate::audit_keys::annotate_stale_suppressions_json(
235                &mut json,
236                &output.output.results,
237                &output.root,
238                &base.dead_code,
239            );
240        } else {
241            crate::audit_keys::annotate_dead_code_json(
242                &mut json,
243                &output.output.results,
244                &output.root,
245                &base.dead_code,
246            );
247        }
248    }
249    Ok(json)
250}
251
252fn serialize_audit_duplication(
253    output: &DuplicationProgrammaticOutput,
254    base_snapshot: Option<&crate::AuditProgrammaticKeySnapshot>,
255) -> ProgrammaticResult<serde_json::Value> {
256    let mut json = serde_json::to_value(&output.output.report).map_err(|err| {
257        ProgrammaticError::new(format!("failed to serialize audit duplication: {err}"), 2)
258            .with_code("FALLOW_SERIALIZE_AUDIT_DUPLICATION")
259            .with_context("audit.duplication")
260    })?;
261    let root_prefix = format!("{}/", output.root.display());
262    strip_root_prefix(&mut json, &root_prefix);
263    if let Some(base) = base_snapshot
264        && !has_persisted_introduced_flags(&json)
265    {
266        annotate_audit_duplication_json(&mut json, output, &base.dupes);
267    }
268    Ok(json)
269}
270
271fn serialize_audit_complexity(
272    output: &HealthProgrammaticOutput,
273    base_snapshot: Option<&crate::AuditProgrammaticKeySnapshot>,
274) -> ProgrammaticResult<serde_json::Value> {
275    let mut json = serde_json::to_value(&output.report).map_err(|err| {
276        ProgrammaticError::new(format!("failed to serialize audit complexity: {err}"), 2)
277            .with_code("FALLOW_SERIALIZE_AUDIT_COMPLEXITY")
278            .with_context("audit.complexity")
279    })?;
280    let root_prefix = format!("{}/", output.root.display());
281    strip_root_prefix(&mut json, &root_prefix);
282    if let Some(base) = base_snapshot {
283        crate::audit_keys::annotate_health_json(
284            &mut json,
285            &output.report,
286            &output.root,
287            &base.health,
288        );
289    }
290    Ok(json)
291}
292
293fn has_persisted_introduced_flags(json: &serde_json::Value) -> bool {
294    json.as_object().is_some_and(|object| {
295        object.values().any(|value| {
296            value
297                .as_array()
298                .is_some_and(|items| items.iter().any(|item| item.get("introduced").is_some()))
299        })
300    })
301}
302
303fn annotate_audit_duplication_json(
304    json: &mut serde_json::Value,
305    output: &DuplicationProgrammaticOutput,
306    base: &rustc_hash::FxHashSet<String>,
307) {
308    let Some(items) = json
309        .get_mut("clone_groups")
310        .and_then(serde_json::Value::as_array_mut)
311    else {
312        return;
313    };
314    for (item, group) in items.iter_mut().zip(&output.output.report.clone_groups) {
315        if let serde_json::Value::Object(map) = item {
316            let key = crate::audit_keys::dupe_group_key(&group.group, &output.root);
317            map.insert(
318                "introduced".to_string(),
319                serde_json::json!(!base.contains(&key)),
320            );
321        }
322    }
323}
324
325/// Serialize typed dead-code output into the stable JSON compatibility contract.
326///
327/// # Errors
328///
329/// Returns a structured error if the output contract cannot be serialized.
330pub fn serialize_dead_code_programmatic_json(
331    output: DeadCodeProgrammaticOutput,
332) -> ProgrammaticResult<serde_json::Value> {
333    let DeadCodeProgrammaticOutput {
334        output,
335        root,
336        config_fixable: _,
337        envelope_mode,
338        telemetry_analysis_run_id,
339    } = output;
340    serialize_check_programmatic_output(
341        output,
342        &root,
343        envelope_mode,
344        telemetry_analysis_run_id.as_deref(),
345        "dead-code",
346        "FALLOW_SERIALIZE_DEAD_CODE_REPORT",
347    )
348}
349
350/// Serialize typed circular-dependency output into the JSON compatibility contract.
351///
352/// # Errors
353///
354/// Returns a structured error if the output contract cannot be serialized.
355pub fn serialize_circular_dependencies_programmatic_json(
356    output: CircularDependenciesProgrammaticOutput,
357) -> ProgrammaticResult<serde_json::Value> {
358    let CircularDependenciesProgrammaticOutput {
359        output,
360        root,
361        envelope_mode,
362        telemetry_analysis_run_id,
363    } = output;
364    serialize_check_programmatic_output(
365        output,
366        &root,
367        envelope_mode,
368        telemetry_analysis_run_id.as_deref(),
369        "circular-dependencies",
370        "FALLOW_SERIALIZE_CIRCULAR_DEPENDENCIES_REPORT",
371    )
372}
373
374/// Serialize typed boundary-family output into the JSON compatibility contract.
375///
376/// # Errors
377///
378/// Returns a structured error if the output contract cannot be serialized.
379pub fn serialize_boundary_violations_programmatic_json(
380    output: BoundaryViolationsProgrammaticOutput,
381) -> ProgrammaticResult<serde_json::Value> {
382    let BoundaryViolationsProgrammaticOutput {
383        output,
384        root,
385        envelope_mode,
386        telemetry_analysis_run_id,
387    } = output;
388    serialize_check_programmatic_output(
389        output,
390        &root,
391        envelope_mode,
392        telemetry_analysis_run_id.as_deref(),
393        "boundary-violations",
394        "FALLOW_SERIALIZE_BOUNDARY_VIOLATIONS_REPORT",
395    )
396}
397
398fn serialize_check_programmatic_output(
399    output: CheckOutput,
400    root: &Path,
401    envelope_mode: RootEnvelopeMode,
402    telemetry_analysis_run_id: Option<&str>,
403    context: &'static str,
404    code: &'static str,
405) -> ProgrammaticResult<serde_json::Value> {
406    let mut json = serialize_check_json_output(output, envelope_mode, telemetry_analysis_run_id)
407        .map_err(|err| {
408            ProgrammaticError::new(format!("failed to serialize {context} report: {err}"), 2)
409                .with_code(code)
410                .with_context(context)
411        })?;
412    let root_prefix = format!("{}/", root.display());
413    strip_root_prefix(&mut json, &root_prefix);
414    Ok(json)
415}
416
417/// Serialize typed duplication output into the JSON compatibility contract.
418///
419/// # Errors
420///
421/// Returns a structured error if the output contract cannot be serialized.
422pub fn serialize_duplication_programmatic_json(
423    output: DuplicationProgrammaticOutput,
424) -> ProgrammaticResult<serde_json::Value> {
425    let DuplicationProgrammaticOutput {
426        output,
427        root,
428        threshold: _,
429        envelope_mode,
430        telemetry_analysis_run_id,
431    } = output;
432    let mut json =
433        serialize_dupes_json_output(output, envelope_mode, telemetry_analysis_run_id.as_deref())
434            .map_err(|err| {
435                ProgrammaticError::new(format!("failed to serialize duplication report: {err}"), 2)
436                    .with_code("FALLOW_SERIALIZE_DUPLICATION_REPORT")
437                    .with_context("dupes")
438            })?;
439    let root_prefix = format!("{}/", root.display());
440    strip_root_prefix(&mut json, &root_prefix);
441    Ok(json)
442}
443
444/// Serialize typed feature-flag output into the JSON compatibility contract.
445///
446/// # Errors
447///
448/// Returns a structured error if the output contract cannot be serialized.
449pub fn serialize_feature_flags_programmatic_json(
450    output: FeatureFlagsProgrammaticOutput,
451) -> ProgrammaticResult<serde_json::Value> {
452    serialize_feature_flags_json_output(
453        output.output,
454        output.envelope_mode,
455        output.telemetry_analysis_run_id.as_deref(),
456    )
457    .map_err(|err| {
458        ProgrammaticError::new(
459            format!("failed to serialize feature flags report: {err}"),
460            2,
461        )
462        .with_code("FALLOW_SERIALIZE_FEATURE_FLAGS_REPORT")
463        .with_context("feature-flags")
464    })
465}
466
467/// Serialize typed export-trace output into the JSON compatibility contract.
468///
469/// # Errors
470///
471/// Returns a structured error if the trace output cannot be serialized.
472pub fn serialize_trace_export_programmatic_json(
473    output: TraceExportProgrammaticOutput,
474) -> ProgrammaticResult<serde_json::Value> {
475    serialize_trace_programmatic_output(
476        output.output,
477        "export trace",
478        "FALLOW_SERIALIZE_TRACE_EXPORT",
479        "trace_export",
480    )
481}
482
483/// Serialize typed file-trace output into the JSON compatibility contract.
484///
485/// # Errors
486///
487/// Returns a structured error if the trace output cannot be serialized.
488pub fn serialize_trace_file_programmatic_json(
489    output: TraceFileProgrammaticOutput,
490) -> ProgrammaticResult<serde_json::Value> {
491    serialize_trace_programmatic_output(
492        output.output,
493        "file trace",
494        "FALLOW_SERIALIZE_TRACE_FILE",
495        "trace_file",
496    )
497}
498
499/// Serialize typed import-path-trace output into the JSON compatibility contract.
500///
501/// # Errors
502///
503/// Returns a structured error if the trace output cannot be serialized.
504pub fn serialize_trace_import_path_programmatic_json(
505    output: TraceImportPathProgrammaticOutput,
506) -> ProgrammaticResult<serde_json::Value> {
507    serialize_trace_programmatic_output(
508        output.output,
509        "import path trace",
510        "FALLOW_SERIALIZE_TRACE_IMPORT_PATH",
511        "trace_import_path",
512    )
513}
514
515/// Serialize typed dependency-trace output into the JSON compatibility contract.
516///
517/// # Errors
518///
519/// Returns a structured error if the trace output cannot be serialized.
520pub fn serialize_trace_dependency_programmatic_json(
521    output: TraceDependencyProgrammaticOutput,
522) -> ProgrammaticResult<serde_json::Value> {
523    serialize_trace_programmatic_output(
524        output.output,
525        "dependency trace",
526        "FALLOW_SERIALIZE_TRACE_DEPENDENCY",
527        "trace_dependency",
528    )
529}
530
531/// Serialize typed stack-trace resolution into the JSON compatibility contract.
532///
533/// # Errors
534///
535/// Returns a structured error if the trace output cannot be serialized.
536pub fn serialize_trace_error_programmatic_json(
537    output: TraceErrorProgrammaticOutput,
538) -> ProgrammaticResult<serde_json::Value> {
539    serialize_trace_programmatic_output(
540        output.output,
541        "stack-trace resolution",
542        "FALLOW_SERIALIZE_TRACE_ERROR",
543        "trace_error",
544    )
545}
546
547/// Serialize typed clone-trace output into the JSON compatibility contract.
548///
549/// # Errors
550///
551/// Returns a structured error if the trace output cannot be serialized.
552pub fn serialize_trace_clone_programmatic_json(
553    output: TraceCloneProgrammaticOutput,
554) -> ProgrammaticResult<serde_json::Value> {
555    serialize_trace_programmatic_output(
556        output.output,
557        "clone trace",
558        "FALLOW_SERIALIZE_TRACE_CLONE",
559        "trace_clone",
560    )
561}
562
563fn serialize_trace_programmatic_output<T: Serialize>(
564    output: T,
565    context: &'static str,
566    code: &'static str,
567    error_context: &'static str,
568) -> ProgrammaticResult<serde_json::Value> {
569    serde_json::to_value(output).map_err(|err| {
570        ProgrammaticError::new(format!("failed to serialize {context}: {err}"), 2)
571            .with_code(code)
572            .with_context(error_context)
573    })
574}
575
576/// Serialize typed health / complexity output into the JSON compatibility contract.
577///
578/// # Errors
579///
580/// Returns a structured error if the health output contract cannot be serialized.
581pub fn serialize_health_programmatic_json(
582    output: HealthProgrammaticOutput,
583) -> ProgrammaticResult<serde_json::Value> {
584    let HealthProgrammaticOutput {
585        report,
586        grouping,
587        root,
588        elapsed,
589        explain,
590        workspace_diagnostics,
591        next_steps,
592        envelope_mode,
593        telemetry_analysis_run_id,
594    } = output;
595    let (grouped_by, groups) = grouping.map_or((None, None), |grouping| {
596        (
597            group_by_mode_from_label(grouping.mode),
598            Some(grouping.groups),
599        )
600    });
601    serialize_health_report_json(HealthJsonReportInput {
602        gate_outcomes: None,
603        request_outcomes: None,
604        report,
605        root: &root,
606        elapsed,
607        explain,
608        type_aware: None,
609        grouped_by,
610        groups,
611        workspace_diagnostics,
612        next_steps,
613        envelope_mode,
614        telemetry_analysis_run_id: telemetry_analysis_run_id.as_deref(),
615    })
616    .map_err(|err| {
617        ProgrammaticError::new(format!("failed to serialize health report: {err}"), 2)
618            .with_code("FALLOW_SERIALIZE_HEALTH_REPORT")
619            .with_context("health")
620    })
621}
622
623fn group_by_mode_from_label(label: &str) -> Option<GroupByMode> {
624    match label {
625        "owner" => Some(GroupByMode::Owner),
626        "directory" => Some(GroupByMode::Directory),
627        "package" => Some(GroupByMode::Package),
628        "section" => Some(GroupByMode::Section),
629        _ => None,
630    }
631}
632
633#[cfg(test)]
634mod tests {
635    use super::{
636        RootEnvelopeMode, serialize_audit_dead_code, serialize_combined_programmatic_json,
637    };
638    use crate::DupesReportPayload;
639    use crate::runtime::{
640        CombinedProgrammaticOutput, DeadCodeProgrammaticOutput, DuplicationProgrammaticOutput,
641        HealthProgrammaticOutput,
642    };
643    use fallow_output::{
644        CHECK_SCHEMA_VERSION, CheckOutputInput, DUPES_SCHEMA_VERSION, DupesOutputInput,
645        HealthReport, build_check_output, build_dupes_output,
646    };
647    use fallow_types::duplicates::DuplicationReport;
648    use fallow_types::results::AnalysisResults;
649    use fallow_types::workspace::{WorkspaceDiagnostic, WorkspaceDiagnosticKind};
650    use std::path::Path;
651    use std::time::Duration;
652
653    fn dead_code_output(
654        root: &Path,
655        workspace_diagnostics: Vec<WorkspaceDiagnostic>,
656    ) -> DeadCodeProgrammaticOutput {
657        DeadCodeProgrammaticOutput {
658            output: build_check_output(CheckOutputInput {
659                schema_version: CHECK_SCHEMA_VERSION,
660                version: "0.0.0-test".to_owned(),
661                elapsed: Duration::ZERO,
662                results: AnalysisResults::default(),
663                config_fixable: false,
664                meta: None,
665                workspace_diagnostics,
666                next_steps: Vec::new(),
667            }),
668            root: root.to_path_buf(),
669            config_fixable: false,
670            envelope_mode: RootEnvelopeMode::Tagged,
671            telemetry_analysis_run_id: None,
672        }
673    }
674
675    /// Issue #2366: the audit envelope's dead-code sub-result carries the run's
676    /// workspace diagnostics root-relative, matching what the CLI audit path
677    /// reads from the registry, and omits the array when there are none.
678    #[test]
679    fn audit_dead_code_section_carries_workspace_diagnostics_root_relative_or_omits_them() {
680        let root = Path::new("/project");
681        let carried = serialize_audit_dead_code(
682            &dead_code_output(
683                root,
684                vec![WorkspaceDiagnostic::new(
685                    root,
686                    root.join("package.json"),
687                    WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
688                )],
689            ),
690            None,
691        )
692        .expect("audit dead-code JSON");
693        assert_eq!(
694            carried["workspace_diagnostics"][0]["kind"],
695            "bun-lockb-override-resolution-skipped"
696        );
697        assert_eq!(carried["workspace_diagnostics"][0]["path"], "package.json");
698
699        let empty = serialize_audit_dead_code(&dead_code_output(root, Vec::new()), None)
700            .expect("audit dead-code JSON");
701        assert!(
702            empty.get("workspace_diagnostics").is_none(),
703            "an empty list is omitted from the audit dead-code section: {empty}"
704        );
705    }
706
707    fn health_output(
708        root: &Path,
709        workspace_diagnostics: Vec<WorkspaceDiagnostic>,
710    ) -> HealthProgrammaticOutput {
711        HealthProgrammaticOutput {
712            report: HealthReport::default(),
713            grouping: None,
714            root: root.to_path_buf(),
715            elapsed: Duration::ZERO,
716            explain: false,
717            workspace_diagnostics,
718            next_steps: Vec::new(),
719            envelope_mode: RootEnvelopeMode::Tagged,
720            telemetry_analysis_run_id: None,
721        }
722    }
723
724    fn duplication_output(
725        root: &Path,
726        workspace_diagnostics: Vec<WorkspaceDiagnostic>,
727    ) -> DuplicationProgrammaticOutput {
728        DuplicationProgrammaticOutput {
729            output: build_dupes_output(DupesOutputInput {
730                gate_outcomes: None,
731                request_outcomes: None,
732                baseline_staleness: None,
733                schema_version: DUPES_SCHEMA_VERSION,
734                version: "0.0.0-test".to_owned(),
735                elapsed: Duration::ZERO,
736                report: DupesReportPayload::from_report(&DuplicationReport::default()),
737                clone_groups_shown: 0,
738                clone_groups_omitted: 0,
739                clone_families_shown: 0,
740                clone_families_omitted: 0,
741                grouped_by: None,
742                total_issues: None,
743                groups: None,
744                meta: None,
745                workspace_diagnostics,
746                next_steps: Vec::new(),
747            }),
748            root: root.to_path_buf(),
749            threshold: 0.0,
750            envelope_mode: RootEnvelopeMode::Tagged,
751            telemetry_analysis_run_id: None,
752        }
753    }
754
755    fn combined_output(
756        root: &Path,
757        dead_code: Option<DeadCodeProgrammaticOutput>,
758        health: Option<HealthProgrammaticOutput>,
759    ) -> CombinedProgrammaticOutput {
760        combined_output_with_duplication(root, dead_code, health, None)
761    }
762
763    fn combined_output_with_duplication(
764        root: &Path,
765        dead_code: Option<DeadCodeProgrammaticOutput>,
766        health: Option<HealthProgrammaticOutput>,
767        duplication: Option<DuplicationProgrammaticOutput>,
768    ) -> CombinedProgrammaticOutput {
769        CombinedProgrammaticOutput {
770            dead_code,
771            duplication,
772            health,
773            root: root.to_path_buf(),
774            elapsed: Duration::ZERO,
775            explain: false,
776            next_steps: Vec::new(),
777            envelope_mode: RootEnvelopeMode::Tagged,
778            telemetry_analysis_run_id: None,
779        }
780    }
781
782    fn bun_lockb_diagnostic(root: &Path) -> WorkspaceDiagnostic {
783        WorkspaceDiagnostic::new(
784            root,
785            root.join("package.json"),
786            WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
787        )
788    }
789
790    fn large_file_diagnostic(root: &Path, relative: &str) -> WorkspaceDiagnostic {
791        WorkspaceDiagnostic::new(
792            root,
793            root.join(relative),
794            WorkspaceDiagnosticKind::SkippedLargeFile {
795                size_bytes: 6_000_000,
796            },
797        )
798    }
799
800    fn root_kinds(document: &serde_json::Value) -> Vec<String> {
801        document["workspace_diagnostics"]
802            .as_array()
803            .cloned()
804            .unwrap_or_default()
805            .iter()
806            .map(|diagnostic| diagnostic["kind"].as_str().unwrap_or_default().to_owned())
807            .collect()
808    }
809
810    /// Issue #2366: the programmatic combined envelope (MCP `analyze` in code
811    /// mode, NAPI, embedders) carries the run's workspace diagnostics on the
812    /// combined root, root-relative, and omits the array when there are none.
813    #[test]
814    fn combined_programmatic_root_carries_workspace_diagnostics_or_omits_them() {
815        let root = Path::new("/project");
816        let carried = serialize_combined_programmatic_json(combined_output(
817            root,
818            Some(dead_code_output(root, vec![bun_lockb_diagnostic(root)])),
819            None,
820        ))
821        .expect("combined JSON");
822        assert_eq!(
823            carried["workspace_diagnostics"][0]["kind"],
824            "bun-lockb-override-resolution-skipped"
825        );
826        assert_eq!(carried["workspace_diagnostics"][0]["path"], "package.json");
827        assert!(
828            carried["check"].is_object(),
829            "the check section is present, so the absence check below is not vacuous: {carried}"
830        );
831        assert!(
832            carried["check"].get("workspace_diagnostics").is_none(),
833            "the check section is not a second carrier: {carried}"
834        );
835
836        let empty = serialize_combined_programmatic_json(combined_output(
837            root,
838            Some(dead_code_output(root, Vec::new())),
839            None,
840        ))
841        .expect("combined JSON");
842        assert!(
843            empty.get("workspace_diagnostics").is_none(),
844            "an empty list is omitted from the combined root: {empty}"
845        );
846    }
847
848    /// Issue #2366: a programmatic combined run without a dead-code section
849    /// (the `--skip check` / `--only health` shape) still reports the
850    /// diagnostics, taken from the section that did run.
851    #[test]
852    fn combined_programmatic_root_carries_workspace_diagnostics_without_a_dead_code_section() {
853        let root = Path::new("/project");
854        let carried = serialize_combined_programmatic_json(combined_output(
855            root,
856            None,
857            Some(health_output(root, vec![bun_lockb_diagnostic(root)])),
858        ))
859        .expect("combined JSON");
860        assert!(
861            carried.get("check").is_none(),
862            "this run has no check section: {carried}"
863        );
864        assert_eq!(
865            carried["workspace_diagnostics"][0]["kind"],
866            "bun-lockb-override-resolution-skipped"
867        );
868        assert_eq!(carried["workspace_diagnostics"][0]["path"], "package.json");
869    }
870
871    /// Issue #2366: a duplication-only combined run (an embedder driving
872    /// `CombinedOptions` with just `duplication`) reports what that section
873    /// recorded.
874    #[test]
875    fn combined_programmatic_root_carries_workspace_diagnostics_from_a_duplication_only_run() {
876        let root = Path::new("/project");
877        let carried = serialize_combined_programmatic_json(combined_output_with_duplication(
878            root,
879            None,
880            None,
881            Some(duplication_output(
882                root,
883                vec![large_file_diagnostic(root, "src/generated.ts")],
884            )),
885        ))
886        .expect("combined JSON");
887        assert!(
888            carried.get("check").is_none() && carried.get("health").is_none(),
889            "only the dupes section ran: {carried}"
890        );
891        assert_eq!(root_kinds(&carried), ["skipped-large-file"]);
892        assert_eq!(
893            carried["workspace_diagnostics"][0]["path"],
894            "src/generated.ts"
895        );
896    }
897
898    /// Issue #2366: sections of one combined run can record different lists,
899    /// because each analysis walks the project itself and a per-analysis
900    /// `production` mode changes which files that walk sees. The root carries
901    /// the union so nothing the run recorded is dropped, deduplicated so a
902    /// diagnostic two sections both saw is reported once, and in section order
903    /// so a run whose analyses agree matches the standalone `dead-code`
904    /// envelope exactly.
905    #[test]
906    fn combined_programmatic_root_unions_sections_that_recorded_different_diagnostics() {
907        let root = Path::new("/project");
908        let shared = bun_lockb_diagnostic(root);
909        let carried = serialize_combined_programmatic_json(combined_output_with_duplication(
910            root,
911            Some(dead_code_output(
912                root,
913                vec![shared.clone(), large_file_diagnostic(root, "src/big.ts")],
914            )),
915            Some(health_output(root, vec![shared.clone()])),
916            Some(duplication_output(
917                root,
918                vec![shared, large_file_diagnostic(root, "src/other.ts")],
919            )),
920        ))
921        .expect("combined JSON");
922        assert_eq!(
923            root_kinds(&carried),
924            [
925                "bun-lockb-override-resolution-skipped",
926                "skipped-large-file",
927                "skipped-large-file",
928            ],
929            "the union keeps dead-code order first and drops the repeats: {}",
930            carried["workspace_diagnostics"]
931        );
932        let paths: Vec<&str> = carried["workspace_diagnostics"]
933            .as_array()
934            .expect("array")
935            .iter()
936            .map(|diagnostic| diagnostic["path"].as_str().unwrap_or_default())
937            .collect();
938        assert_eq!(paths, ["package.json", "src/big.ts", "src/other.ts"]);
939    }
940
941    /// Issue #2366: a diagnostic only the health or duplication section
942    /// recorded still reaches the root when the dead-code section recorded
943    /// nothing, the direction that a `production: { deadCode: true }` split
944    /// produces on a real project.
945    #[test]
946    fn combined_programmatic_root_keeps_diagnostics_an_empty_dead_code_section_missed() {
947        let root = Path::new("/project");
948        let carried = serialize_combined_programmatic_json(combined_output_with_duplication(
949            root,
950            Some(dead_code_output(root, Vec::new())),
951            Some(health_output(
952                root,
953                vec![large_file_diagnostic(root, "src/big.test.ts")],
954            )),
955            None,
956        ))
957        .expect("combined JSON");
958        assert_eq!(root_kinds(&carried), ["skipped-large-file"]);
959        assert_eq!(
960            carried["workspace_diagnostics"][0]["path"],
961            "src/big.test.ts"
962        );
963    }
964}