Support full transitive cross-schema resolution#39
Conversation
| function flattenAllOf( | ||
| def: AnySchemaObject | undefined, | ||
| rootSchema: AnySchemaObject, | ||
| registry: Map<string, AnySchemaObject>, | ||
| stack: Set<string>, | ||
| ): ResolvedSchema[] { | ||
| const resolved = resolveRefWithContext(def, rootSchema, registry, stack); | ||
| if (!resolved) return []; | ||
|
|
||
| const parts: ResolvedSchema[] = []; | ||
| const allOf = asArray<AnySchemaObject>(resolved.schema.allOf); | ||
| for (const sub of allOf) { | ||
| parts.push(...flattenAllOf(sub, resolved.rootSchema, registry, stack)); | ||
| } | ||
|
|
||
| const own = { ...resolved.schema } as Record<string, unknown>; | ||
| delete own.allOf; | ||
| parts.push({ schema: own as AnySchemaObject, rootSchema: resolved.rootSchema }); | ||
| return parts; | ||
| } |
There was a problem hiding this comment.
Bug: flattenAllOf has no cycle detection for mutually recursive allOf schemas — infinite loop / stack overflow
The stack passed here is designed to detect cycles in consecutive $ref chains (e.g. A $ref→ B $ref→ A), but it cannot detect cycles that cross allOf boundaries. The reason is that resolveRefWithContext calls stack.delete(target.refKey) before returning, so by the time flattenAllOf iterates over the resolved schema's allOf sub-entries (line 166–168), the stack is empty again and holds no memory of what schemas are currently being flattened.
Concrete example that causes infinite recursion:
// Schema A: { "$id": "A", "allOf": [{ "$ref": "B" }] }
// Schema B: { "$id": "B", "allOf": [{ "$ref": "A" }] }
Trace:
flattenAllOf(A)→resolveRefWithContext(A)returns immediately (no$ref). Stack:{}- Iterates A's
allOf: [{$ref:"B"}]→ callsflattenAllOf({$ref:"B"}) resolveRefWithContext({$ref:"B"})→ stack.add("B#"), resolves B (no$ref), stack.delete("B#"). Stack:{}- Iterates B's
allOf: [{$ref:"A"}]→ callsflattenAllOf({$ref:"A"}) resolveRefWithContext({$ref:"A"})→ stack.add("A#"), resolves A, stack.delete("A#"). Stack:{}- Iterates A's
allOf: [{$ref:"B"}]→ back to step 2 → infinite recursion
Suggested fix: Give flattenAllOf its own visited set that tracks which resolved schema IDs are currently being processed, separate from the $ref-chain stack. Before recursing into a resolved schema's allOf entries, check if that schema's ID is already in the visited set and skip it if so:
function flattenAllOf(
def: AnySchemaObject | undefined,
rootSchema: AnySchemaObject,
registry: Map<string, AnySchemaObject>,
stack: Set<string>,
visited = new Set<string>(), // ← new parameter
): ResolvedSchema[] {
const resolved = resolveRefWithContext(def, rootSchema, registry, stack);
if (!resolved) return [];
const schemaId = typeof resolved.schema.$id === 'string' ? resolved.schema.$id : undefined;
if (schemaId && visited.has(schemaId)) return []; // ← cycle guard
if (schemaId) visited.add(schemaId); // ← mark as in-progress
const parts: ResolvedSchema[] = [];
const allOf = asArray<AnySchemaObject>(resolved.schema.allOf);
for (const sub of allOf) {
parts.push(...flattenAllOf(sub, resolved.rootSchema, registry, stack, visited));
}
// ...
}Note that mergeVariantProperties (line 198) and callers would also need to pass visited through, or the default parameter handles it automatically for the top-level call.
Implements full multi-level cross-schema ref traversal for schema introspection and template generation.\n\nRefs #33.
Add separate visited set tracking to flattenAllOf to prevent infinite recursion when schemas reference each other through allOf. The existing $ref chain stack cannot detect these cycles because resolveRefWithContext clears stack entries before returning. Fixes stack overflow on patterns like: A.allOf[$ref:B], B.allOf[$ref:A]
376fe9a to
8c77cca
Compare
allOfcompositions for schema introspection used bytemplate-syncandschemas showschema.tsfocused on registry/validator/metadata concernsTesting
Closes #33