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