ignore v1 in JSON schema codegen (#12408)

## Why

The generated unnamespaced JSON envelope schemas (`ClientRequest` and
`ServerNotification`) still contained both v1 and v2 variants, which
pulled legacy v1/core types and v2 types into the same `definitions`
graph. That caused `schemars` to produce numeric suffix names (for
example `AskForApproval2`, `ByteRange2`, `MessagePhase2`).

This PR moves JSON codegen toward v2-only output while preserving the
unnamespaced envelope artifacts, and avoids reintroducing numeric-suffix
tolerance by removing the v1/internal-only variants that caused the
collisions in those envelope schemas.

## What Changed

- In `codex-rs/app-server-protocol/src/export.rs`, JSON generation now
excludes v1 schema artifacts (`v1/*`) while continuing to emit
unnamespaced/root JSON schemas and the JSON bundle.
- Added a narrow JSON v1 allowlist (`JSON_V1_ALLOWLIST`) so
`InitializeParams` and `InitializeResponse` are still emitted.
- Added JSON-only post-processing for the mixed envelope schemas before
collision checks run:
- `ClientRequest`: strips v1 request variants from the generated `oneOf`
using the temporary `V1_CLIENT_REQUEST_METHODS` list
- `ServerNotification`: strips v1 notifications plus the internal-only
`rawResponseItem/completed` notification using the temporary
`EXCLUDED_SERVER_NOTIFICATION_METHODS_FOR_JSON` list
- Added a temporary local-definition pruning pass for those envelope
schemas so now-unreferenced v1/core definitions are removed from
`definitions` after method filtering.
- Updated the variant-title naming heuristic for single-property literal
object variants to use the literal value (when available), avoiding
collisions like multiple `state`-only variants all deriving the same
title.
- Collision handling remains fail-fast (no numeric suffix fallback map
in this PR path).

## Verification

- `just write-app-server-schema`

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/12408).
* __->__ #12408
* #12406
This commit is contained in:
Michael Bolin
2026-02-20 21:36:12 -08:00
committed by GitHub
parent dca9c40dd5
commit 1779feb6a7
47 changed files with 204 additions and 32323 deletions

View File

@@ -32,93 +32,41 @@ use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use std::sync::LazyLock;
use ts_rs::TS;
const HEADER: &str = "// GENERATED CODE! DO NOT MODIFY BY HAND!\n\n";
const IGNORED_DEFINITIONS: &[&str] = &["Option<()>"];
static VARIANT_TITLE_COLLISION_OVERRIDES: LazyLock<HashMap<&'static str, &'static str>> =
LazyLock::new(|| {
HashMap::from([
(
"base=McpStartupStatus|generated=StateMcpStartupStatus|literal:state=ready|only_property=state|required_only=state",
"StateMcpStartupStatus2",
),
(
"base=McpStartupStatus|generated=StateMcpStartupStatus|literal:state=cancelled|only_property=state|required_only=state",
"StateMcpStartupStatus3",
),
])
});
static NUMBERED_DEFINITION_COLLISION_OVERRIDES: LazyLock<HashMap<&'static str, &'static str>> =
LazyLock::new(|| {
HashMap::from([
(
"schema=ClientRequest|container=definitions|generated=AskForApproval2|base=AskForApproval",
"AskForApproval2",
),
(
"schema=ClientRequest|container=definitions|generated=NetworkAccess2|base=NetworkAccess",
"NetworkAccess2",
),
(
"schema=ClientRequest|container=definitions|generated=ReadOnlyAccess2|base=ReadOnlyAccess",
"ReadOnlyAccess2",
),
(
"schema=ClientRequest|container=definitions|generated=SandboxMode2|base=SandboxMode",
"SandboxMode2",
),
(
"schema=ClientRequest|container=definitions|generated=SandboxPolicy2|base=SandboxPolicy",
"SandboxPolicy2",
),
(
"schema=ServerNotification|container=definitions|generated=ByteRange2|base=ByteRange",
"ByteRange2",
),
(
"schema=ServerNotification|container=definitions|generated=CodexErrorInfo2|base=CodexErrorInfo",
"CodexErrorInfo2",
),
(
"schema=ServerNotification|container=definitions|generated=CreditsSnapshot2|base=CreditsSnapshot",
"CreditsSnapshot2",
),
(
"schema=ServerNotification|container=definitions|generated=MessagePhase2|base=MessagePhase",
"MessagePhase2",
),
(
"schema=ServerNotification|container=definitions|generated=ModelRerouteReason2|base=ModelRerouteReason",
"ModelRerouteReason2",
),
(
"schema=ServerNotification|container=definitions|generated=PatchApplyStatus2|base=PatchApplyStatus",
"PatchApplyStatus2",
),
(
"schema=ServerNotification|container=definitions|generated=RateLimitSnapshot2|base=RateLimitSnapshot",
"RateLimitSnapshot2",
),
(
"schema=ServerNotification|container=definitions|generated=RateLimitWindow2|base=RateLimitWindow",
"RateLimitWindow2",
),
(
"schema=ServerNotification|container=definitions|generated=TextElement2|base=TextElement",
"TextElement2",
),
(
"schema=ServerNotification|container=definitions|generated=UserInput2|base=UserInput",
"UserInput2",
),
(
"schema=ServerNotification|container=definitions|generated=WebSearchAction2|base=WebSearchAction",
"WebSearchAction2",
),
])
});
const JSON_V1_ALLOWLIST: &[&str] = &["InitializeParams", "InitializeResponse"];
const V1_CLIENT_REQUEST_METHODS: &[&str] = &[
"newConversation",
"getConversationSummary",
"listConversations",
"resumeConversation",
"forkConversation",
"archiveConversation",
"sendUserMessage",
"sendUserTurn",
"interruptConversation",
"addConversationListener",
"removeConversationListener",
"gitDiffToRemote",
"loginApiKey",
"loginChatGpt",
"cancelLoginChatGpt",
"logoutChatGpt",
"getAuthStatus",
"getUserSavedConfig",
"setDefaultModel",
"getUserAgent",
"userInfo",
"execOneOffCommand",
];
const EXCLUDED_SERVER_NOTIFICATION_METHODS_FOR_JSON: &[&str] = &[
"authStatusChange",
"loginChatGptComplete",
"sessionConfigured",
"rawResponseItem/completed",
];
#[derive(Clone)]
pub struct GeneratedSchema {
@@ -264,6 +212,8 @@ pub fn generate_json_with_experimental(out_dir: &Path, experimental_api: bool) -
schemas.extend(export_server_response_schemas(out_dir)?);
schemas.extend(export_client_notification_schemas(out_dir)?);
schemas.extend(export_server_notification_schemas(out_dir)?);
schemas
.retain(|schema| !schema.in_v1_dir || JSON_V1_ALLOWLIST.contains(&schema.logical_name()));
let mut bundle = build_schema_bundle(schemas)?;
if !experimental_api {
@@ -1031,14 +981,23 @@ where
T: JsonSchema,
{
let file_stem = name.trim();
let (raw_namespace, logical_name) = split_namespace(file_stem);
let include_in_json_codegen =
raw_namespace != Some("v1") || JSON_V1_ALLOWLIST.contains(&logical_name);
let schema = schema_for!(T);
let mut schema_value = serde_json::to_value(schema)?;
enforce_numbered_definition_collision_overrides(file_stem, &mut schema_value);
annotate_schema(&mut schema_value, Some(file_stem));
if include_in_json_codegen {
if file_stem == "ClientRequest" {
strip_v1_client_request_variants_from_json_schema(&mut schema_value);
} else if file_stem == "ServerNotification" {
strip_v1_server_notification_variants_from_json_schema(&mut schema_value);
}
enforce_numbered_definition_collision_overrides(file_stem, &mut schema_value);
annotate_schema(&mut schema_value, Some(file_stem));
}
// If the name looks like a namespaced path (e.g., "v2::Type"), mirror
// the TypeScript layout and write to out_dir/v2/Type.json. Otherwise
// write alongside the legacy files.
let (raw_namespace, logical_name) = split_namespace(file_stem);
let out_path = if let Some(ns) = raw_namespace {
let dir = out_dir.join(ns);
ensure_dir(&dir)?;
@@ -1047,7 +1006,7 @@ where
out_dir.join(format!("{file_stem}.json"))
};
if !IGNORED_DEFINITIONS.contains(&logical_name) {
if include_in_json_codegen && !IGNORED_DEFINITIONS.contains(&logical_name) {
write_pretty_json(out_path, &schema_value)
.with_context(|| format!("Failed to write JSON schema for {file_stem}"))?;
}
@@ -1066,96 +1025,162 @@ where
fn enforce_numbered_definition_collision_overrides(schema_name: &str, schema: &mut Value) {
for defs_key in ["definitions", "$defs"] {
let renames = {
let Some(defs) = schema.get(defs_key).and_then(Value::as_object) else {
continue;
};
collect_numbered_definition_collision_renames(schema_name, defs_key, defs)
};
if renames.is_empty() {
let Some(defs) = schema.get(defs_key).and_then(Value::as_object) else {
continue;
}
#[expect(clippy::expect_used)]
let defs = schema
.get_mut(defs_key)
.and_then(Value::as_object_mut)
.expect("definition map should still be present");
for (old_name, new_name, collision_key) in &renames {
if old_name == new_name {
continue;
}
assert!(
!defs.contains_key(new_name),
"Numbered definition collision override produced duplicate definition {new_name} for {collision_key}",
);
let schema_value = defs.remove(old_name).unwrap_or_else(|| {
panic!("Missing generated numbered definition {old_name} for {collision_key}")
});
defs.insert(new_name.clone(), schema_value);
}
for (old_name, new_name, _) in renames {
if old_name != new_name {
rewrite_named_local_ref(schema, defs_key, &old_name, &new_name);
}
}
};
detect_numbered_definition_collisions(schema_name, defs_key, defs);
}
}
fn collect_numbered_definition_collision_renames(
fn strip_v1_client_request_variants_from_json_schema(schema: &mut Value) {
let v1_methods: HashSet<&str> = V1_CLIENT_REQUEST_METHODS.iter().copied().collect();
strip_method_variants_from_json_schema(schema, &v1_methods);
}
fn strip_v1_server_notification_variants_from_json_schema(schema: &mut Value) {
let methods: HashSet<&str> = EXCLUDED_SERVER_NOTIFICATION_METHODS_FOR_JSON
.iter()
.copied()
.collect();
strip_method_variants_from_json_schema(schema, &methods);
}
fn strip_method_variants_from_json_schema(schema: &mut Value, methods_to_remove: &HashSet<&str>) {
{
let Some(root) = schema.as_object_mut() else {
return;
};
let Some(Value::Array(variants)) = root.get_mut("oneOf") else {
return;
};
variants.retain(|variant| !is_method_variant_in_set(variant, methods_to_remove));
}
let reachable = reachable_local_definitions(schema, "definitions");
let Some(root) = schema.as_object_mut() else {
return;
};
if let Some(definitions) = root.get_mut("definitions").and_then(Value::as_object_mut) {
definitions.retain(|name, _| reachable.contains(name));
}
}
fn is_method_variant_in_set(value: &Value, methods: &HashSet<&str>) -> bool {
let Value::Object(map) = value else {
return false;
};
let Some(properties) = map.get("properties").and_then(Value::as_object) else {
return false;
};
let Some(method_schema) = properties.get("method") else {
return false;
};
let Some(method) = string_literal(method_schema) else {
return false;
};
methods.contains(method)
}
fn reachable_local_definitions(schema: &Value, defs_key: &str) -> HashSet<String> {
let Some(definitions) = schema.get(defs_key).and_then(Value::as_object) else {
return HashSet::new();
};
let mut queue: Vec<String> = Vec::new();
let mut reachable: HashSet<String> = HashSet::new();
collect_local_definition_refs_excluding_maps(schema, defs_key, &mut queue, &mut reachable);
while let Some(name) = queue.pop() {
if let Some(def_schema) = definitions.get(&name) {
collect_local_definition_refs(def_schema, defs_key, &mut queue, &mut reachable);
}
}
reachable
}
fn collect_local_definition_refs_excluding_maps(
value: &Value,
defs_key: &str,
queue: &mut Vec<String>,
reachable: &mut HashSet<String>,
) {
match value {
Value::Object(map) => {
for (key, child) in map {
if key == defs_key || key == "$defs" || key == "definitions" {
continue;
}
collect_local_definition_refs_excluding_maps(child, defs_key, queue, reachable);
}
}
Value::Array(items) => {
for child in items {
collect_local_definition_refs_excluding_maps(child, defs_key, queue, reachable);
}
}
_ => {}
}
collect_local_definition_ref_here(value, defs_key, queue, reachable);
}
fn collect_local_definition_refs(
value: &Value,
defs_key: &str,
queue: &mut Vec<String>,
reachable: &mut HashSet<String>,
) {
collect_local_definition_ref_here(value, defs_key, queue, reachable);
match value {
Value::Object(map) => {
for child in map.values() {
collect_local_definition_refs(child, defs_key, queue, reachable);
}
}
Value::Array(items) => {
for child in items {
collect_local_definition_refs(child, defs_key, queue, reachable);
}
}
_ => {}
}
}
fn collect_local_definition_ref_here(
value: &Value,
defs_key: &str,
queue: &mut Vec<String>,
reachable: &mut HashSet<String>,
) {
let Some(reference) = value
.as_object()
.and_then(|obj| obj.get("$ref"))
.and_then(Value::as_str)
else {
return;
};
let Some(name) = reference.strip_prefix(&format!("#/{defs_key}/")) else {
return;
};
let name = name.split('/').next().unwrap_or(name);
if reachable.insert(name.to_string()) {
queue.push(name.to_string());
}
}
fn detect_numbered_definition_collisions(
schema_name: &str,
defs_key: &str,
defs: &Map<String, Value>,
) -> Vec<(String, String, String)> {
let mut renames = Vec::new();
) {
for generated_name in defs.keys() {
let base_name = generated_name.trim_end_matches(|c: char| c.is_ascii_digit());
if base_name == generated_name || !defs.contains_key(base_name) {
continue;
}
let collision_key = format!(
"schema={schema_name}|container={defs_key}|generated={generated_name}|base={base_name}"
panic!(
"Numbered definition naming collision detected: schema={schema_name}|container={defs_key}|generated={generated_name}|base={base_name}"
);
let replacement = NUMBERED_DEFINITION_COLLISION_OVERRIDES
.get(collision_key.as_str())
.map(|value| (*value).to_string())
.unwrap_or_else(|| {
panic!("Missing numbered definition collision override for {collision_key}")
});
renames.push((generated_name.clone(), replacement, collision_key));
}
renames
}
fn rewrite_named_local_ref(value: &mut Value, defs_key: &str, old_name: &str, new_name: &str) {
let direct = format!("#/{defs_key}/{old_name}");
let prefixed = format!("{direct}/");
let replacement = format!("#/{defs_key}/{new_name}");
let replacement_prefixed = format!("{replacement}/");
match value {
Value::Object(obj) => {
if let Some(Value::String(reference)) = obj.get_mut("$ref") {
if reference == &direct {
*reference = replacement;
} else if let Some(rest) = reference.strip_prefix(&prefixed) {
*reference = format!("{replacement_prefixed}{rest}");
}
}
for child in obj.values_mut() {
rewrite_named_local_ref(child, defs_key, old_name, new_name);
}
}
Value::Array(items) => {
for child in items {
rewrite_named_local_ref(child, defs_key, old_name, new_name);
}
}
_ => {}
}
}
@@ -1263,7 +1288,11 @@ fn variant_definition_name(base: &str, variant: &Value) -> Option<String> {
if props.len() == 1
&& let Some(key) = props.keys().next()
{
let pascal = to_pascal_case(key);
let pascal = props
.get(key)
.and_then(string_literal)
.map(to_pascal_case)
.unwrap_or_else(|| to_pascal_case(key));
return Some(format!("{pascal}{base}"));
}
}
@@ -1376,20 +1405,11 @@ fn annotate_variant_list(variants: &mut [Value], base: Option<&str>) {
&& let Some(base_name) = base
&& let Some(name) = variant_definition_name(base_name, variant)
{
let mut candidate = name.clone();
let candidate = name.clone();
if seen.contains(&candidate) {
let collision_key = variant_title_collision_key(base_name, &name, variant);
candidate = VARIANT_TITLE_COLLISION_OVERRIDES
.get(collision_key.as_str())
.map(|value| (*value).to_string())
.unwrap_or_else(|| {
panic!(
"Missing variant title collision override for {collision_key} (generated name: {name})"
)
});
assert!(
!seen.contains(&candidate),
"Variant title collision override produced duplicate title {candidate} for {collision_key}",
panic!(
"Variant title naming collision detected: {collision_key} (generated name: {name})"
);
}
if let Some(obj) = variant.as_object_mut() {