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