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