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