fix: bound schema and evidence projections
This commit is contained in:
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user