diff --git a/.chronus/changes/copilot-fix-model-circular-references-2026-4-14-20-45-42.md b/.chronus/changes/copilot-fix-model-circular-references-2026-4-14-20-45-42.md new file mode 100644 index 00000000000..dd682cc84ba --- /dev/null +++ b/.chronus/changes/copilot-fix-model-circular-references-2026-4-14-20-45-42.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/compiler" +--- + +Fixed the compiler to correctly detect circular model spread chains while preserving support for recursive model-expression aliases. diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index cd23221098d..ada3f7ede75 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -529,6 +529,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker * Key is the SymId of a node. It can be retrieved with getNodeSymId(node) */ const pendingResolutions = new PendingResolutions(); + const spreadResolutionAncestors = new Map>(); const postCheckValidators: ValidatorFn[] = []; const typespecNamespaceBinding = resolver.symbols.global.exports!.get("TypeSpec"); @@ -6532,6 +6533,34 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } return undefined; } + + // Ancestors are models that already depend on this model via spread. + const modelAncestors = spreadResolutionAncestors.get(modelSymId); + if (targetSym && modelAncestors?.has(targetSym)) { + if (ctx.mapper === undefined) { + reportCheckerDiagnostic( + createDiagnostic({ + code: "spread-model", + messageId: "selfSpread", + target: target, + }), + ); + } + return undefined; + } + + if (targetSym) { + let targetAncestors = spreadResolutionAncestors.get(targetSym); + if (!targetAncestors) { + targetAncestors = new Set(); + spreadResolutionAncestors.set(targetSym, targetAncestors); + } + targetAncestors.add(modelSymId); + for (const ancestor of modelAncestors ?? []) { + targetAncestors.add(ancestor); + } + } + const type = getTypeForNode(target, ctx); return type; } @@ -7360,7 +7389,9 @@ export function createChecker(program: Program, resolver: NameResolver): Checker return inProgressType; } } - + if (node.value.kind === SyntaxKind.ModelExpression) { + return getTypeForNode(node.value, ctx); + } if (ctx.mapper === undefined) { reportCheckerDiagnostic( createDiagnostic({ diff --git a/packages/compiler/test/checker/alias.test.ts b/packages/compiler/test/checker/alias.test.ts index ae5b75f604e..6b27b7ed792 100644 --- a/packages/compiler/test/checker/alias.test.ts +++ b/packages/compiler/test/checker/alias.test.ts @@ -149,6 +149,18 @@ describe("compiler: aliases", () => { strictEqual(expr.namespace, Foo); }); + it("doesn't emit diagnostics for recursive aliases through model expressions", async () => { + const diagnostics = await Tester.diagnose(` + alias A = { + a: B; + }; + alias B = { + a: A; + }; + `); + expectDiagnosticEmpty(diagnostics); + }); + it("emit diagnostics if assign itself", async () => { const diagnostics = await Tester.diagnose(` alias A = A; diff --git a/packages/compiler/test/checker/spread.test.ts b/packages/compiler/test/checker/spread.test.ts index 126b147a885..018cc22f2fd 100644 --- a/packages/compiler/test/checker/spread.test.ts +++ b/packages/compiler/test/checker/spread.test.ts @@ -113,7 +113,7 @@ describe("circular reference", () => { }); // https://github.com/microsoft/typespec/issues/7956 - it.skip("emit diagnostic if models spread each other", async () => { + it("emit diagnostic if models spread each other", async () => { const diagnostics = await Tester.diagnose(` model Foo { ...Bar } model Bar { ...Foo }