Skip to content

Support full transitive cross-schema resolution#39

Merged
mindsocket merged 2 commits into
mainfrom
feat/full-cross-schema-resolution
Mar 9, 2026
Merged

Support full transitive cross-schema resolution#39
mindsocket merged 2 commits into
mainfrom
feat/full-cross-schema-resolution

Conversation

@mindsocket
Copy link
Copy Markdown
Owner

@mindsocket mindsocket commented Mar 9, 2026

  • add a dedicated schema ref traversal module for transitive cross-file/internal `` resolution
  • recursively flatten nested allOf compositions for schema introspection used by template-sync and schemas show
  • keep schema.ts focused on registry/validator/metadata concerns
  • add regression fixtures/tests covering multi-hop cross-schema resolution
  • document transitive schema ref behavior in README and schemas docs

Testing

  • bun run test
  • bun run lint

Closes #33

Comment thread src/schema-refs.ts
Comment on lines +155 to +174
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;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. flattenAllOf(A)resolveRefWithContext(A) returns immediately (no $ref). Stack: {}
  2. Iterates A's allOf: [{$ref:"B"}] → calls flattenAllOf({$ref:"B"})
  3. resolveRefWithContext({$ref:"B"}) → stack.add("B#"), resolves B (no $ref), stack.delete("B#"). Stack: {}
  4. Iterates B's allOf: [{$ref:"A"}] → calls flattenAllOf({$ref:"A"})
  5. resolveRefWithContext({$ref:"A"}) → stack.add("A#"), resolves A, stack.delete("A#"). Stack: {}
  6. 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]
@mindsocket mindsocket force-pushed the feat/full-cross-schema-resolution branch from 376fe9a to 8c77cca Compare March 9, 2026 12:15
@mindsocket mindsocket merged commit 5614a4e into main Mar 9, 2026
0 of 2 checks passed
@mindsocket mindsocket deleted the feat/full-cross-schema-resolution branch March 21, 2026 01:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support full cross-schema resolution of refs

1 participant