Skip to main content

fallow_api/
runtime_json.rs

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