-
Notifications
You must be signed in to change notification settings - Fork 0
Support full transitive cross-schema resolution #39
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,227 @@ | ||
| import type { AnySchemaObject } from 'ajv'; | ||
|
|
||
| interface ResolvedSchema { | ||
| schema: AnySchemaObject; | ||
| rootSchema: AnySchemaObject; | ||
| } | ||
|
|
||
| function isObject(value: unknown): value is Record<string, unknown> { | ||
| return typeof value === 'object' && value !== null; | ||
| } | ||
|
|
||
| function asArray<T>(value: unknown): T[] { | ||
| return Array.isArray(value) ? (value as T[]) : []; | ||
| } | ||
|
|
||
| function decodeJsonPointerToken(token: string): string { | ||
| return token.replace(/~1/g, '/').replace(/~0/g, '~'); | ||
| } | ||
|
|
||
| function resolveJsonPointer(root: AnySchemaObject, pointer: string, fullRef: string): AnySchemaObject { | ||
| if (pointer === '') return root; | ||
| if (!pointer.startsWith('/')) { | ||
| throw new Error(`Unsupported $ref pointer "${fullRef}". Expected a JSON pointer (e.g. "#/$defs/name").`); | ||
| } | ||
|
|
||
| let current: unknown = root; | ||
| for (const rawToken of pointer.slice(1).split('/')) { | ||
| const token = decodeJsonPointerToken(rawToken); | ||
|
|
||
| if (Array.isArray(current)) { | ||
| const index = Number.parseInt(token, 10); | ||
| if (Number.isNaN(index) || index < 0 || index >= current.length) { | ||
| throw new Error(`Cannot resolve $ref "${fullRef}": array index "${token}" is out of bounds.`); | ||
| } | ||
| current = current[index]; | ||
| continue; | ||
| } | ||
|
|
||
| if (!isObject(current) || !(token in current)) { | ||
| throw new Error(`Cannot resolve $ref "${fullRef}": token "${token}" does not exist.`); | ||
| } | ||
|
|
||
| current = current[token]; | ||
| } | ||
|
|
||
| if (!isObject(current)) { | ||
| throw new Error(`Cannot resolve $ref "${fullRef}": target is not an object schema.`); | ||
| } | ||
|
|
||
| return current as AnySchemaObject; | ||
| } | ||
|
|
||
| function mergeSchemaObjects(base: AnySchemaObject, overlay: AnySchemaObject): AnySchemaObject { | ||
| const merged = { ...base, ...overlay } as Record<string, unknown>; | ||
|
|
||
| const baseProps = isObject(base.properties) ? (base.properties as Record<string, AnySchemaObject>) : undefined; | ||
| const overlayProps = isObject(overlay.properties) | ||
| ? (overlay.properties as Record<string, AnySchemaObject>) | ||
| : undefined; | ||
| if (baseProps || overlayProps) { | ||
| merged.properties = { | ||
| ...(baseProps ?? {}), | ||
| ...(overlayProps ?? {}), | ||
| }; | ||
| } | ||
|
|
||
| const baseRequired = asArray<string>(base.required); | ||
| const overlayRequired = asArray<string>(overlay.required); | ||
| if (baseRequired.length > 0 || overlayRequired.length > 0) { | ||
| merged.required = [...new Set([...baseRequired, ...overlayRequired])]; | ||
| } | ||
|
|
||
| const baseAllOf = asArray<AnySchemaObject>(base.allOf); | ||
| const overlayAllOf = asArray<AnySchemaObject>(overlay.allOf); | ||
| if (baseAllOf.length > 0 || overlayAllOf.length > 0) { | ||
| merged.allOf = [...baseAllOf, ...overlayAllOf]; | ||
| } | ||
|
|
||
| return merged as AnySchemaObject; | ||
| } | ||
|
|
||
| function resolveRefTarget( | ||
| ref: string, | ||
| currentRootSchema: AnySchemaObject, | ||
| registry: Map<string, AnySchemaObject>, | ||
| ): { schema: AnySchemaObject; rootSchema: AnySchemaObject; refKey: string } { | ||
| if (ref.startsWith('#')) { | ||
| const pointer = ref.slice(1); | ||
| const rootId = typeof currentRootSchema.$id === 'string' ? currentRootSchema.$id : '(root)'; | ||
| return { | ||
| schema: resolveJsonPointer(currentRootSchema, pointer, ref), | ||
| rootSchema: currentRootSchema, | ||
| refKey: `${rootId}#${pointer}`, | ||
| }; | ||
| } | ||
|
|
||
| const hashIndex = ref.indexOf('#'); | ||
| const baseId = hashIndex >= 0 ? ref.slice(0, hashIndex) : ref; | ||
| const pointer = hashIndex >= 0 ? ref.slice(hashIndex + 1) : ''; | ||
|
|
||
| const externalSchema = registry.get(baseId); | ||
| if (!externalSchema) { | ||
| throw new Error(`Cannot resolve external $ref: ${ref}`); | ||
| } | ||
|
|
||
| return { | ||
| schema: resolveJsonPointer(externalSchema, pointer, ref), | ||
| rootSchema: externalSchema, | ||
| refKey: `${baseId}#${pointer}`, | ||
| }; | ||
| } | ||
|
|
||
| function resolveRefWithContext( | ||
| def: AnySchemaObject | undefined, | ||
| rootSchema: AnySchemaObject, | ||
| registry: Map<string, AnySchemaObject>, | ||
| stack: Set<string>, | ||
| ): ResolvedSchema | undefined { | ||
| if (!def) return undefined; | ||
| const ref = typeof def.$ref === 'string' ? def.$ref : undefined; | ||
| if (!ref) { | ||
| return { schema: def, rootSchema }; | ||
| } | ||
|
|
||
| const target = resolveRefTarget(ref, rootSchema, registry); | ||
| if (stack.has(target.refKey)) { | ||
| throw new Error(`Cyclic $ref detected: ${[...stack, target.refKey].join(' -> ')}`); | ||
| } | ||
|
|
||
| stack.add(target.refKey); | ||
| const resolvedTarget = resolveRefWithContext(target.schema, target.rootSchema, registry, stack); | ||
| stack.delete(target.refKey); | ||
|
|
||
| if (!resolvedTarget) return undefined; | ||
|
|
||
| const overlay: Record<string, unknown> = {}; | ||
| let hasOverlay = false; | ||
| for (const [k, v] of Object.entries(def)) { | ||
| if (k !== '$ref') { | ||
| overlay[k] = v; | ||
| hasOverlay = true; | ||
| } | ||
| } | ||
|
|
||
| if (!hasOverlay) { | ||
| return resolvedTarget; | ||
| } | ||
|
|
||
| return { | ||
| schema: mergeSchemaObjects(resolvedTarget.schema, overlay as AnySchemaObject), | ||
| rootSchema: resolvedTarget.rootSchema, | ||
| }; | ||
| } | ||
|
|
||
| function flattenAllOf( | ||
| def: AnySchemaObject | undefined, | ||
| rootSchema: AnySchemaObject, | ||
| registry: Map<string, AnySchemaObject>, | ||
| stack: Set<string>, | ||
| visited = new Set<string>(), | ||
| ): 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)) { | ||
| // Cycle detected via allOf: this schema is already being processed | ||
| return []; | ||
| } | ||
| if (schemaId) visited.add(schemaId); | ||
|
|
||
| const parts: ResolvedSchema[] = []; | ||
| const allOf = asArray<AnySchemaObject>(resolved.schema.allOf); | ||
| for (const sub of allOf) { | ||
| parts.push(...flattenAllOf(sub, resolved.rootSchema, registry, stack, visited)); | ||
| } | ||
|
|
||
| if (schemaId) visited.delete(schemaId); | ||
|
|
||
| const own = { ...resolved.schema } as Record<string, unknown>; | ||
| delete own.allOf; | ||
| parts.push({ schema: own as AnySchemaObject, rootSchema: resolved.rootSchema }); | ||
| return parts; | ||
| } | ||
|
|
||
| /** | ||
| * Resolve a schema definition, following cross-file and internal refs transitively. | ||
| */ | ||
| export function resolveRef( | ||
| propDef: AnySchemaObject | undefined, | ||
| schema: AnySchemaObject, | ||
| registry: Map<string, AnySchemaObject>, | ||
| ): AnySchemaObject | undefined { | ||
| return resolveRefWithContext(propDef, schema, registry, new Set())?.schema; | ||
| } | ||
|
|
||
| /** | ||
| * Merge properties and required fields from allOf entries recursively across refs. | ||
| * allOf entries are flattened depth-first; direct properties on later fragments override earlier ones. | ||
| */ | ||
| export function mergeVariantProperties( | ||
| variant: AnySchemaObject, | ||
| schema: AnySchemaObject, | ||
| registry: Map<string, AnySchemaObject>, | ||
| ): { properties: Record<string, AnySchemaObject>; required: string[] } { | ||
| const properties: Record<string, AnySchemaObject> = {}; | ||
| const requiredSet = new Set<string>(); | ||
| const fragments = flattenAllOf(variant, schema, registry, new Set()); | ||
|
|
||
| for (const fragment of fragments) { | ||
| const fragmentProps = isObject(fragment.schema.properties) | ||
| ? (fragment.schema.properties as Record<string, AnySchemaObject>) | ||
| : undefined; | ||
|
|
||
| if (fragmentProps) { | ||
| for (const [key, value] of Object.entries(fragmentProps)) { | ||
| properties[key] = resolveRef(value as AnySchemaObject, fragment.rootSchema, registry) ?? value; | ||
| } | ||
| } | ||
|
|
||
| for (const req of asArray<string>(fragment.schema.required)) { | ||
| requiredSet.add(req); | ||
| } | ||
| } | ||
|
|
||
| return { properties, required: [...requiredSet] }; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| { | ||
| "$schema": "http://json-schema.org/draft-07/schema#", | ||
| "$id": "test://leaf", | ||
| "$defs": { | ||
| "score": { | ||
| "type": "integer", | ||
| "minimum": 1, | ||
| "maximum": 5 | ||
| }, | ||
| "leafProps": { | ||
| "type": "object", | ||
| "properties": { | ||
| "score": { "$ref": "#/$defs/score" } | ||
| }, | ||
| "required": ["score"] | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| { | ||
| "$schema": "http://json-schema.org/draft-07/schema#", | ||
| "$id": "test://mid", | ||
| "$defs": { | ||
| "mood": { | ||
| "type": "string", | ||
| "enum": ["happy", "sad"] | ||
| }, | ||
| "midProps": { | ||
| "type": "object", | ||
| "allOf": [{ "$ref": "test://leaf#/$defs/leafProps" }], | ||
| "properties": { | ||
| "mood": { "$ref": "#/$defs/mood" }, | ||
| "status": { "$ref": "ost-tools://_ost_tools_base#/$defs/status" } | ||
| }, | ||
| "required": ["mood", "status"] | ||
| } | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug:
flattenAllOfhas no cycle detection for mutually recursiveallOfschemas — infinite loop / stack overflowThe
stackpassed here is designed to detect cycles in consecutive$refchains (e.g. A$ref→ B$ref→ A), but it cannot detect cycles that crossallOfboundaries. The reason is thatresolveRefWithContextcallsstack.delete(target.refKey)before returning, so by the timeflattenAllOfiterates over the resolved schema'sallOfsub-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:
Trace:
flattenAllOf(A)→resolveRefWithContext(A)returns immediately (no$ref). Stack:{}allOf: [{$ref:"B"}]→ callsflattenAllOf({$ref:"B"})resolveRefWithContext({$ref:"B"})→ stack.add("B#"), resolves B (no$ref), stack.delete("B#"). Stack:{}allOf: [{$ref:"A"}]→ callsflattenAllOf({$ref:"A"})resolveRefWithContext({$ref:"A"})→ stack.add("A#"), resolves A, stack.delete("A#"). Stack:{}allOf: [{$ref:"B"}]→ back to step 2 → infinite recursionSuggested fix: Give
flattenAllOfits 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'sallOfentries, check if that schema's ID is already in the visited set and skip it if so:Note that
mergeVariantProperties(line 198) and callers would also need to passvisitedthrough, or the default parameter handles it automatically for the top-level call.