Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
276 changes: 209 additions & 67 deletions schema/schema.json

Large diffs are not rendered by default.

295 changes: 206 additions & 89 deletions schema/v2/schema.unstable.json

Large diffs are not rendered by default.

124 changes: 109 additions & 15 deletions scripts/generate.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import * as fs from "fs/promises";
import { dirname } from "path";
import * as prettier from "prettier";

const CURRENT_V1_SCHEMA_RELEASE = "schema-v1.24.1";
const CURRENT_V2_SCHEMA_RELEASE = "schema-v2.0.0-alpha.7";
const CURRENT_V1_SCHEMA_RELEASE = "schema-v1.25.0";
const CURRENT_V2_SCHEMA_RELEASE = "schema-v2.0.0-alpha.8";
const CHECK_GENERATED = process.argv.includes("--check");

// ── Extensible-union pipeline ────────────────────────────────────────────────
Expand Down Expand Up @@ -47,6 +47,7 @@ const V1_EXTENSIBLE_UNIONS = [
"CreateElicitationRequest",
"CreateElicitationResponse",
"ElicitationPropertySchema",
"IdleStateUpdate",
"MultiSelectItems",
"StateUpdate",
];
Expand All @@ -59,6 +60,7 @@ const V2_EXTENSIBLE_UNIONS = [
"CreateElicitationResponse",
"DiffChange",
"ElicitationPropertySchema",
"IdleStateUpdate",
"McpServer",
"MultiSelectItems",
"NesSuggestion",
Expand Down Expand Up @@ -599,7 +601,24 @@ function annotateExtensibleUnions(schemaDefs) {
const defExclusions = new Map();
for (const [name, def] of Object.entries(schemaDefs)) {
const exclusion = annotateUnionNode(def);
if (exclusion) defExclusions.set(name, exclusion);
if (exclusion) {
const tag = def.properties?.[exclusion.key];
if (tag?.["x-deserialize-default-on-error"]) {
// The nullable shared tag selects a branch with no reported reason.
// Its field-level catch alone cannot salvage the sibling variant union.
if (
JSON.stringify(tag.type) !== JSON.stringify(["string", "null"]) ||
(def.required ?? []).includes(exclusion.key) ||
Object.hasOwn(tag, "default")
) {
throw new Error(
`${name}: unsupported default-on-error discriminator shape`,
);
}
exclusion.defaultOnErrorTag = true;
}
defExclusions.set(name, exclusion);
}
for (const child of Object.values(def)) {
walkSchema(child, annotateUnionNode);
}
Expand Down Expand Up @@ -675,7 +694,7 @@ function notClauseExclusion(not) {
function detectExtensibleUnions(schemaDefs, expectedUnions, lane, branded) {
const unions = [];
for (const [name, def] of Object.entries(schemaDefs)) {
const union = analyzeExtensibleUnion(name, def, branded);
const union = analyzeExtensibleUnion(name, def, branded, schemaDefs);
if (union) unions.push(union);
}

Expand Down Expand Up @@ -909,7 +928,7 @@ function emitExtensibleUnionGuards(unions) {
);
}

function analyzeExtensibleUnion(name, def, branded) {
function analyzeExtensibleUnion(name, def, branded, schemaDefs) {
const variants = def.anyOf ?? def.oneOf;
if (!Array.isArray(variants)) return undefined;

Expand Down Expand Up @@ -938,12 +957,19 @@ function analyzeExtensibleUnion(name, def, branded) {
.map((variant) => {
const refs = allOfRefs(variant);
const constValue = variant.properties?.[discriminant]?.const;
const nullTag = variant.properties?.[discriminant]?.type === "null";
const optionalNullTag =
nullTag && !(variant.required ?? []).includes(discriminant);
const label =
constValue !== undefined
? String(constValue)
: (variant.title ?? refs[0] ?? discriminant);

if (constValue === undefined && variant.properties?.[discriminant]) {
if (
constValue === undefined &&
variant.properties?.[discriminant] &&
!nullTag
) {
throw new Error(
`${name}: known variant "${label}" declares "${discriminant}" ` +
`without a const tag; analyzeExtensibleUnion cannot emit a sound guard for it`,
Expand All @@ -960,12 +986,21 @@ function analyzeExtensibleUnion(name, def, branded) {
}

const typeParts = refs.map((ref) => `types.${ref}`);
const zodParts = refs.map((ref) => `validate.z${ref}`);
const zodParts = refs.map((ref) =>
referencedPayloadExpr(ref, schemaDefs),
);
if (constValue !== undefined) {
typeParts.push(`{ ${discriminant}: ${JSON.stringify(constValue)} }`);
zodParts.push(
`z.object({ ${discriminant}: z.literal(${JSON.stringify(constValue)}) })`,
);
} else if (nullTag) {
typeParts.push(
`{ ${discriminant}${optionalNullTag ? "?" : ""}: null }`,
);
zodParts.push(
`z.object({ ${discriminant}: z.null()${optionalNullTag ? ".optional()" : ""} })`,
);
}
const inlineRequired = requiredInlineProps(`${name}.${label}`, variant, [
discriminant,
Expand All @@ -989,17 +1024,23 @@ function analyzeExtensibleUnion(name, def, branded) {
// alone would also accept custom-tagged values, since z.object ignores
// unknown keys.
const tagLiteral =
constValue !== undefined ? JSON.stringify(constValue) : "undefined";
constValue !== undefined
? JSON.stringify(constValue)
: nullTag
? "null"
: "undefined";
const tagExpr = `tagOf(value, ${JSON.stringify(discriminant)})`;
const tagCheck = optionalNullTag
? `(${tagExpr} === null || ${tagExpr} === undefined)`
: `${tagExpr} === ${tagLiteral}`;

return {
label,
pascal,
schemaConst,
tsType: `(${typeParts.join(" & ")})${commonPick}`,
zodExpr: chainAnd(zodParts),
checkExpr:
`tagOf(value, ${JSON.stringify(discriminant)}) === ${tagLiteral} &&\n` +
` ${schemaConst}.safeParse(value).success`,
checkExpr: `${tagCheck} &&\n ${schemaConst}.safeParse(value).success`,
};
});

Expand All @@ -1025,6 +1066,36 @@ function analyzeExtensibleUnion(name, def, branded) {
};
}

// Guards narrow the original value, not a salvaged parse result. Validate an
// optional referenced payload before its field-level default-on-error catch
// can hide invalid data (e.g. ErrorStopReason.error must really be an Error).
function referencedPayloadExpr(name, schemaDefs) {
const def = schemaDefs[name];
const props = [];
for (const [key, property] of Object.entries(def?.properties ?? {})) {
if (
!property["x-deserialize-default-on-error"] ||
(def.required ?? []).includes(key)
) {
continue;
}
const variants = property.anyOf;
if (
variants?.length !== 2 ||
!variants.some((variant) => variant.type === "null")
) {
continue;
}
const ref = variants.find((variant) => variant.$ref)?.$ref;
if (ref) {
props.push(`${JSON.stringify(key)}: validate.z${refName(ref)}.nullish()`);
}
}
return props.length
? `validate.z${name}.and(z.object({ ${props.join(", ")} }))`
: `validate.z${name}`;
}

// Zod for the def-level common properties that are required and not salvaged
// by deserialization defaults (e.g. CreateElicitationRequest's `message`).
// Salvaged (x-deserialize-default-on-error) props are deliberately excluded:
Expand Down Expand Up @@ -1278,9 +1349,8 @@ function createDeserializationResolvers(

ctx.chain.current = ctx.nodes.base(ctx);
if (defLevel) {
ctx.chain.current = deserializeWrap(
ctx.chain.current = extensibleUnionExpression(
ctx,
"preserveCustomPayload",
ctx.chain.current,
defLevel,
schemaDeserializeImport,
Expand All @@ -1299,9 +1369,8 @@ function createDeserializationResolvers(
const defLevel = annotatedDefExclusion(ctx, defExclusions);
if (!defLevel) return undefined;

ctx.chain.current = deserializeWrap(
ctx.chain.current = extensibleUnionExpression(
ctx,
"preserveCustomPayload",
ctx.nodes.base(ctx),
defLevel,
schemaDeserializeImport,
Expand Down Expand Up @@ -1337,6 +1406,31 @@ function annotatedDefExclusion(ctx, defExclusions) {
return defExclusions.get(segments[2]);
}

function extensibleUnionExpression(
ctx,
expression,
exclusion,
schemaDeserializeImport,
) {
const preserved = deserializeWrap(
ctx,
"preserveCustomPayload",
expression,
exclusion,
schemaDeserializeImport,
);
if (!exclusion.defaultOnErrorTag) return preserved;
return ctx
.$(
schemaDeserializeSymbol(
ctx.plugin,
"defaultOnErrorOptionalStringTag",
schemaDeserializeImport,
),
)
.call(preserved, ctx.$.fromValue(exclusion.key));
}

// Both schema-deserialize helpers share the (schema, key, knownTags) contract.
function deserializeWrap(
ctx,
Expand Down
1 change: 1 addition & 0 deletions src/acp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export {
CreateElicitationRequest,
CreateElicitationResponse,
ElicitationPropertySchema,
IdleStateUpdate,
MultiSelectItems,
StateUpdate,
} from "./schema/guards.gen.js";
Expand Down
23 changes: 23 additions & 0 deletions src/schema-deserialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,29 @@ export function preserveCustomPayload<Schema extends z.ZodType>(
}) as z.ZodType<z.output<Schema>, z.input<Schema>>;
}

// A shared optional discriminator must be salvaged before selecting a union
// branch, not only inside the common-properties half of an intersection.
// Normalize only malformed tags; valid strings still select their own branch,
// so this cannot turn a malformed known payload into a different variant.
export function defaultOnErrorOptionalStringTag<Schema extends z.ZodType>(
schema: Schema,
key: string,
) {
return z.preprocess((value) => {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return value;
}
const record = value as Record<string, unknown>;
const tag = record[key];
if (tag === undefined || tag === null || typeof tag === "string") {
return value;
}
const normalized = { ...record };
delete normalized[key];
return normalized;
}, schema);
}

export function vecSkipError<ItemSchema extends z.ZodType>(
itemSchema: ItemSchema,
) {
Expand Down
Loading
Loading