diff --git a/packages/1-framework/0-foundation/utils/src/exports/result.ts b/packages/1-framework/0-foundation/utils/src/exports/result.ts index ac03909d07c5..f7dd1dc593bd 100644 --- a/packages/1-framework/0-foundation/utils/src/exports/result.ts +++ b/packages/1-framework/0-foundation/utils/src/exports/result.ts @@ -1,2 +1,2 @@ export type { NotOk, Ok, Result } from '../result'; -export { notOk, ok, okVoid } from '../result'; +export { and, notOk, ok, okVoid, or } from '../result'; diff --git a/packages/1-framework/0-foundation/utils/src/result.ts b/packages/1-framework/0-foundation/utils/src/result.ts index 72c4a72d34fc..55c534052d41 100644 --- a/packages/1-framework/0-foundation/utils/src/result.ts +++ b/packages/1-framework/0-foundation/utils/src/result.ts @@ -145,3 +145,22 @@ const OK_VOID: Ok = ResultImpl.ok(undefined); export function okVoid(): Ok { return OK_VOID; } + +export function or( + left: Result, + right: Result, +): Result { + if (left.ok) return left; + if (right.ok) return right; + if (left.failure.length === 0 || right.failure.length === 0) return notOk([]); + return notOk([...left.failure, ...right.failure]); +} + +export function and( + left: Result, + right: Result, +): Result { + if (left.ok) return right.ok ? okVoid() : notOk(right.failure); + if (right.ok) return notOk(left.failure); + return notOk([...left.failure, ...right.failure]); +} diff --git a/packages/1-framework/0-foundation/utils/test/result.test.ts b/packages/1-framework/0-foundation/utils/test/result.test.ts index 104cd70beec5..72fb334e7595 100644 --- a/packages/1-framework/0-foundation/utils/test/result.test.ts +++ b/packages/1-framework/0-foundation/utils/test/result.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { type NotOk, notOk, type Ok, ok, okVoid } from '../src/result'; +import { and, type NotOk, notOk, type Ok, ok, okVoid, or } from '../src/result'; describe('result', () => { describe('ok()', () => { @@ -92,4 +92,62 @@ describe('result', () => { ); }); }); + + describe('and()', () => { + it('is ok when both sides are ok', () => { + expect(and(ok(1), ok('two'))).toMatchObject({ ok: true }); + }); + + it('takes the failure when the right side fails', () => { + expect(and(ok(1), notOk(['right']))).toMatchObject({ ok: false, failure: ['right'] }); + }); + + it('takes the failure when the left side fails', () => { + expect(and(notOk(['left']), ok(1))).toMatchObject({ ok: false, failure: ['left'] }); + }); + + it('keeps both sides details in order when both fail', () => { + expect(and(notOk(['left']), notOk(['right']))).toMatchObject({ + ok: false, + failure: ['left', 'right'], + }); + }); + + it('keeps a detail-less failure a failure', () => { + expect(and(ok(1), notOk([]))).toMatchObject({ ok: false, failure: [] }); + }); + }); + + describe('or()', () => { + it('takes the left value when both sides are ok', () => { + expect(or(ok('left'), ok('right'))).toMatchObject({ ok: true, value: 'left' }); + }); + + it('takes the right value when only the right side is ok', () => { + expect(or(notOk(['left']), ok('right'))).toMatchObject({ ok: true, value: 'right' }); + }); + + it('takes the left value when only the left side is ok', () => { + expect(or(ok('left'), notOk(['right']))).toMatchObject({ ok: true, value: 'left' }); + }); + + it('pools both sides details in order when both fail loudly', () => { + expect(or(notOk(['left']), notOk(['right']))).toMatchObject({ + ok: false, + failure: ['left', 'right'], + }); + }); + + it('lets a detail-less failure absorb the left side', () => { + expect(or(notOk([]), notOk(['right']))).toMatchObject({ ok: false, failure: [] }); + }); + + it('lets a detail-less failure absorb the right side', () => { + expect(or(notOk(['left']), notOk([]))).toMatchObject({ ok: false, failure: [] }); + }); + + it('stays detail-less when both sides are detail-less', () => { + expect(or(notOk([]), notOk([]))).toMatchObject({ ok: false, failure: [] }); + }); + }); }); diff --git a/packages/1-framework/2-authoring/psl-parser/README.md b/packages/1-framework/2-authoring/psl-parser/README.md index 9c05d230c892..be7f90496704 100644 --- a/packages/1-framework/2-authoring/psl-parser/README.md +++ b/packages/1-framework/2-authoring/psl-parser/README.md @@ -18,6 +18,7 @@ In the provider-based authoring model, PSL providers call `parse` to obtain the - Parse attributes generically (namespaced or not), including optional argument lists; target semantics live downstream. - Emit attribute nodes with explicit target (`field` / `model` / `namedType`), attribute name, and parsed argument list with spans. - Build a scope-aware symbol table from the CST, including duplicate-declaration diagnostics, named-type binding resolution, and descriptor-driven generic-block reconstruction. +- Answer "which declaration does this name denote" for every consumer, once, through the binder — the sole voice of resolution failures. ## Attributes (generic parsing boundary) @@ -40,6 +41,8 @@ Interpretation/validation (for example `@internal/sql-contract-psl`) is responsi - `parse(source, filename, options?)` in `src/parse.ts` (also at `@internal/psl-parser/syntax`) — the CST parser: returns the `DocumentAst`, a `PslSources` registry for resolving nodes to their named `SourceFile`, and syntactic diagnostics. The recursive-descent / lossless-CST path supersedes the legacy `parsePslDocument`. - `buildSymbolTable({ documents, sources, pslBlockDescriptors })` in `src/symbol-table.ts` — a pure, fault-tolerant pass over an ordered `readonly DocumentAst[]` that returns `{ symbolTable, diagnostics }`, with a scope-aware `SymbolTable` (top-level namespaces / named types / blocks / models / composite-types as keyed records discriminated by `kind`, namespace members and block fields nested under their owner, declaration symbols carrying their CST AST `node` plus declaration `span`, and namespace symbols retaining every authored node and span in `declarations`) plus its own source-associated diagnostics (the same `ParseDiagnostic` shape as parser errors: `filename`, `code`, `message`, and a file-local `range`). Duplicate names are first-wins across documents and kinds within one scope (`PSL_DUPLICATE_DECLARATION`); repeated namespaces reopen the same scope, retaining distinct members and diagnosing duplicate member names across declarations and documents. Every supplied document root must be registered in the shared `sources`, even for empty documents. An empty collection returns an empty scope. Single-file callers pass `documents: [document]`; no file discovery is performed. `pslBlockDescriptors` is supplied from authoring contributions so generic/extension blocks can be reconstructed once into `BlockSymbol.block`. The pass also **resolves** the field/named-type read set once: each `FieldSymbol` carries the split type (`typeName`/`typeNamespaceId`/`typeContractSpaceId`), `optional`/`list`, `typeConstructor?`, rendered `attributes`, and `malformedType?` (set, with a `PSL_INVALID_QUALIFIED_TYPE` diagnostic, when the type is over-qualified); `NamedTypeSymbol` carries the resolved binding (`baseType`/`typeConstructor`/`isConstructor`). Interpreters consume this resolved shape directly — there is no per-package field/attribute view layer. +- `createBinder({ sources, symbolTable, typeConstructors, attributeSpecs })` in `src/binder.ts` — the name resolver. It returns `{ binder, diagnostics }`, mirroring `buildSymbolTable`: resolution runs eagerly over the symbol table at creation, and the returned diagnostics are complete when the factory returns. Queries are map reads and say nothing about when resolution ran. See [the binder section below](#binder). +- `referencedModel` / `modelAttributeContext` / `fieldAttributeContext` in `src/binder-context.ts` — build the ADR 249 parse-time attribute contexts from a binder. `resolveReferencedModel` becomes one map read (`symbolForNode(typeReferenceNode(field))`, narrowed to a model) instead of a resolver each consumer supplies for itself, and the context carries the binder itself so the reference combinators stop raising their own existence diagnostics. See [attribute contexts and the single voice](#attribute-contexts-and-the-single-voice). - `readResolvedAttribute(s)` / `readResolvedConstructorCall` + the span maps (`nodePslSpan`, `keywordPslSpan`) in `src/resolve.ts` — the shared CST read helpers `buildSymbolTable` uses and that consumers (e.g. enum-block reconstruction) reuse, with `PslSpan` spans derived from `PslSources`. Pure coordinate conversion lives on `SourceFile`: resolve the file with `sources.sourceFileFor(node.syntax)` and call `sourceFile.rangeToPslSpan(range)`, `sourceFile.offsetToPslPosition(offset)`, or `sourceFile.pslSpanToRange(span)`. - `reconstructExtensionBlock` / `findBlockDescriptor` / `validateExtensionBlockFromSymbol` in `src/extension-block.ts` — reconstruct a @@ -52,6 +55,91 @@ Interpretation/validation (for example `@internal/sql-contract-psl`) is responsi - `@internal/psl-parser/syntax` - `@internal/psl-parser/tokenizer` +## Binder + +The binder is the single authority on which declaration a name denotes. Every consumer asks it rather than scanning the symbol table itself, so one scoping rule and one diagnostic voice serve the SQL and Mongo interpreters, the attribute-spec contexts, and the language server alike. + +```ts +const { binder, diagnostics } = createBinder({ + sources, + symbolTable, + typeConstructors, + attributeSpecs, +}); + +binder.declaredSymbol(modelDeclarationNode); // declaration node -> the symbol it declares +binder.symbolForNode(typeReferenceNode(field)); // reference node -> what it denotes +``` + +The two questions are kept apart deliberately, as Roslyn separates `GetDeclaredSymbol` from `GetSymbolInfo`: `declaredSymbol` answers for the node that *introduces* a name, `symbolForNode` for a node that *mentions* one. + +### Scope chain + +An unqualified reference resolves in exactly this order: + +1. the **declaring namespace** — the namespace the referring declaration itself sits in; +2. the **top level**; +3. the **contributed types** — the type-position names the configured target and its extensions contribute, built from the injected `typeConstructors` registry. + +That third scope holds names nobody declared in a schema: the scalars, type constructors and field presets a target and its composed extension packs bring, in contrast with the models, composite types and named types the documents themselves declare. + +**Sibling namespaces are never consulted.** A schema declaration shadowing a contributed type (a `model Uuid` over a contributed `Uuid`) wins **silently** — shadowing is not a diagnostic. A qualified `ns.Name` is looked up in that PSL namespace, then in the type-constructor namespace of the same name (`pgvector.Vector`), and nowhere else. + +Qualified references resolve at whole-`QualifiedName` granularity: in `app.Item`, the segments `app` and `Item` do not resolve separately — the one `QualifiedName` node carries the one resolution. + +### Resolution kinds + +`symbolForNode` returns `undefined` for a node that is not a reference the binder tracks, and otherwise one of: + +| Kind | Denotes | +| --- | --- | +| `model` / `compositeType` / `namedType` / `block` | a user declaration; `block` covers `enum` and every other descriptor-driven block, which may be a field's type but never an `@@base` target | +| `contributedType` | a scalar, type constructor or field preset from the injected registry | +| `field` | a field named by an attribute argument (`@@index([a])`, `@relation(fields:, references:)`) | +| `attributeSpec` | the spec an attribute's name denotes | +| `crossSpace` | a reference into another contract space, resolvable only where that space is known — an explicit kind, and deliberately **not** a diagnostic | +| `unresolved` | nothing of that name is in scope; the binder has emitted a diagnostic for it | + +A field whose type is malformed (`malformedType`) is skipped entirely: no resolution, no diagnostic, no cascade. + +### Diagnostics + +The binder owns resolution failures and nothing else. Failures come back under `PSL_UNRESOLVED_REFERENCE` (an unknown type, field, or entity name) and `PSL_UNRESOLVED_ATTRIBUTE` (an unknown attribute name), located by filename and range through `PslSources`. Converted consumers adopt these codes and **never re-emit their own** — the same rule the symbol table set for `PSL_DUPLICATE_DECLARATION`. Shape failures (arity, argument type, malformed literals) remain the spec combinators' voice; they are not resolution. References bind to first-wins symbols, and the binder never restates a duplicate-declaration diagnostic the symbol table already made. + +### Attribute contexts and the single voice + +`modelAttributeContext` / `fieldAttributeContext` put the whole `Binder` on the parse-time context. `ModelAttributeCtx` **requires** it, so every context that can reach a reference combinator carries one by construction — there is no binder-less path to fall back to and no dual behavior to reason about. + +`fieldRef` and `referencedFieldRef` resolve solely through it: they read the argument's resolution out of the binder (`symbolForNode(argumentNode)` — a map read of results already computed at creation, never a second resolution) and + +- return the bound field's name when the binder resolved a field; +- return the written name for a `crossSpace` reference, which is deferred by design; +- **fail the argument, carrying no diagnostics of their own**, when the binder bound nothing or bound something that is not a field. The binder has already reported that name as `PSL_UNRESOLVED_REFERENCE`, so a second complaint would be a duplicate. A failed argument fails its attribute rather than quietly yielding a short list or a missing key. + +`entityRef` is unchanged: it never checked existence, so it still returns the written name and leaves the verdict to the binder's diagnostics and to downstream lowering. + +Shape and arity stay the combinator's voice — "Expected a field name", "Expected a list of field name", wrong argument counts. Only *existence* belongs to the binder. The split is the point: resolution is the binder's, shape is the spec's, and no schema error is ever reported twice. + +`AttributeCtx` itself stays binder-free: block attributes are interpreted during `buildSymbolTable`, before a binder can exist, and no block attribute takes a reference argument. + +This lookup rests on red-node identity (below): the combinator receives the very `SyntaxNode` the binder keyed its result under. + +**Precondition, enforced.** The binder on the context must be built over the *same snapshot* — the same symbol table and `PslSources` — and the same `typeConstructors` / `attributeSpecs` registries as the interpretation consuming it. + +The binder records what it examined, including its failures: a reference it could not resolve gets an explicit `unresolved` entry rather than no entry at all. So for a node in reference position, an absent entry cannot mean "the author made a mistake" — it can only mean this binder never saw this tree. The reference combinators therefore **throw an `InternalError`** on a missing entry instead of quietly skipping the check. A mismatched binder fails loudly at the first reference argument rather than silently forgoing existence checking across the whole document. + +Fields whose type is malformed are the one deliberate absence: the binder does not examine them, and no combinator reads a type node. + +### Snapshot lifetime + +The binder is snapshot-scoped: an edit produces a new document, symbol table, and binder, and the old set is dropped whole. There is no invalidation protocol. + +The contributed-type scope is the exception — it is configuration-derived, not document-derived, and is shared across snapshots. That sharing is keyed by the **object identity of the `typeConstructors` registry** the caller passes: pass the same registry object and two binders share one scope; rebuild the registry on every parse and sharing silently degrades to a per-snapshot scope. Resolution stays correct either way, but the guarantee is gone, so hold the registry alongside the configuration it came from. + +### Node identity + +Binder side tables are keyed by red `SyntaxNode` identity, which the red layer guarantees within a snapshot: `SyntaxNode.childAt(index)` caches each child wrapper in its parent's slot on first access, so every traversal reaching the same position — `children()`, `firstChild`, `nextSibling`, `ancestors()`, `tokenAtOffset`, `coveringElement` — returns the identical object (Roslyn's `GetRed` design, single-threaded). Red nodes are therefore sound `WeakMap` keys. Green nodes are not: they are position-free and shareable, so a green-keyed cache would go stale silently. Never key a cache on a green node, and never use a span as a cross-snapshot key. + ## Architecture ```mermaid @@ -64,9 +152,16 @@ flowchart LR Descriptors[pslBlockDescriptors] --> Symbols Symbols --> SymbolTable[SymbolTable] Symbols --> SymbolDiagnostics[Symbol-table diagnostics] + SymbolTable --> Binder[createBinder] + TypeConstructors[typeConstructors] --> Binder + AttributeSpecs[attributeSpecs] --> Binder + Binder --> BinderQueries[declaredSymbol / symbolForNode] + Binder --> BinderDiagnostics[Resolution diagnostics] SymbolTable --> Interpreter[Target PSL interpreter] + BinderQueries --> Interpreter ParseDiagnostics --> Provider[Provider diagnostic seeding] SymbolDiagnostics --> Provider + BinderDiagnostics --> Provider ``` ## Package Boundaries diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/entity-ref.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/entity-ref.ts index 8b01564bf2e6..aa0339681a77 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/entity-ref.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/entity-ref.ts @@ -1,3 +1,4 @@ +import { InternalError } from '@internal/utils/internal-error'; import { notOk, ok, type Result } from '@internal/utils/result'; import type { PslDiagnostic } from '../../diagnostic'; import type { @@ -5,14 +6,20 @@ import type { EntitySelector, ResolvedEntityReference, } from '../../entity-reference'; -import { resolveEntityReference } from '../../entity-reference'; +import { describeResolution, entityReference, matchesSelector } from '../../entity-reference'; import { IdentifierAst } from '../../syntax/ast/identifier'; -import type { AttributeCtx, EntityRefArgType } from '../types'; +import type { EntityRefArgType, ModelAttributeCtx } from '../types'; import { leafDiagnostic } from './diagnostic'; +function unbound(name: string): never { + throw new InternalError( + `The binder on this attribute context bound nothing for "${name}". A reference argument is always examined, so the binder must be built over the same snapshot - the same symbol table and sources - as the interpretation consuming it.`, + ); +} + export function entityRef( expected: S, -): EntityRefArgType, AttributeCtx> { +): EntityRefArgType, ModelAttributeCtx> { const label = `${expected.kind === 'block' ? expected.keyword : expected.kind} reference`; return { kind: 'entityRef', @@ -26,28 +33,19 @@ export function entityRef( if (name === undefined) { return notOk([leafDiagnostic(ctx, arg, `Expected ${label}`)]); } - const reference = resolveEntityReference(arg, name, ctx.symbols); - if (reference === undefined) { - return notOk([leafDiagnostic(ctx, arg, `Unknown ${label} "${name}"`)]); - } - if (!matchesSelector(reference, expected)) { - const actual = reference.declaration; - const kind = actual.kind === 'block' ? actual.keyword : actual.kind; - return notOk([leafDiagnostic(ctx, arg, `Expected ${label} "${name}", found ${kind}`)]); + const resolution = ctx.binder.symbolForNode(arg.syntax) ?? unbound(name); + if (resolution.kind === 'unresolved') return notOk([]); + const reference = entityReference(resolution); + if (reference === undefined || !matchesSelector(reference, expected)) { + return notOk([ + leafDiagnostic( + ctx, + arg, + `Expected ${label} "${name}", found ${describeResolution(resolution)}`, + ), + ]); } return ok(reference); }, }; } - -function matchesSelector( - reference: ResolvedEntityReference, - expected: S, -): reference is ResolvedEntityReference> { - const declaration = reference.declaration; - return ( - declaration.kind === expected.kind && - (expected.kind !== 'block' || - (declaration.kind === 'block' && declaration.keyword === expected.keyword)) - ); -} diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/field-ref.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/field-ref.ts index 310e53872461..749bcac09f30 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/field-ref.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/field-ref.ts @@ -1,10 +1,9 @@ +import { InternalError } from '@internal/utils/internal-error'; import { notOk, ok, type Result } from '@internal/utils/result'; import type { PslDiagnostic } from '../../diagnostic'; -import type { ModelSymbol } from '../../symbol-table'; import type { ExpressionAst } from '../../syntax/ast/expressions'; import { IdentifierAst } from '../../syntax/ast/identifier'; import type { - AttributeCtx, FieldAttributeCtx, FieldRefArgType, ModelAttributeCtx, @@ -14,8 +13,7 @@ import { leafDiagnostic } from './diagnostic'; function parseFieldName( arg: ExpressionAst, - ctx: AttributeCtx, - model: ModelSymbol | undefined, + ctx: ModelAttributeCtx, ): Result { const identifier = IdentifierAst.cast(arg.syntax); if (identifier === undefined) { @@ -25,20 +23,22 @@ function parseFieldName( if (name === undefined) { return notOk([leafDiagnostic(ctx, arg, 'Expected a field name')]); } - // A referenced model in another space can't be resolved here (resolveReferencedModel returns undefined); skip the existence check — it runs where that model is known. - if (model !== undefined && !Object.hasOwn(model.fields, name)) { - return notOk([ - leafDiagnostic(ctx, arg, `Field "${name}" does not exist on model "${model.name}"`), - ]); + const resolution = ctx.binder.symbolForNode(arg.syntax); + if (resolution === undefined) { + throw new InternalError( + `The binder on this attribute context bound nothing for "${name}". A reference argument is always examined, so the binder must be built over the same snapshot - the same symbol table and sources - as the interpretation consuming it.`, + ); } - return ok(name); + if (resolution.kind === 'field') return ok(resolution.symbol.name); + if (resolution.kind === 'crossSpace') return ok(name); + return notOk([]); } export function fieldRef(): FieldRefArgType { return { kind: 'fieldRef', label: 'field name', - parse: (arg, ctx) => parseFieldName(arg, ctx, ctx.selfModel), + parse: (arg, ctx) => parseFieldName(arg, ctx), }; } @@ -46,6 +46,6 @@ export function referencedFieldRef(): ReferencedFieldRefArgType parseFieldName(arg, ctx, ctx.resolveReferencedModel()), + parse: (arg, ctx) => parseFieldName(arg, ctx), }; } diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/list.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/list.ts index 433bb986908e..a73436c5ca37 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/list.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/list.ts @@ -1,4 +1,4 @@ -import { notOk, ok, type Result } from '@internal/utils/result'; +import { and, notOk, ok, okVoid, type Result } from '@internal/utils/result'; import type { PslDiagnostic } from '../../diagnostic'; import { ArrayLiteralAst, type ExpressionAst } from '../../syntax/ast/expressions'; import type { ArgType, AttributeCtx, ListArgType } from '../types'; @@ -28,26 +28,27 @@ export function list( if (literal === undefined) { return notOk([leafDiagnostic(ctx, arg, `Expected a list of ${of.label}`)]); } - const diagnostics: PslDiagnostic[] = []; const parsed: { node: ExpressionAst; value: T }[] = []; + let outcome: Result = okVoid(); let count = 0; for (const element of literal.elements()) { count += 1; const result = of.parse(element, ctx); if (result.ok) parsed.push({ node: element, value: result.value }); - else diagnostics.push(...result.failure); + outcome = and(outcome, result); } if (!allowEmpty && count === 0) { - diagnostics.push(leafDiagnostic(ctx, arg, 'Expected a non-empty list')); + outcome = and(outcome, notOk([leafDiagnostic(ctx, arg, 'Expected a non-empty list')])); } if (unique) { const seen = new Set(); for (const { node, value } of parsed) { - if (seen.has(value)) diagnostics.push(leafDiagnostic(ctx, node, 'Duplicate list entry')); - else seen.add(value); + if (seen.has(value)) { + outcome = and(outcome, notOk([leafDiagnostic(ctx, node, 'Duplicate list entry')])); + } else seen.add(value); } } - if (diagnostics.length > 0) return notOk(diagnostics); + if (!outcome.ok) return notOk(outcome.failure); return ok(parsed.map((entry) => entry.value)); }, }; diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/one-of.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/one-of.ts index 5f05b653624a..f442ab974ab0 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/one-of.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/one-of.ts @@ -1,8 +1,9 @@ import { blindCast } from '@internal/utils/casts'; -import { notOk, ok, type Result } from '@internal/utils/result'; +import { notOk, or, type Result } from '@internal/utils/result'; import type { PslDiagnostic } from '../../diagnostic'; import type { AnyArgType, + ArgType, ContextForRequirement, CtxOf, OneOfArgType, @@ -22,21 +23,19 @@ export function oneOf( label, alternatives: alts, parse: (arg, ctx): Result, readonly PslDiagnostic[]> => { - for (const alt of alts) { - const parse = blindCast< - (arg: Parameters[0], ctx: ParseContext) => ReturnType, - 'ParseContext is computed as the strongest context required by all alternatives, so it is assignable to every alternative parse context even though TypeScript cannot express that relationship while iterating the heterogeneous tuple.' - >(alt.parse); - const result = parse(arg, ctx); - if (result.ok) { - return ok( - blindCast< - OutOf, - 'The matched value comes from an alternative whose output type is a member of the union, but iterating the tuple widens each element to ArgType, erasing that relationship.' - >(result.value), - ); - } + type Alternative = ArgType, ParseContext>; + const [head, ...tail] = blindCast< + readonly [Alternative, ...Alternative[]], + 'ParseContext is the strongest context every alternative requires and each alternative output is a member of the union, but iterating a heterogeneous tuple erases both relationships.' + >(alts); + let rejection = head.parse(arg, ctx); + if (rejection.ok) return rejection; + for (const alt of tail) { + const result = alt.parse(arg, ctx); + if (result.ok) return result; + rejection = or(rejection, result); } + if (!rejection.ok && rejection.failure.length === 0) return notOk([]); return notOk([leafDiagnostic(ctx, arg, `Expected one of: ${label}`)]); }, } satisfies OneOfArgType; diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/record.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/record.ts index a17eead2b0ac..e3db0dce6770 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/record.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/record.ts @@ -1,4 +1,4 @@ -import { notOk, ok, type Result } from '@internal/utils/result'; +import { and, notOk, ok, okVoid, type Result } from '@internal/utils/result'; import type { PslDiagnostic } from '../../diagnostic'; import { ObjectLiteralExprAst } from '../../syntax/ast/expressions'; import type { ArgType, AttributeCtx, RecordArgType } from '../types'; @@ -14,33 +14,36 @@ export function record(of: ArgType): Record if (literal === undefined) { return notOk([leafDiagnostic(ctx, arg, 'Expected an object literal')]); } - const diagnostics: PslDiagnostic[] = []; const entries: [string, T][] = []; const keys = new Set(); + let outcome: Result = okVoid(); for (const field of Array.from(literal.fields())) { const key = field.keyName(); if (key === undefined) { - diagnostics.push(leafDiagnostic(ctx, field, 'Expected a key')); + outcome = and(outcome, notOk([leafDiagnostic(ctx, field, 'Expected a key')])); continue; } const value = field.value(); if (value === undefined) { - diagnostics.push(leafDiagnostic(ctx, field, `Expected a value for key "${key}"`)); + outcome = and( + outcome, + notOk([leafDiagnostic(ctx, field, `Expected a value for key "${key}"`)]), + ); continue; } const parsed = of.parse(value, ctx); if (!parsed.ok) { - diagnostics.push(...parsed.failure); + outcome = and(outcome, parsed); continue; } if (keys.has(key)) { - diagnostics.push(leafDiagnostic(ctx, field, `Duplicate key "${key}"`)); + outcome = and(outcome, notOk([leafDiagnostic(ctx, field, `Duplicate key "${key}"`)])); continue; } keys.add(key); entries.push([key, parsed.value]); } - if (diagnostics.length > 0) return notOk(diagnostics); + if (!outcome.ok) return notOk(outcome.failure); return ok(Object.fromEntries(entries)); }, }; diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/field-attribute.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/field-attribute.ts index 45f88572dc9d..2a927ebbb45d 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/field-attribute.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/field-attribute.ts @@ -34,7 +34,7 @@ export function fieldAttribute< name, documentation: config.documentation, positional: config.positional ?? [], - named: config.named ?? {}, + named: Object.assign(Object.create(null), config.named), ...(config.refine !== undefined ? { refine: config.refine } : {}), }; } diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/interpret.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/interpret.ts index ace522d2cde8..5f17bc7ea55c 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/interpret.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/interpret.ts @@ -1,6 +1,6 @@ import type { PslSpan } from '@internal/framework-components/psl-ast'; import { blindCast } from '@internal/utils/casts'; -import { notOk, ok, type Result } from '@internal/utils/result'; +import { and, notOk, ok, okVoid, type Result } from '@internal/utils/result'; import { diagnosticSource, type PslDiagnostic } from '../diagnostic'; import { nodePslSpan } from '../resolve'; import type { FieldAttributeAst, ModelAttributeAst } from '../syntax/ast/attributes'; @@ -32,7 +32,7 @@ export function interpretArgs( span: PslSpan, sourceNode: SyntaxNode, ): Result, readonly PslDiagnostic[]> { - const diagnostics: PslDiagnostic[] = []; + let outcome: Result = okVoid(); const output: Record = {}; const seen = new Set(); @@ -48,13 +48,16 @@ export function interpretArgs( const posParam = spec.positional[positionalSlot]; if (posParam === undefined) { if (!reportedExcess) { - diagnostics.push( - diagnostic( - `Attribute "${spec.name}" received too many positional arguments`, - ctx, - span, - sourceNode, - ), + outcome = and( + outcome, + notOk([ + diagnostic( + `Attribute "${spec.name}" received too many positional arguments`, + ctx, + span, + sourceNode, + ), + ]), ); reportedExcess = true; } @@ -66,13 +69,16 @@ export function interpretArgs( } else { const namedParam = Object.hasOwn(spec.named, name) ? spec.named[name] : undefined; if (namedParam === undefined) { - diagnostics.push( - diagnostic( - `Attribute "${spec.name}" received unknown argument "${name}"`, - ctx, - nodePslSpan(arg.syntax, ctx.sources), - arg.syntax, - ), + outcome = and( + outcome, + notOk([ + diagnostic( + `Attribute "${spec.name}" received unknown argument "${name}"`, + ctx, + nodePslSpan(arg.syntax, ctx.sources), + arg.syntax, + ), + ]), ); continue; } @@ -81,19 +87,23 @@ export function interpretArgs( } if (seen.has(key)) { - diagnostics.push( - diagnostic( - `Attribute "${spec.name}" received duplicate argument "${key}"`, - ctx, - nodePslSpan(arg.syntax, ctx.sources), - arg.syntax, - ), + outcome = and( + outcome, + notOk([ + diagnostic( + `Attribute "${spec.name}" received duplicate argument "${key}"`, + ctx, + nodePslSpan(arg.syntax, ctx.sources), + arg.syntax, + ), + ]), ); continue; } seen.add(key); - const result = parseArgValue(arg, param, ctx, diagnostics); + const result = parseArgValue(arg, param, ctx); if (result.ok) output[key] = result.value; + outcome = and(outcome, result); } const finalized = new Set(); @@ -110,13 +120,16 @@ export function interpretArgs( if (effective.hasDefault) output[key] = effective.defaultValue; return; } - diagnostics.push( - diagnostic( - `Attribute "${spec.name}" is missing required argument "${key}"`, - ctx, - span, - sourceNode, - ), + outcome = and( + outcome, + notOk([ + diagnostic( + `Attribute "${spec.name}" is missing required argument "${key}"`, + ctx, + span, + sourceNode, + ), + ]), ); }; @@ -128,9 +141,7 @@ export function interpretArgs( finalizeAbsentKey(key, undefined, spec.named[key]?.type); } - if (diagnostics.length > 0) { - return notOk(diagnostics); - } + if (!outcome.ok) return notOk(outcome.failure); return ok(output); } @@ -166,24 +177,19 @@ function parseArgValue( arg: AttributeArgAst, argType: ArgType, ctx: Ctx, - diagnostics: PslDiagnostic[], ): Result { const value = arg.value(); if (value === undefined) { - const missing = diagnostic( - 'Attribute argument is missing a value', - ctx, - nodePslSpan(arg.syntax, ctx.sources), - arg.syntax, - ); - diagnostics.push(missing); - return notOk([missing]); - } - const result = argType.parse(value, ctx); - if (!result.ok) { - for (const failure of result.failure) diagnostics.push(failure); + return notOk([ + diagnostic( + 'Attribute argument is missing a value', + ctx, + nodePslSpan(arg.syntax, ctx.sources), + arg.syntax, + ), + ]); } - return result; + return argType.parse(value, ctx); } function isOptionalArgType( diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/model-attribute.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/model-attribute.ts index 3407f779ff84..f16bd1db3075 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/model-attribute.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/model-attribute.ts @@ -34,7 +34,7 @@ export function modelAttribute< name, documentation: config.documentation, positional: config.positional ?? [], - named: config.named ?? {}, + named: Object.assign(Object.create(null), config.named), ...(config.refine !== undefined ? { refine: config.refine } : {}), }; } diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/types.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/types.ts index d4576cef638d..53248429b882 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/types.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/types.ts @@ -2,6 +2,7 @@ import type { TaggedLiteralCanonicalization } from '@internal/framework-componen import type { PslSpan } from '@internal/framework-components/psl-ast'; import type { Result } from '@internal/utils/result'; import type { Simplify, UnionToIntersection } from '@internal/utils/types'; +import type { Binder } from '../binder'; import type { PslDiagnostic } from '../diagnostic'; import type { EntityDeclaration, @@ -22,11 +23,11 @@ export interface AttributeCtx { export interface ModelAttributeCtx extends AttributeCtx { readonly selfModel: ModelSymbol; + readonly binder: Binder; } export interface FieldAttributeCtx extends ModelAttributeCtx { readonly field: FieldSymbol; - resolveReferencedModel(): ModelSymbol | undefined; } export type ArgTypeKind = diff --git a/packages/1-framework/2-authoring/psl-parser/src/binder.ts b/packages/1-framework/2-authoring/psl-parser/src/binder.ts new file mode 100644 index 000000000000..6d4e18c4b9a8 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/binder.ts @@ -0,0 +1,485 @@ +import type { AuthoringTypeNamespace } from '@internal/framework-components/authoring'; +import type { ControlDefaultRegistries } from '@internal/framework-components/control'; +import type { ContributedPslDiagnosticCode } from '@internal/framework-components/psl-ast'; +import type { AttributeSpecNamespace } from './attribute-spec/spec-context'; +import type { AttributeSpec, FieldAttributeCtx, ModelAttributeCtx } from './attribute-spec/types'; +import { contributedTypeScope } from './contributed-type-scope'; +import { diagnosticSource } from './diagnostic'; +import type { ParseDiagnostic } from './parse'; +import type { ResolvedAttribute } from './resolve'; +import { + contributedScope, + documentScope, + isNamespaceLike, + lookupMember, + namespaceScope, + type Scope, + type ScopeResolution, +} from './scope'; +import type { PslSources } from './source-file'; +import type { + BlockSymbol, + CompositeTypeSymbol, + FieldSymbol, + ModelSymbol, + NamedTypeSymbol, + NamespaceSymbol, + SymbolTable, +} from './symbol-table'; +import type { FieldAttributeAst, ModelAttributeAst } from './syntax/ast/attributes'; +import { ArrayLiteralAst, type ExpressionAst } from './syntax/ast/expressions'; +import { IdentifierAst } from './syntax/ast/identifier'; +import type { SyntaxNode } from './syntax/red'; + +export const PSL_UNRESOLVED_REFERENCE = + 'PSL_UNRESOLVED_REFERENCE' satisfies ContributedPslDiagnosticCode; + +export type BoundSpec = + | AttributeSpec + | AttributeSpec; + +export interface AttributeSymbol { + readonly kind: 'attribute'; + readonly name: string; + readonly level: 'model' | 'field'; + readonly spec: BoundSpec; +} + +export type PslSymbol = + | ModelSymbol + | CompositeTypeSymbol + | NamedTypeSymbol + | BlockSymbol + | NamespaceSymbol + | FieldSymbol; + +export type Resolution = + | ScopeResolution + | { readonly kind: 'field'; readonly symbol: FieldSymbol } + | { readonly kind: 'attribute'; readonly symbol: AttributeSymbol } + | { readonly kind: 'crossSpace' } + | { readonly kind: 'unresolved'; readonly name: string }; + +export interface Binder { + declaredSymbol(node: SyntaxNode): PslSymbol | undefined; + symbolForNode(node: SyntaxNode): Resolution | undefined; +} + +export interface UnsupportedAttribute { + readonly attribute: ResolvedAttribute; + readonly level: 'model' | 'field'; + readonly owner: ModelSymbol | CompositeTypeSymbol; + readonly field: FieldSymbol | undefined; +} + +export type DescribeUnsupportedAttribute = ( + unsupported: UnsupportedAttribute, +) => ParseDiagnostic | undefined; + +export interface CreateBinderOptions { + readonly sources: PslSources; + readonly symbolTable: SymbolTable; + readonly typeConstructors: AuthoringTypeNamespace; + readonly attributeSpecs: AttributeSpecNamespace; + readonly controlMutationDefaults: ControlDefaultRegistries; + readonly describeUnsupportedAttribute?: DescribeUnsupportedAttribute | undefined; +} + +export interface BinderResult { + readonly binder: Binder; + readonly diagnostics: readonly ParseDiagnostic[]; +} + +export function typeReferenceNode(field: FieldSymbol): SyntaxNode | undefined { + return field.node.typeAnnotation()?.name()?.syntax; +} + +class PslBinder implements Binder { + readonly #declarations: WeakMap; + readonly #references: WeakMap; + + constructor( + declarations: WeakMap, + references: WeakMap, + ) { + this.#declarations = declarations; + this.#references = references; + } + + declaredSymbol(node: SyntaxNode): PslSymbol | undefined { + return this.#declarations.get(node); + } + + symbolForNode(node: SyntaxNode): Resolution | undefined { + return this.#references.get(node); + } +} + +class ScopeStack { + readonly #base: Scope; + readonly #scopes: Scope[]; + + constructor(base: Scope) { + this.#base = base; + this.#scopes = [base]; + } + + current(): Scope { + return this.#scopes[this.#scopes.length - 1] ?? this.#base; + } + + push(scope: Scope): void { + this.#scopes.push(scope); + } + + pop(): void { + this.#scopes.pop(); + } +} + +function walkEntities( + symbolTable: SymbolTable, + stack: ScopeStack, + visit: (entity: ModelSymbol | CompositeTypeSymbol) => void, +): void { + const { topLevel } = symbolTable; + for (const entity of Object.values(topLevel.models)) visit(entity); + for (const entity of Object.values(topLevel.compositeTypes)) visit(entity); + for (const namespace of Object.values(topLevel.namespaces)) { + stack.push(namespaceScope(namespace, stack.current())); + for (const entity of Object.values(namespace.models)) visit(entity); + for (const entity of Object.values(namespace.compositeTypes)) visit(entity); + stack.pop(); + } +} + +export function createBinder(options: CreateBinderOptions): BinderResult { + const { + sources, + symbolTable, + typeConstructors, + attributeSpecs, + controlMutationDefaults, + describeUnsupportedAttribute, + } = options; + const stack = new ScopeStack( + documentScope(symbolTable.topLevel, contributedScope(contributedTypeScope(typeConstructors))), + ); + const declarations = new WeakMap(); + const references = new WeakMap(); + const diagnostics: ParseDiagnostic[] = []; + + for (const symbol of Object.values(symbolTable.topLevel.namedTypes)) { + declarations.set(symbol.node.syntax, symbol); + } + for (const symbol of Object.values(symbolTable.topLevel.blocks)) { + declarations.set(symbol.node.syntax, symbol); + } + for (const namespace of Object.values(symbolTable.topLevel.namespaces)) { + for (const declaration of namespace.declarations) { + declarations.set(declaration.node.syntax, namespace); + } + for (const symbol of Object.values(namespace.blocks)) { + declarations.set(symbol.node.syntax, symbol); + } + } + + // Attributes are parsed in a second walk once every field type is bound. + // @relation(references: [x]) reads the referenced model's fields, and that + // model may be declared further down the file. + walkEntities(symbolTable, stack, (entity) => { + declarations.set(entity.node.syntax, entity); + for (const field of Object.values(entity.fields)) { + declarations.set(field.node.syntax, field); + const node = typeReferenceNode(field); + if (node === undefined) continue; + const outcome = resolveTypeReference(field, stack.current()); + if (outcome === undefined) continue; + references.set(node, outcome.resolution); + if (outcome.message !== undefined) { + diagnostics.push({ + code: PSL_UNRESOLVED_REFERENCE, + message: outcome.message, + data: { reference: 'type', name: outcome.name }, + ...diagnosticSource(sources, node).at(), + }); + } + } + }); + + walkEntities(symbolTable, stack, (entity) => { + const context = { + owner: entity, + scope: stack.current(), + references, + diagnostics, + symbolTable, + sources, + describeUnsupportedAttribute, + }; + const specContext = + entity.kind === 'model' + ? { symbols: symbolTable, model: entity, controlMutationDefaults } + : undefined; + bindAttributes( + entity, + entity.attributes, + attributeSpecs.model, + (factory) => (specContext === undefined ? undefined : factory(specContext)), + { ...context, field: undefined }, + ); + for (const field of Object.values(entity.fields)) { + bindAttributes( + field, + field.attributes, + attributeSpecs.field, + (factory) => (specContext === undefined ? undefined : factory({ ...specContext, field })), + { ...context, field }, + ); + } + }); + + return { binder: new PslBinder(declarations, references), diagnostics }; +} + +interface BindContext { + readonly owner: ModelSymbol | CompositeTypeSymbol; + readonly scope: Scope; + readonly field: FieldSymbol | undefined; + readonly references: WeakMap; + readonly diagnostics: ParseDiagnostic[]; + readonly symbolTable: SymbolTable; + readonly sources: PslSources; + readonly describeUnsupportedAttribute: DescribeUnsupportedAttribute | undefined; +} + +function bindAttributes( + holder: ModelSymbol | CompositeTypeSymbol | FieldSymbol, + attributes: readonly ResolvedAttribute[], + specs: Readonly>, + instantiate: (factory: Factory) => BoundSpec | undefined, + ctx: BindContext, +): void { + const declared: Iterable = holder.node.attributes(); + const nodes = Array.from(declared); + const level = holder.kind === 'field' ? 'field' : 'model'; + attributes.forEach((attribute, index) => { + const factory = own(specs, attribute.name); + if (factory === undefined) { + const diagnostic = ctx.describeUnsupportedAttribute?.({ + attribute, + level, + owner: ctx.owner, + field: level === 'field' ? ctx.field : undefined, + }); + if (diagnostic !== undefined) ctx.diagnostics.push(diagnostic); + return; + } + const spec = instantiate(factory); + if (spec === undefined) return; + const nameNode = nodes[index]?.name()?.syntax; + if (nameNode !== undefined) { + ctx.references.set(nameNode, { + kind: 'attribute', + symbol: { + kind: 'attribute', + name: attribute.name, + level, + spec, + }, + }); + } + bindArguments(attribute, spec, ctx); + }); +} + +function bindArguments(attribute: ResolvedAttribute, spec: BoundSpec, ctx: BindContext) { + let positional = 0; + for (const arg of attribute.args) { + const param = arg.name === undefined ? spec.positional[positional++] : spec.named[arg.name]; + if (param === undefined || arg.expression === undefined) continue; + const kind = referenceKind(param.type); + if (kind === undefined) continue; + for (const node of referenceNodes(arg.expression)) { + const resolution = resolveArgument(kind, node, ctx); + if (resolution !== undefined) ctx.references.set(node, resolution); + } + } +} + +function resolveArgument( + kind: ReferenceKind, + node: SyntaxNode, + ctx: BindContext, +): Resolution | undefined { + const name = IdentifierAst.cast(node)?.name(); + if (name === undefined) return undefined; + switch (kind) { + case 'fieldRef': + return resolveOwnerField(name, node, ctx); + case 'referencedFieldRef': + return resolveReferencedField(name, node, ctx); + case 'entityRef': + return resolveEntity(name, node, ctx); + } +} + +function resolveOwnerField(name: string, node: SyntaxNode, ctx: BindContext): Resolution { + const field = ctx.owner.fields[name]; + if (field !== undefined) return { kind: 'field', symbol: field }; + report(`Cannot find field "${name}" on "${ctx.owner.name}"`, node, ctx, 'field'); + return { kind: 'unresolved', name }; +} + +function resolveReferencedField(name: string, node: SyntaxNode, ctx: BindContext): Resolution { + const declaring = ctx.field; + if (declaring === undefined) return { kind: 'unresolved', name }; + if (declaring.typeContractSpaceId !== undefined) return { kind: 'crossSpace' }; + const typeNode = typeReferenceNode(declaring); + const target = typeNode === undefined ? undefined : ctx.references.get(typeNode); + const fields = targetFields(target); + const field = fields?.[name]; + if (field !== undefined) return { kind: 'field', symbol: field }; + report( + `Cannot find field "${name}" on the type of "${ctx.owner.name}.${declaring.name}"`, + node, + ctx, + 'field', + ); + return { kind: 'unresolved', name }; +} + +function targetFields( + target: Resolution | undefined, +): Readonly> | undefined { + if (target === undefined) return undefined; + if (target.kind === 'model' || target.kind === 'compositeType') return target.symbol.fields; + return undefined; +} + +function resolveEntity(name: string, node: SyntaxNode, ctx: BindContext): Resolution { + const found = ctx.scope.lookup(name); + if (found === undefined) { + report(`Cannot find entity "${name}"`, node, ctx, 'entity'); + return { kind: 'unresolved', name }; + } + return found; +} + +function report( + message: string, + node: SyntaxNode, + ctx: BindContext, + reference: 'field' | 'entity', +): void { + ctx.diagnostics.push({ + code: PSL_UNRESOLVED_REFERENCE, + message, + data: { reference }, + ...diagnosticSource(ctx.sources, node).at(), + }); +} + +type ReferenceKind = 'fieldRef' | 'referencedFieldRef' | 'entityRef'; + +function referenceKind(type: unknown): ReferenceKind | undefined { + if (typeof type !== 'object' || type === null) return undefined; + if ('kind' in type) { + const kind = type.kind; + if (kind === 'fieldRef' || kind === 'referencedFieldRef' || kind === 'entityRef') return kind; + } + if ('of' in type) return referenceKind(type.of); + if ('alternatives' in type && Array.isArray(type.alternatives)) { + for (const alternative of type.alternatives) { + const kind = referenceKind(alternative); + if (kind !== undefined) return kind; + } + } + return undefined; +} + +function referenceNodes(expression: ExpressionAst): readonly SyntaxNode[] { + const array = ArrayLiteralAst.cast(expression.syntax); + if (array === undefined) return [expression.syntax]; + return Array.from(array.elements(), (element) => element.syntax); +} + +interface TypeReferenceOutcome { + readonly resolution: Resolution; + readonly message?: string; + readonly name?: string; +} + +function resolveTypeReference(field: FieldSymbol, scope: Scope): TypeReferenceOutcome | undefined { + if (field.malformedType === true) return undefined; + if (field.typeContractSpaceId !== undefined) return { resolution: { kind: 'crossSpace' } }; + const name = field.typeName; + if (name === '') return undefined; + const namespaceId = field.typeNamespaceId; + const found = + namespaceId === undefined ? scope.lookup(name) : qualifiedMember(namespaceId, name, scope); + if (found === undefined) { + const written = namespaceId === undefined ? name : `${namespaceId}.${name}`; + return { + resolution: { kind: 'unresolved', name: written }, + message: `Cannot find type "${written}"`, + name: written, + }; + } + if ('badQualifier' in found) { + return { + resolution: { kind: 'unresolved', name: found.qualifier }, + message: found.badQualifier, + name: found.qualifier, + }; + } + if (found.kind === 'namespace' || found.kind === 'contributedNamespace') { + const written = namespaceId === undefined ? name : `${namespaceId}.${name}`; + return { + resolution: found, + message: `"${written}" is a namespace; a type reference must name a model, composite type, enum, or named type`, + name: written, + }; + } + return { resolution: found }; +} + +interface BadQualifier { + readonly badQualifier: string; + readonly qualifier: string; +} + +function qualifiedMember( + namespaceId: string, + name: string, + scope: Scope, +): ScopeResolution | BadQualifier | undefined { + const qualifier = scope.lookup(namespaceId); + if (qualifier === undefined) return undefined; + if (!isNamespaceLike(qualifier)) { + return { + badQualifier: `"${namespaceId}" is ${describeQualifier(qualifier)}, not a namespace`, + qualifier: namespaceId, + }; + } + return lookupMember(qualifier, name); +} + +function describeQualifier(resolution: ScopeResolution): string { + switch (resolution.kind) { + case 'model': + return 'a model'; + case 'compositeType': + return 'a composite type'; + case 'namedType': + return 'a named type'; + case 'block': + return `${resolution.symbol.keyword === 'enum' ? 'an' : 'a'} ${resolution.symbol.keyword}`; + default: + return 'a scalar type'; + } +} + +function own(record: Record, name: string): T | undefined { + return Object.hasOwn(record, name) ? record[name] : undefined; +} diff --git a/packages/1-framework/2-authoring/psl-parser/src/contributed-type-scope.ts b/packages/1-framework/2-authoring/psl-parser/src/contributed-type-scope.ts new file mode 100644 index 000000000000..339ca0e5cc7a --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/contributed-type-scope.ts @@ -0,0 +1,59 @@ +import type { + AuthoringTypeConstructorDescriptor, + AuthoringTypeNamespace, +} from '@internal/framework-components/authoring'; +import { isAuthoringTypeConstructorDescriptor } from '@internal/framework-components/authoring'; + +export interface ContributedTypeSymbol { + readonly kind: 'contributedType'; + readonly name: string; + readonly path: readonly string[]; + readonly descriptor: AuthoringTypeConstructorDescriptor; +} + +export interface ContributedNamespaceSymbol { + readonly kind: 'contributedNamespace'; + readonly name: string; + readonly path: readonly string[]; + readonly members: ReadonlyMap; +} + +export type ContributedMember = ContributedTypeSymbol | ContributedNamespaceSymbol; + +export interface ContributedTypeScope { + lookup(name: string): ContributedMember | undefined; +} + +const scopes = new WeakMap(); + +export function contributedTypeScope( + typeConstructors: AuthoringTypeNamespace, +): ContributedTypeScope { + const existing = scopes.get(typeConstructors); + if (existing !== undefined) return existing; + const members = collect(typeConstructors, []); + const created: ContributedTypeScope = { + lookup(name) { + return members.get(name); + }, + }; + scopes.set(typeConstructors, created); + return created; +} + +function collect( + namespace: AuthoringTypeNamespace, + prefix: readonly string[], +): ReadonlyMap { + const members = new Map(); + for (const [name, value] of Object.entries(namespace)) { + const path = [...prefix, name]; + members.set( + name, + isAuthoringTypeConstructorDescriptor(value) + ? { kind: 'contributedType', name, path, descriptor: value } + : { kind: 'contributedNamespace', name, path, members: collect(value, path) }, + ); + } + return members; +} diff --git a/packages/1-framework/2-authoring/psl-parser/src/entity-reference.ts b/packages/1-framework/2-authoring/psl-parser/src/entity-reference.ts index 2b975a95dc3d..4d09d0a3ea90 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/entity-reference.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/entity-reference.ts @@ -1,14 +1,11 @@ +import type { Resolution } from './binder'; import type { BlockSymbol, CompositeTypeSymbol, ModelSymbol, NamedTypeSymbol, NamespaceSymbol, - SymbolTable, - TopLevelScope, } from './symbol-table'; -import { NamespaceDeclarationAst } from './syntax/ast/declarations'; -import type { ExpressionAst } from './syntax/ast/expressions'; export type EntitySelector = | { readonly kind: 'model' } @@ -28,62 +25,54 @@ export interface ResolvedEntityReference ->(); +const references = new WeakMap(); -export function resolveEntityReference( - expression: ExpressionAst, - name: string, - symbols: SymbolTable, -): ResolvedEntityReference | undefined { - const namespaceName = expression.syntax - .findAncestor(NamespaceDeclarationAst.cast) - ?.name() - ?.name(); - const namespace = - namespaceName === undefined ? undefined : ownValue(symbols.topLevel.namespaces, namespaceName); - if (namespace !== undefined) { - const declaration = declarationIn(namespace, name); - if (declaration !== undefined) return referenceFor(namespace, declaration, namespace); +export function entityReference(resolution: Resolution): ResolvedEntityReference | undefined { + switch (resolution.kind) { + case 'model': + case 'compositeType': + case 'namedType': + case 'block': + return interned(resolution.symbol, resolution.namespace); + default: + return undefined; } - const declaration = declarationIn(symbols.topLevel, name); - return declaration === undefined - ? undefined - : referenceFor(symbols.topLevel, declaration, undefined); } -function ownValue(values: Readonly>, name: string): T | undefined { - return Object.hasOwn(values, name) ? values[name] : undefined; +function interned( + declaration: EntityDeclaration, + namespace: NamespaceSymbol | undefined, +): ResolvedEntityReference { + const existing = references.get(declaration); + if (existing !== undefined) return existing; + const reference: ResolvedEntityReference = { declaration, namespace }; + references.set(declaration, reference); + return reference; } -function declarationIn( - scope: TopLevelScope | NamespaceSymbol, - name: string, -): EntityDeclaration | undefined { +export function matchesSelector( + reference: ResolvedEntityReference, + expected: S, +): reference is ResolvedEntityReference> { + const declaration = reference.declaration; + if (declaration.kind !== expected.kind) return false; return ( - ownValue(scope.models, name) ?? - ownValue(scope.compositeTypes, name) ?? - ownValue(scope.blocks, name) ?? - ('namedTypes' in scope ? ownValue(scope.namedTypes, name) : undefined) + expected.kind !== 'block' || + (declaration.kind === 'block' && declaration.keyword === expected.keyword) ); } -function referenceFor( - scope: TopLevelScope | NamespaceSymbol, - declaration: EntityDeclaration, - namespace: NamespaceSymbol | undefined, -): ResolvedEntityReference { - let byDeclaration = references.get(scope); - if (byDeclaration === undefined) { - byDeclaration = new WeakMap(); - references.set(scope, byDeclaration); +export function describeResolution(resolution: Resolution): string { + switch (resolution.kind) { + case 'block': + return resolution.symbol.keyword; + case 'contributedType': + return 'scalar type'; + case 'crossSpace': + return 'cross-space reference'; + case 'unresolved': + return 'unresolved name'; + default: + return resolution.kind; } - let reference = byDeclaration.get(declaration); - if (reference === undefined) { - reference = { declaration, namespace }; - byDeclaration.set(declaration, reference); - } - return reference; } diff --git a/packages/1-framework/2-authoring/psl-parser/src/exports/index.ts b/packages/1-framework/2-authoring/psl-parser/src/exports/index.ts index a74afd616a0c..f8ea51b4f8d6 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/exports/index.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/exports/index.ts @@ -95,6 +95,27 @@ export type { TypedFuncCall, UnrestrictedIdentifierArgType, } from '../attribute-spec/types'; +export type { + AttributeSymbol, + Binder, + BinderResult, + BoundSpec, + CreateBinderOptions, + DescribeUnsupportedAttribute, + PslSymbol, + Resolution, + UnsupportedAttribute, +} from '../binder'; +export { + createBinder, + PSL_UNRESOLVED_REFERENCE, +} from '../binder'; +export type { + ContributedMember, + ContributedNamespaceSymbol, + ContributedTypeScope, + ContributedTypeSymbol, +} from '../contributed-type-scope'; export type { DiagnosticSource, PslDiagnostic, PslDiagnosticCollector } from '../diagnostic'; export { createPslDiagnosticCollector, @@ -117,6 +138,7 @@ export { readResolvedConstructorCall, } from '../resolve'; export { isPrismaNextSchema, renameLegacyDirective } from '../schema-directive'; +export type { Scope, ScopeResolution } from '../scope'; export type { BlockSymbol, BuildSymbolTableOptions, diff --git a/packages/1-framework/2-authoring/psl-parser/src/scope.ts b/packages/1-framework/2-authoring/psl-parser/src/scope.ts new file mode 100644 index 000000000000..2f715de5a078 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/scope.ts @@ -0,0 +1,133 @@ +import type { + ContributedMember, + ContributedNamespaceSymbol, + ContributedTypeScope, + ContributedTypeSymbol, +} from './contributed-type-scope'; +import type { + BlockSymbol, + CompositeTypeSymbol, + ModelSymbol, + NamedTypeSymbol, + NamespaceSymbol, + TopLevelScope as TopLevelRecords, +} from './symbol-table'; + +export type ScopeResolution = + | { readonly kind: 'model'; readonly symbol: ModelSymbol; readonly namespace?: NamespaceSymbol } + | { + readonly kind: 'compositeType'; + readonly symbol: CompositeTypeSymbol; + readonly namespace?: NamespaceSymbol; + } + | { + readonly kind: 'namedType'; + readonly symbol: NamedTypeSymbol; + readonly namespace?: NamespaceSymbol; + } + | { readonly kind: 'block'; readonly symbol: BlockSymbol; readonly namespace?: NamespaceSymbol } + | { readonly kind: 'namespace'; readonly symbol: NamespaceSymbol } + | { readonly kind: 'contributedNamespace'; readonly symbol: ContributedNamespaceSymbol } + | { readonly kind: 'contributedType'; readonly symbol: ContributedTypeSymbol }; + +export interface Scope { + lookup(name: string): ScopeResolution | undefined; +} + +function contributedResolution(member: ContributedMember): ScopeResolution { + return member.kind === 'contributedType' + ? { kind: 'contributedType', symbol: member } + : { kind: 'contributedNamespace', symbol: member }; +} + +function namespaceMember(namespace: NamespaceSymbol, name: string): ScopeResolution | undefined { + const model = namespace.models[name]; + if (model !== undefined) return { kind: 'model', symbol: model, namespace }; + const compositeType = namespace.compositeTypes[name]; + if (compositeType !== undefined) + return { kind: 'compositeType', symbol: compositeType, namespace }; + const block = namespace.blocks[name]; + if (block !== undefined) return { kind: 'block', symbol: block, namespace }; + return undefined; +} + +class ContributedScope implements Scope { + readonly #registry: ContributedTypeScope; + + constructor(registry: ContributedTypeScope) { + this.#registry = registry; + } + + lookup(name: string): ScopeResolution | undefined { + const member = this.#registry.lookup(name); + return member === undefined ? undefined : contributedResolution(member); + } +} + +class DocumentScope implements Scope { + readonly #records: TopLevelRecords; + readonly #parent: Scope | undefined; + + constructor(records: TopLevelRecords, parent: Scope | undefined) { + this.#records = records; + this.#parent = parent; + } + + lookup(name: string): ScopeResolution | undefined { + const records = this.#records; + const model = records.models[name]; + if (model !== undefined) return { kind: 'model', symbol: model }; + const compositeType = records.compositeTypes[name]; + if (compositeType !== undefined) return { kind: 'compositeType', symbol: compositeType }; + const namedType = records.namedTypes[name]; + if (namedType !== undefined) return { kind: 'namedType', symbol: namedType }; + const block = records.blocks[name]; + if (block !== undefined) return { kind: 'block', symbol: block }; + const namespace = records.namespaces[name]; + if (namespace !== undefined) return { kind: 'namespace', symbol: namespace }; + return this.#parent?.lookup(name); + } +} + +class NamespaceScope implements Scope { + readonly #namespace: NamespaceSymbol; + readonly #parent: Scope; + + constructor(namespace: NamespaceSymbol, parent: Scope) { + this.#namespace = namespace; + this.#parent = parent; + } + + lookup(name: string): ScopeResolution | undefined { + return namespaceMember(this.#namespace, name) ?? this.#parent.lookup(name); + } +} + +export function contributedScope(registry: ContributedTypeScope): Scope { + return new ContributedScope(registry); +} + +export function documentScope(records: TopLevelRecords, parent: Scope | undefined): Scope { + return new DocumentScope(records, parent); +} + +export function namespaceScope(namespace: NamespaceSymbol, parent: Scope): Scope { + return new NamespaceScope(namespace, parent); +} + +export function isNamespaceLike( + resolution: ScopeResolution, +): resolution is Extract { + return resolution.kind === 'namespace' || resolution.kind === 'contributedNamespace'; +} + +export function lookupMember( + qualifier: Extract, + name: string, +): ScopeResolution | undefined { + if (qualifier.kind === 'contributedNamespace') { + const member = qualifier.symbol.members.get(name); + return member === undefined ? undefined : contributedResolution(member); + } + return namespaceMember(qualifier.symbol, name); +} diff --git a/packages/1-framework/2-authoring/psl-parser/src/syntax/red.ts b/packages/1-framework/2-authoring/psl-parser/src/syntax/red.ts index c38d4910c587..7d02a97a6f7c 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/syntax/red.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/syntax/red.ts @@ -46,12 +46,12 @@ export class SyntaxToken implements Token { /** The sibling element immediately after this token within its parent. */ get nextSiblingOrToken(): SyntaxElement | undefined { - return childAt(this.parent, this.index + 1); + return this.parent.childAt(this.index + 1); } /** The sibling element immediately before this token within its parent. */ get prevSiblingOrToken(): SyntaxElement | undefined { - return childAt(this.parent, this.index - 1); + return this.parent.childAt(this.index - 1); } /** The next token in document order, crossing node boundaries. */ @@ -143,6 +143,8 @@ export class SyntaxNode { readonly parent: SyntaxNode | undefined; /** Position within the parent's children, enabling O(1) sibling navigation without rescanning the green layer. */ readonly index: number; + #childSlots: (SyntaxElement | undefined)[] | undefined; + #childOffsets: number[] | undefined; constructor(green: GreenNode, offset: number, parent: SyntaxNode | undefined, index: number) { this.green = green; @@ -151,6 +153,44 @@ export class SyntaxNode { this.index = index; } + #slots(): (SyntaxElement | undefined)[] { + let slots = this.#childSlots; + if (slots === undefined) { + slots = new Array(this.green.children.length).fill(undefined); + this.#childSlots = slots; + } + return slots; + } + + #childOffset(index: number): number { + let offsets = this.#childOffsets; + if (offsets === undefined) { + offsets = []; + this.#childOffsets = offsets; + } + for (let known = offsets.length; known <= index; known++) { + const previousOffset = offsets[known - 1]; + const previousChild = this.green.children[known - 1]; + offsets.push( + previousOffset === undefined || previousChild === undefined + ? this.offset + : previousOffset + elementTextLength(previousChild), + ); + } + return offsets[index] ?? this.offset; + } + + childAt(index: number): SyntaxElement | undefined { + const green = this.green.children[index]; + if (green === undefined) return undefined; + const slots = this.#slots(); + const cached = slots[index]; + if (cached !== undefined) return cached; + const created = wrapElement(green, this.#childOffset(index), this, index); + slots[index] = created; + return created; + } + get kind(): SyntaxKind { return this.green.kind; } @@ -173,21 +213,19 @@ export class SyntaxNode { } get firstChild(): SyntaxElement | undefined { - return childAt(this, 0); + return this.childAt(0); } get lastChild(): SyntaxElement | undefined { - const len = this.green.children.length; - if (len === 0) return undefined; - return childAt(this, len - 1); + return this.childAt(this.green.children.length - 1); } get nextSibling(): SyntaxElement | undefined { - return this.parent === undefined ? undefined : childAt(this.parent, this.index + 1); + return this.parent?.childAt(this.index + 1); } get prevSibling(): SyntaxElement | undefined { - return this.parent === undefined ? undefined : childAt(this.parent, this.index - 1); + return this.parent?.childAt(this.index - 1); } /** The sibling element immediately after this node within its parent. */ @@ -211,12 +249,10 @@ export class SyntaxNode { } *children(): Iterable { - let offset = this.offset; - let index = 0; - for (const child of this.green.children) { - yield wrapElement(child, offset, this, index); - offset += elementTextLength(child); - index++; + const count = this.green.children.length; + for (let index = 0; index < count; index++) { + const child = this.childAt(index); + if (child !== undefined) yield child; } } @@ -389,7 +425,7 @@ function climbingNext(el: SyntaxElement): SyntaxElement | undefined { for (;;) { const parent = current.parent; if (parent === undefined) return undefined; - const sibling = childAt(parent, current.index + 1); + const sibling = parent.childAt(current.index + 1); if (sibling !== undefined) return sibling; current = parent; } @@ -400,7 +436,7 @@ function climbingPrev(el: SyntaxElement): SyntaxElement | undefined { for (;;) { const parent = current.parent; if (parent === undefined) return undefined; - const sibling = childAt(parent, current.index - 1); + const sibling = parent.childAt(current.index - 1); if (sibling !== undefined) return sibling; current = parent; } @@ -418,20 +454,6 @@ function wrapElement( return new SyntaxNode(green, offset, parent, index); } -function childAt(node: SyntaxNode, index: number): SyntaxElement | undefined { - const children = node.green.children; - const target = children[index]; - if (target === undefined) return undefined; - let offset = node.offset; - for (let i = 0; i < index; i++) { - const child = children[i]; - if (child !== undefined) { - offset += elementTextLength(child); - } - } - return wrapElement(target, offset, node, index); -} - export function createSyntaxTree(green: GreenNode): SyntaxNode { return new SyntaxNode(green, 0, undefined, 0); } diff --git a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-binder.test.ts b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-binder.test.ts new file mode 100644 index 000000000000..b15a550bb701 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-binder.test.ts @@ -0,0 +1,296 @@ +import type { + AuthoringPslBlockDescriptorNamespace, + AuthoringTypeNamespace, +} from '@internal/framework-components/authoring'; +import { describe, expect, it } from 'vitest'; +import { entityRef } from '../src/attribute-spec/combinators/entity-ref'; +import { fieldRef, referencedFieldRef } from '../src/attribute-spec/combinators/field-ref'; +import { list } from '../src/attribute-spec/combinators/list'; +import { str } from '../src/attribute-spec/combinators/str'; +import { fieldAttribute } from '../src/attribute-spec/field-attribute'; +import { interpretAttribute } from '../src/attribute-spec/interpret'; +import { modelAttribute } from '../src/attribute-spec/model-attribute'; +import { optional } from '../src/attribute-spec/optional'; +import { createBinder } from '../src/binder'; +import { parse } from '../src/parse'; +import { PslSources } from '../src/source-file'; +import { buildSymbolTable, type FieldSymbol, type ModelSymbol } from '../src/symbol-table'; +import type { FieldAttributeAst, ModelAttributeAst } from '../src/syntax/ast/attributes'; + +const ENUM_DESCRIPTORS: AuthoringPslBlockDescriptorNamespace = { + enum: { + kind: 'pslBlock', + keyword: 'enum', + discriminator: 'enum', + name: { required: true }, + parameters: {}, + variadicParameters: true, + }, +}; + +const TYPE_CONSTRUCTORS: AuthoringTypeNamespace = { + Int: { kind: 'typeConstructor', output: { codecId: 'fixture/scalar@1', nativeType: 'integer' } }, + String: { kind: 'typeConstructor', output: { codecId: 'fixture/scalar@1', nativeType: 'text' } }, +}; + +const relationSpec = fieldAttribute('relation', { + documentation: 'fixture', + named: { + name: { type: optional(str()), documentation: 'fixture' }, + fields: { + type: optional(list(fieldRef(), { allowEmpty: false, unique: true })), + documentation: 'fixture', + }, + references: { + type: optional(list(referencedFieldRef(), { allowEmpty: false, unique: true })), + documentation: 'fixture', + }, + }, +}); + +const baseSpec = modelAttribute('base', { + documentation: 'fixture', + positional: [{ key: 'model', type: entityRef({ kind: 'model' }), documentation: 'fixture' }], +}); + +const indexSpec = modelAttribute('index', { + documentation: 'fixture', + positional: [{ key: 'fields', type: list(fieldRef()), documentation: 'fixture' }], +}); + +const ATTRIBUTE_SPECS = { + model: { base: () => baseSpec, index: () => indexSpec }, + field: { relation: () => relationSpec }, +}; + +function bind(text: string) { + const { document, sources: parsed } = parse(text, 'schema.psl'); + const sources = new PslSources([[document.syntax, parsed.sourceFileFor(document.syntax)]]); + const { symbolTable } = buildSymbolTable({ + documents: [document], + sources, + pslBlockDescriptors: ENUM_DESCRIPTORS, + }); + const { binder, diagnostics } = createBinder({ + sources, + symbolTable, + typeConstructors: TYPE_CONSTRUCTORS, + attributeSpecs: ATTRIBUTE_SPECS, + controlMutationDefaults: { + defaultFunctionRegistry: new Map(), + dataTypeEntries: {}, + }, + }); + return { sources, symbolTable, binder, binderDiagnostics: diagnostics }; +} + +function fieldAttributeNode(field: FieldSymbol, name: string): FieldAttributeAst { + for (const attribute of field.node.attributes()) { + if (attribute.name()?.path().join('.') === name) return attribute; + } + throw new Error(`no @${name}`); +} + +function modelAttributeNode(model: ModelSymbol, name: string): ModelAttributeAst { + for (const attribute of model.node.attributes()) { + if (attribute.name()?.path().join('.') === name) return attribute; + } + throw new Error(`no @@${name}`); +} + +const RELATION_SCHEMA = [ + 'model User {', + ' id Int', + '}', + 'model Post {', + ' authorId Int', + ' author User @relation(fields: [authorId], references: [id])', + '}', +].join('\n'); + +function interpretRelation(text: string) { + const { sources, symbolTable, binder, binderDiagnostics } = bind(text); + const post = symbolTable.topLevel.models['Post']!; + const field = post.fields['author']!; + const ctx = { sources, symbols: symbolTable, binder, selfModel: post, field }; + return { + binderDiagnostics, + result: interpretAttribute(fieldAttributeNode(field, 'relation'), relationSpec, ctx), + }; +} + +describe('reference combinators with a binder-backed context', () => { + it('parses a valid relation into the bound field names', () => { + const bound = interpretRelation(RELATION_SCHEMA); + + expect(bound.result.ok).toBe(true); + if (bound.result.ok) { + expect(bound.result.value).toEqual({ fields: ['authorId'], references: ['id'] }); + } + expect(bound.binderDiagnostics).toEqual([]); + }); + + it('leaves an unknown referenced field to the binder alone', () => { + const schema = [ + 'model User {', + ' id Int', + '}', + 'model Post {', + ' authorId Int', + ' author User @relation(fields: [authorId], references: [absent])', + '}', + ].join('\n'); + const bound = interpretRelation(schema); + + expect(bound.result.ok).toBe(false); + if (!bound.result.ok) expect(bound.result.failure).toEqual([]); + expect(bound.binderDiagnostics.map(({ code, message }) => [code, message])).toEqual([ + ['PSL_UNRESOLVED_REFERENCE', 'Cannot find field "absent" on the type of "Post.author"'], + ]); + }); + + it('leaves an unknown local field to the binder alone', () => { + const schema = [ + 'model User {', + ' id Int', + '}', + 'model Post {', + ' authorId Int', + ' author User @relation(fields: [nope], references: [id])', + '}', + ].join('\n'); + const bound = interpretRelation(schema); + + expect(bound.result.ok).toBe(false); + if (!bound.result.ok) expect(bound.result.failure).toEqual([]); + expect(bound.binderDiagnostics.map(({ code, message }) => [code, message])).toEqual([ + ['PSL_UNRESOLVED_REFERENCE', 'Cannot find field "nope" on "Post"'], + ]); + }); + + it('leaves an unknown entity to the binder alone', () => { + const { sources, symbolTable, binder, binderDiagnostics } = bind( + 'model Child {\n id Int\n @@base(Ghost)\n}', + ); + const child = symbolTable.topLevel.models['Child']!; + const ctx = { sources, symbols: symbolTable, binder, selfModel: child }; + const result = interpretAttribute(modelAttributeNode(child, 'base'), baseSpec, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.failure).toEqual([]); + expect(binderDiagnostics.map(({ code, message }) => [code, message])).toEqual([ + ['PSL_UNRESOLVED_REFERENCE', 'Cannot find entity "Ghost"'], + ]); + }); + + it('stays silent on both sides for a cross-space relation', () => { + const schema = [ + 'model Post {', + ' authorId Int', + ' author auth:User @relation(fields: [authorId], references: [id])', + '}', + ].join('\n'); + const { sources, symbolTable, binder, binderDiagnostics } = bind(schema); + const post = symbolTable.topLevel.models['Post']!; + const field = post.fields['author']!; + const ctx = { sources, symbols: symbolTable, binder, selfModel: post, field }; + const result = interpretAttribute(fieldAttributeNode(field, 'relation'), relationSpec, ctx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toEqual({ fields: ['authorId'], references: ['id'] }); + expect(binderDiagnostics).toEqual([]); + }); + + it('keeps shape failures as the combinator voice', () => { + const { sources, symbolTable, binder, binderDiagnostics } = bind( + 'model User {\n id Int\n @@index("id")\n}', + ); + const user = symbolTable.topLevel.models['User']!; + const ctx = { sources, symbols: symbolTable, binder, selfModel: user }; + const result = interpretAttribute(modelAttributeNode(user, 'index'), indexSpec, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure.map(({ code, message }) => [code, message])).toEqual([ + ['PSL_INVALID_ATTRIBUTE_SYNTAX', 'Expected a list of field name'], + ]); + } + expect(binderDiagnostics).toEqual([]); + }); + + it('keeps a non-identifier in a reference slot as the combinator voice', () => { + const { sources, symbolTable, binder, binderDiagnostics } = bind( + 'model User {\n id Int\n @@index(["id"])\n}', + ); + const user = symbolTable.topLevel.models['User']!; + const ctx = { sources, symbols: symbolTable, binder, selfModel: user }; + const result = interpretAttribute(modelAttributeNode(user, 'index'), indexSpec, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure.map(({ message }) => message)).toEqual(['Expected a field name']); + } + expect(binderDiagnostics).toEqual([]); + }); +}); + +describe('the binder is the only resolution path', () => { + it('fails a model-level field list the binder did not bind, with no diagnostic of its own', () => { + const { sources, symbolTable, binder, binderDiagnostics } = bind( + 'model User {\n id Int\n @@index([nope])\n}', + ); + const user = symbolTable.topLevel.models['User']!; + const ctx = { sources, symbols: symbolTable, binder, selfModel: user }; + const result = interpretAttribute(modelAttributeNode(user, 'index'), indexSpec, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.failure).toEqual([]); + expect(binderDiagnostics.map(({ code, message }) => [code, message])).toEqual([ + ['PSL_UNRESOLVED_REFERENCE', 'Cannot find field "nope" on "User"'], + ]); + }); + + it('binds a model-level field list the binder resolved', () => { + const { sources, symbolTable, binder, binderDiagnostics } = bind( + 'model User {\n id Int\n @@index([id])\n}', + ); + const user = symbolTable.topLevel.models['User']!; + const ctx = { sources, symbols: symbolTable, binder, selfModel: user }; + const result = interpretAttribute(modelAttributeNode(user, 'index'), indexSpec, ctx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toEqual({ fields: ['id'] }); + expect(binderDiagnostics).toEqual([]); + }); +}); + +describe('a binder built over another snapshot', () => { + it('fails loudly instead of silently forgoing the check', () => { + const first = bind(RELATION_SCHEMA); + const second = bind(RELATION_SCHEMA); + const post = second.symbolTable.topLevel.models['Post']!; + const field = post.fields['author']!; + const ctx = { + sources: second.sources, + symbols: second.symbolTable, + binder: first.binder, + selfModel: post, + field, + }; + + expect(() => + interpretAttribute(fieldAttributeNode(field, 'relation'), relationSpec, ctx), + ).toThrow(/same snapshot/i); + }); + + it('resolves normally when the binder and the context share a snapshot', () => { + const { sources, symbolTable, binder } = bind(RELATION_SCHEMA); + const post = symbolTable.topLevel.models['Post']!; + const field = post.fields['author']!; + const ctx = { sources, symbols: symbolTable, binder, selfModel: post, field }; + + const result = interpretAttribute(fieldAttributeNode(field, 'relation'), relationSpec, ctx); + + expect(result.ok).toBe(true); + }); +}); diff --git a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.foreign-copy.test.ts b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.foreign-copy.test.ts index 424c22bd9fef..b73bde8b6580 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.foreign-copy.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.foreign-copy.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { createBinder } from '../src/binder'; import type { ModelAttributeCtx } from '../src/exports'; import { bool, @@ -38,12 +39,23 @@ function foreignArg(source: string): { arg: ExpressionAst; ctx: ModelAttributeCt const node = selfModel.fields['id']?.node.attributes()[Symbol.iterator]().next().value; const value = node?.argList()?.args()[Symbol.iterator]().next().value?.value(); if (value === undefined) throw new Error('expected one argument'); + const { binder } = createBinder({ + sources, + symbolTable, + typeConstructors: {}, + attributeSpecs: { model: {}, field: {} }, + controlMutationDefaults: { + defaultFunctionRegistry: new Map(), + dataTypeEntries: {}, + }, + }); return { arg: new ForeignCopyOfAnAstNode(value.syntax) as unknown as ExpressionAst, ctx: { sources, symbols: symbolTable, selfModel, + binder, }, }; } @@ -62,7 +74,6 @@ describe('combinators dispatch on syntax kind, not on AST class identity', () => 'Cascade', ], ['unrestricted identifier', identifier(), 'User', 'User'], - ['fieldRef', fieldRef(), 'id', 'id'], ['json', json(), '"{\\"a\\":1}"', { a: 1 }], ['list', list(str()), '["a", "b"]', ['a', 'b']], ['record', record(int()), '{ a: 1 }', { a: 1 }], @@ -75,12 +86,16 @@ describe('combinators dispatch on syntax kind, not on AST class identity', () => if (result.ok) expect(result.value).toEqual(expected); }); - it('entityRef accepts a node from another module copy and preserves identity', () => { + it('fieldRef dispatches on the syntax kind of a node from another module copy', () => { + const { arg, ctx } = foreignArg('id'); + + expect(() => fieldRef().parse(arg, ctx)).toThrow(/same snapshot/i); + }); + + it('entityRef rejects a node from another module copy', () => { const { arg, ctx } = foreignArg('M'); - const reference = { declaration: ctx.selfModel, namespace: undefined }; - const result = entityRef({ kind: 'model' }).parse(arg, ctx); - expect(result.ok).toBe(true); - if (result.ok) expect(result.value).toEqual(reference); + + expect(() => entityRef({ kind: 'model' }).parse(arg, ctx)).toThrow(/same snapshot/i); }); it('funcCall accepts a node from another module copy', () => { diff --git a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.tagged-literal.test.ts b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.tagged-literal.test.ts index 9b0bf42321bc..9fb02956ac6a 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.tagged-literal.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.tagged-literal.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { createBinder } from '../src/binder'; import type { FieldAttributeCtx } from '../src/exports'; import { taggedLiteral } from '../src/exports'; import { Cursor, parse, parseAttribute } from '../src/parse'; @@ -19,13 +20,17 @@ function makeCtx(sources: PslSources): FieldAttributeCtx { if (!selfModel) throw new Error('expected model M in the symbol table'); const field = selfModel.fields['id']; if (!field) throw new Error('expected field id on model M'); - return { - sources, - symbols: symbolTable, - selfModel, - field, - resolveReferencedModel: () => undefined, - }; + const { binder } = createBinder({ + sources: modelSources, + symbolTable, + typeConstructors: {}, + attributeSpecs: { model: {}, field: {} }, + controlMutationDefaults: { + defaultFunctionRegistry: new Map(), + dataTypeEntries: {}, + }, + }); + return { sources, symbols: symbolTable, selfModel, field, binder }; } function argOf(exprSource: string): { expr: ExpressionAst; ctx: FieldAttributeCtx } { diff --git a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.test-d.ts b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.test-d.ts index 61fd220d9fa8..2556a4c79de7 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.test-d.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.test-d.ts @@ -62,7 +62,7 @@ test('checked reference selectors and wrappers preserve inferred outputs', () => expectTypeOf>().toEqualTypeOf< ResolvedEntityReference | string >(); - expectTypeOf(model.parse).parameter(1).toEqualTypeOf(); + expectTypeOf(model.parse).parameter(1).toEqualTypeOf(); expectTypeOf().toEqualTypeOf<'sources' | 'symbols'>(); // @ts-expect-error checked references require an expected selector entityRef(); @@ -70,6 +70,31 @@ test('checked reference selectors and wrappers preserve inferred outputs', () => entityRef({ kind: 'model' }, () => undefined); }); +test('a block attribute cannot name a reference combinator', () => { + blockAttribute('target', { + documentation: 'Names a model.', + positional: [ + { + key: 'model', + // @ts-expect-error a block attribute context carries no binder, so it cannot resolve a reference + type: entityRef({ kind: 'model' }), + documentation: 'The selected model.', + }, + ], + }); + blockAttribute('column', { + documentation: 'Names a field.', + positional: [ + { + key: 'field', + // @ts-expect-error a block attribute context carries no binder, so it cannot resolve a reference + type: fieldRef(), + documentation: 'The selected field.', + }, + ], + }); +}); + test('identifier requires semantic value documentation', () => { // @ts-expect-error identifier values require documentation identifier('Undocumented'); diff --git a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.test.ts b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.test.ts index 1a9854b658c0..020f5a10d3aa 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.test.ts @@ -1,5 +1,6 @@ import { ok } from '@internal/utils/result'; import { describe, expect, it } from 'vitest'; +import { createBinder } from '../src/binder'; import type { ArgType, AttributeCtx, FieldAttributeCtx, ModelAttributeCtx } from '../src/exports'; import { bool, @@ -26,7 +27,7 @@ import { Cursor, parse, parseAttribute } from '../src/parse'; import { PslSources } from '../src/source-file'; import { buildSymbolTable } from '../src/symbol-table'; import { FieldAttributeAst, ModelAttributeAst } from '../src/syntax/ast/attributes'; -import type { ExpressionAst } from '../src/syntax/ast/expressions'; +import { ArrayLiteralAst, type ExpressionAst } from '../src/syntax/ast/expressions'; import { createSyntaxTree } from '../src/syntax/red'; function makeCtx(sources: PslSources): FieldAttributeCtx { @@ -40,13 +41,69 @@ function makeCtx(sources: PslSources): FieldAttributeCtx { if (!selfModel) throw new Error('expected model M in the symbol table'); const field = selfModel.fields['id']; if (!field) throw new Error('expected field id on model M'); - return { - sources, - symbols: symbolTable, - selfModel, - field, - resolveReferencedModel: () => undefined, - }; + const { binder } = createBinder({ + sources: modelSources, + symbolTable, + typeConstructors: {}, + attributeSpecs: { model: {}, field: {} }, + controlMutationDefaults: { + defaultFunctionRegistry: new Map(), + dataTypeEntries: {}, + }, + }); + return { sources, symbols: symbolTable, selfModel, field, binder }; +} + +function schemaArg(schema: string, attribute: string, argName?: string) { + const { document, sources } = parse(schema, 'schema.psl'); + const registry = new PslSources([[document.syntax, sources.sourceFileFor(document.syntax)]]); + const { symbolTable } = buildSymbolTable({ + documents: [document], + sources: registry, + pslBlockDescriptors: {}, + }); + const model = symbolTable.topLevel.models['Post']; + if (!model) throw new Error('expected model Post'); + const field = model.fields['author']; + if (!field) throw new Error('expected field author'); + const { binder } = createBinder({ + sources: registry, + symbolTable, + typeConstructors: {}, + attributeSpecs: { + model: {}, + field: { + [attribute]: () => + fieldAttribute(attribute, { + documentation: 'fixture', + positional: [{ key: 'fields', type: list(fieldRef()), documentation: 'fixture' }], + named: { + fields: { type: list(fieldRef()), documentation: 'fixture' }, + references: { type: list(referencedFieldRef()), documentation: 'fixture' }, + }, + }), + }, + }, + controlMutationDefaults: { + defaultFunctionRegistry: new Map(), + dataTypeEntries: {}, + }, + }); + for (const node of field.node.attributes()) { + if (node.name()?.path().join('.') !== attribute) continue; + for (const arg of node.argList()?.args() ?? []) { + if (arg.name()?.name() !== argName) continue; + const value = arg.value(); + const array = value === undefined ? undefined : ArrayLiteralAst.cast(value.syntax); + const element = Array.from(array?.elements() ?? [])[0]; + if (element === undefined) throw new Error('expected a list element'); + return { + expr: element, + ctx: { sources: registry, symbols: symbolTable, selfModel: model, field, binder }, + }; + } + } + throw new Error('expected the attribute argument'); } function argOf(exprSource: string): { expr: ExpressionAst; ctx: FieldAttributeCtx } { @@ -625,47 +682,56 @@ describe('oneOf', () => { }); describe('fieldRef', () => { - it('resolves a field that exists on the self model', () => { - const { expr, ctx } = argOf('id'); + it('resolves a field the binder bound on the self model', () => { + const { expr, ctx } = schemaArg( + 'model User {\n id Int\n}\nmodel Post {\n authorId Int\n author User @relation(fields: [authorId])\n}', + 'relation', + 'fields', + ); const result = fieldRef().parse(expr, ctx); expect(result.ok).toBe(true); - if (result.ok) expect(result.value).toBe('id'); + if (result.ok) expect(result.value).toBe('authorId'); }); - it('emits an existence diagnostic for a field missing from the self model', () => { - const { expr, ctx } = argOf('ghostField'); + it('fails without a diagnostic when the binder bound nothing', () => { + const { expr, ctx } = schemaArg( + 'model User {\n id Int\n}\nmodel Post {\n authorId Int\n author User @relation(fields: [ghostField])\n}', + 'relation', + 'fields', + ); const result = fieldRef().parse(expr, ctx); expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.failure).toHaveLength(1); - expect(result.failure[0]?.code).toBe('PSL_INVALID_ATTRIBUTE_SYNTAX'); - } + if (!result.ok) expect(result.failure).toEqual([]); }); - it('resolves a field against the referenced model when it is in scope', () => { - const { expr, ctx } = argOf('id'); - const referencedCtx: FieldAttributeCtx = { - ...ctx, - resolveReferencedModel: () => ctx.selfModel, - }; + it('resolves a referenced field the binder bound on the target model', () => { + const { expr, ctx } = schemaArg( + 'model User {\n id Int\n}\nmodel Post {\n authorId Int\n author User @relation(references: [id])\n}', + 'relation', + 'references', + ); - const result = referencedFieldRef().parse(expr, referencedCtx); + const result = referencedFieldRef().parse(expr, ctx); expect(result.ok).toBe(true); if (result.ok) expect(result.value).toBe('id'); }); - it('carries a referenced name through when the referenced model is out of scope', () => { - const { expr, ctx } = argOf('ghostField'); + it('carries a cross-space referenced name through without a diagnostic', () => { + const { expr, ctx } = schemaArg( + 'model Post {\n authorId Int\n author auth:User @relation(references: [id])\n}', + 'relation', + 'references', + ); const result = referencedFieldRef().parse(expr, ctx); expect(result.ok).toBe(true); - if (result.ok) expect(result.value).toBe('ghostField'); + if (result.ok) expect(result.value).toBe('id'); }); it('labels both scopes as a field name', () => { @@ -696,7 +762,25 @@ describe('entityRef', () => { const attribute = field?.node.attributes()[Symbol.iterator]().next().value; const expr = attribute?.argList()?.args()[Symbol.iterator]().next().value?.value(); if (!selfModel || !expr) throw new Error('Missing reference argument'); - return { expr, ctx: { sources, symbols: symbolTable, selfModel } }; + const { binder } = createBinder({ + sources, + symbolTable, + typeConstructors: {}, + attributeSpecs: { + model: {}, + field: { + x: () => + fieldAttribute('x', { + documentation: 'fixture', + positional: [ + { key: 'model', type: entityRef({ kind: 'model' }), documentation: 'fixture' }, + ], + }), + }, + }, + controlMutationDefaults: { defaultFunctionRegistry: new Map(), dataTypeEntries: {} }, + }); + return { expr, ctx: { sources, symbols: symbolTable, selfModel, binder } }; } it('parses a bare identifier into its resolved model', () => { diff --git a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-failure-propagation.test.ts b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-failure-propagation.test.ts new file mode 100644 index 000000000000..db56da5a65a6 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-failure-propagation.test.ts @@ -0,0 +1,202 @@ +import { notOk, ok, type Result } from '@internal/utils/result'; +import { describe, expect, it } from 'vitest'; +import { list } from '../src/attribute-spec/combinators/list'; +import { oneOf } from '../src/attribute-spec/combinators/one-of'; +import { record } from '../src/attribute-spec/combinators/record'; +import { interpretAttribute } from '../src/attribute-spec/interpret'; +import { modelAttribute } from '../src/attribute-spec/model-attribute'; +import type { ArgType, ModelAttributeCtx } from '../src/attribute-spec/types'; +import { createBinder } from '../src/binder'; +import type { PslDiagnostic } from '../src/diagnostic'; +import { parse } from '../src/parse'; +import { PslSources } from '../src/source-file'; +import { buildSymbolTable, type ModelSymbol } from '../src/symbol-table'; + +const silent: ArgType = { + kind: 'fieldRef', + label: 'field name', + parse: (arg): Result => + arg.syntax.green.textLength === 4 ? notOk([]) : ok('kept'), +}; + +function build(text: string) { + const { document, sources: parsed } = parse(text, 'schema.psl'); + const sources = new PslSources([[document.syntax, parsed.sourceFileFor(document.syntax)]]); + const { symbolTable } = buildSymbolTable({ + documents: [document], + sources, + pslBlockDescriptors: {}, + }); + const model = symbolTable.topLevel.models['User']!; + const { binder } = createBinder({ + sources, + symbolTable, + typeConstructors: {}, + attributeSpecs: { model: {}, field: {} }, + controlMutationDefaults: { + defaultFunctionRegistry: new Map(), + dataTypeEntries: {}, + }, + }); + return { sources, model, binder, symbolTable }; +} + +function interpretFirst( + text: string, + spec: Parameters>[1], +) { + const { sources, model, binder, symbolTable } = build(text); + const node = Array.from(model.node.attributes())[0]; + if (node === undefined) throw new Error('no attribute'); + return interpretAttribute(node, spec, { + sources, + symbols: symbolTable, + selfModel: model, + binder, + }); +} + +const listSpec = modelAttribute('index', { + documentation: 'fixture', + positional: [{ key: 'fields', type: list(silent), documentation: 'fixture' }], +}); + +const recordSpec = modelAttribute('index', { + documentation: 'fixture', + positional: [{ key: 'fields', type: record(silent), documentation: 'fixture' }], +}); + +const scalarSpec = modelAttribute('index', { + documentation: 'fixture', + positional: [{ key: 'field', type: silent, documentation: 'fixture' }], +}); + +describe('a failure carrying no diagnostics', () => { + it('fails the list instead of dropping the element', () => { + const result = interpretFirst('model User {\n id Int\n @@index([id, fail])\n}', listSpec); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.failure).toEqual([]); + }); + + it('fails the record instead of dropping the entry', () => { + const result = interpretFirst( + 'model User {\n id Int\n @@index({ a: id, b: fail })\n}', + recordSpec, + ); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.failure).toEqual([]); + }); + + it('fails the attribute instead of returning it without the argument', () => { + const result = interpretFirst('model User {\n id Int\n @@index(fail)\n}', scalarSpec); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.failure).toEqual([]); + }); + + it('leaves a wholly successful parse untouched', () => { + const result = interpretFirst('model User {\n id Int\n @@index([id])\n}', listSpec); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toEqual({ fields: ['kept'] }); + }); + + it('still reports the diagnostics a failing sibling argument carries', () => { + const loud: ArgType = { + kind: 'fieldRef', + label: 'field name', + parse: (arg): Result => + arg.syntax.green.textLength === 4 + ? notOk([ + { + code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', + message: 'loud failure', + filename: 'schema.psl', + ...{ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } }, + }, + ]) + : ok('kept'), + }; + const loudSpec = modelAttribute('index', { + documentation: 'fixture', + positional: [{ key: 'fields', type: list(loud), documentation: 'fixture' }], + }); + const result = interpretFirst('model User {\n id Int\n @@index([id, fail])\n}', loudSpec); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.failure.map(({ message }) => message)).toEqual(['loud failure']); + }); +}); + +describe('ModelSymbol fixture sanity', () => { + it('builds the model the failure tests lean on', () => { + const { model }: { model: ModelSymbol } = build('model User {\n id Int\n}'); + expect(model.name).toBe('User'); + }); +}); + +describe('oneOf and a silently failing alternative', () => { + const silentAlt: ArgType = { + kind: 'fieldRef', + label: 'field name', + parse: (): Result => notOk([]), + }; + + const loudAlt: ArgType = { + kind: 'identifier', + label: 'a four-character name', + parse: (arg): Result => + arg.syntax.green.textLength === 4 + ? ok('matched') + : notOk([ + { + code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', + message: 'not four characters', + filename: 'schema.psl', + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, + }, + ]), + }; + + const specOf = (type: ArgType) => + modelAttribute('index', { + documentation: 'fixture', + positional: [{ key: 'fields', type, documentation: 'fixture' }], + }); + + it('adds no diagnostic of its own when an alternative failed silently', () => { + const result = interpretFirst( + 'model User {\n id Int\n @@index(sevench)\n}', + specOf(oneOf(silentAlt, loudAlt)), + ); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.failure).toEqual([]); + }); + + it('still lets a later alternative match what an earlier one refused silently', () => { + const result = interpretFirst( + 'model User {\n id Int\n @@index(Keep)\n}', + specOf(oneOf(silentAlt, loudAlt)), + ); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toEqual({ fields: 'matched' }); + }); + + it('keeps its own diagnostic when every alternative failed loudly', () => { + const result = interpretFirst( + 'model User {\n id Int\n @@index(sevench)\n}', + specOf(oneOf(loudAlt, loudAlt)), + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure.map(({ message }) => message)).toEqual([ + 'Expected one of: a four-character name | a four-character name', + ]); + } + }); +}); diff --git a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec.test.ts b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec.test.ts index 515906ca69b4..f0f5a6d1ec3d 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec.test.ts @@ -1,5 +1,6 @@ import { notOk, ok, type Result } from '@internal/utils/result'; import { describe, expect, it } from 'vitest'; +import { createBinder } from '../src/binder'; import { diagnosticSource, type PslDiagnostic } from '../src/diagnostic'; import type { ArgType, AttributeCtx, FieldAttributeCtx } from '../src/exports'; import { @@ -28,13 +29,17 @@ function makeCtx(sources: PslSources): FieldAttributeCtx { if (!selfModel) throw new Error('expected model M in the symbol table'); const field = selfModel.fields['id']; if (!field) throw new Error('expected field id on model M'); - return { - sources, - symbols: symbolTable, - selfModel, - field, - resolveReferencedModel: () => undefined, - }; + const { binder } = createBinder({ + sources: modelSources, + symbolTable, + typeConstructors: {}, + attributeSpecs: { model: {}, field: {} }, + controlMutationDefaults: { + defaultFunctionRegistry: new Map(), + dataTypeEntries: {}, + }, + }); + return { sources, symbols: symbolTable, selfModel, field, binder }; } function fieldAttr(source: string): { node: FieldAttributeAst; ctx: FieldAttributeCtx } { diff --git a/packages/1-framework/2-authoring/psl-parser/test/binder.test.ts b/packages/1-framework/2-authoring/psl-parser/test/binder.test.ts new file mode 100644 index 000000000000..677f29a40b84 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/binder.test.ts @@ -0,0 +1,1332 @@ +import type { + AuthoringPslBlockDescriptorNamespace, + AuthoringTypeConstructorDescriptor, + AuthoringTypeNamespace, +} from '@internal/framework-components/authoring'; +import { describe, expect, it } from 'vitest'; +import { entityRef } from '../src/attribute-spec/combinators/entity-ref'; +import { fieldRef, referencedFieldRef } from '../src/attribute-spec/combinators/field-ref'; +import { list } from '../src/attribute-spec/combinators/list'; +import { str } from '../src/attribute-spec/combinators/str'; +import { fieldAttribute } from '../src/attribute-spec/field-attribute'; +import { modelAttribute } from '../src/attribute-spec/model-attribute'; +import type { + AttributeSpecContext, + FieldAttributeSpecContext, +} from '../src/attribute-spec/spec-context'; +import { + createBinder, + type DescribeUnsupportedAttribute, + typeReferenceNode, + type UnsupportedAttribute, +} from '../src/binder'; +import { contributedTypeScope } from '../src/contributed-type-scope'; +import { parse } from '../src/parse'; +import { PslSources } from '../src/source-file'; +import { + buildSymbolTable, + type CompositeTypeSymbol, + type FieldSymbol, + type ModelSymbol, + type SymbolTable, +} from '../src/symbol-table'; +import { ArrayLiteralAst } from '../src/syntax/ast/expressions'; +import type { SyntaxNode } from '../src/syntax/red'; + +const ENUM_DESCRIPTORS: AuthoringPslBlockDescriptorNamespace = { + enum: { + kind: 'pslBlock', + keyword: 'enum', + discriminator: 'enum', + name: { required: true }, + parameters: {}, + variadicParameters: true, + }, +}; + +function scalar(nativeType: string): AuthoringTypeConstructorDescriptor { + return { kind: 'typeConstructor', output: { codecId: 'fixture/scalar@1', nativeType } }; +} + +const TYPE_CONSTRUCTORS: AuthoringTypeNamespace = { + String: scalar('text'), + Int: scalar('integer'), + Uuid: scalar('uuid'), + pgvector: { Vector: scalar('vector') }, +}; + +const fieldListParam = (key: string) => ({ + key, + type: list(fieldRef()), + documentation: 'fixture', +}); + +const modelSpec = (name: string, positional: ReturnType[]) => + modelAttribute(name, { documentation: 'fixture', positional }); + +const MODEL_SPECS = { + id: () => modelSpec('id', [fieldListParam('fields')]), + index: () => modelSpec('index', [fieldListParam('fields')]), + unique: () => modelSpec('unique', [fieldListParam('fields')]), + base: () => + modelAttribute('base', { + documentation: 'fixture', + positional: [{ key: 'model', type: entityRef({ kind: 'model' }), documentation: 'fixture' }], + }), + map: () => + modelAttribute('map', { + documentation: 'fixture', + positional: [{ key: 'name', type: str(), documentation: 'fixture' }], + }), +}; + +const FIELD_SPECS = { + id: () => fieldAttribute('id', { documentation: 'fixture' }), + contextual: () => fieldAttribute('contextual', { documentation: 'fixture' }), + relation: () => + fieldAttribute('relation', { + documentation: 'fixture', + named: { + fields: { type: list(fieldRef()), documentation: 'fixture' }, + references: { type: list(referencedFieldRef()), documentation: 'fixture' }, + name: { type: str(), documentation: 'fixture' }, + }, + }), +}; + +const ATTRIBUTE_SPECS = { model: MODEL_SPECS, field: FIELD_SPECS }; + +const NO_CONTROL_DEFAULTS = { + defaultFunctionRegistry: new Map(), + dataTypeEntries: {}, +}; + +function attributeNodes( + owner: ModelSymbol | CompositeTypeSymbol | FieldSymbol, + attributeName: string, + argName?: string, +): readonly SyntaxNode[] { + for (const attribute of owner.node.attributes()) { + if (attribute.name()?.path().join('.') !== attributeName) continue; + for (const arg of attribute.argList()?.args() ?? []) { + if (arg.name()?.name() !== argName) continue; + const value = arg.value(); + if (value === undefined) return []; + const array = ArrayLiteralAst.cast(value.syntax); + if (array === undefined) return [value.syntax]; + return Array.from(array.elements(), (element) => element.syntax); + } + } + return []; +} + +function attributeNameNode( + owner: ModelSymbol | CompositeTypeSymbol | FieldSymbol, + attributeName: string, +): SyntaxNode { + for (const attribute of owner.node.attributes()) { + const name = attribute.name(); + if (name?.path().join('.') === attributeName) return name.syntax; + } + throw new Error(`no @${attributeName}`); +} + +function build(...texts: string[]) { + const parsed = texts.map((text, index) => parse(text, `${index}.psl`)); + const documents = parsed.map(({ document }) => document); + const sources = new PslSources( + parsed.map( + ({ document, sources }) => [document.syntax, sources.sourceFileFor(document.syntax)] as const, + ), + ); + const { symbolTable } = buildSymbolTable({ + documents, + sources, + pslBlockDescriptors: ENUM_DESCRIPTORS, + }); + return { sources, symbolTable }; +} + +function bind(...texts: string[]) { + const { sources, symbolTable } = build(...texts); + return { + symbolTable, + ...createBinder({ + sources, + symbolTable, + typeConstructors: TYPE_CONSTRUCTORS, + attributeSpecs: ATTRIBUTE_SPECS, + controlMutationDefaults: NO_CONTROL_DEFAULTS, + }), + }; +} + +function bindWithUnsupportedDescriber( + describeUnsupportedAttribute: DescribeUnsupportedAttribute, + ...texts: string[] +) { + const { sources, symbolTable } = build(...texts); + return { + symbolTable, + ...createBinder({ + sources, + symbolTable, + typeConstructors: TYPE_CONSTRUCTORS, + attributeSpecs: ATTRIBUTE_SPECS, + controlMutationDefaults: NO_CONTROL_DEFAULTS, + describeUnsupportedAttribute, + }), + }; +} + +function fieldOf(symbolTable: SymbolTable, ownerPath: string, fieldName: string): FieldSymbol { + const [first = '', second] = ownerPath.split('.'); + const scope = + second === undefined ? symbolTable.topLevel : symbolTable.topLevel.namespaces[first]; + const ownerName = second ?? first; + const owner = scope?.models[ownerName] ?? scope?.compositeTypes[ownerName]; + const field = owner?.fields[fieldName]; + if (field === undefined) throw new Error(`no field ${ownerPath}.${fieldName}`); + return field; +} + +function typeNodeOf(symbolTable: SymbolTable, ownerPath: string, fieldName: string) { + const node = typeReferenceNode(fieldOf(symbolTable, ownerPath, fieldName)); + if (node === undefined) throw new Error(`no type node for ${ownerPath}.${fieldName}`); + return node; +} + +describe('createBinder — declarations', () => { + it('registers model, composite type, and field declaration nodes', () => { + const { symbolTable, binder } = bind( + 'model User {\n id Int\n}\ntype Address {\n street String\n}', + ); + const user = symbolTable.topLevel.models['User']!; + const address = symbolTable.topLevel.compositeTypes['Address']!; + + expect(binder.declaredSymbol(user.node.syntax)).toBe(user); + expect(binder.declaredSymbol(address.node.syntax)).toBe(address); + expect(binder.declaredSymbol(user.fields['id']!.node.syntax)).toBe(user.fields['id']); + expect(binder.declaredSymbol(address.fields['street']!.node.syntax)).toBe( + address.fields['street'], + ); + }); + + it('registers declarations inside namespaces', () => { + const { symbolTable, binder } = bind('namespace app {\n model Item {\n id Int\n }\n}'); + const item = symbolTable.topLevel.namespaces['app']!.models['Item']!; + expect(binder.declaredSymbol(item.node.syntax)).toBe(item); + expect(binder.declaredSymbol(item.fields['id']!.node.syntax)).toBe(item.fields['id']); + }); + + it('returns undefined for a node that declares nothing', () => { + const { symbolTable, binder } = bind('model User {\n id Int\n}'); + const user = symbolTable.topLevel.models['User']!; + expect(binder.declaredSymbol(user.node.syntax.root())).toBeUndefined(); + }); + + it('returns stable results for repeated queries', () => { + const { symbolTable, binder } = bind('model User {\n id Int\n}'); + const node = symbolTable.topLevel.models['User']!.node.syntax; + expect(binder.declaredSymbol(node)).toBe(binder.declaredSymbol(node)); + + const typeNode = typeNodeOf(symbolTable, 'User', 'id'); + expect(binder.symbolForNode(typeNode)).toBe(binder.symbolForNode(typeNode)); + }); +}); + +describe('createBinder — the scope chain', () => { + it('prefers the declaring namespace over a top-level declaration of the same name', () => { + const { symbolTable, binder, diagnostics } = bind( + [ + 'model Account {', + ' id Int', + '}', + 'model Outside {', + ' account Account', + '}', + 'namespace app {', + ' model Account {', + ' id Int', + ' }', + ' model Inside {', + ' account Account', + ' }', + '}', + ].join('\n'), + ); + + expect(diagnostics).toEqual([]); + expect(binder.symbolForNode(typeNodeOf(symbolTable, 'app.Inside', 'account'))).toEqual({ + kind: 'model', + symbol: symbolTable.topLevel.namespaces['app']!.models['Account'], + namespace: symbolTable.topLevel.namespaces['app'], + }); + expect(binder.symbolForNode(typeNodeOf(symbolTable, 'Outside', 'account'))).toEqual({ + kind: 'model', + symbol: symbolTable.topLevel.models['Account'], + }); + }); + + it('never consults a sibling namespace', () => { + const { symbolTable, binder, diagnostics } = bind( + [ + 'namespace one {', + ' model Hidden {', + ' id Int', + ' }', + '}', + 'namespace two {', + ' model Seeker {', + ' target Hidden', + ' }', + '}', + ].join('\n'), + ); + + expect(binder.symbolForNode(typeNodeOf(symbolTable, 'two.Seeker', 'target'))).toEqual({ + kind: 'unresolved', + name: 'Hidden', + }); + expect(diagnostics.map(({ code }) => code)).toEqual(['PSL_UNRESOLVED_REFERENCE']); + }); + + it('falls back to the contributedTypes scope for a scalar name', () => { + const { symbolTable, binder, diagnostics } = bind('model User {\n name String\n}'); + const resolution = binder.symbolForNode(typeNodeOf(symbolTable, 'User', 'name')); + + expect(diagnostics).toEqual([]); + expect(resolution).toMatchObject({ + kind: 'contributedType', + symbol: { kind: 'contributedType', name: 'String', path: ['String'] }, + }); + }); + + it('lets a user declaration shadow a contributedTypes symbol silently', () => { + const { symbolTable, binder, diagnostics } = bind( + 'model Uuid {\n id Int\n}\nmodel User {\n key Uuid\n}', + ); + + expect(diagnostics).toEqual([]); + expect(binder.symbolForNode(typeNodeOf(symbolTable, 'User', 'key'))).toEqual({ + kind: 'model', + symbol: symbolTable.topLevel.models['Uuid'], + }); + }); + + it('resolves composite types, named types, and blocks', () => { + const { symbolTable, binder, diagnostics } = bind( + [ + 'types { Email = String }', + 'type Address {', + ' street String', + '}', + 'enum Role {', + ' Admin', + '}', + 'model User {', + ' address Address', + ' email Email', + ' role Role', + '}', + ].join('\n'), + ); + + expect(diagnostics).toEqual([]); + expect(binder.symbolForNode(typeNodeOf(symbolTable, 'User', 'address'))).toEqual({ + kind: 'compositeType', + symbol: symbolTable.topLevel.compositeTypes['Address'], + }); + expect(binder.symbolForNode(typeNodeOf(symbolTable, 'User', 'email'))).toEqual({ + kind: 'namedType', + symbol: symbolTable.topLevel.namedTypes['Email'], + }); + expect(binder.symbolForNode(typeNodeOf(symbolTable, 'User', 'role'))).toEqual({ + kind: 'block', + symbol: symbolTable.topLevel.blocks['Role'], + }); + }); +}); + +describe('createBinder — qualified references', () => { + it('resolves a namespace-qualified reference', () => { + const { symbolTable, binder, diagnostics } = bind( + [ + 'namespace app {', + ' model Item {', + ' id Int', + ' }', + '}', + 'model Cart {', + ' item app.Item', + '}', + ].join('\n'), + ); + + expect(diagnostics).toEqual([]); + expect(binder.symbolForNode(typeNodeOf(symbolTable, 'Cart', 'item'))).toEqual({ + kind: 'model', + symbol: symbolTable.topLevel.namespaces['app']!.models['Item'], + namespace: symbolTable.topLevel.namespaces['app'], + }); + }); + + it('resolves a qualified reference into a contributedTypes type namespace', () => { + const { symbolTable, binder, diagnostics } = bind( + 'model Doc {\n embedding pgvector.Vector\n}', + ); + + expect(diagnostics).toEqual([]); + expect(binder.symbolForNode(typeNodeOf(symbolTable, 'Doc', 'embedding'))).toMatchObject({ + kind: 'contributedType', + symbol: { name: 'Vector', path: ['pgvector', 'Vector'] }, + }); + }); + + it('reports a qualifier that is not a namespace as what it is', () => { + const { symbolTable, binder, diagnostics } = bind( + 'model app {\n id Int\n}\nmodel Cart {\n slot app.Thing\n}', + ); + + expect(binder.symbolForNode(typeNodeOf(symbolTable, 'Cart', 'slot'))).toEqual({ + kind: 'unresolved', + name: 'app', + }); + expect(diagnostics.map(({ code, message }) => [code, message])).toEqual([ + ['PSL_UNRESOLVED_REFERENCE', '"app" is a model, not a namespace'], + ]); + }); + + it('reports an enum qualifier as an enum, not a namespace', () => { + const { diagnostics } = bind('enum Role {\n Admin\n}\nmodel Cart {\n slot Role.Admin\n}'); + + expect(diagnostics.map(({ message }) => message)).toEqual([ + '"Role" is an enum, not a namespace', + ]); + }); + + it('reports an unresolved qualified reference against a known namespace', () => { + const { symbolTable, binder, diagnostics } = bind( + [ + 'namespace app {', + ' model Item {', + ' id Int', + ' }', + '}', + 'model Cart {', + ' item app.Missing', + '}', + ].join('\n'), + ); + + expect(binder.symbolForNode(typeNodeOf(symbolTable, 'Cart', 'item'))).toEqual({ + kind: 'unresolved', + name: 'app.Missing', + }); + expect(diagnostics.map(({ code }) => code)).toEqual(['PSL_UNRESOLVED_REFERENCE']); + }); +}); + +describe('createBinder — cross-space and malformed references', () => { + it('yields a cross-space resolution without a diagnostic', () => { + const { symbolTable, binder, diagnostics } = bind('model Cart {\n user auth:User\n}'); + + expect(diagnostics).toEqual([]); + expect(binder.symbolForNode(typeNodeOf(symbolTable, 'Cart', 'user'))).toEqual({ + kind: 'crossSpace', + }); + }); + + it('skips a malformed type silently', () => { + const { symbolTable, binder, diagnostics } = bind('model Cart {\n value a.b.c\n}'); + + expect(diagnostics).toEqual([]); + expect(fieldOf(symbolTable, 'Cart', 'value').malformedType).toBe(true); + expect(binder.symbolForNode(typeNodeOf(symbolTable, 'Cart', 'value'))).toBeUndefined(); + }); +}); + +describe('createBinder — diagnostics', () => { + it('locates an unresolved reference by filename and range', () => { + const { diagnostics } = bind('model User {\n id Int\n}', 'model Cart {\n pet Dog\n}'); + + expect(diagnostics).toEqual([ + { + code: 'PSL_UNRESOLVED_REFERENCE', + message: 'Cannot find type "Dog"', + data: { reference: 'type', name: 'Dog' }, + filename: '1.psl', + range: { start: { line: 1, character: 6 }, end: { line: 1, character: 9 } }, + }, + ]); + }); + + it('never re-emits duplicate-declaration diagnostics', () => { + const { diagnostics } = bind('model User {\n id Int\n}\nmodel User {\n id Int\n}'); + expect(diagnostics).toEqual([]); + }); + + it('reports every unresolved reference once', () => { + const { diagnostics } = bind( + 'model Cart {\n first Dog\n second Dog\n}\nmodel Basket {\n third Cat\n}', + ); + expect(diagnostics.map(({ message, range }) => [message, range.start.line])).toEqual([ + ['Cannot find type "Dog"', 1], + ['Cannot find type "Dog"', 2], + ['Cannot find type "Cat"', 5], + ]); + }); +}); + +describe('createBinder — multiple documents', () => { + it('resolves a reference in one document to a declaration in another', () => { + const { symbolTable, binder, diagnostics } = bind( + 'model Cart {\n user User\n}', + 'model User {\n id Int\n}', + ); + + expect(diagnostics).toEqual([]); + expect(binder.symbolForNode(typeNodeOf(symbolTable, 'Cart', 'user'))).toEqual({ + kind: 'model', + symbol: symbolTable.topLevel.models['User'], + }); + }); + + it('resolves into a namespace reopened across documents', () => { + const { symbolTable, binder, diagnostics } = bind( + 'namespace app {\n model Item {\n id Int\n }\n}', + 'namespace app {\n model Cart {\n item Item\n }\n}', + ); + + expect(diagnostics).toEqual([]); + expect(binder.symbolForNode(typeNodeOf(symbolTable, 'app.Cart', 'item'))).toEqual({ + kind: 'model', + symbol: symbolTable.topLevel.namespaces['app']!.models['Item'], + namespace: symbolTable.topLevel.namespaces['app'], + }); + }); +}); + +describe('contributedTypes scope', () => { + it('returns the same scope object for the same registry', () => { + expect(contributedTypeScope(TYPE_CONSTRUCTORS)).toBe(contributedTypeScope(TYPE_CONSTRUCTORS)); + expect(contributedTypeScope({ ...TYPE_CONSTRUCTORS })).not.toBe( + contributedTypeScope(TYPE_CONSTRUCTORS), + ); + }); + + it('shares contributedTypes symbols across two binders built over different documents', () => { + const first = bind('model User {\n name String\n}'); + const second = bind('model Other {\n title String\n}'); + + const firstSymbol = first.binder.symbolForNode(typeNodeOf(first.symbolTable, 'User', 'name')); + const secondSymbol = second.binder.symbolForNode( + typeNodeOf(second.symbolTable, 'Other', 'title'), + ); + + expect(firstSymbol?.kind).toBe('contributedType'); + expect(firstSymbol).not.toBe(secondSymbol); + if (firstSymbol?.kind === 'contributedType' && secondSymbol?.kind === 'contributedType') { + expect(firstSymbol.symbol).toBe(secondSymbol.symbol); + } + }); +}); + +const RELATION_SCHEMA = [ + 'model User {', + ' id Int @id', + ' email String', + '}', + 'model Post {', + ' id Int @id', + ' authorId Int', + ' author User @relation(fields: [authorId], references: [id])', + '}', +].join('\n'); + +describe('createBinder — attribute names', () => { + it('resolves an attribute name to an attribute symbol', () => { + const { symbolTable, binder, diagnostics } = bind(RELATION_SCHEMA); + const post = symbolTable.topLevel.models['Post']!; + + expect(diagnostics).toEqual([]); + expect( + binder.symbolForNode(attributeNameNode(post.fields['author']!, 'relation')), + ).toMatchObject({ + kind: 'attribute', + symbol: { kind: 'attribute', name: 'relation', level: 'field' }, + }); + expect(binder.symbolForNode(attributeNameNode(post.fields['id']!, 'id'))).toMatchObject({ + kind: 'attribute', + symbol: { kind: 'attribute', name: 'id', level: 'field' }, + }); + }); + + it('reaches the spec through the attribute symbol', () => { + const { symbolTable, binder } = bind(RELATION_SCHEMA); + const post = symbolTable.topLevel.models['Post']!; + const resolution = binder.symbolForNode(attributeNameNode(post.fields['author']!, 'relation')); + + expect(resolution?.kind).toBe('attribute'); + if (resolution?.kind === 'attribute') { + expect(Object.keys(resolution.symbol.spec.named)).toEqual(['fields', 'references', 'name']); + } + }); + + it('leaves an attribute name outside the namespace to its target', () => { + const { symbolTable, binder, diagnostics } = bind('model User {\n id Int @bogus\n @@nope\n}'); + const user = symbolTable.topLevel.models['User']!; + + expect(binder.symbolForNode(attributeNameNode(user.fields['id']!, 'bogus'))).toBeUndefined(); + expect(binder.symbolForNode(attributeNameNode(user, 'nope'))).toBeUndefined(); + expect(diagnostics).toEqual([]); + }); + + it('records nothing for a non-reference argument', () => { + const { symbolTable, binder, diagnostics } = bind( + 'model User {\n id Int\n @@map("users")\n}', + ); + const user = symbolTable.topLevel.models['User']!; + const [nameNode] = attributeNodes(user, 'map'); + + expect(diagnostics).toEqual([]); + expect(nameNode).toBeDefined(); + expect(nameNode === undefined ? undefined : binder.symbolForNode(nameNode)).toBeUndefined(); + }); +}); + +describe('createBinder — describeUnsupportedAttribute', () => { + const UNSUPPORTED_SCHEMA = [ + 'model User {', + ' id Int @id @bogus', + ' @@map("users")', + ' @@nope', + '}', + 'type Address {', + ' street String @sensitivity("high")', + '}', + ].join('\n'); + + function record(): { + readonly seen: UnsupportedAttribute[]; + readonly describe: DescribeUnsupportedAttribute; + } { + const seen: UnsupportedAttribute[] = []; + return { + seen, + describe: (unsupported) => { + seen.push(unsupported); + return undefined; + }, + }; + } + + it('calls back for names outside the namespace and stays silent for registered ones', () => { + const { seen, describe } = record(); + bindWithUnsupportedDescriber(describe, UNSUPPORTED_SCHEMA); + + expect( + seen.map(({ attribute, level, owner, field }) => ({ + name: attribute.name, + level, + owner: owner.name, + field: field?.name, + })), + ).toEqual([ + { name: 'nope', level: 'model', owner: 'User', field: undefined }, + { name: 'bogus', level: 'field', owner: 'User', field: 'id' }, + { name: 'sensitivity', level: 'field', owner: 'Address', field: 'street' }, + ]); + }); + + it('collects a returned diagnostic into the binder diagnostics', () => { + const { diagnostics } = bindWithUnsupportedDescriber( + ({ attribute, level, owner, field }) => ({ + filename: '0.psl', + code: 'FIXTURE_UNSUPPORTED_ATTRIBUTE', + message: `${level}:${owner.name}${field === undefined ? '' : `.${field.name}`}:${attribute.name}`, + range: { start: { line: 1, character: 1 }, end: { line: 1, character: 1 } }, + }), + UNSUPPORTED_SCHEMA, + ); + + expect(diagnostics.map(({ code, message }) => ({ code, message }))).toEqual([ + { code: 'FIXTURE_UNSUPPORTED_ATTRIBUTE', message: 'model:User:nope' }, + { code: 'FIXTURE_UNSUPPORTED_ATTRIBUTE', message: 'field:User.id:bogus' }, + { code: 'FIXTURE_UNSUPPORTED_ATTRIBUTE', message: 'field:Address.street:sensitivity' }, + ]); + }); + + it('stays silent when the callback returns undefined', () => { + const { diagnostics } = bindWithUnsupportedDescriber(() => undefined, UNSUPPORTED_SCHEMA); + expect(diagnostics).toEqual([]); + }); + + it('stays silent when no callback is supplied', () => { + const { diagnostics } = bind(UNSUPPORTED_SCHEMA); + expect(diagnostics).toEqual([]); + }); + + it('resolves an attribute the callback described to no symbol', () => { + const { symbolTable, binder } = bindWithUnsupportedDescriber( + () => ({ + filename: '0.psl', + code: 'FIXTURE_UNSUPPORTED_ATTRIBUTE', + message: 'unsupported', + range: { start: { line: 1, character: 1 }, end: { line: 1, character: 1 } }, + }), + UNSUPPORTED_SCHEMA, + ); + const user = symbolTable.topLevel.models['User']!; + + expect(binder.symbolForNode(attributeNameNode(user.fields['id']!, 'bogus'))).toBeUndefined(); + expect(binder.symbolForNode(attributeNameNode(user, 'nope'))).toBeUndefined(); + }); +}); + +describe('createBinder — fieldRef arguments', () => { + it('resolves @relation(fields:) against the declaring owner', () => { + const { symbolTable, binder, diagnostics } = bind(RELATION_SCHEMA); + const post = symbolTable.topLevel.models['Post']!; + const [node] = attributeNodes(post.fields['author']!, 'relation', 'fields'); + + expect(diagnostics).toEqual([]); + expect(node === undefined ? undefined : binder.symbolForNode(node)).toEqual({ + kind: 'field', + symbol: post.fields['authorId'], + }); + }); + + it('resolves @@id, @@unique, and @@index lists and reports one unknown name', () => { + const { symbolTable, binder, diagnostics } = bind( + [ + 'model User {', + ' id Int', + ' name String', + ' @@id([id])', + ' @@unique([name])', + ' @@index([name, missing])', + '}', + ].join('\n'), + ); + const user = symbolTable.topLevel.models['User']!; + const resolved = (attribute: string, index: number) => { + const node = attributeNodes(user, attribute)[index]; + return node === undefined ? undefined : binder.symbolForNode(node); + }; + + expect(resolved('id', 0)).toEqual({ kind: 'field', symbol: user.fields['id'] }); + expect(resolved('unique', 0)).toEqual({ kind: 'field', symbol: user.fields['name'] }); + expect(resolved('index', 0)).toEqual({ kind: 'field', symbol: user.fields['name'] }); + expect(resolved('index', 1)).toEqual({ kind: 'unresolved', name: 'missing' }); + expect(diagnostics.map(({ code, message }) => [code, message])).toEqual([ + ['PSL_UNRESOLVED_REFERENCE', 'Cannot find field "missing" on "User"'], + ]); + }); +}); + +describe('createBinder — referencedFieldRef arguments', () => { + it('resolves @relation(references:) against the phase-1 type target', () => { + const { symbolTable, binder, diagnostics } = bind(RELATION_SCHEMA); + const post = symbolTable.topLevel.models['Post']!; + const user = symbolTable.topLevel.models['User']!; + const [node] = attributeNodes(post.fields['author']!, 'relation', 'references'); + + expect(diagnostics).toEqual([]); + expect(node === undefined ? undefined : binder.symbolForNode(node)).toEqual({ + kind: 'field', + symbol: user.fields['id'], + }); + }); + + it('yields cross-space without a diagnostic when the field type is cross-space', () => { + const { symbolTable, binder, diagnostics } = bind( + [ + 'model Cart {', + ' id Int', + ' userId Int', + ' user auth:User @relation(fields: [userId], references: [id])', + '}', + ].join('\n'), + ); + const cart = symbolTable.topLevel.models['Cart']!; + const [referenced] = attributeNodes(cart.fields['user']!, 'relation', 'references'); + const [local] = attributeNodes(cart.fields['user']!, 'relation', 'fields'); + + expect(diagnostics).toEqual([]); + expect(referenced === undefined ? undefined : binder.symbolForNode(referenced)).toEqual({ + kind: 'crossSpace', + }); + expect(local === undefined ? undefined : binder.symbolForNode(local)).toEqual({ + kind: 'field', + symbol: cart.fields['userId'], + }); + }); + + it('reports a referenced field when the declaring field type is unresolved', () => { + const { symbolTable, binder, diagnostics } = bind( + [ + 'model Cart {', + ' id Int', + ' ownerId Int', + ' owner Ghost @relation(fields: [ownerId], references: [id])', + '}', + ].join('\n'), + ); + const cart = symbolTable.topLevel.models['Cart']!; + const [referenced] = attributeNodes(cart.fields['owner']!, 'relation', 'references'); + + expect(referenced === undefined ? undefined : binder.symbolForNode(referenced)).toEqual({ + kind: 'unresolved', + name: 'id', + }); + expect(diagnostics.map(({ code, message }) => [code, message])).toEqual([ + ['PSL_UNRESOLVED_REFERENCE', 'Cannot find type "Ghost"'], + ['PSL_UNRESOLVED_REFERENCE', 'Cannot find field "id" on the type of "Cart.owner"'], + ]); + }); + + it('reports a referenced field missing on a resolved target', () => { + const { diagnostics } = bind( + [ + 'model User {', + ' id Int', + '}', + 'model Cart {', + ' userId Int', + ' user User @relation(fields: [userId], references: [absent])', + '}', + ].join('\n'), + ); + + expect(diagnostics.map(({ message }) => message)).toEqual([ + 'Cannot find field "absent" on the type of "Cart.user"', + ]); + }); +}); + +describe('createBinder — entityRef arguments', () => { + it('resolves @@base to a model through the scope chain', () => { + const { symbolTable, binder, diagnostics } = bind( + [ + 'model Base {', + ' id Int', + '}', + 'namespace app {', + ' model Base {', + ' id Int', + ' }', + ' model Child {', + ' id Int', + ' @@base(Base)', + ' }', + '}', + ].join('\n'), + ); + const child = symbolTable.topLevel.namespaces['app']!.models['Child']!; + const [node] = attributeNodes(child, 'base'); + + expect(diagnostics).toEqual([]); + expect(node === undefined ? undefined : binder.symbolForNode(node)).toEqual({ + kind: 'model', + symbol: symbolTable.topLevel.namespaces['app']!.models['Base'], + namespace: symbolTable.topLevel.namespaces['app'], + }); + }); + + it('reports a missing entity and refuses a contributedTypes symbol as an entity', () => { + const { symbolTable, binder, diagnostics } = bind( + [ + 'model Orphan {', + ' id Int', + ' @@base(Ghost)', + '}', + 'model Scalarish {', + ' id Int', + ' @@base(String)', + '}', + 'enum Role {', + ' Admin', + '}', + 'model Enumish {', + ' id Int', + ' @@base(Role)', + '}', + ].join('\n'), + ); + const resolvedBase = (model: string) => { + const owner = symbolTable.topLevel.models[model]!; + const node = attributeNodes(owner, 'base')[0]; + return node === undefined ? undefined : binder.symbolForNode(node); + }; + + expect(resolvedBase('Orphan')).toEqual({ kind: 'unresolved', name: 'Ghost' }); + expect(resolvedBase('Scalarish')).toMatchObject({ kind: 'contributedType' }); + expect(resolvedBase('Enumish')).toEqual({ + kind: 'block', + symbol: symbolTable.topLevel.blocks['Role'], + }); + expect(diagnostics.map(({ message }) => message)).toEqual(['Cannot find entity "Ghost"']); + }); +}); + +describe('createBinder — the scope stack the walk pushes and pops', () => { + const TWO_NAMESPACES = [ + 'model Shared {', + ' id Int', + '}', + 'namespace first {', + ' model Local {', + ' id Int', + ' }', + ' model UsesLocal {', + ' slot Local', + ' }', + ' model UsesShared {', + ' slot Shared', + ' }', + '}', + 'namespace second {', + ' model UsesLeak {', + ' slot Local', + ' }', + '}', + ].join('\n'); + + it('pops one namespace before entering the next, so no name leaks sideways', () => { + const { symbolTable, binder, diagnostics } = bind(TWO_NAMESPACES); + const first = symbolTable.topLevel.namespaces['first']!; + + expect(binder.symbolForNode(typeNodeOf(symbolTable, 'first.UsesLocal', 'slot'))).toEqual({ + kind: 'model', + symbol: first.models['Local'], + namespace: first, + }); + expect(binder.symbolForNode(typeNodeOf(symbolTable, 'second.UsesLeak', 'slot'))).toEqual({ + kind: 'unresolved', + name: 'Local', + }); + expect(diagnostics.map(({ message }) => message)).toEqual(['Cannot find type "Local"']); + }); + + it('keeps the document scope beneath every pushed namespace', () => { + const { symbolTable, binder } = bind(TWO_NAMESPACES); + + expect(binder.symbolForNode(typeNodeOf(symbolTable, 'first.UsesShared', 'slot'))).toEqual({ + kind: 'model', + symbol: symbolTable.topLevel.models['Shared'], + }); + }); +}); + +describe('createBinder — one kind-blind scope chain', () => { + const SHADOWING_SCHEMA = [ + 'model Foo {', + ' id Int', + '}', + 'namespace app {', + ' enum Foo {', + ' A', + ' }', + ' model Bar {', + ' id Int', + ' kind Foo', + ' @@base(Foo)', + ' }', + '}', + ].join('\n'); + + it('lets a namespaced enum shadow a top-level model for an entity reference', () => { + const { symbolTable, binder, diagnostics } = bind(SHADOWING_SCHEMA); + const app = symbolTable.topLevel.namespaces['app']!; + const node = attributeNodes(app.models['Bar']!, 'base')[0]!; + + expect(binder.symbolForNode(node)).toEqual({ + kind: 'block', + symbol: app.blocks['Foo'], + namespace: app, + }); + expect(diagnostics).toEqual([]); + }); + + it('resolves a type reference through the same shadowing chain', () => { + const { symbolTable, binder } = bind(SHADOWING_SCHEMA); + const app = symbolTable.topLevel.namespaces['app']!; + + expect(binder.symbolForNode(typeNodeOf(symbolTable, 'app.Bar', 'kind'))).toEqual({ + kind: 'block', + symbol: app.blocks['Foo'], + namespace: app, + }); + }); + + it('reports a namespace named in type and entity position as a namespace', () => { + const { symbolTable, binder, diagnostics } = bind( + [ + 'namespace app {', + ' model Item {', + ' id Int', + ' }', + '}', + 'model Cart {', + ' id Int', + ' slot app', + ' @@base(app)', + '}', + ].join('\n'), + ); + const cart = symbolTable.topLevel.models['Cart']!; + const app = symbolTable.topLevel.namespaces['app']!; + + expect(binder.symbolForNode(typeNodeOf(symbolTable, 'Cart', 'slot'))).toEqual({ + kind: 'namespace', + symbol: app, + }); + expect(binder.symbolForNode(attributeNodes(cart, 'base')[0]!)).toEqual({ + kind: 'namespace', + symbol: app, + }); + expect(diagnostics.map(({ message }) => message)).toEqual([ + '"app" is a namespace; a type reference must name a model, composite type, enum, or named type', + ]); + }); + + it('still reports a name no scope in the chain declares as not found', () => { + const { diagnostics } = bind( + ['namespace app {', ' model Bar {', ' id Int', ' @@base(Ghost)', ' }', '}'].join( + '\n', + ), + ); + + expect(diagnostics.map(({ message }) => message)).toEqual(['Cannot find entity "Ghost"']); + }); +}); + +describe('createBinder — diagnostics completeness', () => { + it('reports every phase-1 and phase-2 failure exactly once', () => { + const { diagnostics } = bind( + [ + 'model User {', + ' id Int', + '}', + 'model Post {', + ' id Int', + ' authorId Int', + ' ghost Phantom', + ' author User @relation(fields: [missingLocal], references: [absent])', + ' @@index([id, alsoMissing])', + ' @@base(NoSuchModel)', + '}', + ].join('\n'), + ); + + expect( + diagnostics.map(({ code, message, filename, range }) => [ + code, + message, + filename, + range.start.line, + ]), + ).toEqual([ + ['PSL_UNRESOLVED_REFERENCE', 'Cannot find type "Phantom"', '0.psl', 6], + ['PSL_UNRESOLVED_REFERENCE', 'Cannot find field "alsoMissing" on "Post"', '0.psl', 8], + ['PSL_UNRESOLVED_REFERENCE', 'Cannot find entity "NoSuchModel"', '0.psl', 9], + ['PSL_UNRESOLVED_REFERENCE', 'Cannot find field "missingLocal" on "Post"', '0.psl', 7], + [ + 'PSL_UNRESOLVED_REFERENCE', + 'Cannot find field "absent" on the type of "Post.author"', + '0.psl', + 7, + ], + ]); + }); + + it('leaves phase-1 type resolution unchanged when attributes are present', () => { + const { symbolTable, binder, diagnostics } = bind(RELATION_SCHEMA); + + expect(diagnostics).toEqual([]); + expect(binder.symbolForNode(typeNodeOf(symbolTable, 'Post', 'author'))).toEqual({ + kind: 'model', + symbol: symbolTable.topLevel.models['User'], + }); + expect(binder.symbolForNode(typeNodeOf(symbolTable, 'User', 'email'))).toMatchObject({ + kind: 'contributedType', + }); + }); +}); + +describe('attribute-spec registry shape', () => { + it('accepts a spec assembled from the combinators in this package', () => { + const assembled = modelAttribute('index', { + documentation: 'fixture', + positional: [{ key: 'fields', type: list(fieldRef()), documentation: 'fixture' }], + }); + const base = modelAttribute('base', { + documentation: 'fixture', + positional: [{ key: 'model', type: entityRef({ kind: 'model' }), documentation: 'fixture' }], + }); + const relation = fieldAttribute('relation', { + documentation: 'fixture', + named: { + fields: { type: list(fieldRef()), documentation: 'fixture' }, + references: { type: list(referencedFieldRef()), documentation: 'fixture' }, + }, + }); + const registry = { + model: { index: () => assembled, base: () => base }, + field: { relation: () => relation }, + }; + + const { sources, symbolTable } = build( + [ + 'model User {', + ' id Int', + ' @@index([id, missing])', + '}', + 'model Post {', + ' userId Int', + ' user User @relation(fields: [userId], references: [id])', + '}', + ].join('\n'), + ); + const { binder, diagnostics } = createBinder({ + sources, + symbolTable, + typeConstructors: TYPE_CONSTRUCTORS, + attributeSpecs: registry, + controlMutationDefaults: NO_CONTROL_DEFAULTS, + }); + const user = symbolTable.topLevel.models['User']!; + const post = symbolTable.topLevel.models['Post']!; + const resolve = (node: SyntaxNode | undefined) => + node === undefined ? undefined : binder.symbolForNode(node); + + expect(resolve(attributeNodes(user, 'index')[0])).toEqual({ + kind: 'field', + symbol: user.fields['id'], + }); + expect(resolve(attributeNodes(post.fields['user']!, 'relation', 'fields')[0])).toEqual({ + kind: 'field', + symbol: post.fields['userId'], + }); + expect(resolve(attributeNodes(post.fields['user']!, 'relation', 'references')[0])).toEqual({ + kind: 'field', + symbol: user.fields['id'], + }); + expect(diagnostics.map(({ message }) => message)).toEqual([ + 'Cannot find field "missing" on "User"', + ]); + }); +}); + +describe('referencedFieldRef on a cross-space list', () => { + it('marks every element of the list cross-space', () => { + const { symbolTable, binder, diagnostics } = bind( + [ + 'model Cart {', + ' aId Int', + ' bId Int', + ' user auth:User @relation(fields: [aId, bId], references: [a, b])', + '}', + ].join('\n'), + ); + const cart = symbolTable.topLevel.models['Cart']!; + const nodes = attributeNodes(cart.fields['user']!, 'relation', 'references'); + + expect(diagnostics).toEqual([]); + expect(nodes).toHaveLength(2); + for (const node of nodes) { + expect(binder.symbolForNode(node)).toEqual({ kind: 'crossSpace' }); + } + }); +}); + +describe('createBinder — prototype-named declarations', () => { + const PROTOTYPE_SCHEMA = [ + 'model constructor {', + ' id Int @id', + ' toString String', + '}', + 'model Cart {', + ' ownerId Int', + ' owner constructor @relation(fields: [ownerId], references: [id])', + ' @@index([ownerId])', + '}', + 'namespace valueOf {', + ' model toString {', + ' id Int @id', + ' }', + ' model Basket {', + ' id Int @id', + ' @@base(toString)', + ' }', + '}', + ].join('\n'); + + it('resolves a prototype-named type, entity, and field reference', () => { + const { symbolTable, binder, diagnostics } = bind(PROTOTYPE_SCHEMA); + const cart = symbolTable.topLevel.models['Cart']!; + const prototypeNamed = symbolTable.topLevel.models['constructor']!; + const namespaced = symbolTable.topLevel.namespaces['valueOf']!; + + expect(diagnostics).toEqual([]); + expect(binder.symbolForNode(typeNodeOf(symbolTable, 'Cart', 'owner'))).toEqual({ + kind: 'model', + symbol: prototypeNamed, + }); + expect( + binder.symbolForNode(attributeNodes(cart.fields['owner']!, 'relation', 'references')[0]!), + ).toEqual({ kind: 'field', symbol: prototypeNamed.fields['id'] }); + expect(binder.symbolForNode(attributeNodes(namespaced.models['Basket']!, 'base')[0]!)).toEqual({ + kind: 'model', + symbol: namespaced.models['toString'], + namespace: namespaced, + }); + }); + + it('leaves an undeclared prototype-named reference unresolved', () => { + const { symbolTable, binder, diagnostics } = bind( + [ + 'model Cart {', + ' id Int @id', + ' slot valueOf', + ' @@index([hasOwnProperty])', + ' @@base(propertyIsEnumerable)', + '}', + ].join('\n'), + ); + const cart = symbolTable.topLevel.models['Cart']!; + + expect(binder.symbolForNode(typeNodeOf(symbolTable, 'Cart', 'slot'))).toEqual({ + kind: 'unresolved', + name: 'valueOf', + }); + expect(binder.symbolForNode(attributeNodes(cart, 'index')[0]!)).toEqual({ + kind: 'unresolved', + name: 'hasOwnProperty', + }); + expect(binder.symbolForNode(attributeNodes(cart, 'base')[0]!)).toEqual({ + kind: 'unresolved', + name: 'propertyIsEnumerable', + }); + expect(diagnostics.map(({ message }) => message)).toEqual([ + 'Cannot find type "valueOf"', + 'Cannot find field "hasOwnProperty" on "Cart"', + 'Cannot find entity "propertyIsEnumerable"', + ]); + }); +}); + +describe('createBinder — reference slots the binder stays silent about', () => { + it('records nothing for a non-identifier in a reference slot', () => { + const { symbolTable, binder, diagnostics } = bind( + ['model User {', ' id Int', ' @@index(["id", 7])', ' @@base("Base")', '}'].join('\n'), + ); + const user = symbolTable.topLevel.models['User']!; + const indexNodes = attributeNodes(user, 'index'); + const baseNodes = attributeNodes(user, 'base'); + + expect(indexNodes).toHaveLength(2); + expect(baseNodes).toHaveLength(1); + for (const node of [...indexNodes, ...baseNodes]) { + expect(binder.symbolForNode(node)).toBeUndefined(); + } + expect(diagnostics).toEqual([]); + }); +}); + +describe('the binder calls the real spec factories', () => { + it('builds each factory its construction context', () => { + const seen: string[] = []; + const { sources, symbolTable } = build( + ['model User {', ' id Int', ' name String @contextual', ' @@index([id])', '}'].join('\n'), + ); + const registry = { + model: { + index: (ctx: AttributeSpecContext) => { + seen.push(`model:index:${ctx.model.name}`); + return modelSpec('index', [fieldListParam('fields')]); + }, + }, + field: { + contextual: (ctx: FieldAttributeSpecContext) => { + seen.push(`field:contextual:${ctx.model.name}.${ctx.field.name}`); + return fieldAttribute('contextual', { documentation: 'fixture' }); + }, + }, + }; + const { binder, diagnostics } = createBinder({ + sources, + symbolTable, + typeConstructors: TYPE_CONSTRUCTORS, + attributeSpecs: registry, + controlMutationDefaults: NO_CONTROL_DEFAULTS, + }); + const user = symbolTable.topLevel.models['User']!; + + expect(seen).toContain('model:index:User'); + expect(seen).toContain('field:contextual:User.name'); + expect(diagnostics).toEqual([]); + expect( + binder.symbolForNode(attributeNameNode(user.fields['name']!, 'contextual')), + ).toMatchObject({ + kind: 'attribute', + symbol: { kind: 'attribute', name: 'contextual', level: 'field' }, + }); + expect(binder.symbolForNode(attributeNodes(user, 'index')[0]!)).toEqual({ + kind: 'field', + symbol: user.fields['id'], + }); + }); +}); + +describe('binder diagnostics carry their reference class', () => { + it('tags type, field and entity failures distinctly', () => { + const { diagnostics } = bind( + [ + 'model Post {', + ' ghost Phantom', + ' id Int', + ' @@index([missingField])', + ' @@base(NoSuchModel)', + '}', + ].join('\n'), + ); + + expect(diagnostics.map(({ code, data }) => [code, data?.['reference']])).toEqual([ + ['PSL_UNRESOLVED_REFERENCE', 'type'], + ['PSL_UNRESOLVED_REFERENCE', 'field'], + ['PSL_UNRESOLVED_REFERENCE', 'entity'], + ]); + }); +}); + +describe('the references table records what it examined', () => { + it('records an unresolved entry for a type it could not resolve', () => { + const { symbolTable, binder } = bind('model Cart {\n owner Ghost\n}'); + + expect(binder.symbolForNode(typeNodeOf(symbolTable, 'Cart', 'owner'))).toEqual({ + kind: 'unresolved', + name: 'Ghost', + }); + }); + + it('records an unresolved entry for an attribute argument it could not resolve', () => { + const { symbolTable, binder } = bind('model User {\n id Int\n @@index([missing])\n}'); + const user = symbolTable.topLevel.models['User']!; + const [node] = attributeNodes(user, 'index'); + + expect(node).toBeDefined(); + expect(node === undefined ? undefined : binder.symbolForNode(node)).toEqual({ + kind: 'unresolved', + name: 'missing', + }); + }); + + it('leaves a malformed type unexamined', () => { + const { symbolTable, binder } = bind('model Cart {\n value a.b.c\n}'); + + expect(fieldOf(symbolTable, 'Cart', 'value').malformedType).toBe(true); + expect(binder.symbolForNode(typeNodeOf(symbolTable, 'Cart', 'value'))).toBeUndefined(); + }); +}); diff --git a/packages/1-framework/2-authoring/psl-parser/test/entity-reference.test.ts b/packages/1-framework/2-authoring/psl-parser/test/entity-reference.test.ts index dd23205ed7aa..16e3461c1130 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/entity-reference.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/entity-reference.test.ts @@ -1,7 +1,7 @@ import { ok } from '@internal/utils/result'; import { describe, expect, it } from 'vitest'; import type { EntitySelector } from '../src/exports'; -import { blockAttribute, entityRef, identifier, list, oneOf } from '../src/exports'; +import { createBinder, entityRef, identifier, list, modelAttribute, oneOf } from '../src/exports'; import { parse } from '../src/parse'; import { buildSymbolTable } from '../src/symbol-table'; import { ModelAttributeAst } from '../src/syntax/ast/attributes'; @@ -35,6 +35,30 @@ function fixture(value: string, local = true) { expect(diagnostics).toEqual([]); const namespace = symbolTable.topLevel.namespaces['Local']; if (!namespace) throw new Error('Missing namespace'); + const owner = namespace.models['Owner'] ?? symbolTable.topLevel.models['Owner']; + if (!owner) throw new Error('Missing owner'); + const { binder, diagnostics: binderDiagnostics } = createBinder({ + sources, + symbolTable, + typeConstructors: {}, + attributeSpecs: { + model: { + test: () => + modelAttribute('test', { + documentation: 'fixture', + positional: [ + { + key: 'model', + type: oneOf(entityRef({ kind: 'model' }), list(entityRef({ kind: 'model' }))), + documentation: 'fixture', + }, + ], + }), + }, + field: {}, + }, + controlMutationDefaults: { defaultFunctionRegistry: new Map(), dataTypeEntries: {} }, + }); for (const syntax of document.syntax.descendants()) { if (!(syntax instanceof SyntaxNode)) continue; const attribute = ModelAttributeAst.cast(syntax); @@ -42,7 +66,8 @@ function fixture(value: string, local = true) { if (expression) return { expression, - ctx: { sources, symbols: symbolTable }, + ctx: { sources, symbols: symbolTable, binder, selfModel: owner }, + binderDiagnostics, sources, table: symbolTable, namespace, @@ -52,37 +77,6 @@ function fixture(value: string, local = true) { } describe('syntax-scoped entity resolution', () => { - it('supplies the completed table to existing block attribute rules', () => { - const { document, sources } = parse( - 'namespace Local {\n permission Reader {\n @@target(Later)\n }\n model Later {}\n}', - 'references.prisma', - ); - const target = blockAttribute('target', { - documentation: 'Names a model.', - positional: [ - { key: 'model', type: entityRef({ kind: 'model' }), documentation: 'The selected model.' }, - ], - }); - const result = buildSymbolTable({ - documents: [document], - sources, - pslBlockDescriptors: { - permission: { - name: { required: true }, - kind: 'pslBlock', - keyword: 'permission', - discriminator: 'permission', - parameters: {}, - attributes: { target: () => target }, - }, - }, - }); - expect(result.diagnostics).toEqual([]); - const namespace = result.symbolTable.topLevel.namespaces['Local']; - expect(namespace?.blocks['Reader']?.block.attributes['target']?.args).toEqual({ - model: { declaration: namespace?.models['Later'], namespace }, - }); - }); it('selects the local declaration, including forward references', () => { const { expression, ctx, namespace } = fixture('Shared'); expect(entityRef({ kind: 'model' }).parse(expression, ctx)).toEqual( @@ -119,23 +113,16 @@ describe('syntax-scoped entity resolution', () => { }); it.each(['Hidden', 'Missing', 'toString', 'constructor', '__proto__'])( - 'rejects unavailable and inherited names: %s', + 'rejects unavailable and inherited names, leaving the voice to the binder: %s', (name) => { - const { expression, ctx, sources } = fixture(name); - const sourceFile = sources.sourceFileFor(expression.syntax); + const { expression, ctx, binderDiagnostics } = fixture(name); expect(entityRef({ kind: 'model' }).parse(expression, ctx)).toMatchObject({ ok: false, - failure: [ - { - message: `Unknown model reference "${name}"`, - filename: 'references.prisma', - range: { - start: sourceFile.positionAt(expression.syntax.offset), - end: sourceFile.positionAt(expression.syntax.endOffset), - }, - }, - ], + failure: [], }); + expect(binderDiagnostics.map(({ code, message }) => [code, message])).toEqual([ + ['PSL_UNRESOLVED_REFERENCE', `Cannot find entity "${name}"`], + ]); }, ); @@ -160,7 +147,27 @@ describe('syntax-scoped entity resolution', () => { const attribute = ModelAttributeAst.cast(syntax); const expression = attribute?.argList()?.args()[Symbol.iterator]().next().value?.value(); if (!expression) continue; - const ctx = { sources, symbols: symbolTable }; + const { binder } = createBinder({ + sources, + symbolTable, + typeConstructors: {}, + attributeSpecs: { + model: { + test: () => + modelAttribute('test', { + documentation: 'fixture', + positional: [ + { key: 'model', type: entityRef({ kind: 'model' }), documentation: 'fixture' }, + ], + }), + }, + field: {}, + }, + controlMutationDefaults: { defaultFunctionRegistry: new Map(), dataTypeEntries: {} }, + }); + const selfModel = symbolTable.topLevel.models['Owner']; + if (!selfModel) throw new Error('Missing owner'); + const ctx = { sources, symbols: symbolTable, binder, selfModel }; expect(entityRef({ kind: 'model' }).parse(expression, ctx)).toEqual( ok({ declaration, namespace: undefined }), ); diff --git a/packages/1-framework/2-authoring/psl-parser/test/scope.test.ts b/packages/1-framework/2-authoring/psl-parser/test/scope.test.ts new file mode 100644 index 000000000000..27a6d1a99081 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/scope.test.ts @@ -0,0 +1,141 @@ +import type { AuthoringTypeNamespace } from '@internal/framework-components/authoring'; +import { describe, expect, it } from 'vitest'; +import { contributedTypeScope } from '../src/contributed-type-scope'; +import { parse } from '../src/parse'; +import { + contributedScope, + documentScope, + isNamespaceLike, + lookupMember, + namespaceScope, +} from '../src/scope'; +import { buildSymbolTable } from '../src/symbol-table'; + +const TYPE_CONSTRUCTORS: AuthoringTypeNamespace = { + String: { kind: 'typeConstructor', output: { codecId: 'fixture/scalar@1' } }, + pgvector: { Vector: { kind: 'typeConstructor', output: { codecId: 'fixture/vector@1' } } }, +}; + +function scopesFor(schema: string) { + const { document, sources } = parse(schema, 'scope.prisma'); + const { symbolTable } = buildSymbolTable({ + documents: [document], + sources, + pslBlockDescriptors: {}, + }); + const contributed = contributedScope(contributedTypeScope(TYPE_CONSTRUCTORS)); + const top = documentScope(symbolTable.topLevel, contributed); + return { symbolTable, contributed, top }; +} + +const SCHEMA = [ + 'model Shared {', + ' id Int', + '}', + 'namespace app {', + ' model Item {', + ' id Int', + ' }', + ' model Shared {', + ' id Int', + ' }', + '}', + 'namespace other {', + ' model Hidden {', + ' id Int', + ' }', + '}', +].join('\n'); + +describe('a scope searches itself, then delegates to its parent', () => { + it('answers from the namespace before the document', () => { + const { symbolTable, top } = scopesFor(SCHEMA); + const app = symbolTable.topLevel.namespaces['app']!; + const scope = namespaceScope(app, top); + + expect(scope.lookup('Shared')).toEqual({ + kind: 'model', + symbol: app.models['Shared'], + namespace: app, + }); + }); + + it('reaches the document scope for a name the namespace does not declare', () => { + const { symbolTable, top } = scopesFor(SCHEMA); + const app = symbolTable.topLevel.namespaces['app']!; + const scope = namespaceScope(app, top); + + expect(scope.lookup('Shared')?.kind).toBe('model'); + expect(namespaceScope(symbolTable.topLevel.namespaces['other']!, top).lookup('Shared')).toEqual( + { + kind: 'model', + symbol: symbolTable.topLevel.models['Shared'], + }, + ); + }); + + it('reaches the contributed root through the document scope', () => { + const { symbolTable, top } = scopesFor(SCHEMA); + const scope = namespaceScope(symbolTable.topLevel.namespaces['app']!, top); + + expect(scope.lookup('String')).toMatchObject({ + kind: 'contributedType', + symbol: { name: 'String', path: ['String'] }, + }); + }); + + it('never reaches a sibling namespace', () => { + const { symbolTable, top } = scopesFor(SCHEMA); + const scope = namespaceScope(symbolTable.topLevel.namespaces['app']!, top); + + expect(scope.lookup('Hidden')).toBeUndefined(); + }); + + it('stops at the contributed root, which has no parent', () => { + const { contributed } = scopesFor(SCHEMA); + + expect(contributed.lookup('Nothing')).toBeUndefined(); + }); +}); + +describe('a qualified reference is two steps', () => { + it('finds the qualifier then the member inside it', () => { + const { symbolTable, top } = scopesFor(SCHEMA); + const qualifier = top.lookup('app'); + + expect(qualifier).toEqual({ + kind: 'namespace', + symbol: symbolTable.topLevel.namespaces['app'], + }); + if (qualifier === undefined || !isNamespaceLike(qualifier)) throw new Error('not a namespace'); + expect(lookupMember(qualifier, 'Item')).toEqual({ + kind: 'model', + symbol: symbolTable.topLevel.namespaces['app']!.models['Item'], + namespace: symbolTable.topLevel.namespaces['app'], + }); + expect(lookupMember(qualifier, 'Missing')).toBeUndefined(); + }); + + it('takes the same two steps through a contributed namespace', () => { + const { top } = scopesFor(SCHEMA); + const qualifier = top.lookup('pgvector'); + + expect(qualifier).toMatchObject({ + kind: 'contributedNamespace', + symbol: { name: 'pgvector', path: ['pgvector'] }, + }); + if (qualifier === undefined || !isNamespaceLike(qualifier)) throw new Error('not a namespace'); + expect(lookupMember(qualifier, 'Vector')).toMatchObject({ + kind: 'contributedType', + symbol: { name: 'Vector', path: ['pgvector', 'Vector'] }, + }); + }); + + it('reports a qualifier that resolves to something else as not a namespace', () => { + const { top } = scopesFor(SCHEMA); + const qualifier = top.lookup('Shared'); + + expect(qualifier?.kind).toBe('model'); + expect(qualifier === undefined ? undefined : isNamespaceLike(qualifier)).toBe(false); + }); +}); diff --git a/packages/1-framework/2-authoring/psl-parser/test/symbol-table.test.ts b/packages/1-framework/2-authoring/psl-parser/test/symbol-table.test.ts index 008f471d5fed..6066ef284ab1 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/symbol-table.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/symbol-table.test.ts @@ -224,6 +224,61 @@ namespace blocks { ); }); + it('builds every scope and field record without a prototype', () => { + const result = build(`model constructor { + toString String +} +type valueOf { + hasOwnProperty Int +} +types { + toString = Vector(1536) +} +policy hasOwnProperty {} +namespace propertyIsEnumerable { + model toString { id Int } +}`); + const { topLevel } = result.symbolTable; + const namespace = topLevel.namespaces['propertyIsEnumerable']; + + expect( + [ + topLevel.models, + topLevel.compositeTypes, + topLevel.namedTypes, + topLevel.blocks, + topLevel.namespaces, + namespace?.models, + namespace?.compositeTypes, + namespace?.blocks, + topLevel.models['constructor']?.fields, + topLevel.compositeTypes['valueOf']?.fields, + namespace?.models['toString']?.fields, + ].map((record) => Object.getPrototypeOf(record)), + ).toEqual(Array(11).fill(null)); + }); + + it('reads an undeclared prototype-named key as undefined at every scope', () => { + const result = build(`model constructor { + toString String +} +namespace valueOf { + model toString { id Int } +}`); + const { topLevel } = result.symbolTable; + + expect(topLevel.models['constructor']?.name).toBe('constructor'); + expect(topLevel.models['toString']).toBeUndefined(); + expect(topLevel.compositeTypes['constructor']).toBeUndefined(); + expect(topLevel.namedTypes['toString']).toBeUndefined(); + expect(topLevel.blocks['valueOf']).toBeUndefined(); + expect(topLevel.namespaces['constructor']).toBeUndefined(); + expect(topLevel.models['constructor']?.fields['toString']?.name).toBe('toString'); + expect(topLevel.models['constructor']?.fields['valueOf']).toBeUndefined(); + expect(topLevel.namespaces['valueOf']?.models['toString']?.name).toBe('toString'); + expect(topLevel.namespaces['valueOf']?.models['constructor']).toBeUndefined(); + }); + it('preserves collisions between namespaces and other top-level declarations', () => { const result = build(`model First {} namespace First {} diff --git a/packages/1-framework/2-authoring/psl-parser/test/syntax/red.test.ts b/packages/1-framework/2-authoring/psl-parser/test/syntax/red.test.ts index 8faddcb05106..7b769c2546e9 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/syntax/red.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/syntax/red.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest'; import { parse } from '../../src/parse'; import { FieldDeclarationAst, ModelDeclarationAst } from '../../src/syntax/ast/declarations'; +import type { GreenNode } from '../../src/syntax/green'; import { GreenNodeBuilder } from '../../src/syntax/green-builder'; +import type { SyntaxElement } from '../../src/syntax/red'; import { createSyntaxTree, SyntaxNode, SyntaxToken, TokenAtOffset } from '../../src/syntax/red'; import type { SyntaxKind } from '../../src/syntax/syntax-kind'; @@ -559,6 +561,120 @@ describe('SyntaxNode.coveringElement', () => { }); }); +describe('red element identity', () => { + function expectSameElements(left: readonly SyntaxElement[], right: readonly SyntaxElement[]) { + expect(right).toHaveLength(left.length); + left.forEach((el, i) => { + expect(right[i]).toBe(el); + }); + } + + it('returns the same wrapper from repeated childAt', () => { + const root = createSyntaxTree(buildSampleTree()); + const model = firstNodeOfKind(root, 'ModelDeclaration'); + expect(model.childAt(0)).toBe(model.childAt(0)); + expect(model.childAt(3)).toBe(model.childAt(3)); + expect(root.childAt(0)).toBe(root.childAt(0)); + }); + + it('returns the same wrappers across two children() walks', () => { + const root = createSyntaxTree(buildSampleTree()); + const model = firstNodeOfKind(root, 'ModelDeclaration'); + expectSameElements(Array.from(model.children()), Array.from(model.children())); + }); + + it('agrees between children() and childAt', () => { + const root = createSyntaxTree(buildSampleTree()); + const model = firstNodeOfKind(root, 'ModelDeclaration'); + const children = Array.from(model.children()); + children.forEach((el, i) => { + expect(model.childAt(i)).toBe(el); + }); + }); + + it('returns the same wrapper from repeated firstChild / lastChild', () => { + const root = createSyntaxTree(buildSampleTree()); + const model = firstNodeOfKind(root, 'ModelDeclaration'); + expect(root.firstChild).toBe(root.firstChild); + expect(model.firstChild).toBe(model.childAt(0)); + expect(model.lastChild).toBe(model.lastChild); + expect(model.lastChild).toBe(model.childAt(model.green.children.length - 1)); + }); + + it('returns the same wrapper from repeated sibling navigation', () => { + const root = createSyntaxTree(buildSampleTree()); + const model = firstNodeOfKind(root, 'ModelDeclaration'); + const name = model.childAt(2); + expect(name).toBeInstanceOf(SyntaxNode); + if (name instanceof SyntaxNode) { + expect(name.nextSibling).toBe(name.nextSibling); + expect(name.nextSibling).toBe(model.childAt(3)); + expect(name.prevSibling).toBe(model.childAt(1)); + expect(name.nextSibling?.prevSiblingOrToken).toBe(name); + } + }); + + it('returns the same ancestors from different descent paths', () => { + const root = createSyntaxTree(buildSampleTree()); + const field = firstNodeOfKind(root, 'FieldDeclaration'); + const viaDescendants = firstNodeOfKind(field, 'Identifier'); + const viaToken = root.tokenAtOffset(16).leftBiased()?.parent; + + expect(viaToken).toBe(viaDescendants); + expectSameElements(Array.from(viaDescendants.ancestors()), [ + field, + firstNodeOfKind(root, 'ModelDeclaration'), + root, + ]); + expectSameElements( + Array.from(viaDescendants.ancestors()), + Array.from(viaDescendants.ancestors()), + ); + expect(viaDescendants.root()).toBe(root); + }); + + it('returns the same token from repeated tokenAtOffset', () => { + const root = createSyntaxTree(buildSampleTree()); + expect(root.tokenAtOffset(19).leftBiased()).toBe(root.tokenAtOffset(19).leftBiased()); + + const seam = root.tokenAtOffset(5); + const seamAgain = root.tokenAtOffset(5); + expect(seam.leftBiased()).toBe(seamAgain.leftBiased()); + expect(seam.rightBiased()).toBe(seamAgain.rightBiased()); + }); + + it('returns the same element from repeated coveringElement', () => { + const root = createSyntaxTree(buildSampleTree()); + expect(root.coveringElement(18, 21)).toBe(root.coveringElement(18, 21)); + expect(root.coveringElement(15, 25)).toBe(root.coveringElement(15, 25)); + expect(root.coveringElement(18, 21)).toBe(root.tokenAtOffset(19).leftBiased()); + expect(root.coveringElement(15, 25)).toBe(firstNodeOfKind(root, 'FieldDeclaration')); + }); + + it('returns the same tokens across two traversals of a parsed document', () => { + const { document } = parse(SAMPLE_SOURCE, 'test.psl'); + const root = document.syntax; + expectSameElements(Array.from(root.tokens()), Array.from(root.tokens())); + expectSameElements(Array.from(root.descendants()), Array.from(root.descendants())); + }); + + it('keys a WeakMap side table stably across traversals', () => { + const root = createSyntaxTree(buildSampleTree()); + const table = new WeakMap(); + table.set(firstNodeOfKind(root, 'FieldDeclaration'), 'field'); + expect(table.get(firstNodeOfKind(root, 'FieldDeclaration'))).toBe('field'); + }); + + it('keeps distinct trees over the same green node distinct', () => { + const green = buildSampleTree(); + const first = createSyntaxTree(green); + const second = createSyntaxTree(green); + expect(first).not.toBe(second); + expect(first.firstChild).not.toBe(second.firstChild); + expect(first.firstChild).toBe(first.firstChild); + }); +}); + describe('SyntaxNode.descendants', () => { it('yields elements in depth-first pre-order', () => { const b = new GreenNodeBuilder(); @@ -625,3 +741,57 @@ describe('zero-width node precondition', () => { }); } }); + +describe('red child-slot laziness', () => { + function watchedChildren(childCount: number) { + const b = new GreenNodeBuilder(); + b.startNode('Document'); + for (let i = 0; i < childCount; i++) { + b.startNode('Identifier'); + b.token('Ident', `n${i}`); + b.finishNode(); + } + const green = b.finishNode(); + const touched = new Set(); + const watched: GreenNode = { + ...green, + children: new Proxy(green.children, { + get(target, property, receiver) { + if (typeof property === 'string' && /^\d+$/.test(property)) { + touched.add(Number(property)); + } + return Reflect.get(target, property, receiver); + }, + }), + }; + return { root: createSyntaxTree(watched), touched }; + } + + it('reaches no further than the child asked for', () => { + const { root, touched } = watchedChildren(6); + touched.clear(); + + root.childAt(1); + + expect([...touched].sort((left, right) => left - right)).toEqual([0, 1]); + }); + + it('leaves later siblings untouched when an early child is wrapped', () => { + const { root, touched } = watchedChildren(6); + touched.clear(); + + root.firstChild; + + expect(touched.has(5)).toBe(false); + }); + + it('still materializes every child when the whole row is iterated', () => { + const { root, touched } = watchedChildren(6); + touched.clear(); + + const children = Array.from(root.children()); + + expect(children).toHaveLength(6); + expect(touched.has(5)).toBe(true); + }); +}); diff --git a/packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts b/packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts index 9e59d4c4ba2c..c1f507d15319 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts @@ -40,6 +40,7 @@ import { mongoContractCanonicalizationHooks } from '@internal/mongo-contract/can import type { CollationOptions } from '@internal/mongo-value/mongodb-types'; import type { AttributeSpecContext, + Binder, BlockSymbol, CompositeTypeSymbol, FieldSymbol, @@ -71,6 +72,7 @@ import { ifDefined } from '@internal/utils/defined'; import { notOk, ok, type Result } from '@internal/utils/result'; import { deriveJsonSchema, derivePolymorphicJsonSchema } from './derive-json-schema'; import { + createMongoBinder, findFieldAttributeNode, findModelAttributeNode, interpretFieldAttribute, @@ -114,6 +116,7 @@ export interface InterpretPslDocumentToMongoContractInput { function validateNamespaceBlocksForMongoTarget(input: { readonly namespaces: readonly NamespaceSymbol[]; readonly sources: PslSources; + readonly binder: Binder; readonly diagnostics: PslDiagnosticCollector; }): void { for (const namespace of input.namespaces) { @@ -127,54 +130,6 @@ function validateNamespaceBlocksForMongoTarget(input: { } } -const UNLOWERED_FIELD_ATTRIBUTE_HINTS: ReadonlyMap = new Map([ - [ - 'updatedAt', - 'Mongo lowers no automatic timestamp updates; delete the attribute and set the timestamp in application code.', - ], -]); - -function unsupportedFieldAttributeMessage( - ownerName: string, - fieldName: string, - attributeName: string, -): string { - const base = `Field "${ownerName}.${fieldName}" uses unsupported attribute "@${attributeName}"`; - const hint = UNLOWERED_FIELD_ATTRIBUTE_HINTS.get(attributeName); - return hint === undefined ? base : `${base}. ${hint}`; -} - -function reportUnknownAttributes(input: { - readonly models: readonly ModelSymbol[]; - readonly compositeTypes: readonly CompositeTypeSymbol[]; - readonly sources: PslSources; - readonly diagnostics: PslDiagnosticCollector; -}): void { - const { sources, diagnostics } = input; - for (const model of input.models) { - for (const attribute of model.attributes) { - if (Object.hasOwn(mongoAttributeSpecs.model, attribute.name)) continue; - diagnostics.push({ - code: 'PSL_UNSUPPORTED_MODEL_ATTRIBUTE', - message: `Model "${model.name}" uses unsupported attribute "@@${attribute.name}"`, - ...diagnosticSource(sources, model.node.syntax).at(attribute.span), - }); - } - } - for (const owner of [...input.models, ...input.compositeTypes]) { - for (const field of Object.values(owner.fields)) { - for (const attribute of field.attributes) { - if (Object.hasOwn(mongoAttributeSpecs.field, attribute.name)) continue; - diagnostics.push({ - code: 'PSL_UNSUPPORTED_FIELD_ATTRIBUTE', - message: unsupportedFieldAttributeMessage(owner.name, field.name, attribute.name), - ...diagnosticSource(sources, field.node.syntax).at(attribute.span), - }); - } - } - } -} - interface FieldMappings { readonly pslNameToMapped: Map; } @@ -213,9 +168,10 @@ function resolveFieldMappings(input: { readonly model: ModelSymbol; readonly specContext: AttributeSpecContext; readonly sources: PslSources; + readonly binder: Binder; readonly diagnostics: PslDiagnosticCollector; }): FieldMappings { - const { model, specContext, sources, diagnostics } = input; + const { model, specContext, sources, binder, diagnostics } = input; const pslNameToMapped = new Map(); for (const field of Object.values(model.fields)) { const mapNode = findFieldAttributeNode(field, 'map'); @@ -228,6 +184,7 @@ function resolveFieldMappings(input: { model, field, sources, + binder, diagnostics, })?.name : undefined) ?? field.name; @@ -240,9 +197,10 @@ function resolveCollectionName(input: { readonly model: ModelSymbol; readonly specContext: AttributeSpecContext; readonly sources: PslSources; + readonly binder: Binder; readonly diagnostics: PslDiagnosticCollector; }): string { - const { model, specContext, sources, diagnostics } = input; + const { model, specContext, sources, binder, diagnostics } = input; const mapNode = findModelAttributeNode(model, 'map'); const name = mapNode ? interpretModelAttribute({ @@ -251,6 +209,7 @@ function resolveCollectionName(input: { spec: mongoAttributeSpecs.model.map(specContext), model, sources, + binder, diagnostics, })?.name : undefined; @@ -288,6 +247,7 @@ function collectPolymorphismDeclarations( specContextFor: (model: ModelSymbol) => AttributeSpecContext, modelMetadataByName: ReadonlyMap, sources: PslSources, + binder: Binder, diagnostics: PslDiagnosticCollector, ): { discriminatorDeclarations: Map; @@ -306,6 +266,7 @@ function collectPolymorphismDeclarations( spec: mongoAttributeSpecs.model.discriminator(specContext), model, sources, + binder, diagnostics, }); if (parsed) { @@ -335,6 +296,7 @@ function collectPolymorphismDeclarations( spec: mongoAttributeSpecs.model.base(), model, sources, + binder, diagnostics, }); if (parsed) { @@ -859,6 +821,7 @@ function collectIndexes( fieldMappings: FieldMappings, modelNames: ReadonlySet, sources: PslSources, + binder: Binder, diagnostics: PslDiagnosticCollector, indexSpans: Map, indexSources: Map, @@ -881,6 +844,7 @@ function collectIndexes( model: pslModel, field, sources, + binder, diagnostics, }); if (unique === undefined) continue; @@ -917,6 +881,7 @@ function collectIndexes( spec: mongoAttributeSpecs.model.textIndex(specContext), model: pslModel, sources, + binder, diagnostics, }); if (!parsed || parsed.fields.length === 0) continue; @@ -940,6 +905,7 @@ function collectIndexes( : mongoAttributeSpecs.model.index(specContext), model: pslModel, sources, + binder, diagnostics, }); if (!parsed) continue; @@ -1028,6 +994,7 @@ function resolveNonRelationField( function processEnumDeclarations(input: { readonly enumSymbols: readonly BlockSymbol[]; readonly sources: PslSources; + readonly binder: Binder; readonly authoringContributions: AuthoringContributions | undefined; readonly entityContext: AuthoringEntityContext; readonly diagnostics: PslDiagnosticCollector; @@ -1085,22 +1052,27 @@ export function interpretPslDocumentToMongoContract( ): Result { const { symbolTable, sources, scalarTypeCodecIds, codecLookup } = input; const diagnostics = createPslDiagnosticCollector(sources); + const { binder, diagnostics: binderDiagnostics } = createMongoBinder({ + symbolTable, + sources, + scalarTypeCodecIds, + controlMutationDefaults: input.controlMutationDefaults, + authoringContributions: input.authoringContributions, + }); + diagnostics.push( + ...binderDiagnostics.filter((diagnostic) => diagnostic.data?.['reference'] !== 'type'), + ); const topLevel = symbolTable.topLevel; validateNamespaceBlocksForMongoTarget({ namespaces: Object.values(topLevel.namespaces), sources, + binder, diagnostics, }); const allModels: ModelSymbol[] = Object.values(topLevel.models); const allCompositeTypes: CompositeTypeSymbol[] = Object.values(topLevel.compositeTypes); const modelNames = new Set(allModels.map((m) => m.name)); const compositeTypeNames = new Set(allCompositeTypes.map((ct) => ct.name)); - reportUnknownAttributes({ - models: allModels, - compositeTypes: allCompositeTypes, - sources, - diagnostics, - }); const specContextFor = (model: ModelSymbol): AttributeSpecContext => ({ symbols: symbolTable, model, @@ -1114,12 +1086,14 @@ export function interpretPslDocumentToMongoContract( model, specContext, sources, + binder, diagnostics, }), fieldMappings: resolveFieldMappings({ model, specContext, sources, + binder, diagnostics, }), }); @@ -1130,6 +1104,7 @@ export function interpretPslDocumentToMongoContract( const builtEnums = processEnumDeclarations({ enumSymbols: topLevelEnumSymbols, sources, + binder, authoringContributions: input.authoringContributions, entityContext: { family: 'mongo', @@ -1191,8 +1166,8 @@ export function interpretPslDocumentToMongoContract( model: pslModel, field, sources, + binder, diagnostics, - resolveReferencedModel: () => allModels.find((m) => m.name === field.typeName), }) : undefined; @@ -1281,6 +1256,7 @@ export function interpretPslDocumentToMongoContract( model: pslModel, field, sources, + binder, diagnostics, }) !== undefined ); @@ -1323,6 +1299,7 @@ export function interpretPslDocumentToMongoContract( fieldMappings, modelNames, sources, + binder, diagnostics, indexSpans, indexSources, @@ -1428,6 +1405,7 @@ export function interpretPslDocumentToMongoContract( specContextFor, modelMetadataByName, sources, + binder, diagnostics, ); const polyResult = resolvePolymorphism({ diff --git a/packages/2-mongo-family/2-authoring/contract-psl/src/mongo-attribute-specs.ts b/packages/2-mongo-family/2-authoring/contract-psl/src/mongo-attribute-specs.ts index 0f319faa5db6..c40b7e3a4ec9 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/src/mongo-attribute-specs.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/src/mongo-attribute-specs.ts @@ -1,8 +1,15 @@ +import type { + AuthoringContributions, + AuthoringTypeConstructorDescriptor, +} from '@internal/framework-components/authoring'; +import type { ControlDefaultRegistries } from '@internal/framework-components/control'; import type { ArgType, AttributeSpec, AttributeSpecContext, AttributeSpecNamespace, + Binder, + DescribeUnsupportedAttribute, FieldAttributeCtx, FieldAttributeSpecContext, FieldSymbol, @@ -10,11 +17,14 @@ import type { InferAttr, ModelAttributeCtx, ModelSymbol, + PslDiagnostic, SymbolTable, TypedFuncCall, } from '@internal/psl-parser'; import { bool, + createBinder, + diagnosticSource, entityRef, fieldAttribute, fieldRef, @@ -59,10 +69,12 @@ function buildModelAttributeCtx(input: { readonly symbols: SymbolTable; readonly selfModel: ModelSymbol; readonly sources: PslSources; + readonly binder: Binder; }): ModelAttributeCtx { return { sources: input.sources, selfModel: input.selfModel, + binder: input.binder, symbols: input.symbols, }; } @@ -72,17 +84,65 @@ function buildFieldAttributeCtx(input: { readonly selfModel: ModelSymbol; readonly field: FieldSymbol; readonly sources: PslSources; - readonly resolveReferencedModel?: (() => ModelSymbol | undefined) | undefined; + readonly binder: Binder; }): FieldAttributeCtx { return { sources: input.sources, selfModel: input.selfModel, - resolveReferencedModel: input.resolveReferencedModel ?? (() => undefined), field: input.field, + binder: input.binder, symbols: input.symbols, }; } +const UNLOWERED_FIELD_ATTRIBUTE_HINTS: ReadonlyMap = new Map([ + [ + 'updatedAt', + 'Mongo lowers no automatic timestamp updates; delete the attribute and set the timestamp in application code.', + ], +]); + +function describeUnsupportedMongoAttribute(sources: PslSources): DescribeUnsupportedAttribute { + return ({ attribute, level, owner, field }) => { + if (level === 'model') { + return { + code: 'PSL_UNSUPPORTED_MODEL_ATTRIBUTE', + message: `Model "${owner.name}" uses unsupported attribute "@@${attribute.name}"`, + ...diagnosticSource(sources, owner.node.syntax).at(attribute.span), + }; + } + if (field === undefined) return undefined; + const base = `Field "${owner.name}.${field.name}" uses unsupported attribute "@${attribute.name}"`; + const hint = UNLOWERED_FIELD_ATTRIBUTE_HINTS.get(attribute.name); + return { + code: 'PSL_UNSUPPORTED_FIELD_ATTRIBUTE', + message: hint === undefined ? base : `${base}. ${hint}`, + ...diagnosticSource(sources, field.node.syntax).at(attribute.span), + }; + }; +} + +export function createMongoBinder(input: { + readonly symbolTable: SymbolTable; + readonly sources: PslSources; + readonly scalarTypeCodecIds: ReadonlyMap; + readonly controlMutationDefaults: ControlDefaultRegistries; + readonly authoringContributions?: AuthoringContributions | undefined; +}): { readonly binder: Binder; readonly diagnostics: readonly PslDiagnostic[] } { + const scalars: Record = {}; + for (const [name, codecId] of input.scalarTypeCodecIds) { + scalars[name] = { kind: 'typeConstructor', output: { codecId } }; + } + return createBinder({ + sources: input.sources, + symbolTable: input.symbolTable, + typeConstructors: { ...scalars, ...(input.authoringContributions?.type ?? {}) }, + attributeSpecs: mongoAttributeSpecs, + controlMutationDefaults: input.controlMutationDefaults, + describeUnsupportedAttribute: describeUnsupportedMongoAttribute(input.sources), + }); +} + // Interpret a model-level attribute node against its spec, draining any parse // failures into `diagnostics`. Returns the typed value, or `undefined` on // failure so the caller can apply its own default/absence handling. @@ -92,6 +152,7 @@ export function interpretModelAttribute(input: { readonly spec: AttributeSpec; readonly model: ModelSymbol; readonly sources: PslSources; + readonly binder: Binder; readonly diagnostics: PslDiagnosticCollector; }): Out | undefined { const result = interpretAttribute( @@ -101,6 +162,7 @@ export function interpretModelAttribute(input: { symbols: input.symbols, selfModel: input.model, sources: input.sources, + binder: input.binder, }), ); if (!result.ok) { @@ -120,8 +182,8 @@ export function interpretFieldAttribute(input: { readonly model: ModelSymbol; readonly field: FieldSymbol; readonly sources: PslSources; + readonly binder: Binder; readonly diagnostics: PslDiagnosticCollector; - readonly resolveReferencedModel?: () => ModelSymbol | undefined; }): Out | undefined { const result = interpretAttribute( input.node, @@ -131,7 +193,7 @@ export function interpretFieldAttribute(input: { selfModel: input.model, field: input.field, sources: input.sources, - resolveReferencedModel: input.resolveReferencedModel, + binder: input.binder, }), ); if (!result.ok) { diff --git a/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter-test-helpers.ts b/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter-test-helpers.ts index 65bded39164a..c4fae47fbd1e 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter-test-helpers.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter-test-helpers.ts @@ -20,3 +20,19 @@ export function expectInvalidAttributeSyntax( expect(diagnostic.message).toMatch(message); return diagnostic; } + +export function expectUnresolvedReference( + result: Result, + message: RegExp, +): ContractSourceDiagnostic { + expect(result.ok).toBe(false); + if (result.ok) throw new Error('Expected interpretation to fail'); + const diagnostics = result.failure.diagnostics.filter( + (diagnostic) => diagnostic.code === 'PSL_UNRESOLVED_REFERENCE', + ); + expect(diagnostics).toHaveLength(1); + const diagnostic = diagnostics[0]; + if (!diagnostic) throw new Error('Expected PSL_UNRESOLVED_REFERENCE diagnostic'); + expect(diagnostic.message).toMatch(message); + return diagnostic; +} diff --git a/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts b/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts index 1ebfb6211f20..5663ec0987fb 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts @@ -12,7 +12,10 @@ import type { DocumentAst, PslSources } from '@internal/psl-parser/syntax'; import { parse } from '@internal/psl-parser/syntax'; import { describe, expect, it } from 'vitest'; import { interpretPslDocumentToMongoContract } from '../src/interpreter'; -import { expectInvalidAttributeSyntax } from './interpreter-test-helpers'; +import { + expectInvalidAttributeSyntax, + expectUnresolvedReference, +} from './interpreter-test-helpers'; const mongoScalarTypeDescriptors: ReadonlyMap = new Map([ ['String', 'mongo/string@1'], @@ -368,8 +371,8 @@ namespace scoped { expect(result.failure.diagnostics).toEqual( expect.arrayContaining([ expect.objectContaining({ - code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', - message: expect.stringContaining('does not exist'), + code: 'PSL_UNRESOLVED_REFERENCE', + message: expect.stringContaining('Cannot find field'), }), ]), ); @@ -447,8 +450,8 @@ namespace scoped { expect(result.failure.diagnostics).toEqual( expect.arrayContaining([ expect.objectContaining({ - code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', - message: 'Unknown model reference "NonExistent"', + code: 'PSL_UNRESOLVED_REFERENCE', + message: expect.stringContaining('Cannot find entity'), }), ]), ); @@ -745,7 +748,7 @@ namespace scoped { } `); - const diag = expectInvalidAttributeSyntax(result, /Expected one of/); + const diag = expectUnresolvedReference(result, /Cannot find field "title"/); expect(diag.span?.start.offset).toBeGreaterThan(0); }); }); diff --git a/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.single-voice.test.ts b/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.single-voice.test.ts new file mode 100644 index 000000000000..d3abfe59e578 --- /dev/null +++ b/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.single-voice.test.ts @@ -0,0 +1,92 @@ +import { buildSymbolTable, type SymbolTable } from '@internal/psl-parser'; +import type { DocumentAst, PslSources } from '@internal/psl-parser/syntax'; +import { parse } from '@internal/psl-parser/syntax'; +import { describe, expect, it } from 'vitest'; +import { interpretPslDocumentToMongoContract } from '../src/interpreter'; + +function symbolTableInput(schema: string): { + documents: readonly DocumentAst[]; + symbolTable: SymbolTable; + sources: PslSources; +} { + const { document, sources } = parse(schema, 'test.prisma'); + const { symbolTable } = buildSymbolTable({ + documents: [document], + sources, + pslBlockDescriptors: {}, + }); + return { documents: [document], symbolTable, sources }; +} + +const scalarTypeCodecIds: ReadonlyMap = new Map([ + ['String', 'mongo/string@1'], + ['Int', 'mongo/int32@1'], + ['ObjectId', 'mongo/objectId@1'], +]); + +function diagnosticCodes(schema: string): readonly string[] { + const result = interpretPslDocumentToMongoContract({ + ...symbolTableInput(schema), + scalarTypeCodecIds, + controlMutationDefaults: { + defaultFunctionRegistry: new Map(), + dataTypeEntries: {}, + }, + }); + if (result.ok) throw new Error('expected interpretation to fail'); + return result.failure.diagnostics.map((diagnostic) => diagnostic.code); +} + +describe('one voice per resolution failure', () => { + it('reports an unknown model attribute only as unsupported', () => { + expect(diagnosticCodes('model Item {\n id ObjectId @id @map("_id")\n @@mystery\n}')).toEqual([ + 'PSL_UNSUPPORTED_MODEL_ATTRIBUTE', + ]); + }); + + it('reports a missing @@base target only as an unresolved reference', () => { + expect( + diagnosticCodes('model Bug {\n id ObjectId @id @map("_id")\n @@base(NoSuch, "bug")\n}'), + ).toEqual(['PSL_UNRESOLVED_REFERENCE']); + }); + + it('reports an unknown @@index field only as an unresolved reference', () => { + expect( + diagnosticCodes('model Item {\n id ObjectId @id @map("_id")\n @@index([nope])\n}'), + ).toEqual(['PSL_UNRESOLVED_REFERENCE']); + }); + + it('keeps the orphaned-backrelation verdict beside an unresolved relation field', () => { + expect( + diagnosticCodes( + [ + 'model User {', + ' id ObjectId @id @map("_id")', + '}', + 'model Post {', + ' id ObjectId @id @map("_id")', + ' authorId ObjectId', + ' author User @relation(fields: [missing], references: [id])', + '}', + ].join('\n'), + ), + ).toEqual(['PSL_UNRESOLVED_REFERENCE', 'PSL_ORPHANED_BACKRELATION']); + }); + + it('keeps the orphaned-base verdict beside an unresolved discriminator field', () => { + expect( + diagnosticCodes( + [ + 'model Base {', + ' id ObjectId @id @map("_id")', + ' @@discriminator(nope)', + '}', + 'model Child {', + ' id ObjectId @id @map("_id")', + ' @@base(Base, "c")', + '}', + ].join('\n'), + ), + ).toEqual(['PSL_UNRESOLVED_REFERENCE', 'PSL_ORPHANED_BASE']); + }); +}); diff --git a/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.test.ts b/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.test.ts index 6917e6e91874..16005e9009d2 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.test.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.test.ts @@ -23,7 +23,10 @@ import { type InterpretPslDocumentToMongoContractInput, interpretPslDocumentToMongoContract, } from '../src/interpreter'; -import { expectInvalidAttributeSyntax } from './interpreter-test-helpers'; +import { + expectInvalidAttributeSyntax, + expectUnresolvedReference, +} from './interpreter-test-helpers'; function buildSymbolTableInput( schema: string, @@ -716,7 +719,16 @@ describe('interpretPslDocumentToMongoContract', () => { author User @relation(fields: [missing], references: [id]) } `); - expectInvalidAttributeSyntax(result, /missing.*does not exist/i); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_UNRESOLVED_REFERENCE', + message: expect.stringContaining('Cannot find field "missing"'), + }), + ]), + ); }); }); @@ -1850,7 +1862,7 @@ describe('interpretPslDocumentToMongoContract', () => { @@index([nonexistent]) } `); - const diag = expectInvalidAttributeSyntax(result, /Expected one of/); + const diag = expectUnresolvedReference(result, /Cannot find field "nonexistent"/); expect(diag.span?.start.offset).toBeGreaterThan(0); expect(diag.span?.end.offset).toBeGreaterThan(diag.span?.start.offset ?? 0); }); @@ -1863,7 +1875,7 @@ describe('interpretPslDocumentToMongoContract', () => { @@unique([nonexistent]) } `); - expectInvalidAttributeSyntax(result, /Expected one of/); + expectUnresolvedReference(result, /Cannot find field/); }); it('rejects @@textIndex that references an undeclared field', () => { @@ -1874,7 +1886,7 @@ describe('interpretPslDocumentToMongoContract', () => { @@textIndex([nonexistent]) } `); - expectInvalidAttributeSyntax(result, /Expected one of/); + expectUnresolvedReference(result, /Cannot find field "nonexistent"/); }); it('rejects @@index wildcard scope referencing an undeclared field', () => { @@ -1906,9 +1918,7 @@ describe('interpretPslDocumentToMongoContract', () => { const result = interpret(source); expect(result.ok).toBe(false); if (result.ok) return; - const diags = result.failure.diagnostics.filter( - (d) => d.code === 'PSL_INVALID_ATTRIBUTE_SYNTAX', - ); + const diags = result.failure.diagnostics.filter((d) => d.code === 'PSL_UNRESOLVED_REFERENCE'); expect(diags).toHaveLength(1); expect(diags[0]?.span).toMatchObject({ start: { offset: source.indexOf('nonexistent') }, diff --git a/packages/2-mongo-family/2-authoring/contract-psl/test/mongo-attribute-specs.test.ts b/packages/2-mongo-family/2-authoring/contract-psl/test/mongo-attribute-specs.test.ts index fadb7f8880ad..6c82caea82af 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/test/mongo-attribute-specs.test.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/test/mongo-attribute-specs.test.ts @@ -13,6 +13,7 @@ import { buildSymbolTable, createPslDiagnosticCollector } from '@internal/psl-pa import { parse } from '@internal/psl-parser/syntax'; import { describe, expect, expectTypeOf, it } from 'vitest'; import { + createMongoBinder, findModelAttributeNode, interpretModelAttribute, mongoAttributeSpecs, @@ -129,6 +130,12 @@ model Base { id String }`, spec: mongoAttributeSpecs.model.base(), model, sources, + binder: createMongoBinder({ + symbolTable, + sources, + scalarTypeCodecIds: new Map(), + controlMutationDefaults: { defaultFunctionRegistry: new Map(), dataTypeEntries: {} }, + }).binder, diagnostics, }); expectTypeOf(value).toEqualTypeOf< diff --git a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts index 256d6928f992..e1763b690a0b 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts @@ -42,6 +42,7 @@ import type { MutationDefaultGeneratorDescriptor, } from '@internal/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import type { Binder } from '@internal/psl-parser'; import { type BlockSymbol, type CompositeTypeSymbol, @@ -94,12 +95,12 @@ import type { ColumnDescriptor } from './psl-column-resolution'; import { checkUncomposedNamespace, getAuthoringEntity, - reportUncomposedNamespace, resolveFieldTypeDescriptor, } from './psl-column-resolution'; import { buildModelMappings, collectResolvedFields, + describeUnsupportedSqlAttribute, type ModelNameMapping, type ModelNamespaceEntry, modelCoordinateKey, @@ -116,8 +117,10 @@ import { validateBackrelationFieldAttributes, } from './psl-relation-resolution'; import { + createSqlBinder, findModelAttributeNode, interpretModelAttribute, + modelAttributeSpecsFrom, PSL_CHECK_ON_STI_VARIANT, sqlAttributeSpecs, } from './sql-attribute-specs'; @@ -275,6 +278,7 @@ function validateNamespaceBlocksForSqlTarget(input: { readonly targetId: string; readonly source: DiagnosticSource; readonly sources: PslSources; + readonly binder: Binder; readonly diagnostics: PslDiagnosticCollector; }): void { if (input.targetId === 'sqlite') { @@ -642,6 +646,7 @@ interface BuildModelNodeInput { readonly generatorDescriptorById: ReadonlyMap; readonly scalarColumnDescriptors: ReadonlyMap; readonly sources: PslSources; + readonly binder: Binder; readonly symbolTable: SymbolTable; readonly diagnostics: PslDiagnosticCollector; /** Resolved namespace id keyed by model name — used to stamp the target namespace on FKs. */ @@ -663,6 +668,7 @@ interface BuildModelNodeInput { readonly codecLookup?: CodecLookup; /** Contributed model-attribute descriptors keyed by bare `@@` attribute name (the exact shape `buildModelAttributesByName` produces). */ readonly modelAttributesByName: ReadonlyMap; + readonly contributedModelAttributeSpecs: Readonly>; /** The target's default namespace id — the lowering context's `namespaceId` fallback for a model with no explicit PSL namespace. */ readonly defaultNamespaceId: string; } @@ -753,6 +759,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult generatorDescriptorById: input.generatorDescriptorById, diagnostics, sources: input.sources, + binder: input.binder, scalarColumnDescriptors: input.scalarColumnDescriptors, ...ifDefined('enumHandles', input.enumHandles), capabilities: input.capabilities, @@ -802,6 +809,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult modelName: model.name, field, sources: input.sources, + binder: input.binder, composedExtensions: input.composedExtensions, authoringContributions: input.authoringContributions, diagnostics, @@ -815,6 +823,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult field, symbols: input.symbolTable, sources: input.sources, + binder: input.binder, diagnostics, }); if (!parsedRelation) { @@ -880,30 +889,6 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult !Object.hasOwn(sqlAttributeSpecs.model, modelAttribute.name) && !input.modelAttributesByName.has(modelAttribute.name) ) { - const uncomposedNamespace = checkUncomposedNamespace( - modelAttribute.name, - input.composedExtensions, - { - familyId: input.familyId, - targetId: input.targetId, - authoringContributions: input.authoringContributions, - }, - ); - if (uncomposedNamespace) { - reportUncomposedNamespace({ - subjectLabel: `Attribute "@@${modelAttribute.name}"`, - namespace: uncomposedNamespace, - source, - span: modelAttribute.span, - diagnostics, - }); - continue; - } - diagnostics.push({ - code: 'PSL_UNSUPPORTED_MODEL_ATTRIBUTE', - message: `Model "${model.name}" uses unsupported attribute "@@${modelAttribute.name}"`, - ...source.at(modelAttribute.span), - }); continue; } if (modelAttribute.name === 'map') { @@ -935,6 +920,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult model, symbols: input.symbolTable, sources: input.sources, + binder: input.binder, diagnostics, }); if (parsed !== undefined) { @@ -971,6 +957,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult model, symbols: input.symbolTable, sources: input.sources, + binder: input.binder, diagnostics, }); if (parsed === undefined) { @@ -1016,6 +1003,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult model, symbols: input.symbolTable, sources: input.sources, + binder: input.binder, diagnostics, }); if (parsed === undefined) { @@ -1050,6 +1038,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult model, symbols: input.symbolTable, sources: input.sources, + binder: input.binder, diagnostics, }); if (parsed === undefined) { @@ -1110,6 +1099,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult model, symbols: input.symbolTable, sources: input.sources, + binder: input.binder, diagnostics, }); if (parsed === undefined) { @@ -1143,10 +1133,10 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult if (node === undefined) { continue; } - const specFactory = blindCast< - ModelAttributeSpecFactory, - 'contributed model-attribute descriptors carry an ADR-231 attribute-spec factory by construction' - >(contributedModelAttribute.spec); + const specFactory = input.contributedModelAttributeSpecs[contributedModelAttribute.attribute]; + if (specFactory === undefined) { + continue; + } const parsed = interpretModelAttribute({ node, spec: specFactory({ @@ -1160,6 +1150,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult model, symbols: input.symbolTable, sources: input.sources, + binder: input.binder, diagnostics, }); if (parsed === undefined) { @@ -1265,6 +1256,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult field: relationAttribute.field, symbols: input.symbolTable, sources: input.sources, + binder: input.binder, diagnostics, }); if (!parsedRelation) { @@ -1424,6 +1416,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult field: relationAttribute.field, symbols: input.symbolTable, sources: input.sources, + binder: input.binder, diagnostics, }); if (!parsedRelation) { @@ -1583,6 +1576,7 @@ interface BuildValueObjectsInput { readonly authoringContributions: AuthoringContributions | undefined; readonly diagnostics: PslDiagnosticCollector; readonly sources: PslSources; + readonly binder: Binder; } function buildValueObjects(input: BuildValueObjectsInput): Record { @@ -1709,6 +1703,7 @@ function collectPolymorphismDeclarations( identities: ReadonlyMap, symbols: SymbolTable, sources: PslSources, + binder: Binder, diagnostics: PslDiagnosticCollector, ): { discriminatorDeclarations: Map; @@ -1727,6 +1722,7 @@ function collectPolymorphismDeclarations( spec: sqlAttributeSpecs.model.discriminator(), model, sources, + binder, diagnostics, }); if (parsed !== undefined) { @@ -1752,6 +1748,7 @@ function collectPolymorphismDeclarations( spec: sqlAttributeSpecs.model.base(), model, sources, + binder, diagnostics, }); if (parsed !== undefined) { @@ -2084,6 +2081,22 @@ function stripStorageOnlyDomainFields( return { ...model, fields, storage: { ...storage, fields: storageFields } }; } +function voicedAsUncomposedNamespace( + diagnostic: PslDiagnostic, + composedExtensions: ReadonlySet, + context: { + readonly familyId?: string; + readonly targetId?: string; + readonly authoringContributions?: AuthoringContributions | undefined; + }, +): boolean { + const data = diagnostic.data; + if (data?.['reference'] !== 'type') return false; + const name = data['name']; + if (typeof name !== 'string') return false; + return checkUncomposedNamespace(name, composedExtensions, context) !== undefined; +} + export function interpretPslDocumentToSqlContract( input: InterpretPslDocumentToSqlContractInput, ): Result { @@ -2099,6 +2112,37 @@ export function interpretPslDocumentToSqlContract( assertDefined(anchorDocument, 'interpretPslDocumentToSqlContract requires at least one document'); const source = diagnosticSource(input.sources, anchorDocument.syntax); const diagnostics = createPslDiagnosticCollector(input.sources); + const composedExtensionNames = new Set(input.composedExtensions ?? []); + const modelAttributesByName = buildModelAttributesByName(input.authoringContributions); + const contributedModelSpecs = modelAttributeSpecsFrom(modelAttributesByName); + const { binder, diagnostics: binderDiagnostics } = createSqlBinder({ + symbolTable: input.symbolTable, + sources: input.sources, + authoringContributions: input.authoringContributions, + controlMutationDefaults: { + defaultFunctionRegistry: input.controlMutationDefaults?.defaultFunctionRegistry ?? new Map(), + dataTypeEntries: input.authoringContributions?.dataTypes ?? {}, + }, + scalarColumnDescriptors: input.scalarColumnDescriptors, + contributedModelAttributeSpecs: contributedModelSpecs, + describeUnsupportedAttribute: describeUnsupportedSqlAttribute({ + composedExtensions: composedExtensionNames, + authoringContributions: input.authoringContributions, + sources: input.sources, + familyId: input.target.familyId, + targetId: input.target.targetId, + }), + }); + diagnostics.push( + ...binderDiagnostics.filter( + (diagnostic) => + !voicedAsUncomposedNamespace(diagnostic, composedExtensionNames, { + familyId: 'sql', + targetId: input.target.targetId, + authoringContributions: input.authoringContributions, + }), + ), + ); const { topLevel } = input.symbolTable; const namespaceSymbols = Object.values(topLevel.namespaces); @@ -2107,6 +2151,7 @@ export function interpretPslDocumentToSqlContract( targetId: input.target.targetId, source, sources: input.sources, + binder, diagnostics, }); validateBlockModelAttributeRequirements({ @@ -2261,7 +2306,6 @@ export function interpretPslDocumentToSqlContract( // already-lowered extension entity — see `namespaceExtensionEntities` // threaded into `collectResolvedFields` below. const entityTypesByDiscriminator = buildEntityTypesByDiscriminator(input.authoringContributions); - const modelAttributesByName = buildModelAttributesByName(input.authoringContributions); // Warnings pushed by entity factories run ahead of // `buildSqlContractFromDefinition`; handed to the build via the definition // so its one per-build flush covers the whole build. @@ -2294,6 +2338,7 @@ export function interpretPslDocumentToSqlContract( defaultNamespaceId, createPslDiagnosticCollector(input.sources), input.sources, + binder, ); const composedPslBlockDescriptors = input.authoringContributions?.pslBlockDescriptors ?? {}; const namespaceExtensionEntities = new Map< @@ -2445,6 +2490,7 @@ export function interpretPslDocumentToSqlContract( defaultNamespaceId, diagnostics, input.sources, + binder, ); // Bare-name view for unqualified relation targets, where // resolution is by bare model name. When a bare name is shared across @@ -2495,6 +2541,7 @@ export function interpretPslDocumentToSqlContract( generatorDescriptorById, scalarColumnDescriptors: input.scalarColumnDescriptors, sources: input.sources, + binder, symbolTable: input.symbolTable, diagnostics, modelNamespaceIds, @@ -2503,6 +2550,7 @@ export function interpretPslDocumentToSqlContract( ...(namespaceExtensionEntities.size > 0 ? { namespaceExtensionEntities } : {}), ...ifDefined('codecLookup', input.codecLookup), modelAttributesByName, + contributedModelAttributeSpecs: contributedModelSpecs, defaultNamespaceId, }); modelNodes.push( @@ -2584,6 +2632,7 @@ export function interpretPslDocumentToSqlContract( modelIdentities, input.symbolTable, input.sources, + binder, diagnostics, ); @@ -2656,6 +2705,7 @@ export function interpretPslDocumentToSqlContract( authoringContributions: input.authoringContributions, diagnostics, sources: input.sources, + binder, }); if (diagnostics.length > 0 || (input.seedDiagnostics?.length ?? 0) > 0) { diff --git a/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts b/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts index 12d9a8ab164f..a5cc5f19de7d 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/psl-column-resolution.ts @@ -31,10 +31,12 @@ import { type MutationDefaultGeneratorDescriptor, } from '@internal/framework-components/control'; import type { + Binder, FieldSymbol, ModelSymbol, NumLiteral, ParsedTaggedLiteral, + PslDiagnostic, PslSpan, ResolvedTypeConstructorCall, SymbolTable, @@ -207,19 +209,35 @@ export function checkUncomposedNamespace( * * The `data` payload carries the missing namespace so machine consumers (agents, IDE extensions, CLI auto-fix) don't have to parse the prose. */ -export function reportUncomposedNamespace(input: { +export function uncomposedNamespaceDiagnostic(input: { readonly subjectLabel: string; readonly namespace: string; readonly source: DiagnosticSource; readonly span: PslSpan; - readonly diagnostics: PslDiagnosticCollector; -}): void { - input.diagnostics.push({ +}): PslDiagnostic { + return { code: 'PSL_EXTENSION_NAMESPACE_NOT_COMPOSED', message: `${input.subjectLabel} uses unrecognized namespace "${input.namespace}". Add extension pack "${input.namespace}" to extensions in prisma.config.ts.`, ...input.source.at(input.span), data: { namespace: input.namespace, suggestedPack: input.namespace }, - }); + }; +} + +export function reportUncomposedNamespace(input: { + readonly subjectLabel: string; + readonly namespace: string; + readonly source: DiagnosticSource; + readonly span: PslSpan; + readonly diagnostics: PslDiagnosticCollector; +}): void { + input.diagnostics.push( + uncomposedNamespaceDiagnostic({ + subjectLabel: input.subjectLabel, + namespace: input.namespace, + source: input.source, + span: input.span, + }), + ); } /** @@ -764,6 +782,7 @@ export function lowerDefaultForField(input: { readonly model: ModelSymbol; readonly symbolTable: SymbolTable; readonly sources: PslSources; + readonly binder: Binder; readonly columnDescriptor: ColumnDescriptor; readonly generatorDescriptorById: ReadonlyMap; readonly defaultFunctionRegistry: ControlMutationDefaultRegistry; @@ -795,6 +814,7 @@ export function lowerDefaultForField(input: { model: input.model, field: input.field, sources: input.sources, + binder: input.binder, diagnostics: input.diagnostics, }); if (interpreted === undefined) return {}; diff --git a/packages/2-sql/2-authoring/contract-psl/src/psl-field-resolution.ts b/packages/2-sql/2-authoring/contract-psl/src/psl-field-resolution.ts index 8288e0287805..00f645bc8d84 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/psl-field-resolution.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/psl-field-resolution.ts @@ -10,6 +10,8 @@ import type { MutationDefaultGeneratorDescriptor, } from '@internal/framework-components/control'; import type { + Binder, + DescribeUnsupportedAttribute, FieldSymbol, ModelSymbol, ResolvedAttribute, @@ -32,8 +34,8 @@ import type { ColumnDescriptor, FieldPresetContributions } from './psl-column-re import { checkUncomposedNamespace, lowerDefaultForField, - reportUncomposedNamespace, resolveFieldTypeDescriptor, + uncomposedNamespaceDiagnostic, } from './psl-column-resolution'; import { fieldSpecContext, @@ -56,6 +58,7 @@ function lowerEnumDefaultForField(input: { readonly model: ModelSymbol; readonly symbolTable: SymbolTable; readonly sources: PslSources; + readonly binder: Binder; readonly enumHandle: EnumTypeHandle; readonly defaultFunctionRegistry: ControlMutationDefaultRegistry; readonly dataTypeSupport: DataTypeSupport; @@ -83,6 +86,7 @@ function lowerEnumDefaultForField(input: { model, field, sources: input.sources, + binder: input.binder, diagnostics, }); if (interpreted === undefined) return {}; @@ -166,6 +170,7 @@ export interface CollectResolvedFieldsInput { readonly generatorDescriptorById: ReadonlyMap; readonly diagnostics: PslDiagnosticCollector; readonly sources: PslSources; + readonly binder: Binder; readonly scalarColumnDescriptors: ReadonlyMap; readonly enumHandles?: ReadonlyMap; readonly capabilities: CapabilityMatrix; @@ -215,59 +220,79 @@ const REMOVED_ATTRIBUTE_RULES: ReadonlyMap = new M } } -function validateFieldAttributes(input: { - readonly model: ModelSymbol; - readonly field: FieldSymbol; +export function describeUnsupportedSqlAttribute(input: { readonly composedExtensions: ReadonlySet; readonly authoringContributions: AuthoringContributions | undefined; - readonly diagnostics: PslDiagnosticCollector; readonly sources: PslSources; - readonly familyId: string; - readonly targetId: string; -}): void { - for (const attribute of input.field.attributes) { - if (Object.hasOwn(sqlAttributeSpecs.field, attribute.name)) { - continue; + readonly familyId: string | undefined; + readonly targetId: string | undefined; +}): DescribeUnsupportedAttribute { + const namespaceContext = { + ...ifDefined('familyId', input.familyId), + ...ifDefined('targetId', input.targetId), + authoringContributions: input.authoringContributions, + }; + return ({ attribute, level, owner, field }) => { + if (level === 'model') { + const source = diagnosticSource(input.sources, owner.node.syntax); + const uncomposedNamespace = checkUncomposedNamespace( + attribute.name, + input.composedExtensions, + namespaceContext, + ); + if (uncomposedNamespace) { + return uncomposedNamespaceDiagnostic({ + subjectLabel: `Attribute "@@${attribute.name}"`, + namespace: uncomposedNamespace, + source, + span: attribute.span, + }); + } + return { + code: 'PSL_UNSUPPORTED_MODEL_ATTRIBUTE', + message: `Model "${owner.name}" uses unsupported attribute "@@${attribute.name}"`, + ...source.at(attribute.span), + }; } + if (field === undefined) return undefined; + const source = diagnosticSource(input.sources, field.node.syntax); + if (attribute.name.startsWith('db.')) { - input.diagnostics.push({ + return { code: 'PSL_UNSUPPORTED_FIELD_ATTRIBUTE', message: formatDbAttributeMigrationMessage(attribute), - ...diagnosticSource(input.sources, input.field.node.syntax).at(attribute.span), - }); - continue; + ...source.at(attribute.span), + }; } - const uncomposedNamespace = checkUncomposedNamespace(attribute.name, input.composedExtensions, { - familyId: input.familyId, - targetId: input.targetId, - authoringContributions: input.authoringContributions, - }); + const uncomposedNamespace = checkUncomposedNamespace( + attribute.name, + input.composedExtensions, + namespaceContext, + ); if (uncomposedNamespace) { - reportUncomposedNamespace({ + return uncomposedNamespaceDiagnostic({ subjectLabel: `Attribute "@${attribute.name}"`, namespace: uncomposedNamespace, - source: diagnosticSource(input.sources, input.field.node.syntax), + source, span: attribute.span, - diagnostics: input.diagnostics, }); - continue; } - const baseMessage = `Field "${input.model.name}.${input.field.name}" uses unsupported attribute "@${attribute.name}"`; + const baseMessage = `Field "${owner.name}.${field.name}" uses unsupported attribute "@${attribute.name}"`; const removedRule = REMOVED_ATTRIBUTE_RULES.get(attribute.name); const message = - removedRule && !removedRule.suppressWhen(input.field) + removedRule && !removedRule.suppressWhen(field) ? `${baseMessage}. ${removedRule.hint}` : baseMessage; - input.diagnostics.push({ + return { code: 'PSL_UNSUPPORTED_FIELD_ATTRIBUTE', message, - ...diagnosticSource(input.sources, input.field.node.syntax).at(attribute.span), - }); - } + ...source.at(attribute.span), + }; + }; } function extractFieldConstraintNames(input: { @@ -275,6 +300,7 @@ function extractFieldConstraintNames(input: { readonly model: ModelSymbol; readonly field: FieldSymbol; readonly sources: PslSources; + readonly binder: Binder; readonly diagnostics: PslDiagnosticCollector; }): { readonly idAttribute: ResolvedAttribute | undefined; @@ -295,6 +321,7 @@ function extractFieldConstraintNames(input: { model: input.model, field: input.field, sources: input.sources, + binder: input.binder, diagnostics: input.diagnostics, })?.map; const uniqueNode = findFieldAttributeNode(input.field, 'unique'); @@ -308,6 +335,7 @@ function extractFieldConstraintNames(input: { model: input.model, field: input.field, sources: input.sources, + binder: input.binder, diagnostics: input.diagnostics, })?.map; return { idAttribute, uniqueAttribute, idName, uniqueName }; @@ -329,6 +357,7 @@ function lowerNoCheckForField(input: { readonly model: ModelSymbol; readonly field: FieldSymbol; readonly sources: PslSources; + readonly binder: Binder; readonly isListField: boolean; readonly isDomainEnum: boolean; readonly diagnostics: PslDiagnosticCollector; @@ -342,6 +371,7 @@ function lowerNoCheckForField(input: { model: input.model, field: input.field, sources: input.sources, + binder: input.binder, diagnostics: input.diagnostics, }); if (interpreted === undefined) return undefined; @@ -394,6 +424,7 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv compositeTypeNames, composedExtensions, authoringContributions, + binder, familyId, targetId, defaultFunctionRegistry, @@ -435,17 +466,6 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv continue; } - validateFieldAttributes({ - model, - field, - composedExtensions, - authoringContributions, - diagnostics, - sources, - familyId, - targetId, - }); - const relationAttribute = getAttribute(field.attributes, 'relation'); if (isModelField && relationAttribute) { continue; @@ -472,6 +492,7 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv let presetContributions: FieldPresetContributions | undefined; const resolveInput = { field, + binder, enumTypeDescriptors, namedTypeDescriptors, scalarColumnDescriptors, @@ -578,6 +599,7 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv model, symbolTable, sources: input.sources, + binder: input.binder, enumHandle, defaultFunctionRegistry, dataTypeSupport, @@ -590,6 +612,7 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv model, symbolTable, sources: input.sources, + binder: input.binder, columnDescriptor: descriptor, generatorDescriptorById, defaultFunctionRegistry, @@ -638,6 +661,7 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv model, field, sources: input.sources, + binder: input.binder, diagnostics, }); let isIdField = Boolean(idAttribute); @@ -684,6 +708,7 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv model, field, sources: input.sources, + binder: input.binder, // The storage shape decides, not the PSL shape: a value-object list // lands in one JSONB column, which derives no generated checks, so // any waiver on it waives nothing and must be rejected here rather @@ -720,6 +745,7 @@ export function buildModelMappings( defaultNamespaceId: string, diagnostics: PslDiagnosticCollector, sources: PslSources, + binder: Binder, ): Map { const result = new Map(); for (const { model, namespaceId } of modelEntries) { @@ -733,6 +759,7 @@ export function buildModelMappings( spec: sqlAttributeSpecs.model.map(), model, sources, + binder, diagnostics, })?.name ?? defaultTableName(model.name)); const fieldColumns = new Map(); @@ -747,6 +774,7 @@ export function buildModelMappings( spec: sqlAttributeSpecs.field.map(), model, field, + binder, sources, diagnostics, })?.name ?? field.name); diff --git a/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts b/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts index c769c953d27f..06aaae9ea1b0 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts @@ -1,5 +1,5 @@ import type { AuthoringContributions } from '@internal/framework-components/authoring'; -import type { FieldSymbol, ModelSymbol, SymbolTable } from '@internal/psl-parser'; +import type { Binder, FieldSymbol, ModelSymbol, SymbolTable } from '@internal/psl-parser'; import { diagnosticSource, type PslDiagnostic, @@ -72,25 +72,12 @@ export function normalizeReferentialAction(actionToken: string): ReferentialActi return REFERENTIAL_ACTION_MAP[actionToken]; } -function resolveReferencedModel(symbols: SymbolTable, field: FieldSymbol): ModelSymbol | undefined { - const topLevel = symbols.topLevel.models[field.typeName]; - if (topLevel !== undefined) { - return topLevel; - } - for (const namespace of Object.values(symbols.topLevel.namespaces)) { - const model = namespace.models[field.typeName]; - if (model !== undefined) { - return model; - } - } - return undefined; -} - export function interpretRelationAttribute(input: { readonly selfModel: ModelSymbol; readonly field: FieldSymbol; readonly symbols: SymbolTable; readonly sources: PslSources; + readonly binder: Binder; readonly diagnostics: PslDiagnosticCollector; }): SqlRelationOutput | undefined { const node = findFieldAttributeNode(input.field, 'relation'); @@ -102,8 +89,8 @@ export function interpretRelationAttribute(input: { model: input.selfModel, field: input.field, sources: input.sources, + binder: input.binder, diagnostics: input.diagnostics, - resolveReferencedModel: () => resolveReferencedModel(input.symbols, input.field), }); } @@ -495,6 +482,7 @@ export function validateBackrelationFieldAttributes(input: { readonly modelName: string; readonly field: FieldSymbol; readonly sources: PslSources; + readonly binder: Binder; readonly composedExtensions: Set; readonly authoringContributions: AuthoringContributions | undefined; readonly diagnostics: PslDiagnosticCollector; diff --git a/packages/2-sql/2-authoring/contract-psl/src/sql-attribute-specs.ts b/packages/2-sql/2-authoring/contract-psl/src/sql-attribute-specs.ts index 86f1853746b9..3d31b8e52fbd 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/sql-attribute-specs.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/sql-attribute-specs.ts @@ -1,3 +1,11 @@ +import type { + AuthoringContributions, + AuthoringFieldNamespace, + AuthoringModelAttributeDescriptor, + AuthoringTypeConstructorDescriptor, + AuthoringTypeNamespace, +} from '@internal/framework-components/authoring'; +import { isAuthoringFieldPresetDescriptor } from '@internal/framework-components/authoring'; import type { ControlDefaultRegistries } from '@internal/framework-components/control'; import type { ContributedPslDiagnosticCode } from '@internal/framework-components/psl-ast'; import type { @@ -6,12 +14,15 @@ import type { AttributeSpec, AttributeSpecContext, AttributeSpecNamespace, + Binder, + DescribeUnsupportedAttribute, FieldAttributeCtx, FieldAttributeSpecContext, FieldSymbol, FuncCallSig, InferAttr, ModelAttributeCtx, + ModelAttributeSpecFactory, ModelSymbol, NumLiteral, ParsedTaggedLiteral, @@ -23,6 +34,7 @@ import type { } from '@internal/psl-parser'; import { bool, + createBinder, diagnosticSource, entityRef, fieldAttribute, @@ -78,10 +90,12 @@ function buildModelAttributeCtx(input: { readonly symbols: SymbolTable; readonly selfModel: ModelSymbol; readonly sources: PslSources; + readonly binder: Binder; }): ModelAttributeCtx { return { sources: input.sources, selfModel: input.selfModel, + binder: input.binder, symbols: input.symbols, }; } @@ -91,17 +105,82 @@ function buildFieldAttributeCtx(input: { readonly selfModel: ModelSymbol; readonly field: FieldSymbol; readonly sources: PslSources; - readonly resolveReferencedModel?: (() => ModelSymbol | undefined) | undefined; + readonly binder: Binder; }): FieldAttributeCtx { return { sources: input.sources, selfModel: input.selfModel, - resolveReferencedModel: input.resolveReferencedModel ?? (() => undefined), field: input.field, + binder: input.binder, symbols: input.symbols, }; } +function fieldPresetsAsTypeNames( + namespace: AuthoringFieldNamespace | undefined, +): AuthoringTypeNamespace { + if (namespace === undefined) return {}; + const result: Record = {}; + for (const [name, value] of Object.entries(namespace)) { + result[name] = isAuthoringFieldPresetDescriptor(value) + ? { kind: 'typeConstructor', output: { codecId: value.output.codecId } } + : fieldPresetsAsTypeNames(value); + } + return result; +} + +export function modelAttributeSpecsFrom( + modelAttributesByName: ReadonlyMap, +): Readonly> { + const result: Record = Object.create(null); + for (const [name, descriptor] of modelAttributesByName) { + result[name] = blindCast< + ModelAttributeSpecFactory, + 'contributed model-attribute descriptors carry an ADR-231 attribute-spec factory by construction' + >(descriptor.spec); + } + return result; +} + +export function createSqlBinder(input: { + readonly symbolTable: SymbolTable; + readonly sources: PslSources; + readonly authoringContributions?: AuthoringContributions | undefined; + readonly controlMutationDefaults?: ControlDefaultRegistries | undefined; + readonly scalarColumnDescriptors?: ReadonlyMap | undefined; + readonly describeUnsupportedAttribute?: DescribeUnsupportedAttribute | undefined; + readonly contributedModelAttributeSpecs?: + | Readonly> + | undefined; +}): { readonly binder: Binder; readonly diagnostics: readonly PslDiagnostic[] } { + const scalars: Record = {}; + for (const [name, descriptor] of input.scalarColumnDescriptors ?? []) { + scalars[name] = { kind: 'typeConstructor', output: { codecId: descriptor.codecId } }; + } + return createBinder({ + sources: input.sources, + symbolTable: input.symbolTable, + typeConstructors: { + ...scalars, + ...fieldPresetsAsTypeNames(input.authoringContributions?.field), + ...(input.authoringContributions?.type ?? {}), + }, + attributeSpecs: { + model: Object.assign( + Object.create(null), + sqlAttributeSpecs.model, + input.contributedModelAttributeSpecs, + ), + field: sqlAttributeSpecs.field, + }, + controlMutationDefaults: input.controlMutationDefaults ?? { + defaultFunctionRegistry: new Map(), + dataTypeEntries: {}, + }, + describeUnsupportedAttribute: input.describeUnsupportedAttribute, + }); +} + // Interpret a model-level attribute node against its spec, draining any parse // failures into `diagnostics`. Returns the typed value, or `undefined` on // failure so the caller can apply its own default/absence handling. @@ -111,6 +190,7 @@ export function interpretModelAttribute(input: { readonly spec: AttributeSpec; readonly model: ModelSymbol; readonly sources: PslSources; + readonly binder: Binder; readonly diagnostics: PslDiagnosticCollector; }): Out | undefined { const result = interpretAttribute( @@ -120,6 +200,7 @@ export function interpretModelAttribute(input: { symbols: input.symbols, selfModel: input.model, sources: input.sources, + binder: input.binder, }), ); if (!result.ok) { @@ -139,8 +220,8 @@ export function interpretFieldAttribute(input: { readonly model: ModelSymbol; readonly field: FieldSymbol; readonly sources: PslSources; + readonly binder: Binder; readonly diagnostics: PslDiagnosticCollector; - readonly resolveReferencedModel?: () => ModelSymbol | undefined; }): Out | undefined { const result = interpretAttribute( input.node, @@ -150,7 +231,7 @@ export function interpretFieldAttribute(input: { selfModel: input.model, field: input.field, sources: input.sources, - resolveReferencedModel: input.resolveReferencedModel, + binder: input.binder, }), ); if (!result.ok) { diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts index a56b71bc36ab..2ef215120598 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.diagnostics.test.ts @@ -304,8 +304,8 @@ model User { } `, { - code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', - message: 'Field "missingId" does not exist on model "Membership"', + code: 'PSL_UNRESOLVED_REFERENCE', + message: 'Cannot find field "missingId" on "Membership"', }, ); }); @@ -805,6 +805,24 @@ model User { ); }); + it('leaves an uncomposed namespace to the composition diagnostic alone', () => { + const document = symbolTableInputFromParseArgs({ + schema: 'model Document {\n id Int @id\n embedding pgvector.Vector(1536)\n}', + sourceId: 'schema.prisma', + }); + const result = interpretPslDocumentToSqlContract({ + ...baseInput, + ...document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics.map(({ code }) => code)).toEqual([ + 'PSL_EXTENSION_NAMESPACE_NOT_COMPOSED', + ]); + }); + it('rejects @@id referencing an unknown field', () => { const document = symbolTableInputFromParseArgs({ schema: `model Thing { @@ -824,8 +842,8 @@ model User { expect(result.failure.diagnostics).toEqual( expect.arrayContaining([ expect.objectContaining({ - code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', - message: expect.stringContaining('Field "nope" does not exist on model "Thing"'), + code: 'PSL_UNRESOLVED_REFERENCE', + message: expect.stringContaining('Cannot find field "nope" on "Thing"'), }), ]), ); diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.model-attributes.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.model-attributes.test.ts index 0cf0f7576bfd..8a865c2e3c39 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.model-attributes.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.model-attributes.test.ts @@ -1,6 +1,6 @@ import type { AuthoringContributions } from '@internal/framework-components/authoring'; import type { ModelAttributeSpecFactory } from '@internal/psl-parser'; -import { modelAttribute, optional, str } from '@internal/psl-parser'; +import { fieldRef, list, modelAttribute, optional, str } from '@internal/psl-parser'; import type { SqlNamespaceInput } from '@internal/sql-contract/types'; import { describe, expect, it } from 'vitest'; import { createTestSqlNamespace } from '../../../1-core/contract/test/test-support'; @@ -99,6 +99,81 @@ function expectDiagnostic( ); } +const searchIndexSpecFactory: ModelAttributeSpecFactory = () => + modelAttribute('searchIndex', { + documentation: 'Indexes one column of this model for search.', + positional: [ + { + key: 'fields', + type: list(fieldRef(), { allowEmpty: false, unique: true }), + documentation: 'The single field to index.', + }, + ], + named: { + name: { type: optional(str()), documentation: 'The index name.' }, + }, + }); + +const searchIndexAuthoringContributions: AuthoringContributions = { + modelAttributes: { + searchIndex: { + kind: 'modelAttribute', + attribute: 'searchIndex', + spec: searchIndexSpecFactory, + lower: (parsed: { readonly fields: readonly string[]; readonly name?: string }, ctx) => ({ + key: parsed.name ?? ctx.storageName, + entity: { + kind: 'searchIndex', + tableName: ctx.storageName, + columns: parsed.fields, + name: parsed.name, + }, + }), + }, + }, +}; + +describe('a contributed model attribute carrying reference arguments', () => { + const schema = `model Person { + id Int @id + name String + @@searchIndex([name], name: "person_name_search") +}`; + + it('resolves its field references through the binder', () => { + const { result, capturedEntries } = interpretWith(schema, searchIndexAuthoringContributions); + + expect(result.ok).toBe(true); + expect(capturedEntries['public']?.['searchIndex']?.['person_name_search']).toEqual({ + kind: 'searchIndex', + tableName: 'Person', + columns: ['name'], + name: 'person_name_search', + }); + }); + + it('stays silent about the contributed name in the unsupported-attribute voice', () => { + const { result } = interpretWith(schema, searchIndexAuthoringContributions); + + expect(result.ok).toBe(true); + }); + + it('still reports a genuinely unregistered model attribute', () => { + expectDiagnostic( + `model Person { + id Int @id + name String + @@mystery([name]) +}`, + { + code: 'PSL_UNSUPPORTED_MODEL_ATTRIBUTE', + message: 'Model "Person" uses unsupported attribute "@@mystery"', + }, + searchIndexAuthoringContributions, + ); + }); +}); + describe('contributed model attributes (AuthoringContributions.modelAttributes)', () => { it('consults the contributed descriptor and files the lowered entity under entries[attribute][key]', () => { const { result, capturedEntries } = interpretWith( diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.namespaces.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.namespaces.test.ts index 7a55832b6415..86df18fabbad 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.namespaces.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.namespaces.test.ts @@ -180,7 +180,7 @@ namespace blog { model Post { id Int @id authorId Int - author User @relation(fields: [authorId], references: [id]) + author public.User @relation(fields: [authorId], references: [id]) } } `, @@ -201,7 +201,7 @@ namespace blog { }); }); - it('lowers an unqualified relation to a model that lives in another namespace', () => { + it('refuses an unqualified relation to a model in a sibling namespace', () => { const document = symbolTableInputFromParseArgs({ schema: `namespace public { model Post { @@ -223,16 +223,16 @@ namespace auth { const result = interpretPslDocumentToSqlContract({ ...baseInput, ...document }); - expect(result.ok).toBe(true); - if (!result.ok) return; - - const storage = result.value.storage as SqlStorage; - const postTable = storage.namespaces['public']!.entries.table?.['Post']; - const fks: readonly ForeignKey[] = postTable?.foreignKeys ?? []; - expect(fks.length).toBe(1); - expect(fks[0]).toMatchObject({ - target: { namespaceId: 'auth', tableName: 'user' }, - }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_UNRESOLVED_REFERENCE', + message: expect.stringContaining('Cannot find type "User"'), + }), + ]), + ); }); it('lowers the same bare table name in two namespaces with differing columns and a cross-namespace FK', () => { diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts index 0e99c4d3a17e..a8a60e6259c7 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts @@ -118,18 +118,21 @@ describe('interpretPslDocumentToSqlContract — polymorphism', () => { 'type Base { value String }', 'model Variant { id Int @id\n @@base(Base, "v") }', 'Expected model reference "Base", found compositeType', + 'PSL_INVALID_ATTRIBUTE_SYNTAX', ], [ 'namespace sibling { model Base { id Int @id } }', 'namespace local { model Variant { id Int @id\n @@base(Base, "v") } }', - 'Unknown model reference "Base"', + 'Cannot find entity "Base"', + 'PSL_UNRESOLVED_REFERENCE', ], [ 'model Base { id Int @id }', 'namespace local { type Base { value String }\n model Variant { id Int @id\n @@base(Base, "v") } }', 'Expected model reference "Base", found compositeType', + 'PSL_INVALID_ATTRIBUTE_SYNTAX', ], - ])('reports checked-reference failure for %s', (base, variant, message) => { + ])('reports checked-reference failure for %s', (base, variant, message, code) => { const schema = `${base}\n${variant}`; const result = interpretPslDocumentToSqlContract({ ...symbolTableInputFromParseArgs({ schema, sourceId: 'schema.prisma' }), @@ -139,7 +142,7 @@ describe('interpretPslDocumentToSqlContract — polymorphism', () => { if (!result.ok) expect(result.failure.diagnostics).toEqual([ expect.objectContaining({ - code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', + code, message, sourceId: 'schema.prisma', span: expect.objectContaining({ @@ -868,8 +871,8 @@ model Bug { expect(result.failure.diagnostics).toEqual( expect.arrayContaining([ expect.objectContaining({ - code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', - message: expect.stringContaining('does not exist'), + code: 'PSL_UNRESOLVED_REFERENCE', + message: expect.stringContaining('Cannot find field'), }), ]), ); @@ -967,8 +970,8 @@ model Bug { expect(result.failure.diagnostics).toEqual( expect.arrayContaining([ expect.objectContaining({ - code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', - message: 'Unknown model reference "NonExistent"', + code: 'PSL_UNRESOLVED_REFERENCE', + message: expect.stringContaining('Cannot find entity "NonExistent"'), }), ]), ); diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts index ad5ab8ec4e3a..9a667f749ae5 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts @@ -690,8 +690,8 @@ model Post { expect(result.failure.diagnostics).toEqual( expect.arrayContaining([ expect.objectContaining({ - code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', - message: expect.stringContaining('Field "missingUserId" does not exist on model "Post"'), + code: 'PSL_UNRESOLVED_REFERENCE', + message: expect.stringContaining('Cannot find field "missingUserId" on "Post"'), }), ]), ); @@ -724,8 +724,10 @@ model Post { expect(result.failure.diagnostics).toEqual( expect.arrayContaining([ expect.objectContaining({ - code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', - message: expect.stringContaining('Field "missingId" does not exist on model "User"'), + code: 'PSL_UNRESOLVED_REFERENCE', + message: expect.stringContaining( + 'Cannot find field "missingId" on the type of "Post.user"', + ), }), ]), ); diff --git a/packages/2-sql/2-authoring/contract-psl/test/provider.interpret.test.ts b/packages/2-sql/2-authoring/contract-psl/test/provider.interpret.test.ts index c7b133745a69..4349a740c7ea 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/provider.interpret.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/provider.interpret.test.ts @@ -10,6 +10,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { createTestSqlNamespace } from '../../../1-core/contract/test/test-support'; import { prismaContract } from '../src/exports/provider'; import { lowerDefaultForField } from '../src/psl-column-resolution'; +import { createSqlBinder } from '../src/sql-attribute-specs'; import { fixtureDataTypeSupport } from './fixture-data-types'; import { createPostgresTestContext, postgresTarget, testEnumPslBlockDescriptor } from './fixtures'; @@ -209,6 +210,7 @@ model Other { fieldName: field.name, field, model, + binder: createSqlBinder({ symbolTable: input.symbolTable, sources: input.sources }).binder, symbolTable: input.symbolTable, sources: input.sources, columnDescriptor: { codecId: 'pg/text@1', nativeType: 'text' }, diff --git a/packages/2-sql/2-authoring/contract-psl/test/semantic-diagnostics.test.ts b/packages/2-sql/2-authoring/contract-psl/test/semantic-diagnostics.test.ts index 147b13567340..c418a43bc6b3 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/semantic-diagnostics.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/semantic-diagnostics.test.ts @@ -2,6 +2,7 @@ import { buildSymbolTable } from '@internal/psl-parser'; import { parse } from '@internal/psl-parser/syntax'; import { expect, it, vi } from 'vitest'; import { lowerDefaultForField } from '../src/psl-column-resolution'; +import { createSqlBinder } from '../src/sql-attribute-specs'; import { fixtureDataTypeSupport } from './fixture-data-types'; import { createPostgresTestContext } from './fixtures'; @@ -33,6 +34,7 @@ it('pushes owned default diagnostics with filename and range rather than a provi model, symbolTable, sources, + binder: createSqlBinder({ symbolTable, sources }).binder, columnDescriptor: { codecId: 'pg/text@1', nativeType: 'text' }, generatorDescriptorById: new Map(), defaultFunctionRegistry: new Map(), diff --git a/packages/2-sql/2-authoring/contract-psl/test/sql-attribute-specs.test.ts b/packages/2-sql/2-authoring/contract-psl/test/sql-attribute-specs.test.ts index 444228f9ef2b..b0ca6480d264 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/sql-attribute-specs.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/sql-attribute-specs.test.ts @@ -13,6 +13,7 @@ import type { import { createPslDiagnosticCollector } from '@internal/psl-parser'; import { describe, expect, it } from 'vitest'; import { + createSqlBinder, fieldSpecContext, findFieldAttributeNode, findModelAttributeNode, @@ -116,6 +117,7 @@ function interpretDefault(schema: string, fieldName: string) { model, field: target, sources, + binder: createSqlBinder({ symbolTable, sources }).binder, diagnostics, }); return { value, diagnostics: diagnostics.toExternal() }; @@ -140,6 +142,7 @@ namespace scoped { spec: sqlAttributeSpecs.model.base(), model, sources: input.sources, + binder: createSqlBinder({ symbolTable: input.symbolTable, sources: input.sources }).binder, diagnostics, }); expect(diagnostics.toExternal()).toEqual([]); diff --git a/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts b/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts index d0c184c7f6a3..a7ed136e8858 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts @@ -396,7 +396,7 @@ describe('TS and PSL authoring parity', () => { model Post { id Int @id authorId Int - author User @relation(fields: [authorId], references: [id]) + author auth.User @relation(fields: [authorId], references: [id]) @@map("post") } `, diff --git a/projects/symbol-table-resolve/design-decisions.md b/projects/symbol-table-resolve/design-decisions.md new file mode 100644 index 000000000000..89996b878ca7 --- /dev/null +++ b/projects/symbol-table-resolve/design-decisions.md @@ -0,0 +1,125 @@ +# symbol-table-resolve — design-decision record + +Outcome of the design discussion (2026-09-18) that preceded [`spec.md`](./spec.md). Each entry names the decision, the reasoning that drives it, the assumptions it rests on, and the alternatives rejected. If an assumption below falsifies mid-flight, halt and re-enter discussion (invariant I12). + +## 1. Red-slot caching gives within-snapshot node identity + +**Decision.** The red layer caches each child wrapper in its parent red node's slot on first access (Roslyn's `SyntaxNode.GetRed` design, verified against Roslyn source). Two traversals to the same position return the same object for the snapshot's lifetime. + +**Why.** Today `red.ts` allocates fresh wrappers on every `children()` / `childAt` / `findAncestor` call, so identity-keyed caches (`WeakMap`) would silently never hit — the LSP already falls back to span-equality scans because of this. Caching in the parent slot is a contained change (`SyntaxNode` already holds `parent` and `index`) and also removes per-request wrapper churn in every consumer. + +**Assumes.** All traversal descends from the single registered root, so each position materializes exactly one wrapper — the same property PR #30335's `PslSources` already relies on for red roots. + +**Rejected.** +- *Span-keyed memos with the red layer untouched* — workable (rust-analyzer's pointer style) but keeps the allocation churn and leaves span keys valid only within one parse anyway. +- *Keying caches on green nodes* — wrong, not merely inferior: green nodes erase position, and resolution is a function of position; green subtrees are shareable across snapshots by design, so a green-keyed cache would serve pre-edit resolutions after an edit — stale answers with no error — the moment incremental reparse or hash-consing arrives. + +## 2. One scoping rule: declaring namespace → top level → contributed types; never siblings + +**Decision.** Operator decree: an unqualified reference resolves against same-namespace declarations first, then top level. Sibling namespaces are never consulted. + +**Why.** Four resolvers answer this differently today; the SQL interpreter consults sibling namespaces in arbitrary key order, which is order-dependent and surprising. A single binder must canonize one rule; lexical scoping is the LSP's current rule and matches every surveyed language. + +**Consequence accepted.** The SQL and Mongo interpreters change behavior for schemas where a namespaced model shadows a top-level name. These are corrections, named in their slices, not silent drift. + +## 3. Multi-document from birth, stacked on PR #30335 + +**Decision.** The binder addresses a set of documents (N=1 common case), built on the `multifiile-psl` branch where `buildSymbolTable` already accepts `documents[]` + `PslSources`. + +**Why.** Designing a single-document API would churn when multi-file support lands, and the multi-document groundwork already exists on the open PR. + +**Assumes.** PR #30335 lands. The project stacks on it and has no independent landing path (spec Open Question 2). + +## 4. The binder is a separate per-snapshot service; the symbol table stays pure data + +**Decision.** Interface + factory (`createBinder`) per the repo's stateful-service pattern; created over `{documents, sources, symbolTable, typeConstructors}`; holds all memo tables; dropped whole on any change. The eager symbol table gains no lookup methods and no laziness. + +**Why.** Caches are state, and the repo pattern puts stateful services behind an interface + factory. The split mirrors the surveyed systems: a cheap eager declaration pass (TypeScript's binder, Roslyn's declaration table) and a lazy, memoized resolution layer (TypeScript's checker, Roslyn's `SemanticModel`), where every cache may be discarded because resolution is a pure function of the snapshot. The existing `project-artifacts.ts` interpret slot already practices compute-on-demand, invalidate-by-dropping-the-holder. + +**Rejected.** *Salsa-style dependency-tracked invalidation* — revision counters, dependency recording, and memo verification pay off for large multi-file inputs with macros; rust-analyzer's own architecture writing flags the complexity and constant-factor cost. PSL's inputs are small; snapshot discard is strictly simpler and has no protocol to get wrong. + +## 5. Scalars become symbols in a config-derived contributed-type scope + +> **Renamed (2026-09-22, operator decree):** originally "universe scope" after the compiler term (Go's universe block). The operator ruled the term confusing and the analogy imprecise — the scope holds whatever the configured target and its extensions contribute (composed scalars, type constructors, field presets, extension namespaces), not language built-ins. New vocabulary: `ContributedTypeScope` / `ContributedTypeSymbol` / resolution kind `contributedType`, matching the repo's established "contributed" idiom (ADR 236; `ContributedPslDiagnosticCode`). Historical mentions of "universe" in the review log and dispatch briefs are archive and stand unedited. + +**Decision.** Scalars (and type constructors) are exposed as symbols in an outermost contributed-type scope, built once per configuration from the existing type-constructor registry, chained after top level. User declarations shadow contributed-type symbols. Document edits never invalidate this layer. + +**Why.** Surveyed compilers treat builtins as ordinary symbols in an outer scope, which deletes the LSP's two duplicated hand-rolled classification cascades. Placement outside the eager table matters because scalar sets are target- and contract-space-specific and change on configuration change, not document edit — welding them into the document table would conflate two invalidation triggers and contaminate a family-neutral structure. + +**Rejected.** *Restoring a scalar-name parameter to `buildSymbolTable`* — commit `72cd71550f` deliberately unified scalars into the type-constructor channel; a name list would partly undo that unification, and the eager table's family-blindness is preserved deliberately (`NamedTypeSymbol` classification is pronounced by interpreters). + +## 6. The binder is the sole voice of resolution-failure diagnostics + +**Decision.** Unresolved- and ambiguous-reference diagnostics are emitted only by the binder, via a lazily-forced, memoized full-resolution pass; consumers map them into their channels and never re-emit. + +**Why.** The symbol table set the precedent with duplicate-declaration diagnostics ("downstream consumers should consume first-wins symbols rather than re-emitting"). Without single ownership, the four differing error surfaces would survive unification — same schema, different complaints per tool. Laziness holds: TypeScript's checker is lazy, and its diagnostics pass simply forces full resolution once. + +## 7. Scope and sequencing decrees + +**Decision.** Prisma-7 is excluded entirely. Conversion order: binder + attribute specs land first, then the SQL interpreter, then Mongo, then the LSP last. + +**Why.** Operator decree. Landing all conversions with the binder would make one change carry four behavior shifts; interpreters-first was chosen over LSP-first. Sequencing detail belongs to the project plan; it is recorded here because it was an explicit operator decision, not a planner's inference. + +## 8. Node-addressed core API + +**Decision.** The binder exposes exactly `declaredSymbol(node)` (declaration node → the symbol it declares), `symbolForNode(node)` (reference node → the symbol it denotes), and `diagnostics()`. Symbol-addressed conveniences (e.g. `resolveTypeReference(fieldSymbol)`) are dropped until call sites demand them. + +**Why.** These are the two questions every surveyed compiler distinguishes (Roslyn: `GetDeclaredSymbol` vs `GetSymbolInfo`); node → symbol binding is what the operator asked for, and earlier symbol-addressed sketches obscured that core. Diagnostics survive as a binder output because "no resolution errors" is a whole-document claim: interpreters touch every reference in their walk and can consume per-query failure results, but the language server must publish complete resolution errors for regions no feature queried, and deriving them from interpretation would reintroduce family-dependent short-circuiting. Boundary: name-resolution failures are the binder's voice; shape failures (arity, argument types) remain the spec combinators'. + +**Amended same day (operator):** diagnostics are not a method — `createBinder` returns `{ binder, diagnostics }`, extending `buildSymbolTable`'s `{ symbolTable, diagnostics }` result pattern so every pipeline stage hands over its artifact and its complaints together. This hardens eagerness into the factory contract (a future lazy implementation must fill the diagnostics at creation or change the result shape) — accepted knowingly for pipeline symmetry; the query methods alone stay timing-neutral. + +## 9. Eager per-snapshot binding replaces the lazy decree + +**Decision.** The binder resolves everything at creation in a two-phase pass — phase 1: declarations and type references; phase 2: attribute references, which read phase-1 results (no cycle exists in the other direction). Queries are map reads; the diagnostics are the pass's byproduct, returned from the factory. Query timing is not part of the contract: `declaredSymbol` and `symbolForNode` observe only snapshot-scoped, stable answers. Normative pseudo-code lives in `spec.md` § "The eager pass". + +**Why.** The original requirement asked for lazy binding, and the operator consciously revised it once the economics were laid out: every keystroke already pays a full reparse and full symbol-table rebuild (incremental reparse is a non-goal), the LSP forces full resolution for diagnostics moments after every edit, and interpreters resolve everything by nature — so laziness defers a minority slice of an already-paid cost, only to collect it immediately. Attribute references decide it: resolving `@relation(references: [id])` lazily from a bare node means re-deriving spec, combinator kind, enclosing field, and its type target by climbing the tree — context the eager walk holds in its hands. Laziness in the surveyed systems (TypeScript, Roslyn) exists for compilation units that are enormous and not reparsed wholesale per edit; we lack their economics until incremental reparse exists. + +**Assumes.** Incremental reparse arrives eventually (operator-confirmed) but outside this project; the timing-neutral contract lets that project reintroduce laziness and cross-snapshot memo retention behind the same interface. + +**Rejected.** *Lazy-first implementation* — a second resolution entry path from bare nodes, plus a full-walk `diagnostics()` forcing pass that duplicates the eager walk anyway. *Cross-space references as silent skips* — they become an explicit cross-space resolution kind with no diagnostic. + +## 10. The binder is mandatory in attribute contexts (operator decree, post-D5) + +**Decision.** `AttributeCtx.binder` is required, not optional; `resolveReferencedModel` is deleted from the context types along with its four consumer-supplied implementations; the reference combinators keep no binder-less path; every context construction site (SQL, Mongo, language server) threads a binder built over the same snapshot. Additionally, a `fieldRef`/`referencedFieldRef` argument whose binder resolution is not a field fails its parse — without emitting a second diagnostic, the binder's being the voice; cross-space parses successfully with resolution deferred. + +**Why.** The optional-binder design left a dual path alive: an unconverted consumer's attribute parsing silently kept the old existence checks, and F5 documented how a mismatched or missing binder disables checking with no signal. Requiring the binder deletes the trap instead of documenting it, converts the attribute-argument question across all consumers at once (per-question wholeness), and makes the compiler enumerate every construction site — nothing can be missed silently. Fail-on-non-field closes the last soft spot: a parse that succeeds against an unresolved name hands interpreters a bogus value. + +**Supersedes.** The D5 gating design ("legacy byte-for-byte when no binder") and the transitional constraint's per-consumer wording, both amended in the specs the same day. + +**Execution note (D6, orchestrator ruling).** Fail-on-non-field is unrepresentable in the parse machinery as found: `list.ts`, `record.ts`, and `interpretArgs` decide failure by diagnostic count, so a diagnostic-less failure is silently converted back into success — empirically shown to yield a truncated `@@index` value with no complaint from either voice. The orchestrator authorized the minimal machinery change under the decree: failure is tracked separately from diagnostic count in those aggregation points. Provably inert for existing specs (no combinator returned an empty failure before this). `contract-prisma7` was verified clear of attribute-context construction before threading. + +**Ratified placement refinements (D6, orchestrator).** (A) The required `binder` lives on `ModelAttributeCtx`, not base `AttributeCtx` — `block-reconstruction.ts` interprets block attributes from inside `buildSymbolTable`, before any binder can exist, and no block-attribute spec takes a reference argument (repo-verified), so every ctx that can reach a reference combinator carries a binder by construction. (B) ~~`entityRef` keeps base-`AttributeCtx` typing — retyping would force genericizing `FuncCallSig` (Mongo nests `optional(entityRef())` in a `funcCall`).~~ **Dissolved in review iteration 4:** main itself replaced that nesting with `optional(identifier())`, and no `entityRef` sits inside any `funcCall` repo-wide — the fork's premise no longer exists. `entityRef` is typed against the binder-bearing context exactly as `fieldRef` is; the optional `binder` is deleted from base `AttributeCtx` (decision 10 restored at the type level); a block spec naming a reference combinator fails to compile (decision 13 compiler-enforced, negative-type-tested). ~~`oneOf` never wraps a reference combinator in any production spec (repo-verified), so its no-match fall-through needed no failure-awareness.~~ **Corrected (review round): this claim was grep-verified and is false.** Mongo's `modelFieldElement` assembles an `arms` array containing `fieldRef()` and spreads it into `oneOf(...arms)` — invisible to a one-line grep, proven reachable by probe (neutering the failure-awareness fails `interpreter.single-voice.test.ts`). `oneOf` therefore participates in wordless-failure propagation; per operator direction its handling lives in the shared `Result` algebra (`and` for the conjoining aggregators, a pooling counterpart for `oneOf`), not in local helpers. + +**Second execution ruling (operator, D6).** Threading alone produced a zero-voice interval: with the combinators' existence checks deleted and binder diagnostics still discarded by consumers, schema errors vanished (`@@id` on an unknown field emitted a contract without its primary key). The operator decreed the diagnostics be surfaced in D6 itself: every consumer pushes its binder's diagnostics into its collector; the registry view becomes owner-aware so context-dependent specs (SQL `@default`) stop producing false unknown-attribute flags; consumers' duplicate resolution-class emissions are removed; the sibling-namespace correction lands with diagnostics and named test updates. The conversion slices shrink accordingly — their sole-voice half moved here; their remaining work is deleting each consumer's hand-rolled type-reference/relation machinery. Two boundary findings from execution: the language server constructs no attribute contexts at all (its spec resolver serves completion/signature metadata, never `interpretAttribute`), so its binder-diagnostic adoption stays in the LSP conversion slice as planned; and the recognized-scalar route died on evidence (no canonical recognized-names list exists to widen Mongo's universe to — hard-coding was forbidden). The ratified mechanism instead: **binder diagnostics carry the class of reference that failed** (`reference: 'type' | 'field' | 'entity' | 'attribute'`), and each consumer adopts the binder's voice per class as it converts — Mongo surfaces `field`/`entity`/`attribute` and keeps `PSL_UNSUPPORTED_FIELD_TYPE` as its `type`-position voice; SQL, whose universe is complete, adopts all four. This generalizes to every conversion slice: voices are adopted question by question, which is the transitional constraint's per-question wholeness made mechanical. + +**Explicit-unresolved hardening (operator decree, PR #30349 review round).** The binder records an explicit `unresolved` resolution entry for every reference it examines and fails to resolve, instead of leaving the side table silent. Consequence: for a reference-position node parsed under a required-binder context, an *absent* map entry no longer means "schema mistake" — it can only mean a binder built over a different snapshot (the F5 precondition violated), and the reference combinators throw an internal error on it rather than silently forgoing existence checks. `unresolved` still parses to a wordless failure; `crossSpace` and `field` behave as before. Fields with `malformedType` remain unexamined by design. + +## 11. The spec-registry interface is abolished; the binder speaks the rich attribute voice (operator decrees, PR #30349 review) + +**Decision.** (a) `AttributeSpecRegistry`/`AttributeSpecView` are deleted: `createBinder`'s options widen to the consumers' real spec namespaces (the tables already `satisfies AttributeSpecNamespace`) plus `controlMutationDefaults` — the one construction ingredient the factories need that the binder lacks — and the binder builds the real ADR 249 construction context and calls the real factories itself. (b) On an attribute name absent from the namespace, the binder invokes an injected `describeUnsupportedAttribute` callback and emits whatever it returns — the binder owns the *when* (it holds the namespace), the consumer owns the *what* (its cascade is family knowledge: migration prose, removed-attribute hints, config-dependent `PSL_EXTENSION_NAMESPACE_NOT_COMPOSED`); the consumers' own detection loops are deleted, output byte-identical. The weak `PSL_UNRESOLVED_ATTRIBUTE` dies. Execution note: the binder-emits-itself reading of this decision halted on evidence — SQL's message is a four-branch cascade over consumer-private state that cannot enter the family-blind parser (cross-cutting requirement 6); the operator chose the callback inversion. (c) Attribute identifiers resolve to first-class attribute symbols, not a spec-carrying special case. + +**Why.** The interface's four justifications collapsed under review: a map holds factories (the owner-aware methods were just currying); the "permissive policy" was a switch every consumer set to off — a diagnostic emitted on no production path is a misfeature, not a policy; the variance trap applies to generic registry typing, not to accepting the existing named namespace type; and injection-by-layering survives with plain wider options. Operator's rule: do not invent a new interface for an already-existing thing. Moving the rich voice into the binder completes sole-voice for the attribute-name question — the last name-shaped question a consumer still answered — and symbols-for-attribute-names give the LSP slice the same uniform handle every other name has. + +## 12. Scopes are one abstraction; lookup is kind-blind; kinds are validated after resolution (operator decrees, PR #30349 review) + +**Decision.** The namespace scope, top-level scope, and contributed-type scope implement one shape — `lookup(name) → symbol | undefined` — and resolution is a fold over the chain, first hit wins. Lookup is kind-blind: an enum shadows a model of the same name in an outer scope, uniformly for every reference kind. What kind a reference site requires (`@@base` needs an entity; a field type needs a type-position symbol) is validated *after* resolution, with kind-mismatch diagnostics ("Foo is an enum; a base must be a model or composite type") replacing false not-found complaints. + +**Why.** Within one scope a name can denote at most one symbol — the symbol table's flat duplicate-claiming guarantees it — so per-kind lookup cascades encode an ambiguity that cannot exist, and per-reference-kind member sets (entityRef seeing past an enum a type reference would hit) created inconsistent shadowing the operator rejected: one scoping rule means one, kind-blind. The uniform `lookup` also restores the surveyed chained-environment design the binder's hand-indexed cascade had drifted from. + +**Named behavior changes** (pins rewritten with them): `@@base` naming a shadowing enum/scalar now reports kind-mismatch instead of unresolved; type-position lookups that reach a wrong-kind symbol report what the symbol is. + +**Superseding implementation (operator blueprint, 2026-09-24).** The chain-array fold was replaced by the operator's design: one `Scope` interface, a class family (`ContributedScope` root → `DocumentScope` → `NamespaceScope`) where each `lookup(name)` searches its own records then delegates to its parent; the binder owns an explicit `ScopeStack` — named `push` on entering a namespace region, named `pop` on leaving, `current()` at the top, both phases resolving against `current()` at the point of use — so resolution context is where-the-walk-stands. (First landed as a generator pairing entities with scopes — stack semantics without the mechanism; the operator rejected the substitution and the literal stack replaced it. Standing lesson: a specified mechanism is the requirement, not a sketch of one.) Qualified references push nothing: the qualifier resolves as an ordinary name (kind-blind — a non-namespace qualifier now earns `"app" is a model, not a namespace` instead of a false not-found), then the member is looked up within the namespace symbol; contributed namespaces (`pgvector.Vector`) take the identical two steps off the registry's natural nesting. Sibling exclusion became structural: no parentage leads sideways. Latent consequence, named: a name that is both a user and a contributed namespace binds to the user one with no fall-through — which is what this decision's shadowing rule already decrees. + +## 13. Convergent entity resolution on `main`, reconciled to one resolver (orchestrator ruling under standing decrees, 2026-09-24) + +**Situation.** While this branch was in review, `main` independently shipped `entity-reference.ts` — an entity resolver with the same semantics decree 12 decrees (declaring namespace → top level, kind-blind, kind validated after resolution via per-site `EntitySelector`s) but hand-rolled beside the binder, with its own not-found and kind-mismatch voices. It merged conflict-free into untouched files; only the pins collided (three mutually exclusive `@@base(NonExistent)` diagnostics across base/ours/main). + +**Ruling.** Reconcile to the decrees, keeping main's superior surface: main's `EntitySelector`/`ResolvedEntityReference` API and per-site kind validation stay (decree 12's "kinds validated after resolution", finer-grained than the binder's hardcoded entity check, which is removed along with its message); `resolveEntityReference`'s own scope walk is replaced by the binder (`symbolForNode`), one resolver; the combinator's not-found voice is deleted, the binder's stands (cross-cutting requirement 2); `Resolution` carries the declaring namespace for consumers reading `reference.namespace`. Coexistence was rejected (ships a duplicate voice against standing law); deleting main's subsystem was rejected (reverts shipped selector capability for enum/named-type/block targets). Binder pins decreed two rounds prior are rewritten with this — the kind-mismatch law relocates to the selector site; the operator may veto. + +**Amputated (operator decree, 2026-09-24):** the pre-binder entity lookup (`lookupEntityReferenceInSymbols`) and the combinator's binder-less branch are deleted. Grep proved block-attribute entity references have ZERO production users — the capability existed only in main's own synthetic test. Root-cause finding: main's design makes `parse` *produce* resolutions (the parsed value is the resolved reference), which forced pre-binder resolution for block attributes and created the dual path. Standing rule for the future: when a production block attribute needs an entity reference, it is implemented as binder phase-2 resolution over block symbols (parse returns the name; consumers read the binder) — never as parse-time resolution. `entityRef` reached without a binder is an internal error; the synthetic `@@target` pin is rewritten as the unsupported case. + +**Superseded execution refinement (historical):** `entityRef` also serves *block* attributes, which are interpreted inside `buildSymbolTable` — before a binder can exist (the binder consumes the finished table; binder-backing that path is a construction cycle, the same reality that placed the required binder on `ModelAttributeCtx` in decision 10's fork A). Resolution: the scope chain (`Scope`, the chain builders, `lookupIn`) extracts from `binder.ts` into a module working from a bare `SymbolTable`; the binder consumes it and memoizes, block-attribute interpretation consumes it directly pre-binder. One implementation, two entry points. Sole voice holds per-question: block-attribute entity references are voiced exactly once, during table build — a diagnostic domain the symbol table already owns. + +## 14. Open-question resolutions (2026-09-18) + +The spec's four launch questions were answered by the operator: (1) all new LSP features, go-to-definition included, are follow-on work — the LSP slice converts existing surfaces only; (2) the stack on PR #30335 stands, no independent landing path; (3) resolution failures use a new parser-owned `PSL_UNRESOLVED_REFERENCE` code family, adopted by interpreters; (4) user declarations shadow contributed-type symbols silently, with no diagnostic. diff --git a/projects/symbol-table-resolve/plan.md b/projects/symbol-table-resolve/plan.md new file mode 100644 index 000000000000..0d378ee1feb6 --- /dev/null +++ b/projects/symbol-table-resolve/plan.md @@ -0,0 +1,59 @@ +# symbol-table-resolve — Plan + +**Spec:** `projects/symbol-table-resolve/spec.md` +**Linear Project:** omitted at operator request. + +## At a glance + +Four slices: one foundation slice delivering the eager binder in `psl-parser` (with red-slot identity and attribute-spec wiring), then the two interpreter conversions running in parallel, then the language-server conversion closing the project. Mixed shape: a two-stage stack whose middle stage is a parallel pair. + +## Composition + +### Stack (deliver in order) + +1. **Slice `binder-core`** — Linear: omitted + - **Outcome:** `psl-parser` exports `createBinder(...) → { binder, diagnostics }` per the spec's normative pseudo-code: red-slot caching in `red.ts` gives within-snapshot node identity; the contributed-type scope turns injected type constructors into symbols; the two-phase eager pass resolves type references and attribute references by the decreed chain (declaring namespace → top level → contributed types, never siblings); `PSL_UNRESOLVED_REFERENCE` diagnostics are born here; cross-space references yield the explicit cross-space result kind. The attribute-spec parse-time context's `resolveReferencedModel` is served by the binder inside `psl-parser`. + - **Builds on:** PR #30335 (`multifiile-psl`) — external, unmerged; this project stacks on it. + - **Hands to:** the binder API + red-slot identity + diagnostic codes, stable for every conversion slice; parser tests pinning the scoping rule, contributed-type-scope invalidation (identity across snapshots), and node-identity guarantees. + - **Focus:** everything inside `psl-parser`. No consumer package changes; the four hand-rolled resolvers keep working untouched (spec's transitional-shape constraint: the binder is additive until a conversion slice claims its consumer). + +2. _(after both parallel slices below)_ **Slice `lsp-conversion`** — Linear: omitted + - **Outcome:** the language server's existing surfaces (completions, signature help, semantic tokens, diagnostics publishing) run on the binder: the span-scan reverse binding (`modelSymbolForNode` / `fieldSymbolForNode`) and both duplicated name-classification cascades are deleted; published diagnostics include the binder's; per-snapshot binder creation joins `project-artifacts.ts`'s drop-on-change discipline. + - **Builds on:** `binder-core`'s hand-off; sequenced after the interpreter conversions by operator decree. + - **Hands to:** project close-out; the reverse-binding API proven, ready for the follow-on features project (go-to-definition and kin, out of scope here). + - **Focus:** conversion of existing surfaces only — no new LSP features (spec non-goal). + - **Binder-side API decreed for this slice (operator, 2026-09-25):** completion runs on the binder — `Scope` gains enumeration (`entries()`-shaped; one entry per name, nearest declaration suppressing parents, mirroring `lookup`'s shadowing exactly) and the binder retains its scopes from the walk, exposing a position-shaped retrieval (`scopeAt(node)`; Roslyn's `LookupSymbols`/`GetEnclosingBinder` precedent). This deletes the LSP's hand-rolled candidate enumerations; contributed types join completions through the chain; qualified completion reads the namespace symbol's members. + +### Parallel group A (after `binder-core`, independent of group B) + +- **Slice `sql-conversion`** — Linear: omitted + - **Outcome:** the SQL interpreter answers type-reference, relation-target, and entity-reference questions exclusively through the binder: `resolveReferencedModel` in `psl-relation-resolution.ts`, the flattened name sets, and the attribute-node re-walks are deleted; the sibling-namespace scan's behavior change is named in the PR and pinned by shadowing-schema tests; binder diagnostics are adopted in place of locally-phrased unresolved-reference complaints. + - **Builds on:** `binder-core`'s hand-off. + - **Hands to:** project close-out (nothing downstream consumes SQL-specific state; the LSP slice waits on this by decree, not by dependency). + - **Focus:** `packages/2-sql/2-authoring/contract-psl` only. Interpreter-internal indexes that are not name resolution (FK pairing, STI/MTI maps) stay as they are. + - **Carried finding (PR #30349 review, CodeRabbit, verified real):** `interpreter.ts:1430-1435` falls back to `input.modelMappings.get(fieldTypeName)`, a map flattened by bare name (last-wins), so `public.User` / `auth.User` duplicates can stamp an FK against the wrong namespace. This is exactly the hand-rolled lowering this slice replaces with the binder — include a duplicate-name-across-namespaces regression test when it does. + +### Parallel group B (after `binder-core`, independent of group A) + +- **Slice `mongo-conversion`** — Linear: omitted + - **Outcome:** the Mongo interpreter resolves through the binder: `allModels.find(...)`, the flattened model-name set, and its `findModelAttributeNode` copy are deleted; namespace-blindness is corrected, named in the PR, and pinned by shadowing-schema tests; binder diagnostics adopted. + - **Builds on:** `binder-core`'s hand-off. + - **Hands to:** project close-out. + - **Focus:** `packages/2-mongo-family/2-authoring/contract-psl` only. + +## Dependencies (external) + +- [x] PR #30335 (`multifiile-psl`) — **merged 2026-09-18** (squash `e943c959e8`); `binder-core` rebased onto `origin/main` cleanly, all gates re-run green (base drift: one renamed helper + one added test, neither ours). Slices now target `main` directly. +- [x] Base-branch escapee (`integration-tests` typecheck) — **fixed by the squash itself**: `lsp-emit-parity.integration.test.ts` now constructs `new DocumentStore()`; 66/66 pass on `origin/main`. The `--filter='!integration-tests'` gate exclusion is retired; only the environmental `prisma7-adoption` exclusion remains. + +## Open items + +- `PSL_UNRESOLVED_REFERENCE` is exported as a constant from the binder module but is not yet a member of the `PslDiagnosticCode` union (`framework-components/src/shared/psl-extension-block.ts`, where its sibling `PSL_DUPLICATE_DECLARATION` lives) — that file is outside the binder-core slice's walls. Land the union member in whichever slice or follow-up first lawfully touches `framework-components`; compilation is unaffected meanwhile (`ContributedPslDiagnosticCode` is `` `PSL_${string}` ``). +- ADR 163 line 49 carries the genuinely stale `buildSymbolTable({ document, sourceFile, scalarTypes, pslBlockDescriptors })` signature (the psl-parser README's copy was already fixed on the base branch; D4 verified rather than invented a correction). Out of every slice's scope — route as a small direct change after this project, or fold into whichever slice next touches `docs/`. +- Spec amendment (D2, implementer-discovered, orchestrator-accepted): `createBinder` takes `{ sources, symbolTable, typeConstructors, attributeSpecs }` — the `documents` option was unread (phase 1 reaches every node through the symbol table) and a dead parameter would falsely claim a dependency. Operator may veto. + +- Flaky under concurrent load, seen in two dispatches, passes in isolation every time: `integration-tests` typecheck (`Cannot find module '@prisma/orm-postgres/...'` — build-ordering in the turbo wave) and two npm-tarball test files. Worth an operator-filed ticket rather than per-round re-diagnosis. + +## Sequencing rationale + +The dependency graph alone would allow the LSP conversion to run parallel with the interpreter conversions — all three depend only on `binder-core`. The serialization of LSP after both interpreters is an explicit operator decree (design-decisions.md § 7), not a graph constraint: the interpreters exercise the binder's resolution semantics most deeply, so their conversions surface any semantic fault before the LSP builds on it. `sql-conversion` and `mongo-conversion` stay parallel — different packages, no shared files, no ordering decree between them. `prisma-7` appears nowhere by decree; its untouched status is a project-DoD condition, not a slice. diff --git a/projects/symbol-table-resolve/slices/binder-core/briefs/d1-r1.md b/projects/symbol-table-resolve/slices/binder-core/briefs/d1-r1.md new file mode 100644 index 000000000000..0a6d622be5b2 --- /dev/null +++ b/projects/symbol-table-resolve/slices/binder-core/briefs/d1-r1.md @@ -0,0 +1,36 @@ +# Brief: red-slot-identity (dispatch 1, round 1) + +## Task + +Add child-wrapper caching to the red layer of `@internal/psl-parser` so that repeated traversal to the same tree position returns the identical object. Today `packages/1-framework/2-authoring/psl-parser/src/syntax/red.ts` constructs a fresh `SyntaxNode`/`SyntaxToken` wrapper on every access (`wrapElement`/`childAt`, around lines 401–425): two walks to the same position yield non-`===` objects, which makes `WeakMap`-keyed side tables silently miss. Cache each child wrapper in its parent red node's slot on first access (Roslyn's `SyntaxNode.GetRed` design, single-threaded variant: a lazily-filled slot array on `SyntaxNode`), so `childAt`, `children()`, `firstChild`, `nextSibling`, `ancestors()`, `tokenAtOffset`, and `coveringElement` all return cached wrappers on repeat access. The green layer stays untouched — green nodes are position-free and shareable by design and must never hold red state. + +Write the tests BEFORE the implementation (repo rule): extend the red-layer test suite with identity pins — same `===` object from repeated `childAt(i)`, from two `children()` iterations, from `tokenAtOffset` at the same offset twice, from `coveringElement` over the same range twice, and for parent chains reached via different descent paths. + +## Scope + +**In:** `packages/1-framework/2-authoring/psl-parser/src/syntax/red.ts` and the red-layer tests (`packages/1-framework/2-authoring/psl-parser/test/syntax/red.test.ts`). + +**Out:** everything else. No green-layer changes (`green.ts`, `green-builder.ts`). No binder code (later dispatches). No changes to `exports/`, no consumer packages, no AST wrapper classes (`syntax/ast/*`). + +## Completed when + +- [ ] Identity tests exist and pass: repeated traversal to the same position returns the identical object across `childAt`, `children()`, `firstChild`, `nextSibling`, `ancestors()`, `tokenAtOffset`, `coveringElement`. +- [ ] All pre-existing `psl-parser` package tests stay green (`pnpm test` inside `packages/1-framework/2-authoring/psl-parser`) and workspace typecheck is clean (`pnpm typecheck` at repo root). +- [ ] `git diff --stat` shows only `src/syntax/red.ts` + red-layer test files changed. + +## Standing instruction + +Stay focused on the goal; control scope. Trivial-and-related fixes that obviously serve the goal go in the same dispatch with a one-line note in your wrap-up message. Anything that pulls you off the goal — even if it looks useful — halts and surfaces. + +## References + +- Slice spec: `projects/symbol-table-resolve/slices/binder-core/spec.md` — chosen design + pre-investigated edge cases (the "green nodes shared/position-free" row governs this dispatch). +- Slice plan entry: `projects/symbol-table-resolve/slices/binder-core/plan.md` § Dispatch 1. +- Parent spec: `projects/symbol-table-resolve/spec.md` § Cross-cutting requirement 4 (within-snapshot node identity) — external grounding: Roslyn `SyntaxNode.GetRed` caches red children in parent slots via compare-exchange; our variant is single-threaded, so a plain slot assignment suffices. +- Working branch: `binder-core` (stacked on `origin/multifiile-psl`, PR #30335). You are already on it. + +## Operational metadata + +- **Environment:** this worktree has NO `node_modules` — run `pnpm install` first (uses the shell's Node; never `nvm`/`npx`). Build/test commands per repo `CLAUDE.md`. +- **Time-box:** 45 minutes of active work after install completes. Overrun → halt and surface. +- **Halt conditions:** a green-layer change appears necessary; existing tests turn out to depend on wrappers being fresh (e.g. mutation of wrapper state between traversals); the diff spreads beyond `red.ts` + red tests; `pnpm install` fails. diff --git a/projects/symbol-table-resolve/slices/binder-core/briefs/d1.ids.json b/projects/symbol-table-resolve/slices/binder-core/briefs/d1.ids.json new file mode 100644 index 000000000000..d2df3d3da3ab --- /dev/null +++ b/projects/symbol-table-resolve/slices/binder-core/briefs/d1.ids.json @@ -0,0 +1,8 @@ +{ + "dispatch_id": "a7dfb427-1c2a-4645-9bb5-3e952ea7a399", + "round_id": "08f31830-8aa1-48bb-956f-7622ce04d01a", + "dispatch_start_ts": "2026-09-18T14:41:38.756Z", + "round_start_ts": "2026-09-18T14:41:38.756Z", + "round2_id": "0098993f-479c-46bc-ba4e-61769347ace5", + "round2_start_ts": "2026-09-18T14:58:23.579Z" +} diff --git a/projects/symbol-table-resolve/slices/binder-core/briefs/d2-r1.md b/projects/symbol-table-resolve/slices/binder-core/briefs/d2-r1.md new file mode 100644 index 000000000000..cef4f4dd7a57 --- /dev/null +++ b/projects/symbol-table-resolve/slices/binder-core/briefs/d2-r1.md @@ -0,0 +1,36 @@ +# Brief: binder-phase-1 (dispatch 2, round 1) + +## Task + +Create the binder's phase 1 in `@internal/psl-parser`: a package-internal `createBinder(options) → { binder, diagnostics }` (interface + factory per repo pattern — interface exported as a type, implementing class package-private) that, at creation, walks the symbol table once, registers every declaration in a `WeakMap` (`declaredSymbol` lookup), and resolves every field's type reference through the decreed scope chain — declaring namespace → top level → universe scope, sibling namespaces never consulted — recording each resolution in a `WeakMap` keyed by the field's type node (`symbolForNode` lookup). The universe scope is a separate module: built once from an injected type-constructor registry, mapping scalar/constructor names to universe symbols; the same registry object must yield the same universe scope object across two binder creations (config-derived, document-edit-independent). Unresolved type references produce diagnostics in a new `PSL_UNRESOLVED_REFERENCE` code family, returned beside the binder (mirroring `buildSymbolTable`'s `{ symbolTable, diagnostics }` shape). Resolution kinds cover at least: model, composite type, named type, universe symbol, cross-space (when `typeContractSpaceId` is set — no diagnostic), unresolved (diagnostic). Fields with `malformedType: true` are skipped silently. References bind to first-wins symbols; the binder never re-emits duplicate-declaration diagnostics. Normative pseudo-code: parent spec § "The eager pass" — phase 1 only; attribute references (phase 2) are the next dispatch. + +Write tests BEFORE implementation. Must-cover: the shadowing schema (same model name in a namespace and at top level — namespace-local wins from inside, top level from outside), universe fallback (`String` resolves to a universe symbol; a user `model Uuid` shadows the universe `Uuid` silently), qualified references (`ns.Name` via `typeNamespaceId`), cross-space kind (no diagnostic), unresolved diagnostic (code, filename, range via `PslSources`), `malformedType` silence, `declaredSymbol` for model/composite/field declaration nodes, repeated queries returning stable (`===`) results, universe-scope object identity across two `createBinder` calls with the same registry, and multi-document resolution (reference in document A to a model declared in document B). + +## Scope + +**In:** new modules under `packages/1-framework/2-authoring/psl-parser/src/` (suggested: `binder.ts` + `universe-scope.ts`, or a `binder/` folder — implementer's judgment), their tests under `test/`, and — only if a type needs sharing — minimal type additions in existing files. Diagnostic-code addition where `ParseDiagnostic` codes live. + +**Out:** attribute-reference resolution (D3). Package exports (`src/exports/index.ts` — D4). Any consumer package. Any change to `symbol-table.ts` beyond none-at-all (if phase 1 appears to require a symbol-table change, that is a halt condition, not a judgment call). `red.ts` (D1 landed it; report, don't touch). + +## Completed when + +- [ ] Tests exist (written first) covering every must-cover case above and pass. +- [ ] Package tests green (`pnpm test` in `psl-parser`); filtered workspace typecheck green (`pnpm turbo run typecheck --filter='!prisma7-adoption' --filter='!integration-tests'` — the two exclusions are pre-existing at the branch point); package lint clean. +- [ ] Diff confined to `psl-parser` src/test. +- [ ] No code comments (operator's standing law; the README documents the binder in D4). + +## Standing instruction + +Stay focused on the goal; control scope. Trivial-and-related fixes that obviously serve the goal go in the same dispatch with a one-line note. Anything that pulls you off the goal halts and surfaces. + +## References + +- Parent spec: `projects/symbol-table-resolve/spec.md` § "At a glance" + "The eager pass" (normative) + Cross-cutting requirements 1–6. +- Slice spec § Pre-investigated edge cases (all six rows are phase-1-or-phase-2 relevant). +- Existing surfaces: `src/symbol-table.ts` (SymbolTable/ModelSymbol/FieldSymbol shapes — `typeName`/`typeNamespaceId`/`typeContractSpaceId`/`malformedType`), `src/source-file.ts` (`PslSources`), `src/diagnostic.ts` (diagnostic shapes), type-constructor registry surface (find via `getAuthoringTypeConstructor` usages in the SQL interpreter for the shape; the binder takes the registry as an injected option — no dependency on target packages). +- D1's hand-off: red nodes now have within-snapshot identity; `WeakMap` keying is safe; `childAt` is public on `SyntaxNode`. + +## Operational metadata + +- **Time-box:** 90 minutes active work. Overrun → halt and surface. +- **Halt conditions:** a `symbol-table.ts` change appears necessary; the type-constructor registry's shape can't be consumed without importing from a target package; the decreed scope chain conflicts with an existing behavior a test pins; diff spreads beyond `psl-parser`. diff --git a/projects/symbol-table-resolve/slices/binder-core/briefs/d2.ids.json b/projects/symbol-table-resolve/slices/binder-core/briefs/d2.ids.json new file mode 100644 index 000000000000..2cf970dbbe36 --- /dev/null +++ b/projects/symbol-table-resolve/slices/binder-core/briefs/d2.ids.json @@ -0,0 +1,8 @@ +{ + "dispatch_id": "70c07c0a-9521-4131-85b6-f15b2c5f843a", + "round_id": "655b087a-fab3-4dc5-bb4a-57e2e3460f19", + "dispatch_start_ts": "2026-09-18T15:00:48.630Z", + "round_start_ts": "2026-09-18T15:00:48.630Z", + "round2_id": "f758f1ec-6533-4dd4-ae94-a72e4b8a88f2", + "round2_start_ts": "2026-09-18T15:17:47.563Z" +} diff --git a/projects/symbol-table-resolve/slices/binder-core/briefs/d3-r1.md b/projects/symbol-table-resolve/slices/binder-core/briefs/d3-r1.md new file mode 100644 index 000000000000..8aec1617cc70 --- /dev/null +++ b/projects/symbol-table-resolve/slices/binder-core/briefs/d3-r1.md @@ -0,0 +1,44 @@ +# Brief: binder-phase-2-attributes (dispatch 3, round 1) + +## Task + +Extend the binder with phase 2: attribute-reference resolution, running after phase 1 in the same eager creation pass and reading phase-1 results. For every model/composite symbol and every field symbol, walk its `ResolvedAttribute[]` (already on the symbols — do not re-walk the CST): + +1. **Attribute name → spec.** Resolve each attribute's name against an injected attribute-spec registry (a new `attributeSpecs` option on `createBinder`; family-agnostic shape — the binder must not import from target packages). Record the resolution keyed by the attribute's name node. Unknown attribute → `PSL_UNRESOLVED_REFERENCE`-family diagnostic (distinct code acceptable within the family, implementer's judgment). +2. **Reference-kinded arguments**, as declared by the spec's combinators (ADR 231/249 — `fieldRef`, `referencedFieldRef`, `entityRef`): + - `fieldRef` (e.g. `@@index([a, b])`, `@relation(fields: [...])`, `@@id`, `@@unique`): resolve each name against the declaring owner's fields; unresolved → diagnostic. + - `referencedFieldRef` (e.g. `@relation(references: [...])`): when the declaring field's `typeContractSpaceId` is set → explicit cross-space resolution kind, NO diagnostic; otherwise resolve against the phase-1 type target's fields (a map read of phase-1 results, not a re-resolution); unresolved or target-missing → diagnostic. + - `entityRef` (e.g. `@@base(Foo)`): resolve through the decreed scope chain (declaring namespace → top level; universe symbols are not entities — a scalar name here is unresolved). +3. All resolutions land in the same `WeakMap` behind `symbolForNode`; all failures land in the returned `diagnostics` (filename + range via `PslSources`). + +Consult how the attribute-spec combinator kinds are represented today (`src/attribute-spec/` — `combinators/field-ref.ts`, `entity-ref.ts`, `types.ts`, `assemble.ts`) to define the injected registry's shape; the parse-time `AttributeCtx` machinery itself is NOT to be modified in this dispatch (the ctx-helper wiring is D4). + +Tests FIRST. Must-cover: `@relation(fields:, references:)` both resolving; `references:` against a cross-space field type (explicit kind, no diagnostic); `references:` when the field's type is unresolved (diagnostic, no crash); `@@index`/`@@id`/`@@unique` field lists (resolved + one unknown name → diagnostic); `@@base` to an existing model, to a missing name (diagnostic), and to a scalar name (diagnostic); unknown attribute name (diagnostic); attribute name node reachable via `symbolForNode`; diagnostics-completeness (a schema with several failures yields exactly the expected set — no duplicates, no omissions); phase-1 behaviors unregressed. + +## Scope + +**In:** the binder/universe modules from D2 and their tests; a registry-shape type addition if needed (family-agnostic). + +**Out:** `src/attribute-spec/**` modifications (D4 wires the ctx helper; the spec machinery itself changes in neither). Package exports (D4). Consumers. `red.ts`, `symbol-table.ts`. + +## Completed when + +- [ ] Must-cover tests exist (written first) and pass; full package suite green. +- [ ] Filtered workspace typecheck green (`pnpm turbo run typecheck --filter='!prisma7-adoption' --filter='!integration-tests'`); package lint clean. +- [ ] Diff confined to `psl-parser` src/test; no changes under `src/attribute-spec/`. +- [ ] No code comments. + +## Standing instruction + +Stay focused on the goal; control scope. Trivial-and-related fixes with a one-line note; drift halts and surfaces. + +## References + +- Parent spec § "The eager pass" — phase 2 + `bindArgs` pseudo-code (normative). +- Slice spec § Pre-investigated edge cases — cross-space and duplicate-declaration rows. +- D2's hand-off: phase-1 resolution results, diagnostic channel, universe scope. + +## Operational metadata + +- **Time-box:** 90 minutes active work. Overrun → halt and surface. +- **Halt conditions:** the combinator kinds cannot be represented family-agnostically without importing a target package; a change inside `src/attribute-spec/` appears necessary; an existing attribute-spec test pins behavior conflicting with the decreed resolution; diff spreads beyond `psl-parser`. diff --git a/projects/symbol-table-resolve/slices/binder-core/briefs/d3.ids.json b/projects/symbol-table-resolve/slices/binder-core/briefs/d3.ids.json new file mode 100644 index 000000000000..f01f4c1c31cc --- /dev/null +++ b/projects/symbol-table-resolve/slices/binder-core/briefs/d3.ids.json @@ -0,0 +1,8 @@ +{ + "dispatch_id": "600acbc6-919e-4e10-9cc4-d08a24f9a75f", + "round_id": "6eeb9b5b-5755-465c-9db0-b1d167579b2a", + "dispatch_start_ts": "2026-09-18T15:20:23.607Z", + "round_start_ts": "2026-09-18T15:20:23.607Z", + "round2_id": "0f752f3c-de14-4c40-a0bc-5247c4ee3685", + "round2_start_ts": "2026-09-18T15:37:30.067Z" +} diff --git a/projects/symbol-table-resolve/slices/binder-core/briefs/d4-r1.md b/projects/symbol-table-resolve/slices/binder-core/briefs/d4-r1.md new file mode 100644 index 000000000000..8e49fe4ead77 --- /dev/null +++ b/projects/symbol-table-resolve/slices/binder-core/briefs/d4-r1.md @@ -0,0 +1,40 @@ +# Brief: public-surface (dispatch 4, round 1) + +## Task + +Finish the slice's public surface in `psl-parser`: + +1. **Exports.** From `src/exports/index.ts`, export the binder factory and its types (`createBinder`, the `Binder` interface, the resolution-kind union, `typeReferenceNode`, the `PSL_UNRESOLVED_REFERENCE` constant, universe-scope types as needed). No barrel re-exports elsewhere; no `exports/syntax.ts` changes unless a binder type genuinely belongs there. +2. **Attribute-ctx helper.** A helper that builds the ADR 249 parse-time contexts from a binder — specifically `resolveReferencedModel` becomes a binder map read (`symbolForNode(typeReferenceNode(field))` narrowed to a model). Shape it so the three conversion slices consume it without re-deriving context (consult `src/attribute-spec/spec-context.ts` + `types.ts` and the consumer call sites in the SQL interpreter's `sql-attribute-specs.ts` for what the helper must produce). Do not change the attribute-spec machinery itself; the helper adapts, it does not modify. +3. **README.** Update `packages/1-framework/2-authoring/psl-parser/README.md`: document the binder (creation, the decreed scope chain — declaring namespace → top level → universe, never siblings — the resolution kinds including `block` and `crossSpace`, the `{ binder, diagnostics }` result and the diagnostics-ownership rule), document the now-public `SyntaxNode.childAt` and the red-layer identity guarantee, and correct the stale symbol-table text (`scalarTypes` parameter and `ScalarSymbol`/`TypeAliasSymbol` types no longer exist — current shape is `NamedTypeSymbol` and injected type constructors). Two reviewer-mandated sentences: (a) universe-scope sharing is keyed by the caller's registry object identity — rebuild the registry per parse and sharing silently degrades to per-snapshot scopes (still correct, guarantee gone); (b) qualified references resolve at whole-`QualifiedName` granularity — `app` and `Item` in `app.Item` yield the one resolution. +4. **Final gates**, including a package build. + +Tests first for the helper (a test proving `resolveReferencedModel` returns the model symbol via the binder for a `@relation` schema, and `undefined` for cross-space/unresolved). Reviewer-mandated: at least one helper test drives the binder with a REAL target attribute spec whose reference params sit under `optional(list(...))` (as `sql-attribute-specs.ts`'s relation spec does), not only bare `list(...)` — the ctx helper is where a real registry first meets the binder. + +## Scope + +**In:** `src/exports/index.ts`, a new helper module, its tests, `README.md`, and the minimal type surface the exports need. + +**Out:** consumer packages; `src/attribute-spec/**` behavior changes; new binder semantics; `red.ts`/`symbol-table.ts`/`binder.ts` internals beyond what exporting strictly requires. + +## Completed when + +- [ ] Helper exported + tested (`resolveReferencedModel` as a binder map read, demonstrated). +- [ ] README updated as specified (binder, scope chain, `childAt` identity guarantee, stale text corrected). +- [ ] `pnpm build` green for `psl-parser`; package tests green; filtered workspace typecheck green (`--filter='!prisma7-adoption' --filter='!integration-tests'`); package lint clean. +- [ ] Slice-DoD sweep: `git diff --stat origin/multifiile-psl...HEAD` confined to `psl-parser` + `projects/symbol-table-resolve/`; no code comments anywhere in the slice's `+` diff. + +## Standing instruction + +Stay focused on the goal; control scope. Trivial-and-related fixes with a one-line note; drift halts and surfaces. + +## References + +- Slice spec § Chosen design (helper bullet) + slice-specific done conditions. +- Parent spec § At a glance (consumer-wiring block — normative for the helper's shape). +- D2/D3 hand-offs: the binder API and resolution kinds as committed. + +## Operational metadata + +- **Time-box:** 60 minutes active work. Overrun → halt and surface. +- **Halt conditions:** the helper cannot be built without modifying `src/attribute-spec/**`; an export forces a change in `framework-components`; README correction reveals a semantic (not textual) doc conflict; diff spreads beyond scope. diff --git a/projects/symbol-table-resolve/slices/binder-core/briefs/d4.ids.json b/projects/symbol-table-resolve/slices/binder-core/briefs/d4.ids.json new file mode 100644 index 000000000000..1c10991d1e59 --- /dev/null +++ b/projects/symbol-table-resolve/slices/binder-core/briefs/d4.ids.json @@ -0,0 +1,6 @@ +{ + "dispatch_id": "8f52b77f-b02a-4e3b-8a32-5fe61e655422", + "round_id": "efdaddf2-e234-466d-a906-232940f19fdb", + "dispatch_start_ts": "2026-09-18T15:40:42.692Z", + "round_start_ts": "2026-09-18T15:40:42.692Z" +} diff --git a/projects/symbol-table-resolve/slices/binder-core/briefs/d5-r1.md b/projects/symbol-table-resolve/slices/binder-core/briefs/d5-r1.md new file mode 100644 index 000000000000..ac66bece07a0 --- /dev/null +++ b/projects/symbol-table-resolve/slices/binder-core/briefs/d5-r1.md @@ -0,0 +1,38 @@ +# Brief: combinator-binder-wiring (dispatch 5, round 1) + +## Task + +Wire the binder into the attribute-spec parse path so a converted consumer speaks with one diagnostic voice. Three moves, all inside `psl-parser`: + +1. **Ctx carries the whole binder.** The parse-time `AttributeCtx` (`src/attribute-spec/types.ts`) gains an optional `binder: Binder` member (operator decree: the whole `Binder`, not a narrowed callback). The D4 context builders (`binder-context.ts`) populate it. `resolveReferencedModel` stays on the ctx for legacy construction sites. +2. **Reference combinators consume binder resolutions.** In `fieldRef`, `referencedFieldRef` (`combinators/field-ref.ts`), and `entityRef` (`combinators/entity-ref.ts`): when `ctx.binder` is present, obtain the argument's resolution via `ctx.binder.symbolForNode()` — a map read of the phase-2 results, never a re-resolution — and emit **no** resolution/existence diagnostics from the combinator (the binder's returned diagnostics are the sole voice; shape/arity failures remain the combinator's). The combinator's parsed output value must be unchanged in both modes — interpreters keep receiving exactly what they receive today. +3. **Legacy path untouched.** When `ctx.binder` is absent, behavior is byte-for-byte today's; the existing attribute-spec suite pins it and must stay green unmodified. + +Tests FIRST. Must-cover: with a binder-backed ctx, (a) a valid `@relation(fields:, references:)` parses to the same output as the legacy path; (b) an unknown referenced field yields exactly ONE diagnostic — the binder's `PSL_UNRESOLVED_REFERENCE` — and none from the combinator; (c) same single-voice property for an unknown `fieldRef` name and an unknown `entityRef`; (d) cross-space `references:` — no diagnostic from either voice, parse output as today; (e) legacy ctx (no binder) — duplicate-free existing behavior preserved (existing tests unmodified and green). Also update the README's binder/helper documentation for the ctx wiring. + +## Scope + +**In:** `src/attribute-spec/types.ts`, `combinators/field-ref.ts`, `combinators/entity-ref.ts`, `src/binder-context.ts`, their tests, README's binder section, exports only if a type must surface. + +**Out:** consumer packages; binder/universe semantics; other combinators; `symbol-table.ts`; `red.ts`. + +## Completed when + +- [ ] Must-cover tests exist (written first) and pass; existing attribute-spec tests green unmodified. +- [ ] Full gates: package tests; `pnpm build` in `psl-parser`; workspace typecheck `pnpm turbo run typecheck --filter='!prisma7-adoption'` (the `integration-tests` exclusion is retired — do not use it); package lint. +- [ ] Diff confined to `psl-parser` + `projects/symbol-table-resolve/`; no code comments. + +## Standing instruction + +Stay focused on the goal; control scope. Trivial-and-related fixes with a one-line note; drift halts and surfaces. + +## References + +- Slice spec § Chosen design, D5 bullet (the contract) + new done condition. +- Parent spec § Cross-cutting requirement 2 (sole voice) and the shape/resolution boundary. +- D3's keying: resolutions are keyed by `ResolvedAttributeArg.expression` nodes — the same nodes the combinators hold. Red-slot identity guarantees the lookup hits. + +## Operational metadata + +- **Time-box:** 60 minutes active work. Overrun → halt and surface. +- **Halt conditions:** the combinator cannot reach the same node object the binder keyed (identity mismatch — this would falsify D3's keying and must surface, not be patched); suppressing combinator diagnostics breaks a consumer-facing test in an unconverted path; diff spreads beyond scope. diff --git a/projects/symbol-table-resolve/slices/binder-core/briefs/d5.ids.json b/projects/symbol-table-resolve/slices/binder-core/briefs/d5.ids.json new file mode 100644 index 000000000000..b4561a09e521 --- /dev/null +++ b/projects/symbol-table-resolve/slices/binder-core/briefs/d5.ids.json @@ -0,0 +1,8 @@ +{ + "dispatch_id": "eb7520b6-0c88-4130-a487-750d7d0902b9", + "round_id": "7973c95f-e562-40bf-add2-5085d903a6e0", + "dispatch_start_ts": "2026-09-18T16:15:21.383Z", + "round_start_ts": "2026-09-18T16:15:21.383Z", + "round2_id": "cca77cba-09a5-48c8-8a64-5292402d7d73", + "round2_start_ts": "2026-09-18T16:36:11.993Z" +} diff --git a/projects/symbol-table-resolve/slices/binder-core/briefs/d6-r1.md b/projects/symbol-table-resolve/slices/binder-core/briefs/d6-r1.md new file mode 100644 index 000000000000..353c9bbf6fbe --- /dev/null +++ b/projects/symbol-table-resolve/slices/binder-core/briefs/d6-r1.md @@ -0,0 +1,43 @@ +# Brief: required-binder-threading (dispatch 6, round 1) + +## Task + +Execute decision 10 (operator decree; see `design-decisions.md § 10` and the amended slice spec): + +1. **`AttributeCtx.binder` becomes required.** Delete `resolveReferencedModel` from the context types (`src/attribute-spec/types.ts`) entirely — the combinators no longer call it, so it is dead surface; its four consumer-supplied implementations go with it. +2. **Remove the binder-less fallback** from `fieldRef`/`referencedFieldRef` (`combinators/field-ref.ts`) and `entityRef` (`combinators/entity-ref.ts`): the binder map read is the only path; the legacy existence-check code is deleted, not gated. +3. **Fail resolution on non-field.** In `fieldRef`/`referencedFieldRef`: when the binder's resolution for the argument node is absent or its kind is not `field`, the argument PARSE FAILS — the combinator returns a failed result and interpreters never receive a bogus name. Constraint: this must not produce a second diagnostic for a failure the binder already voiced (`PSL_UNRESOLVED_REFERENCE` exists for that node). If the parse machinery cannot represent a failure without emitting its own diagnostic, HALT and surface the machinery's shape — do not invent a workaround. Exception: `crossSpace` resolutions parse successfully (deferred by design, D3). `entityRef` keeps its current success semantics (the decree names field references). +3a. **Surface the binder's diagnostics in every consumer (operator decree, D6 R3).** Each interpreter pushes the diagnostics returned by its `createBinder` call into its own diagnostic collector; the language server merges them into its published set. Companions required for correctness: (i) the binder's spec-registry view becomes **owner-aware** so context-dependent spec factories (SQL `@default`) are visible and no false `PSL_UNRESOLVED_ATTRIBUTE` is emitted — minimal API extension, implementer's shape; (ii) where a consumer's residual validator re-voices a resolution-class failure the binder now reports (e.g. SQL `resolvePolymorphism` on `@@base` targets), the consumer's duplicate emission is removed — resolution failures are the binder's, semantic validation stays the consumer's; (iii) the sibling-namespace scoping correction surfaces as binder diagnostics with named test updates, never as silent drops. + +4. **Thread the binder through every construction site.** Making the member required lets the compiler enumerate them — follow the type errors. Expected sites: `psl-parser`'s own helpers/tests, SQL `contract-psl` (`sql-attribute-specs.ts:75-97` builders), Mongo `contract-psl` (`mongo-attribute-specs.ts`), language server (`attribute-spec-resolution.ts`, which today passes `() => undefined`). Each consumer builds its binder from the snapshot artifacts it already holds (symbol table, sources, its type-constructor registry, its assembled attribute specs) — use the D4 helpers where they fit. Thread ONLY the context construction; do not convert the consumers' other hand-rolled resolution (their slices' work). +5. **README**: update the binder/ctx section — the precondition paragraph stays (same-snapshot requirement is now enforced by construction at the type level for presence, still prose for sameness); drop the "optional" framing. + +Tests: existing D5 legacy-path pins are now pins of a deleted path — REWRITE them as binder-required tests (this is the one authorized case of modifying existing tests; name the rewrites in your report). New must-cover: non-field parse failure for both field-ref kinds with no duplicate diagnostic; cross-space still parses; each consumer package's suite green after threading. + +## Scope + +**In:** `psl-parser` (`attribute-spec/types.ts`, the two combinator files, `binder-context.ts`, exports if needed, README, tests); **authorized by orchestrator ruling under decision 10:** the parse machinery's failure-tracking change in `attribute-spec/combinators/list.ts`, `record.ts`, and `interpret.ts`'s `interpretArgs` (failure tracked separately from diagnostic count; provably inert — no existing combinator returns an empty failure), extended to `one-of.ts` only if its no-match fall-through would otherwise re-voice a reference failure; the attribute-context construction sites ONLY in `packages/2-sql/2-authoring/contract-psl`, `packages/2-mongo-family/2-authoring/contract-psl`, `packages/1-framework/3-tooling/language-server` (+ any site the compiler surfaces inside those packages or `psl-parser`). + +**Out:** consumers' type-reference/relation resolution; `binder.ts` semantics; `contract-prisma7` — **if the compiler surfaces a `contract-prisma7` construction site, HALT immediately** (its exclusion decree conflicts; the operator must rule). + +## Completed when + +- [ ] `resolveReferencedModel` gone from ctx types and from every consumer; `binder` required; no binder-less combinator path remains (grep-verifiable). +- [ ] Non-field parse failure + cross-space success pinned by tests; no double diagnostics anywhere (the D5 single-voice contrast tests, rewritten, still prove it). +- [ ] Gates: `pnpm test` in `psl-parser` AND in each touched consumer package; `pnpm test:packages` at the root (cross-package gate — required since public surface changed); workspace typecheck `pnpm turbo run typecheck --filter='!prisma7-adoption'`; `pnpm build` for `psl-parser`; `pnpm lint:deps`; package lints. +- [ ] Diff confined to `psl-parser`, the three consumers' attribute-context files, and `projects/symbol-table-resolve/`. No code comments. + +## Standing instruction + +Stay focused on the goal; control scope. Trivial-and-related fixes with a one-line note; drift halts and surfaces. + +## References + +- `design-decisions.md § 10` (the decree), slice spec § Chosen design (amended D5/D6 bullet) + done conditions. +- D5's hand-off: the map-read mechanics and contrast-pair tests you wrote. +- LSP context note: the language server today defaults `resolveReferencedModel` to `() => undefined` — after threading, its attribute parsing gains real resolution through the binder; if any LSP test pinned the `undefined` behavior (e.g. absent diagnostics it now gains), report the delta rather than silently accepting it, and HALT if the delta is user-visible in a way the test suite disputes. + +## Operational metadata + +- **Time-box:** 120 minutes active work. Overrun → halt and surface. +- **Halt conditions:** a `contract-prisma7` construction site; parse-failure-without-diagnostic unrepresentable; an LSP behavior delta its suite disputes; any consumer needing more than ctx-construction changes; diff beyond scope. diff --git a/projects/symbol-table-resolve/slices/binder-core/briefs/d6.ids.json b/projects/symbol-table-resolve/slices/binder-core/briefs/d6.ids.json new file mode 100644 index 000000000000..d7904fad8d15 --- /dev/null +++ b/projects/symbol-table-resolve/slices/binder-core/briefs/d6.ids.json @@ -0,0 +1,12 @@ +{ + "dispatch_id": "ebb4c38f-2303-45f3-9004-a6b2f5ffa3a4", + "round_id": "0c2f0276-4a49-4660-bb5d-c9db5c4c3d1f", + "dispatch_start_ts": "2026-09-18T17:08:43.752Z", + "round_start_ts": "2026-09-18T17:08:43.752Z", + "round2_id": "f83cd940-380a-4e6d-9b37-b7fb34820fa5", + "round2_start_ts": "2026-09-18T17:23:38.055Z", + "round3_id": "7095a1d3-3810-41d6-8b75-e98b8031d3bf", + "round3_start_ts": "2026-09-18T18:52:18.467Z", + "round4_id": "20d1c941-b874-4a82-be61-0c48f9c1e8ca", + "round4_start_ts": "2026-09-18T19:49:49.359Z" +} diff --git a/projects/symbol-table-resolve/slices/binder-core/plan.md b/projects/symbol-table-resolve/slices/binder-core/plan.md new file mode 100644 index 000000000000..72b05a67844e --- /dev/null +++ b/projects/symbol-table-resolve/slices/binder-core/plan.md @@ -0,0 +1,47 @@ +# binder-core — Dispatch plan + +Slice spec: `projects/symbol-table-resolve/slices/binder-core/spec.md`. Branch: `binder-core` created from `origin/multifiile-psl` (PR #30335). All dispatches write tests before implementation per the repo rule. + +### Dispatch 1: red-slot-identity + +- **Outcome:** `syntax/red.ts` caches child wrappers in parent slots: repeated traversal to the same position returns the identical (`===`) `SyntaxNode`/`SyntaxToken` object, pinned by tests across `childAt`, `children()`, `firstChild`, `nextSibling`, `ancestors()`, `tokenAtOffset`, `coveringElement`; `pnpm test` green in `psl-parser`. +- **Builds on:** the slice spec's chosen design; `origin/multifiile-psl` checked out. +- **Hands to:** within-snapshot node identity — `WeakMap` side tables are henceforth correct; green layer untouched. +- **Focus:** `src/syntax/red.ts` and its tests only. No binder code; no public API change. + +### Dispatch 2: binder-phase-1 + +- **Outcome:** `createBinder(...) → { binder, diagnostics }` exists (interface + factory, package-internal): contributed-type scope built from an injected type-constructor registry; phase 1 registers declarations and resolves every field type reference through declaring namespace → top level → contributed types (never siblings); `declaredSymbol`/`symbolForNode` answer from `WeakMap` tables; unresolved type references emit `PSL_UNRESOLVED_REFERENCE`; `malformedType` fields are skipped silently; `typeContractSpaceId` references yield the explicit cross-space kind; references bind to first-wins symbols; tests pin the scope chain (shadowing schema), contributed-type-scope identity across two builds, and stable repeated-query results. +- **Builds on:** dispatch 1's node identity (the `WeakMap` tables). +- **Hands to:** a working binder for declarations + type references, with the diagnostic channel and contributed-type scope in place — the structure phase 2 extends. +- **Focus:** new binder + contributed-type-scope modules and tests. Attribute references untouched (phase 2); nothing exported from the package root yet. + +### Dispatch 3: binder-phase-2-attributes + +- **Outcome:** phase 2 resolves attribute references: attribute names against the injected spec registry (unknown attribute → diagnostic), `fieldRef` args against the declaring owner's fields, `referencedFieldRef` args against the phase-1 type target (cross-space → explicit kind, no diagnostic), `entityRef` args through the scope chain; the returned diagnostics carry every resolution failure; tests cover `@relation(fields:/references:)`, `@@index`, `@@id`, `@@unique`, `@@base`, and unknown-attribute cases. +- **Builds on:** dispatch 2's binder structure and phase-1 resolution results. +- **Hands to:** complete eager resolution per the parent spec's normative pseudo-code — the binder is semantically whole. +- **Focus:** phase 2 inside the binder module + tests. No consumer-facing helper yet. + +### Dispatch 4: public-surface + +- **Outcome:** the binder, its types, and an attribute-ctx helper (builds the ADR 249 parse-time context with `resolveReferencedModel` as a binder map read, demonstrated by test) are exported from `src/exports/index.ts`; the `psl-parser` README documents the binder and its scope chain, the now-public `SyntaxNode.childAt` with its identity guarantee (reviewer note, D1), and corrects the stale `scalarTypes`/`ScalarSymbol` text while touching that section; `pnpm build` + full package tests green; diff confined to `psl-parser` + `projects/symbol-table-resolve/`. +- **Builds on:** dispatch 3's semantically-whole binder. +- **Hands to:** the slice-DoD state — the stable API surface the three conversion slices consume. +- **Focus:** exports, helper, docs, final gates. No new resolution logic. + +### Dispatch 5: combinator-binder-wiring (added by operator decree after D4 closed) + +- **Outcome:** `AttributeCtx` carries an optional `binder: Binder`; the D4 context builders populate it; `fieldRef`/`referencedFieldRef`/`entityRef` consume the binder's phase-2 resolutions via `symbolForNode` (map read, no re-resolution) and emit no resolution diagnostics when the binder is present — the binder's diagnostics are the sole voice; legacy no-binder behavior byte-for-byte unchanged and pinned by the existing suite; README's binder section documents the ctx wiring; full gates green (workspace typecheck now excluding only `prisma7-adoption`). +- **Builds on:** dispatch 3's phase-2 resolutions and dispatch 4's context builders. +- **Hands to:** the slice-DoD state, now including single-voice diagnostics through the spec-interpretation path — conversion slices become pure deletions with no double-diagnostic intermediate state. +- **Focus:** `src/attribute-spec/` ctx types + the three reference combinators + `binder-context.ts` + tests. No consumer packages; no binder-semantics changes. + +### Dispatch 6: required-binder-threading (added by operator decree after D5 closed) + +- **Outcome:** `AttributeCtx.binder` is required; `resolveReferencedModel` is deleted from the context types; the combinators' binder-less fallback paths are removed; every context construction site — SQL (`sql-attribute-specs.ts`), Mongo (`mongo-attribute-specs.ts`), language server (`attribute-spec-resolution.ts`), plus whatever the compiler surfaces — threads a same-snapshot binder; `fieldRef`/`referencedFieldRef` arguments resolving to a non-field fail their parse without a second diagnostic; cross-space still parses; all affected package suites green. +- **Builds on:** dispatch 5's wiring. +- **Hands to:** the slice-DoD state under decision 10 — no dual path anywhere; conversion slices inherit consumers that already hold a binder. +- **Focus (widened by operator decree mid-dispatch):** ctx types + combinators + parse-machinery failure tracking + owner-aware registry view in `psl-parser`; in the three consumer packages: ctx-construction threading AND binder-diagnostic surfacing (collectors gain the binder's diagnostics; duplicate resolution-class emissions removed; registries completed). NOT the consumers' hand-rolled type-reference/relation machinery beyond what the threading already deleted (their slices, now correspondingly smaller). `contract-prisma7` verified clear. + +Sizes: D1 S, D2 L, D3 M, D4 S, D5 M, D6 L. Sequential; no parallel-within-slice. diff --git a/projects/symbol-table-resolve/slices/binder-core/spec.md b/projects/symbol-table-resolve/slices/binder-core/spec.md new file mode 100644 index 000000000000..5ad0b456fab9 --- /dev/null +++ b/projects/symbol-table-resolve/slices/binder-core/spec.md @@ -0,0 +1,56 @@ +# Slice: binder-core + +Parent project `projects/symbol-table-resolve/`. Outcome: `psl-parser` gains the eager binder — the single authoritative resolver every later slice converts its consumer onto. + +## At a glance + +Adds red-slot child caching to `syntax/red.ts` (within-snapshot node identity) and a new binder module exporting `createBinder(...) → { binder, diagnostics }`, resolving all type and attribute references eagerly per snapshot under the decreed scope chain. Unblocks the SQL, Mongo, and LSP conversion slices; no consumer package changes. + +## Chosen design + +Normative pseudo-code and API live in the parent spec — `projects/symbol-table-resolve/spec.md` § "At a glance" / "The eager pass". Slice-level specifics: + +- **Red-slot caching** (`src/syntax/red.ts`): `SyntaxNode` gains a lazily-filled child-slot array; `childAt` (and everything built on it — `children()`, `firstChild`, `nextSibling`, `tokenAtOffset`, `coveringElement`) returns the cached wrapper on repeat access. Roslyn's `GetRed` design, single-threaded variant. Green layer untouched. +- **Contributed-type scope** (new module in `psl-parser`): builds `name → ContributedTypeSymbol` once from an injected type-constructor registry; the object is config-derived and shared across snapshots. +- **Binder** (new module in `psl-parser`, interface + factory per repo pattern): two-phase eager pass over the symbol table (phase 1 declarations + type references, phase 2 attribute references reading phase-1 results); side tables are `WeakMap`; queries `declaredSymbol(node)` / `symbolForNode(node)`; diagnostics returned beside the binder, `PSL_UNRESOLVED_REFERENCE` code family, cross-space references yield an explicit cross-space result kind with no diagnostic. +- **Attribute-ctx helper**: `psl-parser` exports a helper that builds the ADR 249 parse-time context from a binder, so each conversion slice wires `resolveReferencedModel` as one map read. Consumers are not modified in this slice — the four existing hand-rolled implementations keep working (parent spec, transitional-shape constraint). +- **Exports** via `src/exports/index.ts`; tests written before implementation per repo rule. +- **Combinator wiring (D5, operator-decreed after D4 closed; hardened by D6):** the parse-time `AttributeCtx` carries the whole `Binder` — **required** as of D6 (decision 10); `resolveReferencedModel` is deleted from the context types. The reference combinators (`fieldRef`, `referencedFieldRef`, `entityRef`) consume the binder's already-computed resolutions via `symbolForNode` (one map read, never a re-resolution) and emit **no** resolution diagnostics of their own — the binder's returned diagnostics are the sole voice (parent spec, cross-cutting requirement 2). There is no binder-less path. A field reference resolving to a non-field fails its parse without a second diagnostic; cross-space parses successfully with resolution deferred. Every context construction site — including the three consumer packages' — threads a same-snapshot binder. + +## Coherence rationale + +One package, one subject: the binder and the node-identity substrate it requires. Every line of the diff lands in `psl-parser` (plus project artifacts); the reviewer reads a self-contained new capability with its tests, with zero behavior change for any existing consumer. + +## Scope + +**In:** `packages/1-framework/2-authoring/psl-parser` — `src/syntax/red.ts`, new binder + contributed-type-scope modules, attribute-ctx helper, the D5 combinator wiring (`src/attribute-spec/` ctx types + reference combinators), `src/exports/index.ts`, package tests; branch originally stacked on `origin/multifiile-psl` (PR #30335), rebased onto `main` after its squash-merge. + +**Out:** the SQL/Mongo/LSP consumers' hand-rolled type-reference and relation resolution (their conversion slices) — D6 touches only their attribute-context construction sites; `contract-prisma7` (non-goal — if it turns out to construct attribute contexts, halt for operator ruling); laziness or cross-snapshot memo retention; incremental reparse; new LSP features. + +## Pre-investigated edge cases + +| Edge case | Disposition | Notes | +| --------- | ----------- | ----- | +| Green nodes shared/position-free | Caches live only on red nodes; green layer must stay untouched | Decided in discussion — green-keyed caches would go stale silently once sharing exists (design-decisions § 1) | +| Cross-space reference (`typeContractSpaceId` set) | Explicit `crossSpace` resolution kind, **no** diagnostic | Replaces today's documented silent skip in `field-ref.ts` | +| Field with `malformedType` | Skip resolution, no diagnostic | Existing flag exists precisely to prevent cascades | +| Duplicate declarations | References bind to the first-wins symbol | Symbol table's documented first-wins policy; binder must not re-emit duplicate diagnostics | +| Reopened namespaces (`namespace X {}` twice) | One merged `NamespaceSymbol`; scope covers members of all blocks | Already merged by the table; binder consumes the merged scope | +| Model shadowing a contributed scalar (`model Uuid`) | User declaration wins, silently | Operator-decreed (spec § Cross-cutting 1) | + +## Slice-specific done conditions + +- [ ] Diff touches only `psl-parser`, `projects/symbol-table-resolve/`, the attribute-context construction sites in `2-sql/2-authoring/contract-psl` and `2-mongo-family/2-authoring/contract-psl` (D6; the language server needed none), and one additive `and` combinator in `0-foundation/utils` where the `Result` type lives (review round, operator-designed) — nothing else in those packages. +- [ ] A `fieldRef`/`referencedFieldRef` argument resolving to a non-field fails its parse with no second diagnostic; cross-space still parses; pinned by tests. +- [ ] Contexts carry the binder as a compile-level requirement wherever a reference combinator can appear (decision 10 — `resolveReferencedModel` no longer exists; the once-planned attribute-ctx builder helpers were deleted in review: zero production callers, their purpose died with the callback they replaced). +- [ ] A resolution failure inside an attribute argument yields exactly one diagnostic — the binder's, carrying its reference class; neither the combinators nor a consumer's residual validators re-voice it, pinned by exact-set assertions in each converted consumer. + +## Open Questions + +None — all design questions were settled in discussion (see `projects/symbol-table-resolve/design-decisions.md`). + +## References + +- Parent project: `projects/symbol-table-resolve/spec.md` +- Linear issue: omitted at operator request. +- Relevant ADRs: 249 (attribute-spec contexts), 253 (red-root source ownership), 126 (descriptor injection precedent). diff --git a/projects/symbol-table-resolve/spec.md b/projects/symbol-table-resolve/spec.md new file mode 100644 index 000000000000..72cfc8a60852 --- /dev/null +++ b/projects/symbol-table-resolve/spec.md @@ -0,0 +1,150 @@ +# symbol-table-resolve — PSL binder + +## Purpose + +Give every PSL consumer one authoritative answer to "which declaration does this name denote," with one scoping rule and one diagnostic voice. Today four independent resolvers (SQL interpreter, Mongo interpreter, Prisma-7 interpreter, language server) re-implement name resolution and disagree on namespace scoping, so the same schema resolves differently depending on which tool asks. The parser gains a lazy, cached binder service; consumers stop hand-rolling resolution. + +## At a glance + +Today, resolving a field's type name is answered four different ways: + +- SQL interpreter: top-level models first, then **all namespaces in arbitrary key order** (`psl-relation-resolution.ts:71-83`). +- Language server: declaring namespace first, then top level (`completion-symbols.ts:151-166`). +- Mongo interpreter: `allModels.find((m) => m.name === field.typeName)` — namespace-blind. +- Attribute specs: an uncached `resolveReferencedModel()` callback each consumer supplies with its own rule. + +After this project, one service answers, lazily, with memoized results: + +```ts +const { binder, diagnostics } = createBinder({ sources, symbolTable, typeConstructors, attributeSpecs }); + +binder.declaredSymbol(modelDeclarationNode); // declaration node -> the symbol it declares +binder.symbolForNode(typeReferenceNode); // reference node -> the symbol it denotes +diagnostics; // every resolution failure; the binder's sole voice +``` + +The API is node-addressed and minimal — the two questions every surveyed compiler distinguishes (Roslyn: `GetDeclaredSymbol` vs `GetSymbolInfo`), with diagnostics returned beside the binder, extending the `buildSymbolTable` result pattern (`{ symbolTable, diagnostics }`). Symbol-addressed conveniences are added later only if call sites demand them. + +Resolution follows a single decreed rule — declaring namespace → top level → contributed-type scope (config-derived scalar/type-constructor symbols) — and sibling namespaces are never consulted. The binder resolves **eagerly at creation** in one two-phase pass; queries are map reads. Invalidation is by abandonment: an edit produces a new snapshot (documents + symbol table + binder), and the old one becomes garbage as a whole. There is no invalidation protocol to maintain or to get wrong. + +### The eager pass (normative pseudo-code) + +```ts +function createBinder({ sources, symbolTable, typeConstructors, attributeSpecs }) { + const contributedTypes = contributedTypeScope(typeConstructors); // config scope: type names, shared across snapshots + const specs = attributeSpecs; // config scope: attribute names (target-contributed, ADR 236) + const declarations = new WeakMap(); // decl node -> its symbol + const references = new WeakMap(); // ref node -> what it denotes + const diagnostics: ParseDiagnostic[] = []; + + // PHASE 1: declarations + type references + for (const scope of scopesOf(symbolTable)) + for (const entity of [...scope.models, ...scope.compositeTypes]) { + declarations.set(entity.node.syntax, entity); + for (const field of entity.fields) { + declarations.set(field.node.syntax, field); + references.set(typeNode(field).syntax, + resolveTypeRef(field, chain(scope, symbolTable.topLevel, contributedTypes))); + } + } + + // PHASE 2: attribute references (reads phase-1 results; no cycle — type refs never need attribute refs) + for (const [scope, entity] of entitiesOf(symbolTable)) { + for (const attr of entity.attributes) { // ResolvedAttribute[] — args already parsed + const spec = specs.model(attr.name); + references.set(attrNameNode(attr).syntax, + spec ? { kind: 'attributeSpec', spec } : unknownAttribute(attr, diagnostics)); + if (spec) bindArgs(attr, spec, { self: entity, scope }); + } + for (const field of entity.fields) + for (const attr of field.attributes) { + const spec = specs.field(attr.name); + const referencedModel = modelOf(references.get(typeNode(field).syntax)); // phase-1 map read + if (spec) bindArgs(attr, spec, { self: entity, field, referencedModel, scope }); + } + } + + function bindArgs(attr, spec, ctx) { + // only the spec knows which arguments are references (combinator kinds, ADR 231/249) + for (const { argNode, kind, name } of referenceArgs(attr, spec)) { + switch (kind) { + case 'fieldRef': // @@index([a, b]), @relation(fields: [...]) + bind(argNode, ctx.self.fields[name], diagnostics); break; + case 'referencedFieldRef': // @relation(references: [...]) + if (ctx.field.typeContractSpaceId !== undefined) + references.set(argNode.syntax, { kind: 'crossSpace' }); // explicit kind, no diagnostic: + // resolvable only where that contract space is known (replaces today's silent skip) + else bind(argNode, ctx.referencedModel?.fields[name], diagnostics); break; + case 'entityRef': // @@base(Foo), @@discriminator target + bind(argNode, resolveName(name, chain(ctx.scope, symbolTable.topLevel)), diagnostics); break; + } + } + } + + return { binder: { declaredSymbol, symbolForNode }, diagnostics }; +} +``` + +Consumer wiring (amended by operator decree after the first slice's D5): the attribute-spec parse-time context carries the binder as a **required** member — `resolveReferencedModel` is deleted from the context types, its four consumer-supplied implementations with it. Every context construction site (SQL, Mongo, language server) supplies a binder built over the same snapshot; the reference combinators have no binder-less path. A `fieldRef`/`referencedFieldRef` argument whose binder resolution is not a field **fails the parse** (without a second diagnostic — the binder's is the voice); cross-space stays a successful parse with resolution deferred to where that space is known. + +Interpreters still run spec interpretation for argument **values**; the binder owns only name-reference resolution within attribute arguments. Query timing is not part of the binder's contract — `declaredSymbol` and `symbolForNode` reveal nothing about when resolution ran; the factory's eagerly-returned diagnostics are the contract's one timing commitment, owned and revisable by the future incremental-reparse project. + +## Non-goals + +- **Prisma-7 interpreter conversion.** `contract-prisma7` keeps its hand-rolled cross-file name map untouched, by operator decree. +- **Incremental reparse, green-node sharing, hash-consing.** Every edit still reparses the document in full; snapshot economics make this acceptable at PSL scale. +- **Dependency-tracked (Salsa-style) invalidation.** No revision counters, no memo verification, no dependency recording. Snapshot discard only. +- **Lazy binding and cross-snapshot memo retention.** The binder resolves eagerly per snapshot. On-demand resolution and early-cutoff memo reuse become worthwhile only alongside incremental reparse; that future project inherits an API already shaped for them. +- **Bug-for-bug parity for corrected consumers.** The SQL sibling-namespace scan and Mongo's namespace blindness are defects being corrected, not behavior being preserved; each correction is named in its slice. +- **Restoring scalar name lists into `buildSymbolTable`.** The eager symbol table stays family-blind; scalar knowledge enters only at binder creation, as symbols in the contributed-type scope, sourced from the existing type-constructor registry (commit `72cd71550f`'s unification stands). +- **New LSP features.** The LSP slice only converts existing surfaces (completions, signature help, semantic tokens) onto the binder. Every new resolution-backed feature — go-to-definition, hover, references, rename — is follow-on work outside this project, by operator decree. + +## Place in the larger world + +- **Home:** `packages/1-framework/2-authoring/psl-parser`. The binder follows the repo's interface + factory pattern for stateful services; the symbol table remains pure data with no lookup methods. +- **Base branch:** stacks on PR #30335 (`multifiile-psl`), which already makes `buildSymbolTable` accept `documents: readonly DocumentAst[]` plus a `PslSources` registry (red-root → `SourceFile`, ADR 253). The binder is multi-document from birth; N=1 is the common case. +- **Consumers converted:** SQL `contract-psl` (`packages/2-sql/2-authoring/contract-psl`), Mongo `contract-psl` (`packages/2-mongo-family/2-authoring/contract-psl`), the attribute-spec combinators (`psl-parser/src/attribute-spec/`, ADR 249 contexts), and the language server (`packages/1-framework/3-tooling/language-server`). +- **Constraining ADRs:** ADR 249 (central attribute-spec registry — the `resolveReferencedModel` seat the binder fills), ADR 253 (red-root source ownership — the identity-keyed side-table precedent the binder extends), ADR 126 (block SPI — the injection precedent for target-contributed knowledge), ADR 104 (namespace qualification grammar the scoping rule interprets), ADR 163 (provider → parse → symbol table → interpret contract the binder slots into). +- **External practice grounding** (researched, not recalled): Roslyn's red-slot caching (`SyntaxNode.GetRed`, `Interlocked.CompareExchange`) and lazy `Binder`/`SemanticModel` layering; TypeScript's eager per-file bind with lazy memoized checking discarded per program; snapshot-discard invalidation over dependency tracking per rust-analyzer's own architecture assessment for small languages. Sources in References. + +## Cross-cutting requirements + +1. **One scoping rule everywhere, kind-blind.** Unqualified references resolve declaring namespace → top level → contributed-type scope; sibling namespaces are never consulted; within a scope a name denotes at most one symbol (the table's duplicate checking guarantees it), so each scope answers `lookup(name)` without regard to kind and **any declaration shadows any outer declaration of the same name** — an enum shadows a model, silently. What kind a reference *requires* is validated after resolution: a name that resolves to the wrong kind gets a kind-mismatch diagnostic naming what it is and what was needed, never a false "cannot find". Every converted consumer carries a test pinning shadowing against a schema where a namespaced declaration shadows a top-level one. +2. **The binder is the sole voice of resolution failures.** It owns unresolved- and ambiguous-reference diagnostics (it holds `PslSources`, so filenames and ranges are at hand), returned beside the binder from `createBinder`. Failure diagnostics use a new parser-owned `PSL_UNRESOLVED_REFERENCE` code family; converted consumers adopt these codes, map them into their channels, and never re-emit their own — the symbol table's duplicate-declaration precedent, extended. Shape failures (arity, argument type, malformed literals) remain the spec combinators' voice; they are not resolution. +3. **Queries are timing-neutral; diagnostics are a creation-time guarantee.** `declaredSymbol` and `symbolForNode` return snapshot-scoped, stable answers and reveal nothing about when resolution ran. Complete diagnostics are returned by `createBinder` itself — the contract's one eager commitment, accepted knowingly for pipeline symmetry; a future lazy implementation must fill it at creation or change the factory's result shape. Resolution runs eagerly at creation: phase 1 type references, phase 2 attribute references (which read phase-1 results); the diagnostics are that pass's byproduct. +4. **Within-snapshot node identity.** The red layer caches child wrappers in parent slots (Roslyn's `GetRed` design): two traversals to the same position return the same object, making identity-keyed side tables (`WeakMap`) correct. Memos are keyed by object identity of symbols and red nodes — never by green nodes (position-free and shareable across snapshots) and never by spans as cross-snapshot keys. +5. **Invalidation by abandonment.** Document-derived state (red tree, symbol table, binder memos) is dropped whole on any document change, per the existing `project-artifacts.ts` drop-on-change discipline. The contributed-type scope is config-derived, shared across snapshots, and invalidated only by configuration change — never by document edits. +6. **Family knowledge is injected, not imported.** The binder receives the type-constructor registry (scalars included) at its factory, as `pslBlockDescriptors` is injected today; `psl-parser` gains no dependency on target packages. +7. **Corrections are named, never smuggled.** Each consumer conversion states its behavior changes in its slice spec and PR description. + +## Transitional-shape constraints + +- ~~Work stacks on the unmerged PR #30335 and lands after it.~~ Obsolete: #30335 squash-merged 2026-09-18; the branch is based on `main`. PR CI tests the merge into current `main` automatically — which on 2026-09-24 exposed that contributed model attributes (`@@fullTextIndex`, landed on `main` post-branch-point, carrying reference arguments) must reach the binder's injected spec namespace; fixed in-tree with a reproduction fixture, no base-merge required. +- A consumer converts wholly within its slice: no consumer carries both a hand-rolled resolver and binder calls for the same question across slice boundaries. +- Unconverted consumers keep working at every intermediate state. Amended by operator decree: the attribute-argument question converts across ALL consumers at once (the required-binder threading) — per-question wholeness supersedes per-consumer wholeness for that question; each consumer's remaining hand-rolled resolution (type references, relation targets) still converts wholly in its own slice. + +## Project Definition of Done + +- [ ] Team-DoD floor (inherited; `drive/calibration/dod.md` is absent in this repo — the standing floor is green CI, tests written before implementation, and `pnpm lint:deps` clean). +- [ ] A binder service exists in `psl-parser` behind an interface + factory returning `{ binder, diagnostics }`, the binder exposing the node-addressed core (`declaredSymbol`, `symbolForNode`); tests pin that repeated queries return stable results and that the returned diagnostics carry every resolution failure without consumer-side enumeration. +- [ ] Cross-space references resolve to an explicit cross-space result kind with no binder diagnostic, replacing today's silent skip in `referencedFieldRef`. +- [ ] A red-layer test pins within-snapshot identity: traversing to the same child twice yields the same object. +- [ ] SQL and Mongo interpreters answer type-reference, relation-target, and entity-reference questions exclusively through the binder; their local resolvers (`resolveReferencedModel` in `psl-relation-resolution.ts`, Mongo's `allModels.find`) are deleted. +- [ ] `resolveReferencedModel` is removed from the attribute-spec context types; the context's `binder` member is required; every construction site (SQL, Mongo, language server) threads a same-snapshot binder; the combinators have no binder-less path; a field reference resolving to a non-field fails its parse without a second diagnostic. +- [ ] The language server's span-scan reverse binding (`modelSymbolForNode`) and both duplicated name-classification cascades (`completion-symbols.ts`, `semantic-tokens.ts`) are replaced by binder queries. +- [ ] The shadowing schema (same name declared in a namespace and at top level) resolves identically — namespace-local first — in parser tests, both interpreter test suites, and LSP tests. +- [ ] Unresolved-reference diagnostics are emitted only by the binder; converted consumers contain no unresolved-reference emission of their own. +- [ ] A test pins that a document edit does not rebuild the contributed-type scope (object identity across snapshots) and that user declarations shadow contributed-type symbols. +- [ ] `packages/2-sql/2-authoring/contract-prisma7` is untouched: its diff against the base branch is empty at project close. + +## Open Questions + +None. The four questions this spec first shipped with were resolved by the operator on 2026-09-18: all new LSP features (go-to-definition included) are follow-on work; the stack on PR #30335 stands with no independent landing path; the `PSL_UNRESOLVED_REFERENCE` code family is confirmed; silent shadowing of contributed-type symbols is confirmed. The resolutions are folded into Non-goals and Cross-cutting requirements above. + +## References + +- Linear Project: omitted at operator request. +- Base PR: [prisma/orm#30335 — refactor(psl): track source provenance by syntax root](https://github.com/prisma/orm/pull/30335), branch `multifiile-psl`. +- ADRs: 249 (attribute-spec registry), 253 (red-root source ownership), 126 (block SPI), 104 (namespacing), 163 (provider interpretation). +- Design-discussion record: [`design-decisions.md`](./design-decisions.md) — the decrees with reasoning and rejected alternatives. +- External sources: [Roslyn `SyntaxNode.GetRed`](https://github.com/dotnet/roslyn/blob/main/src/Compilers/Core/Portable/Syntax/SyntaxNode.cs), [Lippert — red-green trees](https://ericlippert.com/2012/06/08/red-green-trees/), [Roslyn `BinderFactory`](https://github.com/dotnet/roslyn/blob/main/src/Compilers/CSharp/Portable/Binder/BinderFactory.cs), [TypeScript binder notes](https://github.com/microsoft/TypeScript-Compiler-Notes/blob/main/codebase/src/compiler/binder.md), [TypeScript checker notes](https://github.com/microsoft/TypeScript-Compiler-Notes/blob/main/codebase/src/compiler/checker.md), [Salsa book](https://salsa-rs.github.io/salsa/), [rust-analyzer architecture](https://rust-analyzer.github.io/book/contributing/architecture.html), [Three Architectures for Responsive IDE](https://rust-analyzer.github.io/blog/2020/07/20/three-architectures-for-responsive-ide.html), [clangd threading design](https://clangd.llvm.org/design/threads). diff --git a/projects/symbol-table-resolve/trace.jsonl b/projects/symbol-table-resolve/trace.jsonl new file mode 100644 index 000000000000..044b20c074c7 --- /dev/null +++ b/projects/symbol-table-resolve/trace.jsonl @@ -0,0 +1,69 @@ +{"event_id":"fc2536ad-0597-403a-a8b0-975f91da6911","event_type":"spec-authored","schema_version":"1","ts":"2026-09-18T14:01:16.477Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"spec_path":"projects/symbol-table-resolve/spec.md","spec_kind":"project","byte_length":11353,"edge_cases_count":null,"open_questions_count":4,"dod_items_count":10} +{"event_id":"680dc951-ebaf-48f8-998f-93074d15e32c","event_type":"spec-amended","schema_version":"1","ts":"2026-09-18T14:06:19.118Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"spec_path":"projects/symbol-table-resolve/spec.md","spec_kind":"project","byte_length":11214,"bytes_delta":-139,"edge_cases_count":null,"open_questions_count":0,"dod_items_count":10,"reason":"operator-correction","sections_changed":["Non-goals","Cross-cutting requirements","Open Questions"]} +{"event_id":"24c7fb3d-ddde-40e1-a964-f520e0161f4d","event_type":"spec-amended","schema_version":"1","ts":"2026-09-18T14:27:22.386Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"spec_path":"projects/symbol-table-resolve/spec.md","spec_kind":"project","byte_length":16023,"bytes_delta":4809,"edge_cases_count":null,"open_questions_count":0,"dod_items_count":11,"reason":"replan-from-discussion","sections_changed":["At a glance","Non-goals","Cross-cutting requirements","Project Definition of Done","References"]} +{"event_id":"528d5f0c-01f2-4d09-9d3d-4518acdc301b","event_type":"spec-amended","schema_version":"1","ts":"2026-09-18T14:30:41.554Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"spec_path":"projects/symbol-table-resolve/spec.md","spec_kind":"project","byte_length":16394,"bytes_delta":371,"edge_cases_count":null,"open_questions_count":0,"dod_items_count":11,"reason":"operator-correction","sections_changed":["At a glance","Cross-cutting requirements","Project Definition of Done"]} +{"event_id":"91780a92-cf10-44b6-9c65-c311a9831ead","event_type":"plan-authored","schema_version":"1","ts":"2026-09-18T14:34:59.589Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"plan_path":"projects/symbol-table-resolve/plan.md","plan_kind":"project","byte_length":5059,"slice_count":4,"dispatch_count":null,"dispatch_size_distribution":null,"open_items_count":1} +{"event_id":"468e4c97-d094-4f79-8f32-5a2b283cf1ad","event_type":"health-check-fired","schema_version":"1","ts":"2026-09-18T14:38:17.834Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"cadence":"opening-rollup","drift_signal_count":0,"max_drift_severity":"none","recommended_next":"slice binder-core (needs slice spec + slice plan before build)"} +{"event_id":"a7a7fe9a-89b1-4a63-abf1-40d3187579e1","event_type":"spec-authored","schema_version":"1","ts":"2026-09-18T14:39:15.345Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"spec_path":"projects/symbol-table-resolve/slices/binder-core/spec.md","spec_kind":"slice","byte_length":4683,"edge_cases_count":6,"open_questions_count":0,"dod_items_count":2} +{"event_id":"153b08b7-d390-46be-ba90-ea59dff7b55c","schema_version":"1","ts":"2026-09-18T14:40:06.796Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"plan-authored","plan_path":"projects/symbol-table-resolve/slices/binder-core/plan.md","plan_kind":"slice","byte_length":3724,"dispatch_count":4,"slice_count":null,"dispatch_size_distribution":{"S":2,"M":1,"L":1,"XL":0},"open_items_count":0} +{"event_id":"43c03429-d045-45cd-b5e9-1760669f278d","schema_version":"1","ts":"2026-09-18T14:40:06.796Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"slice-started","slice_slug":"binder-core","slice_index":1,"linear_ref":null} +{"event_id":"93270070-e983-4024-98cf-5175e5de980c","schema_version":"1","ts":"2026-09-18T14:41:38.756Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"dispatch-start","dispatch_id":"a7dfb427-1c2a-4645-9bb5-3e952ea7a399","dispatch_name":"red-slot-identity D1 R1","subagent_type":"general-purpose","model":null,"parent_dispatch_id":null} +{"event_id":"61e6f906-31f9-47a4-a099-513db5d2a27e","schema_version":"1","ts":"2026-09-18T14:41:38.756Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"round-start","dispatch_id":"a7dfb427-1c2a-4645-9bb5-3e952ea7a399","round_id":"08f31830-8aa1-48bb-956f-7622ce04d01a","round_number":1} +{"event_id":"66bab5fd-4382-4a62-b405-11c40987fffa","schema_version":"1","ts":"2026-09-18T14:41:38.757Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"a7dfb427-1c2a-4645-9bb5-3e952ea7a399","round_id":"08f31830-8aa1-48bb-956f-7622ce04d01a","brief_byte_length":3739,"brief_content_hash":"19d61aa5b4a2dad3407816caa77b8bb07fdbbff534504d61677cdd7ffea8a589","brief_disposition":"initial"} +{"event_id":"bac508a6-09dc-4f12-839d-62ff626597b0","schema_version":"1","ts":"2026-09-18T14:58:23.579Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"round-end","dispatch_id":"a7dfb427-1c2a-4645-9bb5-3e952ea7a399","round_id":"08f31830-8aa1-48bb-956f-7622ce04d01a","verdict":"another-round-needed","findings_filed":1,"wall_clock_ms":1004823} +{"event_id":"3c7e25f1-c8fa-44e8-9c09-014132bba84b","schema_version":"1","ts":"2026-09-18T14:58:23.579Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"round-start","dispatch_id":"a7dfb427-1c2a-4645-9bb5-3e952ea7a399","round_id":"0098993f-479c-46bc-ba4e-61769347ace5","round_number":2} +{"event_id":"1b1976bf-8cac-4878-b61f-7d2ed16b3396","schema_version":"1","ts":"2026-09-18T14:58:23.579Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"a7dfb427-1c2a-4645-9bb5-3e952ea7a399","round_id":"0098993f-479c-46bc-ba4e-61769347ace5","brief_byte_length":144,"brief_content_hash":"528e468605c8a348fb1c81dcf741f3161060334c6e09c05a3bda00a357c4297f","brief_disposition":"amended"} +{"event_id":"bb9802bc-d3a9-48c6-823d-8b1c04df884b","schema_version":"1","ts":"2026-09-18T15:00:48.630Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"round-end","dispatch_id":"a7dfb427-1c2a-4645-9bb5-3e952ea7a399","round_id":"0098993f-479c-46bc-ba4e-61769347ace5","verdict":"satisfied","findings_filed":0,"wall_clock_ms":145051} +{"event_id":"33a3f74e-9e63-4b3e-999b-8e30cadfad74","schema_version":"1","ts":"2026-09-18T15:00:48.630Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"dispatch-end","dispatch_id":"a7dfb427-1c2a-4645-9bb5-3e952ea7a399","result":"completed","wall_clock_ms":1149874} +{"event_id":"6cdb020a-f1d9-474c-ba0d-ab41480f7998","schema_version":"1","ts":"2026-09-18T15:00:48.630Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"dispatch-start","dispatch_id":"70c07c0a-9521-4131-85b6-f15b2c5f843a","dispatch_name":"binder-phase-1 D2 R1","subagent_type":"general-purpose","model":"opus","parent_dispatch_id":"a7dfb427-1c2a-4645-9bb5-3e952ea7a399"} +{"event_id":"b72eeee9-01f2-4407-bd88-728f978f4062","schema_version":"1","ts":"2026-09-18T15:00:48.630Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"round-start","dispatch_id":"70c07c0a-9521-4131-85b6-f15b2c5f843a","round_id":"655b087a-fab3-4dc5-bb4a-57e2e3460f19","round_number":1} +{"event_id":"e1c925bf-c830-4d9a-b364-4f03299ebe02","schema_version":"1","ts":"2026-09-18T15:00:48.630Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"70c07c0a-9521-4131-85b6-f15b2c5f843a","round_id":"655b087a-fab3-4dc5-bb4a-57e2e3460f19","brief_byte_length":5023,"brief_content_hash":"97c19777ce6265572508cb780bda9646971fa92721324c3ba846ab705d45b169","brief_disposition":"initial"} +{"event_id":"a09b61ee-6c51-4a86-b325-912f61442bcb","event_type":"spec-amended","schema_version":"1","ts":"2026-09-18T15:12:48.635Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"spec_path":"projects/symbol-table-resolve/spec.md","spec_kind":"project","byte_length":16372,"bytes_delta":-22,"edge_cases_count":null,"open_questions_count":0,"dod_items_count":11,"reason":"operator-correction","sections_changed":["At a glance"]} +{"event_id":"4411a438-6409-413b-9522-869fdb032320","schema_version":"1","ts":"2026-09-18T15:17:47.563Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"round-end","dispatch_id":"70c07c0a-9521-4131-85b6-f15b2c5f843a","round_id":"655b087a-fab3-4dc5-bb4a-57e2e3460f19","verdict":"another-round-needed","findings_filed":1,"wall_clock_ms":1018933} +{"event_id":"b1bb4839-aea7-47a8-a804-2ee4c8267541","schema_version":"1","ts":"2026-09-18T15:17:47.563Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"round-start","dispatch_id":"70c07c0a-9521-4131-85b6-f15b2c5f843a","round_id":"f758f1ec-6533-4dd4-ae94-a72e4b8a88f2","round_number":2} +{"event_id":"fae28ddf-b71b-4ae0-8d8f-4ce9380cb956","schema_version":"1","ts":"2026-09-18T15:17:47.563Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"70c07c0a-9521-4131-85b6-f15b2c5f843a","round_id":"f758f1ec-6533-4dd4-ae94-a72e4b8a88f2","brief_byte_length":146,"brief_content_hash":"89c8c94b2e6cc5df2950268264c900250413806a6aff24de1b0e8476d6c4d96b","brief_disposition":"amended"} +{"event_id":"44eeae42-7e31-45a5-8c44-87a99d28b974","schema_version":"1","ts":"2026-09-18T15:20:23.607Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"round-end","dispatch_id":"70c07c0a-9521-4131-85b6-f15b2c5f843a","round_id":"f758f1ec-6533-4dd4-ae94-a72e4b8a88f2","verdict":"satisfied","findings_filed":0,"wall_clock_ms":156044} +{"event_id":"3d7509ef-638e-4b32-8d2f-8ad09aee0214","schema_version":"1","ts":"2026-09-18T15:20:23.607Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"dispatch-end","dispatch_id":"70c07c0a-9521-4131-85b6-f15b2c5f843a","result":"completed","wall_clock_ms":1174977} +{"event_id":"7837ad9c-8fa1-4dc2-ac93-998b54994dfc","schema_version":"1","ts":"2026-09-18T15:20:23.607Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"dispatch-start","dispatch_id":"600acbc6-919e-4e10-9cc4-d08a24f9a75f","dispatch_name":"binder-phase-2-attributes D3 R1","subagent_type":"general-purpose","model":"opus","parent_dispatch_id":"70c07c0a-9521-4131-85b6-f15b2c5f843a"} +{"event_id":"f6885b6c-34c2-4170-bae2-2386e1cb72c5","schema_version":"1","ts":"2026-09-18T15:20:23.607Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"round-start","dispatch_id":"600acbc6-919e-4e10-9cc4-d08a24f9a75f","round_id":"6eeb9b5b-5755-465c-9db0-b1d167579b2a","round_number":1} +{"event_id":"b4873091-8ec3-4949-9391-737d57c0da2a","schema_version":"1","ts":"2026-09-18T15:20:23.607Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"600acbc6-919e-4e10-9cc4-d08a24f9a75f","round_id":"6eeb9b5b-5755-465c-9db0-b1d167579b2a","brief_byte_length":4266,"brief_content_hash":"a3820587449833355aba8582b0e075cc904395227956c6bd9302944f6bb2af82","brief_disposition":"initial"} +{"event_id":"e9f6bc92-a5e6-4ac0-92fb-e36b95302758","schema_version":"1","ts":"2026-09-18T15:37:30.067Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"round-end","dispatch_id":"600acbc6-919e-4e10-9cc4-d08a24f9a75f","round_id":"6eeb9b5b-5755-465c-9db0-b1d167579b2a","verdict":"another-round-needed","findings_filed":2,"wall_clock_ms":1026460} +{"event_id":"e27b83c1-3df7-47d5-a9f5-671455256bc2","schema_version":"1","ts":"2026-09-18T15:37:30.067Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"round-start","dispatch_id":"600acbc6-919e-4e10-9cc4-d08a24f9a75f","round_id":"0f752f3c-de14-4c40-a0bc-5247c4ee3685","round_number":2} +{"event_id":"2a3c5e0b-df3e-427b-b161-6806891173a1","schema_version":"1","ts":"2026-09-18T15:37:30.067Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"600acbc6-919e-4e10-9cc4-d08a24f9a75f","round_id":"0f752f3c-de14-4c40-a0bc-5247c4ee3685","brief_byte_length":171,"brief_content_hash":"047898df9ec025740b8f3c93cc16e16ae529ecc11b23ec10f99f0553c641c8c8","brief_disposition":"amended"} +{"event_id":"33741dfa-be9d-4c52-b066-863993f82885","schema_version":"1","ts":"2026-09-18T15:40:42.692Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"round-end","dispatch_id":"600acbc6-919e-4e10-9cc4-d08a24f9a75f","round_id":"0f752f3c-de14-4c40-a0bc-5247c4ee3685","verdict":"satisfied","findings_filed":0,"wall_clock_ms":192625} +{"event_id":"d814edec-aa2f-46cb-acd0-35c6f6292ced","schema_version":"1","ts":"2026-09-18T15:40:42.692Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"dispatch-end","dispatch_id":"600acbc6-919e-4e10-9cc4-d08a24f9a75f","result":"completed","wall_clock_ms":1219085} +{"event_id":"6ddd1eb7-a538-4bf7-9def-b529844fab7e","schema_version":"1","ts":"2026-09-18T15:40:42.692Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"dispatch-start","dispatch_id":"8f52b77f-b02a-4e3b-8a32-5fe61e655422","dispatch_name":"public-surface D4 R1","subagent_type":"general-purpose","model":"opus","parent_dispatch_id":"600acbc6-919e-4e10-9cc4-d08a24f9a75f"} +{"event_id":"9723ffc6-127c-41c8-b396-d9b2828be389","schema_version":"1","ts":"2026-09-18T15:40:42.692Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"round-start","dispatch_id":"8f52b77f-b02a-4e3b-8a32-5fe61e655422","round_id":"efdaddf2-e234-466d-a906-232940f19fdb","round_number":1} +{"event_id":"350caf48-2285-4629-8b80-d52ca8817d5b","schema_version":"1","ts":"2026-09-18T15:40:42.692Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"8f52b77f-b02a-4e3b-8a32-5fe61e655422","round_id":"efdaddf2-e234-466d-a906-232940f19fdb","brief_byte_length":4173,"brief_content_hash":"0e13bbd6bf345af122666e31ed418f9ee086dd3e8f2bfae4864b133139f85628","brief_disposition":"initial"} +{"event_id":"4fd56d3e-b08f-47fc-898b-dcbeaa3123e9","schema_version":"1","ts":"2026-09-18T15:52:43.045Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"round-end","dispatch_id":"8f52b77f-b02a-4e3b-8a32-5fe61e655422","round_id":"efdaddf2-e234-466d-a906-232940f19fdb","verdict":"satisfied","findings_filed":0,"wall_clock_ms":720353} +{"event_id":"1cbd7dc5-2aff-4120-8775-c4d5b573191d","schema_version":"1","ts":"2026-09-18T15:52:43.045Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"dispatch-end","dispatch_id":"8f52b77f-b02a-4e3b-8a32-5fe61e655422","result":"completed","wall_clock_ms":720353} +{"event_id":"d7fa0f60-59e5-45f7-b97f-e1a34f5e0cf3","schema_version":"1","ts":"2026-09-18T16:15:21.383Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"spec-amended","spec_path":"projects/symbol-table-resolve/slices/binder-core/spec.md","spec_kind":"slice","byte_length":5884,"bytes_delta":1201,"edge_cases_count":6,"open_questions_count":0,"dod_items_count":3,"reason":"replan-from-discussion","sections_changed":["Chosen design","Scope","Slice-specific done conditions"]} +{"event_id":"eee1aad8-dd0f-4152-b080-9f2c1a103ded","schema_version":"1","ts":"2026-09-18T16:15:21.383Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"plan-amended","plan_path":"projects/symbol-table-resolve/slices/binder-core/plan.md","plan_kind":"slice","byte_length":4911,"bytes_delta":1103,"dispatch_count":5,"slice_count":null,"dispatch_size_distribution":{"S":2,"M":2,"L":1,"XL":0},"open_items_count":0,"reason":"dispatch-added","dispatches_added":1,"dispatches_removed":0,"dispatches_resized":0} +{"event_id":"008e2132-5d7f-4471-a18e-b398d701356b","schema_version":"1","ts":"2026-09-18T16:15:21.383Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"dispatch-start","dispatch_id":"eb7520b6-0c88-4130-a487-750d7d0902b9","dispatch_name":"combinator-binder-wiring D5 R1","subagent_type":"general-purpose","model":"opus","parent_dispatch_id":null} +{"event_id":"70ecbe01-9add-478e-b185-58811b812fa6","schema_version":"1","ts":"2026-09-18T16:15:21.383Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"round-start","dispatch_id":"eb7520b6-0c88-4130-a487-750d7d0902b9","round_id":"7973c95f-e562-40bf-add2-5085d903a6e0","round_number":1} +{"event_id":"4607a315-8ffc-4960-8d6f-74c93a9ad7cc","schema_version":"1","ts":"2026-09-18T16:15:21.383Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"eb7520b6-0c88-4130-a487-750d7d0902b9","round_id":"7973c95f-e562-40bf-add2-5085d903a6e0","brief_byte_length":3661,"brief_content_hash":"efd0d799d5c31ab432c3303d81f6650393eb07ed1a0195f4930d40c7c5458438","brief_disposition":"initial"} +{"event_id":"0ab13ae9-e547-48e7-83f9-f1b4bffdab61","schema_version":"1","ts":"2026-09-18T16:36:11.993Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"round-end","dispatch_id":"eb7520b6-0c88-4130-a487-750d7d0902b9","round_id":"7973c95f-e562-40bf-add2-5085d903a6e0","verdict":"another-round-needed","findings_filed":1,"wall_clock_ms":1250610} +{"event_id":"1c1cfd0a-5c6c-4af5-9814-2b9cdcd8309b","schema_version":"1","ts":"2026-09-18T16:36:11.993Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"round-start","dispatch_id":"eb7520b6-0c88-4130-a487-750d7d0902b9","round_id":"cca77cba-09a5-48c8-8a64-5292402d7d73","round_number":2} +{"event_id":"6228d507-78ae-4e46-bd77-f2fa56300c80","schema_version":"1","ts":"2026-09-18T16:36:11.993Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"eb7520b6-0c88-4130-a487-750d7d0902b9","round_id":"cca77cba-09a5-48c8-8a64-5292402d7d73","brief_byte_length":192,"brief_content_hash":"6f0e679747478dad96bffb6cdcef18f3f168ca92a587f147793535996ae1ecb2","brief_disposition":"amended"} +{"event_id":"35ca36e2-f5d1-4b5f-b45e-427e86815ed7","schema_version":"1","ts":"2026-09-18T16:38:20.750Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"round-end","dispatch_id":"eb7520b6-0c88-4130-a487-750d7d0902b9","round_id":"cca77cba-09a5-48c8-8a64-5292402d7d73","verdict":"satisfied","findings_filed":0,"wall_clock_ms":128757} +{"event_id":"4a034b03-a882-44bc-96b6-a4f859865891","schema_version":"1","ts":"2026-09-18T16:38:20.750Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"dispatch-end","dispatch_id":"eb7520b6-0c88-4130-a487-750d7d0902b9","result":"completed","wall_clock_ms":1379367} +{"event_id":"9e35b4ad-daf5-4f82-b5f4-2c773ae1f14a","schema_version":"1","ts":"2026-09-18T17:08:43.752Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"spec-amended","spec_path":"projects/symbol-table-resolve/spec.md","spec_kind":"project","byte_length":17185,"bytes_delta":813,"edge_cases_count":null,"open_questions_count":0,"dod_items_count":11,"reason":"replan-from-discussion","sections_changed":["At a glance","Transitional-shape constraints","Project Definition of Done"]} +{"event_id":"9d47a673-3806-47cb-9ee5-ead191dd9420","schema_version":"1","ts":"2026-09-18T17:08:43.752Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"spec-amended","spec_path":"projects/symbol-table-resolve/slices/binder-core/spec.md","spec_kind":"slice","byte_length":6378,"bytes_delta":494,"edge_cases_count":6,"open_questions_count":0,"dod_items_count":5,"reason":"replan-from-discussion","sections_changed":["Chosen design","Scope","Slice-specific done conditions"]} +{"event_id":"e633da18-2e0e-4c8c-a312-69188e89e2e9","schema_version":"1","ts":"2026-09-18T17:08:43.752Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"plan-amended","plan_path":"projects/symbol-table-resolve/slices/binder-core/plan.md","plan_kind":"slice","byte_length":6086,"bytes_delta":1175,"dispatch_count":6,"slice_count":null,"dispatch_size_distribution":{"S":2,"M":2,"L":2,"XL":0},"open_items_count":0,"reason":"dispatch-added","dispatches_added":1,"dispatches_removed":0,"dispatches_resized":0} +{"event_id":"3f7fa562-aedc-4af6-af43-405e7f644fc1","schema_version":"1","ts":"2026-09-18T17:08:43.752Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"dispatch-start","dispatch_id":"ebb4c38f-2303-45f3-9004-a6b2f5ffa3a4","dispatch_name":"required-binder-threading D6 R1","subagent_type":"general-purpose","model":"opus","parent_dispatch_id":null} +{"event_id":"14a989c2-30f3-4057-a0dd-226973eef6f4","schema_version":"1","ts":"2026-09-18T17:08:43.752Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"round-start","dispatch_id":"ebb4c38f-2303-45f3-9004-a6b2f5ffa3a4","round_id":"0c2f0276-4a49-4660-bb5d-c9db5c4c3d1f","round_number":1} +{"event_id":"171cd499-c276-43c2-80a4-8d4b7c91840a","schema_version":"1","ts":"2026-09-18T17:08:43.752Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"ebb4c38f-2303-45f3-9004-a6b2f5ffa3a4","round_id":"0c2f0276-4a49-4660-bb5d-c9db5c4c3d1f","brief_byte_length":5266,"brief_content_hash":"f5dacf5a0963a2dcfcfb64d3141e66048eefb38aada48e40a234a2b3d67c08cc","brief_disposition":"initial"} +{"event_id":"686672ee-1c12-436a-9bea-1119ddcecb48","event_type":"brief-issued","schema_version":"1","ts":"2026-09-18T17:12:13.301Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"dispatch_id":"ebb4c38f-2303-45f3-9004-a6b2f5ffa3a4","round_id":"0c2f0276-4a49-4660-bb5d-c9db5c4c3d1f","brief_byte_length":5684,"brief_content_hash":"4f258a0b48655bed812dc67432a00dd6696baee210f92ff2b023a008fa5ba6fc","brief_disposition":"amended"} +{"event_id":"a4b05846-10ab-499f-8c1a-2999a2009398","schema_version":"1","ts":"2026-09-18T17:23:38.055Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"round-end","dispatch_id":"ebb4c38f-2303-45f3-9004-a6b2f5ffa3a4","round_id":"0c2f0276-4a49-4660-bb5d-c9db5c4c3d1f","verdict":"another-round-needed","findings_filed":0,"wall_clock_ms":894303} +{"event_id":"15017503-32a8-4c95-834f-b3056364d96b","schema_version":"1","ts":"2026-09-18T17:23:38.055Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"round-start","dispatch_id":"ebb4c38f-2303-45f3-9004-a6b2f5ffa3a4","round_id":"f83cd940-380a-4e6d-9b37-b7fb34820fa5","round_number":2} +{"event_id":"affc50b9-a356-4dbc-bec0-904780146823","schema_version":"1","ts":"2026-09-18T17:23:38.055Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"ebb4c38f-2303-45f3-9004-a6b2f5ffa3a4","round_id":"f83cd940-380a-4e6d-9b37-b7fb34820fa5","brief_byte_length":262,"brief_content_hash":"1bf83ca0eda9c73efd95f5b1d53bd2eac8e3be2e13b2ede1622f8a7c5414aaa5","brief_disposition":"amended"} +{"event_id":"0e282640-2859-4116-b215-101b13d8000c","schema_version":"1","ts":"2026-09-18T18:52:18.467Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"plan-amended","plan_path":"projects/symbol-table-resolve/slices/binder-core/plan.md","plan_kind":"slice","byte_length":6303,"bytes_delta":1392,"dispatch_count":6,"slice_count":null,"dispatch_size_distribution":{"S":2,"M":2,"L":1,"XL":1},"open_items_count":0,"reason":"dispatch-resize","dispatches_added":0,"dispatches_removed":0,"dispatches_resized":1} +{"event_id":"5553ad7f-25cd-40a9-820d-724b53d83de4","schema_version":"1","ts":"2026-09-18T18:52:18.467Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"round-end","dispatch_id":"ebb4c38f-2303-45f3-9004-a6b2f5ffa3a4","round_id":"f83cd940-380a-4e6d-9b37-b7fb34820fa5","verdict":"stop-condition","findings_filed":0,"wall_clock_ms":5320412} +{"event_id":"d94b2212-996a-4ee8-9d8c-d32cb4f91802","schema_version":"1","ts":"2026-09-18T18:52:18.467Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"round-start","dispatch_id":"ebb4c38f-2303-45f3-9004-a6b2f5ffa3a4","round_id":"7095a1d3-3810-41d6-8b75-e98b8031d3bf","round_number":3} +{"event_id":"4a6ecef5-5d15-4887-9fb5-bdda72c9c72f","schema_version":"1","ts":"2026-09-18T18:52:18.467Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"ebb4c38f-2303-45f3-9004-a6b2f5ffa3a4","round_id":"7095a1d3-3810-41d6-8b75-e98b8031d3bf","brief_byte_length":6610,"brief_content_hash":"a9ba332758bb2383714e4faf387c536769decb43352a71ce7bed6ebd307f1a88","brief_disposition":"amended"} +{"event_id":"47878865-a8a3-4c03-a821-80951639c109","schema_version":"1","ts":"2026-09-18T19:49:49.359Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"round-end","dispatch_id":"ebb4c38f-2303-45f3-9004-a6b2f5ffa3a4","round_id":"7095a1d3-3810-41d6-8b75-e98b8031d3bf","verdict":"another-round-needed","findings_filed":1,"wall_clock_ms":3450892} +{"event_id":"6b7cce89-4dea-4ca6-a086-a59f57130036","schema_version":"1","ts":"2026-09-18T19:49:49.359Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"round-start","dispatch_id":"ebb4c38f-2303-45f3-9004-a6b2f5ffa3a4","round_id":"20d1c941-b874-4a82-be61-0c48f9c1e8ca","round_number":4} +{"event_id":"4904ffcd-33c6-456c-8bdf-208db0e851e1","schema_version":"1","ts":"2026-09-18T19:49:49.359Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"ebb4c38f-2303-45f3-9004-a6b2f5ffa3a4","round_id":"20d1c941-b874-4a82-be61-0c48f9c1e8ca","brief_byte_length":303,"brief_content_hash":"31e31ee305d19d7e7d38d76498d6302f89ce82d6de23878672ee17e2e9853f00","brief_disposition":"amended"} +{"event_id":"abe2b89e-add3-4a15-a6fe-0aa4a7761ffa","schema_version":"1","ts":"2026-09-18T20:00:35.516Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"round-end","dispatch_id":"ebb4c38f-2303-45f3-9004-a6b2f5ffa3a4","round_id":"20d1c941-b874-4a82-be61-0c48f9c1e8ca","verdict":"satisfied","findings_filed":0,"wall_clock_ms":646157} +{"event_id":"bf55de44-bfc7-4b24-b38f-7f9a1b47ac0d","schema_version":"1","ts":"2026-09-18T20:00:35.516Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"event_type":"dispatch-end","dispatch_id":"ebb4c38f-2303-45f3-9004-a6b2f5ffa3a4","result":"completed","wall_clock_ms":10311764} +{"event_id":"a8c38b6c-23eb-4107-8fe1-f267a2f5e0b9","event_type":"spec-amended","schema_version":"1","ts":"2026-09-22T13:02:05.327Z","project_run_id":"symbol-table-resolve","orchestrator_agent_id":null,"spec_path":"projects/symbol-table-resolve/spec.md","spec_kind":"project","byte_length":17671,"bytes_delta":394,"edge_cases_count":null,"open_questions_count":0,"dod_items_count":11,"reason":"replan-from-discussion","sections_changed":["Cross-cutting requirements"]}