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