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, group_filter) = grouping.map_or((None, None, None), |grouping| {
584        (
585            group_by_mode_from_label(grouping.mode),
586            Some(grouping.groups),
587            grouping.filter,
588        )
589    });
590    serialize_health_report_json(HealthJsonReportInput {
591        gate_outcomes: None,
592        request_outcomes,
593        report,
594        root: &root,
595        elapsed,
596        explain,
597        type_aware: None,
598        grouped_by,
599        groups,
600        group_filter,
601        workspace_diagnostics,
602        next_steps,
603        telemetry_analysis_run_id: telemetry_analysis_run_id.as_deref(),
604    })
605    .map_err(|err| {
606        ProgrammaticError::new(format!("failed to serialize health report: {err}"), 2)
607            .with_code("FALLOW_SERIALIZE_HEALTH_REPORT")
608            .with_context("health")
609    })
610}
611
612fn group_by_mode_from_label(label: &str) -> Option<GroupByMode> {
613    match label {
614        "owner" => Some(GroupByMode::Owner),
615        "directory" => Some(GroupByMode::Directory),
616        "package" => Some(GroupByMode::Package),
617        "section" => Some(GroupByMode::Section),
618        _ => None,
619    }
620}
621
622#[cfg(test)]
623mod tests {
624    use super::{serialize_audit_dead_code, serialize_combined_programmatic_json};
625    use crate::DupesReportPayload;
626    use crate::runtime::{
627        CombinedProgrammaticOutput, DeadCodeProgrammaticOutput, DuplicationProgrammaticOutput,
628        HealthProgrammaticOutput,
629    };
630    use fallow_output::{
631        CHECK_SCHEMA_VERSION, CheckOutputInput, DUPES_SCHEMA_VERSION, DupesOutputInput,
632        HealthReport, build_check_output, build_dupes_output,
633    };
634    use fallow_types::duplicates::DuplicationReport;
635    use fallow_types::results::AnalysisResults;
636    use fallow_types::workspace::{WorkspaceDiagnostic, WorkspaceDiagnosticKind};
637    use std::path::Path;
638    use std::time::Duration;
639
640    fn dead_code_output(
641        root: &Path,
642        workspace_diagnostics: Vec<WorkspaceDiagnostic>,
643    ) -> DeadCodeProgrammaticOutput {
644        DeadCodeProgrammaticOutput {
645            output: build_check_output(CheckOutputInput {
646                schema_version: CHECK_SCHEMA_VERSION,
647                version: "0.0.0-test".to_owned(),
648                elapsed: Duration::ZERO,
649                results: AnalysisResults::default(),
650                config_fixable: false,
651                meta: None,
652                workspace_diagnostics,
653                next_steps: Vec::new(),
654            }),
655            root: root.to_path_buf(),
656            config_fixable: false,
657            telemetry_analysis_run_id: None,
658        }
659    }
660
661    /// Issue #2366: the audit envelope's dead-code sub-result carries the run's
662    /// workspace diagnostics root-relative, matching what the CLI audit path
663    /// reads from the registry, and omits the array when there are none.
664    #[test]
665    fn audit_dead_code_section_carries_workspace_diagnostics_root_relative_or_omits_them() {
666        let root = Path::new("/project");
667        let carried = serialize_audit_dead_code(
668            &dead_code_output(
669                root,
670                vec![WorkspaceDiagnostic::new(
671                    root,
672                    root.join("package.json"),
673                    WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
674                )],
675            ),
676            None,
677        )
678        .expect("audit dead-code JSON");
679        assert_eq!(
680            carried["workspace_diagnostics"][0]["kind"],
681            "bun-lockb-override-resolution-skipped"
682        );
683        assert_eq!(carried["workspace_diagnostics"][0]["path"], "package.json");
684
685        let empty = serialize_audit_dead_code(&dead_code_output(root, Vec::new()), None)
686            .expect("audit dead-code JSON");
687        assert!(
688            empty.get("workspace_diagnostics").is_none(),
689            "an empty list is omitted from the audit dead-code section: {empty}"
690        );
691    }
692
693    fn health_output(
694        root: &Path,
695        workspace_diagnostics: Vec<WorkspaceDiagnostic>,
696    ) -> HealthProgrammaticOutput {
697        HealthProgrammaticOutput {
698            report: HealthReport::default(),
699            grouping: None,
700            root: root.to_path_buf(),
701            elapsed: Duration::ZERO,
702            explain: false,
703            workspace_diagnostics,
704            next_steps: Vec::new(),
705            telemetry_analysis_run_id: None,
706            request_outcomes: None,
707        }
708    }
709
710    fn duplication_output(
711        root: &Path,
712        workspace_diagnostics: Vec<WorkspaceDiagnostic>,
713    ) -> DuplicationProgrammaticOutput {
714        DuplicationProgrammaticOutput {
715            output: build_dupes_output(DupesOutputInput {
716                gate_outcomes: None,
717                request_outcomes: None,
718                baseline_staleness: None,
719                schema_version: DUPES_SCHEMA_VERSION,
720                version: "0.0.0-test".to_owned(),
721                elapsed: Duration::ZERO,
722                report: DupesReportPayload::from_report(&DuplicationReport::default()),
723                clone_groups_shown: 0,
724                clone_groups_omitted: 0,
725                clone_families_shown: 0,
726                clone_families_omitted: 0,
727                grouped_by: None,
728                total_issues: None,
729                groups: None,
730                meta: None,
731                workspace_diagnostics,
732                next_steps: Vec::new(),
733            }),
734            root: root.to_path_buf(),
735            threshold: 0.0,
736            telemetry_analysis_run_id: None,
737        }
738    }
739
740    fn combined_output(
741        root: &Path,
742        dead_code: Option<DeadCodeProgrammaticOutput>,
743        health: Option<HealthProgrammaticOutput>,
744    ) -> CombinedProgrammaticOutput {
745        combined_output_with_duplication(root, dead_code, health, None)
746    }
747
748    fn combined_output_with_duplication(
749        root: &Path,
750        dead_code: Option<DeadCodeProgrammaticOutput>,
751        health: Option<HealthProgrammaticOutput>,
752        duplication: Option<DuplicationProgrammaticOutput>,
753    ) -> CombinedProgrammaticOutput {
754        CombinedProgrammaticOutput {
755            dead_code,
756            duplication,
757            health,
758            root: root.to_path_buf(),
759            elapsed: Duration::ZERO,
760            explain: false,
761            next_steps: Vec::new(),
762            telemetry_analysis_run_id: None,
763            request_outcomes: None,
764        }
765    }
766
767    fn bun_lockb_diagnostic(root: &Path) -> WorkspaceDiagnostic {
768        WorkspaceDiagnostic::new(
769            root,
770            root.join("package.json"),
771            WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
772        )
773    }
774
775    fn large_file_diagnostic(root: &Path, relative: &str) -> WorkspaceDiagnostic {
776        WorkspaceDiagnostic::new(
777            root,
778            root.join(relative),
779            WorkspaceDiagnosticKind::SkippedLargeFile {
780                size_bytes: 6_000_000,
781            },
782        )
783    }
784
785    fn root_kinds(document: &serde_json::Value) -> Vec<String> {
786        document["workspace_diagnostics"]
787            .as_array()
788            .cloned()
789            .unwrap_or_default()
790            .iter()
791            .map(|diagnostic| diagnostic["kind"].as_str().unwrap_or_default().to_owned())
792            .collect()
793    }
794
795    /// Issue #2366: the programmatic combined envelope (MCP `analyze` in code
796    /// mode, NAPI, embedders) carries the run's workspace diagnostics on the
797    /// combined root, root-relative, and omits the array when there are none.
798    #[test]
799    fn combined_programmatic_root_carries_workspace_diagnostics_or_omits_them() {
800        let root = Path::new("/project");
801        let carried = serialize_combined_programmatic_json(combined_output(
802            root,
803            Some(dead_code_output(root, vec![bun_lockb_diagnostic(root)])),
804            None,
805        ))
806        .expect("combined JSON");
807        assert_eq!(
808            carried["workspace_diagnostics"][0]["kind"],
809            "bun-lockb-override-resolution-skipped"
810        );
811        assert_eq!(carried["workspace_diagnostics"][0]["path"], "package.json");
812        assert!(
813            carried["check"].is_object(),
814            "the check section is present, so the absence check below is not vacuous: {carried}"
815        );
816        assert!(
817            carried["check"].get("workspace_diagnostics").is_none(),
818            "the check section is not a second carrier: {carried}"
819        );
820
821        let empty = serialize_combined_programmatic_json(combined_output(
822            root,
823            Some(dead_code_output(root, Vec::new())),
824            None,
825        ))
826        .expect("combined JSON");
827        assert!(
828            empty.get("workspace_diagnostics").is_none(),
829            "an empty list is omitted from the combined root: {empty}"
830        );
831    }
832
833    /// Issue #2366: a programmatic combined run without a dead-code section
834    /// (the `--skip check` / `--only health` shape) still reports the
835    /// diagnostics, taken from the section that did run.
836    #[test]
837    fn combined_programmatic_root_carries_workspace_diagnostics_without_a_dead_code_section() {
838        let root = Path::new("/project");
839        let carried = serialize_combined_programmatic_json(combined_output(
840            root,
841            None,
842            Some(health_output(root, vec![bun_lockb_diagnostic(root)])),
843        ))
844        .expect("combined JSON");
845        assert!(
846            carried.get("check").is_none(),
847            "this run has no check section: {carried}"
848        );
849        assert_eq!(
850            carried["workspace_diagnostics"][0]["kind"],
851            "bun-lockb-override-resolution-skipped"
852        );
853        assert_eq!(carried["workspace_diagnostics"][0]["path"], "package.json");
854    }
855
856    /// Issue #2366: a duplication-only combined run (an embedder driving
857    /// `CombinedOptions` with just `duplication`) reports what that section
858    /// recorded.
859    #[test]
860    fn combined_programmatic_root_carries_workspace_diagnostics_from_a_duplication_only_run() {
861        let root = Path::new("/project");
862        let carried = serialize_combined_programmatic_json(combined_output_with_duplication(
863            root,
864            None,
865            None,
866            Some(duplication_output(
867                root,
868                vec![large_file_diagnostic(root, "src/generated.ts")],
869            )),
870        ))
871        .expect("combined JSON");
872        assert!(
873            carried.get("check").is_none() && carried.get("health").is_none(),
874            "only the dupes section ran: {carried}"
875        );
876        assert_eq!(root_kinds(&carried), ["skipped-large-file"]);
877        assert_eq!(
878            carried["workspace_diagnostics"][0]["path"],
879            "src/generated.ts"
880        );
881    }
882
883    /// Issue #2366: sections of one combined run can record different lists,
884    /// because each analysis walks the project itself and a per-analysis
885    /// `production` mode changes which files that walk sees. The root carries
886    /// the union so nothing the run recorded is dropped, deduplicated so a
887    /// diagnostic two sections both saw is reported once, and in section order
888    /// so a run whose analyses agree matches the standalone `dead-code`
889    /// envelope exactly.
890    #[test]
891    fn combined_programmatic_root_unions_sections_that_recorded_different_diagnostics() {
892        let root = Path::new("/project");
893        let shared = bun_lockb_diagnostic(root);
894        let carried = serialize_combined_programmatic_json(combined_output_with_duplication(
895            root,
896            Some(dead_code_output(
897                root,
898                vec![shared.clone(), large_file_diagnostic(root, "src/big.ts")],
899            )),
900            Some(health_output(root, vec![shared.clone()])),
901            Some(duplication_output(
902                root,
903                vec![shared, large_file_diagnostic(root, "src/other.ts")],
904            )),
905        ))
906        .expect("combined JSON");
907        assert_eq!(
908            root_kinds(&carried),
909            [
910                "bun-lockb-override-resolution-skipped",
911                "skipped-large-file",
912                "skipped-large-file",
913            ],
914            "the union keeps dead-code order first and drops the repeats: {}",
915            carried["workspace_diagnostics"]
916        );
917        let paths: Vec<&str> = carried["workspace_diagnostics"]
918            .as_array()
919            .expect("array")
920            .iter()
921            .map(|diagnostic| diagnostic["path"].as_str().unwrap_or_default())
922            .collect();
923        assert_eq!(paths, ["package.json", "src/big.ts", "src/other.ts"]);
924    }
925
926    /// Issue #2366: a diagnostic only the health or duplication section
927    /// recorded still reaches the root when the dead-code section recorded
928    /// nothing, the direction that a `production: { deadCode: true }` split
929    /// produces on a real project.
930    #[test]
931    fn combined_programmatic_root_keeps_diagnostics_an_empty_dead_code_section_missed() {
932        let root = Path::new("/project");
933        let carried = serialize_combined_programmatic_json(combined_output_with_duplication(
934            root,
935            Some(dead_code_output(root, Vec::new())),
936            Some(health_output(
937                root,
938                vec![large_file_diagnostic(root, "src/big.test.ts")],
939            )),
940            None,
941        ))
942        .expect("combined JSON");
943        assert_eq!(root_kinds(&carried), ["skipped-large-file"]);
944        assert_eq!(
945            carried["workspace_diagnostics"][0]["path"],
946            "src/big.test.ts"
947        );
948    }
949}