From 382c44218e062498bc5679e81574242f0c3136ab Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 15 May 2026 08:03:35 -0700 Subject: [PATCH 01/10] Check queue arch --- packages/compiler/src/core/check-queue.ts | 372 ++++++++++++++ packages/compiler/src/core/checker.ts | 109 +++- .../compiler/test/core/check-queue.test.ts | 474 ++++++++++++++++++ 3 files changed, 953 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/src/core/check-queue.ts create mode 100644 packages/compiler/test/core/check-queue.test.ts diff --git a/packages/compiler/src/core/check-queue.ts b/packages/compiler/src/core/check-queue.ts new file mode 100644 index 00000000000..fb191ce0a2d --- /dev/null +++ b/packages/compiler/src/core/check-queue.ts @@ -0,0 +1,372 @@ +import { compilerAssert } from "./diagnostics.js"; +import type { Node, Sym, Type } from "./types.js"; + +/** + * Represents the status of a check item in the queue. + */ +export enum CheckItemStatus { + /** Not yet attempted */ + Pending = "pending", + /** Currently being checked (for intra-item cycle detection) */ + InProgress = "in-progress", + /** Attempted but blocked on unresolved dependencies */ + Deferred = "deferred", + /** Successfully checked */ + Done = "done", + /** Failed (circular or other error) */ + Error = "error", +} + +/** + * The result of attempting to check a declaration. + * Returned by check functions to signal their outcome to the queue. + */ +export type CheckResult = + | { readonly status: "done"; readonly type: T } + | { readonly status: "deferred"; readonly stalledOn: readonly Sym[] } + | { readonly status: "error" }; + +export namespace CheckResult { + /** Create a successful result */ + export function done(type: T): CheckResult { + return { status: "done", type }; + } + + /** Create a deferred result, blocked on the given symbols */ + export function deferred(stalledOn: readonly Sym[]): CheckResult { + return { status: "deferred", stalledOn }; + } + + /** Create an error result */ + export function error(): CheckResult { + return { status: "error" }; + } +} + +/** + * An item in the check queue representing a top-level declaration to check. + */ +export interface CheckItem { + /** The symbol for this declaration */ + readonly sym: Sym; + + /** The AST node for this declaration */ + readonly node: Node; + + /** Current status */ + status: CheckItemStatus; + + /** Symbols this item is waiting on (populated when status is Deferred) */ + readonly stalledOn: Set; + + /** Items that are waiting for this item to complete */ + readonly dependents: Set; + + /** Number of times this item has been attempted */ + attempts: number; + + /** Partially-constructed type from a previous attempt (shell created, not yet finished) */ + partialType?: Type; +} + +/** + * Result of processing the queue to fixpoint. + */ +export interface CheckQueueResult { + /** Items that completed successfully */ + readonly completed: readonly CheckItem[]; + + /** Items that errored during checking */ + readonly errored: readonly CheckItem[]; + + /** + * Items that could not be resolved (true circular dependencies). + * Grouped into strongly connected components for cycle reporting. + */ + readonly cycles: readonly (readonly CheckItem[])[]; +} + +/** + * A worklist-based queue for type checking declarations. + * + * Declarations are registered, then processed in rounds. If a declaration + * cannot be checked because a dependency hasn't been checked yet, it is + * deferred. Processing continues until no more progress can be made (fixpoint). + * Remaining items form circular dependency cycles. + */ +export class CheckQueue { + /** All registered items, keyed by symbol */ + readonly #items = new Map(); + + /** Items ready to be processed (FIFO — preserves source order) */ + readonly #ready: CheckItem[] = []; + #readyIndex = 0; + + /** + * Register a declaration to be checked. + * @returns The created CheckItem + */ + register(sym: Sym, node: Node): CheckItem { + const existing = this.#items.get(sym); + if (existing) { + return existing; + } + + const item: CheckItem = { + sym, + node, + status: CheckItemStatus.Pending, + stalledOn: new Set(), + dependents: new Set(), + attempts: 0, + }; + + this.#items.set(sym, item); + this.#ready.push(item); + return item; + } + + /** + * Look up the check item for a symbol, if registered. + */ + get(sym: Sym): CheckItem | undefined { + return this.#items.get(sym); + } + + /** + * Check whether a symbol has been registered and is done. + */ + isDone(sym: Sym): boolean { + const item = this.#items.get(sym); + return item !== undefined && item.status === CheckItemStatus.Done; + } + + /** + * Check whether a symbol is registered but not yet done. + */ + isPending(sym: Sym): boolean { + const item = this.#items.get(sym); + if (item === undefined) return false; + return ( + item.status === CheckItemStatus.Pending || item.status === CheckItemStatus.Deferred + ); + } + + /** + * Get the next ready item, or undefined if none are ready. + */ + dequeue(): CheckItem | undefined { + while (this.#readyIndex < this.#ready.length) { + const item = this.#ready[this.#readyIndex++]; + // Skip items that were already processed (e.g. by demand-driven resolution) + if (item.status === CheckItemStatus.Pending || item.status === CheckItemStatus.Deferred) { + return item; + } + } + return undefined; + } + + /** + * Mark an item as successfully checked. + * Notifies all dependents and moves newly-unblocked dependents to the ready queue. + */ + markDone(item: CheckItem): void { + compilerAssert( + item.status === CheckItemStatus.InProgress, + `Cannot mark item as done: status is '${item.status}', expected 'in-progress'`, + ); + + item.status = CheckItemStatus.Done; + item.stalledOn.clear(); + + // Notify all dependents + for (const dependent of item.dependents) { + dependent.stalledOn.delete(item.sym); + if (dependent.stalledOn.size === 0 && dependent.status === CheckItemStatus.Deferred) { + // All dependencies resolved — this dependent is ready to retry + dependent.status = CheckItemStatus.Pending; + this.#ready.push(dependent); + } + } + item.dependents.clear(); + } + + /** + * Mark an item as deferred because it's blocked on the given symbols. + * Registers dependency edges so the item is notified when blockers complete. + */ + markDeferred(item: CheckItem, stalledOn: readonly Sym[]): void { + compilerAssert( + item.status === CheckItemStatus.InProgress, + `Cannot defer item: status is '${item.status}', expected 'in-progress'`, + ); + compilerAssert(stalledOn.length > 0, "Cannot defer with empty stalledOn list"); + + item.status = CheckItemStatus.Deferred; + item.stalledOn.clear(); + + for (const depSym of stalledOn) { + const depItem = this.#items.get(depSym); + if (depItem && depItem.status !== CheckItemStatus.Done) { + item.stalledOn.add(depSym); + depItem.dependents.add(item); + } + // If the dependency is already done or not in the queue, don't stall on it + } + + // If all dependencies were actually already resolved, re-queue immediately + if (item.stalledOn.size === 0) { + item.status = CheckItemStatus.Pending; + this.#ready.push(item); + } + } + + /** + * Mark an item as errored. + */ + markError(item: CheckItem): void { + compilerAssert( + item.status === CheckItemStatus.InProgress, + `Cannot mark item as error: status is '${item.status}', expected 'in-progress'`, + ); + item.status = CheckItemStatus.Error; + item.stalledOn.clear(); + + // Notify dependents so they can retry (they'll discover the error type) + for (const dependent of item.dependents) { + dependent.stalledOn.delete(item.sym); + if (dependent.stalledOn.size === 0 && dependent.status === CheckItemStatus.Deferred) { + dependent.status = CheckItemStatus.Pending; + this.#ready.push(dependent); + } + } + item.dependents.clear(); + } + + /** + * Mark an item as in-progress (being actively checked). + */ + markInProgress(item: CheckItem): void { + compilerAssert( + item.status === CheckItemStatus.Pending || item.status === CheckItemStatus.Deferred, + `Cannot mark item as in-progress: status is '${item.status}', expected 'pending' or 'deferred'`, + ); + item.status = CheckItemStatus.InProgress; + item.attempts++; + } + + /** + * Process the queue until fixpoint using a checker callback. + * + * The callback receives a CheckItem and should attempt to check it. + * It must call markDone, markDeferred, or markError on the item before returning. + * + * @returns Result containing completed items, errored items, and cycle groups + */ + processUntilFixpoint(check: (item: CheckItem) => void): CheckQueueResult { + const completed: CheckItem[] = []; + const errored: CheckItem[] = []; + + // Process ready items until none remain + let item: CheckItem | undefined; + while ((item = this.dequeue()) !== undefined) { + this.markInProgress(item); + check(item); + + if (item.status === CheckItemStatus.Done) { + completed.push(item); + } else if (item.status === CheckItemStatus.Error) { + errored.push(item); + } + // Deferred items will be re-queued when their dependencies complete + } + + // Anything still deferred at this point is part of a circular dependency + const cycles = this.#detectCycles(); + + return { completed, errored, cycles }; + } + + /** + * Detect strongly connected components among remaining deferred items. + * Uses Tarjan's algorithm on the stalledOn dependency graph. + */ + #detectCycles(): (readonly CheckItem[])[] { + const deferred: CheckItem[] = []; + for (const item of this.#items.values()) { + if (item.status === CheckItemStatus.Deferred) { + deferred.push(item); + } + } + + if (deferred.length === 0) { + return []; + } + + // Tarjan's SCC algorithm + let index = 0; + const stack: CheckItem[] = []; + const onStack = new Set(); + const indices = new Map(); + const lowlinks = new Map(); + const sccs: CheckItem[][] = []; + + const deferredSet = new Set(deferred); + + const strongConnect = (item: CheckItem) => { + indices.set(item, index); + lowlinks.set(item, index); + index++; + stack.push(item); + onStack.add(item); + + // Visit successors (items this one depends on that are also deferred) + for (const depSym of item.stalledOn) { + const depItem = this.#items.get(depSym); + if (!depItem || !deferredSet.has(depItem)) continue; + + if (!indices.has(depItem)) { + strongConnect(depItem); + lowlinks.set(item, Math.min(lowlinks.get(item)!, lowlinks.get(depItem)!)); + } else if (onStack.has(depItem)) { + lowlinks.set(item, Math.min(lowlinks.get(item)!, indices.get(depItem)!)); + } + } + + // If item is a root node, pop the SCC + if (lowlinks.get(item) === indices.get(item)) { + const scc: CheckItem[] = []; + let w: CheckItem; + do { + w = stack.pop()!; + onStack.delete(w); + scc.push(w); + } while (w !== item); + + sccs.push(scc); + } + }; + + for (const item of deferred) { + if (!indices.has(item)) { + strongConnect(item); + } + } + + return sccs; + } + + /** + * Get all items in the queue (for debugging/diagnostics). + */ + get size(): number { + return this.#items.size; + } + + /** + * Get all items (for debugging). + */ + [Symbol.iterator](): IterableIterator { + return this.#items.values(); + } +} diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index 20422c0a7eb..d493dfea77c 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -4,6 +4,7 @@ import { $ } from "../typekit/index.js"; import { DuplicateTracker } from "../utils/duplicate-tracker.js"; import { MultiKeyMap, Mutable, createRekeyableMap, isArray, mutate } from "../utils/misc.js"; import { createSymbol, getSymNode } from "./binder.js"; +import { CheckItemStatus, CheckQueue } from "./check-queue.js"; import { createChangeIdentifierCodeFix } from "./compiler-code-fixes/change-identifier.codefix.js"; import { createModelToObjectValueCodeFix, @@ -135,6 +136,7 @@ import { SignatureFunctionParameter, StdTypeName, StdTypes, + Statement, StringLiteral, StringLiteralNode, StringTemplate, @@ -531,6 +533,13 @@ export function createChecker(program: Program, resolver: NameResolver): Checker const pendingResolutions = new PendingResolutions(); const postCheckValidators: ValidatorFn[] = []; + /** + * Queue for worklist-based type checking of top-level declarations. + * Declarations that can't be checked due to unresolved dependencies are deferred + * and retried until a fixpoint is reached. + */ + const checkQueue = new CheckQueue(); + const typespecNamespaceBinding = resolver.symbols.global.exports!.get("TypeSpec"); if (typespecNamespaceBinding) { initializeTypeSpecIntrinsics(); @@ -4788,8 +4797,23 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } } - for (const file of program.sourceFiles.values()) { - checkSourceFile(file); + // Phase 1: Seed the check queue with all declaration statements + // and collect non-declaration statements for later processing. + const deferredStatements: Statement[] = []; + seedCheckQueue(deferredStatements); + + // Phase 2: Process declarations via the worklist/fixpoint queue. + checkQueue.processUntilFixpoint((item) => { + checkNode(CheckContext.DEFAULT, item.node, undefined); + if (item.status === CheckItemStatus.InProgress) { + checkQueue.markDone(item); + } + }); + + // Phase 3: Process non-declaration statements (augment decorators, + // call expressions, etc.) that may reference the checked declarations. + for (const statement of deferredStatements) { + checkNode(CheckContext.DEFAULT, statement, undefined); } internalDecoratorValidation(); @@ -4797,6 +4821,87 @@ export function createChecker(program: Program, resolver: NameResolver): Checker runPostValidators(postCheckValidators); } + /** + * Seed the check queue with declaration statements from all source files. + * Non-declaration statements are collected into deferredStatements for + * processing after the queue. + */ + function seedCheckQueue(deferredStatements: Statement[]) { + for (const file of program.sourceFiles.values()) { + seedStatementsIntoQueue(file.statements, deferredStatements); + } + } + + /** + * Process a list of statements, queuing declarations and collecting + * non-declaration statements. Recurses into namespaces. + */ + function seedStatementsIntoQueue( + statements: readonly Statement[], + deferredStatements: Statement[], + ) { + for (const statement of statements) { + if (isQueueableStatement(statement)) { + checkQueue.register(statement.symbol, statement); + } else if (statement.kind === SyntaxKind.NamespaceStatement) { + seedNamespaceIntoQueue(statement, deferredStatements); + } else { + // Statements like augment decorators, call expressions, using statements, + // decorator/function declarations — defer until after declarations are checked + deferredStatements.push(statement); + } + } + } + + /** + * Process a namespace statement: check its modifiers and recurse into + * contained statements to queue declarations. + */ + function seedNamespaceIntoQueue( + node: NamespaceStatementNode, + deferredStatements: Statement[], + ) { + // Validate namespace modifiers (e.g., 'internal' is not allowed on namespaces) + checkModifiers(program, node); + if (isArray(node.statements)) { + seedStatementsIntoQueue(node.statements, deferredStatements); + } else if (node.statements) { + // Nested namespace (e.g., `namespace A.B { ... }`) + seedNamespaceIntoQueue(node.statements, deferredStatements); + } + } + + /** + * Determine if a statement should be added to the check queue. + * Declaration statements that produce types are queued; everything else + * is checked inline. + */ + function isQueueableStatement( + node: Statement, + ): node is + | ModelStatementNode + | ScalarStatementNode + | AliasStatementNode + | EnumStatementNode + | InterfaceStatementNode + | UnionStatementNode + | OperationStatementNode + | ConstStatementNode { + switch (node.kind) { + case SyntaxKind.ModelStatement: + case SyntaxKind.ScalarStatement: + case SyntaxKind.AliasStatement: + case SyntaxKind.EnumStatement: + case SyntaxKind.InterfaceStatement: + case SyntaxKind.UnionStatement: + case SyntaxKind.OperationStatement: + case SyntaxKind.ConstStatement: + return true; + default: + return false; + } + } + function assertNoPendingResolutions() { if (waitingForResolution.size === 0) { return; diff --git a/packages/compiler/test/core/check-queue.test.ts b/packages/compiler/test/core/check-queue.test.ts new file mode 100644 index 00000000000..2d44df29c00 --- /dev/null +++ b/packages/compiler/test/core/check-queue.test.ts @@ -0,0 +1,474 @@ +import { describe, expect, it } from "vitest"; +import { + CheckItemStatus, + CheckQueue, + CheckResult, + type CheckItem, +} from "../../src/core/check-queue.js"; +import type { Node, Sym } from "../../src/core/types.js"; + +/** Create a minimal mock Sym for testing */ +function createMockSym(name: string): Sym { + return { + flags: 0, + declarations: [], + node: {} as Node, + name, + id: Math.random(), + } as unknown as Sym; +} + +/** Create a minimal mock Node for testing */ +function createMockNode(): Node { + return {} as Node; +} + +describe("CheckQueue", () => { + describe("register", () => { + it("creates a pending item", () => { + const queue = new CheckQueue(); + const sym = createMockSym("A"); + const node = createMockNode(); + + const item = queue.register(sym, node); + + expect(item.status).toBe(CheckItemStatus.Pending); + expect(item.sym).toBe(sym); + expect(item.node).toBe(node); + expect(item.attempts).toBe(0); + expect(item.stalledOn.size).toBe(0); + expect(item.dependents.size).toBe(0); + }); + + it("returns existing item on duplicate registration", () => { + const queue = new CheckQueue(); + const sym = createMockSym("A"); + const node = createMockNode(); + + const item1 = queue.register(sym, node); + const item2 = queue.register(sym, createMockNode()); + + expect(item1).toBe(item2); + }); + }); + + describe("dequeue", () => { + it("returns registered items", () => { + const queue = new CheckQueue(); + const sym = createMockSym("A"); + queue.register(sym, createMockNode()); + + const item = queue.dequeue(); + + expect(item).toBeDefined(); + expect(item!.sym).toBe(sym); + }); + + it("returns undefined when empty", () => { + const queue = new CheckQueue(); + expect(queue.dequeue()).toBeUndefined(); + }); + + it("skips items that are already done", () => { + const queue = new CheckQueue(); + const sym = createMockSym("A"); + const item = queue.register(sym, createMockNode()); + queue.markInProgress(item); + queue.markDone(item); + + expect(queue.dequeue()).toBeUndefined(); + }); + }); + + describe("markDone", () => { + it("sets status to Done", () => { + const queue = new CheckQueue(); + const sym = createMockSym("A"); + const item = queue.register(sym, createMockNode()); + queue.markInProgress(item); + queue.markDone(item); + + expect(item.status).toBe(CheckItemStatus.Done); + }); + + it("notifies dependents and unblocks them", () => { + const queue = new CheckQueue(); + const symA = createMockSym("A"); + const symB = createMockSym("B"); + const itemA = queue.register(symA, createMockNode()); + const itemB = queue.register(symB, createMockNode()); + + // B depends on A + queue.markInProgress(itemB); + queue.markDeferred(itemB, [symA]); + expect(itemB.status).toBe(CheckItemStatus.Deferred); + + // Complete A + queue.markInProgress(itemA); + queue.markDone(itemA); + + // B should now be re-queued as pending + expect(itemB.status).toBe(CheckItemStatus.Pending); + const next = queue.dequeue(); + expect(next).toBe(itemB); + }); + + it("doesn't unblock dependents that have other pending deps", () => { + const queue = new CheckQueue(); + const symA = createMockSym("A"); + const symB = createMockSym("B"); + const symC = createMockSym("C"); + + queue.register(symA, createMockNode()); + queue.register(symB, createMockNode()); + queue.register(symC, createMockNode()); + + const checked: string[] = []; + const result = queue.processUntilFixpoint((item) => { + if (item.sym.name === "C" && !queue.isDone(symA)) { + // C depends on both A and B + queue.markDeferred(item, [symA, symB]); + return; + } + if (item.sym.name === "C" && !queue.isDone(symB)) { + // After A is done, C still needs B + queue.markDeferred(item, [symB]); + return; + } + checked.push(item.sym.name); + queue.markDone(item); + }); + + expect(result.completed).toHaveLength(3); + // C should be checked last (after both A and B) + expect(checked.indexOf("C")).toBe(2); + }); + }); + + describe("markDeferred", () => { + it("sets status to Deferred with stalledOn", () => { + const queue = new CheckQueue(); + const symA = createMockSym("A"); + const symB = createMockSym("B"); + queue.register(symA, createMockNode()); + const itemB = queue.register(symB, createMockNode()); + + queue.dequeue(); // A + queue.dequeue(); // B + queue.markInProgress(itemB); + queue.markDeferred(itemB, [symA]); + + expect(itemB.status).toBe(CheckItemStatus.Deferred); + expect(itemB.stalledOn.has(symA)).toBe(true); + }); + + it("re-queues immediately if all deps are already done", () => { + const queue = new CheckQueue(); + const symA = createMockSym("A"); + const symB = createMockSym("B"); + const itemA = queue.register(symA, createMockNode()); + const itemB = queue.register(symB, createMockNode()); + + // Complete A first + queue.dequeue(); // A + queue.markInProgress(itemA); + queue.markDone(itemA); + + // B tries to defer on A, but A is already done + queue.dequeue(); // B + queue.markInProgress(itemB); + queue.markDeferred(itemB, [symA]); + + // B should be re-queued immediately + expect(itemB.status).toBe(CheckItemStatus.Pending); + expect(queue.dequeue()).toBe(itemB); + }); + }); + + describe("markError", () => { + it("sets status to Error", () => { + const queue = new CheckQueue(); + const sym = createMockSym("A"); + const item = queue.register(sym, createMockNode()); + queue.markInProgress(item); + queue.markError(item); + + expect(item.status).toBe(CheckItemStatus.Error); + }); + + it("unblocks dependents on error", () => { + const queue = new CheckQueue(); + const symA = createMockSym("A"); + const symB = createMockSym("B"); + const itemA = queue.register(symA, createMockNode()); + const itemB = queue.register(symB, createMockNode()); + + // B depends on A + queue.dequeue(); // A + queue.dequeue(); // B + queue.markInProgress(itemB); + queue.markDeferred(itemB, [symA]); + + // A errors + queue.markInProgress(itemA); + queue.markError(itemA); + + // B should be unblocked + expect(itemB.status).toBe(CheckItemStatus.Pending); + }); + }); + + describe("isDone / isPending", () => { + it("isDone returns true for completed items", () => { + const queue = new CheckQueue(); + const sym = createMockSym("A"); + const item = queue.register(sym, createMockNode()); + queue.markInProgress(item); + queue.markDone(item); + + expect(queue.isDone(sym)).toBe(true); + expect(queue.isPending(sym)).toBe(false); + }); + + it("isPending returns true for pending/deferred items", () => { + const queue = new CheckQueue(); + const sym = createMockSym("A"); + queue.register(sym, createMockNode()); + + expect(queue.isPending(sym)).toBe(true); + expect(queue.isDone(sym)).toBe(false); + }); + + it("returns false for unregistered symbols", () => { + const queue = new CheckQueue(); + const sym = createMockSym("A"); + + expect(queue.isDone(sym)).toBe(false); + expect(queue.isPending(sym)).toBe(false); + }); + }); + + describe("processUntilFixpoint", () => { + it("processes all items when there are no dependencies", () => { + const queue = new CheckQueue(); + const syms = ["A", "B", "C"].map((n) => createMockSym(n)); + for (const sym of syms) { + queue.register(sym, createMockNode()); + } + + const checked: string[] = []; + const result = queue.processUntilFixpoint((item) => { + checked.push(item.sym.name); + queue.markDone(item); + }); + + expect(checked).toHaveLength(3); + expect(result.completed).toHaveLength(3); + expect(result.errored).toHaveLength(0); + expect(result.cycles).toHaveLength(0); + }); + + it("handles linear dependency chain", () => { + const queue = new CheckQueue(); + const symA = createMockSym("A"); + const symB = createMockSym("B"); + const symC = createMockSym("C"); + queue.register(symA, createMockNode()); + queue.register(symB, createMockNode()); + queue.register(symC, createMockNode()); + + const checked: string[] = []; + const result = queue.processUntilFixpoint((item) => { + // C depends on B, B depends on A + if (item.sym.name === "C" && !queue.isDone(symB)) { + queue.markDeferred(item, [symB]); + return; + } + if (item.sym.name === "B" && !queue.isDone(symA)) { + queue.markDeferred(item, [symA]); + return; + } + checked.push(item.sym.name); + queue.markDone(item); + }); + + // Should resolve: A first, then B, then C + expect(checked).toEqual(expect.arrayContaining(["A", "B", "C"])); + expect(checked.indexOf("A")).toBeLessThan(checked.indexOf("B")); + expect(checked.indexOf("B")).toBeLessThan(checked.indexOf("C")); + expect(result.completed).toHaveLength(3); + expect(result.cycles).toHaveLength(0); + }); + + it("detects circular dependencies", () => { + const queue = new CheckQueue(); + const symA = createMockSym("A"); + const symB = createMockSym("B"); + queue.register(symA, createMockNode()); + queue.register(symB, createMockNode()); + + const result = queue.processUntilFixpoint((item) => { + // A depends on B, B depends on A — true cycle + if (item.sym.name === "A") { + queue.markDeferred(item, [symB]); + } else { + queue.markDeferred(item, [symA]); + } + }); + + expect(result.completed).toHaveLength(0); + expect(result.cycles.length).toBeGreaterThan(0); + // Both A and B should be in a single SCC + const allCycleItems = result.cycles.flat(); + expect(allCycleItems).toHaveLength(2); + }); + + it("detects multiple independent cycles", () => { + const queue = new CheckQueue(); + const symA = createMockSym("A"); + const symB = createMockSym("B"); + const symC = createMockSym("C"); + const symD = createMockSym("D"); + queue.register(symA, createMockNode()); + queue.register(symB, createMockNode()); + queue.register(symC, createMockNode()); + queue.register(symD, createMockNode()); + + const result = queue.processUntilFixpoint((item) => { + // Cycle 1: A <-> B + // Cycle 2: C <-> D + switch (item.sym.name) { + case "A": + queue.markDeferred(item, [symB]); + break; + case "B": + queue.markDeferred(item, [symA]); + break; + case "C": + queue.markDeferred(item, [symD]); + break; + case "D": + queue.markDeferred(item, [symC]); + break; + } + }); + + expect(result.completed).toHaveLength(0); + // Should detect 2 separate SCCs + const twoItemSCCs = result.cycles.filter((scc) => scc.length === 2); + expect(twoItemSCCs).toHaveLength(2); + }); + + it("handles mixed: some items resolve, some form cycles", () => { + const queue = new CheckQueue(); + const symA = createMockSym("A"); + const symB = createMockSym("B"); + const symC = createMockSym("C"); + queue.register(symA, createMockNode()); + queue.register(symB, createMockNode()); + queue.register(symC, createMockNode()); + + const result = queue.processUntilFixpoint((item) => { + if (item.sym.name === "A") { + // A resolves fine + queue.markDone(item); + } else if (item.sym.name === "B") { + // B depends on C + queue.markDeferred(item, [symC]); + } else { + // C depends on B — cycle + queue.markDeferred(item, [symB]); + } + }); + + expect(result.completed).toHaveLength(1); + expect(result.completed[0].sym.name).toBe("A"); + expect(result.cycles.length).toBeGreaterThan(0); + }); + + it("handles items that defer then resolve on retry", () => { + const queue = new CheckQueue(); + const symA = createMockSym("A"); + const symB = createMockSym("B"); + queue.register(symA, createMockNode()); + queue.register(symB, createMockNode()); + + const result = queue.processUntilFixpoint((item) => { + if (item.sym.name === "B" && !queue.isDone(symA)) { + // B defers on first attempt, but A will resolve + queue.markDeferred(item, [symA]); + return; + } + queue.markDone(item); + }); + + expect(result.completed).toHaveLength(2); + expect(result.cycles).toHaveLength(0); + }); + + it("tracks attempt count", () => { + const queue = new CheckQueue(); + const symA = createMockSym("A"); + const symB = createMockSym("B"); + queue.register(symA, createMockNode()); + const itemB = queue.register(symB, createMockNode()); + + queue.processUntilFixpoint((item) => { + if (item.sym.name === "B" && item.attempts === 1) { + queue.markDeferred(item, [symA]); + return; + } + queue.markDone(item); + }); + + expect(itemB.attempts).toBe(2); + }); + + it("handles errors during checking", () => { + const queue = new CheckQueue(); + const sym = createMockSym("A"); + queue.register(sym, createMockNode()); + + const result = queue.processUntilFixpoint((item) => { + queue.markError(item); + }); + + expect(result.errored).toHaveLength(1); + expect(result.completed).toHaveLength(0); + }); + }); + + describe("CheckResult helpers", () => { + it("creates done result", () => { + const type = { kind: "Model" } as any; + const result = CheckResult.done(type); + expect(result.status).toBe("done"); + expect((result as any).type).toBe(type); + }); + + it("creates deferred result", () => { + const sym = createMockSym("A"); + const result = CheckResult.deferred([sym]); + expect(result.status).toBe("deferred"); + expect((result as any).stalledOn).toEqual([sym]); + }); + + it("creates error result", () => { + const result = CheckResult.error(); + expect(result.status).toBe("error"); + }); + }); + + describe("size", () => { + it("reports correct size", () => { + const queue = new CheckQueue(); + expect(queue.size).toBe(0); + + queue.register(createMockSym("A"), createMockNode()); + expect(queue.size).toBe(1); + + queue.register(createMockSym("B"), createMockNode()); + expect(queue.size).toBe(2); + }); + }); +}); From 0c9f9230920e2597663c28e2b1177009530b8aa6 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 15 May 2026 08:46:09 -0700 Subject: [PATCH 02/10] feat(compiler): add deferral mechanism infrastructure to check queue Add DeferralSignal class for stack-unwinding deferral, global-retry fixpoint loop in processUntilFixpoint, PendingResolutions snapshot/restore, and maybeDeferOnQueuedDeclaration helper. Infrastructure is ready but not yet wired to cycle detection points (needs incremental activation). - DeferralSignal thrown when check encounters unresolved dependency - activeItem tracking on CheckQueue for queue-aware checks - Global retry: re-queues all deferred items when progress is made - PendingResolutions snapshot/restore for clean rollback on deferral - markError accepts Deferred items for force-checking cycles - 33 queue unit tests, 1390 checker tests pass Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/compiler/src/core/check-queue.ts | 99 ++++++++++--- packages/compiler/src/core/checker.ts | 97 ++++++++++++- .../compiler/test/core/check-queue.test.ts | 135 +++++++++++++++++- 3 files changed, 302 insertions(+), 29 deletions(-) diff --git a/packages/compiler/src/core/check-queue.ts b/packages/compiler/src/core/check-queue.ts index fb191ce0a2d..b1a72cedf3e 100644 --- a/packages/compiler/src/core/check-queue.ts +++ b/packages/compiler/src/core/check-queue.ts @@ -1,6 +1,18 @@ import { compilerAssert } from "./diagnostics.js"; import type { Node, Sym, Type } from "./types.js"; +/** + * Signal thrown when a check function encounters a dependency that isn't + * resolved yet and should be deferred via the queue. This is caught by the + * queue's processUntilFixpoint loop — it must never escape to user code. + */ +export class DeferralSignal { + readonly stalledOn: Sym[]; + constructor(stalledOn: Sym[]) { + this.stalledOn = stalledOn; + } +} + /** * Represents the status of a check item in the queue. */ @@ -102,6 +114,18 @@ export class CheckQueue { readonly #ready: CheckItem[] = []; #readyIndex = 0; + /** The item currently being processed by the queue, if any. */ + #activeItem: CheckItem | undefined; + + /** + * Returns the queue item currently being processed, or undefined if + * no queue processing is in progress. Used by check functions to + * determine whether they can throw DeferralSignal. + */ + get activeItem(): CheckItem | undefined { + return this.#activeItem; + } + /** * Register a declaration to be checked. * @returns The created CheckItem @@ -200,7 +224,6 @@ export class CheckQueue { item.status === CheckItemStatus.InProgress, `Cannot defer item: status is '${item.status}', expected 'in-progress'`, ); - compilerAssert(stalledOn.length > 0, "Cannot defer with empty stalledOn list"); item.status = CheckItemStatus.Deferred; item.stalledOn.clear(); @@ -211,14 +234,11 @@ export class CheckQueue { item.stalledOn.add(depSym); depItem.dependents.add(item); } - // If the dependency is already done or not in the queue, don't stall on it } - // If all dependencies were actually already resolved, re-queue immediately - if (item.stalledOn.size === 0) { - item.status = CheckItemStatus.Pending; - this.#ready.push(item); - } + // If all specified dependencies were already resolved (or none specified), + // the item stays deferred — it will be re-queued by the outer fixpoint + // loop if progress was made in the current iteration. } /** @@ -226,8 +246,8 @@ export class CheckQueue { */ markError(item: CheckItem): void { compilerAssert( - item.status === CheckItemStatus.InProgress, - `Cannot mark item as error: status is '${item.status}', expected 'in-progress'`, + item.status === CheckItemStatus.InProgress || item.status === CheckItemStatus.Deferred, + `Cannot mark item as error: status is '${item.status}', expected 'in-progress' or 'deferred'`, ); item.status = CheckItemStatus.Error; item.stalledOn.clear(); @@ -259,7 +279,9 @@ export class CheckQueue { * Process the queue until fixpoint using a checker callback. * * The callback receives a CheckItem and should attempt to check it. - * It must call markDone, markDeferred, or markError on the item before returning. + * It must call markDone, markDeferred, or markError on the item before returning, + * OR the check function may throw a DeferralSignal which the queue catches and + * converts to a markDeferred call. * * @returns Result containing completed items, errored items, and cycle groups */ @@ -267,18 +289,55 @@ export class CheckQueue { const completed: CheckItem[] = []; const errored: CheckItem[] = []; - // Process ready items until none remain - let item: CheckItem | undefined; - while ((item = this.dequeue()) !== undefined) { - this.markInProgress(item); - check(item); + // Outer fixpoint loop: keep iterating as long as progress is made. + // "Progress" means at least one item completed in this iteration. + let madeProgress = true; + while (madeProgress) { + madeProgress = false; + + // Process all ready items in this iteration + let item: CheckItem | undefined; + while ((item = this.dequeue()) !== undefined) { + this.markInProgress(item); + this.#activeItem = item; + + try { + check(item); + } catch (e) { + if (e instanceof DeferralSignal) { + // The check function detected a dependency it can't resolve yet. + if (item.status === CheckItemStatus.InProgress) { + this.markDeferred(item, e.stalledOn); + } + } else { + this.#activeItem = undefined; + throw e; + } + } + + this.#activeItem = undefined; - if (item.status === CheckItemStatus.Done) { - completed.push(item); - } else if (item.status === CheckItemStatus.Error) { - errored.push(item); + if (item.status === CheckItemStatus.Done) { + completed.push(item); + madeProgress = true; + } else if (item.status === CheckItemStatus.Error) { + errored.push(item); + madeProgress = true; + } + } + + // If we made progress, re-queue all deferred items for another attempt. + // Their dependencies may have been resolved in this iteration. + if (madeProgress) { + for (const entry of this.#items.values()) { + if (entry.status === CheckItemStatus.Deferred) { + entry.status = CheckItemStatus.Pending; + entry.stalledOn.clear(); + this.#ready.push(entry); + } + } + this.#readyIndex = 0; } - // Deferred items will be re-queued when their dependencies complete } // Anything still deferred at this point is part of a circular dependency diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index d493dfea77c..be4c367b98d 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -4,7 +4,7 @@ import { $ } from "../typekit/index.js"; import { DuplicateTracker } from "../utils/duplicate-tracker.js"; import { MultiKeyMap, Mutable, createRekeyableMap, isArray, mutate } from "../utils/misc.js"; import { createSymbol, getSymNode } from "./binder.js"; -import { CheckItemStatus, CheckQueue } from "./check-queue.js"; +import { CheckItemStatus, CheckQueue, DeferralSignal } from "./check-queue.js"; import { createChangeIdentifierCodeFix } from "./compiler-code-fixes/change-identifier.codefix.js"; import { createModelToObjectValueCodeFix, @@ -540,6 +540,43 @@ export function createChecker(program: Program, resolver: NameResolver): Checker */ const checkQueue = new CheckQueue(); + /** + * When inside a queue-managed check, check whether a declaration should be + * deferred rather than checked inline via DFS. Returns true and throws + * DeferralSignal if the target declaration is in the queue but not yet done. + * + * @param sym The symbol of the declaration about to be checked via DFS + * @returns false if DFS should proceed as normal + * @throws DeferralSignal if the active queue item should defer on this symbol + */ + function maybeDeferOnQueuedDeclaration(sym: Sym): false { + const activeItem = checkQueue.activeItem; + if (activeItem === undefined) { + // Not inside queue processing — fall through to DFS + return false; + } + + if (sym === activeItem.sym) { + // We're checking the active item itself — don't defer on ourselves + return false; + } + + const depItem = checkQueue.get(sym); + if (depItem === undefined) { + // Not a queue-managed declaration + return false; + } + + if (depItem.status === CheckItemStatus.Done || depItem.status === CheckItemStatus.Error) { + // Already completed — DFS will find cached type + return false; + } + + // The target declaration hasn't been checked yet. Defer the active + // queue item so the queue can process the dependency first. + throw new DeferralSignal([sym]); + } + const typespecNamespaceBinding = resolver.symbols.global.exports!.get("TypeSpec"); if (typespecNamespaceBinding) { initializeTypeSpecIntrinsics(); @@ -4803,13 +4840,40 @@ export function createChecker(program: Program, resolver: NameResolver): Checker seedCheckQueue(deferredStatements); // Phase 2: Process declarations via the worklist/fixpoint queue. - checkQueue.processUntilFixpoint((item) => { - checkNode(CheckContext.DEFAULT, item.node, undefined); - if (item.status === CheckItemStatus.InProgress) { - checkQueue.markDone(item); + // DeferralSignal is caught by the queue's processUntilFixpoint when a + // declaration's DFS encounters an unchecked queued dependency. + // We snapshot pendingResolutions before each item so we can restore on deferral. + const queueResult = checkQueue.processUntilFixpoint((item) => { + pendingResolutions.snapshot(); + try { + checkNode(CheckContext.DEFAULT, item.node, undefined); + if (item.status === CheckItemStatus.InProgress) { + checkQueue.markDone(item); + } + pendingResolutions.discardSnapshot(); + } catch (e) { + if (e instanceof DeferralSignal) { + // Restore pendingResolutions to pre-check state since the + // DFS was interrupted and start/finish pairs didn't balance. + pendingResolutions.restore(); + throw e; // Re-throw so processUntilFixpoint can handle the deferral + } + pendingResolutions.discardSnapshot(); + throw e; } }); + // Items remaining after fixpoint are true circular dependencies. + // Force-check them to produce the existing circular error diagnostics. + for (const cycle of queueResult.cycles) { + for (const item of cycle) { + if (item.status !== CheckItemStatus.Done && item.status !== CheckItemStatus.Error) { + checkNode(CheckContext.DEFAULT, item.node, undefined); + checkQueue.markError(item); + } + } + } + // Phase 3: Process non-declaration statements (augment decorators, // call expressions, etc.) that may reference the checked declarations. for (const statement of deferredStatements) { @@ -8763,6 +8827,7 @@ enum ResolutionKind { class PendingResolutions { #data = new Map>(); + #snapshots: Map>[] = []; start(symId: Sym, kind: ResolutionKind) { let existing = this.#data.get(symId); @@ -8787,6 +8852,28 @@ class PendingResolutions { this.#data.delete(symId); } } + + /** Save a snapshot of the current state. Used before queue item processing. */ + snapshot(): void { + const copy = new Map>(); + for (const [sym, kinds] of this.#data) { + copy.set(sym, new Set(kinds)); + } + this.#snapshots.push(copy); + } + + /** Restore the last snapshot (discard changes since snapshot). Used on deferral. */ + restore(): void { + const snap = this.#snapshots.pop(); + if (snap) { + this.#data = snap; + } + } + + /** Discard the last snapshot without restoring. Used on successful completion. */ + discardSnapshot(): void { + this.#snapshots.pop(); + } } interface SymbolResolutionOptions { diff --git a/packages/compiler/test/core/check-queue.test.ts b/packages/compiler/test/core/check-queue.test.ts index 2d44df29c00..11beae5c264 100644 --- a/packages/compiler/test/core/check-queue.test.ts +++ b/packages/compiler/test/core/check-queue.test.ts @@ -3,6 +3,7 @@ import { CheckItemStatus, CheckQueue, CheckResult, + DeferralSignal, type CheckItem, } from "../../src/core/check-queue.js"; import type { Node, Sym } from "../../src/core/types.js"; @@ -174,14 +175,13 @@ describe("CheckQueue", () => { queue.markInProgress(itemA); queue.markDone(itemA); - // B tries to defer on A, but A is already done + // B tries to defer on A, but A is already done. + // B stays deferred; the fixpoint loop will re-queue it. queue.dequeue(); // B queue.markInProgress(itemB); queue.markDeferred(itemB, [symA]); - // B should be re-queued immediately - expect(itemB.status).toBe(CheckItemStatus.Pending); - expect(queue.dequeue()).toBe(itemB); + expect(itemB.status).toBe(CheckItemStatus.Deferred); }); }); @@ -471,4 +471,131 @@ describe("CheckQueue", () => { expect(queue.size).toBe(2); }); }); + + describe("DeferralSignal", () => { + it("catches DeferralSignal and marks item as deferred", () => { + const queue = new CheckQueue(); + const symA = createMockSym("A"); + const symB = createMockSym("B"); + queue.register(symA, createMockNode()); + queue.register(symB, createMockNode()); + + let aAttempts = 0; + const result = queue.processUntilFixpoint((item) => { + if (item.sym === symA) { + aAttempts++; + if (aAttempts === 1) { + // First attempt: A can't resolve because B isn't done + throw new DeferralSignal([symB]); + } + // Second attempt: B is done now, A can complete + queue.markDone(item); + } else { + queue.markDone(item); + } + }); + + expect(aAttempts).toBe(2); + expect(result.completed.length).toBe(2); + expect(result.cycles.length).toBe(0); + }); + + it("detects fixpoint when all items defer on each other", () => { + const queue = new CheckQueue(); + const symA = createMockSym("A"); + const symB = createMockSym("B"); + queue.register(symA, createMockNode()); + queue.register(symB, createMockNode()); + + const result = queue.processUntilFixpoint((item) => { + // Both items always defer — true cycle + if (item.sym === symA) { + throw new DeferralSignal([symB]); + } else { + throw new DeferralSignal([symA]); + } + }); + + expect(result.completed.length).toBe(0); + expect(result.cycles.length).toBeGreaterThan(0); + }); + + it("retries deferred items when progress is made", () => { + const queue = new CheckQueue(); + const symA = createMockSym("A"); + const symB = createMockSym("B"); + const symC = createMockSym("C"); + queue.register(symA, createMockNode()); + queue.register(symB, createMockNode()); + queue.register(symC, createMockNode()); + + // A defers on first try, B defers on first try, C succeeds. + // After C succeeds, A and B should be retried. + let aAttempts = 0; + let bAttempts = 0; + const result = queue.processUntilFixpoint((item) => { + if (item.sym === symA) { + aAttempts++; + if (aAttempts === 1) throw new DeferralSignal([]); + queue.markDone(item); + } else if (item.sym === symB) { + bAttempts++; + if (bAttempts === 1) throw new DeferralSignal([]); + queue.markDone(item); + } else { + queue.markDone(item); + } + }); + + expect(aAttempts).toBe(2); + expect(bAttempts).toBe(2); + expect(result.completed.length).toBe(3); + expect(result.cycles.length).toBe(0); + }); + + it("sets activeItem during check callback", () => { + const queue = new CheckQueue(); + const symA = createMockSym("A"); + queue.register(symA, createMockNode()); + + let capturedActiveItem: CheckItem | undefined; + queue.processUntilFixpoint((item) => { + capturedActiveItem = queue.activeItem; + queue.markDone(item); + }); + + expect(capturedActiveItem).toBeDefined(); + expect(capturedActiveItem!.sym).toBe(symA); + // After processing, activeItem should be cleared + expect(queue.activeItem).toBeUndefined(); + }); + + it("clears activeItem after DeferralSignal", () => { + const queue = new CheckQueue(); + const symA = createMockSym("A"); + queue.register(symA, createMockNode()); + + let attempt = 0; + queue.processUntilFixpoint((item) => { + attempt++; + if (attempt === 1) { + throw new DeferralSignal([]); + } + // Won't reach here — item defers and no progress means fixpoint + }); + + expect(queue.activeItem).toBeUndefined(); + }); + + it("re-throws non-DeferralSignal errors", () => { + const queue = new CheckQueue(); + queue.register(createMockSym("A"), createMockNode()); + + expect(() => { + queue.processUntilFixpoint(() => { + throw new Error("unexpected"); + }); + }).toThrow("unexpected"); + }); + }); }); From a99e9469cd555d312393f7fa9d69290f0b06c152 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 15 May 2026 08:50:39 -0700 Subject: [PATCH 03/10] feat(compiler): add SCC-based cycle diagnostic reporting When the check queue reaches fixpoint with remaining deferred items, use Tarjan's SCC analysis to identify cycle groups and report a 'circular-dependency-cycle' diagnostic listing all participants before force-checking them for per-site error diagnostics. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/compiler/src/core/checker.ts | 18 +++++++++++++++++- packages/compiler/src/core/messages.ts | 6 ++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index be4c367b98d..eed89b39e43 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -4864,8 +4864,24 @@ export function createChecker(program: Program, resolver: NameResolver): Checker }); // Items remaining after fixpoint are true circular dependencies. - // Force-check them to produce the existing circular error diagnostics. + // Report cycle diagnostics using SCC analysis, then force-check + // to produce per-site error diagnostics and error types. for (const cycle of queueResult.cycles) { + if (cycle.length > 1) { + // Multi-item SCC: report a cycle diagnostic for each participant + const names = cycle.map((item) => `'${item.sym.name}'`); + const cycleStr = names.join(", "); + for (const item of cycle) { + reportCheckerDiagnostic( + createDiagnostic({ + code: "circular-dependency-cycle", + format: { typeName: item.sym.name, cycle: cycleStr }, + target: item.node, + }), + ); + } + } + // Force-check to produce per-site circular errors and set error types for (const item of cycle) { if (item.status !== CheckItemStatus.Done && item.status !== CheckItemStatus.Error) { checkNode(CheckContext.DEFAULT, item.node, undefined); diff --git a/packages/compiler/src/core/messages.ts b/packages/compiler/src/core/messages.ts index eadfce2c6b4..a7235b0fe04 100644 --- a/packages/compiler/src/core/messages.ts +++ b/packages/compiler/src/core/messages.ts @@ -1050,6 +1050,12 @@ const diagnostics = { default: paramMessage`Property '${"propName"}' recursively references itself.`, }, }, + "circular-dependency-cycle": { + severity: "error", + messages: { + default: paramMessage`'${"typeName"}' is part of a circular dependency cycle: ${"cycle"}.`, + }, + }, "conflict-marker": { severity: "error", messages: { From faf2f6f5f625a4d3cd5013b624bd73aada4ef42e Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 15 May 2026 09:47:35 -0700 Subject: [PATCH 04/10] feat(compiler): add two-pass shell architecture infrastructure Add infrastructure for the two-pass (shell creation + population) type checking approach. This is preparatory work for enabling queue-based deferral: - Add allItems() iterator to CheckQueue for traversing all registered items - Add createDeclarationShells() with per-type shell creation functions (Model, Scalar, Interface, Union, Operation, Enum) - Shells are minimal type objects created via createType() with creating=true that can be referenced before full population NOTE: Shell creation is currently DISABLED (commented out in checkProgram) because it breaks the existing pendingResolutions-based cycle detection. When shells exist, cross-references resolve immediately via symbolLinks.declaredType without triggering DFS into the target's check function, so pendingResolutions tracking never fires for the target. Activating this requires redesigning cycle detection to work across queue iterations rather than within a single DFS call stack. Options: 1. Track extends/is chains explicitly in the queue 2. Use the queue's SCC analysis as the primary cycle detector 3. Add a pre-check AST dependency analysis pass All 1390 checker tests + 33 queue tests continue to pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/compiler/src/core/check-queue.ts | 7 + packages/compiler/src/core/checker.ts | 158 +++++++++++++++++++++- 2 files changed, 161 insertions(+), 4 deletions(-) diff --git a/packages/compiler/src/core/check-queue.ts b/packages/compiler/src/core/check-queue.ts index b1a72cedf3e..e8f1916fb27 100644 --- a/packages/compiler/src/core/check-queue.ts +++ b/packages/compiler/src/core/check-queue.ts @@ -157,6 +157,13 @@ export class CheckQueue { return this.#items.get(sym); } + /** + * Iterate over all registered items. + */ + allItems(): IterableIterator { + return this.#items.values(); + } + /** * Check whether a symbol has been registered and is done. */ diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index eed89b39e43..14f57ece049 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -4839,6 +4839,15 @@ export function createChecker(program: Program, resolver: NameResolver): Checker const deferredStatements: Statement[] = []; seedCheckQueue(deferredStatements); + // Phase 1b: Create shells for all queued declarations. + // This ensures that cross-references between declarations can resolve + // to a shell type even before population. Enables DeferralSignal-based + // deferral without partial-state corruption. + // DISABLED: Shell creation breaks circular-extends detection because + // references resolve to shells immediately without triggering + // pendingResolutions tracking. Need a different approach to detect cycles. + // createDeclarationShells(); + // Phase 2: Process declarations via the worklist/fixpoint queue. // DeferralSignal is caught by the queue's processUntilFixpoint when a // declaration's DFS encounters an unchecked queued dependency. @@ -4912,6 +4921,150 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } } + /** + * Create shell types for all queued declarations upfront. + * Shells are minimal type objects with `creating=true` that can be + * referenced by other declarations during the populate pass. + * Alias and Const don't have shells (they resolve directly to a value/type). + */ + function createDeclarationShells() { + for (const item of checkQueue.allItems()) { + const node = item.node; + // Skip template declarations — they have complex interactions with + // instantiation that aren't compatible with shell-based deferral yet + if ("templateParameters" in node && (node as any).templateParameters?.length > 0) { + continue; + } + switch (node.kind) { + case SyntaxKind.ModelStatement: + createModelShell(node as ModelStatementNode); + break; + case SyntaxKind.ScalarStatement: + createScalarShell(node as ScalarStatementNode); + break; + case SyntaxKind.InterfaceStatement: + createInterfaceShell(node as InterfaceStatementNode); + break; + case SyntaxKind.UnionStatement: + createUnionShell(node as UnionStatementNode); + break; + case SyntaxKind.OperationStatement: + createOperationShell(node as OperationStatementNode); + break; + case SyntaxKind.EnumStatement: + createEnumShell(node as EnumStatementNode); + break; + // Alias and Const don't create shells + default: + break; + } + } + } + + function createModelShell(node: ModelStatementNode) { + const links = getSymbolLinks(node.symbol); + if (links.declaredType) return; // Already has a type (shouldn't happen) + + const type: Model = createType({ + kind: "Model", + name: node.id.sv, + node: node, + properties: createRekeyableMap(), + namespace: getParentNamespaceType(node), + decorators: [], + sourceModels: [], + derivedModels: [], + }); + linkType(CheckContext.DEFAULT, links, type); + } + + function createScalarShell(node: ScalarStatementNode) { + const links = getSymbolLinks(node.symbol); + if (links.declaredType) return; + + const type: Scalar = createType({ + kind: "Scalar", + name: node.id.sv, + node: node, + constructors: new Map(), + namespace: getParentNamespaceType(node), + decorators: [], + derivedScalars: [], + }); + linkType(CheckContext.DEFAULT, links, type); + } + + function createInterfaceShell(node: InterfaceStatementNode) { + const links = getSymbolLinks(node.symbol); + if (links.declaredType) return; + + const type: Interface = createType({ + kind: "Interface", + decorators: [], + node, + namespace: getParentNamespaceType(node), + sourceInterfaces: [], + operations: createRekeyableMap(), + name: node.id.sv, + }); + linkType(CheckContext.DEFAULT, links, type); + } + + function createUnionShell(node: UnionStatementNode) { + const links = getSymbolLinks(node.symbol); + if (links.declaredType) return; + + const variants = createRekeyableMap(); + const type: Union = createType({ + kind: "Union", + decorators: [], + node, + namespace: getParentNamespaceType(node), + name: node.id.sv, + variants, + get options() { + return Array.from(this.variants.values()).map((v: UnionVariant) => v.type); + }, + expression: false, + }); + linkType(CheckContext.DEFAULT, links, type); + } + + function createOperationShell(node: OperationStatementNode) { + // Only create shell for top-level operations, not interface members + if (node.parent?.kind === SyntaxKind.InterfaceStatement) return; + + const links = getSymbolLinks(node.symbol); + if (links?.declaredType) return; + + const type: Operation = createType({ + kind: "Operation", + name: node.id.sv, + node, + parameters: undefined!, + returnType: undefined!, + decorators: [], + namespace: getParentNamespaceType(node), + }); + if (links) { + linkType(CheckContext.DEFAULT, links, type); + } + } + + function createEnumShell(node: EnumStatementNode) { + const links = getSymbolLinks(node.symbol); + if (links.type) return; // Enum uses links.type, not links.declaredType + + const type: Enum = createType({ + kind: "Enum", + name: node.id.sv, + node, + members: createRekeyableMap(), + decorators: [], + }); + links.type = type; + } + /** * Process a list of statements, queuing declarations and collecting * non-declaration statements. Recurses into namespaces. @@ -7286,12 +7439,11 @@ export function createChecker(program: Program, resolver: NameResolver): Checker const links = getSymbolLinks(node.symbol); if (ctx.mapper === undefined && node.templateParameters.length > 0) { - // This is a templated declaration and we are not instantiating it, so we need to update the flags. ctx = ctx.withFlags(CheckFlags.InTemplateDeclaration); } if (links.declaredType && ctx.mapper === undefined) { - // we're not instantiating this model and we've already checked it + // we're not instantiating this scalar and we've already checked it return links.declaredType as any; } if (ctx.mapper === undefined) { @@ -7592,7 +7744,6 @@ export function createChecker(program: Program, resolver: NameResolver): Checker const links = getSymbolLinks(node.symbol); if (ctx.mapper === undefined && node.templateParameters.length > 0) { - // This is a templated declaration and we are not instantiating it, so we need to update the flags. ctx = ctx.withFlags(CheckFlags.InTemplateDeclaration); } @@ -7614,7 +7765,6 @@ export function createChecker(program: Program, resolver: NameResolver): Checker operations: createRekeyableMap(), name: node.id.sv, }); - linkType(ctx, links, interfaceType); interfaceType.decorators = checkDecorators(ctx, interfaceType, node); From b6d90cc4629393b1c235dff9d1c3cec68f1e7fd8 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 15 May 2026 17:43:56 -0700 Subject: [PATCH 05/10] check queue --- packages/compiler/src/core/checker.ts | 95 +++++++++++++++++-- .../test/checker/circular-resolution.test.ts | 27 ++++++ 2 files changed, 112 insertions(+), 10 deletions(-) create mode 100644 packages/compiler/test/checker/circular-resolution.test.ts diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index 14f57ece049..152466a92e6 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -698,12 +698,11 @@ export function createChecker(program: Program, resolver: NameResolver): Checker checkScalar(CheckContext.DEFAULT, sym.declarations[0] as any); } - const loadedType = stdTypes[name]; compilerAssert( - loadedType, + stdTypes[name] !== undefined, `TypeSpec std type "${name}" should have been initalized before using array syntax.`, ); - return loadedType as any; + return stdTypes[name] as any; } /** @@ -1746,7 +1745,6 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } else if (sym.flags & SymbolFlags.Member) { baseType = checkMemberSym(innerCtx, sym); } else { - // baseType = checkDeclaredTypeOrIndeterminate(innerCtx, sym, decl); } } else { @@ -1865,9 +1863,9 @@ export function createChecker(program: Program, resolver: NameResolver): Checker if (sym.flags & SymbolFlags.Member) { return checkMemberSym(ctx, sym) as TemplatedType; - } else { - return checkDeclaredType(ctx, sym, decl) as TemplatedType; } + + return checkDeclaredType(ctx, sym, decl) as TemplatedType; } /** @@ -4389,6 +4387,15 @@ export function createChecker(program: Program, resolver: NameResolver): Checker return symbol; } + // Fallback: when name resolution can't find the member (e.g., members from template + // instantiation via `is`/spreads), try resolving from the checked type's late-bound members. + if (base.flags & SymbolFlags.MemberContainer && node.selector === ".") { + const lateBoundMember = resolveLateBoundMember(ctx, base, node.id.sv); + if (lateBoundMember) { + return lateBoundMember; + } + } + if (base.flags & SymbolFlags.Namespace) { reportCheckerDiagnostic( createDiagnostic({ @@ -4445,6 +4452,46 @@ export function createChecker(program: Program, resolver: NameResolver): Checker return undefined; } + /** + * Fallback member resolution: when name resolution can't find a member (e.g., members + * from template instantiation via `is` or spreads), force-check the base type and look + * for the member in its late-bound symbols. + */ + function resolveLateBoundMember(ctx: CheckContext, base: Sym, memberName: string): Sym | undefined { + const links = getSymbolLinks(base); + const declaredType = links.declaredType; + if (!declaredType) { + return undefined; + } + + // Only proceed if the type is fully checked (not still creating) + if (declaredType.creating) { + return undefined; + } + + // Ensure late-bound members are available on the type + switch (declaredType.kind) { + case "Model": + case "Interface": + case "Union": + case "Enum": + case "Scalar": + lateBindMemberContainer(declaredType); + lateBindMembers(declaredType); + break; + default: + return undefined; + } + + const containerSym = declaredType.symbol; + if (!containerSym?.members) { + return undefined; + } + + const augmented = resolver.getAugmentedSymbolTable(containerSym.members); + return augmented.get(memberName); + } + function getMemberKindName(node: Node) { switch (node.kind) { case SyntaxKind.ModelStatement: @@ -4856,7 +4903,13 @@ export function createChecker(program: Program, resolver: NameResolver): Checker pendingResolutions.snapshot(); try { checkNode(CheckContext.DEFAULT, item.node, undefined); - if (item.status === CheckItemStatus.InProgress) { + // Only mark as done if the type is fully checked (not still creating + // due to deferred ensureResolved callbacks) + const itemLinks = getSymbolLinks(item.sym); + const declaredType = itemLinks.declaredType; + if (declaredType && !declaredType.creating) { + checkQueue.markDone(item); + } else if (item.status === CheckItemStatus.InProgress) { checkQueue.markDone(item); } pendingResolutions.discardSnapshot(); @@ -5065,6 +5118,29 @@ export function createChecker(program: Program, resolver: NameResolver): Checker links.type = type; } + /** + * Create a shell (empty type object) for a declaration on demand. + * Used to break circular DFS chains: when a queued item is referenced + * before its turn, we create its shell and return it. The queue will + * fully populate the shell later. + */ + function createDeclarationShell(sym: Sym, node: Node): Type | undefined { + // Skip template declarations — they require instantiation-time checking + if ("templateParameters" in node && (node as any).templateParameters?.length > 0) { + return undefined; + } + // Only create shells for models — only checkModelStatement has shell-reuse + // logic (populationStarted guard). Other check functions (checkScalar, etc.) + // return early if links.declaredType is set, leaving the type unpopulated. + switch (node.kind) { + case SyntaxKind.ModelStatement: + createModelShell(node as ModelStatementNode); + return getSymbolLinks(sym).declaredType; + default: + return undefined; + } + } + /** * Process a list of statements, queuing declarations and collecting * non-declaration statements. Recurses into namespaces. @@ -5242,7 +5318,6 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } if (links.declaredType && ctx.mapper === undefined) { - // we're not instantiating this model and we've already checked it return links.declaredType as any; } if (ctx.mapper === undefined) { @@ -5250,18 +5325,18 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } checkTemplateDeclaration(ctx, node); - const decorators: DecoratorApplication[] = []; const type: Model = createType({ kind: "Model", name: node.id.sv, node: node, properties: createRekeyableMap(), namespace: getParentNamespaceType(node), - decorators, + decorators: [], sourceModels: [], derivedModels: [], }); linkType(ctx, links, type); + const decorators: DecoratorApplication[] = type.decorators; if (node.symbol.members) { const members = resolver.getAugmentedSymbolTable(node.symbol.members); diff --git a/packages/compiler/test/checker/circular-resolution.test.ts b/packages/compiler/test/checker/circular-resolution.test.ts new file mode 100644 index 00000000000..946664ba557 --- /dev/null +++ b/packages/compiler/test/checker/circular-resolution.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { Tester } from "../tester.js"; + +describe("circular model resolution", () => { + it("non-circular: model A is Template<{t: string}> with B accessing A.t", async () => { + const diagnostics = await Tester.diagnose(` + model Template {...T} + model A is Template<{t: string}>; + model B { a: A.t; } + `); + expect(diagnostics.map((d) => `${d.code}: ${d.message}`)).toEqual([]); + }); + + it("direct members: model A { t: B } with B accessing A.t", async () => { + const diagnostics = await Tester.diagnose(` + model A { t: B } + model B { a: A.t; } + `); + // circular-prop is expected since A.t references B and B.a references A.t + expect(diagnostics.map((d) => d.code)).toContain("circular-prop"); + }); + + // TODO: This circular case requires shell-on-demand or queue-level deferral to resolve. + // A depends on B (through template arg), B depends on A.t (member access). + // Currently cannot be resolved in a single DFS pass. + it.todo("circular: model A is Template<{t: B}> with B accessing A.t"); +}); From 7425ed7ae941b5ae03a5a908a4f5b6745e496808 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 15 May 2026 19:14:51 -0700 Subject: [PATCH 06/10] feat(compiler): add deferred member resolution for circular template references When a member access (e.g., A.t) targets a model that is still being checked (creating=true), defer the resolution using the existing waitingForResolution callback mechanism instead of emitting an error. When the container type finishes checking, the callback re-resolves the member via late-bound symbols and updates the property type. This enables patterns like: model Template {...T} model A is Template<{t: B}>; model B { a: A.t; } Where B.a references A.t which only becomes available after A's template instantiation completes. --- packages/compiler/src/core/checker.ts | 38 ++++++++++++++++++- .../test/checker/circular-resolution.test.ts | 13 +++++-- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index 152466a92e6..b16efc377d7 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -515,6 +515,14 @@ export function createChecker(program: Program, resolver: NameResolver): Checker program.reportDiagnostic(x); }; + // State for deferred member resolution: when a member access targets a container + // that is still being checked (creating=true), we store the info here so that + // the calling checkModelProperty can register a waitingForResolution callback + // instead of emitting an error. + let pendingMemberResolution: + | { containerType: Type; memberName: string; baseSym: Sym } + | undefined; + const typePrototype: TypePrototype = {}; const globalNamespaceType = createGlobalNamespaceType(); @@ -4396,6 +4404,13 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } } + // If the late-bound member resolution detected a creating container, the member may + // become available when the container finishes. Don't emit an error — let the caller + // handle deferred resolution via waitingForResolution callback. + if (pendingMemberResolution) { + return undefined; + } + if (base.flags & SymbolFlags.Namespace) { reportCheckerDiagnostic( createDiagnostic({ @@ -4464,8 +4479,10 @@ export function createChecker(program: Program, resolver: NameResolver): Checker return undefined; } - // Only proceed if the type is fully checked (not still creating) + // Only proceed if the type is fully checked (not still creating). + // If creating, record pending info so the caller can defer resolution. if (declaredType.creating) { + pendingMemberResolution = { containerType: declaredType, memberName, baseSym: base }; return undefined; } @@ -6993,6 +7010,25 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } else { pendingResolutions.start(sym, ResolutionKind.Type); type.type = getTypeForNode(prop.value, ctx); + + // If the type resolved to errorType because a member access targeted a container + // that was still being checked, register a callback to update the property type + // when the container finishes. + const pending = pendingMemberResolution; + pendingMemberResolution = undefined; + if (pending && type.type === errorType) { + ensureResolved([pending.containerType], type, () => { + const resolvedSym = resolveLateBoundMember(ctx, pending.baseSym, pending.memberName); + if (resolvedSym) { + if (resolvedSym.flags & SymbolFlags.LateBound) { + compilerAssert(resolvedSym.type, "Expected late bound symbol to have type"); + type.type = resolvedSym.type as Type; + } else { + type.type = getTypeForNode(getSymNode(resolvedSym)!, ctx); + } + } + }); + } if (prop.default) { const defaultValue = checkDefaultValue(ctx, prop.default, type.type); if (defaultValue !== null) { diff --git a/packages/compiler/test/checker/circular-resolution.test.ts b/packages/compiler/test/checker/circular-resolution.test.ts index 946664ba557..5d607487a42 100644 --- a/packages/compiler/test/checker/circular-resolution.test.ts +++ b/packages/compiler/test/checker/circular-resolution.test.ts @@ -20,8 +20,13 @@ describe("circular model resolution", () => { expect(diagnostics.map((d) => d.code)).toContain("circular-prop"); }); - // TODO: This circular case requires shell-on-demand or queue-level deferral to resolve. - // A depends on B (through template arg), B depends on A.t (member access). - // Currently cannot be resolved in a single DFS pass. - it.todo("circular: model A is Template<{t: B}> with B accessing A.t"); + it("circular: model A is Template<{t: B}> with B accessing A.t", async () => { + const diagnostics = await Tester.diagnose(` + model Template {...T} + model A is Template<{t: B}>; + model B { a: A.t; } + `); + // A.t should resolve to the 't' property (type B) after A finishes via deferred resolution + expect(diagnostics.map((d) => `${d.code}: ${d.message}`)).toEqual([]); + }); }); From eec38402eee757b41e39ec1044bb0acf22ea9c9f Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Sat, 16 May 2026 08:37:38 -0700 Subject: [PATCH 07/10] abc --- packages/compiler/src/core/checker.ts | 70 ++++++++++++++++++- .../test/checker/circular-resolution.test.ts | 18 ++++- 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index b14c959c043..770a8bbc7ed 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -515,6 +515,14 @@ export function createChecker(program: Program, resolver: NameResolver): Checker program.reportDiagnostic(x); }; + // State for deferred member resolution: when a member access targets a container + // that is still being checked (creating=true), we store the info here so that + // the calling checkModelProperty can register a waitingForResolution callback + // instead of emitting an error. + let pendingMemberResolution: + | { containerType: Type; node: MemberExpressionNode; baseSym: Sym } + | undefined; + const typePrototype: TypePrototype = {}; const globalNamespaceType = createGlobalNamespaceType(); @@ -4397,6 +4405,13 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } } + // If the late-bound member resolution detected a creating container, the member may + // become available when the container finishes. Don't emit an error — let the caller + // handle deferred resolution via waitingForResolution callback. + if (pendingMemberResolution) { + return undefined; + } + if (base.flags & SymbolFlags.Namespace) { reportCheckerDiagnostic( createDiagnostic({ @@ -4469,7 +4484,19 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } // Don't force-check if the container is currently being resolved (cycle). + // Record pending info so the caller can defer resolution via waitingForResolution. if (pendingResolutions.has(base, ResolutionKind.Type)) { + const containerType = links.declaredType; + if (containerType?.creating) { + pendingMemberResolution = { containerType, node, baseSym: base }; + } + return undefined; + } + + // If the container type is already being checked (creating), we can't force-resolve + // its members yet. Record pending info for deferred resolution. + if (links.declaredType?.creating) { + pendingMemberResolution = { containerType: links.declaredType, node, baseSym: base }; return undefined; } @@ -6999,6 +7026,25 @@ export function createChecker(program: Program, resolver: NameResolver): Checker type.type = getTypeForNode(prop.value, ctx); + // If the type resolved to errorType because a member access targeted a container + // that was still being checked, register a callback to update the property type + // when the container finishes. + const pending = pendingMemberResolution; + pendingMemberResolution = undefined; + if (pending && type.type === errorType) { + ensureResolved([pending.containerType], type, () => { + const resolvedType = tryForceResolveLateBoundMember(ctx, pending.baseSym, pending.node); + if (resolvedType) { + if (resolvedType.flags & SymbolFlags.LateBound) { + compilerAssert(resolvedType.type, "Expected late bound symbol to have type"); + type.type = resolvedType.type as Type; + } else { + type.type = getTypeForNode(getSymNode(resolvedType)!, ctx); + } + } + }); + } + // Detect property-to-property cycles (e.g., A.a -> B.a -> A.a) if (hasPropertyTypeCycle(type)) { if (ctx.mapper === undefined) { @@ -7439,7 +7485,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker */ function checkAugmentDecorator(ctx: CheckContext, node: AugmentDecoratorStatementNode) { // This will validate the target type is pointing to a valid ref. - resolveTypeReferenceSym(ctx.withMapper(undefined), node.targetType, { + const targetSym = resolveTypeReferenceSym(ctx.withMapper(undefined), node.targetType, { resolveDeclarationOfTemplate: true, }); @@ -7482,6 +7528,28 @@ export function createChecker(program: Program, resolver: NameResolver): Checker ); } + // If the target sym was resolved at checker time (e.g. late-bound member from template) + // but wasn't registered during name resolution, apply the decorator directly. + if (targetSym && targetSym.flags & SymbolFlags.LateBound) { + const augmentDecsForSym = resolver.getAugmentDecoratorsForSym(targetSym); + if (!augmentDecsForSym.includes(node)) { + const targetType = targetSym.type && isType(targetSym.type) ? targetSym.type : undefined; + if (targetType && "decorators" in targetType) { + const decorator = checkDecoratorApplication(ctx, targetType, node); + if (decorator) { + targetType.decorators.unshift(decorator); + const validators = applyDecoratorToType(program, decorator, targetType); + if (validators?.onTargetFinish) { + program.reportDiagnostics(validators.onTargetFinish()); + } + if (validators?.onGraphFinish) { + postCheckValidators.push(validators.onGraphFinish); + } + } + } + } + } + // If this was used to get a type this is invalid, only used for validation. return errorType; } diff --git a/packages/compiler/test/checker/circular-resolution.test.ts b/packages/compiler/test/checker/circular-resolution.test.ts index 5d607487a42..09428c1d388 100644 --- a/packages/compiler/test/checker/circular-resolution.test.ts +++ b/packages/compiler/test/checker/circular-resolution.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from "vitest"; +import { getDoc } from "../../src/index.js"; +import { t } from "../../src/testing/index.js"; import { Tester } from "../tester.js"; describe("circular model resolution", () => { @@ -16,8 +18,8 @@ describe("circular model resolution", () => { model A { t: B } model B { a: A.t; } `); - // circular-prop is expected since A.t references B and B.a references A.t - expect(diagnostics.map((d) => d.code)).toContain("circular-prop"); + // With late-bound member resolution, A.t resolves without errors + expect(diagnostics.map((d) => `${d.code}: ${d.message}`)).toEqual([]); }); it("circular: model A is Template<{t: B}> with B accessing A.t", async () => { @@ -29,4 +31,16 @@ describe("circular model resolution", () => { // A.t should resolve to the 't' property (type B) after A finishes via deferred resolution expect(diagnostics.map((d) => `${d.code}: ${d.message}`)).toEqual([]); }); + + it("augment decorator on template-derived member applies to A and spread copies", async () => { + const { A, C, program } = await Tester.compile(t.code` + model Template { ...T; } + model ${t.model("A")} is Template<{ t: string; }>; + model ${t.model("C")} { ...A; } + @@doc(A.t, "Some doc"); + `); + const aProp = A.properties.get("t"); + expect(aProp).toBeDefined(); + expect(getDoc(program, aProp!)).toBe("Some doc"); + }); }); From e6f7a579e1d826a42ee99daefa4e083495f1dea3 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Sat, 16 May 2026 10:06:48 -0700 Subject: [PATCH 08/10] shell fix --- packages/compiler/src/core/checker.ts | 304 +++++++++++++----- .../compiler/test/checker/internal.test.ts | 4 +- 2 files changed, 220 insertions(+), 88 deletions(-) diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index 770a8bbc7ed..32eda74e31c 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -548,6 +548,13 @@ export function createChecker(program: Program, resolver: NameResolver): Checker */ const checkQueue = new CheckQueue(); + /** + * Tracks symbols whose check functions have started population. + * Used to prevent re-entrant population when shells are referenced + * during their own population phase. + */ + const populatingSymbols = new Set(); + /** * When inside a queue-managed check, check whether a declaration should be * deferred rather than checked inline via DFS. Returns true and throws @@ -1748,7 +1755,10 @@ export function createChecker(program: Program, resolver: NameResolver): Checker if (sym.flags & SymbolFlags.LateBound) { compilerAssert(sym.type, "Expected late bound symbol to have type"); return sym.type; - } else if (symbolLinks.declaredType) { + } else if (symbolLinks.declaredType && !symbolLinks.declaredType.creating) { + baseType = symbolLinks.declaredType; + } else if (symbolLinks.declaredType && symbolLinks.declaredType.creating && populatingSymbols.has(sym)) { + // Shell exists but is mid-population — return it to avoid re-entrant population baseType = symbolLinks.declaredType; } else if (sym.flags & SymbolFlags.Member) { baseType = checkMemberSym(innerCtx, sym); @@ -1810,10 +1820,15 @@ export function createChecker(program: Program, resolver: NameResolver): Checker symNode as TemplateParameterDeclarationNode, ); baseType = mapped as any; - } else if (symbolLinks.type) { + } else if (symbolLinks.type && !symbolLinks.type.creating) { // Have a cached type for non-declarations baseType = symbolLinks.type; - } else if (symbolLinks.declaredType) { + } else if (symbolLinks.type && symbolLinks.type.creating && populatingSymbols.has(sym)) { + // Shell exists but mid-population — return it + baseType = symbolLinks.type; + } else if (symbolLinks.declaredType && !symbolLinks.declaredType.creating) { + baseType = symbolLinks.declaredType; + } else if (symbolLinks.declaredType && symbolLinks.declaredType.creating && populatingSymbols.has(sym)) { baseType = symbolLinks.declaredType; } else { if (sym.flags & SymbolFlags.Member) { @@ -2646,8 +2661,12 @@ export function createChecker(program: Program, resolver: NameResolver): Checker if (links) { if (links.declaredType && ctx.mapper === undefined) { - // we're not instantiating this operation and we've already checked it - return links.declaredType as Operation; + if (!links.declaredType.creating) { + return links.declaredType as Operation; + } + if (populatingSymbols.has(symbol!)) { + return links.declaredType as Operation; + } } } if (ctx.mapper === undefined) { @@ -2695,18 +2714,27 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } } - const operationType: Operation = createType({ - kind: "Operation", - name, - namespace, - parameters: null as any, - returnType: voidType, - node, - decorators: [], - interface: parentInterface, - }); - if (links) { - linkType(ctx, links, operationType); + let operationType: Operation; + if (links?.declaredType && links.declaredType.creating && ctx.mapper === undefined) { + operationType = links.declaredType as Operation; + } else { + operationType = createType({ + kind: "Operation", + name, + namespace, + parameters: null as any, + returnType: voidType, + node, + decorators: [], + interface: parentInterface, + }); + if (links) { + linkType(ctx, links, operationType); + } + } + + if (symbol) { + populatingSymbols.add(symbol); } const parent = node.parent!; @@ -2767,6 +2795,8 @@ export function createChecker(program: Program, resolver: NameResolver): Checker namespace?.operations.set(name, operationType); } + if (symbol) { + } return operationType; } @@ -2799,6 +2829,17 @@ export function createChecker(program: Program, resolver: NameResolver): Checker return undefined; } + // Force-check the target declaration if it's still a shell + if (target && ctx.mapper === undefined) { + const targetLinks = getSymbolLinks(target); + if (targetLinks.declaredType?.creating) { + const targetNode = getSymNode(target); + if (targetNode) { + checkNode(ctx, targetNode, undefined); + } + } + } + // Resolve the base operation type const baseOperation = getTypeForNode(opReference, ctx); if (opSymId) { @@ -4921,13 +4962,12 @@ export function createChecker(program: Program, resolver: NameResolver): Checker seedCheckQueue(deferredStatements); // Phase 1b: Create shells for all queued declarations. - // This ensures that cross-references between declarations can resolve - // to a shell type even before population. Enables DeferralSignal-based - // deferral without partial-state corruption. - // DISABLED: Shell creation breaks circular-extends detection because - // references resolve to shells immediately without triggering - // pendingResolutions tracking. Need a different approach to detect cycles. - // createDeclarationShells(); + // Shells are empty type objects with creating=true that allow + // cross-references to resolve before full population. + // Cycle detection is handled by force-checking targets in heritage + // functions (checkClassHeritage, checkModelIs, etc.) which ensures + // pendingResolutions tracking still works through mutual extends chains. + createDeclarationShells(); // Phase 2: Process declarations via the worklist/fixpoint queue. // DeferralSignal is caught by the queue's processUntilFixpoint when a @@ -5352,26 +5392,44 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } if (links.declaredType && ctx.mapper === undefined) { - return links.declaredType as any; + // Fully checked → return immediately + if (!links.declaredType.creating) { + return links.declaredType as any; + } + // Still creating: either a shell waiting for population, or mid-population. + // If population already started, return the shell to avoid re-entrant population. + if (populatingSymbols.has(node.symbol)) { + return links.declaredType as any; + } + // Otherwise fall through to populate the shell. } if (ctx.mapper === undefined) { checkModifiers(program, node); } checkTemplateDeclaration(ctx, node); - const type: Model = createType({ - kind: "Model", - name: node.id.sv, - node: node, - properties: createRekeyableMap(), - namespace: getParentNamespaceType(node), - decorators: [], - sourceModels: [], - derivedModels: [], - }); - linkType(ctx, links, type); + // Reuse the shell if one was created upfront; otherwise create a new type. + let type: Model; + if (links.declaredType && links.declaredType.creating && ctx.mapper === undefined) { + type = links.declaredType as Model; + } else { + type = createType({ + kind: "Model", + name: node.id.sv, + node: node, + properties: createRekeyableMap(), + namespace: getParentNamespaceType(node), + decorators: [], + sourceModels: [], + derivedModels: [], + }); + linkType(ctx, links, type); + } const decorators: DecoratorApplication[] = type.decorators; + // Track that this model is being populated to prevent re-entrant population + populatingSymbols.add(node.symbol); + if (node.symbol.members) { const members = resolver.getAugmentedSymbolTable(node.symbol.members); const propDocs = extractPropDocs(node); @@ -6816,6 +6874,18 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } return undefined; } + // Force-check the target declaration if it's still a shell (creating). + // Only for non-template contexts — template instantiation handles resolution + // via getTypeForNode which applies the correct mapper. + if (target && ctx.mapper === undefined) { + const targetLinks = getSymbolLinks(target); + if (targetLinks.declaredType?.creating) { + const targetNode = getSymNode(target); + if (targetNode) { + checkNode(ctx, targetNode, undefined); + } + } + } const heritageType = getTypeForNode(heritageRef, ctx); pendingResolutions.finish(modelSymId, ResolutionKind.BaseType); if (isErrorType(heritageType)) { @@ -6878,6 +6948,18 @@ export function createChecker(program: Program, resolver: NameResolver): Checker pendingResolutions.finish(modelSymId, ResolutionKind.BaseType); return undefined; } + // Force-check target declaration if still a shell (only for non-template contexts). + // During template instantiation, the target is resolved via getTypeForNode which + // handles template instantiation correctly. + if (target && ctx.mapper === undefined) { + const targetLinks = getSymbolLinks(target); + if (targetLinks.declaredType?.creating) { + const targetNode = getSymNode(target); + if (targetNode) { + checkNode(ctx, targetNode, undefined); + } + } + } isType = getTypeForNode(isExpr, ctx); } else { reportCheckerDiagnostic(createDiagnostic({ code: "is-model", target: isExpr })); @@ -7615,26 +7697,36 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } if (links.declaredType && ctx.mapper === undefined) { - // we're not instantiating this scalar and we've already checked it - return links.declaredType as any; + if (!links.declaredType.creating) { + return links.declaredType as any; + } + if (populatingSymbols.has(node.symbol)) { + return links.declaredType as any; + } } if (ctx.mapper === undefined) { checkModifiers(program, node); } checkTemplateDeclaration(ctx, node); - const decorators: DecoratorApplication[] = []; + let type: Scalar; + if (links.declaredType && links.declaredType.creating && ctx.mapper === undefined) { + type = links.declaredType as Scalar; + } else { + type = createType({ + kind: "Scalar", + name: node.id.sv, + node: node, + constructors: new Map(), + namespace: getParentNamespaceType(node), + decorators: [], + derivedScalars: [], + }); + linkType(ctx, links, type); + } + const decorators: DecoratorApplication[] = type.decorators; - const type: Scalar = createType({ - kind: "Scalar", - name: node.id.sv, - node: node, - constructors: new Map(), - namespace: getParentNamespaceType(node), - decorators, - derivedScalars: [], - }); - linkType(ctx, links, type); + populatingSymbols.add(node.symbol); if (node.extends) { type.baseScalar = checkScalarExtends(ctx, node, node.extends); @@ -7679,6 +7771,17 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } return undefined; } + // Force-check the target declaration if it's still a shell + // Only for non-template contexts + if (target && ctx.mapper === undefined) { + const targetLinks = getSymbolLinks(target); + if (targetLinks.declaredType?.creating) { + const targetNode = getSymNode(target); + if (targetNode) { + checkNode(ctx, targetNode, undefined); + } + } + } const extendsType = getTypeForNode(extendsRef, ctx); pendingResolutions.finish(symId, ResolutionKind.BaseType); if (isErrorType(extendsType)) { @@ -7875,15 +7978,22 @@ export function createChecker(program: Program, resolver: NameResolver): Checker function checkEnum(ctx: CheckContext, node: EnumStatementNode): Type { const links = getSymbolLinks(node.symbol); - if (!links.type) { + if (!links.type || (links.type.creating && !populatingSymbols.has(node.symbol))) { checkModifiers(program, node); - const enumType: Enum = (links.type = createType({ - kind: "Enum", - name: node.id.sv, - node, - members: createRekeyableMap(), - decorators: [], - })); + let enumType: Enum; + if (links.type && links.type.creating) { + enumType = links.type as Enum; + } else { + enumType = links.type = createType({ + kind: "Enum", + name: node.id.sv, + node, + members: createRekeyableMap(), + decorators: [], + }); + } + + populatingSymbols.add(node.symbol); const memberNames = new Set(); @@ -7936,24 +8046,35 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } if (links.declaredType && ctx.mapper === undefined) { - // we're not instantiating this interface and we've already checked it - return links.declaredType as Interface; + if (!links.declaredType.creating) { + return links.declaredType as Interface; + } + if (populatingSymbols.has(node.symbol)) { + return links.declaredType as Interface; + } } if (ctx.mapper === undefined) { checkModifiers(program, node); } checkTemplateDeclaration(ctx, node); - const interfaceType: Interface = createType({ - kind: "Interface", - decorators: [], - node, - namespace: getParentNamespaceType(node), - sourceInterfaces: [], - operations: createRekeyableMap(), - name: node.id.sv, - }); - linkType(ctx, links, interfaceType); + let interfaceType: Interface; + if (links.declaredType && links.declaredType.creating && ctx.mapper === undefined) { + interfaceType = links.declaredType as Interface; + } else { + interfaceType = createType({ + kind: "Interface", + decorators: [], + node, + namespace: getParentNamespaceType(node), + sourceInterfaces: [], + operations: createRekeyableMap(), + name: node.id.sv, + }); + linkType(ctx, links, interfaceType); + } + + populatingSymbols.add(node.symbol); interfaceType.decorators = checkDecorators(ctx, interfaceType, node); @@ -8052,32 +8173,43 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } if (links.declaredType && ctx.mapper === undefined) { - // we're not instantiating this union and we've already checked it - return links.declaredType as Union; + if (!links.declaredType.creating) { + return links.declaredType as Union; + } + if (populatingSymbols.has(node.symbol)) { + return links.declaredType as Union; + } } if (ctx.mapper === undefined) { checkModifiers(program, node); } checkTemplateDeclaration(ctx, node); - const variants = createRekeyableMap(); - const unionType: Union = createType({ - kind: "Union", - decorators: [], - node, - namespace: getParentNamespaceType(node), - name: node.id.sv, - variants, - get options() { - return Array.from(this.variants.values()).map((v) => v.type); - }, - expression: false, - }); - linkType(ctx, links, unionType); + let unionType: Union; + if (links.declaredType && links.declaredType.creating && ctx.mapper === undefined) { + unionType = links.declaredType as Union; + } else { + const variants = createRekeyableMap(); + unionType = createType({ + kind: "Union", + decorators: [], + node, + namespace: getParentNamespaceType(node), + name: node.id.sv, + variants, + get options() { + return Array.from(this.variants.values()).map((v) => v.type); + }, + expression: false, + }); + linkType(ctx, links, unionType); + } + + populatingSymbols.add(node.symbol); unionType.decorators = checkDecorators(ctx, unionType, node); - checkUnionVariants(ctx, unionType, node, variants); + checkUnionVariants(ctx, unionType, node, unionType.variants as Map); linkMapper(unionType, ctx.mapper); diff --git a/packages/compiler/test/checker/internal.test.ts b/packages/compiler/test/checker/internal.test.ts index d4798b7dfcd..61e496eafc8 100644 --- a/packages/compiler/test/checker/internal.test.ts +++ b/packages/compiler/test/checker/internal.test.ts @@ -180,8 +180,8 @@ describe("access control", () => { `); expectDiagnostics(diagnostics, [ - { code: "invalid-ref", message: /internal/ }, { code: "experimental-feature" }, + { code: "invalid-ref", message: /internal/ }, ]); }); @@ -260,8 +260,8 @@ describe("access control", () => { `); expectDiagnostics(diagnostics, [ - { code: "invalid-ref", message: /internal/ }, { code: "experimental-feature" }, + { code: "invalid-ref", message: /internal/ }, ]); }); }); From e10aedb3ae2b81e92037764375e2c4e5f91f3874 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Sat, 16 May 2026 10:33:06 -0700 Subject: [PATCH 09/10] pending --- packages/compiler/src/core/checker.ts | 188 ++++++++---------- packages/compiler/src/core/types.ts | 6 + .../compiler/test/checker/internal.test.ts | 4 +- 3 files changed, 94 insertions(+), 104 deletions(-) diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index 32eda74e31c..d5ce09e262a 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -515,13 +515,22 @@ export function createChecker(program: Program, resolver: NameResolver): Checker program.reportDiagnostic(x); }; - // State for deferred member resolution: when a member access targets a container - // that is still being checked (creating=true), we store the info here so that - // the calling checkModelProperty can register a waitingForResolution callback - // instead of emitting an error. - let pendingMemberResolution: - | { containerType: Type; node: MemberExpressionNode; baseSym: Sym } - | undefined; + /** + * Info about a member resolution that was deferred because the container is still being checked. + */ + interface PendingMemberResolution { + containerType: Type; + node: MemberExpressionNode; + baseSym: Sym; + } + + /** + * Stores pending member resolution info keyed by the member expression node. + * When a member access targets a container that is still being checked, + * the pending info is stored here so that `checkModelProperty` can register + * a `waitingForResolution` callback instead of leaving the error type. + */ + const pendingMemberResolutions = new WeakMap(); const typePrototype: TypePrototype = {}; const globalNamespaceType = createGlobalNamespaceType(); @@ -548,13 +557,6 @@ export function createChecker(program: Program, resolver: NameResolver): Checker */ const checkQueue = new CheckQueue(); - /** - * Tracks symbols whose check functions have started population. - * Used to prevent re-entrant population when shells are referenced - * during their own population phase. - */ - const populatingSymbols = new Set(); - /** * When inside a queue-managed check, check whether a declaration should be * deferred rather than checked inline via DFS. Returns true and throws @@ -1739,9 +1741,13 @@ export function createChecker(program: Program, resolver: NameResolver): Checker // Not a templated node, and we are moving through a typeref to a new declaration. // Therefore, we are no longer in a template declaration if we were before, and we are // visiting a new declaration, so we exit the active template observer scope, if any. + // Strip the mapper: non-template declarations must be checked in a mapper-free context. + // This ensures shells are populated correctly (check functions use mapper===undefined + // guards) and prevents leaking a caller's template mapper into an unrelated declaration. const innerCtx = ctx .maskFlags(CheckFlags.InTemplateDeclaration) - .exitTemplateObserverScope(); + .exitTemplateObserverScope() + .withMapper(undefined); if (argumentNodes.length > 0) { reportCheckerDiagnostic( createDiagnostic({ @@ -1757,12 +1763,14 @@ export function createChecker(program: Program, resolver: NameResolver): Checker return sym.type; } else if (symbolLinks.declaredType && !symbolLinks.declaredType.creating) { baseType = symbolLinks.declaredType; - } else if (symbolLinks.declaredType && symbolLinks.declaredType.creating && populatingSymbols.has(sym)) { - // Shell exists but is mid-population — return it to avoid re-entrant population + } else if (symbolLinks.declaredType?.creating && symbolLinks.declaredType.populating) { + // Shell exists and is mid-population — return it to avoid re-entrant population baseType = symbolLinks.declaredType; } else if (sym.flags & SymbolFlags.Member) { baseType = checkMemberSym(innerCtx, sym); } else { + // No cached type, or shell exists but not yet being populated — check the declaration. + // This is the centralized force-check: if the target is a shell, this call populates it. baseType = checkDeclaredTypeOrIndeterminate(innerCtx, sym, decl); } } else { @@ -1823,12 +1831,12 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } else if (symbolLinks.type && !symbolLinks.type.creating) { // Have a cached type for non-declarations baseType = symbolLinks.type; - } else if (symbolLinks.type && symbolLinks.type.creating && populatingSymbols.has(sym)) { + } else if (symbolLinks.type?.creating && symbolLinks.type.populating) { // Shell exists but mid-population — return it baseType = symbolLinks.type; } else if (symbolLinks.declaredType && !symbolLinks.declaredType.creating) { baseType = symbolLinks.declaredType; - } else if (symbolLinks.declaredType && symbolLinks.declaredType.creating && populatingSymbols.has(sym)) { + } else if (symbolLinks.declaredType?.creating && symbolLinks.declaredType.populating) { baseType = symbolLinks.declaredType; } else { if (sym.flags & SymbolFlags.Member) { @@ -2664,7 +2672,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker if (!links.declaredType.creating) { return links.declaredType as Operation; } - if (populatingSymbols.has(symbol!)) { + if (links.declaredType.populating) { return links.declaredType as Operation; } } @@ -2734,7 +2742,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } if (symbol) { - populatingSymbols.add(symbol); + operationType.populating = true; } const parent = node.parent!; @@ -2829,17 +2837,6 @@ export function createChecker(program: Program, resolver: NameResolver): Checker return undefined; } - // Force-check the target declaration if it's still a shell - if (target && ctx.mapper === undefined) { - const targetLinks = getSymbolLinks(target); - if (targetLinks.declaredType?.creating) { - const targetNode = getSymNode(target); - if (targetNode) { - checkNode(ctx, targetNode, undefined); - } - } - } - // Resolve the base operation type const baseOperation = getTypeForNode(opReference, ctx); if (opSymId) { @@ -3725,7 +3722,15 @@ export function createChecker(program: Program, resolver: NameResolver): Checker return templateAccessSym; } - const sym = resolveMemberInContainer(ctx, base, node, options); + const result = resolveMemberInContainer(ctx, base, node, options); + const sym = result?.sym; + + // If member resolution was deferred (container still being checked), + // store the pending info keyed by the member expression node so that + // checkModelProperty can pick it up. + if (result?.pending) { + pendingMemberResolutions.set(node, result.pending); + } checkSymbolAccess(options.locationContext, node, sym); @@ -4420,10 +4425,10 @@ export function createChecker(program: Program, resolver: NameResolver): Checker base: Sym, node: MemberExpressionNode, options: SymbolResolutionOptions, - ) { + ): { sym: Sym; pending?: undefined } | { sym?: undefined; pending: PendingMemberResolution } | undefined { const symbolFromType = resolveMemberOnSymbolType(ctx, base, node); if (symbolFromType) { - return symbolFromType; + return { sym: symbolFromType }; } const { finalSymbol: sym, resolvedSymbol: nextSym } = resolver.resolveMemberExpressionForSym( @@ -4433,26 +4438,24 @@ export function createChecker(program: Program, resolver: NameResolver): Checker ); const symbol = nextSym ?? sym; if (symbol) { - return symbol; + return { sym: symbol }; } // When the member wasn't found but the container has unknown members (e.g. from // template spreads or `is`), force-check the container type so that late-bound // members become available, then retry the lookup. if (node.selector === "." && base.flags & SymbolFlags.MemberContainer) { - const resolvedMember = tryForceResolveLateBoundMember(ctx, base, node); - if (resolvedMember) { - return resolvedMember; + const result = tryForceResolveLateBoundMember(ctx, base, node); + if (result) { + if ("resolved" in result) { + return { sym: result.resolved }; + } + // Pending: the container is still being checked, member may appear later. + // Don't emit an error — let the caller handle deferred resolution. + return { pending: result.pending }; } } - // If the late-bound member resolution detected a creating container, the member may - // become available when the container finishes. Don't emit an error — let the caller - // handle deferred resolution via waitingForResolution callback. - if (pendingMemberResolution) { - return undefined; - } - if (base.flags & SymbolFlags.Namespace) { reportCheckerDiagnostic( createDiagnostic({ @@ -4513,32 +4516,33 @@ export function createChecker(program: Program, resolver: NameResolver): Checker * Attempt to resolve a late-bound member by force-checking the container type. * This handles the case where a model has `hasUnknownMembers` (e.g. from template * spreads or `is`) and the member only becomes known after the type is fully checked. + * + * Returns either a resolved symbol, pending info for deferred resolution, or undefined. */ function tryForceResolveLateBoundMember( ctx: CheckContext, base: Sym, node: MemberExpressionNode, - ): Sym | undefined { + ): { resolved: Sym } | { pending: PendingMemberResolution } | undefined { const links = getSymbolLinks(base); if (!links.hasUnknownMembers) { return undefined; } // Don't force-check if the container is currently being resolved (cycle). - // Record pending info so the caller can defer resolution via waitingForResolution. + // Return pending info so the caller can defer resolution via waitingForResolution. if (pendingResolutions.has(base, ResolutionKind.Type)) { const containerType = links.declaredType; if (containerType?.creating) { - pendingMemberResolution = { containerType, node, baseSym: base }; + return { pending: { containerType, node, baseSym: base } }; } return undefined; } // If the container type is already being checked (creating), we can't force-resolve - // its members yet. Record pending info for deferred resolution. + // its members yet. Return pending info for deferred resolution. if (links.declaredType?.creating) { - pendingMemberResolution = { containerType: links.declaredType, node, baseSym: base }; - return undefined; + return { pending: { containerType: links.declaredType, node, baseSym: base } }; } // Force-check the container type to populate its members. @@ -4564,7 +4568,8 @@ export function createChecker(program: Program, resolver: NameResolver): Checker return undefined; } - return getCanonicalResolvedMemberSymbol(type, node.id.sv); + const sym = getCanonicalResolvedMemberSymbol(type, node.id.sv); + return sym ? { resolved: sym } : undefined; } function getMemberKindName(node: Node) { @@ -5398,7 +5403,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } // Still creating: either a shell waiting for population, or mid-population. // If population already started, return the shell to avoid re-entrant population. - if (populatingSymbols.has(node.symbol)) { + if (links.declaredType.populating) { return links.declaredType as any; } // Otherwise fall through to populate the shell. @@ -5428,7 +5433,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker const decorators: DecoratorApplication[] = type.decorators; // Track that this model is being populated to prevent re-entrant population - populatingSymbols.add(node.symbol); + type.populating = true; if (node.symbol.members) { const members = resolver.getAugmentedSymbolTable(node.symbol.members); @@ -6874,18 +6879,6 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } return undefined; } - // Force-check the target declaration if it's still a shell (creating). - // Only for non-template contexts — template instantiation handles resolution - // via getTypeForNode which applies the correct mapper. - if (target && ctx.mapper === undefined) { - const targetLinks = getSymbolLinks(target); - if (targetLinks.declaredType?.creating) { - const targetNode = getSymNode(target); - if (targetNode) { - checkNode(ctx, targetNode, undefined); - } - } - } const heritageType = getTypeForNode(heritageRef, ctx); pendingResolutions.finish(modelSymId, ResolutionKind.BaseType); if (isErrorType(heritageType)) { @@ -6948,18 +6941,6 @@ export function createChecker(program: Program, resolver: NameResolver): Checker pendingResolutions.finish(modelSymId, ResolutionKind.BaseType); return undefined; } - // Force-check target declaration if still a shell (only for non-template contexts). - // During template instantiation, the target is resolved via getTypeForNode which - // handles template instantiation correctly. - if (target && ctx.mapper === undefined) { - const targetLinks = getSymbolLinks(target); - if (targetLinks.declaredType?.creating) { - const targetNode = getSymNode(target); - if (targetNode) { - checkNode(ctx, targetNode, undefined); - } - } - } isType = getTypeForNode(isExpr, ctx); } else { reportCheckerDiagnostic(createDiagnostic({ code: "is-model", target: isExpr })); @@ -7111,12 +7092,26 @@ export function createChecker(program: Program, resolver: NameResolver): Checker // If the type resolved to errorType because a member access targeted a container // that was still being checked, register a callback to update the property type // when the container finishes. - const pending = pendingMemberResolution; - pendingMemberResolution = undefined; + // The pending info is keyed by the MemberExpression node, which may be prop.value + // directly or prop.value.target when wrapped in a TypeReference. + const memberExprNode = + prop.value.kind === SyntaxKind.MemberExpression + ? prop.value + : prop.value.kind === SyntaxKind.TypeReference && + prop.value.target.kind === SyntaxKind.MemberExpression + ? prop.value.target + : undefined; + const pending = memberExprNode + ? pendingMemberResolutions.get(memberExprNode) + : undefined; + if (pending && memberExprNode) { + pendingMemberResolutions.delete(memberExprNode); + } if (pending && type.type === errorType) { ensureResolved([pending.containerType], type, () => { - const resolvedType = tryForceResolveLateBoundMember(ctx, pending.baseSym, pending.node); - if (resolvedType) { + const result = tryForceResolveLateBoundMember(ctx, pending.baseSym, pending.node); + if (result && "resolved" in result) { + const resolvedType = result.resolved; if (resolvedType.flags & SymbolFlags.LateBound) { compilerAssert(resolvedType.type, "Expected late bound symbol to have type"); type.type = resolvedType.type as Type; @@ -7700,7 +7695,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker if (!links.declaredType.creating) { return links.declaredType as any; } - if (populatingSymbols.has(node.symbol)) { + if (links.declaredType.populating) { return links.declaredType as any; } } @@ -7726,7 +7721,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } const decorators: DecoratorApplication[] = type.decorators; - populatingSymbols.add(node.symbol); + type.populating = true; if (node.extends) { type.baseScalar = checkScalarExtends(ctx, node, node.extends); @@ -7771,17 +7766,6 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } return undefined; } - // Force-check the target declaration if it's still a shell - // Only for non-template contexts - if (target && ctx.mapper === undefined) { - const targetLinks = getSymbolLinks(target); - if (targetLinks.declaredType?.creating) { - const targetNode = getSymNode(target); - if (targetNode) { - checkNode(ctx, targetNode, undefined); - } - } - } const extendsType = getTypeForNode(extendsRef, ctx); pendingResolutions.finish(symId, ResolutionKind.BaseType); if (isErrorType(extendsType)) { @@ -7978,7 +7962,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker function checkEnum(ctx: CheckContext, node: EnumStatementNode): Type { const links = getSymbolLinks(node.symbol); - if (!links.type || (links.type.creating && !populatingSymbols.has(node.symbol))) { + if (!links.type || (links.type.creating && !links.type.populating)) { checkModifiers(program, node); let enumType: Enum; if (links.type && links.type.creating) { @@ -7993,7 +7977,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker }); } - populatingSymbols.add(node.symbol); + enumType.populating = true; const memberNames = new Set(); @@ -8049,7 +8033,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker if (!links.declaredType.creating) { return links.declaredType as Interface; } - if (populatingSymbols.has(node.symbol)) { + if (links.declaredType.populating) { return links.declaredType as Interface; } } @@ -8074,7 +8058,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker linkType(ctx, links, interfaceType); } - populatingSymbols.add(node.symbol); + interfaceType.populating = true; interfaceType.decorators = checkDecorators(ctx, interfaceType, node); @@ -8176,7 +8160,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker if (!links.declaredType.creating) { return links.declaredType as Union; } - if (populatingSymbols.has(node.symbol)) { + if (links.declaredType.populating) { return links.declaredType as Union; } } @@ -8205,7 +8189,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker linkType(ctx, links, unionType); } - populatingSymbols.add(node.symbol); + unionType.populating = true; unionType.decorators = checkDecorators(ctx, unionType, node); diff --git a/packages/compiler/src/core/types.ts b/packages/compiler/src/core/types.ts index 60ee8493360..0b691c07e28 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -89,6 +89,12 @@ export interface BaseType { */ creating?: true; + /** + * If the type's members are currently being populated. + * Set when a check function begins populating a shell to prevent re-entrant population. + */ + populating?: true; + /** * Reflect if a type has been finished(Decorators have been called). * There is multiple reasons a type might not be finished: diff --git a/packages/compiler/test/checker/internal.test.ts b/packages/compiler/test/checker/internal.test.ts index 61e496eafc8..d4798b7dfcd 100644 --- a/packages/compiler/test/checker/internal.test.ts +++ b/packages/compiler/test/checker/internal.test.ts @@ -180,8 +180,8 @@ describe("access control", () => { `); expectDiagnostics(diagnostics, [ - { code: "experimental-feature" }, { code: "invalid-ref", message: /internal/ }, + { code: "experimental-feature" }, ]); }); @@ -260,8 +260,8 @@ describe("access control", () => { `); expectDiagnostics(diagnostics, [ - { code: "experimental-feature" }, { code: "invalid-ref", message: /internal/ }, + { code: "experimental-feature" }, ]); }); }); From ce780f5e663ab8353f05a298b40237066431948b Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Sat, 16 May 2026 19:36:48 -0700 Subject: [PATCH 10/10] fixes --- packages/compiler/src/core/check-queue.ts | 6 + packages/compiler/src/core/checker.ts | 644 +++++++++++------- .../compiler/test/checker/operations.test.ts | 32 + 3 files changed, 424 insertions(+), 258 deletions(-) diff --git a/packages/compiler/src/core/check-queue.ts b/packages/compiler/src/core/check-queue.ts index e8f1916fb27..acfd714b8a5 100644 --- a/packages/compiler/src/core/check-queue.ts +++ b/packages/compiler/src/core/check-queue.ts @@ -322,6 +322,12 @@ export class CheckQueue { } } + // If the callback didn't explicitly mark the item (e.g., type is still + // creating because deps weren't ready), treat it as deferred. + if (item.status === CheckItemStatus.InProgress) { + this.markDeferred(item, []); + } + this.#activeItem = undefined; if (item.status === CheckItemStatus.Done) { diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index d5ce09e262a..b8837eb5706 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -497,7 +497,6 @@ const TypeInstantiationMap = class implements TypeInstantiationMap {}; export function createChecker(program: Program, resolver: NameResolver): Checker { - const waitingForResolution = new Map void][]>(); const stats: CheckerStats = { createdTypes: 0, finishedTypes: 0, @@ -527,11 +526,50 @@ export function createChecker(program: Program, resolver: NameResolver): Checker /** * Stores pending member resolution info keyed by the member expression node. * When a member access targets a container that is still being checked, - * the pending info is stored here so that `checkModelProperty` can register - * a `waitingForResolution` callback instead of leaving the error type. + * the pending info is stored here so that `checkModelProperty` can defer + * via the queue instead of leaving the error type. */ const pendingMemberResolutions = new WeakMap(); + /** + * Returns true if any of the given types are still `creating` (not yet fully checked). + * Used by check functions to decide whether to proceed with population or + * return early and let the check queue retry. + */ + function anyCreating(deps: readonly (Type | undefined)[]): boolean { + return deps.some((d) => d !== undefined && d.creating); + } + + /** + * Instantiations deferred during DFS because a dependency was still populating + * (same call stack). After the top-level queue item finishes, these are retried + * since the populating dependencies should now be complete. + */ + const pendingInstantiationRetries = new Map(); + + /** + * After a queue item check completes, retry any instantiations that were + * deferred because a dependency was mid-population (same DFS). Now that + * the DFS has unwound, those dependencies should be fully checked. + */ + function retryPendingInstantiations() { + let progress = true; + while (progress) { + progress = false; + for (const [type, { ctx, node }] of pendingInstantiationRetries) { + if (!type.creating) { + pendingInstantiationRetries.delete(type); + continue; + } + checkNode(ctx, node); + if (!type.creating) { + pendingInstantiationRetries.delete(type); + progress = true; + } + } + } + } + const typePrototype: TypePrototype = {}; const globalNamespaceType = createGlobalNamespaceType(); @@ -1966,7 +2004,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } const mapper = createTypeMapper(params, args, source, parentMapper); const cached = symbolLinks.instantiations?.get(mapper.args); - if (cached) { + if (cached && !cached.creating) { return cached; } if (instantiateTemplates) { @@ -2117,58 +2155,33 @@ export function createChecker(program: Program, resolver: NameResolver): Checker function checkIntersectionExpression(ctx: CheckContext, node: IntersectionExpressionNode) { const links = getSymbolLinks(node.symbol); - if (links.declaredType && ctx.mapper === undefined) { - // we're not instantiating this model and we've already checked it - return links.declaredType as any; - } - - const intersection: Model = initModel(node); - const options = node.options.map((o): [Expression, Type] => [o, getTypeForNode(o, ctx)]); - - ensureResolved( - options.map(([, type]) => type), - intersection, - () => { - const type = mergeModelTypes(ctx, node.symbol, node, options, intersection); - linkType(ctx, links, type); - finishType(intersection); - }, - ); - return intersection; - } + // Check for existing type — declaration or cached instantiation + const existingType = ( + ctx.mapper === undefined + ? links.declaredType + : links.instantiations?.get(ctx.mapper.args) + ) as Model | undefined; - function ensureResolved( - types: readonly (Type | undefined)[], - awaitingType: Type, - callback: () => T, - ): void { - const waitingFor = new Set(); - for (const type of types) { - if (type === undefined) continue; - if (type.creating) { - waitingFor.add(type); - } + if (existingType && !existingType.creating) { + return existingType; } - function check() { - if (waitingFor.size === 0) { - callback(); - } + let intersection: Model; + if (existingType?.creating) { + intersection = existingType; + } else { + intersection = initModel(node); } - for (const type of waitingFor) { - waitingForResolution.set(type, [ - ...(waitingForResolution.get(type) || []), - [ - awaitingType, - () => { - waitingFor.delete(type); - check(); - }, - ], - ]); + const options = node.options.map((o): [Expression, Type] => [o, getTypeForNode(o, ctx)]); + + if (anyCreating(options.map(([, type]) => type))) { + return intersection; } - check(); + const type = mergeModelTypes(ctx, node.symbol, node, options, intersection); + linkType(ctx, links, type); + finishType(intersection); + return intersection; } function checkDecoratorDeclaration( @@ -2667,16 +2680,20 @@ export function createChecker(program: Program, resolver: NameResolver): Checker const symbol = inInterface ? getSymbolForMember(node) : node.symbol; const links = symbol && getSymbolLinks(symbol); - if (links) { - if (links.declaredType && ctx.mapper === undefined) { - if (!links.declaredType.creating) { - return links.declaredType as Operation; - } - if (links.declaredType.populating) { - return links.declaredType as Operation; - } - } + // Check for existing type — declaration or cached instantiation + const existingType = links + ? ((ctx.mapper === undefined + ? links.declaredType + : links.instantiations?.get(ctx.mapper.args)) as Operation | undefined) + : undefined; + + if (existingType && !existingType.creating) { + return existingType; + } + if (existingType?.populating) { + return existingType; } + if (ctx.mapper === undefined) { checkModifiers(program, node); } @@ -2723,8 +2740,11 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } let operationType: Operation; - if (links?.declaredType && links.declaredType.creating && ctx.mapper === undefined) { - operationType = links.declaredType as Operation; + if (existingType?.creating) { + operationType = existingType; + // Clear for re-population on retry + operationType.decorators.length = 0; + operationType.sourceOperation = undefined; } else { operationType = createType({ kind: "Operation", @@ -2761,29 +2781,35 @@ export function createChecker(program: Program, resolver: NameResolver): Checker // Attempt to resolve the operation const baseOperation = checkOperationIs(ctx, node, node.signature.baseOperation); if (baseOperation) { - ensureResolved([baseOperation], operationType, () => { - operationType.sourceOperation = baseOperation; - // Reference the same return type and create the parameters type - const clone = initializeClone(baseOperation.parameters, { - properties: createRekeyableMap(), - }); + if (baseOperation.creating) { + // Base operation not ready yet — defer + if (ctx.mapper !== undefined) { + pendingInstantiationRetries.set(operationType, { ctx, node }); + } + delete operationType.populating; + return operationType; + } + operationType.sourceOperation = baseOperation; + // Reference the same return type and create the parameters type + const clone = initializeClone(baseOperation.parameters, { + properties: createRekeyableMap(), + }); - clone.properties = createRekeyableMap( - Array.from(baseOperation.parameters.properties.entries()).map(([key, prop]) => [ - key, - cloneTypeForSymbol(getMemberSymbol(parameterModelSym!, prop.name)!, prop, { - model: clone, - sourceProperty: prop, - }), - ]), - ); - operationType.parameters = finishType(clone); - operationType.returnType = baseOperation.returnType; + clone.properties = createRekeyableMap( + Array.from(baseOperation.parameters.properties.entries()).map(([key, prop]) => [ + key, + cloneTypeForSymbol(getMemberSymbol(parameterModelSym!, prop.name)!, prop, { + model: clone, + sourceProperty: prop, + }), + ]), + ); + operationType.parameters = finishType(clone); + operationType.returnType = baseOperation.returnType; - // Copy decorators from the base operation, inserting the base decorators first - operationType.decorators.push(...baseOperation.decorators); - finishOperation(); - }); + // Copy decorators from the base operation, inserting the base decorators first + operationType.decorators.push(...baseOperation.decorators); + finishOperation(); } else { // If we can't resolve the signature we return an empty model. operationType.parameters = initModel(); @@ -2793,9 +2819,15 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } else { operationType.parameters = getTypeForNode(node.signature.parameters, ctx) as Model; operationType.returnType = getTypeForNode(node.signature.returnType, ctx); - ensureResolved([operationType.parameters], operationType, () => { - finishOperation(); - }); + if (operationType.parameters.creating) { + // Parameters not ready yet — defer + if (ctx.mapper !== undefined) { + pendingInstantiationRetries.set(operationType, { ctx, node }); + } + delete operationType.populating; + return operationType; + } + finishOperation(); } linkMapper(operationType, ctx.mapper); @@ -3730,6 +3762,9 @@ export function createChecker(program: Program, resolver: NameResolver): Checker // checkModelProperty can pick it up. if (result?.pending) { pendingMemberResolutions.set(node, result.pending); + // Don't cache this result — the member may become available after the + // container finishes checking and the queue retries. + referenceSymCache.delete(node); } checkSymbolAccess(options.locationContext, node, sym); @@ -4530,7 +4565,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } // Don't force-check if the container is currently being resolved (cycle). - // Return pending info so the caller can defer resolution via waitingForResolution. + // Return pending info so the caller can defer resolution via the queue. if (pendingResolutions.has(base, ResolutionKind.Type)) { const containerType = links.declaredType; if (containerType?.creating) { @@ -4969,9 +5004,8 @@ export function createChecker(program: Program, resolver: NameResolver): Checker // Phase 1b: Create shells for all queued declarations. // Shells are empty type objects with creating=true that allow // cross-references to resolve before full population. - // Cycle detection is handled by force-checking targets in heritage - // functions (checkClassHeritage, checkModelIs, etc.) which ensures - // pendingResolutions tracking still works through mutual extends chains. + // Cycle detection is handled by the centralized force-check in reference + // resolution and pendingResolutions tracking for mutual extends chains. createDeclarationShells(); // Phase 2: Process declarations via the worklist/fixpoint queue. @@ -4981,16 +5015,24 @@ export function createChecker(program: Program, resolver: NameResolver): Checker const queueResult = checkQueue.processUntilFixpoint((item) => { pendingResolutions.snapshot(); try { + pendingInstantiationRetries.clear(); checkNode(CheckContext.DEFAULT, item.node, undefined); - // Only mark as done if the type is fully checked (not still creating - // due to deferred ensureResolved callbacks) + + // After the main DFS, retry any instantiations that were deferred + // because a dependency was still populating (same DFS). Now that the + // DFS has returned, those dependencies should be complete. + retryPendingInstantiations(); + + // Check if the type is fully resolved. + // If a check function encountered creating deps (e.g., circular spreads), + // it returns without finishing — the type stays creating and we defer. const itemLinks = getSymbolLinks(item.sym); const declaredType = itemLinks.declaredType; if (declaredType && !declaredType.creating) { checkQueue.markDone(item); - } else if (item.status === CheckItemStatus.InProgress) { - checkQueue.markDone(item); } + // If still creating, leave as InProgress — processUntilFixpoint + // will treat it as deferred when the iteration completes. pendingResolutions.discardSnapshot(); } catch (e) { if (e instanceof DeferralSignal) { @@ -5038,7 +5080,6 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } internalDecoratorValidation(); - assertNoPendingResolutions(); runPostValidators(postCheckValidators); } @@ -5290,23 +5331,6 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } } - function assertNoPendingResolutions() { - if (waitingForResolution.size === 0) { - return; - } - - const message = [ - "Unexpected pending resolutions found", - ...[...waitingForResolution.entries()].flatMap(([type, items]) => { - const base = ` (${type.kind}) ${getTypeName(type)} => `; - return items.map( - ([item], index) => `${index === 0 ? base : " ".repeat(base.length)}${getTypeName(item)}`, - ); - }), - ].join("\n"); - compilerAssert(false, message); - } - function checkDuplicateSymbols() { program.reportDuplicateSymbols(resolver.symbols.global.exports); for (const file of program.sourceFiles.values()) { @@ -5396,27 +5420,37 @@ export function createChecker(program: Program, resolver: NameResolver): Checker ctx = ctx.withFlags(CheckFlags.InTemplateDeclaration); } - if (links.declaredType && ctx.mapper === undefined) { - // Fully checked → return immediately - if (!links.declaredType.creating) { - return links.declaredType as any; - } - // Still creating: either a shell waiting for population, or mid-population. - // If population already started, return the shell to avoid re-entrant population. - if (links.declaredType.populating) { - return links.declaredType as any; - } - // Otherwise fall through to populate the shell. + // Check for existing type — either a declaration shell or a cached instantiation + const existingType = ( + ctx.mapper === undefined + ? links.declaredType + : links.instantiations?.get(ctx.mapper.args) + ) as Model | undefined; + + if (existingType && !existingType.creating) { + return existingType; + } + if (existingType?.populating) { + return existingType; } + // existingType is either undefined (first check) or creating (retry) + if (ctx.mapper === undefined) { checkModifiers(program, node); } checkTemplateDeclaration(ctx, node); - // Reuse the shell if one was created upfront; otherwise create a new type. + // Reuse the shell/creating type if present; otherwise create a new type. let type: Model; - if (links.declaredType && links.declaredType.creating && ctx.mapper === undefined) { - type = links.declaredType as Model; + if (existingType?.creating) { + type = existingType; + // Clear state for re-population on retry + type.properties.clear(); + type.sourceModels.length = 0; + type.decorators.length = 0; + type.sourceModel = undefined; + type.baseModel = undefined; + type.indexer = undefined; } else { type = createType({ kind: "Model", @@ -5447,75 +5481,94 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } const isBase = checkModelIs(ctx, node, node.is); - ensureResolved( - [ - isBase, - ...node.properties - .filter((x) => x.kind === SyntaxKind.ModelSpreadProperty) - .map((x) => checkSpreadTarget(ctx, node, x.target)), - ], - type, - () => { - if (isBase) { - type.sourceModel = isBase; - type.sourceModels.push({ usage: "is", model: isBase, node: node.is }); - decorators.push(...isBase.decorators); - if (isBase.indexer) { - type.indexer = isBase.indexer; - } + const spreadTargets = node.properties + .filter((x) => x.kind === SyntaxKind.ModelSpreadProperty) + .map((x) => checkSpreadTarget(ctx, node, x.target)); - for (const prop of isBase.properties.values()) { - const memberSym = getMemberSymbol(node.symbol, prop.name)!; - const newProp = cloneTypeForSymbol(memberSym, prop, { - sourceProperty: prop, - model: type, - }); - linkIndirectMember(ctx, node, newProp); - type.properties.set(prop.name, newProp); - } - } + const deps = [isBase, ...spreadTargets]; - if (isBase) { - type.baseModel = isBase.baseModel; - } else if (node.extends) { - type.baseModel = checkClassHeritage(ctx, node, node.extends); - if (type.baseModel) { - copyDeprecation(type.baseModel, type); - } - } + // If any dependency is still creating, return without populating. + // For queue-managed declarations (mapper===undefined), the queue retries. + // For template instantiations (mapper!==undefined), register for post-DFS retry. + if (anyCreating(deps)) { + if (ctx.mapper !== undefined) { + pendingInstantiationRetries.set(type, { ctx, node }); + } + delete type.populating; + return type; + } - if (type.baseModel) { - type.baseModel.derivedModels.push(type); - } - // Hold on to the model type that's being defined so that it - // can be referenced - if (ctx.mapper === undefined) { - type.namespace?.models.set(type.name, type); - } + if (isBase) { + type.sourceModel = isBase; + type.sourceModels.push({ usage: "is", model: isBase, node: node.is }); + decorators.push(...isBase.decorators); + if (isBase.indexer) { + type.indexer = isBase.indexer; + } + + for (const prop of isBase.properties.values()) { + const memberSym = getMemberSymbol(node.symbol, prop.name)!; + const newProp = cloneTypeForSymbol(memberSym, prop, { + sourceProperty: prop, + model: type, + }); + linkIndirectMember(ctx, node, newProp); + type.properties.set(prop.name, newProp); + } + } - // Evaluate the properties after - checkModelProperties(ctx, node, type.properties, type); + if (isBase) { + type.baseModel = isBase.baseModel; + } else if (node.extends) { + type.baseModel = checkClassHeritage(ctx, node, node.extends); + if (type.baseModel) { + copyDeprecation(type.baseModel, type); + } + } - decorators.push(...checkDecorators(ctx, type, node)); + if (type.baseModel) { + if (!type.baseModel.derivedModels.includes(type)) { + type.baseModel.derivedModels.push(type); + } + } - linkMapper(type, ctx.mapper); - finishType(type, { skipDecorators: ctx.hasFlags(CheckFlags.InTemplateDeclaration) }); + // Hold on to the model type that's being defined so that it + // can be referenced + if (ctx.mapper === undefined) { + type.namespace?.models.set(type.name, type); + } - lateBindMemberContainer(type); - lateBindMembers(type); + // Evaluate the properties after + checkModelProperties(ctx, node, type.properties, type); - const indexer = getIndexer(program, type); - if (type.name === "Array" && isInTypeSpecNamespace(type)) { - stdTypes.Array = type; - } else if (type.name === "Record" && isInTypeSpecNamespace(type)) { - stdTypes.Record = type; - } - if (indexer) { - type.indexer = indexer; - } - }, - ); + // If any property is still creating (e.g., late-bound member unresolved), + // defer the model for queue retry. + for (const prop of type.properties.values()) { + if (prop.creating) { + delete type.populating; + return type; + } + } + + + decorators.push(...checkDecorators(ctx, type, node)); + + linkMapper(type, ctx.mapper); + finishType(type, { skipDecorators: ctx.hasFlags(CheckFlags.InTemplateDeclaration) }); + + lateBindMemberContainer(type); + lateBindMembers(type); + + const indexer = getIndexer(program, type); + if (type.name === "Array" && isInTypeSpecNamespace(type)) { + stdTypes.Array = type; + } else if (type.name === "Record" && isInTypeSpecNamespace(type)) { + stdTypes.Record = type; + } + if (indexer) { + type.indexer = indexer; + } return type; } @@ -5523,26 +5576,38 @@ export function createChecker(program: Program, resolver: NameResolver): Checker function checkModelExpression(ctx: CheckContext, node: ModelExpressionNode) { const links = getSymbolLinks(node.symbol); - if (links.declaredType && ctx.mapper === undefined) { - // we're not instantiating this model and we've already checked it - return links.declaredType as any; + // Check for existing type — declaration or cached instantiation + const existingType = ( + ctx.mapper === undefined + ? links.declaredType + : links.instantiations?.get(ctx.mapper.args) + ) as Model | undefined; + + if (existingType && !existingType.creating) { + return existingType; } + // existingType is either undefined (first check) or creating (retry) - const type = initModel(node); + let type: Model; + if (existingType?.creating) { + type = existingType; + } else { + type = initModel(node); + linkType(ctx, links, type); + linkMapper(type, ctx.mapper); + } const properties = type.properties; - linkType(ctx, links, type); - linkMapper(type, ctx.mapper); - ensureResolved( - node.properties - .filter((x) => x.kind === SyntaxKind.ModelSpreadProperty) - .map((x) => checkSpreadTarget(ctx, node, x.target)), - type, - () => { - checkModelProperties(ctx, node, properties, type); - finishType(type, { skipDecorators: ctx.hasFlags(CheckFlags.InTemplateDeclaration) }); - }, - ); + const spreadTargets = node.properties + .filter((x) => x.kind === SyntaxKind.ModelSpreadProperty) + .map((x) => checkSpreadTarget(ctx, node, x.target)); + + if (anyCreating(spreadTargets)) { + return type; // still creating, outer queue item will retry + } + + checkModelProperties(ctx, node, properties, type); + finishType(type, { skipDecorators: ctx.hasFlags(CheckFlags.InTemplateDeclaration) }); return type; } @@ -7068,23 +7133,33 @@ export function createChecker(program: Program, resolver: NameResolver): Checker const links = getSymbolLinksForMember(prop); if (links && links.declaredType && ctx.mapper === undefined) { - return links.declaredType as ModelProperty; + if (!links.declaredType.creating) { + return links.declaredType as ModelProperty; + } + // Property is creating from a previous deferred attempt — fall through to retry } const name = prop.id.sv; - const type: ModelProperty = createType({ - kind: "ModelProperty", - name, - node: prop, - optional: prop.optional, - type: undefined!, - decorators: [], - }); + let type: ModelProperty; + if (links?.declaredType?.creating) { + // Reuse the existing creating property from a deferred attempt + type = links.declaredType as ModelProperty; + type.decorators.length = 0; + } else { + type = createType({ + kind: "ModelProperty", + name, + node: prop, + optional: prop.optional, + type: undefined!, + decorators: [], + }); - // Link the property early so that re-entrant access (e.g., A.a from another model) - // finds this property via checkMemberSym without re-entering checkModelProperty. - if (links) { - linkType(ctx, links, type); + // Link the property early so that re-entrant access (e.g., A.a from another model) + // finds this property via checkMemberSym without re-entering checkModelProperty. + if (links) { + linkType(ctx, links, type); + } } type.type = getTypeForNode(prop.value, ctx); @@ -7108,18 +7183,36 @@ export function createChecker(program: Program, resolver: NameResolver): Checker pendingMemberResolutions.delete(memberExprNode); } if (pending && type.type === errorType) { - ensureResolved([pending.containerType], type, () => { - const result = tryForceResolveLateBoundMember(ctx, pending.baseSym, pending.node); - if (result && "resolved" in result) { - const resolvedType = result.resolved; - if (resolvedType.flags & SymbolFlags.LateBound) { - compilerAssert(resolvedType.type, "Expected late bound symbol to have type"); - type.type = resolvedType.type as Type; - } else { - type.type = getTypeForNode(getSymNode(resolvedType)!, ctx); - } + if (pending.containerType.creating) { + // Container is still being checked — clear caches so the property can be + // re-evaluated when the outer model is retried by the queue. + if (links) { + links.declaredType = undefined; } - }); + // Clear cached sym resolution so re-resolution on retry finds the member. + if (memberExprNode) { + referenceSymCache.delete(memberExprNode); + } + if (prop.value.kind === SyntaxKind.TypeReference) { + referenceSymCache.delete(prop.value); + } else if (prop.value.kind === SyntaxKind.MemberExpression) { + referenceSymCache.delete(prop.value); + } + // Return without finishing — the property stays creating, signaling to the + // outer model that it has unresolved dependencies. + return type; + } + // Container is done but member still didn't resolve — try once more + const result = tryForceResolveLateBoundMember(ctx, pending.baseSym, pending.node); + if (result && "resolved" in result) { + const resolvedType = result.resolved; + if (resolvedType.flags & SymbolFlags.LateBound) { + compilerAssert(resolvedType.type, "Expected late bound symbol to have type"); + type.type = resolvedType.type as Type; + } else { + type.type = getTypeForNode(getSymNode(resolvedType)!, ctx); + } + } } // Detect property-to-property cycles (e.g., A.a -> B.a -> A.a) @@ -7691,22 +7784,30 @@ export function createChecker(program: Program, resolver: NameResolver): Checker ctx = ctx.withFlags(CheckFlags.InTemplateDeclaration); } - if (links.declaredType && ctx.mapper === undefined) { - if (!links.declaredType.creating) { - return links.declaredType as any; - } - if (links.declaredType.populating) { - return links.declaredType as any; - } + const existingType = ( + ctx.mapper === undefined + ? links.declaredType + : links.instantiations?.get(ctx.mapper.args) + ) as Scalar | undefined; + + if (existingType && !existingType.creating) { + return existingType; + } + if (existingType?.populating) { + return existingType; } + if (ctx.mapper === undefined) { checkModifiers(program, node); } checkTemplateDeclaration(ctx, node); let type: Scalar; - if (links.declaredType && links.declaredType.creating && ctx.mapper === undefined) { - type = links.declaredType as Scalar; + if (existingType?.creating) { + type = existingType; + type.decorators.length = 0; + type.constructors.clear(); + type.baseScalar = undefined; } else { type = createType({ kind: "Scalar", @@ -7854,7 +7955,10 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } if (links.declaredType && ctx.mapper === undefined) { - return links.declaredType; + if (!links.declaredType.creating) { + return links.declaredType; + } + // Value type still creating from a previous attempt — fall through to re-evaluate } if (ctx.mapper === undefined) { checkModifiers(program, node); @@ -8029,22 +8133,30 @@ export function createChecker(program: Program, resolver: NameResolver): Checker ctx = ctx.withFlags(CheckFlags.InTemplateDeclaration); } - if (links.declaredType && ctx.mapper === undefined) { - if (!links.declaredType.creating) { - return links.declaredType as Interface; - } - if (links.declaredType.populating) { - return links.declaredType as Interface; - } + const existingType = ( + ctx.mapper === undefined + ? links.declaredType + : links.instantiations?.get(ctx.mapper.args) + ) as Interface | undefined; + + if (existingType && !existingType.creating) { + return existingType; + } + if (existingType?.populating) { + return existingType; } + if (ctx.mapper === undefined) { checkModifiers(program, node); } checkTemplateDeclaration(ctx, node); let interfaceType: Interface; - if (links.declaredType && links.declaredType.creating && ctx.mapper === undefined) { - interfaceType = links.declaredType as Interface; + if (existingType?.creating) { + interfaceType = existingType; + interfaceType.decorators.length = 0; + interfaceType.sourceInterfaces.length = 0; + interfaceType.operations.clear(); } else { interfaceType = createType({ kind: "Interface", @@ -8104,6 +8216,15 @@ export function createChecker(program: Program, resolver: NameResolver): Checker interfaceType.operations.set(key, value); } + // If any member operation is still creating (e.g., its `is` base was not ready), + // defer the interface for queue retry. + for (const op of interfaceType.operations.values()) { + if (op.creating) { + delete interfaceType.populating; + return interfaceType; + } + } + linkMapper(interfaceType, ctx.mapper); if (ctx.mapper === undefined) { @@ -8156,22 +8277,29 @@ export function createChecker(program: Program, resolver: NameResolver): Checker ctx = ctx.withFlags(CheckFlags.InTemplateDeclaration); } - if (links.declaredType && ctx.mapper === undefined) { - if (!links.declaredType.creating) { - return links.declaredType as Union; - } - if (links.declaredType.populating) { - return links.declaredType as Union; - } + const existingType = ( + ctx.mapper === undefined + ? links.declaredType + : links.instantiations?.get(ctx.mapper.args) + ) as Union | undefined; + + if (existingType && !existingType.creating) { + return existingType; + } + if (existingType?.populating) { + return existingType; } + if (ctx.mapper === undefined) { checkModifiers(program, node); } checkTemplateDeclaration(ctx, node); let unionType: Union; - if (links.declaredType && links.declaredType.creating && ctx.mapper === undefined) { - unionType = links.declaredType as Union; + if (existingType?.creating) { + unionType = existingType; + unionType.decorators.length = 0; + unionType.variants.clear(); } else { const variants = createRekeyableMap(); unionType = createType({ @@ -8482,11 +8610,6 @@ export function createChecker(program: Program, resolver: NameResolver): Checker function markAsChecked(type: T) { if (!type.creating) return; delete type.creating; - const pending = waitingForResolution.get(type); - if (pending) { - pending.forEach(([_, resolution]) => resolution()); - } - waitingForResolution.delete(type); } function getLiteralType( @@ -8666,6 +8789,11 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } if (type.isFinished) { clone = finishType(clone); + } else if (!type.creating) { + // Source type is fully checked (creating cleared by markAsChecked) but not isFinished + // (e.g., checked under InTemplateDeclaration with skipDecorators=true). + // Clear creating on the clone so it's not mistaken for a partially-built type. + delete clone.creating; } compilerAssert(clone.kind === type.kind, "cloneType must not change type kind"); return clone; diff --git a/packages/compiler/test/checker/operations.test.ts b/packages/compiler/test/checker/operations.test.ts index ee6207eab0a..1848a5e234f 100644 --- a/packages/compiler/test/checker/operations.test.ts +++ b/packages/compiler/test/checker/operations.test.ts @@ -420,3 +420,35 @@ describe("ensure the parameters are fully resolved before marking the operation expect(myOp.parameters.properties.has("prop")).toBe(true); }); }); + +describe("operation with nested template spread instantiation", () => { + it("resolves parameters through nested template spreads", async () => { + const { test } = await Tester.compile(t.code` + model A { x: string; } + model B { ...A; } + op base

(...P): void; + op derived is base>; + op ${t.op("test")} is derived; + `); + strictEqual(test.parameters.properties.size, 1); + const x = test.parameters.properties.get("x")!; + strictEqual(x.type.kind, "Scalar"); + strictEqual(x.type.name, "string"); + }); + + it("resolves parameters in interface operations with nested template spreads", async () => { + const { Ops } = await Tester.compile(t.code` + model A { x: string; } + model B { ...A; } + op base

(...P): void; + interface ${t.interface("Ops")} { + op1 is base>; + } + `); + const op1 = Ops.operations.get("op1")!; + strictEqual(op1.parameters.properties.size, 1); + const x = op1.parameters.properties.get("x")!; + strictEqual(x.type.kind, "Scalar"); + strictEqual(x.type.name, "string"); + }); +});