Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions src/json-schema-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,4 +99,39 @@ describe('parser', () => {
// ASSERT
expect(errors).toEqual([]);
});

it('parses a schema with a circular reference', async () => {
// ARRANGE
const schema = {
$schema: 'http://json-schema.org/draft-07/schema#',
$id: 'https://example.com/mutual.schema.json',
$ref: '#/definitions/A',
definitions: {
A: {
type: 'object',
properties: {
b: {
$ref: '#/definitions/B',
},
},
},
B: {
type: 'object',
properties: {
a: {
$ref: '#/definitions/A',
},
},
},
},
};

const sourceContent = JSON.stringify(schema);

// ACT
const service = (await parser(sourceContent, absoluteSourcePath)).service;

// ASSERT
expect(service).toBeTruthy();

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

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

This test only asserts that service is truthy, so it won't catch cases where circular refs no longer overflow but still produce an invalid/partially-parsed service (e.g., missing type declarations or parser violations). It would be stronger to also validate the returned service (and/or assert no parser violations) and check that the expected types are present (e.g. A and B).

Suggested change
expect(service).toBeTruthy();
const validation = validate(service);
expect(validation.errors).toEqual([]);
const typeNames = service.types.map((t) => t.name);
expect(typeNames).toEqual(expect.arrayContaining(['A', 'B']));

Copilot uses AI. Check for mistakes.
});
});
58 changes: 41 additions & 17 deletions src/json-schema-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class JsonSchemaParser {
private readonly types = new Map<string, Type>();
private readonly enums = new Map<string, Enum>();
private readonly unions = new Map<string, Union>();
private readonly resolvingRefs = new Set<string>();
private readonly violations: Violation[] = [];

parse(): { service: Service; violations: Violation[] } {
Expand Down Expand Up @@ -208,25 +209,48 @@ class JsonSchemaParser {
loc: string | undefined,
): { memberValue: MemberValue; inheritedDescription: StringLiteral[] } {
if (schema.ref) {
const resolved = resolve(
this.source.node,
schema.ref.value,
AST.SchemaNode,
);
if (this.resolvingRefs.has(schema.ref.value)) {
const resolved = resolve(
this.source.node,
schema.ref.value,
AST.SchemaNode,
);
const typeName = this.parseTypeName(resolved);

if (resolved) {
return this.parseType(resolved, loc);
} else {
const { range, sourceIndex } = decodeRange(
encodeRange(0, schema.ref.loc),
if (typeName) {
return {
memberValue: { kind: 'ComplexValue', typeName, rules: [] },
inheritedDescription: [],
};
}

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

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

In the circular-ref short-circuit path, the parser returns a ComplexValue based on parseTypeName(resolved) without ensuring the referenced schema will actually result in a declared Type/Enum/Union. For cycles made entirely of $ref aliases (e.g. A -> B -> A where both schemas are just $ref), this will break the stack overflow but can still produce a service that references a type name that never gets created, and no violation is reported. Consider detecting $ref-only cycles and emitting a parser error (return untyped()), or registering an in-progress placeholder in the relevant map before descending so circular references always point at an eventual declaration.

Suggested change
const { range, sourceIndex } = decodeRange(
encodeRange(0, schema.ref.loc),
);
this.violations.push({
code: 'PARSER_ERROR',
message: `Circular ref '${schema.ref.value}' does not resolve to a concrete type`,
severity: 'error',
range,
sourcePath: this.sourcePaths[sourceIndex],
});

Copilot uses AI. Check for mistakes.
return { memberValue: untyped(), inheritedDescription: [] };
}

this.resolvingRefs.add(schema.ref.value);
try {
const resolved = resolve(
this.source.node,
schema.ref.value,
AST.SchemaNode,
);
this.violations.push({
code: 'PARSER_ERROR',
message: `Cannot resolve ref '${schema.ref.value}'`,
severity: 'error',
range,
sourcePath: this.sourcePaths[sourceIndex],
});

if (resolved) {
return this.parseType(resolved, loc);
} else {
const { range, sourceIndex } = decodeRange(
encodeRange(0, schema.ref.loc),
);
this.violations.push({
code: 'PARSER_ERROR',
message: `Cannot resolve ref '${schema.ref.value}'`,
severity: 'error',
range,
sourcePath: this.sourcePaths[sourceIndex],
});
}
} finally {
this.resolvingRefs.delete(schema.ref.value);
}
}

Expand Down
Loading