diff --git a/packages/compiler/src/core/check-queue.ts b/packages/compiler/src/core/check-queue.ts new file mode 100644 index 00000000000..acfd714b8a5 --- /dev/null +++ b/packages/compiler/src/core/check-queue.ts @@ -0,0 +1,444 @@ +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. + */ +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; + + /** 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 + */ + 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); + } + + /** + * Iterate over all registered items. + */ + allItems(): IterableIterator { + return this.#items.values(); + } + + /** + * 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'`, + ); + + 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 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. + } + + /** + * Mark an item as errored. + */ + markError(item: CheckItem): void { + compilerAssert( + 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(); + + // 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, + * 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 + */ + processUntilFixpoint(check: (item: CheckItem) => void): CheckQueueResult { + const completed: CheckItem[] = []; + const errored: CheckItem[] = []; + + // 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; + } + } + + // 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) { + 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; + } + } + + // 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 cd23221098d..b8837eb5706 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, DeferralSignal } 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, @@ -495,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, @@ -513,6 +514,62 @@ export function createChecker(program: Program, resolver: NameResolver): Checker program.reportDiagnostic(x); }; + /** + * 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 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(); @@ -531,6 +588,50 @@ 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(); + + /** + * 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(); @@ -652,12 +753,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; } /** @@ -1679,9 +1779,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({ @@ -1695,12 +1799,16 @@ 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?.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 { @@ -1758,10 +1866,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?.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?.creating && symbolLinks.declaredType.populating) { baseType = symbolLinks.declaredType; } else { if (sym.flags & SymbolFlags.Member) { @@ -1819,9 +1932,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; } /** @@ -1891,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) { @@ -2042,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)]); + // Check for existing type — declaration or cached instantiation + const existingType = ( + ctx.mapper === undefined + ? links.declaredType + : links.instantiations?.get(ctx.mapper.args) + ) as Model | undefined; - ensureResolved( - options.map(([, type]) => type), - intersection, - () => { - const type = mergeModelTypes(ctx, node.symbol, node, options, intersection); - linkType(ctx, links, type); - finishType(intersection); - }, - ); - return intersection; - } - - 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( @@ -2592,12 +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) { - // we're not instantiating this operation and we've already checked it - 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); } @@ -2643,18 +2739,30 @@ 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 (existingType?.creating) { + operationType = existingType; + // Clear for re-population on retry + operationType.decorators.length = 0; + operationType.sourceOperation = undefined; + } 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) { + operationType.populating = true; } const parent = node.parent!; @@ -2673,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(); @@ -2705,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); @@ -2715,6 +2835,8 @@ export function createChecker(program: Program, resolver: NameResolver): Checker namespace?.operations.set(name, operationType); } + if (symbol) { + } return operationType; } @@ -3632,7 +3754,18 @@ 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); + // 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); @@ -4327,10 +4460,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( @@ -4340,16 +4473,21 @@ 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 }; } } @@ -4413,22 +4551,35 @@ 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). + // 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) { + 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. Return pending info for deferred resolution. + if (links.declaredType?.creating) { + return { pending: { containerType: links.declaredType, node, baseSym: base } }; + } + // Force-check the container type to populate its members. pendingResolutions.start(base, ResolutionKind.Type); const type = checkTypeReferenceSymbol(ctx, base, node.base); @@ -4452,7 +4603,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) { @@ -4844,30 +4996,339 @@ 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 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 the centralized force-check in reference + // resolution and pendingResolutions tracking for mutual extends chains. + 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. + // We snapshot pendingResolutions before each item so we can restore on deferral. + const queueResult = checkQueue.processUntilFixpoint((item) => { + pendingResolutions.snapshot(); + try { + pendingInstantiationRetries.clear(); + checkNode(CheckContext.DEFAULT, item.node, undefined); + + // 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); + } + // If still creating, leave as InProgress — processUntilFixpoint + // will treat it as deferred when the iteration completes. + 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. + // 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); + 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) { + checkNode(CheckContext.DEFAULT, statement, undefined); } internalDecoratorValidation(); - assertNoPendingResolutions(); runPostValidators(postCheckValidators); } - function assertNoPendingResolutions() { - if (waitingForResolution.size === 0) { - return; + /** + * 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); } + } - 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); + /** + * 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; + } + + /** + * 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. + */ + 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 checkDuplicateSymbols() { @@ -4959,27 +5420,54 @@ export function createChecker(program: Program, resolver: NameResolver): Checker ctx = ctx.withFlags(CheckFlags.InTemplateDeclaration); } - 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 — 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); - const decorators: DecoratorApplication[] = []; - 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/creating type if present; otherwise create a new type. + let type: 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", + 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 + type.populating = true; if (node.symbol.members) { const members = resolver.getAugmentedSymbolTable(node.symbol.members); @@ -4993,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; + } - // Evaluate the properties after - checkModelProperties(ctx, node, type.properties, type); + 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); + } + } - decorators.push(...checkDecorators(ctx, type, node)); + if (isBase) { + type.baseModel = isBase.baseModel; + } else if (node.extends) { + type.baseModel = checkClassHeritage(ctx, node, node.extends); + if (type.baseModel) { + copyDeprecation(type.baseModel, type); + } + } - linkMapper(type, ctx.mapper); - finishType(type, { skipDecorators: ctx.hasFlags(CheckFlags.InTemplateDeclaration) }); + if (type.baseModel) { + if (!type.baseModel.derivedModels.includes(type)) { + type.baseModel.derivedModels.push(type); + } + } - lateBindMemberContainer(type); - lateBindMembers(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); + } + + // Evaluate the properties after + checkModelProperties(ctx, node, type.properties, type); + + // 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; + } + } - 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; - } - }, - ); + + 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; } @@ -5069,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; } @@ -6614,27 +7133,88 @@ 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); + // 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. + // 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) { + 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) if (hasPropertyTypeCycle(type)) { if (ctx.mapper === undefined) { @@ -7075,7 +7655,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, }); @@ -7118,6 +7698,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; } @@ -7179,31 +7781,48 @@ 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 - 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); - const decorators: DecoratorApplication[] = []; + let type: Scalar; + if (existingType?.creating) { + type = existingType; + type.decorators.length = 0; + type.constructors.clear(); + type.baseScalar = undefined; + } 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); + type.populating = true; if (node.extends) { type.baseScalar = checkScalarExtends(ctx, node, node.extends); @@ -7336,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); @@ -7444,15 +8066,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 && !links.type.populating)) { 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: [], + }); + } + + enumType.populating = true; const memberNames = new Set(); @@ -7501,30 +8130,47 @@ 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 interface and we've already checked it - 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); - const interfaceType: Interface = createType({ - kind: "Interface", - decorators: [], - node, - namespace: getParentNamespaceType(node), - sourceInterfaces: [], - operations: createRekeyableMap(), - name: node.id.sv, - }); + let interfaceType: Interface; + if (existingType?.creating) { + interfaceType = existingType; + interfaceType.decorators.length = 0; + interfaceType.sourceInterfaces.length = 0; + interfaceType.operations.clear(); + } else { + interfaceType = createType({ + kind: "Interface", + decorators: [], + node, + namespace: getParentNamespaceType(node), + sourceInterfaces: [], + operations: createRekeyableMap(), + name: node.id.sv, + }); + linkType(ctx, links, interfaceType); + } - linkType(ctx, links, interfaceType); + interfaceType.populating = true; interfaceType.decorators = checkDecorators(ctx, interfaceType, node); @@ -7570,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) { @@ -7622,33 +8277,51 @@ export function createChecker(program: Program, resolver: NameResolver): Checker ctx = ctx.withFlags(CheckFlags.InTemplateDeclaration); } - if (links.declaredType && ctx.mapper === undefined) { - // we're not instantiating this union and we've already checked it - 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); - 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 (existingType?.creating) { + unionType = existingType; + unionType.decorators.length = 0; + unionType.variants.clear(); + } 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); + } + + unionType.populating = true; unionType.decorators = checkDecorators(ctx, unionType, node); - checkUnionVariants(ctx, unionType, node, variants); + checkUnionVariants(ctx, unionType, node, unionType.variants as Map); linkMapper(unionType, ctx.mapper); @@ -7937,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( @@ -8121,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; @@ -8752,6 +9425,7 @@ enum ResolutionKind { class PendingResolutions { #data = new Map>(); + #snapshots: Map>[] = []; start(symId: Sym, kind: ResolutionKind) { let existing = this.#data.get(symId); @@ -8776,6 +9450,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/src/core/messages.ts b/packages/compiler/src/core/messages.ts index 457ae774915..ed8819efdd3 100644 --- a/packages/compiler/src/core/messages.ts +++ b/packages/compiler/src/core/messages.ts @@ -1069,6 +1069,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: { 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/circular-resolution.test.ts b/packages/compiler/test/checker/circular-resolution.test.ts new file mode 100644 index 00000000000..09428c1d388 --- /dev/null +++ b/packages/compiler/test/checker/circular-resolution.test.ts @@ -0,0 +1,46 @@ +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", () => { + 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; } + `); + // 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 () => { + 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([]); + }); + + 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"); + }); +}); 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"); + }); +}); 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..11beae5c264 --- /dev/null +++ b/packages/compiler/test/core/check-queue.test.ts @@ -0,0 +1,601 @@ +import { describe, expect, it } from "vitest"; +import { + CheckItemStatus, + CheckQueue, + CheckResult, + DeferralSignal, + 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. + // B stays deferred; the fixpoint loop will re-queue it. + queue.dequeue(); // B + queue.markInProgress(itemB); + queue.markDeferred(itemB, [symA]); + + expect(itemB.status).toBe(CheckItemStatus.Deferred); + }); + }); + + 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); + }); + }); + + 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"); + }); + }); +});