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