fix: bound schema and evidence projections

This commit is contained in:
lda
2026-08-11 22:01:36 +07:00 Verified
parent e017cc0a97
commit fe49d1a11c
5 changed files with 274 additions and 15 deletions
@@ -173,6 +173,78 @@ describe("normalizeSchema", () => {
});
});
it("falls back deterministically for an object reference cycle through a property", () => {
const field = normalizeSchema({
$defs: {
Node: {
type: "object",
properties: {
label: { type: "string" },
child: { $ref: "#/$defs/Node" },
},
},
},
type: "object",
properties: {
root: { $ref: "#/$defs/Node" },
},
});
const root = field.children.find((child) => child.key === "root");
const child = root?.children.find((candidate) => candidate.key === "child");
expect(child).toMatchObject({
kind: "json",
fallbackReason: "Local schema reference cycle detected.",
});
});
it("falls back deterministically for an array reference cycle through its items", () => {
const field = normalizeSchema({
$defs: {
RecursiveList: {
type: "array",
items: { $ref: "#/$defs/RecursiveList" },
},
},
type: "object",
properties: {
values: { $ref: "#/$defs/RecursiveList" },
},
});
const values = field.children.find((child) => child.key === "values");
expect(values).toMatchObject({ kind: "array" });
expect(values?.item).toMatchObject({
kind: "json",
fallbackReason: "Local schema reference cycle detected.",
});
});
it("allows independent sibling fields to reuse the same referenced schema", () => {
const field = normalizeSchema({
$defs: {
Shared: {
type: "object",
properties: { value: { type: "string" } },
},
},
type: "object",
properties: {
first: { $ref: "#/$defs/Shared" },
second: { $ref: "#/$defs/Shared" },
},
});
expect(field.children.map((child) => child.kind)).toEqual([
"object",
"object",
]);
expect(field.children.map((child) => child.children[0]?.kind)).toEqual([
"string",
"string",
]);
});
it("recursively rebases nested object and array item paths", () => {
const field = normalizeSchema({
type: "object",
@@ -1,4 +1,4 @@
import { resolveLocalSchemaNode } from "./schema-reference.js";
import { resolveLocalSchemaNodeWithAncestry } from "./schema-reference.js";
export type SchemaField = {
readonly path: ReadonlyArray<string | number>;
@@ -37,6 +37,8 @@ export const rebaseSchemaField = (
type SchemaRecord = Record<string, unknown>;
type EnumValue = string | number | boolean | null;
const MAX_NORMALIZATION_DEPTH = 64;
const isRecord = (value: unknown): value is SchemaRecord =>
typeof value === "object" && value !== null && !Array.isArray(value);
@@ -109,8 +111,25 @@ const normalizeField = (
key: string,
required: boolean,
defaultTitle: string,
referenceAncestry: ReadonlySet<string>,
depth: number,
): SchemaField => {
const resolution = resolveLocalSchemaNode(rootSchema, schema);
if (depth >= MAX_NORMALIZATION_DEPTH) {
return fallback(
schema,
path,
key,
required,
defaultTitle,
"Schema normalization depth limit exceeded.",
);
}
const resolution = resolveLocalSchemaNodeWithAncestry(
rootSchema,
schema,
referenceAncestry,
);
if (!resolution.ok) {
const title = isRecord(schema) ? stringValue(schema.title) ?? defaultTitle : defaultTitle;
return fallback(schema, path, key, required, title, resolution.reason);
@@ -164,6 +183,8 @@ const normalizeField = (
propertyKey,
requiredNames.has(propertyKey),
stringValue(propertySchema && isRecord(propertySchema) ? propertySchema.title : null) ?? propertyKey,
resolution.referenceAncestry,
depth + 1,
),
)
: [];
@@ -188,7 +209,16 @@ const normalizeField = (
if (itemSchema === undefined) {
return fallback(resolvedSchema, path, key, required, title, "The array has no item schema; edit JSON directly.");
}
const item = normalizeField(rootSchema, itemSchema, [...path, 0], "item", true, `${title} item`);
const item = normalizeField(
rootSchema,
itemSchema,
[...path, 0],
"item",
true,
`${title} item`,
resolution.referenceAncestry,
depth + 1,
);
return {
path,
key,
@@ -230,7 +260,17 @@ export const normalizeSchemaField = (
path: ReadonlyArray<string | number> = [],
key = "root",
required = true,
): SchemaField => normalizeField(schema, schema, path, key, required, key === "root" ? "Value" : key);
): SchemaField =>
normalizeField(
schema,
schema,
path,
key,
required,
key === "root" ? "Value" : key,
new Set(),
0,
);
export const normalizeSchema = (schema: unknown): SchemaField =>
normalizeSchemaField(schema);
@@ -4,6 +4,14 @@ export type SchemaReferenceResolution =
| { readonly ok: true; readonly schema: unknown }
| { readonly ok: false; readonly reason: string };
export type SchemaReferenceTraversalResolution =
| {
readonly ok: true;
readonly schema: unknown;
readonly referenceAncestry: ReadonlySet<string>;
}
| { readonly ok: false; readonly reason: string };
const ANNOTATION_KEYS = ["title", "description", "default"] as const;
const isRecord = (value: unknown): value is SchemaRecord =>
@@ -12,7 +20,9 @@ const isRecord = (value: unknown): value is SchemaRecord =>
const hasOwn = (value: SchemaRecord, key: string): boolean =>
Object.prototype.hasOwnProperty.call(value, key);
const failure = (reason: string): SchemaReferenceResolution => ({
const failure = (
reason: string,
): { readonly ok: false; readonly reason: string } => ({
ok: false,
reason,
});
@@ -93,10 +103,10 @@ const mergeAnnotations = (
const resolveNode = (
rootSchema: unknown,
schemaNode: unknown,
activeReferences: Set<string>,
): SchemaReferenceResolution => {
referenceAncestry: ReadonlySet<string>,
): SchemaReferenceTraversalResolution => {
if (!isRecord(schemaNode) || !hasOwn(schemaNode, "$ref")) {
return { ok: true, schema: schemaNode };
return { ok: true, schema: schemaNode, referenceAncestry };
}
const ref = schemaNode.$ref;
@@ -114,7 +124,7 @@ const resolveNode = (
if (hasStructuralSiblings(schemaNode)) {
return failure("Structural siblings beside $ref are not supported.");
}
if (activeReferences.has(ref))
if (referenceAncestry.has(ref))
return failure("Local schema reference cycle detected.");
const target = pointerTarget(rootSchema, ref);
@@ -126,15 +136,36 @@ const resolveNode = (
);
}
// Track only the current reference path so repeated sibling refs remain valid.
activeReferences.add(ref);
const resolved = resolveNode(rootSchema, target.value, activeReferences);
activeReferences.delete(ref);
// Keep references active for structural child descent. Callers pass the
// resulting immutable ancestry to each child, so sibling reuse stays valid.
const nextAncestry = new Set(referenceAncestry);
nextAncestry.add(ref);
const resolved = resolveNode(rootSchema, target.value, nextAncestry);
if (!resolved.ok) return resolved;
return { ok: true, schema: mergeAnnotations(resolved.schema, schemaNode) };
return {
ok: true,
schema: mergeAnnotations(resolved.schema, schemaNode),
referenceAncestry: resolved.referenceAncestry,
};
};
export const resolveLocalSchemaNodeWithAncestry = (
rootSchema: unknown,
schemaNode: unknown,
referenceAncestry: ReadonlySet<string>,
): SchemaReferenceTraversalResolution =>
resolveNode(rootSchema, schemaNode, referenceAncestry);
export const resolveLocalSchemaNode = (
rootSchema: unknown,
schemaNode: unknown,
): SchemaReferenceResolution => resolveNode(rootSchema, schemaNode, new Set());
): SchemaReferenceResolution => {
const resolution = resolveLocalSchemaNodeWithAncestry(
rootSchema,
schemaNode,
new Set(),
);
return resolution.ok
? { ok: true, schema: resolution.schema }
: resolution;
};