From 77cb7d417437646488eef5e71abe4ff6908fbf2d Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 18 Sep 2026 14:41:11 +0000 Subject: [PATCH 01/66] chore(drive): symbol-table-resolve project artifacts (spec, plan, slice binder-core) Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- .../symbol-table-resolve/design-decisions.md | 81 +++++++++ projects/symbol-table-resolve/plan.md | 48 ++++++ .../slices/binder-core/plan.md | 33 ++++ .../slices/binder-core/spec.md | 53 ++++++ projects/symbol-table-resolve/spec.md | 157 ++++++++++++++++++ projects/symbol-table-resolve/trace.jsonl | 9 + 6 files changed, 381 insertions(+) create mode 100644 projects/symbol-table-resolve/design-decisions.md create mode 100644 projects/symbol-table-resolve/plan.md create mode 100644 projects/symbol-table-resolve/slices/binder-core/plan.md create mode 100644 projects/symbol-table-resolve/slices/binder-core/spec.md create mode 100644 projects/symbol-table-resolve/spec.md create mode 100644 projects/symbol-table-resolve/trace.jsonl diff --git a/projects/symbol-table-resolve/design-decisions.md b/projects/symbol-table-resolve/design-decisions.md new file mode 100644 index 000000000000..9f39f70e073d --- /dev/null +++ b/projects/symbol-table-resolve/design-decisions.md @@ -0,0 +1,81 @@ +# 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 → universe; 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 universe scope + +**Decision.** Scalars (and type constructors) are exposed as symbols in an outermost universe scope, built once per configuration from the existing type-constructor registry, chained after top level. User declarations shadow universe 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. 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 universe 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..02a61f0d91b8 --- /dev/null +++ b/projects/symbol-table-resolve/plan.md @@ -0,0 +1,48 @@ +# 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 universe 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 → universe, 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, universe-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). + +### 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. + +### 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) + +- [ ] PR #30335 (`multifiile-psl`) merges — open at planning time; operator judges it will not stall. All slices stack on it; no independent landing path (spec transitional-shape constraint). + +## 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/plan.md b/projects/symbol-table-resolve/slices/binder-core/plan.md new file mode 100644 index 000000000000..82c4363013f0 --- /dev/null +++ b/projects/symbol-table-resolve/slices/binder-core/plan.md @@ -0,0 +1,33 @@ +# 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): universe scope built from an injected type-constructor registry; phase 1 registers declarations and resolves every field type reference through declaring namespace → top level → universe (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), universe-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 universe scope in place — the structure phase 2 extends. +- **Focus:** new binder + universe-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 (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. + +Sizes: D1 S, D2 L, D3 M, D4 S. 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..8d9188d52ba4 --- /dev/null +++ b/projects/symbol-table-resolve/slices/binder-core/spec.md @@ -0,0 +1,53 @@ +# 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. +- **Universe scope** (new module in `psl-parser`): builds `name → UniverseSymbol` 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. + +## 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 + universe-scope modules, attribute-ctx helper, `src/exports/index.ts`, package tests; branch stacks on `origin/multifiile-psl` (PR #30335). + +**Out:** any change to SQL/Mongo interpreters, language server, or `contract-prisma7` (later slices / non-goals); 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 universe scalar (`model Uuid`) | User declaration wins, silently | Operator-decreed (spec § Cross-cutting 1) | + +## Slice-specific done conditions + +- [ ] Diff touches only `psl-parser` and `projects/symbol-table-resolve/` (verifiable via `git diff --stat` against `origin/multifiile-psl`). +- [ ] The attribute-ctx helper is exported and covered by a test demonstrating `resolveReferencedModel` as a binder map read. + +## 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..7ba1a8ff50cd --- /dev/null +++ b/projects/symbol-table-resolve/spec.md @@ -0,0 +1,157 @@ +# 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({ documents, 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 → universe 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({ documents, sources, symbolTable, typeConstructors, attributeSpecs }) { + const universe = universeScope(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 owner of [...scope.models, ...scope.compositeTypes]) { + declarations.set(owner.node.syntax, owner); + for (const field of owner.fields) { + declarations.set(field.node.syntax, field); + references.set(typeNode(field).syntax, + resolveTypeRef(field, chain(scope, symbolTable.topLevel, universe))); + } + } + + // PHASE 2: attribute references (reads phase-1 results; no cycle — type refs never need attribute refs) + for (const [scope, owner] of ownersOf(symbolTable)) { + for (const attr of owner.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: owner, scope }); + } + for (const field of owner.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: owner, 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: the attribute-spec parse-time context keeps its ADR 249 shape, but `resolveReferencedModel` stops being a consumer-supplied callback with four implementations and becomes one map read: + +```ts +const ctx: FieldAttributeCtx = { + ..., + resolveReferencedModel: () => modelOf(binder.symbolForNode(typeNode(field))), +}; +``` + +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 universe 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.** Unqualified references resolve declaring namespace → top level → universe scope; sibling namespaces are never consulted. User declarations shadow universe-scope symbols silently — no shadowing diagnostic. Every converted consumer carries a test pinning this rule 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 universe 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; no cherry-picking its content into `main` independently. +- 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 unmodified at every intermediate state; the binder is additive until a conversion slice claims its consumer. + +## 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. +- [ ] The attribute-spec contexts' `resolveReferencedModel` is served by the binder; consumer-supplied resolution callbacks are gone. +- [ ] 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 universe scope (object identity across snapshots) and that user declarations shadow universe 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 universe 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..1251d83d1e6a --- /dev/null +++ b/projects/symbol-table-resolve/trace.jsonl @@ -0,0 +1,9 @@ +{"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} From 03e020cb2335da1caf05307f130f060c1ff487bb Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 18 Sep 2026 14:52:40 +0000 Subject: [PATCH 02/66] feat(psl-parser): cache red child wrappers so tree positions have stable identity Repeated traversal to the same position in a red tree built fresh wrappers every time, so WeakMap-keyed side tables missed on every second lookup. SyntaxNode now materializes its child wrappers once into a lazily-filled slot array and serves childAt/children from it, making every navigation path that reaches a position return the identical object. The green layer is untouched. Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- .../2-authoring/psl-parser/src/syntax/red.ts | 67 +++++----- .../psl-parser/test/syntax/red.test.ts | 115 ++++++++++++++++++ 2 files changed, 151 insertions(+), 31 deletions(-) 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..e826f26dc23e 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,7 @@ 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: ReadonlyArray | undefined; constructor(green: GreenNode, offset: number, parent: SyntaxNode | undefined, index: number) { this.green = green; @@ -151,6 +152,32 @@ export class SyntaxNode { this.index = index; } + #slots(): ReadonlyArray { + let slots = this.#childSlots; + if (slots === undefined) { + const created: SyntaxElement[] = []; + let offset = this.offset; + let index = 0; + for (const child of this.green.children) { + created.push(wrapElement(child, offset, this, index)); + offset += elementTextLength(child); + index++; + } + slots = created; + this.#childSlots = slots; + } + return slots; + } + + /** + * The child at `index`, or `undefined` when out of range. Every traversal + * reaching the same position within one tree returns the identical wrapper, + * so red elements are usable as `WeakMap`/`Map` keys for side tables. + */ + childAt(index: number): SyntaxElement | undefined { + return this.#slots()[index]; + } + get kind(): SyntaxKind { return this.green.kind; } @@ -173,21 +200,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,13 +236,7 @@ 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++; - } + yield* this.#slots(); } *childNodes(): Iterable { @@ -389,7 +408,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 +419,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 +437,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/syntax/red.test.ts b/packages/1-framework/2-authoring/psl-parser/test/syntax/red.test.ts index 8faddcb05106..cd6fb2172beb 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 @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { parse } from '../../src/parse'; import { FieldDeclarationAst, ModelDeclarationAst } from '../../src/syntax/ast/declarations'; 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 +560,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(); From 1890f0fb532f6c36fe1796858300bb33d7abd2b9 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 18 Sep 2026 14:58:57 +0000 Subject: [PATCH 03/66] refactor(psl-parser): drop the childAt doc block Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- .../1-framework/2-authoring/psl-parser/src/syntax/red.ts | 5 ----- 1 file changed, 5 deletions(-) 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 e826f26dc23e..ff2ca2e748f7 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 @@ -169,11 +169,6 @@ export class SyntaxNode { return slots; } - /** - * The child at `index`, or `undefined` when out of range. Every traversal - * reaching the same position within one tree returns the identical wrapper, - * so red elements are usable as `WeakMap`/`Map` keys for side tables. - */ childAt(index: number): SyntaxElement | undefined { return this.#slots()[index]; } From 000f4f2d9fda3e797a0b65016abcc3991964d9ba Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 18 Sep 2026 15:10:40 +0000 Subject: [PATCH 04/66] feat(psl-parser): add the eager binder's declaration and type-reference pass createBinder walks the symbol table once at creation, registers every declaration node against its symbol, and resolves each field's type reference through the decreed chain: declaring namespace, then top level, then the universe scope built from the injected type-constructor registry. Sibling namespaces are never consulted, user declarations shadow universe symbols silently, cross-space references get an explicit kind with no diagnostic, and malformed types are skipped. Unresolved references are reported under the new PSL_UNRESOLVED_REFERENCE code, returned beside the binder. The universe scope is memoized per registry object so it survives document edits. Attribute-reference resolution is not part of this pass. Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- .../2-authoring/psl-parser/src/binder.ts | 191 +++++++++ .../psl-parser/src/universe-scope.ts | 51 +++ .../psl-parser/test/binder.test.ts | 382 ++++++++++++++++++ 3 files changed, 624 insertions(+) create mode 100644 packages/1-framework/2-authoring/psl-parser/src/binder.ts create mode 100644 packages/1-framework/2-authoring/psl-parser/src/universe-scope.ts create mode 100644 packages/1-framework/2-authoring/psl-parser/test/binder.test.ts 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..64b9de837d2e --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/binder.ts @@ -0,0 +1,191 @@ +import type { AuthoringTypeNamespace } from '@internal/framework-components/authoring'; +import { diagnosticSource } from './diagnostic'; +import type { ParseDiagnostic } from './parse'; +import type { PslSources } from './source-file'; +import type { + BlockSymbol, + CompositeTypeSymbol, + FieldSymbol, + ModelSymbol, + NamedTypeSymbol, + NamespaceSymbol, + SymbolTable, + TopLevelScope, +} from './symbol-table'; +import type { SyntaxNode } from './syntax/red'; +import { type UniverseScope, type UniverseSymbol, universeScope } from './universe-scope'; + +export const PSL_UNRESOLVED_REFERENCE = 'PSL_UNRESOLVED_REFERENCE'; + +export type PslSymbol = + | ModelSymbol + | CompositeTypeSymbol + | NamedTypeSymbol + | BlockSymbol + | NamespaceSymbol + | FieldSymbol; + +export type Resolution = + | { readonly kind: 'model'; readonly symbol: ModelSymbol } + | { readonly kind: 'compositeType'; readonly symbol: CompositeTypeSymbol } + | { readonly kind: 'namedType'; readonly symbol: NamedTypeSymbol } + | { readonly kind: 'block'; readonly symbol: BlockSymbol } + | { readonly kind: 'universe'; readonly symbol: UniverseSymbol } + | { readonly kind: 'crossSpace' } + | { readonly kind: 'unresolved'; readonly name: string }; + +export interface Binder { + declaredSymbol(node: SyntaxNode): PslSymbol | undefined; + symbolForNode(node: SyntaxNode): Resolution | undefined; +} + +export interface CreateBinderOptions { + readonly sources: PslSources; + readonly symbolTable: SymbolTable; + readonly typeConstructors: AuthoringTypeNamespace; +} + +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); + } +} + +interface Owner { + readonly scope: NamespaceSymbol | undefined; + readonly symbol: ModelSymbol | CompositeTypeSymbol; +} + +export function createBinder(options: CreateBinderOptions): BinderResult { + const { sources, symbolTable, typeConstructors } = options; + const universe = universeScope(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); + } + } + + for (const { scope, symbol } of owners(symbolTable)) { + declarations.set(symbol.node.syntax, symbol); + for (const field of Object.values(symbol.fields)) { + declarations.set(field.node.syntax, field); + const node = typeReferenceNode(field); + if (node === undefined) continue; + const resolution = resolveTypeReference(field, scope, symbolTable.topLevel, universe); + if (resolution === undefined) continue; + references.set(node, resolution); + if (resolution.kind === 'unresolved') { + diagnostics.push({ + code: PSL_UNRESOLVED_REFERENCE, + message: `Cannot find type "${resolution.name}"`, + ...diagnosticSource(sources, node).at(), + }); + } + } + } + + return { binder: new PslBinder(declarations, references), diagnostics }; +} + +function* owners(symbolTable: SymbolTable): Iterable { + const { topLevel } = symbolTable; + for (const symbol of Object.values(topLevel.models)) yield { scope: undefined, symbol }; + for (const symbol of Object.values(topLevel.compositeTypes)) yield { scope: undefined, symbol }; + for (const scope of Object.values(topLevel.namespaces)) { + for (const symbol of Object.values(scope.models)) yield { scope, symbol }; + for (const symbol of Object.values(scope.compositeTypes)) yield { scope, symbol }; + } +} + +function resolveTypeReference( + field: FieldSymbol, + scope: NamespaceSymbol | undefined, + topLevel: TopLevelScope, + universe: UniverseScope, +): Resolution | undefined { + if (field.malformedType === true) return undefined; + if (field.typeContractSpaceId !== undefined) return { kind: 'crossSpace' }; + const name = field.typeName; + if (name === '') return undefined; + + const namespaceId = field.typeNamespaceId; + if (namespaceId !== undefined) { + const namespace = own(topLevel.namespaces, namespaceId); + const declared = namespace === undefined ? undefined : inNamespace(namespace, name); + if (declared !== undefined) return declared; + const universeSymbol = universe.lookup([namespaceId, name]); + if (universeSymbol !== undefined) return { kind: 'universe', symbol: universeSymbol }; + return { kind: 'unresolved', name: `${namespaceId}.${name}` }; + } + + const local = scope === undefined ? undefined : inNamespace(scope, name); + if (local !== undefined) return local; + const global = inTopLevel(topLevel, name); + if (global !== undefined) return global; + const universeSymbol = universe.lookup([name]); + if (universeSymbol !== undefined) return { kind: 'universe', symbol: universeSymbol }; + return { kind: 'unresolved', name }; +} + +function inNamespace(namespace: NamespaceSymbol, name: string): Resolution | undefined { + const model = own(namespace.models, name); + if (model !== undefined) return { kind: 'model', symbol: model }; + const compositeType = own(namespace.compositeTypes, name); + if (compositeType !== undefined) return { kind: 'compositeType', symbol: compositeType }; + const block = own(namespace.blocks, name); + if (block !== undefined) return { kind: 'block', symbol: block }; + return undefined; +} + +function inTopLevel(topLevel: TopLevelScope, name: string): Resolution | undefined { + const model = own(topLevel.models, name); + if (model !== undefined) return { kind: 'model', symbol: model }; + const compositeType = own(topLevel.compositeTypes, name); + if (compositeType !== undefined) return { kind: 'compositeType', symbol: compositeType }; + const namedType = own(topLevel.namedTypes, name); + if (namedType !== undefined) return { kind: 'namedType', symbol: namedType }; + const block = own(topLevel.blocks, name); + if (block !== undefined) return { kind: 'block', symbol: block }; + return undefined; +} + +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/universe-scope.ts b/packages/1-framework/2-authoring/psl-parser/src/universe-scope.ts new file mode 100644 index 000000000000..edb7e71c3fb8 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/universe-scope.ts @@ -0,0 +1,51 @@ +import type { + AuthoringTypeConstructorDescriptor, + AuthoringTypeNamespace, +} from '@internal/framework-components/authoring'; +import { isAuthoringTypeConstructorDescriptor } from '@internal/framework-components/authoring'; + +export interface UniverseSymbol { + readonly kind: 'universe'; + readonly name: string; + readonly path: readonly string[]; + readonly descriptor: AuthoringTypeConstructorDescriptor; +} + +export interface UniverseScope { + lookup(path: readonly string[]): UniverseSymbol | undefined; +} + +const scopes = new WeakMap(); + +export function universeScope(typeConstructors: AuthoringTypeNamespace): UniverseScope { + const existing = scopes.get(typeConstructors); + if (existing !== undefined) return existing; + const created = buildScope(typeConstructors); + scopes.set(typeConstructors, created); + return created; +} + +function buildScope(typeConstructors: AuthoringTypeNamespace): UniverseScope { + const symbols = new Map(); + collect(typeConstructors, [], symbols); + return { + lookup(path) { + return symbols.get(path.join('.')); + }, + }; +} + +function collect( + namespace: AuthoringTypeNamespace, + prefix: readonly string[], + symbols: Map, +): void { + for (const [name, value] of Object.entries(namespace)) { + const path = [...prefix, name]; + if (isAuthoringTypeConstructorDescriptor(value)) { + symbols.set(path.join('.'), { kind: 'universe', name, path, descriptor: value }); + } else { + collect(value, path, symbols); + } + } +} 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..78af9bc95310 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/binder.test.ts @@ -0,0 +1,382 @@ +import type { + AuthoringPslBlockDescriptorNamespace, + AuthoringTypeConstructorDescriptor, + AuthoringTypeNamespace, +} from '@internal/framework-components/authoring'; +import { describe, expect, it } from 'vitest'; +import { createBinder, typeReferenceNode } from '../src/binder'; +import { parse } from '../src/parse'; +import { PslSources } from '../src/source-file'; +import { buildSymbolTable, type FieldSymbol, type SymbolTable } from '../src/symbol-table'; +import { universeScope } from '../src/universe-scope'; + +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') }, +}; + +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 }), + }; +} + +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'], + }); + 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 universe 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: 'universe', + symbol: { kind: 'universe', name: 'String', path: ['String'] }, + }); + }); + + it('lets a user declaration shadow a universe 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'], + }); + }); + + it('resolves a qualified reference into a universe 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: 'universe', + symbol: { name: 'Vector', path: ['pgvector', 'Vector'] }, + }); + }); + + 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"', + 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'], + }); + }); +}); + +describe('universe scope', () => { + it('returns the same scope object for the same registry', () => { + expect(universeScope(TYPE_CONSTRUCTORS)).toBe(universeScope(TYPE_CONSTRUCTORS)); + expect(universeScope({ ...TYPE_CONSTRUCTORS })).not.toBe(universeScope(TYPE_CONSTRUCTORS)); + }); + + it('shares universe 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('universe'); + expect(firstSymbol).not.toBe(secondSymbol); + if (firstSymbol?.kind === 'universe' && secondSymbol?.kind === 'universe') { + expect(firstSymbol.symbol).toBe(secondSymbol.symbol); + } + }); +}); From 62368613137a14021c1a267c3883a213c46fcafb Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 18 Sep 2026 15:18:32 +0000 Subject: [PATCH 05/66] refactor(psl-parser): pin the unresolved-reference code to the contributed-code shape Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- packages/1-framework/2-authoring/psl-parser/src/binder.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/1-framework/2-authoring/psl-parser/src/binder.ts b/packages/1-framework/2-authoring/psl-parser/src/binder.ts index 64b9de837d2e..5fe11551caa5 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/binder.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/binder.ts @@ -1,4 +1,5 @@ import type { AuthoringTypeNamespace } from '@internal/framework-components/authoring'; +import type { ContributedPslDiagnosticCode } from '@internal/framework-components/psl-ast'; import { diagnosticSource } from './diagnostic'; import type { ParseDiagnostic } from './parse'; import type { PslSources } from './source-file'; @@ -15,7 +16,8 @@ import type { import type { SyntaxNode } from './syntax/red'; import { type UniverseScope, type UniverseSymbol, universeScope } from './universe-scope'; -export const PSL_UNRESOLVED_REFERENCE = 'PSL_UNRESOLVED_REFERENCE'; +export const PSL_UNRESOLVED_REFERENCE = + 'PSL_UNRESOLVED_REFERENCE' satisfies ContributedPslDiagnosticCode; export type PslSymbol = | ModelSymbol From c2947fb54e064a6c524547b6fee48c139a515aa7 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 18 Sep 2026 15:31:41 +0000 Subject: [PATCH 06/66] feat(psl-parser): resolve attribute names and reference arguments in the binder Phase 2 runs after phase 1 in the same eager pass and reads its results. Each model, composite type, and field attribute is matched against the injected attribute-spec registry: a hit records the spec against the attribute's name node, a miss reports PSL_UNRESOLVED_ATTRIBUTE. Arguments the spec declares as references are then resolved by kind - fieldRef against the declaring owner's fields, referencedFieldRef against the phase-1 type target via a map read (cross-space field types yield the explicit kind with no diagnostic), and entityRef through the namespace-then-top-level chain, where universe symbols and blocks are not entities. The registry is a structural view of the spec's argument shapes, so the binder reads the combinator kinds without depending on any target package. Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- .../2-authoring/psl-parser/src/binder.ts | 209 +++++++- .../psl-parser/test/binder.test.ts | 467 +++++++++++++++++- 2 files changed, 672 insertions(+), 4 deletions(-) diff --git a/packages/1-framework/2-authoring/psl-parser/src/binder.ts b/packages/1-framework/2-authoring/psl-parser/src/binder.ts index 5fe11551caa5..ce2cb071adc4 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/binder.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/binder.ts @@ -2,6 +2,7 @@ import type { AuthoringTypeNamespace } from '@internal/framework-components/auth import type { ContributedPslDiagnosticCode } from '@internal/framework-components/psl-ast'; import { diagnosticSource } from './diagnostic'; import type { ParseDiagnostic } from './parse'; +import type { ResolvedAttribute } from './resolve'; import type { PslSources } from './source-file'; import type { BlockSymbol, @@ -13,12 +14,18 @@ import type { SymbolTable, TopLevelScope, } 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'; import { type UniverseScope, type UniverseSymbol, universeScope } from './universe-scope'; export const PSL_UNRESOLVED_REFERENCE = 'PSL_UNRESOLVED_REFERENCE' satisfies ContributedPslDiagnosticCode; +export const PSL_UNRESOLVED_ATTRIBUTE = + 'PSL_UNRESOLVED_ATTRIBUTE' satisfies ContributedPslDiagnosticCode; + export type PslSymbol = | ModelSymbol | CompositeTypeSymbol @@ -33,9 +40,35 @@ export type Resolution = | { readonly kind: 'namedType'; readonly symbol: NamedTypeSymbol } | { readonly kind: 'block'; readonly symbol: BlockSymbol } | { readonly kind: 'universe'; readonly symbol: UniverseSymbol } + | { readonly kind: 'field'; readonly symbol: FieldSymbol } + | { readonly kind: 'attributeSpec'; readonly spec: AttributeSpecView } | { readonly kind: 'crossSpace' } | { readonly kind: 'unresolved'; readonly name: string }; +export interface AttributeArgTypeView { + readonly kind: string; + readonly of?: AttributeArgTypeView; + readonly alternatives?: readonly AttributeArgTypeView[]; +} + +export interface AttributeParamView { + readonly type: AttributeArgTypeView; +} + +export interface AttributePositionalParamView extends AttributeParamView { + readonly key: string; +} + +export interface AttributeSpecView { + readonly positional: readonly AttributePositionalParamView[]; + readonly named: Readonly>; +} + +export interface AttributeSpecRegistry { + model(name: string): AttributeSpecView | undefined; + field(name: string): AttributeSpecView | undefined; +} + export interface Binder { declaredSymbol(node: SyntaxNode): PslSymbol | undefined; symbolForNode(node: SyntaxNode): Resolution | undefined; @@ -45,6 +78,7 @@ export interface CreateBinderOptions { readonly sources: PslSources; readonly symbolTable: SymbolTable; readonly typeConstructors: AuthoringTypeNamespace; + readonly attributeSpecs: AttributeSpecRegistry; } export interface BinderResult { @@ -83,7 +117,7 @@ interface Owner { } export function createBinder(options: CreateBinderOptions): BinderResult { - const { sources, symbolTable, typeConstructors } = options; + const { sources, symbolTable, typeConstructors, attributeSpecs } = options; const universe = universeScope(typeConstructors); const declarations = new WeakMap(); const references = new WeakMap(); @@ -123,9 +157,182 @@ export function createBinder(options: CreateBinderOptions): BinderResult { } } + for (const { scope, symbol } of owners(symbolTable)) { + const context = { + owner: symbol, + scope, + references, + diagnostics, + symbolTable, + sources, + }; + bindAttributes(symbol, symbol.attributes, (name) => attributeSpecs.model(name), { + ...context, + field: undefined, + }); + for (const field of Object.values(symbol.fields)) { + bindAttributes(field, field.attributes, (name) => attributeSpecs.field(name), { + ...context, + field, + }); + } + } + return { binder: new PslBinder(declarations, references), diagnostics }; } +interface BindContext { + readonly owner: ModelSymbol | CompositeTypeSymbol; + readonly scope: NamespaceSymbol | undefined; + readonly field: FieldSymbol | undefined; + readonly references: WeakMap; + readonly diagnostics: ParseDiagnostic[]; + readonly symbolTable: SymbolTable; + readonly sources: PslSources; +} + +function bindAttributes( + holder: ModelSymbol | CompositeTypeSymbol | FieldSymbol, + attributes: readonly ResolvedAttribute[], + lookupSpec: (name: string) => AttributeSpecView | undefined, + ctx: BindContext, +): void { + const marker = holder.kind === 'field' ? '@' : '@@'; + const declared: Iterable = holder.node.attributes(); + const nodes = Array.from(declared); + attributes.forEach((attribute, index) => { + const spec = lookupSpec(attribute.name); + const nameNode = nodes[index]?.name()?.syntax; + if (nameNode !== undefined) { + if (spec === undefined) { + ctx.references.set(nameNode, { kind: 'unresolved', name: attribute.name }); + ctx.diagnostics.push({ + code: PSL_UNRESOLVED_ATTRIBUTE, + message: `Cannot find attribute "${marker}${attribute.name}"`, + ...diagnosticSource(ctx.sources, nameNode).at(), + }); + } else { + ctx.references.set(nameNode, { kind: 'attributeSpec', spec }); + } + } + if (spec !== undefined) bindArguments(attribute, spec, ctx); + }); +} + +function bindArguments(attribute: ResolvedAttribute, spec: AttributeSpecView, ctx: BindContext) { + let positional = 0; + for (const arg of attribute.args) { + const param = + arg.name === undefined ? spec.positional[positional++] : own(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 = own(ctx.owner.fields, name); + if (field !== undefined) return { kind: 'field', symbol: field }; + report(`Cannot find field "${name}" on "${ctx.owner.name}"`, node, ctx); + return { kind: 'unresolved', name }; +} + +function resolveReferencedField(name: string, node: SyntaxNode, ctx: BindContext): Resolution { + const declaring = ctx.field; + if (declaring === undefined) return resolveOwnerField(name, node, ctx); + 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 === undefined ? undefined : own(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, + ); + 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 entity = entityNamed(name, ctx); + if (entity !== undefined) return entity; + report(`Cannot find entity "${name}"`, node, ctx); + return { kind: 'unresolved', name }; +} + +function entityNamed(name: string, ctx: BindContext): Resolution | undefined { + const { scope, symbolTable } = ctx; + if (scope !== undefined) { + const model = own(scope.models, name); + if (model !== undefined) return { kind: 'model', symbol: model }; + const compositeType = own(scope.compositeTypes, name); + if (compositeType !== undefined) return { kind: 'compositeType', symbol: compositeType }; + } + const model = own(symbolTable.topLevel.models, name); + if (model !== undefined) return { kind: 'model', symbol: model }; + const compositeType = own(symbolTable.topLevel.compositeTypes, name); + if (compositeType !== undefined) return { kind: 'compositeType', symbol: compositeType }; + return undefined; +} + +function report(message: string, node: SyntaxNode, ctx: BindContext): void { + ctx.diagnostics.push({ + code: PSL_UNRESOLVED_REFERENCE, + message, + ...diagnosticSource(ctx.sources, node).at(), + }); +} + +type ReferenceKind = 'fieldRef' | 'referencedFieldRef' | 'entityRef'; + +function referenceKind(type: AttributeArgTypeView): ReferenceKind | undefined { + if (type.kind === 'fieldRef' || type.kind === 'referencedFieldRef' || type.kind === 'entityRef') { + return type.kind; + } + if (type.of !== undefined) return referenceKind(type.of); + 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); +} + function* owners(symbolTable: SymbolTable): Iterable { const { topLevel } = symbolTable; for (const symbol of Object.values(topLevel.models)) yield { scope: undefined, symbol }; 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 index 78af9bc95310..ce679fe5c01b 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/binder.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/binder.test.ts @@ -4,10 +4,23 @@ import type { AuthoringTypeNamespace, } from '@internal/framework-components/authoring'; import { describe, expect, it } from 'vitest'; -import { createBinder, typeReferenceNode } from '../src/binder'; +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 { fieldAttribute } from '../src/attribute-spec/field-attribute'; +import { modelAttribute } from '../src/attribute-spec/model-attribute'; +import { type AttributeSpecRegistry, createBinder, typeReferenceNode } from '../src/binder'; import { parse } from '../src/parse'; import { PslSources } from '../src/source-file'; -import { buildSymbolTable, type FieldSymbol, type SymbolTable } from '../src/symbol-table'; +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'; import { universeScope } from '../src/universe-scope'; const ENUM_DESCRIPTORS: AuthoringPslBlockDescriptorNamespace = { @@ -32,6 +45,64 @@ const TYPE_CONSTRUCTORS: AuthoringTypeNamespace = { pgvector: { Vector: scalar('vector') }, }; +const fieldRefList = { kind: 'list', of: { kind: 'fieldRef' } } as const; +const referencedFieldRefList = { kind: 'list', of: { kind: 'referencedFieldRef' } } as const; + +const MODEL_SPECS: Readonly>> = { + id: { positional: [{ key: 'fields', type: fieldRefList }], named: {} }, + index: { positional: [{ key: 'fields', type: fieldRefList }], named: {} }, + unique: { positional: [{ key: 'fields', type: fieldRefList }], named: {} }, + base: { positional: [{ key: 'model', type: { kind: 'entityRef' } }], named: {} }, + map: { positional: [{ key: 'name', type: { kind: 'str' } }], named: {} }, +}; + +const FIELD_SPECS: Readonly>> = { + id: { positional: [], named: {} }, + relation: { + positional: [], + named: { + fields: { type: fieldRefList }, + references: { type: referencedFieldRefList }, + name: { type: { kind: 'str' } }, + }, + }, +}; + +const ATTRIBUTE_SPECS: AttributeSpecRegistry = { + model: (name) => MODEL_SPECS[name], + field: (name) => FIELD_SPECS[name], +}; + +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); @@ -52,7 +123,12 @@ function bind(...texts: string[]) { const { sources, symbolTable } = build(...texts); return { symbolTable, - ...createBinder({ sources, symbolTable, typeConstructors: TYPE_CONSTRUCTORS }), + ...createBinder({ + sources, + symbolTable, + typeConstructors: TYPE_CONSTRUCTORS, + attributeSpecs: ATTRIBUTE_SPECS, + }), }; } @@ -380,3 +456,388 @@ describe('universe scope', () => { } }); }); + +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 its spec', () => { + const { symbolTable, binder, diagnostics } = bind(RELATION_SCHEMA); + const post = symbolTable.topLevel.models['Post']!; + + expect(diagnostics).toEqual([]); + expect(binder.symbolForNode(attributeNameNode(post.fields['author']!, 'relation'))).toEqual({ + kind: 'attributeSpec', + spec: FIELD_SPECS['relation'], + }); + expect(binder.symbolForNode(attributeNameNode(post.fields['id']!, 'id'))).toEqual({ + kind: 'attributeSpec', + spec: FIELD_SPECS['id'], + }); + }); + + it('reports an unknown attribute name at model and field level', () => { + 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'))).toEqual({ + kind: 'unresolved', + name: 'bogus', + }); + expect(binder.symbolForNode(attributeNameNode(user, 'nope'))).toEqual({ + kind: 'unresolved', + name: 'nope', + }); + expect(diagnostics.map(({ code, message }) => [code, message])).toEqual([ + ['PSL_UNRESOLVED_ATTRIBUTE', 'Cannot find attribute "@@nope"'], + ['PSL_UNRESOLVED_ATTRIBUTE', 'Cannot find attribute "@bogus"'], + ]); + }); + + 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 — 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'], + }); + }); + + it('reports a missing entity and refuses a universe 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')).toEqual({ kind: 'unresolved', name: 'String' }); + expect(resolvedBase('Enumish')).toEqual({ kind: 'unresolved', name: 'Role' }); + expect(diagnostics.map(({ message }) => message)).toEqual([ + 'Cannot find entity "Ghost"', + 'Cannot find entity "String"', + 'Cannot find entity "Role"', + ]); + }); +}); + +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)', + ' @@mystery', + '}', + ].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_ATTRIBUTE', 'Cannot find attribute "@@mystery"', '0.psl', 10], + ['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: 'universe', + }); + }); +}); + +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(), documentation: 'fixture' }], + }); + const relation = fieldAttribute('relation', { + documentation: 'fixture', + named: { + fields: { type: list(fieldRef()), documentation: 'fixture' }, + references: { type: list(referencedFieldRef()), documentation: 'fixture' }, + }, + }); + const registry: AttributeSpecRegistry = { + model: (name) => (name === 'index' ? assembled : name === 'base' ? base : undefined), + field: (name) => (name === 'relation' ? relation : undefined), + }; + + 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, + }); + 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' }); + } + }); +}); From 4cce74bf3948e22d6773a0dcde0f66052494a05e Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 18 Sep 2026 15:38:38 +0000 Subject: [PATCH 07/66] fix(psl-parser): stay silent for a referenced-field slot with no declaring field A referencedFieldRef argument outside a field attribute has no field whose type could name the target, so the binder now records nothing and reports nothing rather than falling back to the declaring owner's own fields. Pins the two silent branches: this one, and a non-identifier expression in a reference slot. Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- .../2-authoring/psl-parser/src/binder.ts | 8 +++-- .../psl-parser/test/binder.test.ts | 31 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/1-framework/2-authoring/psl-parser/src/binder.ts b/packages/1-framework/2-authoring/psl-parser/src/binder.ts index ce2cb071adc4..c48762f23d8d 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/binder.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/binder.ts @@ -258,9 +258,13 @@ function resolveOwnerField(name: string, node: SyntaxNode, ctx: BindContext): Re return { kind: 'unresolved', name }; } -function resolveReferencedField(name: string, node: SyntaxNode, ctx: BindContext): Resolution { +function resolveReferencedField( + name: string, + node: SyntaxNode, + ctx: BindContext, +): Resolution | undefined { const declaring = ctx.field; - if (declaring === undefined) return resolveOwnerField(name, node, ctx); + if (declaring === undefined) return undefined; if (declaring.typeContractSpaceId !== undefined) return { kind: 'crossSpace' }; const typeNode = typeReferenceNode(declaring); const target = typeNode === undefined ? undefined : ctx.references.get(typeNode); 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 index ce679fe5c01b..2792e60af257 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/binder.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/binder.test.ts @@ -54,6 +54,7 @@ const MODEL_SPECS: Readonly>> = { @@ -841,3 +842,33 @@ describe('referencedFieldRef on a cross-space list', () => { } }); }); + +describe('createBinder — reference slots the binder stays silent about', () => { + it('records nothing for a referencedFieldRef outside a field attribute', () => { + const { symbolTable, binder, diagnostics } = bind( + 'model User {\n id Int\n @@borrowed([id])\n}', + ); + const user = symbolTable.topLevel.models['User']!; + const [node] = attributeNodes(user, 'borrowed'); + + expect(node).toBeDefined(); + expect(node === undefined ? undefined : binder.symbolForNode(node)).toBeUndefined(); + expect(diagnostics).toEqual([]); + }); + + 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([]); + }); +}); From e0f927294060ada97cdbe0c673e11b47c32b923f Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 18 Sep 2026 15:47:20 +0000 Subject: [PATCH 08/66] feat(psl-parser): export the binder and build attribute contexts from it Exports createBinder, the Binder interface, the resolution union, the two diagnostic codes, and the universe types from the package entry, so consumers can resolve names without reaching into src/. Adds the attribute-context helper the conversion slices need: modelAttributeContext and fieldAttributeContext build the ADR 249 parse-time contexts from a binder, with resolveReferencedModel served by one map read narrowed to a model. The attribute-spec machinery is untouched - the helper adapts to it. The README now documents the binder: the scope chain, the resolution kinds, who owns resolution diagnostics, the snapshot lifetime, how universe-scope sharing depends on registry object identity, and the red-node identity guarantee that makes the side tables sound. Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- .../2-authoring/psl-parser/README.md | 69 +++++ .../psl-parser/src/binder-context.ts | 34 +++ .../psl-parser/src/exports/index.ts | 28 ++ .../psl-parser/test/binder-context.test.ts | 242 ++++++++++++++++++ 4 files changed, 373 insertions(+) create mode 100644 packages/1-framework/2-authoring/psl-parser/src/binder-context.ts create mode 100644 packages/1-framework/2-authoring/psl-parser/test/binder-context.test.ts diff --git a/packages/1-framework/2-authoring/psl-parser/README.md b/packages/1-framework/2-authoring/psl-parser/README.md index 9c05d230c892..5c8f89b04093 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. - `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,65 @@ 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 **universe scope** — the scalar and type-constructor names built from the injected `typeConstructors` registry. + +**Sibling namespaces are never consulted.** A user declaration shadowing a universe symbol (a `model Uuid` over a built-in `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 | +| `universe` | a scalar or type constructor 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. + +### 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 universe 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 universe 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 +126,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/binder-context.ts b/packages/1-framework/2-authoring/psl-parser/src/binder-context.ts new file mode 100644 index 000000000000..d6bed5c464cc --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/binder-context.ts @@ -0,0 +1,34 @@ +import type { FieldAttributeCtx, ModelAttributeCtx } from './attribute-spec/types'; +import { type Binder, typeReferenceNode } from './binder'; +import type { PslSources } from './source-file'; +import type { FieldSymbol, ModelSymbol } from './symbol-table'; + +export function referencedModel(binder: Binder, field: FieldSymbol): ModelSymbol | undefined { + const node = typeReferenceNode(field); + if (node === undefined) return undefined; + const resolution = binder.symbolForNode(node); + return resolution?.kind === 'model' ? resolution.symbol : undefined; +} + +export interface ModelAttributeContextInput { + readonly binder: Binder; + readonly sources: PslSources; + readonly model: ModelSymbol; +} + +export interface FieldAttributeContextInput extends ModelAttributeContextInput { + readonly field: FieldSymbol; +} + +export function modelAttributeContext(input: ModelAttributeContextInput): ModelAttributeCtx { + return { sources: input.sources, selfModel: input.model }; +} + +export function fieldAttributeContext(input: FieldAttributeContextInput): FieldAttributeCtx { + return { + sources: input.sources, + selfModel: input.model, + field: input.field, + resolveReferencedModel: () => referencedModel(input.binder, input.field), + }; +} 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 84526c4e5b45..3a51da54aa36 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 @@ -91,6 +91,33 @@ export type { TaggedLiteralArgType, TypedFuncCall, } from '../attribute-spec/types'; +export type { + AttributeArgTypeView, + AttributeParamView, + AttributePositionalParamView, + AttributeSpecRegistry, + AttributeSpecView, + Binder, + BinderResult, + CreateBinderOptions, + PslSymbol, + Resolution, +} from '../binder'; +export { + createBinder, + PSL_UNRESOLVED_ATTRIBUTE, + PSL_UNRESOLVED_REFERENCE, + typeReferenceNode, +} from '../binder'; +export type { + FieldAttributeContextInput, + ModelAttributeContextInput, +} from '../binder-context'; +export { + fieldAttributeContext, + modelAttributeContext, + referencedModel, +} from '../binder-context'; export type { DiagnosticSource, PslDiagnostic, PslDiagnosticCollector } from '../diagnostic'; export { createPslDiagnosticCollector, @@ -123,3 +150,4 @@ export type { TopLevelScope, } from '../symbol-table'; export { buildSymbolTable } from '../symbol-table'; +export type { UniverseScope, UniverseSymbol } from '../universe-scope'; diff --git a/packages/1-framework/2-authoring/psl-parser/test/binder-context.test.ts b/packages/1-framework/2-authoring/psl-parser/test/binder-context.test.ts new file mode 100644 index 000000000000..53c40b8d7dc9 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/binder-context.test.ts @@ -0,0 +1,242 @@ +import type { + AuthoringPslBlockDescriptorNamespace, + AuthoringTypeNamespace, +} from '@internal/framework-components/authoring'; +import { describe, expect, it } from 'vitest'; +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 { optional } from '../src/attribute-spec/optional'; +import type { AttributeSpecRegistry } from '../src/binder'; +import { createBinder } from '../src/binder'; +import { + fieldAttributeContext, + modelAttributeContext, + referencedModel, +} from '../src/binder-context'; +import { parse } from '../src/parse'; +import { PslSources } from '../src/source-file'; +import { buildSymbolTable, type ModelSymbol } from '../src/symbol-table'; +import { ArrayLiteralAst } from '../src/syntax/ast/expressions'; + +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', + positional: [{ key: 'name', type: optional(str()), documentation: 'fixture' }], + named: { + fields: { + type: optional(list(fieldRef(), { allowEmpty: false, unique: true })), + documentation: 'fixture', + }, + references: { + type: optional(list(referencedFieldRef(), { allowEmpty: false, unique: true })), + documentation: 'fixture', + }, + }, +}); + +const ATTRIBUTE_SPECS: AttributeSpecRegistry = { + model: () => undefined, + field: (name) => (name === 'relation' ? relationSpec : undefined), +}; + +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, + }); + return { + sources, + symbolTable, + ...createBinder({ + sources, + symbolTable, + typeConstructors: TYPE_CONSTRUCTORS, + attributeSpecs: ATTRIBUTE_SPECS, + }), + }; +} + +const RELATION_SCHEMA = [ + 'model User {', + ' id Int', + '}', + 'model Post {', + ' authorId Int', + ' author User @relation(fields: [authorId], references: [id])', + '}', +].join('\n'); + +describe('referencedModel', () => { + it('returns the model a relation field points at', () => { + const { symbolTable, binder } = bind(RELATION_SCHEMA); + const post = symbolTable.topLevel.models['Post']!; + + expect(referencedModel(binder, post.fields['author']!)).toBe( + symbolTable.topLevel.models['User'], + ); + }); + + it('returns undefined for a cross-space field type', () => { + const { symbolTable, binder } = bind('model Cart {\n user auth:User\n}'); + const cart = symbolTable.topLevel.models['Cart']!; + + expect(referencedModel(binder, cart.fields['user']!)).toBeUndefined(); + }); + + it('returns undefined for an unresolved field type', () => { + const { symbolTable, binder } = bind('model Cart {\n owner Ghost\n}'); + const cart = symbolTable.topLevel.models['Cart']!; + + expect(referencedModel(binder, cart.fields['owner']!)).toBeUndefined(); + }); + + it('returns undefined when the type resolves to something other than a model', () => { + const { symbolTable, binder } = bind( + [ + 'enum Role {', + ' Admin', + '}', + 'type Address {', + ' street String', + '}', + 'model User {', + ' role Role', + ' address Address', + ' name String', + '}', + ].join('\n'), + ); + const user = symbolTable.topLevel.models['User']!; + + expect(referencedModel(binder, user.fields['role']!)).toBeUndefined(); + expect(referencedModel(binder, user.fields['address']!)).toBeUndefined(); + expect(referencedModel(binder, user.fields['name']!)).toBeUndefined(); + }); +}); + +describe('fieldAttributeContext', () => { + it('serves resolveReferencedModel as a binder map read', () => { + const { sources, symbolTable, binder } = bind(RELATION_SCHEMA); + const post = symbolTable.topLevel.models['Post']!; + const ctx = fieldAttributeContext({ + binder, + sources, + model: post, + field: post.fields['author']!, + }); + + expect(ctx.sources).toBe(sources); + expect(ctx.selfModel).toBe(post); + expect(ctx.field).toBe(post.fields['author']); + expect(ctx.resolveReferencedModel()).toBe(symbolTable.topLevel.models['User']); + expect(ctx.resolveReferencedModel()).toBe(ctx.resolveReferencedModel()); + }); + + it('resolves to undefined without throwing for a cross-space relation', () => { + const { sources, symbolTable, binder } = bind( + 'model Cart {\n userId Int\n user auth:User @relation(fields: [userId], references: [id])\n}', + ); + const cart = symbolTable.topLevel.models['Cart']!; + const ctx = fieldAttributeContext({ + binder, + sources, + model: cart, + field: cart.fields['user']!, + }); + + expect(ctx.resolveReferencedModel()).toBeUndefined(); + }); +}); + +describe('modelAttributeContext', () => { + it('carries the sources and the declaring model', () => { + const { sources, symbolTable, binder } = bind(RELATION_SCHEMA); + const post = symbolTable.topLevel.models['Post']!; + const ctx = modelAttributeContext({ binder, sources, model: post }); + + expect(ctx.sources).toBe(sources); + expect(ctx.selfModel).toBe(post); + }); +}); + +describe('reference parameters under optional(list(...))', () => { + it('resolves both sides of a relation spec shaped like a target', () => { + const { symbolTable, binder, diagnostics } = bind(RELATION_SCHEMA); + const post = symbolTable.topLevel.models['Post']!; + const user = symbolTable.topLevel.models['User']!; + const argumentNode = (argName: string) => { + for (const attribute of post.fields['author']!.node.attributes()) { + for (const arg of attribute.argList()?.args() ?? []) { + if (arg.name()?.name() !== argName) continue; + const value = arg.value(); + const array = value === undefined ? undefined : ArrayLiteralAst.cast(value.syntax); + return Array.from(array?.elements() ?? [], (element) => element.syntax)[0]; + } + } + return undefined; + }; + + expect(diagnostics).toEqual([]); + const fields = argumentNode('fields'); + const references = argumentNode('references'); + expect(fields).toBeDefined(); + expect(references).toBeDefined(); + expect(fields === undefined ? undefined : binder.symbolForNode(fields)).toEqual({ + kind: 'field', + symbol: post.fields['authorId'], + }); + expect(references === undefined ? undefined : binder.symbolForNode(references)).toEqual({ + kind: 'field', + symbol: user.fields['id'], + }); + }); + + it('reports an unknown name through the optional wrapper', () => { + const { diagnostics } = bind( + [ + 'model User {', + ' id Int', + '}', + 'model Post {', + ' authorId Int', + ' author User @relation(fields: [nope], references: [alsoNope])', + '}', + ].join('\n'), + ); + + expect(diagnostics.map(({ message }) => message)).toEqual([ + 'Cannot find field "nope" on "Post"', + 'Cannot find field "alsoNope" on the type of "Post.author"', + ]); + }); +}); + +describe('typed model surface', () => { + it('narrows a binder resolution to a model symbol', () => { + const { symbolTable, binder } = bind(RELATION_SCHEMA); + const post = symbolTable.topLevel.models['Post']!; + const resolved: ModelSymbol | undefined = referencedModel(binder, post.fields['author']!); + + expect(resolved?.name).toBe('User'); + }); +}); From 40c35a0125324d202bb07e27f9220e5920a5de8d Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 18 Sep 2026 15:52:43 +0000 Subject: [PATCH 09/66] chore(drive): binder-core slice artifacts through D4 Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- projects/symbol-table-resolve/plan.md | 7 +++ .../slices/binder-core/briefs/d1-r1.md | 36 +++++++++++++++ .../slices/binder-core/briefs/d1.ids.json | 8 ++++ .../slices/binder-core/briefs/d2-r1.md | 36 +++++++++++++++ .../slices/binder-core/briefs/d2.ids.json | 8 ++++ .../slices/binder-core/briefs/d3-r1.md | 44 +++++++++++++++++++ .../slices/binder-core/briefs/d3.ids.json | 8 ++++ .../slices/binder-core/briefs/d4-r1.md | 40 +++++++++++++++++ .../slices/binder-core/briefs/d4.ids.json | 6 +++ .../slices/binder-core/plan.md | 2 +- projects/symbol-table-resolve/spec.md | 4 +- projects/symbol-table-resolve/trace.jsonl | 30 +++++++++++++ 12 files changed, 226 insertions(+), 3 deletions(-) create mode 100644 projects/symbol-table-resolve/slices/binder-core/briefs/d1-r1.md create mode 100644 projects/symbol-table-resolve/slices/binder-core/briefs/d1.ids.json create mode 100644 projects/symbol-table-resolve/slices/binder-core/briefs/d2-r1.md create mode 100644 projects/symbol-table-resolve/slices/binder-core/briefs/d2.ids.json create mode 100644 projects/symbol-table-resolve/slices/binder-core/briefs/d3-r1.md create mode 100644 projects/symbol-table-resolve/slices/binder-core/briefs/d3.ids.json create mode 100644 projects/symbol-table-resolve/slices/binder-core/briefs/d4-r1.md create mode 100644 projects/symbol-table-resolve/slices/binder-core/briefs/d4.ids.json diff --git a/projects/symbol-table-resolve/plan.md b/projects/symbol-table-resolve/plan.md index 02a61f0d91b8..44db064285d3 100644 --- a/projects/symbol-table-resolve/plan.md +++ b/projects/symbol-table-resolve/plan.md @@ -42,6 +42,13 @@ Four slices: one foundation slice delivering the eager binder in `psl-parser` (w ## Dependencies (external) - [ ] PR #30335 (`multifiile-psl`) merges — open at planning time; operator judges it will not stall. All slices stack on it; no independent landing path (spec transitional-shape constraint). +- [ ] Base-branch escapee, surfaced during D1: `integration-tests` typecheck is broken at the branch point itself — `test/integration/test/authoring/lsp-emit-parity.integration.test.ts:8` imports `createDocumentStore`, removed by `multifiile-psl`'s own commit `a54c338980`. Belongs to PR #30335 to fix; our stack inherits the red CI until it does. Not fixed here (out of scope; operator may wish to flag it on the PR themselves). + +## 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. ## Sequencing rationale 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/plan.md b/projects/symbol-table-resolve/slices/binder-core/plan.md index 82c4363013f0..c726ba4d68a8 100644 --- a/projects/symbol-table-resolve/slices/binder-core/plan.md +++ b/projects/symbol-table-resolve/slices/binder-core/plan.md @@ -25,7 +25,7 @@ Slice spec: `projects/symbol-table-resolve/slices/binder-core/spec.md`. Branch: ### 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 (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/`. +- **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. diff --git a/projects/symbol-table-resolve/spec.md b/projects/symbol-table-resolve/spec.md index 7ba1a8ff50cd..6ef5dcb641f3 100644 --- a/projects/symbol-table-resolve/spec.md +++ b/projects/symbol-table-resolve/spec.md @@ -16,7 +16,7 @@ Today, resolving a field's type name is answered four different ways: After this project, one service answers, lazily, with memoized results: ```ts -const { binder, diagnostics } = createBinder({ documents, sources, symbolTable, typeConstructors, attributeSpecs }); +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 @@ -30,7 +30,7 @@ Resolution follows a single decreed rule — declaring namespace → top level ### The eager pass (normative pseudo-code) ```ts -function createBinder({ documents, sources, symbolTable, typeConstructors, attributeSpecs }) { +function createBinder({ sources, symbolTable, typeConstructors, attributeSpecs }) { const universe = universeScope(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 diff --git a/projects/symbol-table-resolve/trace.jsonl b/projects/symbol-table-resolve/trace.jsonl index 1251d83d1e6a..f6d2ea79aa0d 100644 --- a/projects/symbol-table-resolve/trace.jsonl +++ b/projects/symbol-table-resolve/trace.jsonl @@ -7,3 +7,33 @@ {"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} From 50d793f07c11da0dd38719e807d382ed49663fa2 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 18 Sep 2026 16:00:23 +0000 Subject: [PATCH 10/66] chore(drive): record base-merge rebase and gate updates Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- projects/symbol-table-resolve/plan.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/projects/symbol-table-resolve/plan.md b/projects/symbol-table-resolve/plan.md index 44db064285d3..74f3ba4e44bb 100644 --- a/projects/symbol-table-resolve/plan.md +++ b/projects/symbol-table-resolve/plan.md @@ -41,8 +41,8 @@ Four slices: one foundation slice delivering the eager binder in `psl-parser` (w ## Dependencies (external) -- [ ] PR #30335 (`multifiile-psl`) merges — open at planning time; operator judges it will not stall. All slices stack on it; no independent landing path (spec transitional-shape constraint). -- [ ] Base-branch escapee, surfaced during D1: `integration-tests` typecheck is broken at the branch point itself — `test/integration/test/authoring/lsp-emit-parity.integration.test.ts:8` imports `createDocumentStore`, removed by `multifiile-psl`'s own commit `a54c338980`. Belongs to PR #30335 to fix; our stack inherits the red CI until it does. Not fixed here (out of scope; operator may wish to flag it on the PR themselves). +- [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 From 49c03390d9662ab572690d4c842e9667edc37557 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 18 Sep 2026 16:30:13 +0000 Subject: [PATCH 11/66] feat(psl-parser): let the reference combinators speak with the binder's voice The parse-time AttributeCtx now carries the binder, and the D4 context builders populate it. When it is present, fieldRef, referencedFieldRef and entityRef read the argument's resolution out of the binder - a map read of results computed at creation, keyed by the very node the combinator holds - and raise no existence diagnostic of their own. An unknown name is reported once, by the binder. Without a binder on the context the combinators behave exactly as before, so unconverted call sites are untouched; the existing attribute-spec suite pins that and is unmodified. Shape and arity failures remain the combinator's voice in both modes - only existence moved. Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- .../2-authoring/psl-parser/README.md | 13 +- .../attribute-spec/combinators/entity-ref.ts | 3 +- .../attribute-spec/combinators/field-ref.ts | 15 +- .../psl-parser/src/attribute-spec/types.ts | 2 + .../psl-parser/src/binder-context.ts | 3 +- .../test/attribute-spec-binder.test.ts | 286 ++++++++++++++++++ 6 files changed, 314 insertions(+), 8 deletions(-) create mode 100644 packages/1-framework/2-authoring/psl-parser/test/attribute-spec-binder.test.ts diff --git a/packages/1-framework/2-authoring/psl-parser/README.md b/packages/1-framework/2-authoring/psl-parser/README.md index 5c8f89b04093..d735339ea378 100644 --- a/packages/1-framework/2-authoring/psl-parser/README.md +++ b/packages/1-framework/2-authoring/psl-parser/README.md @@ -42,7 +42,7 @@ 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. +- `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 @@ -104,6 +104,17 @@ A field whose type is malformed (`malformedType`) is skipped entirely: no resolu 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 `AttributeCtx` (`binder`), alongside the legacy `resolveReferencedModel`. The reference combinators — `fieldRef`, `referencedFieldRef`, `entityRef` — check for it: + +- **With a binder**, the combinator reads the argument's resolution straight out of the binder (`symbolForNode(argumentNode)` — a map read of results already computed at creation, never a second resolution) and emits **no** existence diagnostic. An unknown name is reported once, by the binder, as `PSL_UNRESOLVED_REFERENCE`. The attribute still parses to its usual value, so the interpreter keeps receiving what it always received. +- **Without a binder**, behavior is exactly as before: the combinator checks the model itself and raises its own "does not exist" diagnostic. Unconverted call sites keep working untouched. + +Shape and arity stay the combinator's voice in both modes — "Expected a field name", "Expected a list of field name", wrong argument counts. Only *existence* moved. The split is the point: resolution is the binder's, shape is the spec's, and no schema error is ever reported twice. + +This lookup rests on red-node identity (below): the combinator receives the very `SyntaxNode` the binder keyed its result under. + ### 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. 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 9b74e5e518d0..b5daabd2556f 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 @@ -19,7 +19,8 @@ export function entityRef(): EntityRefArgType { if (name === undefined) { return notOk([leafDiagnostic(ctx, arg, 'Expected a model name')]); } - return ok(name); + const resolution = ctx.binder?.symbolForNode(arg.syntax); + return ok(resolution?.kind === 'model' ? resolution.symbol.name : name); }, }; } 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..a46252a9122c 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 @@ -15,7 +15,7 @@ import { leafDiagnostic } from './diagnostic'; function parseFieldName( arg: ExpressionAst, ctx: AttributeCtx, - model: ModelSymbol | undefined, + model: () => ModelSymbol | undefined, ): Result { const identifier = IdentifierAst.cast(arg.syntax); if (identifier === undefined) { @@ -25,10 +25,15 @@ function parseFieldName( if (name === undefined) { return notOk([leafDiagnostic(ctx, arg, 'Expected a field name')]); } + if (ctx.binder !== undefined) { + const resolution = ctx.binder.symbolForNode(arg.syntax); + return ok(resolution?.kind === 'field' ? resolution.symbol.name : 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)) { + const owner = model(); + if (owner !== undefined && !Object.hasOwn(owner.fields, name)) { return notOk([ - leafDiagnostic(ctx, arg, `Field "${name}" does not exist on model "${model.name}"`), + leafDiagnostic(ctx, arg, `Field "${name}" does not exist on model "${owner.name}"`), ]); } return ok(name); @@ -38,7 +43,7 @@ export function fieldRef(): FieldRefArgType { return { kind: 'fieldRef', label: 'field name', - parse: (arg, ctx) => parseFieldName(arg, ctx, ctx.selfModel), + parse: (arg, ctx) => parseFieldName(arg, ctx, () => ctx.selfModel), }; } @@ -46,6 +51,6 @@ export function referencedFieldRef(): ReferencedFieldRefArgType parseFieldName(arg, ctx, ctx.resolveReferencedModel()), + parse: (arg, ctx) => parseFieldName(arg, ctx, () => ctx.resolveReferencedModel()), }; } 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 43de50a12b10..35fbbb31e688 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 { PslSources } from '../source-file'; import type { FieldSymbol, ModelSymbol } from '../symbol-table'; @@ -12,6 +13,7 @@ export type AttributeLevel = 'field' | 'model' | 'block'; export interface AttributeCtx { readonly sources: PslSources; + readonly binder?: Binder; } export interface ModelAttributeCtx extends AttributeCtx { diff --git a/packages/1-framework/2-authoring/psl-parser/src/binder-context.ts b/packages/1-framework/2-authoring/psl-parser/src/binder-context.ts index d6bed5c464cc..3bfb93a64022 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/binder-context.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/binder-context.ts @@ -21,12 +21,13 @@ export interface FieldAttributeContextInput extends ModelAttributeContextInput { } export function modelAttributeContext(input: ModelAttributeContextInput): ModelAttributeCtx { - return { sources: input.sources, selfModel: input.model }; + return { sources: input.sources, binder: input.binder, selfModel: input.model }; } export function fieldAttributeContext(input: FieldAttributeContextInput): FieldAttributeCtx { return { sources: input.sources, + binder: input.binder, selfModel: input.model, field: input.field, resolveReferencedModel: () => referencedModel(input.binder, input.field), 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..2c535f607727 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-binder.test.ts @@ -0,0 +1,286 @@ +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 type { AttributeSpecRegistry } from '../src/binder'; +import { createBinder } from '../src/binder'; +import { + fieldAttributeContext, + modelAttributeContext, + referencedModel, +} from '../src/binder-context'; +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(), documentation: 'fixture' }], +}); + +const indexSpec = modelAttribute('index', { + documentation: 'fixture', + positional: [{ key: 'fields', type: list(fieldRef()), documentation: 'fixture' }], +}); + +const ATTRIBUTE_SPECS: AttributeSpecRegistry = { + model: (name) => (name === 'base' ? baseSpec : name === 'index' ? indexSpec : undefined), + field: (name) => (name === 'relation' ? relationSpec : undefined), +}; + +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, + }); + 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, withBinder: boolean) { + const { sources, symbolTable, binder, binderDiagnostics } = bind(text); + const post = symbolTable.topLevel.models['Post']!; + const field = post.fields['author']!; + const ctx = withBinder + ? fieldAttributeContext({ binder, sources, model: post, field }) + : { + sources, + selfModel: post, + field, + resolveReferencedModel: () => referencedModel(binder, field), + }; + return { + binderDiagnostics, + result: interpretAttribute(fieldAttributeNode(field, 'relation'), relationSpec, ctx), + }; +} + +describe('reference combinators with a binder-backed context', () => { + it('parses a valid relation to the same output as the legacy path', () => { + const legacy = interpretRelation(RELATION_SCHEMA, false); + const bound = interpretRelation(RELATION_SCHEMA, true); + + const expected = { fields: ['authorId'], references: ['id'] }; + expect(legacy.result.ok).toBe(true); + expect(bound.result.ok).toBe(true); + if (legacy.result.ok) expect(legacy.result.value).toEqual(expected); + if (bound.result.ok) expect(bound.result.value).toEqual(expected); + 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 legacy = interpretRelation(schema, false); + const bound = interpretRelation(schema, true); + + expect(legacy.result.ok).toBe(false); + expect(bound.result.ok).toBe(true); + 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, true); + + expect(bound.result.ok).toBe(true); + 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 = modelAttributeContext({ binder, sources, model: child }); + const result = interpretAttribute(modelAttributeNode(child, 'base'), baseSpec, ctx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toEqual({ model: 'Ghost' }); + 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 = fieldAttributeContext({ binder, sources, model: 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 = modelAttributeContext({ binder, sources, model: 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 = modelAttributeContext({ binder, sources, model: 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('reference combinators without a binder', () => { + it('still reports an unknown local field itself', () => { + const { sources, symbolTable } = bind('model User {\n id Int\n @@index([nope])\n}'); + const user = symbolTable.topLevel.models['User']!; + const result = interpretAttribute(modelAttributeNode(user, 'index'), indexSpec, { + sources, + selfModel: user, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure.map(({ message }) => message)).toEqual([ + 'Field "nope" does not exist on model "User"', + ]); + } + }); + + it('still reports an unknown referenced field itself', () => { + const legacy = interpretRelation( + [ + 'model User {', + ' id Int', + '}', + 'model Post {', + ' authorId Int', + ' author User @relation(fields: [authorId], references: [absent])', + '}', + ].join('\n'), + false, + ); + + expect(legacy.result.ok).toBe(false); + if (!legacy.result.ok) { + expect(legacy.result.failure.map(({ message }) => message)).toEqual([ + 'Field "absent" does not exist on model "User"', + ]); + } + }); +}); From 42ca7dde636096390f52b1c844095bc09e5b3468 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 18 Sep 2026 16:36:32 +0000 Subject: [PATCH 12/66] docs(psl-parser): state the binder-on-context snapshot precondition The reference combinators defer to the binder whenever one is on the context, not only when the lookup hits, so a binder from another snapshot or from registries disagreeing with the interpreted specs drops existence diagnostics without a word from either voice. Say so where the wiring is documented. Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- packages/1-framework/2-authoring/psl-parser/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/1-framework/2-authoring/psl-parser/README.md b/packages/1-framework/2-authoring/psl-parser/README.md index d735339ea378..36b3f00bb2a3 100644 --- a/packages/1-framework/2-authoring/psl-parser/README.md +++ b/packages/1-framework/2-authoring/psl-parser/README.md @@ -115,6 +115,8 @@ Shape and arity stay the combinator's voice in both modes — "Expected a field This lookup rests on red-node identity (below): the combinator receives the very `SyntaxNode` the binder keyed its result under. +**Precondition.** 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 combinators defer to the binder whenever one is present, not only when the lookup hits, so a binder from a different snapshot or from registries that disagree with the specs being interpreted silently forgoes existence diagnostics: neither voice reports the unknown name. Build the binder and run interpretation over one snapshot. + ### 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. From 1cdf68a5f9c23bab6ba20ddd45800a020840ddc3 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 18 Sep 2026 16:38:20 +0000 Subject: [PATCH 13/66] chore(drive): binder-core D5 amendments and trace Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- .../slices/binder-core/briefs/d5-r1.md | 38 +++++++++++++++++++ .../slices/binder-core/briefs/d5.ids.json | 8 ++++ .../slices/binder-core/plan.md | 9 ++++- .../slices/binder-core/spec.md | 4 +- projects/symbol-table-resolve/trace.jsonl | 10 +++++ 5 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 projects/symbol-table-resolve/slices/binder-core/briefs/d5-r1.md create mode 100644 projects/symbol-table-resolve/slices/binder-core/briefs/d5.ids.json 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/plan.md b/projects/symbol-table-resolve/slices/binder-core/plan.md index c726ba4d68a8..ca7333979553 100644 --- a/projects/symbol-table-resolve/slices/binder-core/plan.md +++ b/projects/symbol-table-resolve/slices/binder-core/plan.md @@ -30,4 +30,11 @@ Slice spec: `projects/symbol-table-resolve/slices/binder-core/spec.md`. Branch: - **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. -Sizes: D1 S, D2 L, D3 M, D4 S. Sequential; no parallel-within-slice. +### 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. + +Sizes: D1 S, D2 L, D3 M, D4 S, D5 M. 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 index 8d9188d52ba4..d88201febc0a 100644 --- a/projects/symbol-table-resolve/slices/binder-core/spec.md +++ b/projects/symbol-table-resolve/slices/binder-core/spec.md @@ -15,6 +15,7 @@ Normative pseudo-code and API live in the parent spec — `projects/symbol-table - **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):** the parse-time `AttributeCtx` carries the whole `Binder` as an optional member, populated by the context builders; the reference combinators (`fieldRef`, `referencedFieldRef`, `entityRef`) consume the binder's already-computed resolutions via `symbolForNode` (one map read, never a re-resolution) and, when the binder is present, emit **no** resolution diagnostics of their own — the binder's returned diagnostics are the sole voice (parent spec, cross-cutting requirement 2). Without a binder, legacy behavior is byte-for-byte unchanged, preserving the transitional constraint for unconverted consumers. Without this, a converted consumer would report each resolution failure twice — once from the binder, once from the combinator's own existence check. ## Coherence rationale @@ -22,7 +23,7 @@ One package, one subject: the binder and the node-identity substrate it requires ## Scope -**In:** `packages/1-framework/2-authoring/psl-parser` — `src/syntax/red.ts`, new binder + universe-scope modules, attribute-ctx helper, `src/exports/index.ts`, package tests; branch stacks on `origin/multifiile-psl` (PR #30335). +**In:** `packages/1-framework/2-authoring/psl-parser` — `src/syntax/red.ts`, new binder + universe-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:** any change to SQL/Mongo interpreters, language server, or `contract-prisma7` (later slices / non-goals); laziness or cross-snapshot memo retention; incremental reparse; new LSP features. @@ -41,6 +42,7 @@ One package, one subject: the binder and the node-identity substrate it requires - [ ] Diff touches only `psl-parser` and `projects/symbol-table-resolve/` (verifiable via `git diff --stat` against `origin/multifiile-psl`). - [ ] The attribute-ctx helper is exported and covered by a test demonstrating `resolveReferencedModel` as a binder map read. +- [ ] With a binder-backed context, a resolution failure inside an attribute argument yields exactly one diagnostic — the binder's; the combinators emit none. Without a binder, the legacy path is pinned unchanged by the existing suite. ## Open Questions diff --git a/projects/symbol-table-resolve/trace.jsonl b/projects/symbol-table-resolve/trace.jsonl index f6d2ea79aa0d..6a1199a5f58c 100644 --- a/projects/symbol-table-resolve/trace.jsonl +++ b/projects/symbol-table-resolve/trace.jsonl @@ -37,3 +37,13 @@ {"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} From 75761fc120ac1b1a1cff9a4c6f4014b186a05442 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 18 Sep 2026 19:41:28 +0000 Subject: [PATCH 14/66] feat(psl-parser): make the binder the only resolution path for reference arguments AttributeCtx no longer carries resolveReferencedModel, and ModelAttributeCtx requires a binder, so every context that can reach a reference combinator has one by construction - there is no second path to fall back to. fieldRef and referencedFieldRef resolve solely through the binder: a bound field yields its name, a cross-space reference parses as before, and anything else fails the argument while staying silent, because the binder has already reported that name. entityRef keeps its success semantics and its base-level context, since block attributes are interpreted before a binder can exist. Failing an argument without a diagnostic needed the aggregation points to stop inferring failure from diagnostic count: list, record and interpretArgs now track it directly, so a silent failure fails its attribute rather than quietly yielding a short list or a missing key. Binder diagnostics carry the class of reference that failed, letting a consumer adopt the binder's voice for the questions it has converted and keep its own for the rest. The attribute-spec registry now sees the declaring owner, so a spec a consumer builds per model is visible to the binder rather than looking unknown. Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- .../2-authoring/psl-parser/README.md | 17 ++- .../attribute-spec/combinators/entity-ref.ts | 3 +- .../attribute-spec/combinators/field-ref.ts | 25 +--- .../src/attribute-spec/combinators/list.ts | 11 +- .../src/attribute-spec/combinators/record.ts | 4 +- .../src/attribute-spec/interpret.ts | 4 +- .../psl-parser/src/attribute-spec/types.ts | 3 +- .../psl-parser/src/binder-context.ts | 1 - .../2-authoring/psl-parser/src/binder.ts | 27 +++- .../test/attribute-spec-binder.test.ts | 91 +++++-------- ...bute-spec-combinators.foreign-copy.test.ts | 18 ++- ...te-spec-combinators.tagged-literal.test.ts | 14 +- .../test/attribute-spec-combinators.test.ts | 110 +++++++++++---- ...attribute-spec-failure-propagation.test.ts | 128 ++++++++++++++++++ .../psl-parser/test/attribute-spec.test.ts | 14 +- .../psl-parser/test/binder-context.test.ts | 10 +- .../psl-parser/test/binder.test.ts | 64 +++++++++ 17 files changed, 403 insertions(+), 141 deletions(-) create mode 100644 packages/1-framework/2-authoring/psl-parser/test/attribute-spec-failure-propagation.test.ts diff --git a/packages/1-framework/2-authoring/psl-parser/README.md b/packages/1-framework/2-authoring/psl-parser/README.md index 36b3f00bb2a3..3741d670b2d8 100644 --- a/packages/1-framework/2-authoring/psl-parser/README.md +++ b/packages/1-framework/2-authoring/psl-parser/README.md @@ -106,16 +106,23 @@ The binder owns resolution failures and nothing else. Failures come back under ` ### Attribute contexts and the single voice -`modelAttributeContext` / `fieldAttributeContext` put the whole `Binder` on the parse-time `AttributeCtx` (`binder`), alongside the legacy `resolveReferencedModel`. The reference combinators — `fieldRef`, `referencedFieldRef`, `entityRef` — check for it: +`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. -- **With a binder**, the combinator reads the argument's resolution straight out of the binder (`symbolForNode(argumentNode)` — a map read of results already computed at creation, never a second resolution) and emits **no** existence diagnostic. An unknown name is reported once, by the binder, as `PSL_UNRESOLVED_REFERENCE`. The attribute still parses to its usual value, so the interpreter keeps receiving what it always received. -- **Without a binder**, behavior is exactly as before: the combinator checks the model itself and raises its own "does not exist" diagnostic. Unconverted call sites keep working untouched. +`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 -Shape and arity stay the combinator's voice in both modes — "Expected a field name", "Expected a list of field name", wrong argument counts. Only *existence* moved. The split is the point: resolution is the binder's, shape is the spec's, and no schema error is ever reported twice. +- 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.** 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 combinators defer to the binder whenever one is present, not only when the lookup hits, so a binder from a different snapshot or from registries that disagree with the specs being interpreted silently forgoes existence diagnostics: neither voice reports the unknown name. Build the binder and run interpretation over one snapshot. +**Precondition.** 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 combinators trust the binder's answer rather than re-deriving it, so a binder from a different snapshot or from registries that disagree with the specs being interpreted will bind nothing for those nodes: every reference argument fails to parse while the binder's own diagnostics describe a different document. Build the binder and run interpretation over one snapshot. ### Snapshot lifetime 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 b5daabd2556f..9b74e5e518d0 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 @@ -19,8 +19,7 @@ export function entityRef(): EntityRefArgType { if (name === undefined) { return notOk([leafDiagnostic(ctx, arg, 'Expected a model name')]); } - const resolution = ctx.binder?.symbolForNode(arg.syntax); - return ok(resolution?.kind === 'model' ? resolution.symbol.name : name); + return ok(name); }, }; } 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 a46252a9122c..a6ec5f549a23 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,8 @@ 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 +12,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,25 +22,17 @@ function parseFieldName( if (name === undefined) { return notOk([leafDiagnostic(ctx, arg, 'Expected a field name')]); } - if (ctx.binder !== undefined) { - const resolution = ctx.binder.symbolForNode(arg.syntax); - return ok(resolution?.kind === 'field' ? resolution.symbol.name : 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. - const owner = model(); - if (owner !== undefined && !Object.hasOwn(owner.fields, name)) { - return notOk([ - leafDiagnostic(ctx, arg, `Field "${name}" does not exist on model "${owner.name}"`), - ]); - } - return ok(name); + const resolution = ctx.binder.symbolForNode(arg.syntax); + 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), }; } @@ -51,6 +40,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 de145c49a332..9729d279552e 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 @@ -28,12 +28,17 @@ export function list( } const diagnostics: PslDiagnostic[] = []; const parsed: { node: ExpressionAst; value: T }[] = []; + let failed = false; 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); + if (result.ok) { + parsed.push({ node: element, value: result.value }); + } else { + failed = true; + diagnostics.push(...result.failure); + } } if (!allowEmpty && count === 0) { diagnostics.push(leafDiagnostic(ctx, arg, 'Expected a non-empty list')); @@ -45,7 +50,7 @@ export function list( else seen.add(value); } } - if (diagnostics.length > 0) return notOk(diagnostics); + if (failed || diagnostics.length > 0) return notOk(diagnostics); return ok(parsed.map((entry) => entry.value)); }, }; 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..fee3ee010ad4 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 @@ -17,6 +17,7 @@ export function record(of: ArgType): Record const diagnostics: PslDiagnostic[] = []; const entries: [string, T][] = []; const keys = new Set(); + let failed = false; for (const field of Array.from(literal.fields())) { const key = field.keyName(); if (key === undefined) { @@ -30,6 +31,7 @@ export function record(of: ArgType): Record } const parsed = of.parse(value, ctx); if (!parsed.ok) { + failed = true; diagnostics.push(...parsed.failure); continue; } @@ -40,7 +42,7 @@ export function record(of: ArgType): Record keys.add(key); entries.push([key, parsed.value]); } - if (diagnostics.length > 0) return notOk(diagnostics); + if (failed || diagnostics.length > 0) return notOk(diagnostics); return ok(Object.fromEntries(entries)); }, }; 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..ed066c51b197 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 @@ -38,6 +38,7 @@ export function interpretArgs( const seen = new Set(); let positionalSlot = 0; let reportedExcess = false; + let failed = false; for (const arg of args) { const name = arg.name()?.name(); @@ -94,6 +95,7 @@ export function interpretArgs( seen.add(key); const result = parseArgValue(arg, param, ctx, diagnostics); if (result.ok) output[key] = result.value; + else failed = true; } const finalized = new Set(); @@ -128,7 +130,7 @@ export function interpretArgs( finalizeAbsentKey(key, undefined, spec.named[key]?.type); } - if (diagnostics.length > 0) { + if (failed || diagnostics.length > 0) { return notOk(diagnostics); } return ok(output); 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 35fbbb31e688..91623aa2cdef 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 @@ -13,16 +13,15 @@ export type AttributeLevel = 'field' | 'model' | 'block'; export interface AttributeCtx { readonly sources: PslSources; - readonly binder?: Binder; } 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-context.ts b/packages/1-framework/2-authoring/psl-parser/src/binder-context.ts index 3bfb93a64022..91a5a50a3916 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/binder-context.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/binder-context.ts @@ -30,6 +30,5 @@ export function fieldAttributeContext(input: FieldAttributeContextInput): FieldA binder: input.binder, selfModel: input.model, field: input.field, - resolveReferencedModel: () => referencedModel(input.binder, input.field), }; } diff --git a/packages/1-framework/2-authoring/psl-parser/src/binder.ts b/packages/1-framework/2-authoring/psl-parser/src/binder.ts index c48762f23d8d..cb95a774c464 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/binder.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/binder.ts @@ -65,8 +65,12 @@ export interface AttributeSpecView { } export interface AttributeSpecRegistry { - model(name: string): AttributeSpecView | undefined; - field(name: string): AttributeSpecView | undefined; + model(name: string, owner: ModelSymbol | CompositeTypeSymbol): AttributeSpecView | undefined; + field( + name: string, + owner: ModelSymbol | CompositeTypeSymbol, + field: FieldSymbol, + ): AttributeSpecView | undefined; } export interface Binder { @@ -151,6 +155,7 @@ export function createBinder(options: CreateBinderOptions): BinderResult { diagnostics.push({ code: PSL_UNRESOLVED_REFERENCE, message: `Cannot find type "${resolution.name}"`, + data: { reference: 'type' }, ...diagnosticSource(sources, node).at(), }); } @@ -166,12 +171,12 @@ export function createBinder(options: CreateBinderOptions): BinderResult { symbolTable, sources, }; - bindAttributes(symbol, symbol.attributes, (name) => attributeSpecs.model(name), { + bindAttributes(symbol, symbol.attributes, (name) => attributeSpecs.model(name, symbol), { ...context, field: undefined, }); for (const field of Object.values(symbol.fields)) { - bindAttributes(field, field.attributes, (name) => attributeSpecs.field(name), { + bindAttributes(field, field.attributes, (name) => attributeSpecs.field(name, symbol, field), { ...context, field, }); @@ -209,6 +214,7 @@ function bindAttributes( ctx.diagnostics.push({ code: PSL_UNRESOLVED_ATTRIBUTE, message: `Cannot find attribute "${marker}${attribute.name}"`, + data: { reference: 'attribute' }, ...diagnosticSource(ctx.sources, nameNode).at(), }); } else { @@ -254,7 +260,7 @@ function resolveArgument( function resolveOwnerField(name: string, node: SyntaxNode, ctx: BindContext): Resolution { const field = own(ctx.owner.fields, name); if (field !== undefined) return { kind: 'field', symbol: field }; - report(`Cannot find field "${name}" on "${ctx.owner.name}"`, node, ctx); + report(`Cannot find field "${name}" on "${ctx.owner.name}"`, node, ctx, 'field'); return { kind: 'unresolved', name }; } @@ -275,6 +281,7 @@ function resolveReferencedField( `Cannot find field "${name}" on the type of "${ctx.owner.name}.${declaring.name}"`, node, ctx, + 'field', ); return { kind: 'unresolved', name }; } @@ -290,7 +297,7 @@ function targetFields( function resolveEntity(name: string, node: SyntaxNode, ctx: BindContext): Resolution { const entity = entityNamed(name, ctx); if (entity !== undefined) return entity; - report(`Cannot find entity "${name}"`, node, ctx); + report(`Cannot find entity "${name}"`, node, ctx, 'entity'); return { kind: 'unresolved', name }; } @@ -309,10 +316,16 @@ function entityNamed(name: string, ctx: BindContext): Resolution | undefined { return undefined; } -function report(message: string, node: SyntaxNode, ctx: BindContext): void { +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(), }); } 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 index 2c535f607727..9f33e84d39fb 100644 --- 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 @@ -13,11 +13,7 @@ import { modelAttribute } from '../src/attribute-spec/model-attribute'; import { optional } from '../src/attribute-spec/optional'; import type { AttributeSpecRegistry } from '../src/binder'; import { createBinder } from '../src/binder'; -import { - fieldAttributeContext, - modelAttributeContext, - referencedModel, -} from '../src/binder-context'; +import { fieldAttributeContext, modelAttributeContext } from '../src/binder-context'; import { parse } from '../src/parse'; import { PslSources } from '../src/source-file'; import { buildSymbolTable, type FieldSymbol, type ModelSymbol } from '../src/symbol-table'; @@ -110,18 +106,11 @@ const RELATION_SCHEMA = [ '}', ].join('\n'); -function interpretRelation(text: string, withBinder: boolean) { +function interpretRelation(text: string) { const { sources, symbolTable, binder, binderDiagnostics } = bind(text); const post = symbolTable.topLevel.models['Post']!; const field = post.fields['author']!; - const ctx = withBinder - ? fieldAttributeContext({ binder, sources, model: post, field }) - : { - sources, - selfModel: post, - field, - resolveReferencedModel: () => referencedModel(binder, field), - }; + const ctx = fieldAttributeContext({ binder, sources, model: post, field }); return { binderDiagnostics, result: interpretAttribute(fieldAttributeNode(field, 'relation'), relationSpec, ctx), @@ -129,15 +118,13 @@ function interpretRelation(text: string, withBinder: boolean) { } describe('reference combinators with a binder-backed context', () => { - it('parses a valid relation to the same output as the legacy path', () => { - const legacy = interpretRelation(RELATION_SCHEMA, false); - const bound = interpretRelation(RELATION_SCHEMA, true); + it('parses a valid relation into the bound field names', () => { + const bound = interpretRelation(RELATION_SCHEMA); - const expected = { fields: ['authorId'], references: ['id'] }; - expect(legacy.result.ok).toBe(true); expect(bound.result.ok).toBe(true); - if (legacy.result.ok) expect(legacy.result.value).toEqual(expected); - if (bound.result.ok) expect(bound.result.value).toEqual(expected); + if (bound.result.ok) { + expect(bound.result.value).toEqual({ fields: ['authorId'], references: ['id'] }); + } expect(bound.binderDiagnostics).toEqual([]); }); @@ -151,11 +138,10 @@ describe('reference combinators with a binder-backed context', () => { ' author User @relation(fields: [authorId], references: [absent])', '}', ].join('\n'); - const legacy = interpretRelation(schema, false); - const bound = interpretRelation(schema, true); + const bound = interpretRelation(schema); - expect(legacy.result.ok).toBe(false); - expect(bound.result.ok).toBe(true); + 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"'], ]); @@ -171,9 +157,10 @@ describe('reference combinators with a binder-backed context', () => { ' author User @relation(fields: [nope], references: [id])', '}', ].join('\n'); - const bound = interpretRelation(schema, true); + const bound = interpretRelation(schema); - expect(bound.result.ok).toBe(true); + 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"'], ]); @@ -245,42 +232,32 @@ describe('reference combinators with a binder-backed context', () => { }); }); -describe('reference combinators without a binder', () => { - it('still reports an unknown local field itself', () => { - const { sources, symbolTable } = bind('model User {\n id Int\n @@index([nope])\n}'); +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 result = interpretAttribute(modelAttributeNode(user, 'index'), indexSpec, { - sources, - selfModel: user, - }); + const ctx = modelAttributeContext({ binder, sources, model: user }); + const result = interpretAttribute(modelAttributeNode(user, 'index'), indexSpec, ctx); expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.failure.map(({ message }) => message)).toEqual([ - 'Field "nope" does not exist on model "User"', - ]); - } + 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('still reports an unknown referenced field itself', () => { - const legacy = interpretRelation( - [ - 'model User {', - ' id Int', - '}', - 'model Post {', - ' authorId Int', - ' author User @relation(fields: [authorId], references: [absent])', - '}', - ].join('\n'), - false, + 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 = modelAttributeContext({ binder, sources, model: user }); + const result = interpretAttribute(modelAttributeNode(user, 'index'), indexSpec, ctx); - expect(legacy.result.ok).toBe(false); - if (!legacy.result.ok) { - expect(legacy.result.failure.map(({ message }) => message)).toEqual([ - 'Field "absent" does not exist on model "User"', - ]); - } + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toEqual({ fields: ['id'] }); + expect(binderDiagnostics).toEqual([]); }); }); 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 781a69ca5cba..9d3d4fd87ad4 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, @@ -43,11 +44,18 @@ function foreignArg(source: string): { arg: ExpressionAst; ctx: ModelAttributeCt }); const selfModel = symbolTable.topLevel.models['M']; if (selfModel === undefined) throw new Error('expected model M'); + const { binder } = createBinder({ + sources, + symbolTable, + typeConstructors: {}, + attributeSpecs: { model: () => undefined, field: () => undefined }, + }); return { arg: new ForeignCopyOfAnAstNode(value.syntax) as unknown as ExpressionAst, ctx: { sources: new PslSources([[root, cursor.sourceFile]]), selfModel, + binder, }, }; } @@ -66,7 +74,6 @@ describe('combinators dispatch on syntax kind, not on AST class identity', () => 'Cascade', ], ['entityRef', entityRef(), '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 }], @@ -79,6 +86,15 @@ describe('combinators dispatch on syntax kind, not on AST class identity', () => if (result.ok) expect(result.value).toEqual(expected); }); + it('fieldRef dispatches on the syntax kind of a node from another module copy', () => { + const { arg, ctx } = foreignArg('id'); + + const result = fieldRef().parse(arg, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.failure).toEqual([]); + }); + it('funcCall accepts a node from another module copy', () => { const { arg, ctx } = foreignArg('now()'); 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 dd3f0c23e741..fb3be85c2181 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,12 +20,13 @@ 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, - selfModel, - field, - resolveReferencedModel: () => undefined, - }; + const { binder } = createBinder({ + sources: modelSources, + symbolTable, + typeConstructors: {}, + attributeSpecs: { model: () => undefined, field: () => undefined }, + }); + return { sources, 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.ts b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.test.ts index 59c32bacc853..88dcebf09ee5 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,12 +41,60 @@ 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, - selfModel, - field, - resolveReferencedModel: () => undefined, - }; + const { binder } = createBinder({ + sources: modelSources, + symbolTable, + typeConstructors: {}, + attributeSpecs: { model: () => undefined, field: () => undefined }, + }); + return { sources, 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: () => undefined, + field: (name) => + name === attribute + ? { + positional: [{ key: 'fields', type: { kind: 'list', of: { kind: 'fieldRef' } } }], + named: { + fields: { type: { kind: 'list', of: { kind: 'fieldRef' } } }, + references: { type: { kind: 'list', of: { kind: 'referencedFieldRef' } } }, + }, + } + : undefined, + }, + }); + 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, selfModel: model, field, binder }, + }; + } + } + throw new Error('expected the attribute argument'); } function argOf(exprSource: string): { expr: ExpressionAst; ctx: FieldAttributeCtx } { @@ -624,47 +673,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', () => { 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..c160f826311a --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-failure-propagation.test.ts @@ -0,0 +1,128 @@ +import { notOk, ok, type Result } from '@internal/utils/result'; +import { describe, expect, it } from 'vitest'; +import { list } from '../src/attribute-spec/combinators/list'; +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: () => undefined, field: () => undefined }, + }); + return { sources, model, binder }; +} + +function interpretFirst( + text: string, + spec: Parameters>[1], +) { + const { sources, model, binder } = build(text); + const node = Array.from(model.node.attributes())[0]; + if (node === undefined) throw new Error('no attribute'); + return interpretAttribute(node, spec, { sources, 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'); + }); +}); 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 7581eb774c8b..29412ea673a6 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,12 +29,13 @@ 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, - selfModel, - field, - resolveReferencedModel: () => undefined, - }; + const { binder } = createBinder({ + sources: modelSources, + symbolTable, + typeConstructors: {}, + attributeSpecs: { model: () => undefined, field: () => undefined }, + }); + return { sources, selfModel, field, binder }; } function fieldAttr(source: string): { node: FieldAttributeAst; ctx: FieldAttributeCtx } { diff --git a/packages/1-framework/2-authoring/psl-parser/test/binder-context.test.ts b/packages/1-framework/2-authoring/psl-parser/test/binder-context.test.ts index 53c40b8d7dc9..0b6fe548d005 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/binder-context.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/binder-context.test.ts @@ -135,7 +135,7 @@ describe('referencedModel', () => { }); describe('fieldAttributeContext', () => { - it('serves resolveReferencedModel as a binder map read', () => { + it('carries the binder and the declaring symbols', () => { const { sources, symbolTable, binder } = bind(RELATION_SCHEMA); const post = symbolTable.topLevel.models['Post']!; const ctx = fieldAttributeContext({ @@ -148,11 +148,11 @@ describe('fieldAttributeContext', () => { expect(ctx.sources).toBe(sources); expect(ctx.selfModel).toBe(post); expect(ctx.field).toBe(post.fields['author']); - expect(ctx.resolveReferencedModel()).toBe(symbolTable.topLevel.models['User']); - expect(ctx.resolveReferencedModel()).toBe(ctx.resolveReferencedModel()); + expect(ctx.binder).toBe(binder); + expect(referencedModel(ctx.binder, ctx.field)).toBe(symbolTable.topLevel.models['User']); }); - it('resolves to undefined without throwing for a cross-space relation', () => { + it('carries a binder that resolves no model for a cross-space relation', () => { const { sources, symbolTable, binder } = bind( 'model Cart {\n userId Int\n user auth:User @relation(fields: [userId], references: [id])\n}', ); @@ -164,7 +164,7 @@ describe('fieldAttributeContext', () => { field: cart.fields['user']!, }); - expect(ctx.resolveReferencedModel()).toBeUndefined(); + expect(referencedModel(ctx.binder, ctx.field)).toBeUndefined(); }); }); 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 index 2792e60af257..60cee035e28f 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/binder.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/binder.test.ts @@ -384,6 +384,7 @@ describe('createBinder — diagnostics', () => { { code: 'PSL_UNRESOLVED_REFERENCE', message: 'Cannot find type "Dog"', + data: { reference: 'type' }, filename: '1.psl', range: { start: { line: 1, character: 6 }, end: { line: 1, character: 9 } }, }, @@ -872,3 +873,66 @@ describe('createBinder — reference slots the binder stays silent about', () => expect(diagnostics).toEqual([]); }); }); + +describe('owner-aware attribute-spec registry', () => { + it('passes the owner and field so a context-dependent spec is visible', () => { + const seen: string[] = []; + const { sources, symbolTable } = build( + ['model User {', ' id Int', ' name String @contextual', ' @@index([id])', '}'].join('\n'), + ); + const registry: AttributeSpecRegistry = { + model: (name, owner) => { + seen.push(`model:${name}:${owner.name}`); + return name === 'index' + ? { positional: [{ key: 'fields', type: fieldRefList }], named: {} } + : undefined; + }, + field: (name, owner, field) => { + seen.push(`field:${name}:${owner.name}.${field.name}`); + return name === 'contextual' ? { positional: [], named: {} } : undefined; + }, + }; + const { binder, diagnostics } = createBinder({ + sources, + symbolTable, + typeConstructors: TYPE_CONSTRUCTORS, + attributeSpecs: registry, + }); + 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'))).toEqual({ + kind: 'attributeSpec', + spec: { positional: [], named: {} }, + }); + 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, entity and attribute failures distinctly', () => { + const { diagnostics } = bind( + [ + 'model Post {', + ' ghost Phantom', + ' id Int', + ' @@index([missingField])', + ' @@base(NoSuchModel)', + ' @@mystery', + '}', + ].join('\n'), + ); + + expect(diagnostics.map(({ code, data }) => [code, data?.['reference']])).toEqual([ + ['PSL_UNRESOLVED_REFERENCE', 'type'], + ['PSL_UNRESOLVED_REFERENCE', 'field'], + ['PSL_UNRESOLVED_REFERENCE', 'entity'], + ['PSL_UNRESOLVED_ATTRIBUTE', 'attribute'], + ]); + }); +}); From f01bc7254ebf67d59639c14905eea374f08f1375 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 18 Sep 2026 19:41:51 +0000 Subject: [PATCH 15/66] feat(sql,mongo): build a binder per interpretation and let it voice resolution Both interpreters now construct a binder once from the snapshot artifacts they already hold and thread it into every attribute context, so the reference combinators resolve through it. Their binder diagnostics are pushed into the interpreter's collector rather than discarded - without that, removing the combinators' existence checks would have left unknown names unreported and contracts quietly missing constraints. Each universe is built from what its target actually recognises: SQL folds in the composed scalar descriptors and the field presets that are legal in type position, Mongo its scalar codec map. Both registries answer permissively for attribute names they do not own, because each interpreter already voices its own unsupported-attribute diagnostics and a second complaint would duplicate them. Mongo keeps PSL_UNSUPPORTED_FIELD_TYPE as the authority on type names it recognises but cannot store, so it takes the binder's other reference classes and leaves that one alone. Two corrections follow, both intended: an unqualified reference no longer finds a model in a sibling namespace, and it now says so instead of dropping the relation; and the interpreter's own missing-@@base-target diagnostic gives way to the binder's. Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- .../contract-psl/src/interpreter.ts | 40 ++++++- .../contract-psl/src/mongo-attribute-specs.ts | 63 ++++++++++- .../test/interpreter.polymorphism.test.ts | 4 +- .../contract-psl/test/interpreter.test.ts | 15 ++- .../contract-psl/src/interpreter.ts | 43 ++++++-- .../contract-psl/src/psl-column-resolution.ts | 3 + .../contract-psl/src/psl-field-resolution.ts | 20 ++++ .../src/psl-relation-resolution.ts | 20 +--- .../contract-psl/src/sql-attribute-specs.ts | 104 +++++++++++++++++- .../test/interpreter.diagnostics.test.ts | 8 +- .../test/interpreter.namespaces.test.ts | 24 ++-- .../test/interpreter.polymorphism.test.ts | 7 +- .../test/interpreter.relations.test.ts | 10 +- .../test/provider.interpret.test.ts | 2 + .../test/semantic-diagnostics.test.ts | 2 + .../test/sql-attribute-specs.test.ts | 2 + .../contract-psl/test/ts-psl-parity.test.ts | 2 +- 17 files changed, 303 insertions(+), 66 deletions(-) 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 20b0b2d0bfd1..c4a95b41b4d7 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) { @@ -148,6 +151,7 @@ function reportUnknownAttributes(input: { readonly models: readonly ModelSymbol[]; readonly compositeTypes: readonly CompositeTypeSymbol[]; readonly sources: PslSources; + readonly binder: Binder; readonly diagnostics: PslDiagnosticCollector; }): void { const { sources, diagnostics } = input; @@ -213,9 +217,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'); @@ -227,6 +232,7 @@ function resolveFieldMappings(input: { model, field, sources, + binder, diagnostics, })?.name : undefined) ?? field.name; @@ -239,9 +245,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({ @@ -249,6 +256,7 @@ function resolveCollectionName(input: { spec: mongoAttributeSpecs.model.map(specContext), model, sources, + binder, diagnostics, })?.name : undefined; @@ -286,6 +294,7 @@ function collectPolymorphismDeclarations( specContextFor: (model: ModelSymbol) => AttributeSpecContext, modelMetadataByName: ReadonlyMap, sources: PslSources, + binder: Binder, diagnostics: PslDiagnosticCollector, ): { discriminatorDeclarations: Map; @@ -303,6 +312,7 @@ function collectPolymorphismDeclarations( spec: mongoAttributeSpecs.model.discriminator(specContext), model, sources, + binder, diagnostics, }); if (parsed) { @@ -331,6 +341,7 @@ function collectPolymorphismDeclarations( spec: mongoAttributeSpecs.model.base(specContext), model, sources, + binder, diagnostics, }); if (parsed) { @@ -867,6 +878,7 @@ function collectIndexes( fieldMappings: FieldMappings, modelNames: ReadonlySet, sources: PslSources, + binder: Binder, diagnostics: PslDiagnosticCollector, indexSpans: Map, indexSources: Map, @@ -888,6 +900,7 @@ function collectIndexes( model: pslModel, field, sources, + binder, diagnostics, }); if (unique === undefined) continue; @@ -923,6 +936,7 @@ function collectIndexes( spec: mongoAttributeSpecs.model.textIndex(specContext), model: pslModel, sources, + binder, diagnostics, }); if (!parsed || parsed.fields.length === 0) continue; @@ -945,6 +959,7 @@ function collectIndexes( : mongoAttributeSpecs.model.index(specContext), model: pslModel, sources, + binder, diagnostics, }); if (!parsed) continue; @@ -1033,6 +1048,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; @@ -1090,10 +1106,21 @@ 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); @@ -1104,6 +1131,7 @@ export function interpretPslDocumentToMongoContract( models: allModels, compositeTypes: allCompositeTypes, sources, + binder, diagnostics, }); const specContextFor = (model: ModelSymbol): AttributeSpecContext => ({ @@ -1119,12 +1147,14 @@ export function interpretPslDocumentToMongoContract( model, specContext, sources, + binder, diagnostics, }), fieldMappings: resolveFieldMappings({ model, specContext, sources, + binder, diagnostics, }), }); @@ -1135,6 +1165,7 @@ export function interpretPslDocumentToMongoContract( const builtEnums = processEnumDeclarations({ enumSymbols: topLevelEnumSymbols, sources, + binder, authoringContributions: input.authoringContributions, entityContext: { family: 'mongo', @@ -1195,8 +1226,8 @@ export function interpretPslDocumentToMongoContract( model: pslModel, field, sources, + binder, diagnostics, - resolveReferencedModel: () => allModels.find((m) => m.name === field.typeName), }) : undefined; @@ -1284,6 +1315,7 @@ export function interpretPslDocumentToMongoContract( model: pslModel, field, sources, + binder, diagnostics, }) !== undefined ); @@ -1326,6 +1358,7 @@ export function interpretPslDocumentToMongoContract( fieldMappings, modelNames, sources, + binder, diagnostics, indexSpans, indexSources, @@ -1431,6 +1464,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 fe614533bae8..16c99d36bf3f 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,14 @@ +import type { + AuthoringContributions, + AuthoringTypeConstructorDescriptor, +} from '@internal/framework-components/authoring'; +import type { ControlDefaultRegistries } from '@internal/framework-components/control'; import type { ArgType, AttributeSpec, AttributeSpecContext, AttributeSpecNamespace, + Binder, FieldAttributeCtx, FieldAttributeSpecContext, FieldSymbol, @@ -10,10 +16,13 @@ import type { InferAttr, ModelAttributeCtx, ModelSymbol, + PslDiagnostic, + SymbolTable, TypedFuncCall, } from '@internal/psl-parser'; import { bool, + createBinder, entityRef, fieldAttribute, fieldRef, @@ -57,10 +66,12 @@ export function findFieldAttributeNode( function buildModelAttributeCtx(input: { readonly selfModel: ModelSymbol; readonly sources: PslSources; + readonly binder: Binder; }): ModelAttributeCtx { return { sources: input.sources, selfModel: input.selfModel, + binder: input.binder, }; } @@ -68,16 +79,58 @@ 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, }; } +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 } }; + } + const noReferences = { positional: [], named: {} }; + return createBinder({ + sources: input.sources, + symbolTable: input.symbolTable, + typeConstructors: { ...scalars, ...(input.authoringContributions?.type ?? {}) }, + attributeSpecs: { + model: (name, owner) => { + if (!Object.hasOwn(mongoAttributeSpecs.model, name) || owner.kind !== 'model') { + return noReferences; + } + return mongoAttributeSpecs.model[name as keyof typeof mongoAttributeSpecs.model]({ + symbols: input.symbolTable, + model: owner, + controlMutationDefaults: input.controlMutationDefaults, + }); + }, + field: (name, owner, declaringField) => { + if (!Object.hasOwn(mongoAttributeSpecs.field, name) || owner.kind !== 'model') { + return noReferences; + } + return mongoAttributeSpecs.field[name as keyof typeof mongoAttributeSpecs.field]({ + symbols: input.symbolTable, + model: owner, + field: declaringField, + controlMutationDefaults: input.controlMutationDefaults, + }); + }, + }, + }); +} + // 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. @@ -86,6 +139,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( @@ -94,6 +148,7 @@ export function interpretModelAttribute(input: { buildModelAttributeCtx({ selfModel: input.model, sources: input.sources, + binder: input.binder, }), ); if (!result.ok) { @@ -112,8 +167,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, @@ -122,7 +177,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.polymorphism.test.ts b/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts index 56740d0d35e6..8b396174b0e0 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 @@ -305,8 +305,8 @@ describe('interpretPslDocumentToMongoContract — polymorphism', () => { 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'), }), ]), ); 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 78723d04cf84..1e78eaff0b05 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 @@ -716,7 +716,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"'), + }), + ]), + ); }); }); @@ -1906,9 +1915,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-sql/2-authoring/contract-psl/src/interpreter.ts b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts index 99291dfb0caa..561908344388 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts @@ -43,6 +43,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, @@ -88,7 +89,6 @@ import { ifDefined } from '@internal/utils/defined'; import { InternalError } from '@internal/utils/internal-error'; import { notOk, ok, type Result } from '@internal/utils/result'; import { contractError } from './contract-errors'; - import { getAttribute, getNamedArgument, mapFieldNamesToColumns } from './psl-attribute-parsing'; import type { ColumnDescriptor } from './psl-column-resolution'; import { @@ -116,6 +116,7 @@ import { validateBackrelationFieldAttributes, } from './psl-relation-resolution'; import { + createSqlBinder, findModelAttributeNode, interpretModelAttribute, PSL_CHECK_ON_STI_VARIANT, @@ -273,6 +274,7 @@ function validateNamespaceBlocksForSqlTarget(input: { readonly targetId: string; readonly source: DiagnosticSource; readonly sources: PslSources; + readonly binder: Binder; readonly diagnostics: PslDiagnosticCollector; }): void { if (input.targetId === 'sqlite') { @@ -640,6 +642,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. */ @@ -751,6 +754,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, @@ -800,6 +804,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult modelName: model.name, field, sources: input.sources, + binder: input.binder, composedExtensions: input.composedExtensions, authoringContributions: input.authoringContributions, diagnostics, @@ -813,6 +818,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult field, symbols: input.symbolTable, sources: input.sources, + binder: input.binder, diagnostics, }); if (!parsedRelation) { @@ -932,6 +938,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult spec: sqlAttributeSpecs.model.control(), model, sources: input.sources, + binder: input.binder, diagnostics, }); if (parsed !== undefined) { @@ -967,6 +974,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult spec: sqlAttributeSpecs.model.id(), model, sources: input.sources, + binder: input.binder, diagnostics, }); if (parsed === undefined) { @@ -1011,6 +1019,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult spec: sqlAttributeSpecs.model.unique(), model, sources: input.sources, + binder: input.binder, diagnostics, }); if (parsed === undefined) { @@ -1044,6 +1053,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult spec: sqlAttributeSpecs.model.index(), model, sources: input.sources, + binder: input.binder, diagnostics, }); if (parsed === undefined) { @@ -1103,6 +1113,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult spec: sqlAttributeSpecs.model.check(), model, sources: input.sources, + binder: input.binder, diagnostics, }); if (parsed === undefined) { @@ -1149,6 +1160,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult }), model, sources: input.sources, + binder: input.binder, diagnostics, }); if (parsed === undefined) { @@ -1240,6 +1252,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult field: relationAttribute.field, symbols: input.symbolTable, sources: input.sources, + binder: input.binder, diagnostics, }); if (!parsedRelation) { @@ -1399,6 +1412,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult field: relationAttribute.field, symbols: input.symbolTable, sources: input.sources, + binder: input.binder, diagnostics, }); if (!parsedRelation) { @@ -1558,6 +1572,7 @@ interface BuildValueObjectsInput { readonly authoringContributions: AuthoringContributions | undefined; readonly diagnostics: PslDiagnosticCollector; readonly sources: PslSources; + readonly binder: Binder; } function buildValueObjects(input: BuildValueObjectsInput): Record { @@ -1677,6 +1692,7 @@ type BaseDeclaration = { function collectPolymorphismDeclarations( models: readonly ModelSymbol[], sources: PslSources, + binder: Binder, diagnostics: PslDiagnosticCollector, ): { discriminatorDeclarations: Map; @@ -1694,6 +1710,7 @@ function collectPolymorphismDeclarations( spec: sqlAttributeSpecs.model.discriminator(), model, sources, + binder, diagnostics, }); if (parsed !== undefined) { @@ -1718,6 +1735,7 @@ function collectPolymorphismDeclarations( spec: sqlAttributeSpecs.model.base(), model, sources, + binder, diagnostics, }); if (parsed !== undefined) { @@ -1813,14 +1831,7 @@ function resolvePolymorphism( } for (const [variantName, baseDecl] of baseDeclarations) { - if (!modelNames.has(baseDecl.baseName)) { - diagnostics.push({ - code: 'PSL_BASE_TARGET_NOT_FOUND', - message: `Model "${variantName}" @@base references non-existent model "${baseDecl.baseName}"`, - ...baseDecl.source.at(baseDecl.span), - }); - continue; - } + if (!modelNames.has(baseDecl.baseName)) continue; if (!discriminatorDeclarations.has(baseDecl.baseName)) { diagnostics.push({ @@ -2054,6 +2065,14 @@ export function interpretPslDocumentToSqlContract( ): Result { const source = diagnosticSource(input.sources, input.document.syntax); const diagnostics = createPslDiagnosticCollector(input.sources); + const { binder, diagnostics: binderDiagnostics } = createSqlBinder({ + symbolTable: input.symbolTable, + sources: input.sources, + authoringContributions: input.authoringContributions, + controlMutationDefaults: input.controlMutationDefaults, + scalarColumnDescriptors: input.scalarColumnDescriptors, + }); + diagnostics.push(...binderDiagnostics); if (!input.target) { diagnostics.pushUnlocated({ code: 'PSL_TARGET_CONTEXT_REQUIRED', @@ -2084,6 +2103,7 @@ export function interpretPslDocumentToSqlContract( targetId: input.target.targetId, source, sources: input.sources, + binder, diagnostics, }); validateBlockModelAttributeRequirements({ @@ -2268,6 +2288,7 @@ export function interpretPslDocumentToSqlContract( defaultNamespaceId, createPslDiagnosticCollector(input.sources), input.sources, + binder, ); const composedPslBlockDescriptors = input.authoringContributions?.pslBlockDescriptors ?? {}; const namespaceExtensionEntities = new Map< @@ -2418,6 +2439,7 @@ export function interpretPslDocumentToSqlContract( defaultNamespaceId, diagnostics, input.sources, + binder, ); // Bare-name view for unqualified relation targets and polymorphism, where // resolution is by bare model name. When a bare name is shared across @@ -2468,6 +2490,7 @@ export function interpretPslDocumentToSqlContract( generatorDescriptorById, scalarColumnDescriptors: input.scalarColumnDescriptors, sources: input.sources, + binder, symbolTable: input.symbolTable, diagnostics, modelNamespaceIds, @@ -2543,6 +2566,7 @@ export function interpretPslDocumentToSqlContract( const { discriminatorDeclarations, baseDeclarations } = collectPolymorphismDeclarations( models, input.sources, + binder, diagnostics, ); @@ -2604,6 +2628,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 46bc3c3527c1..0500ecaaaa02 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 @@ -30,6 +30,7 @@ import { type MutationDefaultGeneratorDescriptor, } from '@internal/framework-components/control'; import type { + Binder, FieldSymbol, ModelSymbol, NumLiteral, @@ -750,6 +751,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; @@ -780,6 +782,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 49ccc60e450c..705b8cf63b12 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 @@ -11,6 +11,7 @@ import type { MutationDefaultGeneratorDescriptor, } from '@internal/framework-components/control'; import type { + Binder, FieldSymbol, ModelSymbol, ResolvedAttribute, @@ -56,6 +57,7 @@ function lowerEnumDefaultForField(input: { readonly model: ModelSymbol; readonly symbolTable: SymbolTable; readonly sources: PslSources; + readonly binder: Binder; readonly enumHandle: EnumTypeHandle; readonly defaultFunctionRegistry: ControlMutationDefaultRegistry; readonly defaultLiteralTagRegistry: ControlDefaultLiteralTagRegistry; @@ -82,6 +84,7 @@ function lowerEnumDefaultForField(input: { model, field, sources: input.sources, + binder: input.binder, diagnostics, }); if (interpreted === undefined) return {}; @@ -165,6 +168,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; @@ -221,6 +225,7 @@ function validateFieldAttributes(input: { readonly authoringContributions: AuthoringContributions | undefined; readonly diagnostics: PslDiagnosticCollector; readonly sources: PslSources; + readonly binder: Binder; readonly familyId: string; readonly targetId: string; }): void { @@ -273,6 +278,7 @@ function extractFieldConstraintNames(input: { readonly model: ModelSymbol; readonly field: FieldSymbol; readonly sources: PslSources; + readonly binder: Binder; readonly diagnostics: PslDiagnosticCollector; }): { readonly idAttribute: ResolvedAttribute | undefined; @@ -292,6 +298,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'); @@ -304,6 +311,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 }; @@ -324,6 +332,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; @@ -336,6 +345,7 @@ function lowerNoCheckForField(input: { model: input.model, field: input.field, sources: input.sources, + binder: input.binder, diagnostics: input.diagnostics, }); if (interpreted === undefined) return undefined; @@ -388,6 +398,7 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv compositeTypeNames, composedExtensions, authoringContributions, + binder, familyId, targetId, defaultFunctionRegistry, @@ -436,6 +447,7 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv authoringContributions, diagnostics, sources, + binder, familyId, targetId, }); @@ -466,6 +478,7 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv let presetContributions: FieldPresetContributions | undefined; const resolveInput = { field, + binder, enumTypeDescriptors, namedTypeDescriptors, scalarColumnDescriptors, @@ -572,6 +585,7 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv model, symbolTable, sources: input.sources, + binder: input.binder, enumHandle, defaultFunctionRegistry, defaultLiteralTagRegistry, @@ -584,6 +598,7 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv model, symbolTable, sources: input.sources, + binder: input.binder, columnDescriptor: descriptor, generatorDescriptorById, defaultFunctionRegistry, @@ -631,6 +646,7 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv model, field, sources: input.sources, + binder: input.binder, diagnostics, }); let isIdField = Boolean(idAttribute); @@ -676,6 +692,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 @@ -711,6 +728,7 @@ export function buildModelMappings( defaultNamespaceId: string, diagnostics: PslDiagnosticCollector, sources: PslSources, + binder: Binder, ): Map { const result = new Map(); for (const { model, namespaceId } of modelEntries) { @@ -723,6 +741,7 @@ export function buildModelMappings( spec: sqlAttributeSpecs.model.map(), model, sources, + binder, diagnostics, })?.name ?? defaultTableName(model.name)); const fieldColumns = new Map(); @@ -736,6 +755,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 1f456f43353f..eb1e2d72acd3 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'); @@ -101,8 +88,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), }); } @@ -494,6 +481,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 d24523b8793c..c378048946d5 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,10 @@ +import type { + AuthoringContributions, + AuthoringFieldNamespace, + 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,6 +13,9 @@ import type { AttributeSpec, AttributeSpecContext, AttributeSpecNamespace, + AttributeSpecRegistry, + AttributeSpecView, + Binder, FieldAttributeCtx, FieldAttributeSpecContext, FieldSymbol, @@ -23,6 +33,7 @@ import type { } from '@internal/psl-parser'; import { bool, + createBinder, diagnosticSource, entityRef, fieldAttribute, @@ -75,10 +86,12 @@ export function findFieldAttributeNode( function buildModelAttributeCtx(input: { readonly selfModel: ModelSymbol; readonly sources: PslSources; + readonly binder: Binder; }): ModelAttributeCtx { return { sources: input.sources, selfModel: input.selfModel, + binder: input.binder, }; } @@ -86,14 +99,95 @@ 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, + }; +} + +export function sqlBinderAttributeSpecs(input: { + readonly symbolTable: SymbolTable; + readonly controlMutationDefaults?: ControlDefaultRegistries | undefined; +}): AttributeSpecRegistry { + const model: Readonly> = { + map: mapModelSpec, + id: idModelSpec, + unique: uniqueModelSpec, + index: indexModelSpec, + check: checkModelSpec, + control: controlModelSpec, + discriminator: discriminatorModelSpec, + base: baseModelSpec, + }; + const field: Readonly> = { + map: mapFieldSpec, + id: idFieldSpec, + unique: uniqueFieldSpec, + noCheck: noCheckFieldSpec, + relation: relationFieldSpec, }; + const { controlMutationDefaults } = input; + const noReferences: AttributeSpecView = { positional: [], named: {} }; + return { + model: (name) => (Object.hasOwn(model, name) ? model[name] : noReferences), + field: (name, owner, declaringField) => { + if (Object.hasOwn(field, name)) return field[name]; + if (name === 'default' && controlMutationDefaults !== undefined && owner.kind === 'model') { + return sqlAttributeSpecs.field.default( + fieldSpecContext({ + symbols: input.symbolTable, + model: owner, + field: declaringField, + controlMutationDefaults, + }), + ); + } + return noReferences; + }, + }; +} + +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 createSqlBinder(input: { + readonly symbolTable: SymbolTable; + readonly sources: PslSources; + readonly authoringContributions?: AuthoringContributions | undefined; + readonly controlMutationDefaults?: ControlDefaultRegistries | undefined; + readonly scalarColumnDescriptors?: ReadonlyMap | 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: sqlBinderAttributeSpecs({ + symbolTable: input.symbolTable, + controlMutationDefaults: input.controlMutationDefaults, + }), + }); } // Interpret a model-level attribute node against its spec, draining any parse @@ -104,6 +198,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( @@ -112,6 +207,7 @@ export function interpretModelAttribute(input: { buildModelAttributeCtx({ selfModel: input.model, sources: input.sources, + binder: input.binder, }), ); if (!result.ok) { @@ -130,8 +226,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, @@ -140,7 +236,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 abcd3e6d7c0f..f558e84aff6a 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"', }, ); }); @@ -824,8 +824,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.namespaces.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.namespaces.test.ts index e87c7f9f5aee..cf482f5e8d0e 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 @@ -178,7 +178,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]) } } `, @@ -199,7 +199,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 { @@ -221,16 +221,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 e5bf5f81e2ce..27a4fd16599b 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 @@ -757,8 +757,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'), }), ]), ); @@ -856,7 +856,8 @@ model Bug { expect(result.failure.diagnostics).toEqual( expect.arrayContaining([ expect.objectContaining({ - code: 'PSL_BASE_TARGET_NOT_FOUND', + 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 e2f7e9180306..fb3bff6e0806 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 @@ -688,8 +688,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"'), }), ]), ); @@ -722,8 +722,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 f4931c26d14d..20d05c87a3b9 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 { createPostgresTestContext, postgresTarget, testEnumPslBlockDescriptor } from './fixtures'; const baseOptions = { @@ -200,6 +201,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 8c85f26891eb..44de5479191b 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 { createPostgresTestContext } from './fixtures'; it('pushes owned default diagnostics with filename and range rather than a provider envelope', () => { @@ -32,6 +33,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 9b6cb9b8a391..98c92cdfeb1f 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, interpretFieldAttribute, @@ -109,6 +110,7 @@ function interpretDefault(schema: string, fieldName: string) { model, field: target, sources, + binder: createSqlBinder({ symbolTable, sources }).binder, diagnostics, }); return { value, diagnostics: diagnostics.toExternal() }; 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 28ac0555ce14..ec49ae2835de 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 @@ -394,7 +394,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") } `, From 93178eb232d3973f97363bc50a38c8ca97290683 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 18 Sep 2026 19:55:21 +0000 Subject: [PATCH 16/66] fix(mongo,psl-parser): leave one voice per resolution failure in Mongo Mongo adopted the binder's reference classes without giving up the emissions they replace, so a missing @@base target drew two complaints. Its PSL_BASE_TARGET_NOT_FOUND now goes the way SQL's did, leaving the binder's entity voice alone. An unknown @@index field drew two as well, by a route the earlier survey had judged unreachable: Mongo builds its index element as oneOf(fieldRef(), ...) through a separately assembled arm list. A reference argument that fails silently inside oneOf now propagates that silence instead of drawing "Expected one of" over it, while an alternative that fails loudly still speaks and a later alternative can still match what an earlier one refused. The affected assertions used containment, which passes when an extra diagnostic appears; the new tests pin the exact code list per schema, and record which second codes are consequences of a failure rather than second opinions about it. Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- .../src/attribute-spec/combinators/one-of.ts | 3 + ...attribute-spec-failure-propagation.test.ts | 65 +++++++++++++ .../contract-psl/src/interpreter.ts | 9 +- .../test/interpreter-test-helpers.ts | 16 ++++ .../test/interpreter.polymorphism.test.ts | 14 ++- .../test/interpreter.single-voice.test.ts | 92 +++++++++++++++++++ .../contract-psl/test/interpreter.test.ts | 11 ++- 7 files changed, 195 insertions(+), 15 deletions(-) create mode 100644 packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.single-voice.test.ts 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..c373e9b9bec4 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 @@ -22,6 +22,7 @@ export function oneOf( label, alternatives: alts, parse: (arg, ctx): Result, readonly PslDiagnostic[]> => { + let silent = false; for (const alt of alts) { const parse = blindCast< (arg: Parameters[0], ctx: ParseContext) => ReturnType, @@ -36,7 +37,9 @@ export function oneOf( >(result.value), ); } + if (result.failure.length === 0) silent = true; } + if (silent) return notOk([]); return notOk([leafDiagnostic(ctx, arg, `Expected one of: ${label}`)]); }, } satisfies OneOfArgType; 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 index c160f826311a..d15b7ad865f4 100644 --- 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 @@ -1,6 +1,7 @@ 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'; @@ -126,3 +127,67 @@ describe('ModelSymbol fixture sanity', () => { 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/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts b/packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts index c4a95b41b4d7..06e56c82786d 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 @@ -443,14 +443,7 @@ function resolvePolymorphism(input: { } for (const [variantName, baseDecl] of baseDeclarations) { - if (!modelNames.has(baseDecl.baseName)) { - diagnostics.push({ - code: 'PSL_BASE_TARGET_NOT_FOUND', - message: `Model "${variantName}" @@base references non-existent model "${baseDecl.baseName}"`, - ...baseDecl.source.at(baseDecl.span), - }); - continue; - } + if (!modelNames.has(baseDecl.baseName)) continue; if (!discriminatorDeclarations.has(baseDecl.baseName)) { diagnostics.push({ 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 8b396174b0e0..60c537476057 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'], @@ -382,7 +385,12 @@ describe('interpretPslDocumentToMongoContract — polymorphism', () => { expect(result.ok).toBe(false); if (result.ok) return; expect(result.failure.diagnostics).toEqual( - expect.arrayContaining([expect.objectContaining({ code: 'PSL_BASE_TARGET_NOT_FOUND' })]), + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_UNRESOLVED_REFERENCE', + message: expect.stringContaining('Cannot find entity'), + }), + ]), ); }); @@ -677,7 +685,7 @@ describe('interpretPslDocumentToMongoContract — polymorphism', () => { } `); - 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..353e7844a97a --- /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): { + document: DocumentAst; + symbolTable: SymbolTable; + sources: PslSources; +} { + const { document, sources } = parse(schema, 'test.prisma'); + const { symbolTable } = buildSymbolTable({ + documents: [document], + sources, + pslBlockDescriptors: {}, + }); + return { 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(), + defaultLiteralTagRegistry: new Map(), + }, + }); + 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 1e78eaff0b05..56a6acd2024c 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, @@ -1859,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); }); @@ -1872,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', () => { @@ -1883,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', () => { From 955d1b0637cfc55a5c8f67f23c4ceeae0f5e0b35 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 18 Sep 2026 20:00:35 +0000 Subject: [PATCH 17/66] chore(drive): binder-core D6 artifacts through slice close Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- .../symbol-table-resolve/design-decisions.md | 16 ++++++- projects/symbol-table-resolve/plan.md | 2 + .../slices/binder-core/briefs/d6-r1.md | 43 +++++++++++++++++++ .../slices/binder-core/briefs/d6.ids.json | 12 ++++++ .../slices/binder-core/plan.md | 9 +++- .../slices/binder-core/spec.md | 11 ++--- projects/symbol-table-resolve/spec.md | 13 ++---- projects/symbol-table-resolve/trace.jsonl | 19 ++++++++ 8 files changed, 108 insertions(+), 17 deletions(-) create mode 100644 projects/symbol-table-resolve/slices/binder-core/briefs/d6-r1.md create mode 100644 projects/symbol-table-resolve/slices/binder-core/briefs/d6.ids.json diff --git a/projects/symbol-table-resolve/design-decisions.md b/projects/symbol-table-resolve/design-decisions.md index 9f39f70e073d..80eca9f19607 100644 --- a/projects/symbol-table-resolve/design-decisions.md +++ b/projects/symbol-table-resolve/design-decisions.md @@ -76,6 +76,20 @@ Outcome of the design discussion (2026-09-18) that preceded [`spec.md`](./spec.m **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. Open-question resolutions (2026-09-18) +## 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 with no binder read — retyping it would force genericizing `FuncCallSig` across every call site (Mongo nests `optional(entityRef())` in a `funcCall`); its former read was non-load-bearing (D5 review), and the binder still voices entity failures from phase 2. `oneOf` never wraps a reference combinator in any production spec (repo-verified), so its no-match fall-through needed no failure-awareness. + +**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. + +## 11. 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 universe symbols silently, with no diagnostic. diff --git a/projects/symbol-table-resolve/plan.md b/projects/symbol-table-resolve/plan.md index 74f3ba4e44bb..87a9e991fd81 100644 --- a/projects/symbol-table-resolve/plan.md +++ b/projects/symbol-table-resolve/plan.md @@ -50,6 +50,8 @@ Four slices: one foundation slice delivering the eager binder in `psl-parser` (w - 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/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 index ca7333979553..11402f612a90 100644 --- a/projects/symbol-table-resolve/slices/binder-core/plan.md +++ b/projects/symbol-table-resolve/slices/binder-core/plan.md @@ -37,4 +37,11 @@ Slice spec: `projects/symbol-table-resolve/slices/binder-core/spec.md`. Branch: - **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. -Sizes: D1 S, D2 L, D3 M, D4 S, D5 M. Sequential; no parallel-within-slice. +### 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 index d88201febc0a..c0574fe69d94 100644 --- a/projects/symbol-table-resolve/slices/binder-core/spec.md +++ b/projects/symbol-table-resolve/slices/binder-core/spec.md @@ -15,7 +15,7 @@ Normative pseudo-code and API live in the parent spec — `projects/symbol-table - **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):** the parse-time `AttributeCtx` carries the whole `Binder` as an optional member, populated by the context builders; the reference combinators (`fieldRef`, `referencedFieldRef`, `entityRef`) consume the binder's already-computed resolutions via `symbolForNode` (one map read, never a re-resolution) and, when the binder is present, emit **no** resolution diagnostics of their own — the binder's returned diagnostics are the sole voice (parent spec, cross-cutting requirement 2). Without a binder, legacy behavior is byte-for-byte unchanged, preserving the transitional constraint for unconverted consumers. Without this, a converted consumer would report each resolution failure twice — once from the binder, once from the combinator's own existence check. +- **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 @@ -25,7 +25,7 @@ One package, one subject: the binder and the node-identity substrate it requires **In:** `packages/1-framework/2-authoring/psl-parser` — `src/syntax/red.ts`, new binder + universe-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:** any change to SQL/Mongo interpreters, language server, or `contract-prisma7` (later slices / non-goals); laziness or cross-snapshot memo retention; incremental reparse; new LSP features. +**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 @@ -40,9 +40,10 @@ One package, one subject: the binder and the node-identity substrate it requires ## Slice-specific done conditions -- [ ] Diff touches only `psl-parser` and `projects/symbol-table-resolve/` (verifiable via `git diff --stat` against `origin/multifiile-psl`). -- [ ] The attribute-ctx helper is exported and covered by a test demonstrating `resolveReferencedModel` as a binder map read. -- [ ] With a binder-backed context, a resolution failure inside an attribute argument yields exactly one diagnostic — the binder's; the combinators emit none. Without a binder, the legacy path is pinned unchanged by the existing suite. +- [ ] Diff touches only `psl-parser`, `projects/symbol-table-resolve/`, and (from D6) the attribute-context construction sites in `2-sql/2-authoring/contract-psl`, `2-mongo-family/2-authoring/contract-psl`, and `language-server` — 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. +- [ ] The attribute-ctx builders are exported and covered by tests; contexts carry the required binder (decision 10 — `resolveReferencedModel` no longer exists). +- [ ] 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 diff --git a/projects/symbol-table-resolve/spec.md b/projects/symbol-table-resolve/spec.md index 6ef5dcb641f3..6e16747e0b53 100644 --- a/projects/symbol-table-resolve/spec.md +++ b/projects/symbol-table-resolve/spec.md @@ -85,14 +85,7 @@ function createBinder({ sources, symbolTable, typeConstructors, attributeSpecs } } ``` -Consumer wiring: the attribute-spec parse-time context keeps its ADR 249 shape, but `resolveReferencedModel` stops being a consumer-supplied callback with four implementations and becomes one map read: - -```ts -const ctx: FieldAttributeCtx = { - ..., - resolveReferencedModel: () => modelOf(binder.symbolForNode(typeNode(field))), -}; -``` +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. @@ -128,7 +121,7 @@ Interpreters still run spec interpretation for argument **values**; the binder o - Work stacks on the unmerged PR #30335 and lands after it; no cherry-picking its content into `main` independently. - 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 unmodified at every intermediate state; the binder is additive until a conversion slice claims its consumer. +- 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 @@ -137,7 +130,7 @@ Interpreters still run spec interpretation for argument **values**; the binder o - [ ] 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. -- [ ] The attribute-spec contexts' `resolveReferencedModel` is served by the binder; consumer-supplied resolution callbacks are gone. +- [ ] `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. diff --git a/projects/symbol-table-resolve/trace.jsonl b/projects/symbol-table-resolve/trace.jsonl index 6a1199a5f58c..6b748455a383 100644 --- a/projects/symbol-table-resolve/trace.jsonl +++ b/projects/symbol-table-resolve/trace.jsonl @@ -47,3 +47,22 @@ {"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} From 461e5111f1a3adb4da3ab52e070493c20f5b02a2 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Mon, 21 Sep 2026 10:11:26 +0000 Subject: [PATCH 18/66] refactor(psl-parser): name the reason oneOf can withhold its own diagnostic The boolean said what happened, not why. An alternative that fails carrying no diagnostics has been reported by another voice already, so oneOf must not paint "Expected one of" over it; collecting the rejections and asking alreadyVoicedElsewhere says that where it happens. Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- .../src/attribute-spec/combinators/one-of.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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 c373e9b9bec4..b8af9b906878 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 @@ -11,6 +11,10 @@ import type { } from '../types'; import { leafDiagnostic } from './diagnostic'; +function alreadyVoicedElsewhere(rejection: readonly PslDiagnostic[]): boolean { + return rejection.length === 0; +} + export function oneOf( ...alts: Alts ): OneOfArgType>>> { @@ -22,7 +26,7 @@ export function oneOf( label, alternatives: alts, parse: (arg, ctx): Result, readonly PslDiagnostic[]> => { - let silent = false; + const rejections: (readonly PslDiagnostic[])[] = []; for (const alt of alts) { const parse = blindCast< (arg: Parameters[0], ctx: ParseContext) => ReturnType, @@ -37,9 +41,9 @@ export function oneOf( >(result.value), ); } - if (result.failure.length === 0) silent = true; + rejections.push(result.failure); } - if (silent) return notOk([]); + if (rejections.some(alreadyVoicedElsewhere)) return notOk([]); return notOk([leafDiagnostic(ctx, arg, `Expected one of: ${label}`)]); }, } satisfies OneOfArgType; From 13392bb0dea1ecc66c57b8635b6bad5384ee9024 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Mon, 21 Sep 2026 10:11:35 +0000 Subject: [PATCH 19/66] refactor(mongo): look attribute-spec factories up without casting The spec tables already satisfy AttributeSpecNamespace, so a Record-typed view of each plus an Object.hasOwn guard narrows to a factory with no cast, and an unknown name lands on the same permissive result as before. Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- .../contract-psl/src/mongo-attribute-specs.ts | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) 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 16c99d36bf3f..132a56c4273f 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 @@ -11,10 +11,12 @@ import type { Binder, FieldAttributeCtx, FieldAttributeSpecContext, + FieldAttributeSpecFactory, FieldSymbol, FuncCallSig, InferAttr, ModelAttributeCtx, + ModelAttributeSpecFactory, ModelSymbol, PslDiagnostic, SymbolTable, @@ -101,26 +103,32 @@ export function createMongoBinder(input: { scalars[name] = { kind: 'typeConstructor', output: { codecId } }; } const noReferences = { positional: [], named: {} }; + const modelSpecFactories: Readonly> = + mongoAttributeSpecs.model; + const fieldSpecFactories: Readonly> = + mongoAttributeSpecs.field; return createBinder({ sources: input.sources, symbolTable: input.symbolTable, typeConstructors: { ...scalars, ...(input.authoringContributions?.type ?? {}) }, attributeSpecs: { model: (name, owner) => { - if (!Object.hasOwn(mongoAttributeSpecs.model, name) || owner.kind !== 'model') { - return noReferences; - } - return mongoAttributeSpecs.model[name as keyof typeof mongoAttributeSpecs.model]({ + const factory = Object.hasOwn(modelSpecFactories, name) + ? modelSpecFactories[name] + : undefined; + if (factory === undefined || owner.kind !== 'model') return noReferences; + return factory({ symbols: input.symbolTable, model: owner, controlMutationDefaults: input.controlMutationDefaults, }); }, field: (name, owner, declaringField) => { - if (!Object.hasOwn(mongoAttributeSpecs.field, name) || owner.kind !== 'model') { - return noReferences; - } - return mongoAttributeSpecs.field[name as keyof typeof mongoAttributeSpecs.field]({ + const factory = Object.hasOwn(fieldSpecFactories, name) + ? fieldSpecFactories[name] + : undefined; + if (factory === undefined || owner.kind !== 'model') return noReferences; + return factory({ symbols: input.symbolTable, model: owner, field: declaringField, From a9ca3656c1b6e0f65bdb4b5d8faadb2305ea4584 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Mon, 21 Sep 2026 10:12:27 +0000 Subject: [PATCH 20/66] refactor(psl-parser): give the attribute-context inputs a shared base A field context is not a model context with an extra ingredient; both are built from the same binder, sources and declaring model, and the field one adds the field. Naming that base says so, and the builder signatures are unchanged. Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- .../2-authoring/psl-parser/src/binder-context.ts | 6 ++++-- .../1-framework/2-authoring/psl-parser/src/exports/index.ts | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/1-framework/2-authoring/psl-parser/src/binder-context.ts b/packages/1-framework/2-authoring/psl-parser/src/binder-context.ts index 91a5a50a3916..d5dc139a98f4 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/binder-context.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/binder-context.ts @@ -10,13 +10,15 @@ export function referencedModel(binder: Binder, field: FieldSymbol): ModelSymbol return resolution?.kind === 'model' ? resolution.symbol : undefined; } -export interface ModelAttributeContextInput { +export interface AttributeContextInput { readonly binder: Binder; readonly sources: PslSources; readonly model: ModelSymbol; } -export interface FieldAttributeContextInput extends ModelAttributeContextInput { +export type ModelAttributeContextInput = AttributeContextInput; + +export interface FieldAttributeContextInput extends AttributeContextInput { readonly field: FieldSymbol; } 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 3a51da54aa36..3979d84217a0 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 @@ -110,6 +110,7 @@ export { typeReferenceNode, } from '../binder'; export type { + AttributeContextInput, FieldAttributeContextInput, ModelAttributeContextInput, } from '../binder-context'; From f516a4e0621dd30f3f7000425955fac84b0e96c2 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Mon, 21 Sep 2026 10:13:56 +0000 Subject: [PATCH 21/66] perf(psl-parser): fill red child slots one index at a time Reaching for one child allocated a wrapper for every sibling beside it, so walking to a single field in a wide document built the whole row. Each slot is now filled on the access that needs it, with child offsets accumulated as far as they have been asked for, so repeated access stays a lookup and a full walk still costs one pass. Identity is unchanged - the same position keeps returning the same wrapper. Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- .../2-authoring/psl-parser/src/syntax/red.ts | 48 +++++++++++----- .../psl-parser/test/syntax/red.test.ts | 55 +++++++++++++++++++ 2 files changed, 90 insertions(+), 13 deletions(-) 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 ff2ca2e748f7..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 @@ -143,7 +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: ReadonlyArray | undefined; + #childSlots: (SyntaxElement | undefined)[] | undefined; + #childOffsets: number[] | undefined; constructor(green: GreenNode, offset: number, parent: SyntaxNode | undefined, index: number) { this.green = green; @@ -152,25 +153,42 @@ export class SyntaxNode { this.index = index; } - #slots(): ReadonlyArray { + #slots(): (SyntaxElement | undefined)[] { let slots = this.#childSlots; if (slots === undefined) { - const created: SyntaxElement[] = []; - let offset = this.offset; - let index = 0; - for (const child of this.green.children) { - created.push(wrapElement(child, offset, this, index)); - offset += elementTextLength(child); - index++; - } - slots = created; + 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 { - return this.#slots()[index]; + 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 { @@ -231,7 +249,11 @@ export class SyntaxNode { } *children(): Iterable { - yield* this.#slots(); + const count = this.green.children.length; + for (let index = 0; index < count; index++) { + const child = this.childAt(index); + if (child !== undefined) yield child; + } } *childNodes(): Iterable { 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 cd6fb2172beb..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,6 +1,7 @@ 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'; @@ -740,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); + }); +}); From 99fd8a7aa4f720e0095988b78e8ae8addc24122e Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Mon, 21 Sep 2026 10:20:16 +0000 Subject: [PATCH 22/66] fix(sql): let the uncomposed-namespace diagnostic speak alone A qualified type whose extension pack is not composed drew two complaints: the binder could not resolve pgvector.Vector, and the interpreter said the pgvector pack is missing and named the config key to add. Both describe the same fault and only one tells the author what to do, so the interpreter now drops the binder's reading of a name it is about to report itself. The binder's unresolved-type diagnostics carry the name that failed, so a consumer can recognise the question without reading the prose. Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- .../2-authoring/psl-parser/src/binder.ts | 2 +- .../psl-parser/test/binder.test.ts | 2 +- .../contract-psl/src/interpreter.ts | 29 ++++++++++++++++++- .../test/interpreter.diagnostics.test.ts | 18 ++++++++++++ 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/packages/1-framework/2-authoring/psl-parser/src/binder.ts b/packages/1-framework/2-authoring/psl-parser/src/binder.ts index cb95a774c464..30b27fc0df1f 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/binder.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/binder.ts @@ -155,7 +155,7 @@ export function createBinder(options: CreateBinderOptions): BinderResult { diagnostics.push({ code: PSL_UNRESOLVED_REFERENCE, message: `Cannot find type "${resolution.name}"`, - data: { reference: 'type' }, + data: { reference: 'type', name: resolution.name }, ...diagnosticSource(sources, node).at(), }); } 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 index 60cee035e28f..dae156172e96 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/binder.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/binder.test.ts @@ -384,7 +384,7 @@ describe('createBinder — diagnostics', () => { { code: 'PSL_UNRESOLVED_REFERENCE', message: 'Cannot find type "Dog"', - data: { reference: 'type' }, + data: { reference: 'type', name: 'Dog' }, filename: '1.psl', range: { start: { line: 1, character: 6 }, end: { line: 1, character: 9 } }, }, 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 561908344388..c9a227d039b4 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts @@ -2060,6 +2060,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 { @@ -2072,7 +2088,18 @@ export function interpretPslDocumentToSqlContract( controlMutationDefaults: input.controlMutationDefaults, scalarColumnDescriptors: input.scalarColumnDescriptors, }); - diagnostics.push(...binderDiagnostics); + const composedExtensionNames = new Set(input.composedExtensions ?? []); + diagnostics.push( + ...binderDiagnostics.filter( + (diagnostic) => + !voicedAsUncomposedNamespace(diagnostic, composedExtensionNames, { + ...(input.target === undefined + ? {} + : { familyId: 'sql', targetId: input.target.targetId }), + authoringContributions: input.authoringContributions, + }), + ), + ); if (!input.target) { diagnostics.pushUnlocated({ code: 'PSL_TARGET_CONTEXT_REQUIRED', 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 f558e84aff6a..38fb404e1179 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 @@ -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 { From e1eafbdb8c4d2fee4c37d54a2be7133c13050338 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Mon, 21 Sep 2026 11:59:02 +0000 Subject: [PATCH 23/66] refactor(psl-parser): say why a parse can fail without complaining, once The same boolean appeared at four aggregation points and was named after what happened rather than why it mattered. A child that fails carrying no diagnostics has been reported by another voice already - the binder - so the aggregate must fail without adding a complaint of its own. list, record and interpretArgs set the flag on any failure, which was redundant: a child that fails loudly pushes its diagnostics into the same array, so the count test already caught it. Narrowing each to the silent case makes the name true and leaves behaviour identical, and all four now read as one mechanism through a shared alreadyVoicedElsewhere. Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- .../src/attribute-spec/combinators/diagnostic.ts | 4 ++++ .../src/attribute-spec/combinators/list.ts | 8 ++++---- .../src/attribute-spec/combinators/one-of.ts | 12 ++++-------- .../src/attribute-spec/combinators/record.ts | 8 ++++---- .../psl-parser/src/attribute-spec/interpret.ts | 8 ++++---- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/diagnostic.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/diagnostic.ts index 7b43666db991..b29851cb2af7 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/diagnostic.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/diagnostic.ts @@ -18,3 +18,7 @@ export function leafDiagnostic( ...diagnosticSource(ctx.sources, node.syntax).at(nodePslSpan(node.syntax, ctx.sources)), }; } + +export function alreadyVoicedElsewhere(rejection: readonly PslDiagnostic[]): boolean { + return rejection.length === 0; +} 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 9729d279552e..e9fcd537f48f 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 @@ -2,7 +2,7 @@ import { notOk, ok, 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'; -import { leafDiagnostic } from './diagnostic'; +import { alreadyVoicedElsewhere, leafDiagnostic } from './diagnostic'; export interface ListOptions { readonly allowEmpty?: boolean; @@ -28,7 +28,7 @@ export function list( } const diagnostics: PslDiagnostic[] = []; const parsed: { node: ExpressionAst; value: T }[] = []; - let failed = false; + let voicedElsewhere = false; let count = 0; for (const element of literal.elements()) { count += 1; @@ -36,7 +36,7 @@ export function list( if (result.ok) { parsed.push({ node: element, value: result.value }); } else { - failed = true; + if (alreadyVoicedElsewhere(result.failure)) voicedElsewhere = true; diagnostics.push(...result.failure); } } @@ -50,7 +50,7 @@ export function list( else seen.add(value); } } - if (failed || diagnostics.length > 0) return notOk(diagnostics); + if (voicedElsewhere || diagnostics.length > 0) return notOk(diagnostics); 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 b8af9b906878..844329e50970 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 @@ -9,11 +9,7 @@ import type { OutOf, RequiredContextFor, } from '../types'; -import { leafDiagnostic } from './diagnostic'; - -function alreadyVoicedElsewhere(rejection: readonly PslDiagnostic[]): boolean { - return rejection.length === 0; -} +import { alreadyVoicedElsewhere, leafDiagnostic } from './diagnostic'; export function oneOf( ...alts: Alts @@ -26,7 +22,7 @@ export function oneOf( label, alternatives: alts, parse: (arg, ctx): Result, readonly PslDiagnostic[]> => { - const rejections: (readonly PslDiagnostic[])[] = []; + let voicedElsewhere = false; for (const alt of alts) { const parse = blindCast< (arg: Parameters[0], ctx: ParseContext) => ReturnType, @@ -41,9 +37,9 @@ export function oneOf( >(result.value), ); } - rejections.push(result.failure); + if (alreadyVoicedElsewhere(result.failure)) voicedElsewhere = true; } - if (rejections.some(alreadyVoicedElsewhere)) return notOk([]); + if (voicedElsewhere) 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 fee3ee010ad4..650a12b33d65 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 @@ -2,7 +2,7 @@ import { notOk, ok, type Result } from '@internal/utils/result'; import type { PslDiagnostic } from '../../diagnostic'; import { ObjectLiteralExprAst } from '../../syntax/ast/expressions'; import type { ArgType, AttributeCtx, RecordArgType } from '../types'; -import { leafDiagnostic } from './diagnostic'; +import { alreadyVoicedElsewhere, leafDiagnostic } from './diagnostic'; export function record(of: ArgType): RecordArgType { return { @@ -17,7 +17,7 @@ export function record(of: ArgType): Record const diagnostics: PslDiagnostic[] = []; const entries: [string, T][] = []; const keys = new Set(); - let failed = false; + let voicedElsewhere = false; for (const field of Array.from(literal.fields())) { const key = field.keyName(); if (key === undefined) { @@ -31,7 +31,7 @@ export function record(of: ArgType): Record } const parsed = of.parse(value, ctx); if (!parsed.ok) { - failed = true; + if (alreadyVoicedElsewhere(parsed.failure)) voicedElsewhere = true; diagnostics.push(...parsed.failure); continue; } @@ -42,7 +42,7 @@ export function record(of: ArgType): Record keys.add(key); entries.push([key, parsed.value]); } - if (failed || diagnostics.length > 0) return notOk(diagnostics); + if (voicedElsewhere || diagnostics.length > 0) return notOk(diagnostics); return ok(Object.fromEntries(entries)); }, }; 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 ed066c51b197..e3e7754305e4 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 @@ -6,7 +6,7 @@ import { nodePslSpan } from '../resolve'; import type { FieldAttributeAst, ModelAttributeAst } from '../syntax/ast/attributes'; import type { AttributeArgAst } from '../syntax/ast/expressions'; import type { SyntaxNode } from '../syntax/red'; -import { ATTRIBUTE_DIAGNOSTIC_CODE } from './combinators/diagnostic'; +import { ATTRIBUTE_DIAGNOSTIC_CODE, alreadyVoicedElsewhere } from './combinators/diagnostic'; import type { ArgType, AttributeCtx, @@ -38,7 +38,7 @@ export function interpretArgs( const seen = new Set(); let positionalSlot = 0; let reportedExcess = false; - let failed = false; + let voicedElsewhere = false; for (const arg of args) { const name = arg.name()?.name(); @@ -95,7 +95,7 @@ export function interpretArgs( seen.add(key); const result = parseArgValue(arg, param, ctx, diagnostics); if (result.ok) output[key] = result.value; - else failed = true; + else if (alreadyVoicedElsewhere(result.failure)) voicedElsewhere = true; } const finalized = new Set(); @@ -130,7 +130,7 @@ export function interpretArgs( finalizeAbsentKey(key, undefined, spec.named[key]?.type); } - if (failed || diagnostics.length > 0) { + if (voicedElsewhere || diagnostics.length > 0) { return notOk(diagnostics); } return ok(output); From 7b790f83179aaf73020216b077f866fb1e5a8b4f Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Mon, 21 Sep 2026 14:09:25 +0000 Subject: [PATCH 24/66] fix(psl-parser): make a binder from the wrong snapshot fail loudly The binder left the side table silent for a reference it could not resolve, so an absent entry meant either a schema mistake or a binder built over a different tree, and the combinators could not tell the two apart - the second case quietly skipped existence checking for the whole document. The binder now records an explicit unresolved entry for every reference it examines, including the referenced-field case that has no declaring field. An absent entry therefore has only one meaning left, and the reference combinators raise an internal error naming the same-snapshot precondition rather than proceeding. Resolution outcomes are unchanged: unresolved still parses to a wordless failure, cross-space and bound fields as before, and no diagnostic moved. A malformed type stays unexamined, which stays safe because no combinator ever reads a type node. Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- .../2-authoring/psl-parser/README.md | 6 ++- .../attribute-spec/combinators/field-ref.ts | 10 +++- .../2-authoring/psl-parser/src/binder.ts | 8 +-- .../test/attribute-spec-binder.test.ts | 30 +++++++++++ ...bute-spec-combinators.foreign-copy.test.ts | 5 +- .../psl-parser/test/binder.test.ts | 52 ++++++++++++++++++- 6 files changed, 96 insertions(+), 15 deletions(-) diff --git a/packages/1-framework/2-authoring/psl-parser/README.md b/packages/1-framework/2-authoring/psl-parser/README.md index 3741d670b2d8..68c696a827e5 100644 --- a/packages/1-framework/2-authoring/psl-parser/README.md +++ b/packages/1-framework/2-authoring/psl-parser/README.md @@ -122,7 +122,11 @@ Shape and arity stay the combinator's voice — "Expected a field name", "Expect This lookup rests on red-node identity (below): the combinator receives the very `SyntaxNode` the binder keyed its result under. -**Precondition.** 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 combinators trust the binder's answer rather than re-deriving it, so a binder from a different snapshot or from registries that disagree with the specs being interpreted will bind nothing for those nodes: every reference argument fails to parse while the binder's own diagnostics describe a different document. Build the binder and run interpretation over one snapshot. +**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 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 a6ec5f549a23..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,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 { ExpressionAst } from '../../syntax/ast/expressions'; @@ -23,8 +24,13 @@ function parseFieldName( return notOk([leafDiagnostic(ctx, arg, 'Expected a field name')]); } const resolution = ctx.binder.symbolForNode(arg.syntax); - if (resolution?.kind === 'field') return ok(resolution.symbol.name); - if (resolution?.kind === 'crossSpace') return ok(name); + 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.`, + ); + } + if (resolution.kind === 'field') return ok(resolution.symbol.name); + if (resolution.kind === 'crossSpace') return ok(name); return notOk([]); } diff --git a/packages/1-framework/2-authoring/psl-parser/src/binder.ts b/packages/1-framework/2-authoring/psl-parser/src/binder.ts index 30b27fc0df1f..485b17ae4b3d 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/binder.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/binder.ts @@ -264,13 +264,9 @@ function resolveOwnerField(name: string, node: SyntaxNode, ctx: BindContext): Re return { kind: 'unresolved', name }; } -function resolveReferencedField( - name: string, - node: SyntaxNode, - ctx: BindContext, -): Resolution | undefined { +function resolveReferencedField(name: string, node: SyntaxNode, ctx: BindContext): Resolution { const declaring = ctx.field; - if (declaring === undefined) return undefined; + 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); 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 index 9f33e84d39fb..006e0ec9c93c 100644 --- 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 @@ -261,3 +261,33 @@ describe('the binder is the only resolution path', () => { 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 = fieldAttributeContext({ + binder: first.binder, + sources: second.sources, + model: 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 = fieldAttributeContext({ binder, sources, model: 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 9d3d4fd87ad4..b68f11941ef5 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 @@ -89,10 +89,7 @@ describe('combinators dispatch on syntax kind, not on AST class identity', () => it('fieldRef dispatches on the syntax kind of a node from another module copy', () => { const { arg, ctx } = foreignArg('id'); - const result = fieldRef().parse(arg, ctx); - - expect(result.ok).toBe(false); - if (!result.ok) expect(result.failure).toEqual([]); + expect(() => fieldRef().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/binder.test.ts b/packages/1-framework/2-authoring/psl-parser/test/binder.test.ts index dae156172e96..cc7e39b170a4 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/binder.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/binder.test.ts @@ -845,7 +845,7 @@ describe('referencedFieldRef on a cross-space list', () => { }); describe('createBinder — reference slots the binder stays silent about', () => { - it('records nothing for a referencedFieldRef outside a field attribute', () => { + it('stays wordless for a referencedFieldRef outside a field attribute', () => { const { symbolTable, binder, diagnostics } = bind( 'model User {\n id Int\n @@borrowed([id])\n}', ); @@ -853,7 +853,10 @@ describe('createBinder — reference slots the binder stays silent about', () => const [node] = attributeNodes(user, 'borrowed'); expect(node).toBeDefined(); - expect(node === undefined ? undefined : binder.symbolForNode(node)).toBeUndefined(); + expect(node === undefined ? undefined : binder.symbolForNode(node)).toEqual({ + kind: 'unresolved', + name: 'id', + }); expect(diagnostics).toEqual([]); }); @@ -936,3 +939,48 @@ describe('binder diagnostics carry their reference class', () => { ]); }); }); + +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('records an unresolved entry for a referenced field with no declaring field', () => { + const { symbolTable, binder, diagnostics } = bind( + 'model User {\n id Int\n @@borrowed([id])\n}', + ); + const user = symbolTable.topLevel.models['User']!; + const [node] = attributeNodes(user, 'borrowed'); + + expect(node).toBeDefined(); + expect(node === undefined ? undefined : binder.symbolForNode(node)).toEqual({ + kind: 'unresolved', + name: 'id', + }); + expect(diagnostics).toEqual([]); + }); + + 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(); + }); +}); From 296aee71827099b09fefcd4d57872827b41ad9d6 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Mon, 21 Sep 2026 15:05:10 +0000 Subject: [PATCH 25/66] refactor(utils,psl-parser): accumulate parse failures by conjunction Three aggregation points each carried a boolean recording that some child had failed wordlessly, because failure could not survive being flattened into a diagnostics array. Result gains `and`: ok only when both sides are ok, and two failures keep both sides' diagnostics in order. Folding with it carries failure through the accumulation itself, so list, record and interpretArgs drop their flags and read their verdict off the fold. parseArgValue no longer writes into a caller's array on the side; it returns its result and interpretArgs folds it, which is what let the accumulator become the single account of what went wrong. oneOf keeps its own rule: it pools alternatives rather than conjoining them, so folding would surface every alternative's complaint in place of its summary and would lose the wordless propagation - the emptiness check it still needs now lives beside it. Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- .../0-foundation/utils/src/exports/result.ts | 2 +- .../0-foundation/utils/src/result.ts | 15 +++ .../attribute-spec/combinators/diagnostic.ts | 4 - .../src/attribute-spec/combinators/list.ts | 24 ++-- .../src/attribute-spec/combinators/one-of.ts | 6 +- .../src/attribute-spec/combinators/record.ts | 21 ++-- .../src/attribute-spec/interpret.ts | 106 +++++++++--------- 7 files changed, 97 insertions(+), 81 deletions(-) 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..2a8b6ad05ad4 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 } 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..b81e3035f439 100644 --- a/packages/1-framework/0-foundation/utils/src/result.ts +++ b/packages/1-framework/0-foundation/utils/src/result.ts @@ -145,3 +145,18 @@ const OK_VOID: Ok = ResultImpl.ok(undefined); export function okVoid(): Ok { return OK_VOID; } + +/** + * Conjunction of two results whose failures accumulate: ok only when both are + * ok, and two failures keep both sides' details in order. Values are dropped — + * callers that need them collect them as they go and read them back once the + * accumulated result is ok. + */ +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/2-authoring/psl-parser/src/attribute-spec/combinators/diagnostic.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/diagnostic.ts index b29851cb2af7..7b43666db991 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/diagnostic.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/diagnostic.ts @@ -18,7 +18,3 @@ export function leafDiagnostic( ...diagnosticSource(ctx.sources, node.syntax).at(nodePslSpan(node.syntax, ctx.sources)), }; } - -export function alreadyVoicedElsewhere(rejection: readonly PslDiagnostic[]): boolean { - return rejection.length === 0; -} 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 e9fcd537f48f..5a869fe15b79 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,8 +1,8 @@ -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'; -import { alreadyVoicedElsewhere, leafDiagnostic } from './diagnostic'; +import { leafDiagnostic } from './diagnostic'; export interface ListOptions { readonly allowEmpty?: boolean; @@ -26,31 +26,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 voicedElsewhere = false; + 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 { - if (alreadyVoicedElsewhere(result.failure)) voicedElsewhere = true; - diagnostics.push(...result.failure); - } + if (result.ok) parsed.push({ node: element, value: result.value }); + 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 (voicedElsewhere || 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 844329e50970..783549287ffa 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 @@ -9,7 +9,11 @@ import type { OutOf, RequiredContextFor, } from '../types'; -import { alreadyVoicedElsewhere, leafDiagnostic } from './diagnostic'; +import { leafDiagnostic } from './diagnostic'; + +function alreadyVoicedElsewhere(rejection: readonly PslDiagnostic[]): boolean { + return rejection.length === 0; +} export function oneOf( ...alts: Alts 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 650a12b33d65..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,8 +1,8 @@ -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'; -import { alreadyVoicedElsewhere, leafDiagnostic } from './diagnostic'; +import { leafDiagnostic } from './diagnostic'; export function record(of: ArgType): RecordArgType { return { @@ -14,35 +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 voicedElsewhere = false; + 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) { - if (alreadyVoicedElsewhere(parsed.failure)) voicedElsewhere = true; - 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 (voicedElsewhere || 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/interpret.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/interpret.ts index e3e7754305e4..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,12 +1,12 @@ 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'; import type { AttributeArgAst } from '../syntax/ast/expressions'; import type { SyntaxNode } from '../syntax/red'; -import { ATTRIBUTE_DIAGNOSTIC_CODE, alreadyVoicedElsewhere } from './combinators/diagnostic'; +import { ATTRIBUTE_DIAGNOSTIC_CODE } from './combinators/diagnostic'; import type { ArgType, AttributeCtx, @@ -32,13 +32,12 @@ export function interpretArgs( span: PslSpan, sourceNode: SyntaxNode, ): Result, readonly PslDiagnostic[]> { - const diagnostics: PslDiagnostic[] = []; + let outcome: Result = okVoid(); const output: Record = {}; const seen = new Set(); let positionalSlot = 0; let reportedExcess = false; - let voicedElsewhere = false; for (const arg of args) { const name = arg.name()?.name(); @@ -49,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; } @@ -67,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; } @@ -82,20 +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; - else if (alreadyVoicedElsewhere(result.failure)) voicedElsewhere = true; + outcome = and(outcome, result); } const finalized = new Set(); @@ -112,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, + ), + ]), ); }; @@ -130,9 +141,7 @@ export function interpretArgs( finalizeAbsentKey(key, undefined, spec.named[key]?.type); } - if (voicedElsewhere || diagnostics.length > 0) { - return notOk(diagnostics); - } + if (!outcome.ok) return notOk(outcome.failure); return ok(output); } @@ -168,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( From a7e66d3db5591700cbc375dc1c66ca914b9c703e Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Mon, 21 Sep 2026 15:33:57 +0000 Subject: [PATCH 26/66] refactor(utils,psl-parser): fold oneOf's alternatives by disjunction The emptiness test had a name again, which was the objection: `and` was introduced to carry failure through accumulation, and oneOf was still asking about it by hand. Result gains `or`: the first ok wins with its value, and two failures combine on the failure lane where a detail-less failure is absorbing - a rejection already reported elsewhere outranks the alternatives' complaints rather than being buried under them. oneOf folds its rejections with it, so the wordless case arrives at the tail as a property of the fold. Alternatives are still tried in order and the first match returns immediately; evaluating the rest would now be able to throw, since a reference combinator treats an unexamined node as a snapshot mismatch. The summary is unchanged: it is built from the alternatives' labels, never from their diagnostics. Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- .../0-foundation/utils/src/exports/result.ts | 2 +- .../1-framework/0-foundation/utils/src/result.ts | 16 ++++++++++++++++ .../src/attribute-spec/combinators/one-of.ts | 14 ++++++-------- 3 files changed, 23 insertions(+), 9 deletions(-) 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 2a8b6ad05ad4..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 { and, 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 b81e3035f439..9473ffde5072 100644 --- a/packages/1-framework/0-foundation/utils/src/result.ts +++ b/packages/1-framework/0-foundation/utils/src/result.ts @@ -152,6 +152,22 @@ export function okVoid(): Ok { * callers that need them collect them as they go and read them back once the * accumulated result is ok. */ +/** + * Disjunction of two results: the first ok wins, carrying its value. Two + * failures combine on the failure lane, where a failure carrying no details is + * absorbing — it outranks the other side, because a rejection that was already + * reported elsewhere should not be buried under alternatives' complaints. + */ +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, 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 783549287ffa..515654aa38f4 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,5 +1,5 @@ import { blindCast } from '@internal/utils/casts'; -import { notOk, ok, type Result } from '@internal/utils/result'; +import { notOk, ok, or, type Result } from '@internal/utils/result'; import type { PslDiagnostic } from '../../diagnostic'; import type { AnyArgType, @@ -11,10 +11,6 @@ import type { } from '../types'; import { leafDiagnostic } from './diagnostic'; -function alreadyVoicedElsewhere(rejection: readonly PslDiagnostic[]): boolean { - return rejection.length === 0; -} - export function oneOf( ...alts: Alts ): OneOfArgType>>> { @@ -26,7 +22,7 @@ export function oneOf( label, alternatives: alts, parse: (arg, ctx): Result, readonly PslDiagnostic[]> => { - let voicedElsewhere = false; + let rejection: Result | undefined; for (const alt of alts) { const parse = blindCast< (arg: Parameters[0], ctx: ParseContext) => ReturnType, @@ -41,9 +37,11 @@ export function oneOf( >(result.value), ); } - if (alreadyVoicedElsewhere(result.failure)) voicedElsewhere = true; + rejection = rejection === undefined ? result : or(rejection, result); + } + if (rejection !== undefined && !rejection.ok && rejection.failure.length === 0) { + return notOk([]); } - if (voicedElsewhere) return notOk([]); return notOk([leafDiagnostic(ctx, arg, `Expected one of: ${label}`)]); }, } satisfies OneOfArgType; From e726dca6f7996648a911b53f57ceb455f5c426d6 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Mon, 21 Sep 2026 15:34:07 +0000 Subject: [PATCH 27/66] docs(psl-parser): say where the universe scope gets its name Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- packages/1-framework/2-authoring/psl-parser/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/1-framework/2-authoring/psl-parser/README.md b/packages/1-framework/2-authoring/psl-parser/README.md index 68c696a827e5..e740aa3b11b0 100644 --- a/packages/1-framework/2-authoring/psl-parser/README.md +++ b/packages/1-framework/2-authoring/psl-parser/README.md @@ -81,6 +81,8 @@ An unqualified reference resolves in exactly this order: 2. the **top level**; 3. the **universe scope** — the scalar and type-constructor names built from the injected `typeConstructors` registry. +"Universe" is the standard compiler name for the outermost implicit scope holding a language's predeclared identifiers: Go's specification defines a universe block that encompasses all Go source text, with `int`, `string` and the rest declared in it. PSL's universe scope is that construct for its predeclared type names, which arrive from configuration rather than from the language definition. + **Sibling namespaces are never consulted.** A user declaration shadowing a universe symbol (a `model Uuid` over a built-in `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. From f8fe06522a0760ca94386ee9d67f2072ecf3614d Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Mon, 21 Sep 2026 15:34:49 +0000 Subject: [PATCH 28/66] =?UTF-8?q?chore(drive):=20review-round=20records=20?= =?UTF-8?q?=E2=80=94=2010(B)=20correction,=20confinement=20note,=20carried?= =?UTF-8?q?=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- projects/symbol-table-resolve/design-decisions.md | 4 +++- projects/symbol-table-resolve/plan.md | 1 + projects/symbol-table-resolve/slices/binder-core/spec.md | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/projects/symbol-table-resolve/design-decisions.md b/projects/symbol-table-resolve/design-decisions.md index 80eca9f19607..7adc52102d98 100644 --- a/projects/symbol-table-resolve/design-decisions.md +++ b/projects/symbol-table-resolve/design-decisions.md @@ -86,10 +86,12 @@ Outcome of the design discussion (2026-09-18) that preceded [`spec.md`](./spec.m **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 with no binder read — retyping it would force genericizing `FuncCallSig` across every call site (Mongo nests `optional(entityRef())` in a `funcCall`); its former read was non-load-bearing (D5 review), and the binder still voices entity failures from phase 2. `oneOf` never wraps a reference combinator in any production spec (repo-verified), so its no-match fall-through needed no failure-awareness. +**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 with no binder read — retyping it would force genericizing `FuncCallSig` across every call site (Mongo nests `optional(entityRef())` in a `funcCall`); its former read was non-load-bearing (D5 review), and the binder still voices entity failures from phase 2. ~~`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. 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 universe symbols silently, with no diagnostic. diff --git a/projects/symbol-table-resolve/plan.md b/projects/symbol-table-resolve/plan.md index 87a9e991fd81..c8e643a7f4f0 100644 --- a/projects/symbol-table-resolve/plan.md +++ b/projects/symbol-table-resolve/plan.md @@ -30,6 +30,7 @@ Four slices: one foundation slice delivering the eager binder in `psl-parser` (w - **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) diff --git a/projects/symbol-table-resolve/slices/binder-core/spec.md b/projects/symbol-table-resolve/slices/binder-core/spec.md index c0574fe69d94..f83559410ecd 100644 --- a/projects/symbol-table-resolve/slices/binder-core/spec.md +++ b/projects/symbol-table-resolve/slices/binder-core/spec.md @@ -40,7 +40,7 @@ One package, one subject: the binder and the node-identity substrate it requires ## Slice-specific done conditions -- [ ] Diff touches only `psl-parser`, `projects/symbol-table-resolve/`, and (from D6) the attribute-context construction sites in `2-sql/2-authoring/contract-psl`, `2-mongo-family/2-authoring/contract-psl`, and `language-server` — nothing else in those packages. +- [ ] 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. - [ ] The attribute-ctx builders are exported and covered by tests; contexts carry the required binder (decision 10 — `resolveReferencedModel` no longer exists). - [ ] 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. From 55ba8b2ff7e4fa94fa39e73c029d96f3e66a3983 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Tue, 22 Sep 2026 08:27:58 +0000 Subject: [PATCH 29/66] refactor(psl-parser): name the third scope for what it holds "Universe" borrowed a term from language specifications for a scope that holds no language built-ins: its names are whatever the configured target and its composed extension packs contribute in type position - scalars, type constructors, field presets. The scope, its symbol, its resolution kind and the docs now say contributed types instead, matching how the repo already names target-contributed surfaces elsewhere. Rename only; every assertion is untouched and the test titles moved with the vocabulary. Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- .../2-authoring/psl-parser/README.md | 10 +++--- .../2-authoring/psl-parser/src/binder.ts | 24 ++++++++------ ...rse-scope.ts => contributed-type-scope.ts} | 22 +++++++------ .../psl-parser/src/exports/index.ts | 2 +- .../psl-parser/test/binder.test.ts | 32 ++++++++++--------- 5 files changed, 50 insertions(+), 40 deletions(-) rename packages/1-framework/2-authoring/psl-parser/src/{universe-scope.ts => contributed-type-scope.ts} (60%) diff --git a/packages/1-framework/2-authoring/psl-parser/README.md b/packages/1-framework/2-authoring/psl-parser/README.md index e740aa3b11b0..be7f90496704 100644 --- a/packages/1-framework/2-authoring/psl-parser/README.md +++ b/packages/1-framework/2-authoring/psl-parser/README.md @@ -79,11 +79,11 @@ 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 **universe scope** — the scalar and type-constructor names built from the injected `typeConstructors` registry. +3. the **contributed types** — the type-position names the configured target and its extensions contribute, built from the injected `typeConstructors` registry. -"Universe" is the standard compiler name for the outermost implicit scope holding a language's predeclared identifiers: Go's specification defines a universe block that encompasses all Go source text, with `int`, `string` and the rest declared in it. PSL's universe scope is that construct for its predeclared type names, which arrive from configuration rather than from the language definition. +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 user declaration shadowing a universe symbol (a `model Uuid` over a built-in `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. +**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. @@ -94,7 +94,7 @@ Qualified references resolve at whole-`QualifiedName` granularity: in `app.Item` | 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 | -| `universe` | a scalar or type constructor from the injected registry | +| `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 | @@ -134,7 +134,7 @@ Fields whose type is malformed are the one deliberate absence: the binder does n 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 universe 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 universe 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. +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 diff --git a/packages/1-framework/2-authoring/psl-parser/src/binder.ts b/packages/1-framework/2-authoring/psl-parser/src/binder.ts index 485b17ae4b3d..75b11615e675 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/binder.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/binder.ts @@ -1,5 +1,10 @@ import type { AuthoringTypeNamespace } from '@internal/framework-components/authoring'; import type { ContributedPslDiagnosticCode } from '@internal/framework-components/psl-ast'; +import { + type ContributedTypeScope, + type ContributedTypeSymbol, + contributedTypeScope, +} from './contributed-type-scope'; import { diagnosticSource } from './diagnostic'; import type { ParseDiagnostic } from './parse'; import type { ResolvedAttribute } from './resolve'; @@ -18,7 +23,6 @@ import type { FieldAttributeAst, ModelAttributeAst } from './syntax/ast/attribut import { ArrayLiteralAst, type ExpressionAst } from './syntax/ast/expressions'; import { IdentifierAst } from './syntax/ast/identifier'; import type { SyntaxNode } from './syntax/red'; -import { type UniverseScope, type UniverseSymbol, universeScope } from './universe-scope'; export const PSL_UNRESOLVED_REFERENCE = 'PSL_UNRESOLVED_REFERENCE' satisfies ContributedPslDiagnosticCode; @@ -39,7 +43,7 @@ export type Resolution = | { readonly kind: 'compositeType'; readonly symbol: CompositeTypeSymbol } | { readonly kind: 'namedType'; readonly symbol: NamedTypeSymbol } | { readonly kind: 'block'; readonly symbol: BlockSymbol } - | { readonly kind: 'universe'; readonly symbol: UniverseSymbol } + | { readonly kind: 'contributedType'; readonly symbol: ContributedTypeSymbol } | { readonly kind: 'field'; readonly symbol: FieldSymbol } | { readonly kind: 'attributeSpec'; readonly spec: AttributeSpecView } | { readonly kind: 'crossSpace' } @@ -122,7 +126,7 @@ interface Owner { export function createBinder(options: CreateBinderOptions): BinderResult { const { sources, symbolTable, typeConstructors, attributeSpecs } = options; - const universe = universeScope(typeConstructors); + const contributedTypes = contributedTypeScope(typeConstructors); const declarations = new WeakMap(); const references = new WeakMap(); const diagnostics: ParseDiagnostic[] = []; @@ -148,7 +152,7 @@ export function createBinder(options: CreateBinderOptions): BinderResult { declarations.set(field.node.syntax, field); const node = typeReferenceNode(field); if (node === undefined) continue; - const resolution = resolveTypeReference(field, scope, symbolTable.topLevel, universe); + const resolution = resolveTypeReference(field, scope, symbolTable.topLevel, contributedTypes); if (resolution === undefined) continue; references.set(node, resolution); if (resolution.kind === 'unresolved') { @@ -360,7 +364,7 @@ function resolveTypeReference( field: FieldSymbol, scope: NamespaceSymbol | undefined, topLevel: TopLevelScope, - universe: UniverseScope, + contributedTypes: ContributedTypeScope, ): Resolution | undefined { if (field.malformedType === true) return undefined; if (field.typeContractSpaceId !== undefined) return { kind: 'crossSpace' }; @@ -372,8 +376,9 @@ function resolveTypeReference( const namespace = own(topLevel.namespaces, namespaceId); const declared = namespace === undefined ? undefined : inNamespace(namespace, name); if (declared !== undefined) return declared; - const universeSymbol = universe.lookup([namespaceId, name]); - if (universeSymbol !== undefined) return { kind: 'universe', symbol: universeSymbol }; + const contributedSymbol = contributedTypes.lookup([namespaceId, name]); + if (contributedSymbol !== undefined) + return { kind: 'contributedType', symbol: contributedSymbol }; return { kind: 'unresolved', name: `${namespaceId}.${name}` }; } @@ -381,8 +386,9 @@ function resolveTypeReference( if (local !== undefined) return local; const global = inTopLevel(topLevel, name); if (global !== undefined) return global; - const universeSymbol = universe.lookup([name]); - if (universeSymbol !== undefined) return { kind: 'universe', symbol: universeSymbol }; + const contributedSymbol = contributedTypes.lookup([name]); + if (contributedSymbol !== undefined) + return { kind: 'contributedType', symbol: contributedSymbol }; return { kind: 'unresolved', name }; } diff --git a/packages/1-framework/2-authoring/psl-parser/src/universe-scope.ts b/packages/1-framework/2-authoring/psl-parser/src/contributed-type-scope.ts similarity index 60% rename from packages/1-framework/2-authoring/psl-parser/src/universe-scope.ts rename to packages/1-framework/2-authoring/psl-parser/src/contributed-type-scope.ts index edb7e71c3fb8..f73688c6d09d 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/universe-scope.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/contributed-type-scope.ts @@ -4,20 +4,22 @@ import type { } from '@internal/framework-components/authoring'; import { isAuthoringTypeConstructorDescriptor } from '@internal/framework-components/authoring'; -export interface UniverseSymbol { - readonly kind: 'universe'; +export interface ContributedTypeSymbol { + readonly kind: 'contributedType'; readonly name: string; readonly path: readonly string[]; readonly descriptor: AuthoringTypeConstructorDescriptor; } -export interface UniverseScope { - lookup(path: readonly string[]): UniverseSymbol | undefined; +export interface ContributedTypeScope { + lookup(path: readonly string[]): ContributedTypeSymbol | undefined; } -const scopes = new WeakMap(); +const scopes = new WeakMap(); -export function universeScope(typeConstructors: AuthoringTypeNamespace): UniverseScope { +export function contributedTypeScope( + typeConstructors: AuthoringTypeNamespace, +): ContributedTypeScope { const existing = scopes.get(typeConstructors); if (existing !== undefined) return existing; const created = buildScope(typeConstructors); @@ -25,8 +27,8 @@ export function universeScope(typeConstructors: AuthoringTypeNamespace): Univers return created; } -function buildScope(typeConstructors: AuthoringTypeNamespace): UniverseScope { - const symbols = new Map(); +function buildScope(typeConstructors: AuthoringTypeNamespace): ContributedTypeScope { + const symbols = new Map(); collect(typeConstructors, [], symbols); return { lookup(path) { @@ -38,12 +40,12 @@ function buildScope(typeConstructors: AuthoringTypeNamespace): UniverseScope { function collect( namespace: AuthoringTypeNamespace, prefix: readonly string[], - symbols: Map, + symbols: Map, ): void { for (const [name, value] of Object.entries(namespace)) { const path = [...prefix, name]; if (isAuthoringTypeConstructorDescriptor(value)) { - symbols.set(path.join('.'), { kind: 'universe', name, path, descriptor: value }); + symbols.set(path.join('.'), { kind: 'contributedType', name, path, descriptor: value }); } else { collect(value, path, symbols); } 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 3979d84217a0..e3104d9277ec 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 @@ -119,6 +119,7 @@ export { modelAttributeContext, referencedModel, } from '../binder-context'; +export type { ContributedTypeScope, ContributedTypeSymbol } from '../contributed-type-scope'; export type { DiagnosticSource, PslDiagnostic, PslDiagnosticCollector } from '../diagnostic'; export { createPslDiagnosticCollector, @@ -151,4 +152,3 @@ export type { TopLevelScope, } from '../symbol-table'; export { buildSymbolTable } from '../symbol-table'; -export type { UniverseScope, UniverseSymbol } from '../universe-scope'; 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 index cc7e39b170a4..cbb54a540bd8 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/binder.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/binder.test.ts @@ -10,6 +10,7 @@ import { list } from '../src/attribute-spec/combinators/list'; import { fieldAttribute } from '../src/attribute-spec/field-attribute'; import { modelAttribute } from '../src/attribute-spec/model-attribute'; import { type AttributeSpecRegistry, createBinder, typeReferenceNode } from '../src/binder'; +import { contributedTypeScope } from '../src/contributed-type-scope'; import { parse } from '../src/parse'; import { PslSources } from '../src/source-file'; import { @@ -21,7 +22,6 @@ import { } from '../src/symbol-table'; import { ArrayLiteralAst } from '../src/syntax/ast/expressions'; import type { SyntaxNode } from '../src/syntax/red'; -import { universeScope } from '../src/universe-scope'; const ENUM_DESCRIPTORS: AuthoringPslBlockDescriptorNamespace = { enum: { @@ -244,18 +244,18 @@ describe('createBinder — the scope chain', () => { expect(diagnostics.map(({ code }) => code)).toEqual(['PSL_UNRESOLVED_REFERENCE']); }); - it('falls back to the universe scope for a scalar name', () => { + 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: 'universe', - symbol: { kind: 'universe', name: 'String', path: ['String'] }, + kind: 'contributedType', + symbol: { kind: 'contributedType', name: 'String', path: ['String'] }, }); }); - it('lets a user declaration shadow a universe symbol silently', () => { + 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}', ); @@ -323,14 +323,14 @@ describe('createBinder — qualified references', () => { }); }); - it('resolves a qualified reference into a universe type namespace', () => { + 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: 'universe', + kind: 'contributedType', symbol: { name: 'Vector', path: ['pgvector', 'Vector'] }, }); }); @@ -436,13 +436,15 @@ describe('createBinder — multiple documents', () => { }); }); -describe('universe scope', () => { +describe('contributedTypes scope', () => { it('returns the same scope object for the same registry', () => { - expect(universeScope(TYPE_CONSTRUCTORS)).toBe(universeScope(TYPE_CONSTRUCTORS)); - expect(universeScope({ ...TYPE_CONSTRUCTORS })).not.toBe(universeScope(TYPE_CONSTRUCTORS)); + expect(contributedTypeScope(TYPE_CONSTRUCTORS)).toBe(contributedTypeScope(TYPE_CONSTRUCTORS)); + expect(contributedTypeScope({ ...TYPE_CONSTRUCTORS })).not.toBe( + contributedTypeScope(TYPE_CONSTRUCTORS), + ); }); - it('shares universe symbols across two binders built over different documents', () => { + 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}'); @@ -451,9 +453,9 @@ describe('universe scope', () => { typeNodeOf(second.symbolTable, 'Other', 'title'), ); - expect(firstSymbol?.kind).toBe('universe'); + expect(firstSymbol?.kind).toBe('contributedType'); expect(firstSymbol).not.toBe(secondSymbol); - if (firstSymbol?.kind === 'universe' && secondSymbol?.kind === 'universe') { + if (firstSymbol?.kind === 'contributedType' && secondSymbol?.kind === 'contributedType') { expect(firstSymbol.symbol).toBe(secondSymbol.symbol); } }); @@ -667,7 +669,7 @@ describe('createBinder — entityRef arguments', () => { }); }); - it('reports a missing entity and refuses a universe symbol as an entity', () => { + it('reports a missing entity and refuses a contributedTypes symbol as an entity', () => { const { symbolTable, binder, diagnostics } = bind( [ 'model Orphan {', @@ -754,7 +756,7 @@ describe('createBinder — diagnostics completeness', () => { symbol: symbolTable.topLevel.models['User'], }); expect(binder.symbolForNode(typeNodeOf(symbolTable, 'User', 'email'))).toMatchObject({ - kind: 'universe', + kind: 'contributedType', }); }); }); From 5485c0f687dd397884b57b76cdaa34fc41287841 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Tue, 22 Sep 2026 08:33:56 +0000 Subject: [PATCH 30/66] refactor(utils): drop the doc blocks added with and/or They were introduced with the two combinators in this branch, and the second insertion left the conjunction paragraph standing above `or` while `and` went bare. The laws are pinned by tests rather than described in prose. Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- .../1-framework/0-foundation/utils/src/result.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/packages/1-framework/0-foundation/utils/src/result.ts b/packages/1-framework/0-foundation/utils/src/result.ts index 9473ffde5072..55c534052d41 100644 --- a/packages/1-framework/0-foundation/utils/src/result.ts +++ b/packages/1-framework/0-foundation/utils/src/result.ts @@ -146,18 +146,6 @@ export function okVoid(): Ok { return OK_VOID; } -/** - * Conjunction of two results whose failures accumulate: ok only when both are - * ok, and two failures keep both sides' details in order. Values are dropped — - * callers that need them collect them as they go and read them back once the - * accumulated result is ok. - */ -/** - * Disjunction of two results: the first ok wins, carrying its value. Two - * failures combine on the failure lane, where a failure carrying no details is - * absorbing — it outranks the other side, because a rejection that was already - * reported elsewhere should not be buried under alternatives' complaints. - */ export function or( left: Result, right: Result, From 88f359c88b56adeb90c5dd9f391014f2a8f4b8d8 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Tue, 22 Sep 2026 08:34:05 +0000 Subject: [PATCH 31/66] refactor(psl-parser): seed oneOf's fold with its first alternative The accumulator started empty and every step asked whether it existed yet. Alternatives are already typed as a non-empty tuple, so the first arm is the seed and the rest fold onto it - the undefined checks are gone without touching the fold's law. Seeding with notOk([]) would have been the shorter spelling and the wrong one: a detail-less failure is absorbing on the disjunction lane, so it would make every fold wordless and leave the "Expected one of" summary unreachable. Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- .../src/attribute-spec/combinators/one-of.ts | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) 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 515654aa38f4..c73c4c4be1f5 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 @@ -22,26 +22,30 @@ export function oneOf( label, alternatives: alts, parse: (arg, ctx): Result, readonly PslDiagnostic[]> => { - let rejection: Result | undefined; - for (const alt of alts) { + const attempt = (alt: Alts[number]): Result => { 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), - ); - } - rejection = rejection === undefined ? result : or(rejection, result); - } - if (rejection !== undefined && !rejection.ok && rejection.failure.length === 0) { - return notOk([]); + return parse(arg, ctx); + }; + const matched = (value: unknown): Result, readonly PslDiagnostic[]> => + 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.' + >(value), + ); + + const [head, ...tail] = alts; + let rejection = attempt(head); + if (rejection.ok) return matched(rejection.value); + for (const alt of tail) { + const result = attempt(alt); + if (result.ok) return matched(result.value); + rejection = or(rejection, result); } + if (!rejection.ok && rejection.failure.length === 0) return notOk([]); return notOk([leafDiagnostic(ctx, arg, `Expected one of: ${label}`)]); }, } satisfies OneOfArgType; From 2d23e68d197d48120c608ccab028fb73f6cefe03 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Tue, 22 Sep 2026 08:34:16 +0000 Subject: [PATCH 32/66] test(utils): pin the conjunction and disjunction laws Both combinators arrived with their only coverage coming from the parser that uses them. These cover each lane directly, including the one that is easy to reach for and wrong: on the disjunction lane a detail-less failure absorbs the other side, so it can never serve as the fold's seed. Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- .../0-foundation/utils/test/result.test.ts | 60 ++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) 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: [] }); + }); + }); }); From 042cbd1e3bcd5200b59f030c7f00f6a784cb2cfb Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Tue, 22 Sep 2026 08:35:34 +0000 Subject: [PATCH 33/66] chore(drive): rename contributed-type scope across project records Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- .../symbol-table-resolve/design-decisions.md | 10 ++++++---- projects/symbol-table-resolve/plan.md | 4 ++-- .../slices/binder-core/plan.md | 6 +++--- .../slices/binder-core/spec.md | 6 +++--- projects/symbol-table-resolve/spec.md | 16 ++++++++-------- 5 files changed, 22 insertions(+), 20 deletions(-) diff --git a/projects/symbol-table-resolve/design-decisions.md b/projects/symbol-table-resolve/design-decisions.md index 7adc52102d98..4db284ded282 100644 --- a/projects/symbol-table-resolve/design-decisions.md +++ b/projects/symbol-table-resolve/design-decisions.md @@ -14,7 +14,7 @@ Outcome of the design discussion (2026-09-18) that preceded [`spec.md`](./spec.m - *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 → universe; never siblings +## 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. @@ -38,9 +38,11 @@ Outcome of the design discussion (2026-09-18) that preceded [`spec.md`](./spec.m **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 universe scope +## 5. Scalars become symbols in a config-derived contributed-type scope -**Decision.** Scalars (and type constructors) are exposed as symbols in an outermost universe scope, built once per configuration from the existing type-constructor registry, chained after top level. User declarations shadow universe symbols. Document edits never invalidate this layer. +> **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. @@ -94,4 +96,4 @@ Outcome of the design discussion (2026-09-18) that preceded [`spec.md`](./spec.m ## 11. 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 universe symbols silently, with no diagnostic. +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 index c8e643a7f4f0..29df1390df65 100644 --- a/projects/symbol-table-resolve/plan.md +++ b/projects/symbol-table-resolve/plan.md @@ -12,9 +12,9 @@ Four slices: one foundation slice delivering the eager binder in `psl-parser` (w ### 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 universe 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 → universe, 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`. + - **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, universe-scope invalidation (identity across snapshots), and node-identity guarantees. + - **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 diff --git a/projects/symbol-table-resolve/slices/binder-core/plan.md b/projects/symbol-table-resolve/slices/binder-core/plan.md index 11402f612a90..72b05a67844e 100644 --- a/projects/symbol-table-resolve/slices/binder-core/plan.md +++ b/projects/symbol-table-resolve/slices/binder-core/plan.md @@ -11,10 +11,10 @@ Slice spec: `projects/symbol-table-resolve/slices/binder-core/spec.md`. Branch: ### Dispatch 2: binder-phase-1 -- **Outcome:** `createBinder(...) → { binder, diagnostics }` exists (interface + factory, package-internal): universe scope built from an injected type-constructor registry; phase 1 registers declarations and resolves every field type reference through declaring namespace → top level → universe (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), universe-scope identity across two builds, and stable repeated-query results. +- **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 universe scope in place — the structure phase 2 extends. -- **Focus:** new binder + universe-scope modules and tests. Attribute references untouched (phase 2); nothing exported from the package root yet. +- **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 diff --git a/projects/symbol-table-resolve/slices/binder-core/spec.md b/projects/symbol-table-resolve/slices/binder-core/spec.md index f83559410ecd..b8c0b01efb04 100644 --- a/projects/symbol-table-resolve/slices/binder-core/spec.md +++ b/projects/symbol-table-resolve/slices/binder-core/spec.md @@ -11,7 +11,7 @@ Adds red-slot child caching to `syntax/red.ts` (within-snapshot node identity) a 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. -- **Universe scope** (new module in `psl-parser`): builds `name → UniverseSymbol` once from an injected type-constructor registry; the object is config-derived and shared across snapshots. +- **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. @@ -23,7 +23,7 @@ One package, one subject: the binder and the node-identity substrate it requires ## Scope -**In:** `packages/1-framework/2-authoring/psl-parser` — `src/syntax/red.ts`, new binder + universe-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. +**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. @@ -36,7 +36,7 @@ One package, one subject: the binder and the node-identity substrate it requires | 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 universe scalar (`model Uuid`) | User declaration wins, silently | Operator-decreed (spec § Cross-cutting 1) | +| Model shadowing a contributed scalar (`model Uuid`) | User declaration wins, silently | Operator-decreed (spec § Cross-cutting 1) | ## Slice-specific done conditions diff --git a/projects/symbol-table-resolve/spec.md b/projects/symbol-table-resolve/spec.md index 6e16747e0b53..2820c63e3d8b 100644 --- a/projects/symbol-table-resolve/spec.md +++ b/projects/symbol-table-resolve/spec.md @@ -25,13 +25,13 @@ diagnostics; // every resolution failure; the bi 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 → universe 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. +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 universe = universeScope(typeConstructors); // config scope: type names, shared across snapshots + 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 @@ -44,7 +44,7 @@ function createBinder({ sources, symbolTable, typeConstructors, attributeSpecs } for (const field of owner.fields) { declarations.set(field.node.syntax, field); references.set(typeNode(field).syntax, - resolveTypeRef(field, chain(scope, symbolTable.topLevel, universe))); + resolveTypeRef(field, chain(scope, symbolTable.topLevel, contributedTypes))); } } @@ -96,7 +96,7 @@ Interpreters still run spec interpretation for argument **values**; the binder o - **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 universe scope, sourced from the existing type-constructor registry (commit `72cd71550f`'s unification stands). +- **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 @@ -109,11 +109,11 @@ Interpreters still run spec interpretation for argument **values**; the binder o ## Cross-cutting requirements -1. **One scoping rule everywhere.** Unqualified references resolve declaring namespace → top level → universe scope; sibling namespaces are never consulted. User declarations shadow universe-scope symbols silently — no shadowing diagnostic. Every converted consumer carries a test pinning this rule against a schema where a namespaced declaration shadows a top-level one. +1. **One scoping rule everywhere.** Unqualified references resolve declaring namespace → top level → contributed-type scope; sibling namespaces are never consulted. User declarations shadow contributed-type symbols silently — no shadowing diagnostic. Every converted consumer carries a test pinning this rule 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 universe scope is config-derived, shared across snapshots, and invalidated only by configuration change — never by document edits. +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. @@ -134,12 +134,12 @@ Interpreters still run spec interpretation for argument **values**; the binder o - [ ] 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 universe scope (object identity across snapshots) and that user declarations shadow universe symbols. +- [ ] 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 universe symbols is confirmed. The resolutions are folded into Non-goals and Cross-cutting requirements above. +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 From 20f5aa873be28222d9d525b449b331eb4d296ee2 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Tue, 22 Sep 2026 09:08:23 +0000 Subject: [PATCH 34/66] refactor(psl-parser): let the binder take the real attribute-spec namespaces The binder described the spec shapes it needed with its own interface, so every consumer wrote an adapter translating tables it already had into a mirror of them. It now takes the namespaces themselves, plus the one ingredient the factories need that it could not already supply, and calls each factory with the ADR 249 context built from the owner it is walking. An attribute name resolves to an attribute symbol rather than a bare spec, so it reads like every other name the binder answers for, with the spec reachable through it. A name absent from the namespace is left to its target, which is what both registries already did by answering permissively - so the weak unresolved-attribute diagnostic and its reference class go, having never reached a consumer. Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- .../2-authoring/psl-parser/src/binder.ts | 123 ++++++------ .../psl-parser/src/exports/index.ts | 8 +- .../test/attribute-spec-binder.test.ts | 11 +- ...bute-spec-combinators.foreign-copy.test.ts | 6 +- ...te-spec-combinators.tagged-literal.test.ts | 6 +- .../test/attribute-spec-combinators.test.ts | 33 +-- ...attribute-spec-failure-propagation.test.ts | 6 +- .../psl-parser/test/attribute-spec.test.ts | 6 +- .../psl-parser/test/binder-context.test.ts | 11 +- .../psl-parser/test/binder.test.ts | 189 +++++++++--------- 10 files changed, 212 insertions(+), 187 deletions(-) diff --git a/packages/1-framework/2-authoring/psl-parser/src/binder.ts b/packages/1-framework/2-authoring/psl-parser/src/binder.ts index 75b11615e675..053bd6babf65 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/binder.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/binder.ts @@ -1,5 +1,8 @@ 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 { type ContributedTypeScope, type ContributedTypeSymbol, @@ -27,8 +30,16 @@ import type { SyntaxNode } from './syntax/red'; export const PSL_UNRESOLVED_REFERENCE = 'PSL_UNRESOLVED_REFERENCE' satisfies ContributedPslDiagnosticCode; -export const PSL_UNRESOLVED_ATTRIBUTE = - 'PSL_UNRESOLVED_ATTRIBUTE' 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 @@ -45,38 +56,10 @@ export type Resolution = | { readonly kind: 'block'; readonly symbol: BlockSymbol } | { readonly kind: 'contributedType'; readonly symbol: ContributedTypeSymbol } | { readonly kind: 'field'; readonly symbol: FieldSymbol } - | { readonly kind: 'attributeSpec'; readonly spec: AttributeSpecView } + | { readonly kind: 'attribute'; readonly symbol: AttributeSymbol } | { readonly kind: 'crossSpace' } | { readonly kind: 'unresolved'; readonly name: string }; -export interface AttributeArgTypeView { - readonly kind: string; - readonly of?: AttributeArgTypeView; - readonly alternatives?: readonly AttributeArgTypeView[]; -} - -export interface AttributeParamView { - readonly type: AttributeArgTypeView; -} - -export interface AttributePositionalParamView extends AttributeParamView { - readonly key: string; -} - -export interface AttributeSpecView { - readonly positional: readonly AttributePositionalParamView[]; - readonly named: Readonly>; -} - -export interface AttributeSpecRegistry { - model(name: string, owner: ModelSymbol | CompositeTypeSymbol): AttributeSpecView | undefined; - field( - name: string, - owner: ModelSymbol | CompositeTypeSymbol, - field: FieldSymbol, - ): AttributeSpecView | undefined; -} - export interface Binder { declaredSymbol(node: SyntaxNode): PslSymbol | undefined; symbolForNode(node: SyntaxNode): Resolution | undefined; @@ -86,7 +69,8 @@ export interface CreateBinderOptions { readonly sources: PslSources; readonly symbolTable: SymbolTable; readonly typeConstructors: AuthoringTypeNamespace; - readonly attributeSpecs: AttributeSpecRegistry; + readonly attributeSpecs: AttributeSpecNamespace; + readonly controlMutationDefaults: ControlDefaultRegistries; } export interface BinderResult { @@ -125,7 +109,8 @@ interface Owner { } export function createBinder(options: CreateBinderOptions): BinderResult { - const { sources, symbolTable, typeConstructors, attributeSpecs } = options; + const { sources, symbolTable, typeConstructors, attributeSpecs, controlMutationDefaults } = + options; const contributedTypes = contributedTypeScope(typeConstructors); const declarations = new WeakMap(); const references = new WeakMap(); @@ -175,15 +160,27 @@ export function createBinder(options: CreateBinderOptions): BinderResult { symbolTable, sources, }; - bindAttributes(symbol, symbol.attributes, (name) => attributeSpecs.model(name, symbol), { - ...context, - field: undefined, - }); + const specContext = + symbol.kind === 'model' + ? { symbols: symbolTable, model: symbol, controlMutationDefaults } + : undefined; + bindAttributes( + symbol, + symbol.attributes, + (name) => + specContext === undefined ? undefined : own(attributeSpecs.model, name)?.(specContext), + { ...context, field: undefined }, + ); for (const field of Object.values(symbol.fields)) { - bindAttributes(field, field.attributes, (name) => attributeSpecs.field(name, symbol, field), { - ...context, + bindAttributes( field, - }); + field.attributes, + (name) => + specContext === undefined + ? undefined + : own(attributeSpecs.field, name)?.({ ...specContext, field }), + { ...context, field }, + ); } } @@ -203,33 +200,31 @@ interface BindContext { function bindAttributes( holder: ModelSymbol | CompositeTypeSymbol | FieldSymbol, attributes: readonly ResolvedAttribute[], - lookupSpec: (name: string) => AttributeSpecView | undefined, + lookupSpec: (name: string) => BoundSpec | undefined, ctx: BindContext, ): void { - const marker = holder.kind === 'field' ? '@' : '@@'; const declared: Iterable = holder.node.attributes(); const nodes = Array.from(declared); attributes.forEach((attribute, index) => { const spec = lookupSpec(attribute.name); + if (spec === undefined) return; const nameNode = nodes[index]?.name()?.syntax; if (nameNode !== undefined) { - if (spec === undefined) { - ctx.references.set(nameNode, { kind: 'unresolved', name: attribute.name }); - ctx.diagnostics.push({ - code: PSL_UNRESOLVED_ATTRIBUTE, - message: `Cannot find attribute "${marker}${attribute.name}"`, - data: { reference: 'attribute' }, - ...diagnosticSource(ctx.sources, nameNode).at(), - }); - } else { - ctx.references.set(nameNode, { kind: 'attributeSpec', spec }); - } + ctx.references.set(nameNode, { + kind: 'attribute', + symbol: { + kind: 'attribute', + name: attribute.name, + level: holder.kind === 'field' ? 'field' : 'model', + spec, + }, + }); } - if (spec !== undefined) bindArguments(attribute, spec, ctx); + bindArguments(attribute, spec, ctx); }); } -function bindArguments(attribute: ResolvedAttribute, spec: AttributeSpecView, ctx: BindContext) { +function bindArguments(attribute: ResolvedAttribute, spec: BoundSpec, ctx: BindContext) { let positional = 0; for (const arg of attribute.args) { const param = @@ -332,14 +327,18 @@ function report( type ReferenceKind = 'fieldRef' | 'referencedFieldRef' | 'entityRef'; -function referenceKind(type: AttributeArgTypeView): ReferenceKind | undefined { - if (type.kind === 'fieldRef' || type.kind === 'referencedFieldRef' || type.kind === 'entityRef') { - return type.kind; +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 (type.of !== undefined) return referenceKind(type.of); - for (const alternative of type.alternatives ?? []) { - const kind = referenceKind(alternative); - if (kind !== undefined) 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; } 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 e3104d9277ec..de49fc37530a 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 @@ -92,20 +92,16 @@ export type { TypedFuncCall, } from '../attribute-spec/types'; export type { - AttributeArgTypeView, - AttributeParamView, - AttributePositionalParamView, - AttributeSpecRegistry, - AttributeSpecView, + AttributeSymbol, Binder, BinderResult, + BoundSpec, CreateBinderOptions, PslSymbol, Resolution, } from '../binder'; export { createBinder, - PSL_UNRESOLVED_ATTRIBUTE, PSL_UNRESOLVED_REFERENCE, typeReferenceNode, } from '../binder'; 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 index 006e0ec9c93c..aefd4fa64380 100644 --- 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 @@ -11,7 +11,6 @@ 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 type { AttributeSpecRegistry } from '../src/binder'; import { createBinder } from '../src/binder'; import { fieldAttributeContext, modelAttributeContext } from '../src/binder-context'; import { parse } from '../src/parse'; @@ -60,9 +59,9 @@ const indexSpec = modelAttribute('index', { positional: [{ key: 'fields', type: list(fieldRef()), documentation: 'fixture' }], }); -const ATTRIBUTE_SPECS: AttributeSpecRegistry = { - model: (name) => (name === 'base' ? baseSpec : name === 'index' ? indexSpec : undefined), - field: (name) => (name === 'relation' ? relationSpec : undefined), +const ATTRIBUTE_SPECS = { + model: { base: () => baseSpec, index: () => indexSpec }, + field: { relation: () => relationSpec }, }; function bind(text: string) { @@ -78,6 +77,10 @@ function bind(text: string) { symbolTable, typeConstructors: TYPE_CONSTRUCTORS, attributeSpecs: ATTRIBUTE_SPECS, + controlMutationDefaults: { + defaultFunctionRegistry: new Map(), + defaultLiteralTagRegistry: new Map(), + }, }); return { sources, symbolTable, binder, binderDiagnostics: diagnostics }; } 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 b68f11941ef5..38d315d1a42a 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 @@ -48,7 +48,11 @@ function foreignArg(source: string): { arg: ExpressionAst; ctx: ModelAttributeCt sources, symbolTable, typeConstructors: {}, - attributeSpecs: { model: () => undefined, field: () => undefined }, + attributeSpecs: { model: {}, field: {} }, + controlMutationDefaults: { + defaultFunctionRegistry: new Map(), + defaultLiteralTagRegistry: new Map(), + }, }); return { arg: new ForeignCopyOfAnAstNode(value.syntax) as unknown as ExpressionAst, 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 fb3be85c2181..0f3d20fe349f 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 @@ -24,7 +24,11 @@ function makeCtx(sources: PslSources): FieldAttributeCtx { sources: modelSources, symbolTable, typeConstructors: {}, - attributeSpecs: { model: () => undefined, field: () => undefined }, + attributeSpecs: { model: {}, field: {} }, + controlMutationDefaults: { + defaultFunctionRegistry: new Map(), + defaultLiteralTagRegistry: new Map(), + }, }); return { sources, selfModel, field, binder }; } 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 88dcebf09ee5..066b8c440af5 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 @@ -45,7 +45,11 @@ function makeCtx(sources: PslSources): FieldAttributeCtx { sources: modelSources, symbolTable, typeConstructors: {}, - attributeSpecs: { model: () => undefined, field: () => undefined }, + attributeSpecs: { model: {}, field: {} }, + controlMutationDefaults: { + defaultFunctionRegistry: new Map(), + defaultLiteralTagRegistry: new Map(), + }, }); return { sources, selfModel, field, binder }; } @@ -67,17 +71,22 @@ function schemaArg(schema: string, attribute: string, argName?: string) { symbolTable, typeConstructors: {}, attributeSpecs: { - model: () => undefined, - field: (name) => - name === attribute - ? { - positional: [{ key: 'fields', type: { kind: 'list', of: { kind: 'fieldRef' } } }], - named: { - fields: { type: { kind: 'list', of: { kind: 'fieldRef' } } }, - references: { type: { kind: 'list', of: { kind: 'referencedFieldRef' } } }, - }, - } - : undefined, + 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(), + defaultLiteralTagRegistry: new Map(), }, }); for (const node of field.node.attributes()) { 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 index d15b7ad865f4..4fce99021a33 100644 --- 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 @@ -32,7 +32,11 @@ function build(text: string) { sources, symbolTable, typeConstructors: {}, - attributeSpecs: { model: () => undefined, field: () => undefined }, + attributeSpecs: { model: {}, field: {} }, + controlMutationDefaults: { + defaultFunctionRegistry: new Map(), + defaultLiteralTagRegistry: new Map(), + }, }); return { sources, model, binder }; } 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 29412ea673a6..2c8baf8a1069 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 @@ -33,7 +33,11 @@ function makeCtx(sources: PslSources): FieldAttributeCtx { sources: modelSources, symbolTable, typeConstructors: {}, - attributeSpecs: { model: () => undefined, field: () => undefined }, + attributeSpecs: { model: {}, field: {} }, + controlMutationDefaults: { + defaultFunctionRegistry: new Map(), + defaultLiteralTagRegistry: new Map(), + }, }); return { sources, selfModel, field, binder }; } diff --git a/packages/1-framework/2-authoring/psl-parser/test/binder-context.test.ts b/packages/1-framework/2-authoring/psl-parser/test/binder-context.test.ts index 0b6fe548d005..59e02a543029 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/binder-context.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/binder-context.test.ts @@ -8,7 +8,6 @@ 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 { optional } from '../src/attribute-spec/optional'; -import type { AttributeSpecRegistry } from '../src/binder'; import { createBinder } from '../src/binder'; import { fieldAttributeContext, @@ -51,9 +50,9 @@ const relationSpec = fieldAttribute('relation', { }, }); -const ATTRIBUTE_SPECS: AttributeSpecRegistry = { - model: () => undefined, - field: (name) => (name === 'relation' ? relationSpec : undefined), +const ATTRIBUTE_SPECS = { + model: {}, + field: { relation: () => relationSpec }, }; function bind(text: string) { @@ -72,6 +71,10 @@ function bind(text: string) { symbolTable, typeConstructors: TYPE_CONSTRUCTORS, attributeSpecs: ATTRIBUTE_SPECS, + controlMutationDefaults: { + defaultFunctionRegistry: new Map(), + defaultLiteralTagRegistry: new Map(), + }, }), }; } 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 index cbb54a540bd8..a972b6510bdf 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/binder.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/binder.test.ts @@ -7,9 +7,14 @@ 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 AttributeSpecRegistry, createBinder, typeReferenceNode } from '../src/binder'; +import type { + AttributeSpecContext, + FieldAttributeSpecContext, +} from '../src/attribute-spec/spec-context'; +import { createBinder, typeReferenceNode } from '../src/binder'; import { contributedTypeScope } from '../src/contributed-type-scope'; import { parse } from '../src/parse'; import { PslSources } from '../src/source-file'; @@ -45,33 +50,50 @@ const TYPE_CONSTRUCTORS: AuthoringTypeNamespace = { pgvector: { Vector: scalar('vector') }, }; -const fieldRefList = { kind: 'list', of: { kind: 'fieldRef' } } as const; -const referencedFieldRefList = { kind: 'list', of: { kind: 'referencedFieldRef' } } as const; +const fieldListParam = (key: string) => ({ + key, + type: list(fieldRef()), + documentation: 'fixture', +}); + +const modelSpec = (name: string, positional: ReturnType[]) => + modelAttribute(name, { documentation: 'fixture', positional }); -const MODEL_SPECS: Readonly>> = { - id: { positional: [{ key: 'fields', type: fieldRefList }], named: {} }, - index: { positional: [{ key: 'fields', type: fieldRefList }], named: {} }, - unique: { positional: [{ key: 'fields', type: fieldRefList }], named: {} }, - base: { positional: [{ key: 'model', type: { kind: 'entityRef' } }], named: {} }, - map: { positional: [{ key: 'name', type: { kind: 'str' } }], named: {} }, - borrowed: { positional: [{ key: 'fields', type: referencedFieldRefList }], named: {} }, +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(), documentation: 'fixture' }], + }), + map: () => + modelAttribute('map', { + documentation: 'fixture', + positional: [{ key: 'name', type: str(), documentation: 'fixture' }], + }), }; -const FIELD_SPECS: Readonly>> = { - id: { positional: [], named: {} }, - relation: { - positional: [], - named: { - fields: { type: fieldRefList }, - references: { type: referencedFieldRefList }, - name: { type: { kind: 'str' } }, - }, - }, +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: AttributeSpecRegistry = { - model: (name) => MODEL_SPECS[name], - field: (name) => FIELD_SPECS[name], +const ATTRIBUTE_SPECS = { model: MODEL_SPECS, field: FIELD_SPECS }; + +const NO_CONTROL_DEFAULTS = { + defaultFunctionRegistry: new Map(), + defaultLiteralTagRegistry: new Map(), }; function attributeNodes( @@ -129,6 +151,7 @@ function bind(...texts: string[]) { symbolTable, typeConstructors: TYPE_CONSTRUCTORS, attributeSpecs: ATTRIBUTE_SPECS, + controlMutationDefaults: NO_CONTROL_DEFAULTS, }), }; } @@ -474,37 +497,41 @@ const RELATION_SCHEMA = [ ].join('\n'); describe('createBinder — attribute names', () => { - it('resolves an attribute name to its spec', () => { + 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'))).toEqual({ - kind: 'attributeSpec', - spec: FIELD_SPECS['relation'], + 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'))).toEqual({ - kind: 'attributeSpec', - spec: FIELD_SPECS['id'], + expect(binder.symbolForNode(attributeNameNode(post.fields['id']!, 'id'))).toMatchObject({ + kind: 'attribute', + symbol: { kind: 'attribute', name: 'id', level: 'field' }, }); }); - it('reports an unknown attribute name at model and field level', () => { + 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'))).toEqual({ - kind: 'unresolved', - name: 'bogus', - }); - expect(binder.symbolForNode(attributeNameNode(user, 'nope'))).toEqual({ - kind: 'unresolved', - name: 'nope', - }); - expect(diagnostics.map(({ code, message }) => [code, message])).toEqual([ - ['PSL_UNRESOLVED_ATTRIBUTE', 'Cannot find attribute "@@nope"'], - ['PSL_UNRESOLVED_ATTRIBUTE', 'Cannot find attribute "@bogus"'], - ]); + 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', () => { @@ -720,7 +747,6 @@ describe('createBinder — diagnostics completeness', () => { ' author User @relation(fields: [missingLocal], references: [absent])', ' @@index([id, alsoMissing])', ' @@base(NoSuchModel)', - ' @@mystery', '}', ].join('\n'), ); @@ -736,7 +762,6 @@ describe('createBinder — diagnostics completeness', () => { ['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_ATTRIBUTE', 'Cannot find attribute "@@mystery"', '0.psl', 10], ['PSL_UNRESOLVED_REFERENCE', 'Cannot find field "missingLocal" on "Post"', '0.psl', 7], [ 'PSL_UNRESOLVED_REFERENCE', @@ -778,9 +803,9 @@ describe('attribute-spec registry shape', () => { references: { type: list(referencedFieldRef()), documentation: 'fixture' }, }, }); - const registry: AttributeSpecRegistry = { - model: (name) => (name === 'index' ? assembled : name === 'base' ? base : undefined), - field: (name) => (name === 'relation' ? relation : undefined), + const registry = { + model: { index: () => assembled, base: () => base }, + field: { relation: () => relation }, }; const { sources, symbolTable } = build( @@ -800,6 +825,7 @@ describe('attribute-spec registry shape', () => { symbolTable, typeConstructors: TYPE_CONSTRUCTORS, attributeSpecs: registry, + controlMutationDefaults: NO_CONTROL_DEFAULTS, }); const user = symbolTable.topLevel.models['User']!; const post = symbolTable.topLevel.models['Post']!; @@ -847,21 +873,6 @@ describe('referencedFieldRef on a cross-space list', () => { }); describe('createBinder — reference slots the binder stays silent about', () => { - it('stays wordless for a referencedFieldRef outside a field attribute', () => { - const { symbolTable, binder, diagnostics } = bind( - 'model User {\n id Int\n @@borrowed([id])\n}', - ); - const user = symbolTable.topLevel.models['User']!; - const [node] = attributeNodes(user, 'borrowed'); - - expect(node).toBeDefined(); - expect(node === undefined ? undefined : binder.symbolForNode(node)).toEqual({ - kind: 'unresolved', - name: 'id', - }); - expect(diagnostics).toEqual([]); - }); - 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'), @@ -879,22 +890,24 @@ describe('createBinder — reference slots the binder stays silent about', () => }); }); -describe('owner-aware attribute-spec registry', () => { - it('passes the owner and field so a context-dependent spec is visible', () => { +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: AttributeSpecRegistry = { - model: (name, owner) => { - seen.push(`model:${name}:${owner.name}`); - return name === 'index' - ? { positional: [{ key: 'fields', type: fieldRefList }], named: {} } - : undefined; + const registry = { + model: { + index: (ctx: AttributeSpecContext) => { + seen.push(`model:index:${ctx.model.name}`); + return modelSpec('index', [fieldListParam('fields')]); + }, }, - field: (name, owner, field) => { - seen.push(`field:${name}:${owner.name}.${field.name}`); - return name === 'contextual' ? { positional: [], named: {} } : undefined; + field: { + contextual: (ctx: FieldAttributeSpecContext) => { + seen.push(`field:contextual:${ctx.model.name}.${ctx.field.name}`); + return fieldAttribute('contextual', { documentation: 'fixture' }); + }, }, }; const { binder, diagnostics } = createBinder({ @@ -902,15 +915,18 @@ describe('owner-aware attribute-spec registry', () => { 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'))).toEqual({ - kind: 'attributeSpec', - spec: { positional: [], named: {} }, + 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', @@ -920,7 +936,7 @@ describe('owner-aware attribute-spec registry', () => { }); describe('binder diagnostics carry their reference class', () => { - it('tags type, field, entity and attribute failures distinctly', () => { + it('tags type, field and entity failures distinctly', () => { const { diagnostics } = bind( [ 'model Post {', @@ -928,7 +944,6 @@ describe('binder diagnostics carry their reference class', () => { ' id Int', ' @@index([missingField])', ' @@base(NoSuchModel)', - ' @@mystery', '}', ].join('\n'), ); @@ -937,7 +952,6 @@ describe('binder diagnostics carry their reference class', () => { ['PSL_UNRESOLVED_REFERENCE', 'type'], ['PSL_UNRESOLVED_REFERENCE', 'field'], ['PSL_UNRESOLVED_REFERENCE', 'entity'], - ['PSL_UNRESOLVED_ATTRIBUTE', 'attribute'], ]); }); }); @@ -964,21 +978,6 @@ describe('the references table records what it examined', () => { }); }); - it('records an unresolved entry for a referenced field with no declaring field', () => { - const { symbolTable, binder, diagnostics } = bind( - 'model User {\n id Int\n @@borrowed([id])\n}', - ); - const user = symbolTable.topLevel.models['User']!; - const [node] = attributeNodes(user, 'borrowed'); - - expect(node).toBeDefined(); - expect(node === undefined ? undefined : binder.symbolForNode(node)).toEqual({ - kind: 'unresolved', - name: 'id', - }); - expect(diagnostics).toEqual([]); - }); - it('leaves a malformed type unexamined', () => { const { symbolTable, binder } = bind('model Cart {\n value a.b.c\n}'); From b259dda560e404d9407ce441ad317a82d8c7197f Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Tue, 22 Sep 2026 09:08:36 +0000 Subject: [PATCH 35/66] refactor(sql,mongo): pass the attribute specs straight to the binder Both packages kept an adapter that reshaped their spec tables into the binder's view and answered permissively for names they did not own. The binder takes the tables now, so the adapters go - and with them the two keyof casts the SQL and Mongo lookups needed. Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- .../contract-psl/src/mongo-attribute-specs.ts | 34 +----------- .../contract-psl/src/sql-attribute-specs.ts | 53 ++----------------- 2 files changed, 7 insertions(+), 80 deletions(-) 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 132a56c4273f..71de9e82fba9 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 @@ -11,12 +11,10 @@ import type { Binder, FieldAttributeCtx, FieldAttributeSpecContext, - FieldAttributeSpecFactory, FieldSymbol, FuncCallSig, InferAttr, ModelAttributeCtx, - ModelAttributeSpecFactory, ModelSymbol, PslDiagnostic, SymbolTable, @@ -102,40 +100,12 @@ export function createMongoBinder(input: { for (const [name, codecId] of input.scalarTypeCodecIds) { scalars[name] = { kind: 'typeConstructor', output: { codecId } }; } - const noReferences = { positional: [], named: {} }; - const modelSpecFactories: Readonly> = - mongoAttributeSpecs.model; - const fieldSpecFactories: Readonly> = - mongoAttributeSpecs.field; return createBinder({ sources: input.sources, symbolTable: input.symbolTable, typeConstructors: { ...scalars, ...(input.authoringContributions?.type ?? {}) }, - attributeSpecs: { - model: (name, owner) => { - const factory = Object.hasOwn(modelSpecFactories, name) - ? modelSpecFactories[name] - : undefined; - if (factory === undefined || owner.kind !== 'model') return noReferences; - return factory({ - symbols: input.symbolTable, - model: owner, - controlMutationDefaults: input.controlMutationDefaults, - }); - }, - field: (name, owner, declaringField) => { - const factory = Object.hasOwn(fieldSpecFactories, name) - ? fieldSpecFactories[name] - : undefined; - if (factory === undefined || owner.kind !== 'model') return noReferences; - return factory({ - symbols: input.symbolTable, - model: owner, - field: declaringField, - controlMutationDefaults: input.controlMutationDefaults, - }); - }, - }, + attributeSpecs: mongoAttributeSpecs, + controlMutationDefaults: input.controlMutationDefaults, }); } 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 c378048946d5..7eb6281f43f0 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 @@ -13,8 +13,6 @@ import type { AttributeSpec, AttributeSpecContext, AttributeSpecNamespace, - AttributeSpecRegistry, - AttributeSpecView, Binder, FieldAttributeCtx, FieldAttributeSpecContext, @@ -109,48 +107,6 @@ function buildFieldAttributeCtx(input: { }; } -export function sqlBinderAttributeSpecs(input: { - readonly symbolTable: SymbolTable; - readonly controlMutationDefaults?: ControlDefaultRegistries | undefined; -}): AttributeSpecRegistry { - const model: Readonly> = { - map: mapModelSpec, - id: idModelSpec, - unique: uniqueModelSpec, - index: indexModelSpec, - check: checkModelSpec, - control: controlModelSpec, - discriminator: discriminatorModelSpec, - base: baseModelSpec, - }; - const field: Readonly> = { - map: mapFieldSpec, - id: idFieldSpec, - unique: uniqueFieldSpec, - noCheck: noCheckFieldSpec, - relation: relationFieldSpec, - }; - const { controlMutationDefaults } = input; - const noReferences: AttributeSpecView = { positional: [], named: {} }; - return { - model: (name) => (Object.hasOwn(model, name) ? model[name] : noReferences), - field: (name, owner, declaringField) => { - if (Object.hasOwn(field, name)) return field[name]; - if (name === 'default' && controlMutationDefaults !== undefined && owner.kind === 'model') { - return sqlAttributeSpecs.field.default( - fieldSpecContext({ - symbols: input.symbolTable, - model: owner, - field: declaringField, - controlMutationDefaults, - }), - ); - } - return noReferences; - }, - }; -} - function fieldPresetsAsTypeNames( namespace: AuthoringFieldNamespace | undefined, ): AuthoringTypeNamespace { @@ -183,10 +139,11 @@ export function createSqlBinder(input: { ...fieldPresetsAsTypeNames(input.authoringContributions?.field), ...(input.authoringContributions?.type ?? {}), }, - attributeSpecs: sqlBinderAttributeSpecs({ - symbolTable: input.symbolTable, - controlMutationDefaults: input.controlMutationDefaults, - }), + attributeSpecs: sqlAttributeSpecs, + controlMutationDefaults: input.controlMutationDefaults ?? { + defaultFunctionRegistry: new Map(), + defaultLiteralTagRegistry: new Map(), + }, }); } From 2b53c31d23fd193ff8118b326a825e59dddca5f5 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Tue, 22 Sep 2026 10:01:30 +0000 Subject: [PATCH 36/66] feat(psl): let the binder voice unsupported attribute names through a callback The binder already knows which attribute names the bound spec namespace registers; until now it skipped an unregistered name in silence and left every target to rediscover the same fact with its own scan. createBinder now takes an optional describeUnsupportedAttribute, called once per unregistered name with the attribute, its level, its owner and (at field level) the field. A returned diagnostic joins the binder own diagnostics; undefined, or an omitted callback, keeps the silence. Signed-off-by: Steven McClankerton --- .../2-authoring/psl-parser/src/binder.ts | 55 +++++++-- .../psl-parser/src/exports/index.ts | 2 + .../psl-parser/test/binder.test.ts | 114 +++++++++++++++++- 3 files changed, 158 insertions(+), 13 deletions(-) diff --git a/packages/1-framework/2-authoring/psl-parser/src/binder.ts b/packages/1-framework/2-authoring/psl-parser/src/binder.ts index 053bd6babf65..e11198de07f3 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/binder.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/binder.ts @@ -65,12 +65,24 @@ export interface Binder { 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 { @@ -109,8 +121,14 @@ interface Owner { } export function createBinder(options: CreateBinderOptions): BinderResult { - const { sources, symbolTable, typeConstructors, attributeSpecs, controlMutationDefaults } = - options; + const { + sources, + symbolTable, + typeConstructors, + attributeSpecs, + controlMutationDefaults, + describeUnsupportedAttribute, + } = options; const contributedTypes = contributedTypeScope(typeConstructors); const declarations = new WeakMap(); const references = new WeakMap(); @@ -159,6 +177,7 @@ export function createBinder(options: CreateBinderOptions): BinderResult { diagnostics, symbolTable, sources, + describeUnsupportedAttribute, }; const specContext = symbol.kind === 'model' @@ -167,18 +186,16 @@ export function createBinder(options: CreateBinderOptions): BinderResult { bindAttributes( symbol, symbol.attributes, - (name) => - specContext === undefined ? undefined : own(attributeSpecs.model, name)?.(specContext), + attributeSpecs.model, + (factory) => (specContext === undefined ? undefined : factory(specContext)), { ...context, field: undefined }, ); for (const field of Object.values(symbol.fields)) { bindAttributes( field, field.attributes, - (name) => - specContext === undefined - ? undefined - : own(attributeSpecs.field, name)?.({ ...specContext, field }), + attributeSpecs.field, + (factory) => (specContext === undefined ? undefined : factory({ ...specContext, field })), { ...context, field }, ); } @@ -195,18 +212,32 @@ interface BindContext { readonly diagnostics: ParseDiagnostic[]; readonly symbolTable: SymbolTable; readonly sources: PslSources; + readonly describeUnsupportedAttribute: DescribeUnsupportedAttribute | undefined; } -function bindAttributes( +function bindAttributes( holder: ModelSymbol | CompositeTypeSymbol | FieldSymbol, attributes: readonly ResolvedAttribute[], - lookupSpec: (name: string) => BoundSpec | undefined, + 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 spec = lookupSpec(attribute.name); + 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) { @@ -215,7 +246,7 @@ function bindAttributes( symbol: { kind: 'attribute', name: attribute.name, - level: holder.kind === 'field' ? 'field' : 'model', + level, spec, }, }); 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 de49fc37530a..0c53b90f1908 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 @@ -97,8 +97,10 @@ export type { BinderResult, BoundSpec, CreateBinderOptions, + DescribeUnsupportedAttribute, PslSymbol, Resolution, + UnsupportedAttribute, } from '../binder'; export { createBinder, 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 index a972b6510bdf..b6902fb9b40b 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/binder.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/binder.test.ts @@ -14,7 +14,12 @@ import type { AttributeSpecContext, FieldAttributeSpecContext, } from '../src/attribute-spec/spec-context'; -import { createBinder, typeReferenceNode } from '../src/binder'; +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'; @@ -156,6 +161,24 @@ function bind(...texts: string[]) { }; } +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 = @@ -547,6 +570,95 @@ describe('createBinder — attribute names', () => { }); }); +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); From 256e79b69e440fb77da0bdd0b5ba5676b3f77d9a Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Tue, 22 Sep 2026 10:01:48 +0000 Subject: [PATCH 37/66] refactor(sql,mongo): voice unsupported attributes from the binder callback SQL and Mongo each walked every attribute a second time to rediscover the names their own spec namespace does not register. Both scans are gone; each family now hands the binder a describeUnsupportedAttribute that phrases the same verdicts. SQL keeps its four-branch cascade intact - contributed model attributes stay silent, db.* attributes get the migration prose, an uncomposed extension namespace gets PSL_EXTENSION_NAMESPACE_NOT_COMPOSED, and the rest get the base message plus any removed-attribute hint. Codes and wording are unchanged at both levels, and reportUncomposedNamespace now delegates to uncomposedNamespaceDiagnostic so the prose has one home. Signed-off-by: Steven McClankerton --- .../contract-psl/src/interpreter.ts | 56 ----------- .../contract-psl/src/mongo-attribute-specs.ts | 30 ++++++ .../contract-psl/src/interpreter.ts | 38 +++----- .../contract-psl/src/psl-column-resolution.ts | 27 +++++- .../contract-psl/src/psl-field-resolution.ts | 96 ++++++++++--------- .../contract-psl/src/sql-attribute-specs.ts | 3 + 6 files changed, 119 insertions(+), 131 deletions(-) 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 06e56c82786d..5614a273ccbf 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 @@ -130,55 +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 binder: Binder; - 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; } @@ -1120,13 +1071,6 @@ export function interpretPslDocumentToMongoContract( 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, - binder, - diagnostics, - }); const specContextFor = (model: ModelSymbol): AttributeSpecContext => ({ symbols: symbolTable, model, 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 71de9e82fba9..3bf67b7887b7 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 @@ -9,6 +9,7 @@ import type { AttributeSpecContext, AttributeSpecNamespace, Binder, + DescribeUnsupportedAttribute, FieldAttributeCtx, FieldAttributeSpecContext, FieldSymbol, @@ -23,6 +24,7 @@ import type { import { bool, createBinder, + diagnosticSource, entityRef, fieldAttribute, fieldRef, @@ -89,6 +91,33 @@ function buildFieldAttributeCtx(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 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; @@ -106,6 +135,7 @@ export function createMongoBinder(input: { typeConstructors: { ...scalars, ...(input.authoringContributions?.type ?? {}) }, attributeSpecs: mongoAttributeSpecs, controlMutationDefaults: input.controlMutationDefaults, + describeUnsupportedAttribute: describeUnsupportedMongoAttribute(input.sources), }); } 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 c9a227d039b4..adc5f699d711 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts @@ -94,12 +94,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, @@ -884,30 +884,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') { @@ -2081,14 +2057,23 @@ export function interpretPslDocumentToSqlContract( ): Result { const source = diagnosticSource(input.sources, input.document.syntax); const diagnostics = createPslDiagnosticCollector(input.sources); + const composedExtensionNames = new Set(input.composedExtensions ?? []); + const modelAttributesByName = buildModelAttributesByName(input.authoringContributions); const { binder, diagnostics: binderDiagnostics } = createSqlBinder({ symbolTable: input.symbolTable, sources: input.sources, authoringContributions: input.authoringContributions, controlMutationDefaults: input.controlMutationDefaults, scalarColumnDescriptors: input.scalarColumnDescriptors, + describeUnsupportedAttribute: describeUnsupportedSqlAttribute({ + composedExtensions: composedExtensionNames, + authoringContributions: input.authoringContributions, + sources: input.sources, + familyId: input.target?.familyId, + targetId: input.target?.targetId, + contributedModelAttributeNames: new Set(modelAttributesByName.keys()), + }), }); - const composedExtensionNames = new Set(input.composedExtensions ?? []); diagnostics.push( ...binderDiagnostics.filter( (diagnostic) => @@ -2283,7 +2268,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. 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 0500ecaaaa02..64f3e0c06fc6 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 @@ -35,6 +35,7 @@ import type { ModelSymbol, NumLiteral, ParsedTaggedLiteral, + PslDiagnostic, PslSpan, ResolvedTypeConstructorCall, SymbolTable, @@ -203,19 +204,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, + }), + ); } /** 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 705b8cf63b12..cf55b5ebb586 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 @@ -12,6 +12,7 @@ import type { } from '@internal/framework-components/control'; import type { Binder, + DescribeUnsupportedAttribute, FieldSymbol, ModelSymbol, ResolvedAttribute, @@ -33,8 +34,8 @@ import type { ColumnDescriptor, FieldPresetContributions } from './psl-column-re import { checkUncomposedNamespace, lowerDefaultForField, - reportUncomposedNamespace, resolveFieldTypeDescriptor, + uncomposedNamespaceDiagnostic, } from './psl-column-resolution'; import { fieldSpecContext, @@ -218,60 +219,81 @@ 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 binder: Binder; - 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; + readonly contributedModelAttributeNames: ReadonlySet; +}): DescribeUnsupportedAttribute { + const namespaceContext = { + ...ifDefined('familyId', input.familyId), + ...ifDefined('targetId', input.targetId), + authoringContributions: input.authoringContributions, + }; + return ({ attribute, level, owner, field }) => { + if (level === 'model') { + if (input.contributedModelAttributeNames.has(attribute.name)) return undefined; + 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: { @@ -440,18 +462,6 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv continue; } - validateFieldAttributes({ - model, - field, - composedExtensions, - authoringContributions, - diagnostics, - sources, - binder, - familyId, - targetId, - }); - const relationAttribute = getAttribute(field.attributes, 'relation'); if (isModelField && relationAttribute) { continue; 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 7eb6281f43f0..5ff7444e0cf9 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 @@ -14,6 +14,7 @@ import type { AttributeSpecContext, AttributeSpecNamespace, Binder, + DescribeUnsupportedAttribute, FieldAttributeCtx, FieldAttributeSpecContext, FieldSymbol, @@ -126,6 +127,7 @@ export function createSqlBinder(input: { readonly authoringContributions?: AuthoringContributions | undefined; readonly controlMutationDefaults?: ControlDefaultRegistries | undefined; readonly scalarColumnDescriptors?: ReadonlyMap | undefined; + readonly describeUnsupportedAttribute?: DescribeUnsupportedAttribute | undefined; }): { readonly binder: Binder; readonly diagnostics: readonly PslDiagnostic[] } { const scalars: Record = {}; for (const [name, descriptor] of input.scalarColumnDescriptors ?? []) { @@ -144,6 +146,7 @@ export function createSqlBinder(input: { defaultFunctionRegistry: new Map(), defaultLiteralTagRegistry: new Map(), }, + describeUnsupportedAttribute: input.describeUnsupportedAttribute, }); } From cf1fb191261b65f0cc8474478e8d56d262ef7933 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Tue, 22 Sep 2026 10:12:33 +0000 Subject: [PATCH 38/66] chore(drive): record callback-inversion decision and round learnings Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- projects/symbol-table-resolve/design-decisions.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/projects/symbol-table-resolve/design-decisions.md b/projects/symbol-table-resolve/design-decisions.md index 4db284ded282..d6f6845d0a7a 100644 --- a/projects/symbol-table-resolve/design-decisions.md +++ b/projects/symbol-table-resolve/design-decisions.md @@ -94,6 +94,12 @@ Outcome of the design discussion (2026-09-18) that preceded [`spec.md`](./spec.m **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. Open-question resolutions (2026-09-18) +## 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. 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. From 997791eec5d5b6195e721aa5d98b09a37f9f3668 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Tue, 22 Sep 2026 12:44:43 +0000 Subject: [PATCH 39/66] refactor(psl): name the binder walk set entities, not owners Owner is a relation - the owner OF a field, of an attribute - and the binder uses it correctly in BindContext, resolveOwnerField and the unsupported-attribute callback. It was also doing second duty as a category noun for the models-and-composite-types union, which is the set entityRef already resolves and the set AuthoringContributions calls entityTypes. The category positions take that word: interface Owner becomes ScopedEntity with an entity field, and the owners() generator becomes entities(). Every relational owner stays as it was, the callback surface included, so no consumer moves. Signed-off-by: Steven McClankerton --- .../2-authoring/psl-parser/src/binder.ts | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/1-framework/2-authoring/psl-parser/src/binder.ts b/packages/1-framework/2-authoring/psl-parser/src/binder.ts index e11198de07f3..a80d4b8e9c38 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/binder.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/binder.ts @@ -115,9 +115,9 @@ class PslBinder implements Binder { } } -interface Owner { +interface ScopedEntity { readonly scope: NamespaceSymbol | undefined; - readonly symbol: ModelSymbol | CompositeTypeSymbol; + readonly entity: ModelSymbol | CompositeTypeSymbol; } export function createBinder(options: CreateBinderOptions): BinderResult { @@ -149,9 +149,9 @@ export function createBinder(options: CreateBinderOptions): BinderResult { } } - for (const { scope, symbol } of owners(symbolTable)) { - declarations.set(symbol.node.syntax, symbol); - for (const field of Object.values(symbol.fields)) { + for (const { scope, entity } of entities(symbolTable)) { + 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; @@ -169,9 +169,9 @@ export function createBinder(options: CreateBinderOptions): BinderResult { } } - for (const { scope, symbol } of owners(symbolTable)) { + for (const { scope, entity } of entities(symbolTable)) { const context = { - owner: symbol, + owner: entity, scope, references, diagnostics, @@ -180,17 +180,17 @@ export function createBinder(options: CreateBinderOptions): BinderResult { describeUnsupportedAttribute, }; const specContext = - symbol.kind === 'model' - ? { symbols: symbolTable, model: symbol, controlMutationDefaults } + entity.kind === 'model' + ? { symbols: symbolTable, model: entity, controlMutationDefaults } : undefined; bindAttributes( - symbol, - symbol.attributes, + entity, + entity.attributes, attributeSpecs.model, (factory) => (specContext === undefined ? undefined : factory(specContext)), { ...context, field: undefined }, ); - for (const field of Object.values(symbol.fields)) { + for (const field of Object.values(entity.fields)) { bindAttributes( field, field.attributes, @@ -380,13 +380,13 @@ function referenceNodes(expression: ExpressionAst): readonly SyntaxNode[] { return Array.from(array.elements(), (element) => element.syntax); } -function* owners(symbolTable: SymbolTable): Iterable { +function* entities(symbolTable: SymbolTable): Iterable { const { topLevel } = symbolTable; - for (const symbol of Object.values(topLevel.models)) yield { scope: undefined, symbol }; - for (const symbol of Object.values(topLevel.compositeTypes)) yield { scope: undefined, symbol }; + for (const entity of Object.values(topLevel.models)) yield { scope: undefined, entity }; + for (const entity of Object.values(topLevel.compositeTypes)) yield { scope: undefined, entity }; for (const scope of Object.values(topLevel.namespaces)) { - for (const symbol of Object.values(scope.models)) yield { scope, symbol }; - for (const symbol of Object.values(scope.compositeTypes)) yield { scope, symbol }; + for (const entity of Object.values(scope.models)) yield { scope, entity }; + for (const entity of Object.values(scope.compositeTypes)) yield { scope, entity }; } } From 239a22f5b30067943e4ac38dfe7d28350d145ead Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Tue, 22 Sep 2026 12:45:23 +0000 Subject: [PATCH 40/66] chore(drive): align spec pseudo-code with entity vocabulary Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- projects/symbol-table-resolve/spec.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/projects/symbol-table-resolve/spec.md b/projects/symbol-table-resolve/spec.md index 2820c63e3d8b..fac577c1e7e7 100644 --- a/projects/symbol-table-resolve/spec.md +++ b/projects/symbol-table-resolve/spec.md @@ -39,9 +39,9 @@ function createBinder({ sources, symbolTable, typeConstructors, attributeSpecs } // PHASE 1: declarations + type references for (const scope of scopesOf(symbolTable)) - for (const owner of [...scope.models, ...scope.compositeTypes]) { - declarations.set(owner.node.syntax, owner); - for (const field of owner.fields) { + 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))); @@ -49,18 +49,18 @@ function createBinder({ sources, symbolTable, typeConstructors, attributeSpecs } } // PHASE 2: attribute references (reads phase-1 results; no cycle — type refs never need attribute refs) - for (const [scope, owner] of ownersOf(symbolTable)) { - for (const attr of owner.attributes) { // ResolvedAttribute[] — args already parsed + 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: owner, scope }); + if (spec) bindArgs(attr, spec, { self: entity, scope }); } - for (const field of owner.fields) + 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: owner, field, referencedModel, scope }); + if (spec) bindArgs(attr, spec, { self: entity, field, referencedModel, scope }); } } From f5ae8b74d514424e29b9c33d6af8a280d896eac9 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Tue, 22 Sep 2026 13:04:47 +0000 Subject: [PATCH 41/66] fix(psl): build every symbol-table record without a prototype Only the namespace records were prototype-less; the top-level models, composite types, named types and blocks, and every fields record, were plain literals. A schema declaring nothing named toString still answered topLevel.models["toString"] with Object.prototype.toString, and every consumer that indexes these records unguarded - SQL, the language server - inherited that answer. buildSymbolTable now produces Object.create(null) for all of them, so plain indexing under noUncheckedIndexedAccess returns undefined for a name nobody declared. The binder reads the records directly and keeps own() only where the record comes from a consumer literal whose prototype is still attached: the attribute-spec namespace and a spec own named parameters. Signed-off-by: Steven McClankerton --- .../2-authoring/psl-parser/src/binder.ts | 28 +++---- .../psl-parser/src/symbol-table.ts | 10 +-- .../psl-parser/test/binder.test.ts | 75 +++++++++++++++++++ .../psl-parser/test/symbol-table.test.ts | 55 ++++++++++++++ 4 files changed, 149 insertions(+), 19 deletions(-) diff --git a/packages/1-framework/2-authoring/psl-parser/src/binder.ts b/packages/1-framework/2-authoring/psl-parser/src/binder.ts index a80d4b8e9c38..e6d0eaf6709d 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/binder.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/binder.ts @@ -288,7 +288,7 @@ function resolveArgument( } function resolveOwnerField(name: string, node: SyntaxNode, ctx: BindContext): Resolution { - const field = own(ctx.owner.fields, name); + 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 }; @@ -301,7 +301,7 @@ function resolveReferencedField(name: string, node: SyntaxNode, ctx: BindContext const typeNode = typeReferenceNode(declaring); const target = typeNode === undefined ? undefined : ctx.references.get(typeNode); const fields = targetFields(target); - const field = fields === undefined ? undefined : own(fields, name); + 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}"`, @@ -330,14 +330,14 @@ function resolveEntity(name: string, node: SyntaxNode, ctx: BindContext): Resolu function entityNamed(name: string, ctx: BindContext): Resolution | undefined { const { scope, symbolTable } = ctx; if (scope !== undefined) { - const model = own(scope.models, name); + const model = scope.models[name]; if (model !== undefined) return { kind: 'model', symbol: model }; - const compositeType = own(scope.compositeTypes, name); + const compositeType = scope.compositeTypes[name]; if (compositeType !== undefined) return { kind: 'compositeType', symbol: compositeType }; } - const model = own(symbolTable.topLevel.models, name); + const model = symbolTable.topLevel.models[name]; if (model !== undefined) return { kind: 'model', symbol: model }; - const compositeType = own(symbolTable.topLevel.compositeTypes, name); + const compositeType = symbolTable.topLevel.compositeTypes[name]; if (compositeType !== undefined) return { kind: 'compositeType', symbol: compositeType }; return undefined; } @@ -403,7 +403,7 @@ function resolveTypeReference( const namespaceId = field.typeNamespaceId; if (namespaceId !== undefined) { - const namespace = own(topLevel.namespaces, namespaceId); + const namespace = topLevel.namespaces[namespaceId]; const declared = namespace === undefined ? undefined : inNamespace(namespace, name); if (declared !== undefined) return declared; const contributedSymbol = contributedTypes.lookup([namespaceId, name]); @@ -423,23 +423,23 @@ function resolveTypeReference( } function inNamespace(namespace: NamespaceSymbol, name: string): Resolution | undefined { - const model = own(namespace.models, name); + const model = namespace.models[name]; if (model !== undefined) return { kind: 'model', symbol: model }; - const compositeType = own(namespace.compositeTypes, name); + const compositeType = namespace.compositeTypes[name]; if (compositeType !== undefined) return { kind: 'compositeType', symbol: compositeType }; - const block = own(namespace.blocks, name); + const block = namespace.blocks[name]; if (block !== undefined) return { kind: 'block', symbol: block }; return undefined; } function inTopLevel(topLevel: TopLevelScope, name: string): Resolution | undefined { - const model = own(topLevel.models, name); + const model = topLevel.models[name]; if (model !== undefined) return { kind: 'model', symbol: model }; - const compositeType = own(topLevel.compositeTypes, name); + const compositeType = topLevel.compositeTypes[name]; if (compositeType !== undefined) return { kind: 'compositeType', symbol: compositeType }; - const namedType = own(topLevel.namedTypes, name); + const namedType = topLevel.namedTypes[name]; if (namedType !== undefined) return { kind: 'namedType', symbol: namedType }; - const block = own(topLevel.blocks, name); + const block = topLevel.blocks[name]; if (block !== undefined) return { kind: 'block', symbol: block }; return undefined; } diff --git a/packages/1-framework/2-authoring/psl-parser/src/symbol-table.ts b/packages/1-framework/2-authoring/psl-parser/src/symbol-table.ts index b7644650735d..378b92761edc 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/symbol-table.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/symbol-table.ts @@ -137,10 +137,10 @@ export function buildSymbolTable(options: BuildSymbolTableOptions): SymbolTableR const diagnostics: ParseDiagnostic[] = []; const namespaces: Record = Object.create(null); - const namedTypes: Record = {}; - const blocks: Record = {}; - const models: Record = {}; - const compositeTypes: Record = {}; + const namedTypes: Record = Object.create(null); + const blocks: Record = Object.create(null); + const models: Record = Object.create(null); + const compositeTypes: Record = Object.create(null); const topLevelNames = new Set(); for (const document of documents) { @@ -316,7 +316,7 @@ function buildFields( sources: PslSources, diagnostics: ParseDiagnostic[], ): Record { - const result: Record = {}; + const result: Record = Object.create(null); for (const field of fields) { const nameNode = field.name(); const name = nameNode?.name(); 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 index b6902fb9b40b..ffe4df53bca7 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/binder.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/binder.test.ts @@ -984,6 +984,81 @@ describe('referencedFieldRef on a cross-space list', () => { }); }); +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'], + }); + }); + + 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( 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 {} From 04c1ec7f48ffeb2f73d8aedc4c555598871f271b Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Tue, 22 Sep 2026 13:05:35 +0000 Subject: [PATCH 42/66] chore(drive): record kind-blind scoping decree (decision 12) Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- projects/symbol-table-resolve/design-decisions.md | 10 +++++++++- projects/symbol-table-resolve/spec.md | 2 +- projects/symbol-table-resolve/trace.jsonl | 1 + 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/projects/symbol-table-resolve/design-decisions.md b/projects/symbol-table-resolve/design-decisions.md index d6f6845d0a7a..b4021be287f3 100644 --- a/projects/symbol-table-resolve/design-decisions.md +++ b/projects/symbol-table-resolve/design-decisions.md @@ -100,6 +100,14 @@ Outcome of the design discussion (2026-09-18) that preceded [`spec.md`](./spec.m **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. Open-question resolutions (2026-09-18) +## 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. + +## 13. 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/spec.md b/projects/symbol-table-resolve/spec.md index fac577c1e7e7..6256d74159a4 100644 --- a/projects/symbol-table-resolve/spec.md +++ b/projects/symbol-table-resolve/spec.md @@ -109,7 +109,7 @@ Interpreters still run spec interpretation for argument **values**; the binder o ## Cross-cutting requirements -1. **One scoping rule everywhere.** Unqualified references resolve declaring namespace → top level → contributed-type scope; sibling namespaces are never consulted. User declarations shadow contributed-type symbols silently — no shadowing diagnostic. Every converted consumer carries a test pinning this rule against a schema where a namespaced declaration shadows a top-level one. +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. diff --git a/projects/symbol-table-resolve/trace.jsonl b/projects/symbol-table-resolve/trace.jsonl index 6b748455a383..044b20c074c7 100644 --- a/projects/symbol-table-resolve/trace.jsonl +++ b/projects/symbol-table-resolve/trace.jsonl @@ -66,3 +66,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"]} From d9b992a643f53a619722879b4e72436eec5d59bf Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Tue, 22 Sep 2026 13:18:04 +0000 Subject: [PATCH 43/66] fix(psl): assemble a spec named-parameter record without a prototype A spec stored its author literal straight through, so looking an argument name up on it answered @relation(toString: ...) with Object.prototype.toString. The record is now copied onto a null-proto object at assembly, which is where the guarantee belongs; the binder reads it by plain indexing. Signed-off-by: Steven McClankerton --- .../psl-parser/src/attribute-spec/field-attribute.ts | 2 +- .../psl-parser/src/attribute-spec/model-attribute.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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 } : {}), }; } From 038a967fee4ab3a1c5f4899472df3bdff2b7002b Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Tue, 22 Sep 2026 13:18:23 +0000 Subject: [PATCH 44/66] refactor(psl): resolve every reference through one kind-blind scope chain The binder had two resolution cascades: type references walked models, composite types, named types and blocks, while entity references walked only models and composite types. A namespaced enum therefore shadowed a top-level model for a field type but not for @@base, which saw past it to the outer model - two scoping rules for one language. Namespace, top-level and contributed-type scopes now share one shape, lookup(name), and resolution is a fold over the chain with first hit winning; a qualified reference builds the shorter chain rather than taking a separate branch. Within a scope a name denotes at most one symbol, which the table guarantees by claiming model, composite type, block, named type and namespace names from one set per scope, so the order inside a scope carries no meaning. What a reference site requires is checked after resolution. An entity reference that lands on an enum, a scalar or a namespace now says what the name is instead of claiming it cannot be found, and the top-level scope answers for its namespaces so a type reference naming one says so too. Signed-off-by: Steven McClankerton --- .../2-authoring/psl-parser/src/binder.ts | 211 +++++++++++++----- .../psl-parser/test/binder.test.ts | 95 +++++++- 2 files changed, 240 insertions(+), 66 deletions(-) diff --git a/packages/1-framework/2-authoring/psl-parser/src/binder.ts b/packages/1-framework/2-authoring/psl-parser/src/binder.ts index e6d0eaf6709d..2d0af6a129ab 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/binder.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/binder.ts @@ -54,6 +54,7 @@ export type Resolution = | { readonly kind: 'compositeType'; readonly symbol: CompositeTypeSymbol } | { readonly kind: 'namedType'; readonly symbol: NamedTypeSymbol } | { readonly kind: 'block'; readonly symbol: BlockSymbol } + | { readonly kind: 'namespace'; readonly symbol: NamespaceSymbol } | { readonly kind: 'contributedType'; readonly symbol: ContributedTypeSymbol } | { readonly kind: 'field'; readonly symbol: FieldSymbol } | { readonly kind: 'attribute'; readonly symbol: AttributeSymbol } @@ -158,11 +159,12 @@ export function createBinder(options: CreateBinderOptions): BinderResult { const resolution = resolveTypeReference(field, scope, symbolTable.topLevel, contributedTypes); if (resolution === undefined) continue; references.set(node, resolution); - if (resolution.kind === 'unresolved') { + const typeFailure = typePositionFailure(resolution, field); + if (typeFailure !== undefined) { diagnostics.push({ code: PSL_UNRESOLVED_REFERENCE, - message: `Cannot find type "${resolution.name}"`, - data: { reference: 'type', name: resolution.name }, + message: typeFailure.message, + data: { reference: 'type', name: typeFailure.name }, ...diagnosticSource(sources, node).at(), }); } @@ -177,6 +179,7 @@ export function createBinder(options: CreateBinderOptions): BinderResult { diagnostics, symbolTable, sources, + contributedTypes, describeUnsupportedAttribute, }; const specContext = @@ -212,6 +215,7 @@ interface BindContext { readonly diagnostics: ParseDiagnostic[]; readonly symbolTable: SymbolTable; readonly sources: PslSources; + readonly contributedTypes: ContributedTypeScope; readonly describeUnsupportedAttribute: DescribeUnsupportedAttribute | undefined; } @@ -258,8 +262,7 @@ function bindAttributes( 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++] : own(spec.named, arg.name); + 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; @@ -321,25 +324,23 @@ function targetFields( } function resolveEntity(name: string, node: SyntaxNode, ctx: BindContext): Resolution { - const entity = entityNamed(name, ctx); - if (entity !== undefined) return entity; - report(`Cannot find entity "${name}"`, node, ctx, 'entity'); - return { kind: 'unresolved', name }; -} - -function entityNamed(name: string, ctx: BindContext): Resolution | undefined { - const { scope, symbolTable } = ctx; - if (scope !== undefined) { - const model = scope.models[name]; - if (model !== undefined) return { kind: 'model', symbol: model }; - const compositeType = scope.compositeTypes[name]; - if (compositeType !== undefined) return { kind: 'compositeType', symbol: compositeType }; + const found = lookupIn( + unqualifiedChain(ctx.scope, ctx.symbolTable.topLevel, ctx.contributedTypes), + name, + ); + if (found === undefined) { + report(`Cannot find entity "${name}"`, node, ctx, 'entity'); + return { kind: 'unresolved', name }; } - const model = symbolTable.topLevel.models[name]; - if (model !== undefined) return { kind: 'model', symbol: model }; - const compositeType = symbolTable.topLevel.compositeTypes[name]; - if (compositeType !== undefined) return { kind: 'compositeType', symbol: compositeType }; - return undefined; + if (found.kind !== 'model' && found.kind !== 'compositeType') { + report( + `"${name}" is ${describeResolution(found)}; an entity reference must name a model or composite type`, + node, + ctx, + 'entity', + ); + } + return found; } function report( @@ -390,6 +391,120 @@ function* entities(symbolTable: SymbolTable): Iterable { } } +interface Scope { + lookup(name: string): Resolution | undefined; +} + +function namespaceScope(namespace: NamespaceSymbol): Scope { + return { + lookup(name) { + const model = namespace.models[name]; + if (model !== undefined) return { kind: 'model', symbol: model }; + const compositeType = namespace.compositeTypes[name]; + if (compositeType !== undefined) return { kind: 'compositeType', symbol: compositeType }; + const block = namespace.blocks[name]; + if (block !== undefined) return { kind: 'block', symbol: block }; + return undefined; + }, + }; +} + +function topLevelScope(topLevel: TopLevelScope): Scope { + return { + lookup(name) { + const model = topLevel.models[name]; + if (model !== undefined) return { kind: 'model', symbol: model }; + const compositeType = topLevel.compositeTypes[name]; + if (compositeType !== undefined) return { kind: 'compositeType', symbol: compositeType }; + const namedType = topLevel.namedTypes[name]; + if (namedType !== undefined) return { kind: 'namedType', symbol: namedType }; + const block = topLevel.blocks[name]; + if (block !== undefined) return { kind: 'block', symbol: block }; + const namespace = topLevel.namespaces[name]; + if (namespace !== undefined) return { kind: 'namespace', symbol: namespace }; + return undefined; + }, + }; +} + +function contributedScope( + contributedTypes: ContributedTypeScope, + prefix: readonly string[], +): Scope { + return { + lookup(name) { + const symbol = contributedTypes.lookup([...prefix, name]); + return symbol === undefined ? undefined : { kind: 'contributedType', symbol }; + }, + }; +} + +function unqualifiedChain( + scope: NamespaceSymbol | undefined, + topLevel: TopLevelScope, + contributedTypes: ContributedTypeScope, +): readonly Scope[] { + const outer = [topLevelScope(topLevel), contributedScope(contributedTypes, [])]; + return scope === undefined ? outer : [namespaceScope(scope), ...outer]; +} + +function qualifiedChain( + namespaceId: string, + topLevel: TopLevelScope, + contributedTypes: ContributedTypeScope, +): readonly Scope[] { + const namespace = topLevel.namespaces[namespaceId]; + const contributed = contributedScope(contributedTypes, [namespaceId]); + return namespace === undefined ? [contributed] : [namespaceScope(namespace), contributed]; +} + +function lookupIn(chain: readonly Scope[], name: string): Resolution | undefined { + for (const scope of chain) { + const found = scope.lookup(name); + if (found !== undefined) return found; + } + return undefined; +} + +function describeResolution(resolution: Resolution): 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 enum' + : `a ${resolution.symbol.keyword} block`; + case 'namespace': + return 'a namespace'; + case 'contributedType': + return 'a scalar type'; + default: + return 'not a declaration'; + } +} + +function typePositionFailure( + resolution: Resolution, + field: FieldSymbol, +): { readonly message: string; readonly name: string } | undefined { + if (resolution.kind === 'unresolved') { + return { message: `Cannot find type "${resolution.name}"`, name: resolution.name }; + } + if (resolution.kind !== 'namespace') return undefined; + const name = + field.typeNamespaceId === undefined + ? field.typeName + : `${field.typeNamespaceId}.${field.typeName}`; + return { + message: `"${name}" is a namespace; a type reference must name a model, composite type, enum, or named type`, + name, + }; +} + function resolveTypeReference( field: FieldSymbol, scope: NamespaceSymbol | undefined, @@ -402,46 +517,18 @@ function resolveTypeReference( if (name === '') return undefined; const namespaceId = field.typeNamespaceId; - if (namespaceId !== undefined) { - const namespace = topLevel.namespaces[namespaceId]; - const declared = namespace === undefined ? undefined : inNamespace(namespace, name); - if (declared !== undefined) return declared; - const contributedSymbol = contributedTypes.lookup([namespaceId, name]); - if (contributedSymbol !== undefined) - return { kind: 'contributedType', symbol: contributedSymbol }; - return { kind: 'unresolved', name: `${namespaceId}.${name}` }; + const chain = + namespaceId === undefined + ? unqualifiedChain(scope, topLevel, contributedTypes) + : qualifiedChain(namespaceId, topLevel, contributedTypes); + const found = lookupIn(chain, name); + if (found === undefined) { + return { + kind: 'unresolved', + name: namespaceId === undefined ? name : `${namespaceId}.${name}`, + }; } - - const local = scope === undefined ? undefined : inNamespace(scope, name); - if (local !== undefined) return local; - const global = inTopLevel(topLevel, name); - if (global !== undefined) return global; - const contributedSymbol = contributedTypes.lookup([name]); - if (contributedSymbol !== undefined) - return { kind: 'contributedType', symbol: contributedSymbol }; - return { kind: 'unresolved', name }; -} - -function inNamespace(namespace: NamespaceSymbol, name: string): Resolution | undefined { - const model = namespace.models[name]; - if (model !== undefined) return { kind: 'model', symbol: model }; - const compositeType = namespace.compositeTypes[name]; - if (compositeType !== undefined) return { kind: 'compositeType', symbol: compositeType }; - const block = namespace.blocks[name]; - if (block !== undefined) return { kind: 'block', symbol: block }; - return undefined; -} - -function inTopLevel(topLevel: TopLevelScope, name: string): Resolution | undefined { - const model = topLevel.models[name]; - if (model !== undefined) return { kind: 'model', symbol: model }; - const compositeType = topLevel.compositeTypes[name]; - if (compositeType !== undefined) return { kind: 'compositeType', symbol: compositeType }; - const namedType = topLevel.namedTypes[name]; - if (namedType !== undefined) return { kind: 'namedType', symbol: namedType }; - const block = topLevel.blocks[name]; - if (block !== undefined) return { kind: 'block', symbol: block }; - return undefined; + return found; } function own(record: Record, name: string): T | undefined { 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 index ffe4df53bca7..9ced39108136 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/binder.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/binder.test.ts @@ -835,14 +835,101 @@ describe('createBinder — entityRef arguments', () => { }; expect(resolvedBase('Orphan')).toEqual({ kind: 'unresolved', name: 'Ghost' }); - expect(resolvedBase('Scalarish')).toEqual({ kind: 'unresolved', name: 'String' }); - expect(resolvedBase('Enumish')).toEqual({ kind: 'unresolved', name: 'Role' }); + 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"', - 'Cannot find entity "String"', - 'Cannot find entity "Role"', + '"String" is a scalar type; an entity reference must name a model or composite type', + '"Role" is an enum; an entity reference must name a model or composite type', + ]); + }); +}); + +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'] }); + expect(diagnostics.map(({ code, message }) => [code, message])).toEqual([ + [ + 'PSL_UNRESOLVED_REFERENCE', + '"Foo" is an enum; an entity reference must name a model or composite type', + ], + ]); + }); + + 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'], + }); + }); + + 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', + '"app" is a namespace; an entity reference must name a model or composite 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', () => { From 9708de6e62367c5ac638ecc204b8f5a4fccbdb47 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Thu, 24 Sep 2026 11:07:28 +0000 Subject: [PATCH 45/66] fix(sql): give the binder the contributed model-attribute specs A contributed @@ attribute declares its arguments with the same combinators the built-in attributes use, reference arguments included, but its spec lived only in AuthoringContributions - the binder was handed sqlAttributeSpecs alone. It therefore bound nothing for those argument nodes, and the field-reference combinator, which now requires a binder answer, threw on the first contributed attribute that names a field. createSqlBinder now merges the contributed factories into the model namespace it injects, so @@searchIndex([name]) resolves its field reference like @@index([name]) does. The names are part of SQL spec knowledge and SQL injects them, so the binder stays family-blind. One record of factories serves both the binder and the interpretation, which lets the interpreter drop its own cast, and the callback no longer needs a separate contributed-name guard: a contributed name is now a known name, so the unsupported-attribute voice passes over it on its own. Signed-off-by: Steven McClankerton --- .../contract-psl/src/interpreter.ts | 14 ++-- .../contract-psl/src/psl-field-resolution.ts | 2 - .../contract-psl/src/sql-attribute-specs.ts | 27 ++++++- .../test/interpreter.model-attributes.test.ts | 77 ++++++++++++++++++- 4 files changed, 111 insertions(+), 9 deletions(-) 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 adc5f699d711..8462d95c1c5a 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts @@ -119,6 +119,7 @@ import { createSqlBinder, findModelAttributeNode, interpretModelAttribute, + modelAttributeSpecsFrom, PSL_CHECK_ON_STI_VARIANT, sqlAttributeSpecs, } from './sql-attribute-specs'; @@ -664,6 +665,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; } @@ -1120,10 +1122,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({ @@ -2059,19 +2061,20 @@ export function interpretPslDocumentToSqlContract( 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: input.controlMutationDefaults, scalarColumnDescriptors: input.scalarColumnDescriptors, + contributedModelAttributeSpecs: contributedModelSpecs, describeUnsupportedAttribute: describeUnsupportedSqlAttribute({ composedExtensions: composedExtensionNames, authoringContributions: input.authoringContributions, sources: input.sources, familyId: input.target?.familyId, targetId: input.target?.targetId, - contributedModelAttributeNames: new Set(modelAttributesByName.keys()), }), }); diagnostics.push( @@ -2510,6 +2513,7 @@ export function interpretPslDocumentToSqlContract( ...(namespaceExtensionEntities.size > 0 ? { namespaceExtensionEntities } : {}), ...ifDefined('codecLookup', input.codecLookup), modelAttributesByName, + contributedModelAttributeSpecs: contributedModelSpecs, defaultNamespaceId, }); modelNodes.push( 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 cf55b5ebb586..5219a4cff43e 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 @@ -225,7 +225,6 @@ export function describeUnsupportedSqlAttribute(input: { readonly sources: PslSources; readonly familyId: string | undefined; readonly targetId: string | undefined; - readonly contributedModelAttributeNames: ReadonlySet; }): DescribeUnsupportedAttribute { const namespaceContext = { ...ifDefined('familyId', input.familyId), @@ -234,7 +233,6 @@ export function describeUnsupportedSqlAttribute(input: { }; return ({ attribute, level, owner, field }) => { if (level === 'model') { - if (input.contributedModelAttributeNames.has(attribute.name)) return undefined; const source = diagnosticSource(input.sources, owner.node.syntax); const uncomposedNamespace = checkUncomposedNamespace( attribute.name, 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 5ff7444e0cf9..763e6c3c16ca 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,6 +1,7 @@ import type { AuthoringContributions, AuthoringFieldNamespace, + AuthoringModelAttributeDescriptor, AuthoringTypeConstructorDescriptor, AuthoringTypeNamespace, } from '@internal/framework-components/authoring'; @@ -21,6 +22,7 @@ import type { FuncCallSig, InferAttr, ModelAttributeCtx, + ModelAttributeSpecFactory, ModelSymbol, NumLiteral, ParsedTaggedLiteral, @@ -121,6 +123,19 @@ function fieldPresetsAsTypeNames( 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; @@ -128,6 +143,9 @@ export function createSqlBinder(input: { 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 ?? []) { @@ -141,7 +159,14 @@ export function createSqlBinder(input: { ...fieldPresetsAsTypeNames(input.authoringContributions?.field), ...(input.authoringContributions?.type ?? {}), }, - attributeSpecs: sqlAttributeSpecs, + attributeSpecs: { + model: Object.assign( + Object.create(null), + sqlAttributeSpecs.model, + input.contributedModelAttributeSpecs, + ), + field: sqlAttributeSpecs.field, + }, controlMutationDefaults: input.controlMutationDefaults ?? { defaultFunctionRegistry: new Map(), defaultLiteralTagRegistry: new Map(), 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 78e1212c7373..e8e42c36fcb4 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'; @@ -97,6 +97,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( From b8332ece8204f5167c2cbb468398a712288241b5 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Thu, 24 Sep 2026 12:00:14 +0000 Subject: [PATCH 46/66] refactor(psl): one entity resolver behind main selector surface main entity-reference.ts resolved entity references with the same semantics this branch decrees, but hand-rolled beside the binder and speaking its own not-found and kind-mismatch verdicts. Two resolvers for one language, and the pins said so. The scope chain moves out of the binder into scope-chain.ts, where it works from a bare SymbolTable: Scope, the namespace, top-level and contributed scopes, the qualified and unqualified chains, and the first-hit fold. The binder consumes it and memoises per snapshot as before. Block attributes are interpreted while the symbol table is still being built, so no binder exists for them to read; that path consumes the chain directly. One implementation, two entry points, which is all causality allows: a binder built from the table cannot serve the pass that builds it. The block path also has no contributed-type registry at that moment, so its chain is namespace then top level - stated here rather than hidden in a default. entityRef keeps main EntitySelector surface and its per-site kind verdict; what it loses is its own resolution and its own not-found voice. On the binder path an unresolved name is already the binder verdict, so the combinator fails without speaking; on the table path nobody else can speak, so it speaks there. The binder in turn drops the hardcoded model-or-composite-type check it carried as a stand-in for the selector, and Resolution now records the namespace a hit came from, which is what ResolvedEntityReference exposes. Pins rewritten with the relocation: the binder entity tests keep the shadowing and the resolution and expect silence where the selector now speaks; the namespaced-enum-shadows-a-model case survives with the selector owning the verdict; foreign-copy pins entityRef throwing on a foreign snapshot beside fieldRef; the sibling-namespace base failure carries the binder code while kind mismatches carry the selector one; keyof AttributeCtx gains binder. Also repairs this branch binder construction against main reshaped ControlDefaultRegistries, which had been feeding it a registry with no dataTypeEntries - the whole of the 137 intermediate failures the merge commit carried. Signed-off-by: Steven McClankerton --- .../attribute-spec/combinators/entity-ref.ts | 20 ++- .../psl-parser/src/attribute-spec/types.ts | 1 + .../psl-parser/src/binder-context.ts | 11 +- .../2-authoring/psl-parser/src/binder.ts | 118 +----------------- .../psl-parser/src/entity-reference.ts | 94 ++++++++------ .../psl-parser/src/exports/index.ts | 4 + .../2-authoring/psl-parser/src/scope-chain.ts | 106 ++++++++++++++++ .../test/attribute-spec-binder.test.ts | 37 ++++-- ...bute-spec-combinators.foreign-copy.test.ts | 34 ++--- ...te-spec-combinators.tagged-literal.test.ts | 5 +- .../test/attribute-spec-combinators.test-d.ts | 2 +- .../test/attribute-spec-combinators.test.ts | 9 +- ...attribute-spec-failure-propagation.test.ts | 13 +- .../psl-parser/test/attribute-spec.test.ts | 5 +- .../psl-parser/test/binder-context.test.ts | 6 +- .../psl-parser/test/binder.test.ts | 32 ++--- .../contract-psl/src/interpreter.ts | 5 +- .../contract-psl/src/sql-attribute-specs.ts | 2 +- .../test/interpreter.polymorphism.test.ts | 9 +- .../test/sql-attribute-specs.test.ts | 1 + 20 files changed, 286 insertions(+), 228 deletions(-) create mode 100644 packages/1-framework/2-authoring/psl-parser/src/scope-chain.ts 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..69d49d512005 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 @@ -2,10 +2,11 @@ import { notOk, ok, type Result } from '@internal/utils/result'; import type { PslDiagnostic } from '../../diagnostic'; import type { DeclarationFor, + EntityLookup, EntitySelector, ResolvedEntityReference, } from '../../entity-reference'; -import { resolveEntityReference } from '../../entity-reference'; +import { lookupEntityReference, lookupEntityReferenceInTable } from '../../entity-reference'; import { IdentifierAst } from '../../syntax/ast/identifier'; import type { AttributeCtx, EntityRefArgType } from '../types'; import { leafDiagnostic } from './diagnostic'; @@ -26,10 +27,21 @@ 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}"`)]); + const lookup: EntityLookup = + ctx.binder === undefined + ? lookupEntityReferenceInTable(arg, name, ctx.symbols) + : lookupEntityReference(arg, ctx.binder); + if (lookup.kind === 'unresolved') { + return lookup.voiced + ? notOk([]) + : notOk([leafDiagnostic(ctx, arg, `Unknown ${label} "${name}"`)]); } + if (lookup.kind === 'notAnEntity') { + return notOk([ + leafDiagnostic(ctx, arg, `Expected ${label} "${name}", found ${lookup.described}`), + ]); + } + const reference = lookup.reference; if (!matchesSelector(reference, expected)) { const actual = reference.declaration; const kind = actual.kind === 'block' ? actual.keyword : actual.kind; 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 53248429b882..e0614b5962df 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 @@ -19,6 +19,7 @@ export type AttributeLevel = 'field' | 'model' | 'block'; export interface AttributeCtx { readonly sources: PslSources; readonly symbols: SymbolTable; + readonly binder?: Binder | undefined; } export interface ModelAttributeCtx extends AttributeCtx { diff --git a/packages/1-framework/2-authoring/psl-parser/src/binder-context.ts b/packages/1-framework/2-authoring/psl-parser/src/binder-context.ts index d5dc139a98f4..2f87ab2a01ce 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/binder-context.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/binder-context.ts @@ -1,7 +1,7 @@ import type { FieldAttributeCtx, ModelAttributeCtx } from './attribute-spec/types'; import { type Binder, typeReferenceNode } from './binder'; import type { PslSources } from './source-file'; -import type { FieldSymbol, ModelSymbol } from './symbol-table'; +import type { FieldSymbol, ModelSymbol, SymbolTable } from './symbol-table'; export function referencedModel(binder: Binder, field: FieldSymbol): ModelSymbol | undefined { const node = typeReferenceNode(field); @@ -13,6 +13,7 @@ export function referencedModel(binder: Binder, field: FieldSymbol): ModelSymbol export interface AttributeContextInput { readonly binder: Binder; readonly sources: PslSources; + readonly symbols: SymbolTable; readonly model: ModelSymbol; } @@ -23,12 +24,18 @@ export interface FieldAttributeContextInput extends AttributeContextInput { } export function modelAttributeContext(input: ModelAttributeContextInput): ModelAttributeCtx { - return { sources: input.sources, binder: input.binder, selfModel: input.model }; + return { + sources: input.sources, + symbols: input.symbols, + binder: input.binder, + selfModel: input.model, + }; } export function fieldAttributeContext(input: FieldAttributeContextInput): FieldAttributeCtx { return { sources: input.sources, + symbols: input.symbols, binder: input.binder, selfModel: input.model, field: input.field, diff --git a/packages/1-framework/2-authoring/psl-parser/src/binder.ts b/packages/1-framework/2-authoring/psl-parser/src/binder.ts index 2d0af6a129ab..93a72dc09bba 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/binder.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/binder.ts @@ -3,14 +3,11 @@ import type { ControlDefaultRegistries } from '@internal/framework-components/co 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 { - type ContributedTypeScope, - type ContributedTypeSymbol, - contributedTypeScope, -} from './contributed-type-scope'; +import { type ContributedTypeScope, contributedTypeScope } from './contributed-type-scope'; import { diagnosticSource } from './diagnostic'; import type { ParseDiagnostic } from './parse'; import type { ResolvedAttribute } from './resolve'; +import { lookupIn, qualifiedChain, type ScopeResolution, unqualifiedChain } from './scope-chain'; import type { PslSources } from './source-file'; import type { BlockSymbol, @@ -50,12 +47,7 @@ export type PslSymbol = | FieldSymbol; export type Resolution = - | { readonly kind: 'model'; readonly symbol: ModelSymbol } - | { readonly kind: 'compositeType'; readonly symbol: CompositeTypeSymbol } - | { readonly kind: 'namedType'; readonly symbol: NamedTypeSymbol } - | { readonly kind: 'block'; readonly symbol: BlockSymbol } - | { readonly kind: 'namespace'; readonly symbol: NamespaceSymbol } - | { readonly kind: 'contributedType'; readonly symbol: ContributedTypeSymbol } + | ScopeResolution | { readonly kind: 'field'; readonly symbol: FieldSymbol } | { readonly kind: 'attribute'; readonly symbol: AttributeSymbol } | { readonly kind: 'crossSpace' } @@ -332,14 +324,6 @@ function resolveEntity(name: string, node: SyntaxNode, ctx: BindContext): Resolu report(`Cannot find entity "${name}"`, node, ctx, 'entity'); return { kind: 'unresolved', name }; } - if (found.kind !== 'model' && found.kind !== 'compositeType') { - report( - `"${name}" is ${describeResolution(found)}; an entity reference must name a model or composite type`, - node, - ctx, - 'entity', - ); - } return found; } @@ -391,102 +375,6 @@ function* entities(symbolTable: SymbolTable): Iterable { } } -interface Scope { - lookup(name: string): Resolution | undefined; -} - -function namespaceScope(namespace: NamespaceSymbol): Scope { - return { - lookup(name) { - const model = namespace.models[name]; - if (model !== undefined) return { kind: 'model', symbol: model }; - const compositeType = namespace.compositeTypes[name]; - if (compositeType !== undefined) return { kind: 'compositeType', symbol: compositeType }; - const block = namespace.blocks[name]; - if (block !== undefined) return { kind: 'block', symbol: block }; - return undefined; - }, - }; -} - -function topLevelScope(topLevel: TopLevelScope): Scope { - return { - lookup(name) { - const model = topLevel.models[name]; - if (model !== undefined) return { kind: 'model', symbol: model }; - const compositeType = topLevel.compositeTypes[name]; - if (compositeType !== undefined) return { kind: 'compositeType', symbol: compositeType }; - const namedType = topLevel.namedTypes[name]; - if (namedType !== undefined) return { kind: 'namedType', symbol: namedType }; - const block = topLevel.blocks[name]; - if (block !== undefined) return { kind: 'block', symbol: block }; - const namespace = topLevel.namespaces[name]; - if (namespace !== undefined) return { kind: 'namespace', symbol: namespace }; - return undefined; - }, - }; -} - -function contributedScope( - contributedTypes: ContributedTypeScope, - prefix: readonly string[], -): Scope { - return { - lookup(name) { - const symbol = contributedTypes.lookup([...prefix, name]); - return symbol === undefined ? undefined : { kind: 'contributedType', symbol }; - }, - }; -} - -function unqualifiedChain( - scope: NamespaceSymbol | undefined, - topLevel: TopLevelScope, - contributedTypes: ContributedTypeScope, -): readonly Scope[] { - const outer = [topLevelScope(topLevel), contributedScope(contributedTypes, [])]; - return scope === undefined ? outer : [namespaceScope(scope), ...outer]; -} - -function qualifiedChain( - namespaceId: string, - topLevel: TopLevelScope, - contributedTypes: ContributedTypeScope, -): readonly Scope[] { - const namespace = topLevel.namespaces[namespaceId]; - const contributed = contributedScope(contributedTypes, [namespaceId]); - return namespace === undefined ? [contributed] : [namespaceScope(namespace), contributed]; -} - -function lookupIn(chain: readonly Scope[], name: string): Resolution | undefined { - for (const scope of chain) { - const found = scope.lookup(name); - if (found !== undefined) return found; - } - return undefined; -} - -function describeResolution(resolution: Resolution): 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 enum' - : `a ${resolution.symbol.keyword} block`; - case 'namespace': - return 'a namespace'; - case 'contributedType': - return 'a scalar type'; - default: - return 'not a declaration'; - } -} - function typePositionFailure( resolution: Resolution, field: FieldSymbol, 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..1300163ae2b7 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,3 +1,6 @@ +import { InternalError } from '@internal/utils/internal-error'; +import type { Binder, Resolution } from './binder'; +import { lookupIn, unqualifiedChain } from './scope-chain'; import type { BlockSymbol, CompositeTypeSymbol, @@ -5,7 +8,6 @@ import type { NamedTypeSymbol, NamespaceSymbol, SymbolTable, - TopLevelScope, } from './symbol-table'; import { NamespaceDeclarationAst } from './syntax/ast/declarations'; import type { ExpressionAst } from './syntax/ast/expressions'; @@ -28,62 +30,76 @@ export interface ResolvedEntityReference ->(); +export type EntityLookup = + | { readonly kind: 'entity'; readonly reference: ResolvedEntityReference } + | { readonly kind: 'unresolved'; readonly voiced: boolean } + | { readonly kind: 'notAnEntity'; readonly described: string }; -export function resolveEntityReference( +const references = new WeakMap(); + +export function lookupEntityReference(expression: ExpressionAst, binder: Binder): EntityLookup { + const resolution = binder.symbolForNode(expression.syntax); + if (resolution === undefined) { + throw new InternalError( + 'The binder on this attribute context bound nothing for this entity reference. 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 classify(resolution, true); +} + +export function lookupEntityReferenceInTable( expression: ExpressionAst, name: string, symbols: SymbolTable, -): ResolvedEntityReference | undefined { +): EntityLookup { 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); - } - const declaration = declarationIn(symbols.topLevel, name); - return declaration === undefined - ? undefined - : referenceFor(symbols.topLevel, declaration, undefined); + namespaceName === undefined ? undefined : symbols.topLevel.namespaces[namespaceName]; + const found = lookupIn(unqualifiedChain(namespace, symbols.topLevel), name); + if (found === undefined) return { kind: 'unresolved', voiced: false }; + return classify(found, false); } -function ownValue(values: Readonly>, name: string): T | undefined { - return Object.hasOwn(values, name) ? values[name] : undefined; +function classify(resolution: Resolution, voiced: boolean): EntityLookup { + switch (resolution.kind) { + case 'model': + case 'compositeType': + case 'namedType': + case 'block': + return { kind: 'entity', reference: referenceFor(resolution) }; + case 'unresolved': + return { kind: 'unresolved', voiced }; + default: + return { kind: 'notAnEntity', described: describeNonEntity(resolution) }; + } } -function declarationIn( - scope: TopLevelScope | NamespaceSymbol, - name: string, -): EntityDeclaration | undefined { - return ( - ownValue(scope.models, name) ?? - ownValue(scope.compositeTypes, name) ?? - ownValue(scope.blocks, name) ?? - ('namedTypes' in scope ? ownValue(scope.namedTypes, name) : undefined) - ); +function describeNonEntity(resolution: Resolution): string { + switch (resolution.kind) { + case 'namespace': + return 'namespace'; + case 'contributedType': + return 'scalar type'; + case 'field': + return 'field'; + case 'attribute': + return 'attribute'; + default: + return 'cross-space reference'; + } } function referenceFor( - scope: TopLevelScope | NamespaceSymbol, - declaration: EntityDeclaration, - namespace: NamespaceSymbol | undefined, + resolution: Extract, ): ResolvedEntityReference { - let byDeclaration = references.get(scope); - if (byDeclaration === undefined) { - byDeclaration = new WeakMap(); - references.set(scope, byDeclaration); - } - let reference = byDeclaration.get(declaration); + const declaration = resolution.symbol; + let reference = references.get(declaration); if (reference === undefined) { - reference = { declaration, namespace }; - byDeclaration.set(declaration, reference); + reference = { declaration, namespace: resolution.namespace }; + references.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 2dbafe5dea70..5d5c713574f7 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 @@ -132,9 +132,11 @@ export { export type { DeclarationFor, EntityDeclaration, + EntityLookup, EntitySelector, ResolvedEntityReference, } from '../entity-reference'; +export { lookupEntityReference, lookupEntityReferenceInTable } from '../entity-reference'; export { findBlockDescriptor, validateExtensionBlockFromSymbol } from '../extension-block'; export { keywordPslSpan, @@ -143,6 +145,8 @@ export { readResolvedAttributes, readResolvedConstructorCall, } from '../resolve'; +export type { Scope, ScopeResolution } from '../scope-chain'; +export { lookupIn, qualifiedChain, unqualifiedChain } from '../scope-chain'; export type { BlockSymbol, BuildSymbolTableOptions, diff --git a/packages/1-framework/2-authoring/psl-parser/src/scope-chain.ts b/packages/1-framework/2-authoring/psl-parser/src/scope-chain.ts new file mode 100644 index 000000000000..ae31fcb71957 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/scope-chain.ts @@ -0,0 +1,106 @@ +import type { ContributedTypeScope, ContributedTypeSymbol } from './contributed-type-scope'; +import type { + BlockSymbol, + CompositeTypeSymbol, + ModelSymbol, + NamedTypeSymbol, + NamespaceSymbol, + TopLevelScope, +} 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: 'contributedType'; readonly symbol: ContributedTypeSymbol }; + +export interface Scope { + lookup(name: string): ScopeResolution | undefined; +} + +export function namespaceScope(namespace: NamespaceSymbol): Scope { + return { + lookup(name) { + 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; + }, + }; +} + +export function topLevelScope(topLevel: TopLevelScope): Scope { + return { + lookup(name) { + const model = topLevel.models[name]; + if (model !== undefined) return { kind: 'model', symbol: model }; + const compositeType = topLevel.compositeTypes[name]; + if (compositeType !== undefined) return { kind: 'compositeType', symbol: compositeType }; + const namedType = topLevel.namedTypes[name]; + if (namedType !== undefined) return { kind: 'namedType', symbol: namedType }; + const block = topLevel.blocks[name]; + if (block !== undefined) return { kind: 'block', symbol: block }; + const namespace = topLevel.namespaces[name]; + if (namespace !== undefined) return { kind: 'namespace', symbol: namespace }; + return undefined; + }, + }; +} + +export function contributedScope( + contributedTypes: ContributedTypeScope, + prefix: readonly string[], +): Scope { + return { + lookup(name) { + const symbol = contributedTypes.lookup([...prefix, name]); + return symbol === undefined ? undefined : { kind: 'contributedType', symbol }; + }, + }; +} + +export function unqualifiedChain( + scope: NamespaceSymbol | undefined, + topLevel: TopLevelScope, + contributedTypes?: ContributedTypeScope, +): readonly Scope[] { + const outer = + contributedTypes === undefined + ? [topLevelScope(topLevel)] + : [topLevelScope(topLevel), contributedScope(contributedTypes, [])]; + return scope === undefined ? outer : [namespaceScope(scope), ...outer]; +} + +export function qualifiedChain( + namespaceId: string, + topLevel: TopLevelScope, + contributedTypes?: ContributedTypeScope, +): readonly Scope[] { + const namespace = topLevel.namespaces[namespaceId]; + const declared = namespace === undefined ? [] : [namespaceScope(namespace)]; + return contributedTypes === undefined + ? declared + : [...declared, contributedScope(contributedTypes, [namespaceId])]; +} + +export function lookupIn(chain: readonly Scope[], name: string): ScopeResolution | undefined { + for (const scope of chain) { + const found = scope.lookup(name); + if (found !== undefined) return found; + } + return undefined; +} 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 index aefd4fa64380..f985fb9ee67a 100644 --- 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 @@ -51,7 +51,7 @@ const relationSpec = fieldAttribute('relation', { const baseSpec = modelAttribute('base', { documentation: 'fixture', - positional: [{ key: 'model', type: entityRef(), documentation: 'fixture' }], + positional: [{ key: 'model', type: entityRef({ kind: 'model' }), documentation: 'fixture' }], }); const indexSpec = modelAttribute('index', { @@ -79,7 +79,7 @@ function bind(text: string) { attributeSpecs: ATTRIBUTE_SPECS, controlMutationDefaults: { defaultFunctionRegistry: new Map(), - defaultLiteralTagRegistry: new Map(), + dataTypeEntries: {}, }, }); return { sources, symbolTable, binder, binderDiagnostics: diagnostics }; @@ -113,7 +113,7 @@ function interpretRelation(text: string) { const { sources, symbolTable, binder, binderDiagnostics } = bind(text); const post = symbolTable.topLevel.models['Post']!; const field = post.fields['author']!; - const ctx = fieldAttributeContext({ binder, sources, model: post, field }); + const ctx = fieldAttributeContext({ binder, sources, symbols: symbolTable, model: post, field }); return { binderDiagnostics, result: interpretAttribute(fieldAttributeNode(field, 'relation'), relationSpec, ctx), @@ -174,11 +174,11 @@ describe('reference combinators with a binder-backed context', () => { 'model Child {\n id Int\n @@base(Ghost)\n}', ); const child = symbolTable.topLevel.models['Child']!; - const ctx = modelAttributeContext({ binder, sources, model: child }); + const ctx = modelAttributeContext({ binder, sources, symbols: symbolTable, model: child }); const result = interpretAttribute(modelAttributeNode(child, 'base'), baseSpec, ctx); - expect(result.ok).toBe(true); - if (result.ok) expect(result.value).toEqual({ model: 'Ghost' }); + 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"'], ]); @@ -194,7 +194,13 @@ describe('reference combinators with a binder-backed context', () => { const { sources, symbolTable, binder, binderDiagnostics } = bind(schema); const post = symbolTable.topLevel.models['Post']!; const field = post.fields['author']!; - const ctx = fieldAttributeContext({ binder, sources, model: post, field }); + const ctx = fieldAttributeContext({ + binder, + sources, + symbols: symbolTable, + model: post, + field, + }); const result = interpretAttribute(fieldAttributeNode(field, 'relation'), relationSpec, ctx); expect(result.ok).toBe(true); @@ -207,7 +213,7 @@ describe('reference combinators with a binder-backed context', () => { 'model User {\n id Int\n @@index("id")\n}', ); const user = symbolTable.topLevel.models['User']!; - const ctx = modelAttributeContext({ binder, sources, model: user }); + const ctx = modelAttributeContext({ binder, sources, symbols: symbolTable, model: user }); const result = interpretAttribute(modelAttributeNode(user, 'index'), indexSpec, ctx); expect(result.ok).toBe(false); @@ -224,7 +230,7 @@ describe('reference combinators with a binder-backed context', () => { 'model User {\n id Int\n @@index(["id"])\n}', ); const user = symbolTable.topLevel.models['User']!; - const ctx = modelAttributeContext({ binder, sources, model: user }); + const ctx = modelAttributeContext({ binder, sources, symbols: symbolTable, model: user }); const result = interpretAttribute(modelAttributeNode(user, 'index'), indexSpec, ctx); expect(result.ok).toBe(false); @@ -241,7 +247,7 @@ describe('the binder is the only resolution path', () => { 'model User {\n id Int\n @@index([nope])\n}', ); const user = symbolTable.topLevel.models['User']!; - const ctx = modelAttributeContext({ binder, sources, model: user }); + const ctx = modelAttributeContext({ binder, sources, symbols: symbolTable, model: user }); const result = interpretAttribute(modelAttributeNode(user, 'index'), indexSpec, ctx); expect(result.ok).toBe(false); @@ -256,7 +262,7 @@ describe('the binder is the only resolution path', () => { 'model User {\n id Int\n @@index([id])\n}', ); const user = symbolTable.topLevel.models['User']!; - const ctx = modelAttributeContext({ binder, sources, model: user }); + const ctx = modelAttributeContext({ binder, sources, symbols: symbolTable, model: user }); const result = interpretAttribute(modelAttributeNode(user, 'index'), indexSpec, ctx); expect(result.ok).toBe(true); @@ -273,6 +279,7 @@ describe('a binder built over another snapshot', () => { const field = post.fields['author']!; const ctx = fieldAttributeContext({ binder: first.binder, + symbols: second.symbolTable, sources: second.sources, model: post, field, @@ -287,7 +294,13 @@ describe('a binder built over another snapshot', () => { const { sources, symbolTable, binder } = bind(RELATION_SCHEMA); const post = symbolTable.topLevel.models['Post']!; const field = post.fields['author']!; - const ctx = fieldAttributeContext({ binder, sources, model: post, field }); + const ctx = fieldAttributeContext({ + binder, + sources, + symbols: symbolTable, + model: post, + field, + }); const result = interpretAttribute(fieldAttributeNode(field, 'relation'), relationSpec, ctx); 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 d5bf64df43d3..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 @@ -46,18 +46,18 @@ function foreignArg(source: string): { arg: ExpressionAst; ctx: ModelAttributeCt attributeSpecs: { model: {}, field: {} }, controlMutationDefaults: { defaultFunctionRegistry: new Map(), - defaultLiteralTagRegistry: new Map(), + dataTypeEntries: {}, }, }); return { - arg: new ForeignCopyOfAnAstNode(value.syntax) as unknown as ExpressionAst, - ctx: { - sources, - symbols: symbolTable, - selfModel, - binder, - }, -}; + arg: new ForeignCopyOfAnAstNode(value.syntax) as unknown as ExpressionAst, + ctx: { + sources, + symbols: symbolTable, + selfModel, + binder, + }, + }; } describe('combinators dispatch on syntax kind, not on AST class identity', () => { @@ -99,14 +99,14 @@ describe('combinators dispatch on syntax kind, not on AST class identity', () => }); it('funcCall accepts a node from another module copy', () => { - const { arg, ctx } = foreignArg('now()'); + const { arg, ctx } = foreignArg('now()'); - const result = funcCall('now', { documentation: 'Calls the named value generator.' }).parse( - arg, - ctx, - ); + const result = funcCall('now', { documentation: 'Calls the named value generator.' }).parse( + arg, + ctx, + ); - expect(result.ok).toBe(true); - if (result.ok) expect(result.value).toMatchObject({ fn: 'now', args: {} }); -}); + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toMatchObject({ fn: 'now', args: {} }); + }); }); 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 335b851ccf28..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 @@ -27,11 +27,10 @@ function makeCtx(sources: PslSources): FieldAttributeCtx { attributeSpecs: { model: {}, field: {} }, controlMutationDefaults: { defaultFunctionRegistry: new Map(), - defaultLiteralTagRegistry: new Map(), + dataTypeEntries: {}, }, }); - return { sources, - symbols: symbolTable, selfModel, field, binder }; + 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..7f7bc446b229 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 @@ -63,7 +63,7 @@ test('checked reference selectors and wrappers preserve inferred outputs', () => ResolvedEntityReference | string >(); expectTypeOf(model.parse).parameter(1).toEqualTypeOf(); - expectTypeOf().toEqualTypeOf<'sources' | 'symbols'>(); + expectTypeOf().toEqualTypeOf<'sources' | 'symbols' | 'binder'>(); // @ts-expect-error checked references require an expected selector entityRef(); // @ts-expect-error checked references do not accept injected resolvers 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 b2c758118492..77f23f871fc6 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 @@ -48,11 +48,10 @@ function makeCtx(sources: PslSources): FieldAttributeCtx { attributeSpecs: { model: {}, field: {} }, controlMutationDefaults: { defaultFunctionRegistry: new Map(), - defaultLiteralTagRegistry: new Map(), + dataTypeEntries: {}, }, }); - return { sources, - symbols: symbolTable, selfModel, field, binder }; + return { sources, symbols: symbolTable, selfModel, field, binder }; } function schemaArg(schema: string, attribute: string, argName?: string) { @@ -87,7 +86,7 @@ function schemaArg(schema: string, attribute: string, argName?: string) { }, controlMutationDefaults: { defaultFunctionRegistry: new Map(), - defaultLiteralTagRegistry: new Map(), + dataTypeEntries: {}, }, }); for (const node of field.node.attributes()) { @@ -100,7 +99,7 @@ function schemaArg(schema: string, attribute: string, argName?: string) { if (element === undefined) throw new Error('expected a list element'); return { expr: element, - ctx: { sources: registry, selfModel: model, field, binder }, + ctx: { sources: registry, symbols: symbolTable, selfModel: model, field, binder }, }; } } 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 index 4fce99021a33..db56da5a65a6 100644 --- 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 @@ -35,20 +35,25 @@ function build(text: string) { attributeSpecs: { model: {}, field: {} }, controlMutationDefaults: { defaultFunctionRegistry: new Map(), - defaultLiteralTagRegistry: new Map(), + dataTypeEntries: {}, }, }); - return { sources, model, binder }; + return { sources, model, binder, symbolTable }; } function interpretFirst( text: string, spec: Parameters>[1], ) { - const { sources, model, binder } = build(text); + 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, selfModel: model, binder }); + return interpretAttribute(node, spec, { + sources, + symbols: symbolTable, + selfModel: model, + binder, + }); } const listSpec = modelAttribute('index', { 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 8b89896a1355..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 @@ -36,11 +36,10 @@ function makeCtx(sources: PslSources): FieldAttributeCtx { attributeSpecs: { model: {}, field: {} }, controlMutationDefaults: { defaultFunctionRegistry: new Map(), - defaultLiteralTagRegistry: new Map(), + dataTypeEntries: {}, }, }); - return { sources, - symbols: symbolTable, selfModel, field, binder }; + 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-context.test.ts b/packages/1-framework/2-authoring/psl-parser/test/binder-context.test.ts index 59e02a543029..4d83643f3513 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/binder-context.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/binder-context.test.ts @@ -73,7 +73,7 @@ function bind(text: string) { attributeSpecs: ATTRIBUTE_SPECS, controlMutationDefaults: { defaultFunctionRegistry: new Map(), - defaultLiteralTagRegistry: new Map(), + dataTypeEntries: {}, }, }), }; @@ -143,6 +143,7 @@ describe('fieldAttributeContext', () => { const post = symbolTable.topLevel.models['Post']!; const ctx = fieldAttributeContext({ binder, + symbols: symbolTable, sources, model: post, field: post.fields['author']!, @@ -162,6 +163,7 @@ describe('fieldAttributeContext', () => { const cart = symbolTable.topLevel.models['Cart']!; const ctx = fieldAttributeContext({ binder, + symbols: symbolTable, sources, model: cart, field: cart.fields['user']!, @@ -175,7 +177,7 @@ describe('modelAttributeContext', () => { it('carries the sources and the declaring model', () => { const { sources, symbolTable, binder } = bind(RELATION_SCHEMA); const post = symbolTable.topLevel.models['Post']!; - const ctx = modelAttributeContext({ binder, sources, model: post }); + const ctx = modelAttributeContext({ binder, sources, symbols: symbolTable, model: post }); expect(ctx.sources).toBe(sources); expect(ctx.selfModel).toBe(post); 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 index 9ced39108136..0f9b2948b609 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/binder.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/binder.test.ts @@ -71,7 +71,7 @@ const MODEL_SPECS = { base: () => modelAttribute('base', { documentation: 'fixture', - positional: [{ key: 'model', type: entityRef(), documentation: 'fixture' }], + positional: [{ key: 'model', type: entityRef({ kind: 'model' }), documentation: 'fixture' }], }), map: () => modelAttribute('map', { @@ -98,7 +98,7 @@ const ATTRIBUTE_SPECS = { model: MODEL_SPECS, field: FIELD_SPECS }; const NO_CONTROL_DEFAULTS = { defaultFunctionRegistry: new Map(), - defaultLiteralTagRegistry: new Map(), + dataTypeEntries: {}, }; function attributeNodes( @@ -260,6 +260,7 @@ describe('createBinder — the scope chain', () => { 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', @@ -366,6 +367,7 @@ describe('createBinder — qualified references', () => { expect(binder.symbolForNode(typeNodeOf(symbolTable, 'Cart', 'item'))).toEqual({ kind: 'model', symbol: symbolTable.topLevel.namespaces['app']!.models['Item'], + namespace: symbolTable.topLevel.namespaces['app'], }); }); @@ -478,6 +480,7 @@ describe('createBinder — multiple documents', () => { expect(binder.symbolForNode(typeNodeOf(symbolTable, 'app.Cart', 'item'))).toEqual({ kind: 'model', symbol: symbolTable.topLevel.namespaces['app']!.models['Item'], + namespace: symbolTable.topLevel.namespaces['app'], }); }); }); @@ -805,6 +808,7 @@ describe('createBinder — entityRef arguments', () => { expect(node === undefined ? undefined : binder.symbolForNode(node)).toEqual({ kind: 'model', symbol: symbolTable.topLevel.namespaces['app']!.models['Base'], + namespace: symbolTable.topLevel.namespaces['app'], }); }); @@ -840,11 +844,7 @@ describe('createBinder — entityRef arguments', () => { kind: 'block', symbol: symbolTable.topLevel.blocks['Role'], }); - expect(diagnostics.map(({ message }) => message)).toEqual([ - 'Cannot find entity "Ghost"', - '"String" is a scalar type; an entity reference must name a model or composite type', - '"Role" is an enum; an entity reference must name a model or composite type', - ]); + expect(diagnostics.map(({ message }) => message)).toEqual(['Cannot find entity "Ghost"']); }); }); @@ -870,13 +870,12 @@ describe('createBinder — one kind-blind scope chain', () => { 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'] }); - expect(diagnostics.map(({ code, message }) => [code, message])).toEqual([ - [ - 'PSL_UNRESOLVED_REFERENCE', - '"Foo" is an enum; an entity reference must name a model or composite type', - ], - ]); + 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', () => { @@ -886,6 +885,7 @@ describe('createBinder — one kind-blind scope chain', () => { expect(binder.symbolForNode(typeNodeOf(symbolTable, 'app.Bar', 'kind'))).toEqual({ kind: 'block', symbol: app.blocks['Foo'], + namespace: app, }); }); @@ -917,7 +917,6 @@ describe('createBinder — one kind-blind scope chain', () => { }); expect(diagnostics.map(({ message }) => message)).toEqual([ '"app" is a namespace; a type reference must name a model, composite type, enum, or named type', - '"app" is a namespace; an entity reference must name a model or composite type', ]); }); @@ -993,7 +992,7 @@ describe('attribute-spec registry shape', () => { }); const base = modelAttribute('base', { documentation: 'fixture', - positional: [{ key: 'model', type: entityRef(), documentation: 'fixture' }], + positional: [{ key: 'model', type: entityRef({ kind: 'model' }), documentation: 'fixture' }], }); const relation = fieldAttribute('relation', { documentation: 'fixture', @@ -1110,6 +1109,7 @@ describe('createBinder — prototype-named declarations', () => { expect(binder.symbolForNode(attributeNodes(namespaced.models['Basket']!, 'base')[0]!)).toEqual({ kind: 'model', symbol: namespaced.models['toString'], + namespace: namespaced, }); }); 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 9f81775e0ee3..5b949177aa88 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts @@ -2109,7 +2109,10 @@ export function interpretPslDocumentToSqlContract( symbolTable: input.symbolTable, sources: input.sources, authoringContributions: input.authoringContributions, - controlMutationDefaults: input.controlMutationDefaults, + controlMutationDefaults: { + defaultFunctionRegistry: input.controlMutationDefaults?.defaultFunctionRegistry ?? new Map(), + dataTypeEntries: input.authoringContributions?.dataTypes ?? {}, + }, scalarColumnDescriptors: input.scalarColumnDescriptors, contributedModelAttributeSpecs: contributedModelSpecs, describeUnsupportedAttribute: describeUnsupportedSqlAttribute({ 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 35bcbef65ef2..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 @@ -175,7 +175,7 @@ export function createSqlBinder(input: { }, controlMutationDefaults: input.controlMutationDefaults ?? { defaultFunctionRegistry: new Map(), - defaultLiteralTagRegistry: new Map(), + dataTypeEntries: {}, }, describeUnsupportedAttribute: input.describeUnsupportedAttribute, }); 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 7c151dff9257..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({ 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 512f32cec8b3..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 @@ -142,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([]); From 7ce84aba39e66d88ea03731015c72a8eebe53667 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Thu, 24 Sep 2026 12:19:16 +0000 Subject: [PATCH 47/66] test(mongo): bring the interpreter fixtures onto the merged surfaces The Mongo tests still built the pre-merge control registry and called interpretModelAttribute without a binder; both compiled only because the vitest type pass covers less than tsc does. They now carry dataTypeEntries and a same-snapshot binder, and the polymorphism pin the merge resolved is formatted as the formatter wants it - the debt the merge commit took on by skipping its hook. Signed-off-by: Steven McClankerton --- .../contract-psl/test/interpreter.polymorphism.test.ts | 3 ++- .../contract-psl/test/interpreter.single-voice.test.ts | 2 +- .../contract-psl/test/mongo-attribute-specs.test.ts | 7 +++++++ 3 files changed, 10 insertions(+), 2 deletions(-) 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 c110e23d28b0..33972750e1eb 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 @@ -451,7 +451,8 @@ namespace scoped { expect.arrayContaining([ expect.objectContaining({ code: 'PSL_UNRESOLVED_REFERENCE', - message: expect.stringContaining('Cannot find entity'), }), + message: expect.stringContaining('Cannot find entity'), + }), ]), ); }); 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 index 353e7844a97a..706b401555fd 100644 --- 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 @@ -30,7 +30,7 @@ function diagnosticCodes(schema: string): readonly string[] { scalarTypeCodecIds, controlMutationDefaults: { defaultFunctionRegistry: new Map(), - defaultLiteralTagRegistry: new Map(), + dataTypeEntries: {}, }, }); if (result.ok) throw new Error('expected interpretation to fail'); 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< From ea2b46b1570bbb43b98a2a156a19bd1c03555410 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Thu, 24 Sep 2026 12:30:15 +0000 Subject: [PATCH 48/66] chore(drive): record convergence reconciliation rulings (decision 13) Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- projects/symbol-table-resolve/design-decisions.md | 10 +++++++++- projects/symbol-table-resolve/spec.md | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/projects/symbol-table-resolve/design-decisions.md b/projects/symbol-table-resolve/design-decisions.md index b4021be287f3..e56e8a42c820 100644 --- a/projects/symbol-table-resolve/design-decisions.md +++ b/projects/symbol-table-resolve/design-decisions.md @@ -108,6 +108,14 @@ Outcome of the design discussion (2026-09-18) that preceded [`spec.md`](./spec.m **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. -## 13. Open-question resolutions (2026-09-18) +## 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. + +**Execution refinement (ratified):** `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/spec.md b/projects/symbol-table-resolve/spec.md index 6256d74159a4..72cfc8a60852 100644 --- a/projects/symbol-table-resolve/spec.md +++ b/projects/symbol-table-resolve/spec.md @@ -119,7 +119,7 @@ Interpreters still run spec interpretation for argument **values**; the binder o ## Transitional-shape constraints -- Work stacks on the unmerged PR #30335 and lands after it; no cherry-picking its content into `main` independently. +- ~~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. From eeee7b699f8cfefd293719cf103bd27ff1cce4c2 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Thu, 24 Sep 2026 12:48:35 +0000 Subject: [PATCH 49/66] refactor(psl): name the pre-binder entity lookup after its symbols The framework-vocabulary ratchet reads "Table" as SQL vocabulary wherever it appears under packages/1-framework, and the name carried it in four places. It is a false cognate here - the symbol table is family-blind - but the reading is the rule, and the rule is enforced by a count rather than by argument. lookupEntityReferenceInSymbols says the same thing about the same argument: the function takes symbols, and the binder-backed sibling is already named after the binder it takes. The name also avoids leaning on the SymbolTable exemption, so it stays clean if that guard ever narrows. Signed-off-by: Steven McClankerton --- .../psl-parser/src/attribute-spec/combinators/entity-ref.ts | 4 ++-- .../2-authoring/psl-parser/src/entity-reference.ts | 2 +- .../1-framework/2-authoring/psl-parser/src/exports/index.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) 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 69d49d512005..360dc28a4408 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 @@ -6,7 +6,7 @@ import type { EntitySelector, ResolvedEntityReference, } from '../../entity-reference'; -import { lookupEntityReference, lookupEntityReferenceInTable } from '../../entity-reference'; +import { lookupEntityReference, lookupEntityReferenceInSymbols } from '../../entity-reference'; import { IdentifierAst } from '../../syntax/ast/identifier'; import type { AttributeCtx, EntityRefArgType } from '../types'; import { leafDiagnostic } from './diagnostic'; @@ -29,7 +29,7 @@ export function entityRef( } const lookup: EntityLookup = ctx.binder === undefined - ? lookupEntityReferenceInTable(arg, name, ctx.symbols) + ? lookupEntityReferenceInSymbols(arg, name, ctx.symbols) : lookupEntityReference(arg, ctx.binder); if (lookup.kind === 'unresolved') { return lookup.voiced 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 1300163ae2b7..407c1c258c6a 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 @@ -47,7 +47,7 @@ export function lookupEntityReference(expression: ExpressionAst, binder: Binder) return classify(resolution, true); } -export function lookupEntityReferenceInTable( +export function lookupEntityReferenceInSymbols( expression: ExpressionAst, name: string, symbols: SymbolTable, 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 5d5c713574f7..1cede15b1f75 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 @@ -136,7 +136,7 @@ export type { EntitySelector, ResolvedEntityReference, } from '../entity-reference'; -export { lookupEntityReference, lookupEntityReferenceInTable } from '../entity-reference'; +export { lookupEntityReference, lookupEntityReferenceInSymbols } from '../entity-reference'; export { findBlockDescriptor, validateExtensionBlockFromSymbol } from '../extension-block'; export { keywordPslSpan, From 333a72930583a3ab5beecc79e1e0e2fbae06d6fd Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Thu, 24 Sep 2026 14:33:01 +0000 Subject: [PATCH 50/66] test(mongo): hand the single-voice harness a document list Multi-file emission turned the interpreters plural, and this harness still built a singular document. The file merged without conflict - only our side had touched it - so nothing flagged it, and the vitest type pass covers less than tsc does, which is why the suite stayed green while the workspace typecheck did not. Signed-off-by: Steven McClankerton --- .../contract-psl/test/interpreter.single-voice.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 706b401555fd..d3abfe59e578 100644 --- 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 @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'; import { interpretPslDocumentToMongoContract } from '../src/interpreter'; function symbolTableInput(schema: string): { - document: DocumentAst; + documents: readonly DocumentAst[]; symbolTable: SymbolTable; sources: PslSources; } { @@ -15,7 +15,7 @@ function symbolTableInput(schema: string): { sources, pslBlockDescriptors: {}, }); - return { document, symbolTable, sources }; + return { documents: [document], symbolTable, sources }; } const scalarTypeCodecIds: ReadonlyMap = new Map([ From a59e59e46a87fba8263f2f032055b78ed672acd8 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Thu, 24 Sep 2026 14:53:17 +0000 Subject: [PATCH 51/66] refactor(psl): read an entity reference the way a field reference reads The reconciliation joined main shapes to our paths instead of folding them together: an EntityLookup vocabulary and a classify step sat between the binder answer and the parse result, kind was decided twice, and two mismatch branches said nearly the same thing. entityRef now reads like fieldRef. It asks the binder for the symbol at the node, throws the same internal error when the binder answers nothing, and walks one ladder: a name no scope declares on the pre-binder path is unknown here because nothing else will say so; an unresolved name on the binder path is already the binder verdict, so it fails quietly; a resolution the selector accepts is the reference; anything else is one mismatch naming what the symbol actually is. EntityLookup, classify, the separate non-entity taxonomy and the binder wrapper are gone, and describeResolution is now the single namer - an enum reports its keyword, a namespace and a scalar type report themselves, and the rest report their kind, which is what the existing messages already said. Two files drop from 221 lines to 158. The reference interning stays: list(entityRef(...), { unique: true }) dedupes with a Set on the parsed value, so two mentions of one name have to be one object for a duplicate entry to be seen at all. Signed-off-by: Steven McClankerton --- .../attribute-spec/combinators/entity-ref.ts | 53 ++++++----- .../psl-parser/src/entity-reference.ts | 89 +++++++++---------- .../psl-parser/src/exports/index.ts | 3 +- 3 files changed, 66 insertions(+), 79 deletions(-) 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 360dc28a4408..da0050c198c2 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,16 +1,27 @@ +import { InternalError } from '@internal/utils/internal-error'; import { notOk, ok, type Result } from '@internal/utils/result'; import type { PslDiagnostic } from '../../diagnostic'; import type { DeclarationFor, - EntityLookup, EntitySelector, ResolvedEntityReference, } from '../../entity-reference'; -import { lookupEntityReference, lookupEntityReferenceInSymbols } from '../../entity-reference'; +import { + describeResolution, + entityReference, + lookupEntityReferenceInSymbols, + matchesSelector, +} from '../../entity-reference'; import { IdentifierAst } from '../../syntax/ast/identifier'; import type { AttributeCtx, EntityRefArgType } 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> { @@ -27,39 +38,25 @@ export function entityRef( if (name === undefined) { return notOk([leafDiagnostic(ctx, arg, `Expected ${label}`)]); } - const lookup: EntityLookup = + const resolution = ctx.binder === undefined ? lookupEntityReferenceInSymbols(arg, name, ctx.symbols) - : lookupEntityReference(arg, ctx.binder); - if (lookup.kind === 'unresolved') { - return lookup.voiced - ? notOk([]) - : notOk([leafDiagnostic(ctx, arg, `Unknown ${label} "${name}"`)]); + : (ctx.binder.symbolForNode(arg.syntax) ?? unbound(name)); + if (resolution === undefined) { + return notOk([leafDiagnostic(ctx, arg, `Unknown ${label} "${name}"`)]); } - if (lookup.kind === 'notAnEntity') { + 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 ${lookup.described}`), + leafDiagnostic( + ctx, + arg, + `Expected ${label} "${name}", found ${describeResolution(resolution)}`, + ), ]); } - const reference = lookup.reference; - 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}`)]); - } 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/entity-reference.ts b/packages/1-framework/2-authoring/psl-parser/src/entity-reference.ts index 407c1c258c6a..b796f631ae0d 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,6 +1,5 @@ -import { InternalError } from '@internal/utils/internal-error'; -import type { Binder, Resolution } from './binder'; -import { lookupIn, unqualifiedChain } from './scope-chain'; +import type { Resolution } from './binder'; +import { lookupIn, type ScopeResolution, unqualifiedChain } from './scope-chain'; import type { BlockSymbol, CompositeTypeSymbol, @@ -30,76 +29,68 @@ export interface ResolvedEntityReference(); - -export function lookupEntityReference(expression: ExpressionAst, binder: Binder): EntityLookup { - const resolution = binder.symbolForNode(expression.syntax); - if (resolution === undefined) { - throw new InternalError( - 'The binder on this attribute context bound nothing for this entity reference. 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 classify(resolution, true); -} - export function lookupEntityReferenceInSymbols( expression: ExpressionAst, name: string, symbols: SymbolTable, -): EntityLookup { +): ScopeResolution | undefined { const namespaceName = expression.syntax .findAncestor(NamespaceDeclarationAst.cast) ?.name() ?.name(); const namespace = namespaceName === undefined ? undefined : symbols.topLevel.namespaces[namespaceName]; - const found = lookupIn(unqualifiedChain(namespace, symbols.topLevel), name); - if (found === undefined) return { kind: 'unresolved', voiced: false }; - return classify(found, false); + return lookupIn(unqualifiedChain(namespace, symbols.topLevel), name); } -function classify(resolution: Resolution, voiced: boolean): EntityLookup { +const references = new WeakMap(); + +export function entityReference(resolution: Resolution): ResolvedEntityReference | undefined { switch (resolution.kind) { case 'model': case 'compositeType': case 'namedType': case 'block': - return { kind: 'entity', reference: referenceFor(resolution) }; - case 'unresolved': - return { kind: 'unresolved', voiced }; + return interned(resolution.symbol, resolution.namespace); default: - return { kind: 'notAnEntity', described: describeNonEntity(resolution) }; + return undefined; } } -function describeNonEntity(resolution: Resolution): string { +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; +} + +export function matchesSelector( + reference: ResolvedEntityReference, + expected: S, +): reference is ResolvedEntityReference> { + const declaration = reference.declaration; + if (declaration.kind !== expected.kind) return false; + return ( + expected.kind !== 'block' || + (declaration.kind === 'block' && declaration.keyword === expected.keyword) + ); +} + +export function describeResolution(resolution: Resolution): string { switch (resolution.kind) { - case 'namespace': - return 'namespace'; + case 'block': + return resolution.symbol.keyword; case 'contributedType': return 'scalar type'; - case 'field': - return 'field'; - case 'attribute': - return 'attribute'; - default: + case 'crossSpace': return 'cross-space reference'; + case 'unresolved': + return 'unresolved name'; + default: + return resolution.kind; } } - -function referenceFor( - resolution: Extract, -): ResolvedEntityReference { - const declaration = resolution.symbol; - let reference = references.get(declaration); - if (reference === undefined) { - reference = { declaration, namespace: resolution.namespace }; - references.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 89f0d408f83e..95d66402dd4d 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 @@ -132,11 +132,10 @@ export { export type { DeclarationFor, EntityDeclaration, - EntityLookup, EntitySelector, ResolvedEntityReference, } from '../entity-reference'; -export { lookupEntityReference, lookupEntityReferenceInSymbols } from '../entity-reference'; +export { lookupEntityReferenceInSymbols } from '../entity-reference'; export { findBlockDescriptor, validateExtensionBlockFromSymbol } from '../extension-block'; export { keywordPslSpan, From 1d2997397ffa81ef7850291e732092c9debeb75c Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Thu, 24 Sep 2026 15:16:26 +0000 Subject: [PATCH 52/66] refactor(psl): scopes hold their parent and answer a lookup themselves The chain was assembled outside the scopes: array builders produced an ordered list per reference and a free fold walked it. A caller had to know the shape of the chain to ask a question. Scopes now hold their own parentage. Each class searches its own records and, on a miss, asks its parent: a namespace scope asks the document scope, which asks the contributed scope, which has no parent and ends the search. A caller holds one scope and calls one method. The classes stay private behind the Scope interface, reached through three factories. The binder holds the scope the walk stands in. Entering a namespace it makes a child scope whose parent is the document scope, and every reference met inside resolves against it; leaving, the child is simply gone. Sibling namespaces are unreachable because no scope has a sibling for a parent, not because a chain builder left them out. PSL namespaces do not nest, so the standing scope is never deeper than a namespace inside a document - the stack is real but shallow. A qualified reference is two steps and pushes nothing: look the qualifier up through the standing scope, which reaches the document scope where namespaces live, then look the member up inside the symbol it found. The contributed registry keeps its nesting for this instead of flattening to dotted keys, so pgvector.Vector takes the same two steps as app.Item and answers with a contributed namespace whose members are then read. Named behavior change: a qualifier that resolves to something other than a namespace now says so - "app" is a model, not a namespace - where before the whole reference was reported as a type that could not be found. Pinned for a model and for an enum. Signed-off-by: Steven McClankerton --- .../2-authoring/psl-parser/src/binder.ts | 155 +++++++++++------- .../psl-parser/src/contributed-type-scope.ts | 44 ++--- .../psl-parser/src/entity-reference.ts | 6 +- .../psl-parser/src/exports/index.ts | 10 +- .../2-authoring/psl-parser/src/scope-chain.ts | 106 ------------ .../2-authoring/psl-parser/src/scope.ts | 137 ++++++++++++++++ .../psl-parser/test/binder.test.ts | 22 +++ .../2-authoring/psl-parser/test/scope.test.ts | 141 ++++++++++++++++ 8 files changed, 432 insertions(+), 189 deletions(-) delete mode 100644 packages/1-framework/2-authoring/psl-parser/src/scope-chain.ts create mode 100644 packages/1-framework/2-authoring/psl-parser/src/scope.ts create mode 100644 packages/1-framework/2-authoring/psl-parser/test/scope.test.ts diff --git a/packages/1-framework/2-authoring/psl-parser/src/binder.ts b/packages/1-framework/2-authoring/psl-parser/src/binder.ts index 93a72dc09bba..65ab3767230d 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/binder.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/binder.ts @@ -3,11 +3,19 @@ import type { ControlDefaultRegistries } from '@internal/framework-components/co 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 { type ContributedTypeScope, contributedTypeScope } from './contributed-type-scope'; +import { contributedTypeScope } from './contributed-type-scope'; import { diagnosticSource } from './diagnostic'; import type { ParseDiagnostic } from './parse'; import type { ResolvedAttribute } from './resolve'; -import { lookupIn, qualifiedChain, type ScopeResolution, unqualifiedChain } from './scope-chain'; +import { + contributedScope, + documentScope, + isNamespaceLike, + lookupMember, + namespaceScope, + type Scope, + type ScopeResolution, +} from './scope'; import type { PslSources } from './source-file'; import type { BlockSymbol, @@ -17,7 +25,6 @@ import type { NamedTypeSymbol, NamespaceSymbol, SymbolTable, - TopLevelScope, } from './symbol-table'; import type { FieldAttributeAst, ModelAttributeAst } from './syntax/ast/attributes'; import { ArrayLiteralAst, type ExpressionAst } from './syntax/ast/expressions'; @@ -109,7 +116,7 @@ class PslBinder implements Binder { } interface ScopedEntity { - readonly scope: NamespaceSymbol | undefined; + readonly scope: Scope; readonly entity: ModelSymbol | CompositeTypeSymbol; } @@ -122,7 +129,10 @@ export function createBinder(options: CreateBinderOptions): BinderResult { controlMutationDefaults, describeUnsupportedAttribute, } = options; - const contributedTypes = contributedTypeScope(typeConstructors); + const document = documentScope( + symbolTable.topLevel, + contributedScope(contributedTypeScope(typeConstructors)), + ); const declarations = new WeakMap(); const references = new WeakMap(); const diagnostics: ParseDiagnostic[] = []; @@ -142,28 +152,27 @@ export function createBinder(options: CreateBinderOptions): BinderResult { } } - for (const { scope, entity } of entities(symbolTable)) { + for (const { scope, entity } of entities(symbolTable, document)) { 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 resolution = resolveTypeReference(field, scope, symbolTable.topLevel, contributedTypes); - if (resolution === undefined) continue; - references.set(node, resolution); - const typeFailure = typePositionFailure(resolution, field); - if (typeFailure !== undefined) { + const outcome = resolveTypeReference(field, scope); + if (outcome === undefined) continue; + references.set(node, outcome.resolution); + if (outcome.message !== undefined) { diagnostics.push({ code: PSL_UNRESOLVED_REFERENCE, - message: typeFailure.message, - data: { reference: 'type', name: typeFailure.name }, + message: outcome.message, + data: { reference: 'type', name: outcome.name }, ...diagnosticSource(sources, node).at(), }); } } } - for (const { scope, entity } of entities(symbolTable)) { + for (const { scope, entity } of entities(symbolTable, document)) { const context = { owner: entity, scope, @@ -171,7 +180,6 @@ export function createBinder(options: CreateBinderOptions): BinderResult { diagnostics, symbolTable, sources, - contributedTypes, describeUnsupportedAttribute, }; const specContext = @@ -201,13 +209,12 @@ export function createBinder(options: CreateBinderOptions): BinderResult { interface BindContext { readonly owner: ModelSymbol | CompositeTypeSymbol; - readonly scope: NamespaceSymbol | undefined; + readonly scope: Scope; readonly field: FieldSymbol | undefined; readonly references: WeakMap; readonly diagnostics: ParseDiagnostic[]; readonly symbolTable: SymbolTable; readonly sources: PslSources; - readonly contributedTypes: ContributedTypeScope; readonly describeUnsupportedAttribute: DescribeUnsupportedAttribute | undefined; } @@ -316,10 +323,7 @@ function targetFields( } function resolveEntity(name: string, node: SyntaxNode, ctx: BindContext): Resolution { - const found = lookupIn( - unqualifiedChain(ctx.scope, ctx.symbolTable.topLevel, ctx.contributedTypes), - name, - ); + const found = ctx.scope.lookup(name); if (found === undefined) { report(`Cannot find entity "${name}"`, node, ctx, 'entity'); return { kind: 'unresolved', name }; @@ -365,58 +369,91 @@ function referenceNodes(expression: ExpressionAst): readonly SyntaxNode[] { return Array.from(array.elements(), (element) => element.syntax); } -function* entities(symbolTable: SymbolTable): Iterable { +function* entities(symbolTable: SymbolTable, document: Scope): Iterable { const { topLevel } = symbolTable; - for (const entity of Object.values(topLevel.models)) yield { scope: undefined, entity }; - for (const entity of Object.values(topLevel.compositeTypes)) yield { scope: undefined, entity }; - for (const scope of Object.values(topLevel.namespaces)) { - for (const entity of Object.values(scope.models)) yield { scope, entity }; - for (const entity of Object.values(scope.compositeTypes)) yield { scope, entity }; + for (const entity of Object.values(topLevel.models)) yield { scope: document, entity }; + for (const entity of Object.values(topLevel.compositeTypes)) yield { scope: document, entity }; + for (const namespace of Object.values(topLevel.namespaces)) { + const scope = namespaceScope(namespace, document); + for (const entity of Object.values(namespace.models)) yield { scope, entity }; + for (const entity of Object.values(namespace.compositeTypes)) yield { scope, entity }; } } -function typePositionFailure( - resolution: Resolution, - field: FieldSymbol, -): { readonly message: string; readonly name: string } | undefined { - if (resolution.kind === 'unresolved') { - return { message: `Cannot find type "${resolution.name}"`, name: resolution.name }; - } - if (resolution.kind !== 'namespace') return undefined; - const name = - field.typeNamespaceId === undefined - ? field.typeName - : `${field.typeNamespaceId}.${field.typeName}`; - return { - message: `"${name}" is a namespace; a type reference must name a model, composite type, enum, or named type`, - name, - }; +interface TypeReferenceOutcome { + readonly resolution: Resolution; + readonly message?: string; + readonly name?: string; } -function resolveTypeReference( - field: FieldSymbol, - scope: NamespaceSymbol | undefined, - topLevel: TopLevelScope, - contributedTypes: ContributedTypeScope, -): Resolution | undefined { +function resolveTypeReference(field: FieldSymbol, scope: Scope): TypeReferenceOutcome | undefined { if (field.malformedType === true) return undefined; - if (field.typeContractSpaceId !== undefined) return { kind: 'crossSpace' }; + if (field.typeContractSpaceId !== undefined) return { resolution: { kind: 'crossSpace' } }; const name = field.typeName; if (name === '') return undefined; - const namespaceId = field.typeNamespaceId; - const chain = - namespaceId === undefined - ? unqualifiedChain(scope, topLevel, contributedTypes) - : qualifiedChain(namespaceId, topLevel, contributedTypes); - const found = lookupIn(chain, name); + const found = + namespaceId === undefined ? scope.lookup(name) : qualifiedMember(namespaceId, name, scope); if (found === undefined) { + const written = namespaceId === undefined ? name : `${namespaceId}.${name}`; return { - kind: 'unresolved', - name: namespaceId === undefined ? name : `${namespaceId}.${name}`, + resolution: { kind: 'unresolved', name: written }, + message: `Cannot find type "${written}"`, + name: written, }; } - return found; + 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 { 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 index f73688c6d09d..339ca0e5cc7a 100644 --- 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 @@ -11,8 +11,17 @@ export interface ContributedTypeSymbol { 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(path: readonly string[]): ContributedTypeSymbol | undefined; + lookup(name: string): ContributedMember | undefined; } const scopes = new WeakMap(); @@ -22,32 +31,29 @@ export function contributedTypeScope( ): ContributedTypeScope { const existing = scopes.get(typeConstructors); if (existing !== undefined) return existing; - const created = buildScope(typeConstructors); - scopes.set(typeConstructors, created); - return created; -} - -function buildScope(typeConstructors: AuthoringTypeNamespace): ContributedTypeScope { - const symbols = new Map(); - collect(typeConstructors, [], symbols); - return { - lookup(path) { - return symbols.get(path.join('.')); + 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[], - symbols: Map, -): void { +): ReadonlyMap { + const members = new Map(); for (const [name, value] of Object.entries(namespace)) { const path = [...prefix, name]; - if (isAuthoringTypeConstructorDescriptor(value)) { - symbols.set(path.join('.'), { kind: 'contributedType', name, path, descriptor: value }); - } else { - collect(value, path, symbols); - } + 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 b796f631ae0d..a6551a2eb27a 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,5 +1,5 @@ import type { Resolution } from './binder'; -import { lookupIn, type ScopeResolution, unqualifiedChain } from './scope-chain'; +import { documentScope, namespaceScope, type Scope, type ScopeResolution } from './scope'; import type { BlockSymbol, CompositeTypeSymbol, @@ -38,9 +38,11 @@ export function lookupEntityReferenceInSymbols( .findAncestor(NamespaceDeclarationAst.cast) ?.name() ?.name(); + const document: Scope = documentScope(symbols.topLevel, undefined); const namespace = namespaceName === undefined ? undefined : symbols.topLevel.namespaces[namespaceName]; - return lookupIn(unqualifiedChain(namespace, symbols.topLevel), name); + const scope = namespace === undefined ? document : namespaceScope(namespace, document); + return scope.lookup(name); } const references = new WeakMap(); 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 95d66402dd4d..87c1ca0ea48c 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 @@ -121,7 +121,12 @@ export { modelAttributeContext, referencedModel, } from '../binder-context'; -export type { ContributedTypeScope, ContributedTypeSymbol } from '../contributed-type-scope'; +export type { + ContributedMember, + ContributedNamespaceSymbol, + ContributedTypeScope, + ContributedTypeSymbol, +} from '../contributed-type-scope'; export type { DiagnosticSource, PslDiagnostic, PslDiagnosticCollector } from '../diagnostic'; export { createPslDiagnosticCollector, @@ -145,8 +150,7 @@ export { readResolvedConstructorCall, } from '../resolve'; export { isPrismaNextSchema, renameLegacyDirective } from '../schema-directive'; -export type { Scope, ScopeResolution } from '../scope-chain'; -export { lookupIn, qualifiedChain, unqualifiedChain } from '../scope-chain'; +export type { Scope, ScopeResolution } from '../scope'; export type { BlockSymbol, BuildSymbolTableOptions, diff --git a/packages/1-framework/2-authoring/psl-parser/src/scope-chain.ts b/packages/1-framework/2-authoring/psl-parser/src/scope-chain.ts deleted file mode 100644 index ae31fcb71957..000000000000 --- a/packages/1-framework/2-authoring/psl-parser/src/scope-chain.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { ContributedTypeScope, ContributedTypeSymbol } from './contributed-type-scope'; -import type { - BlockSymbol, - CompositeTypeSymbol, - ModelSymbol, - NamedTypeSymbol, - NamespaceSymbol, - TopLevelScope, -} 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: 'contributedType'; readonly symbol: ContributedTypeSymbol }; - -export interface Scope { - lookup(name: string): ScopeResolution | undefined; -} - -export function namespaceScope(namespace: NamespaceSymbol): Scope { - return { - lookup(name) { - 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; - }, - }; -} - -export function topLevelScope(topLevel: TopLevelScope): Scope { - return { - lookup(name) { - const model = topLevel.models[name]; - if (model !== undefined) return { kind: 'model', symbol: model }; - const compositeType = topLevel.compositeTypes[name]; - if (compositeType !== undefined) return { kind: 'compositeType', symbol: compositeType }; - const namedType = topLevel.namedTypes[name]; - if (namedType !== undefined) return { kind: 'namedType', symbol: namedType }; - const block = topLevel.blocks[name]; - if (block !== undefined) return { kind: 'block', symbol: block }; - const namespace = topLevel.namespaces[name]; - if (namespace !== undefined) return { kind: 'namespace', symbol: namespace }; - return undefined; - }, - }; -} - -export function contributedScope( - contributedTypes: ContributedTypeScope, - prefix: readonly string[], -): Scope { - return { - lookup(name) { - const symbol = contributedTypes.lookup([...prefix, name]); - return symbol === undefined ? undefined : { kind: 'contributedType', symbol }; - }, - }; -} - -export function unqualifiedChain( - scope: NamespaceSymbol | undefined, - topLevel: TopLevelScope, - contributedTypes?: ContributedTypeScope, -): readonly Scope[] { - const outer = - contributedTypes === undefined - ? [topLevelScope(topLevel)] - : [topLevelScope(topLevel), contributedScope(contributedTypes, [])]; - return scope === undefined ? outer : [namespaceScope(scope), ...outer]; -} - -export function qualifiedChain( - namespaceId: string, - topLevel: TopLevelScope, - contributedTypes?: ContributedTypeScope, -): readonly Scope[] { - const namespace = topLevel.namespaces[namespaceId]; - const declared = namespace === undefined ? [] : [namespaceScope(namespace)]; - return contributedTypes === undefined - ? declared - : [...declared, contributedScope(contributedTypes, [namespaceId])]; -} - -export function lookupIn(chain: readonly Scope[], name: string): ScopeResolution | undefined { - for (const scope of chain) { - const found = scope.lookup(name); - if (found !== undefined) return found; - } - return undefined; -} 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..78ceb4c094d3 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/scope.ts @@ -0,0 +1,137 @@ +import type { + 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; +} + +class ContributedScope implements Scope { + readonly #registry: ContributedTypeScope; + + constructor(registry: ContributedTypeScope) { + this.#registry = registry; + } + + lookup(name: string): ScopeResolution | undefined { + const member = this.#registry.lookup(name); + if (member === undefined) return undefined; + return member.kind === 'contributedType' + ? { kind: 'contributedType', symbol: member } + : { kind: 'contributedNamespace', symbol: 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 { + const namespace = this.#namespace; + 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 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); + if (member === undefined) return undefined; + return member.kind === 'contributedType' + ? { kind: 'contributedType', symbol: member } + : { kind: 'contributedNamespace', symbol: member }; + } + const namespace = qualifier.symbol; + 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; +} 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 index 0f9b2948b609..4e802a893317 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/binder.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/binder.test.ts @@ -383,6 +383,28 @@ describe('createBinder — qualified references', () => { }); }); + 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( [ 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); + }); +}); From f687eed86a0a17459b0cf3377df9119b33d6625d Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Thu, 24 Sep 2026 15:18:44 +0000 Subject: [PATCH 53/66] chore(drive): record scope-class blueprint supersession (decision 12) Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- projects/symbol-table-resolve/design-decisions.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/projects/symbol-table-resolve/design-decisions.md b/projects/symbol-table-resolve/design-decisions.md index e56e8a42c820..2e7143c37735 100644 --- a/projects/symbol-table-resolve/design-decisions.md +++ b/projects/symbol-table-resolve/design-decisions.md @@ -108,6 +108,8 @@ Outcome of the design discussion (2026-09-18) that preceded [`spec.md`](./spec.m **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 walks with the current scope stack-wise (push on entering a namespace, drop on leaving), so resolution context is where-the-walk-stands. 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). From d7fc587f6c0b6c8ee4cbb4c5f3e8f988c738f178 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Thu, 24 Sep 2026 17:13:35 +0000 Subject: [PATCH 54/66] refactor(psl): an entity reference is resolved only by the binder The pre-binder path existed because block attributes are interpreted while the symbol table is still being built, and one synthetic fixture resolved an entity reference there. No production block attribute does: entity references reach the binder in every real case, and a parse that produces resolutions is what forced two paths in the first place. So the second path is gone. entityRef reads ctx.binder unconditionally, exactly as fieldRef does, and a binder-less context is an internal error naming the rule: block attributes carry no entity references, and any added later belong in binder phase 2 over block symbols rather than in argument parsing. lookupEntityReferenceInSymbols and its export go with it, and so does the Unknown-reference message, which only the deleted path could ever reach - the binder owns the not-found voice alone now. Pins moved with the deletion: - the block-attribute forward reference now asserts the refusal instead of the resolution it used to produce. - the five unavailable and inherited names (Hidden, Missing, toString, constructor, __proto__) assert what the single-voice law already says elsewhere: the combinator fails carrying nothing and the binder reports "Cannot find entity". - three fixtures that built a binder-less context - the shared one in entity-reference, the declared __proto__ case beside it, and referenceArg in the combinator suite - now build a binder over their own snapshot, which keeps the twenty-six selector, shadowing, identity and alternative cases asserting exactly what they asserted before. Signed-off-by: Steven McClankerton --- .../attribute-spec/combinators/entity-ref.ts | 19 ++- .../psl-parser/src/entity-reference.ts | 20 ---- .../psl-parser/src/exports/index.ts | 1 - .../test/attribute-spec-combinators.test.ts | 20 +++- .../psl-parser/test/entity-reference.test.ts | 109 ++++++++++++------ 5 files changed, 100 insertions(+), 69 deletions(-) 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 da0050c198c2..aa93ed4b00f5 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 @@ -6,12 +6,7 @@ import type { EntitySelector, ResolvedEntityReference, } from '../../entity-reference'; -import { - describeResolution, - entityReference, - lookupEntityReferenceInSymbols, - matchesSelector, -} from '../../entity-reference'; +import { describeResolution, entityReference, matchesSelector } from '../../entity-reference'; import { IdentifierAst } from '../../syntax/ast/identifier'; import type { AttributeCtx, EntityRefArgType } from '../types'; import { leafDiagnostic } from './diagnostic'; @@ -38,13 +33,13 @@ export function entityRef( if (name === undefined) { return notOk([leafDiagnostic(ctx, arg, `Expected ${label}`)]); } - const resolution = - ctx.binder === undefined - ? lookupEntityReferenceInSymbols(arg, name, ctx.symbols) - : (ctx.binder.symbolForNode(arg.syntax) ?? unbound(name)); - if (resolution === undefined) { - return notOk([leafDiagnostic(ctx, arg, `Unknown ${label} "${name}"`)]); + const binder = ctx.binder; + if (binder === undefined) { + throw new InternalError( + `No binder on this attribute context, so "${name}" cannot be resolved: block attributes carry no entity references, and any that are added must be resolved by the binder over block symbols rather than while their arguments parse.`, + ); } + const resolution = binder.symbolForNode(arg.syntax) ?? unbound(name); if (resolution.kind === 'unresolved') return notOk([]); const reference = entityReference(resolution); if (reference === undefined || !matchesSelector(reference, expected)) { 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 a6551a2eb27a..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,15 +1,11 @@ import type { Resolution } from './binder'; -import { documentScope, namespaceScope, type Scope, type ScopeResolution } from './scope'; import type { BlockSymbol, CompositeTypeSymbol, ModelSymbol, NamedTypeSymbol, NamespaceSymbol, - SymbolTable, } from './symbol-table'; -import { NamespaceDeclarationAst } from './syntax/ast/declarations'; -import type { ExpressionAst } from './syntax/ast/expressions'; export type EntitySelector = | { readonly kind: 'model' } @@ -29,22 +25,6 @@ export interface ResolvedEntityReference(); export function entityReference(resolution: Resolution): ResolvedEntityReference | undefined { 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 87c1ca0ea48c..68c954a2178f 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 @@ -140,7 +140,6 @@ export type { EntitySelector, ResolvedEntityReference, } from '../entity-reference'; -export { lookupEntityReferenceInSymbols } from '../entity-reference'; export { findBlockDescriptor, validateExtensionBlockFromSymbol } from '../extension-block'; export { keywordPslSpan, 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 77f23f871fc6..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 @@ -762,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/entity-reference.test.ts b/packages/1-framework/2-authoring/psl-parser/test/entity-reference.test.ts index dd23205ed7aa..b3bfb2d61b93 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,15 @@ 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 { + blockAttribute, + 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 +43,28 @@ function fixture(value: string, local = true) { expect(diagnostics).toEqual([]); const namespace = symbolTable.topLevel.namespaces['Local']; if (!namespace) throw new Error('Missing namespace'); + 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 +72,8 @@ function fixture(value: string, local = true) { if (expression) return { expression, - ctx: { sources, symbols: symbolTable }, + ctx: { sources, symbols: symbolTable, binder }, + binderDiagnostics, sources, table: symbolTable, namespace, @@ -52,7 +83,7 @@ function fixture(value: string, local = true) { } describe('syntax-scoped entity resolution', () => { - it('supplies the completed table to existing block attribute rules', () => { + it('refuses an entity reference in a block attribute, which parses before any binder', () => { const { document, sources } = parse( 'namespace Local {\n permission Reader {\n @@target(Later)\n }\n model Later {}\n}', 'references.prisma', @@ -63,25 +94,22 @@ describe('syntax-scoped entity resolution', () => { { 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(() => + 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 }, - }); + }), + ).toThrow(/block attributes carry no entity references/); }); it('selects the local declaration, including forward references', () => { const { expression, ctx, namespace } = fixture('Shared'); @@ -119,23 +147,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 +181,25 @@ 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 ctx = { sources, symbols: symbolTable, binder }; expect(entityRef({ kind: 'model' }).parse(expression, ctx)).toEqual( ok({ declaration, namespace: undefined }), ); From 3f4b37102fb12f0f6cf9c9b4643704e6f6ae02f9 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Thu, 24 Sep 2026 17:14:35 +0000 Subject: [PATCH 55/66] chore(drive): record pre-binder path amputation (decision 13) Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- projects/symbol-table-resolve/design-decisions.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/projects/symbol-table-resolve/design-decisions.md b/projects/symbol-table-resolve/design-decisions.md index 2e7143c37735..8d56abc0e5c4 100644 --- a/projects/symbol-table-resolve/design-decisions.md +++ b/projects/symbol-table-resolve/design-decisions.md @@ -116,7 +116,9 @@ Outcome of the design discussion (2026-09-18) that preceded [`spec.md`](./spec.m **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. -**Execution refinement (ratified):** `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. +**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) From d616df5a1bdc927f7b4a61a3cd086474cfa0dd6c Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Thu, 24 Sep 2026 17:24:05 +0000 Subject: [PATCH 56/66] refactor(psl): the binder pushes and pops the scope it stands in The scope a reference resolved against came from a generator that paired each entity with a scope. That produced the right answers, but the mechanism was a substitution: the stack discipline was implicit in the pairing rather than written down, so nothing in the code says when a scope begins or ends. The binder now owns a ScopeStack. It starts on the document scope over the contributed root, and walkEntities pushes a namespace scope when the walk enters a namespace region and pops it when the walk leaves; current() is the top, and both phases resolve against it - phase 1 for type references, phase 2 for attribute arguments through BindContext. The discipline is a named operation at a named place, which is what makes it hold when namespaces eventually nest rather than only while they do not. Sibling exclusion now follows from the pop rather than from what a chain builder left out, and is pinned that way: a name declared in one namespace is unresolved in the next, while the document scope underneath stays reachable from inside both. The namespace member cascade also had two copies, one inside the scope and one behind the qualifier lookup. There is one now: the scope is member then parent, the qualifier is the member half alone, and the contributed member mapping is shared the same way. Signed-off-by: Steven McClankerton --- .../2-authoring/psl-parser/src/binder.ts | 67 ++++++++++++------- .../2-authoring/psl-parser/src/scope.ts | 48 ++++++------- .../psl-parser/test/binder.test.ts | 49 ++++++++++++++ 3 files changed, 115 insertions(+), 49 deletions(-) diff --git a/packages/1-framework/2-authoring/psl-parser/src/binder.ts b/packages/1-framework/2-authoring/psl-parser/src/binder.ts index 65ab3767230d..5034c4782d09 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/binder.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/binder.ts @@ -115,9 +115,42 @@ class PslBinder implements Binder { } } -interface ScopedEntity { - readonly scope: Scope; - readonly entity: ModelSymbol | CompositeTypeSymbol; +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 { @@ -129,9 +162,8 @@ export function createBinder(options: CreateBinderOptions): BinderResult { controlMutationDefaults, describeUnsupportedAttribute, } = options; - const document = documentScope( - symbolTable.topLevel, - contributedScope(contributedTypeScope(typeConstructors)), + const stack = new ScopeStack( + documentScope(symbolTable.topLevel, contributedScope(contributedTypeScope(typeConstructors))), ); const declarations = new WeakMap(); const references = new WeakMap(); @@ -152,13 +184,13 @@ export function createBinder(options: CreateBinderOptions): BinderResult { } } - for (const { scope, entity } of entities(symbolTable, document)) { + 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, scope); + const outcome = resolveTypeReference(field, stack.current()); if (outcome === undefined) continue; references.set(node, outcome.resolution); if (outcome.message !== undefined) { @@ -170,12 +202,12 @@ export function createBinder(options: CreateBinderOptions): BinderResult { }); } } - } + }); - for (const { scope, entity } of entities(symbolTable, document)) { + walkEntities(symbolTable, stack, (entity) => { const context = { owner: entity, - scope, + scope: stack.current(), references, diagnostics, symbolTable, @@ -202,7 +234,7 @@ export function createBinder(options: CreateBinderOptions): BinderResult { { ...context, field }, ); } - } + }); return { binder: new PslBinder(declarations, references), diagnostics }; } @@ -369,17 +401,6 @@ function referenceNodes(expression: ExpressionAst): readonly SyntaxNode[] { return Array.from(array.elements(), (element) => element.syntax); } -function* entities(symbolTable: SymbolTable, document: Scope): Iterable { - const { topLevel } = symbolTable; - for (const entity of Object.values(topLevel.models)) yield { scope: document, entity }; - for (const entity of Object.values(topLevel.compositeTypes)) yield { scope: document, entity }; - for (const namespace of Object.values(topLevel.namespaces)) { - const scope = namespaceScope(namespace, document); - for (const entity of Object.values(namespace.models)) yield { scope, entity }; - for (const entity of Object.values(namespace.compositeTypes)) yield { scope, entity }; - } -} - interface TypeReferenceOutcome { readonly resolution: Resolution; readonly message?: string; diff --git a/packages/1-framework/2-authoring/psl-parser/src/scope.ts b/packages/1-framework/2-authoring/psl-parser/src/scope.ts index 78ceb4c094d3..2f715de5a078 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/scope.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/scope.ts @@ -1,4 +1,5 @@ import type { + ContributedMember, ContributedNamespaceSymbol, ContributedTypeScope, ContributedTypeSymbol, @@ -33,6 +34,23 @@ 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; @@ -42,10 +60,7 @@ class ContributedScope implements Scope { lookup(name: string): ScopeResolution | undefined { const member = this.#registry.lookup(name); - if (member === undefined) return undefined; - return member.kind === 'contributedType' - ? { kind: 'contributedType', symbol: member } - : { kind: 'contributedNamespace', symbol: member }; + return member === undefined ? undefined : contributedResolution(member); } } @@ -84,15 +99,7 @@ class NamespaceScope implements Scope { } lookup(name: string): ScopeResolution | undefined { - const namespace = this.#namespace; - 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 this.#parent.lookup(name); + return namespaceMember(this.#namespace, name) ?? this.#parent.lookup(name); } } @@ -120,18 +127,7 @@ export function lookupMember( ): ScopeResolution | undefined { if (qualifier.kind === 'contributedNamespace') { const member = qualifier.symbol.members.get(name); - if (member === undefined) return undefined; - return member.kind === 'contributedType' - ? { kind: 'contributedType', symbol: member } - : { kind: 'contributedNamespace', symbol: member }; + return member === undefined ? undefined : contributedResolution(member); } - const namespace = qualifier.symbol; - 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; + return namespaceMember(qualifier.symbol, name); } 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 index 4e802a893317..677f29a40b84 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/binder.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/binder.test.ts @@ -870,6 +870,55 @@ describe('createBinder — entityRef arguments', () => { }); }); +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 {', From 1c32ec8b0bafd7a8e752b279d1b0f0451b58b811 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Thu, 24 Sep 2026 17:25:16 +0000 Subject: [PATCH 57/66] chore(drive): record the explicit-stack correction (decision 12) Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- projects/symbol-table-resolve/design-decisions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/symbol-table-resolve/design-decisions.md b/projects/symbol-table-resolve/design-decisions.md index 8d56abc0e5c4..69c84e8abf07 100644 --- a/projects/symbol-table-resolve/design-decisions.md +++ b/projects/symbol-table-resolve/design-decisions.md @@ -108,7 +108,7 @@ Outcome of the design discussion (2026-09-18) that preceded [`spec.md`](./spec.m **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 walks with the current scope stack-wise (push on entering a namespace, drop on leaving), so resolution context is where-the-walk-stands. 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. +**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) From f568428858e7836ff5dcd6fee107245a1ee35e7f Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Thu, 24 Sep 2026 19:24:06 +0000 Subject: [PATCH 58/66] refactor(psl): drop the attribute-ctx builders and unexport the type-node accessor The two ctx builders copied their input fields into a context of the same shape, and referencedModel was three lines over symbolForNode. Nothing in the repo called them but psl-parser own tests: no consumer package imported them, and the language server has its own referencedModel with a different signature. They existed to hand each conversion slice a ready-made resolveReferencedModel, and that callback is gone, so the module goes with it and the call sites build ModelAttributeCtx and FieldAttributeCtx literals instead. typeReferenceNode is a different case than the sweep suggested. It has two production callers, not one - phase 1 and resolveReferencedField - so a name for field.node.typeAnnotation()?.name()?.syntax still earns its place inside the binder. What it did not earn is a place on the package surface, which no consumer ever used, so it stays module-level and leaves the barrel. Signed-off-by: Steven McClankerton --- .../psl-parser/src/binder-context.ts | 43 --- .../psl-parser/src/exports/index.ts | 11 - .../test/attribute-spec-binder.test.ts | 39 +-- .../psl-parser/test/binder-context.test.ts | 247 ------------------ 4 files changed, 13 insertions(+), 327 deletions(-) delete mode 100644 packages/1-framework/2-authoring/psl-parser/src/binder-context.ts delete mode 100644 packages/1-framework/2-authoring/psl-parser/test/binder-context.test.ts diff --git a/packages/1-framework/2-authoring/psl-parser/src/binder-context.ts b/packages/1-framework/2-authoring/psl-parser/src/binder-context.ts deleted file mode 100644 index 2f87ab2a01ce..000000000000 --- a/packages/1-framework/2-authoring/psl-parser/src/binder-context.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { FieldAttributeCtx, ModelAttributeCtx } from './attribute-spec/types'; -import { type Binder, typeReferenceNode } from './binder'; -import type { PslSources } from './source-file'; -import type { FieldSymbol, ModelSymbol, SymbolTable } from './symbol-table'; - -export function referencedModel(binder: Binder, field: FieldSymbol): ModelSymbol | undefined { - const node = typeReferenceNode(field); - if (node === undefined) return undefined; - const resolution = binder.symbolForNode(node); - return resolution?.kind === 'model' ? resolution.symbol : undefined; -} - -export interface AttributeContextInput { - readonly binder: Binder; - readonly sources: PslSources; - readonly symbols: SymbolTable; - readonly model: ModelSymbol; -} - -export type ModelAttributeContextInput = AttributeContextInput; - -export interface FieldAttributeContextInput extends AttributeContextInput { - readonly field: FieldSymbol; -} - -export function modelAttributeContext(input: ModelAttributeContextInput): ModelAttributeCtx { - return { - sources: input.sources, - symbols: input.symbols, - binder: input.binder, - selfModel: input.model, - }; -} - -export function fieldAttributeContext(input: FieldAttributeContextInput): FieldAttributeCtx { - return { - sources: input.sources, - symbols: input.symbols, - binder: input.binder, - selfModel: input.model, - field: input.field, - }; -} 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 68c954a2178f..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 @@ -109,18 +109,7 @@ export type { export { createBinder, PSL_UNRESOLVED_REFERENCE, - typeReferenceNode, } from '../binder'; -export type { - AttributeContextInput, - FieldAttributeContextInput, - ModelAttributeContextInput, -} from '../binder-context'; -export { - fieldAttributeContext, - modelAttributeContext, - referencedModel, -} from '../binder-context'; export type { ContributedMember, ContributedNamespaceSymbol, 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 index f985fb9ee67a..b15a550bb701 100644 --- 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 @@ -12,7 +12,6 @@ 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 { fieldAttributeContext, modelAttributeContext } from '../src/binder-context'; import { parse } from '../src/parse'; import { PslSources } from '../src/source-file'; import { buildSymbolTable, type FieldSymbol, type ModelSymbol } from '../src/symbol-table'; @@ -113,7 +112,7 @@ function interpretRelation(text: string) { const { sources, symbolTable, binder, binderDiagnostics } = bind(text); const post = symbolTable.topLevel.models['Post']!; const field = post.fields['author']!; - const ctx = fieldAttributeContext({ binder, sources, symbols: symbolTable, model: post, field }); + const ctx = { sources, symbols: symbolTable, binder, selfModel: post, field }; return { binderDiagnostics, result: interpretAttribute(fieldAttributeNode(field, 'relation'), relationSpec, ctx), @@ -174,7 +173,7 @@ describe('reference combinators with a binder-backed context', () => { 'model Child {\n id Int\n @@base(Ghost)\n}', ); const child = symbolTable.topLevel.models['Child']!; - const ctx = modelAttributeContext({ binder, sources, symbols: symbolTable, model: child }); + const ctx = { sources, symbols: symbolTable, binder, selfModel: child }; const result = interpretAttribute(modelAttributeNode(child, 'base'), baseSpec, ctx); expect(result.ok).toBe(false); @@ -194,13 +193,7 @@ describe('reference combinators with a binder-backed context', () => { const { sources, symbolTable, binder, binderDiagnostics } = bind(schema); const post = symbolTable.topLevel.models['Post']!; const field = post.fields['author']!; - const ctx = fieldAttributeContext({ - binder, - sources, - symbols: symbolTable, - model: post, - field, - }); + const ctx = { sources, symbols: symbolTable, binder, selfModel: post, field }; const result = interpretAttribute(fieldAttributeNode(field, 'relation'), relationSpec, ctx); expect(result.ok).toBe(true); @@ -213,7 +206,7 @@ describe('reference combinators with a binder-backed context', () => { 'model User {\n id Int\n @@index("id")\n}', ); const user = symbolTable.topLevel.models['User']!; - const ctx = modelAttributeContext({ binder, sources, symbols: symbolTable, model: user }); + const ctx = { sources, symbols: symbolTable, binder, selfModel: user }; const result = interpretAttribute(modelAttributeNode(user, 'index'), indexSpec, ctx); expect(result.ok).toBe(false); @@ -230,7 +223,7 @@ describe('reference combinators with a binder-backed context', () => { 'model User {\n id Int\n @@index(["id"])\n}', ); const user = symbolTable.topLevel.models['User']!; - const ctx = modelAttributeContext({ binder, sources, symbols: symbolTable, model: user }); + const ctx = { sources, symbols: symbolTable, binder, selfModel: user }; const result = interpretAttribute(modelAttributeNode(user, 'index'), indexSpec, ctx); expect(result.ok).toBe(false); @@ -247,7 +240,7 @@ describe('the binder is the only resolution path', () => { 'model User {\n id Int\n @@index([nope])\n}', ); const user = symbolTable.topLevel.models['User']!; - const ctx = modelAttributeContext({ binder, sources, symbols: symbolTable, model: user }); + const ctx = { sources, symbols: symbolTable, binder, selfModel: user }; const result = interpretAttribute(modelAttributeNode(user, 'index'), indexSpec, ctx); expect(result.ok).toBe(false); @@ -262,7 +255,7 @@ describe('the binder is the only resolution path', () => { 'model User {\n id Int\n @@index([id])\n}', ); const user = symbolTable.topLevel.models['User']!; - const ctx = modelAttributeContext({ binder, sources, symbols: symbolTable, model: user }); + const ctx = { sources, symbols: symbolTable, binder, selfModel: user }; const result = interpretAttribute(modelAttributeNode(user, 'index'), indexSpec, ctx); expect(result.ok).toBe(true); @@ -277,13 +270,13 @@ describe('a binder built over another snapshot', () => { const second = bind(RELATION_SCHEMA); const post = second.symbolTable.topLevel.models['Post']!; const field = post.fields['author']!; - const ctx = fieldAttributeContext({ - binder: first.binder, - symbols: second.symbolTable, + const ctx = { sources: second.sources, - model: post, + symbols: second.symbolTable, + binder: first.binder, + selfModel: post, field, - }); + }; expect(() => interpretAttribute(fieldAttributeNode(field, 'relation'), relationSpec, ctx), @@ -294,13 +287,7 @@ describe('a binder built over another snapshot', () => { const { sources, symbolTable, binder } = bind(RELATION_SCHEMA); const post = symbolTable.topLevel.models['Post']!; const field = post.fields['author']!; - const ctx = fieldAttributeContext({ - binder, - sources, - symbols: symbolTable, - model: post, - field, - }); + const ctx = { sources, symbols: symbolTable, binder, selfModel: post, field }; const result = interpretAttribute(fieldAttributeNode(field, 'relation'), relationSpec, ctx); diff --git a/packages/1-framework/2-authoring/psl-parser/test/binder-context.test.ts b/packages/1-framework/2-authoring/psl-parser/test/binder-context.test.ts deleted file mode 100644 index 4d83643f3513..000000000000 --- a/packages/1-framework/2-authoring/psl-parser/test/binder-context.test.ts +++ /dev/null @@ -1,247 +0,0 @@ -import type { - AuthoringPslBlockDescriptorNamespace, - AuthoringTypeNamespace, -} from '@internal/framework-components/authoring'; -import { describe, expect, it } from 'vitest'; -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 { optional } from '../src/attribute-spec/optional'; -import { createBinder } from '../src/binder'; -import { - fieldAttributeContext, - modelAttributeContext, - referencedModel, -} from '../src/binder-context'; -import { parse } from '../src/parse'; -import { PslSources } from '../src/source-file'; -import { buildSymbolTable, type ModelSymbol } from '../src/symbol-table'; -import { ArrayLiteralAst } from '../src/syntax/ast/expressions'; - -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', - positional: [{ key: 'name', type: optional(str()), documentation: 'fixture' }], - named: { - fields: { - type: optional(list(fieldRef(), { allowEmpty: false, unique: true })), - documentation: 'fixture', - }, - references: { - type: optional(list(referencedFieldRef(), { allowEmpty: false, unique: true })), - documentation: 'fixture', - }, - }, -}); - -const ATTRIBUTE_SPECS = { - model: {}, - 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, - }); - return { - sources, - symbolTable, - ...createBinder({ - sources, - symbolTable, - typeConstructors: TYPE_CONSTRUCTORS, - attributeSpecs: ATTRIBUTE_SPECS, - controlMutationDefaults: { - defaultFunctionRegistry: new Map(), - dataTypeEntries: {}, - }, - }), - }; -} - -const RELATION_SCHEMA = [ - 'model User {', - ' id Int', - '}', - 'model Post {', - ' authorId Int', - ' author User @relation(fields: [authorId], references: [id])', - '}', -].join('\n'); - -describe('referencedModel', () => { - it('returns the model a relation field points at', () => { - const { symbolTable, binder } = bind(RELATION_SCHEMA); - const post = symbolTable.topLevel.models['Post']!; - - expect(referencedModel(binder, post.fields['author']!)).toBe( - symbolTable.topLevel.models['User'], - ); - }); - - it('returns undefined for a cross-space field type', () => { - const { symbolTable, binder } = bind('model Cart {\n user auth:User\n}'); - const cart = symbolTable.topLevel.models['Cart']!; - - expect(referencedModel(binder, cart.fields['user']!)).toBeUndefined(); - }); - - it('returns undefined for an unresolved field type', () => { - const { symbolTable, binder } = bind('model Cart {\n owner Ghost\n}'); - const cart = symbolTable.topLevel.models['Cart']!; - - expect(referencedModel(binder, cart.fields['owner']!)).toBeUndefined(); - }); - - it('returns undefined when the type resolves to something other than a model', () => { - const { symbolTable, binder } = bind( - [ - 'enum Role {', - ' Admin', - '}', - 'type Address {', - ' street String', - '}', - 'model User {', - ' role Role', - ' address Address', - ' name String', - '}', - ].join('\n'), - ); - const user = symbolTable.topLevel.models['User']!; - - expect(referencedModel(binder, user.fields['role']!)).toBeUndefined(); - expect(referencedModel(binder, user.fields['address']!)).toBeUndefined(); - expect(referencedModel(binder, user.fields['name']!)).toBeUndefined(); - }); -}); - -describe('fieldAttributeContext', () => { - it('carries the binder and the declaring symbols', () => { - const { sources, symbolTable, binder } = bind(RELATION_SCHEMA); - const post = symbolTable.topLevel.models['Post']!; - const ctx = fieldAttributeContext({ - binder, - symbols: symbolTable, - sources, - model: post, - field: post.fields['author']!, - }); - - expect(ctx.sources).toBe(sources); - expect(ctx.selfModel).toBe(post); - expect(ctx.field).toBe(post.fields['author']); - expect(ctx.binder).toBe(binder); - expect(referencedModel(ctx.binder, ctx.field)).toBe(symbolTable.topLevel.models['User']); - }); - - it('carries a binder that resolves no model for a cross-space relation', () => { - const { sources, symbolTable, binder } = bind( - 'model Cart {\n userId Int\n user auth:User @relation(fields: [userId], references: [id])\n}', - ); - const cart = symbolTable.topLevel.models['Cart']!; - const ctx = fieldAttributeContext({ - binder, - symbols: symbolTable, - sources, - model: cart, - field: cart.fields['user']!, - }); - - expect(referencedModel(ctx.binder, ctx.field)).toBeUndefined(); - }); -}); - -describe('modelAttributeContext', () => { - it('carries the sources and the declaring model', () => { - const { sources, symbolTable, binder } = bind(RELATION_SCHEMA); - const post = symbolTable.topLevel.models['Post']!; - const ctx = modelAttributeContext({ binder, sources, symbols: symbolTable, model: post }); - - expect(ctx.sources).toBe(sources); - expect(ctx.selfModel).toBe(post); - }); -}); - -describe('reference parameters under optional(list(...))', () => { - it('resolves both sides of a relation spec shaped like a target', () => { - const { symbolTable, binder, diagnostics } = bind(RELATION_SCHEMA); - const post = symbolTable.topLevel.models['Post']!; - const user = symbolTable.topLevel.models['User']!; - const argumentNode = (argName: string) => { - for (const attribute of post.fields['author']!.node.attributes()) { - for (const arg of attribute.argList()?.args() ?? []) { - if (arg.name()?.name() !== argName) continue; - const value = arg.value(); - const array = value === undefined ? undefined : ArrayLiteralAst.cast(value.syntax); - return Array.from(array?.elements() ?? [], (element) => element.syntax)[0]; - } - } - return undefined; - }; - - expect(diagnostics).toEqual([]); - const fields = argumentNode('fields'); - const references = argumentNode('references'); - expect(fields).toBeDefined(); - expect(references).toBeDefined(); - expect(fields === undefined ? undefined : binder.symbolForNode(fields)).toEqual({ - kind: 'field', - symbol: post.fields['authorId'], - }); - expect(references === undefined ? undefined : binder.symbolForNode(references)).toEqual({ - kind: 'field', - symbol: user.fields['id'], - }); - }); - - it('reports an unknown name through the optional wrapper', () => { - const { diagnostics } = bind( - [ - 'model User {', - ' id Int', - '}', - 'model Post {', - ' authorId Int', - ' author User @relation(fields: [nope], references: [alsoNope])', - '}', - ].join('\n'), - ); - - expect(diagnostics.map(({ message }) => message)).toEqual([ - 'Cannot find field "nope" on "Post"', - 'Cannot find field "alsoNope" on the type of "Post.author"', - ]); - }); -}); - -describe('typed model surface', () => { - it('narrows a binder resolution to a model symbol', () => { - const { symbolTable, binder } = bind(RELATION_SCHEMA); - const post = symbolTable.topLevel.models['Post']!; - const resolved: ModelSymbol | undefined = referencedModel(binder, post.fields['author']!); - - expect(resolved?.name).toBe('User'); - }); -}); From d91af71df998dad9956e7a5186058596f5234294 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Thu, 24 Sep 2026 19:34:34 +0000 Subject: [PATCH 59/66] fix(psl): the binder is required again, and the compiler says so Decision 10 said the binder is required and that requiring it deletes the trap instead of documenting it. The tree had it optional on AttributeCtx with entityRef throwing when it was absent - the trap, documented. It was optional because refinement 10(B) judged retyping entityRef too costly: Mongo nested optional(entityRef()) inside a funcCall, and FuncCallSig types its params against the base context, so demanding a binder there would have meant genericizing the signature. That cost no longer exists. main replaced that nesting with optional(identifier()), and a sweep finds no entityRef inside any funcCall anywhere - the only two call sites are @@base positionals in each family, both on model attributes, which carry a binder already. So entityRef demands the binder-bearing context, exactly as fieldRef does, and the optional field leaves AttributeCtx, which is now the binder-less base the block path uses. A block attribute naming a reference combinator no longer throws at run time: it does not compile, because a context without a binder is not assignable to one with it. The runtime guard for a binder from the wrong snapshot stays - that is a different failure and still only knowable at run time. Pins moved with it: the block-attribute refusal is a negative type test over blockAttribute with entityRef and fieldRef instead of a thrown error, keyof AttributeCtx drops binder again, and the combinator context pin reads ModelAttributeCtx. Signed-off-by: Steven McClankerton --- .../attribute-spec/combinators/entity-ref.ts | 12 ++--- .../psl-parser/src/attribute-spec/types.ts | 1 - .../test/attribute-spec-combinators.test-d.ts | 29 +++++++++++- .../psl-parser/test/entity-reference.test.ts | 46 +++---------------- 4 files changed, 37 insertions(+), 51 deletions(-) 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 aa93ed4b00f5..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 @@ -8,7 +8,7 @@ import type { } 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 { @@ -19,7 +19,7 @@ function unbound(name: string): never { export function entityRef( expected: S, -): EntityRefArgType, AttributeCtx> { +): EntityRefArgType, ModelAttributeCtx> { const label = `${expected.kind === 'block' ? expected.keyword : expected.kind} reference`; return { kind: 'entityRef', @@ -33,13 +33,7 @@ export function entityRef( if (name === undefined) { return notOk([leafDiagnostic(ctx, arg, `Expected ${label}`)]); } - const binder = ctx.binder; - if (binder === undefined) { - throw new InternalError( - `No binder on this attribute context, so "${name}" cannot be resolved: block attributes carry no entity references, and any that are added must be resolved by the binder over block symbols rather than while their arguments parse.`, - ); - } - const resolution = binder.symbolForNode(arg.syntax) ?? unbound(name); + 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)) { 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 e0614b5962df..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 @@ -19,7 +19,6 @@ export type AttributeLevel = 'field' | 'model' | 'block'; export interface AttributeCtx { readonly sources: PslSources; readonly symbols: SymbolTable; - readonly binder?: Binder | undefined; } export interface ModelAttributeCtx extends AttributeCtx { 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 7f7bc446b229..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,14 +62,39 @@ test('checked reference selectors and wrappers preserve inferred outputs', () => expectTypeOf>().toEqualTypeOf< ResolvedEntityReference | string >(); - expectTypeOf(model.parse).parameter(1).toEqualTypeOf(); - expectTypeOf().toEqualTypeOf<'sources' | 'symbols' | 'binder'>(); + expectTypeOf(model.parse).parameter(1).toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<'sources' | 'symbols'>(); // @ts-expect-error checked references require an expected selector entityRef(); // @ts-expect-error checked references do not accept injected resolvers 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/entity-reference.test.ts b/packages/1-framework/2-authoring/psl-parser/test/entity-reference.test.ts index b3bfb2d61b93..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,15 +1,7 @@ import { ok } from '@internal/utils/result'; import { describe, expect, it } from 'vitest'; import type { EntitySelector } from '../src/exports'; -import { - blockAttribute, - createBinder, - entityRef, - identifier, - list, - modelAttribute, - 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'; @@ -43,6 +35,8 @@ 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, @@ -72,7 +66,7 @@ function fixture(value: string, local = true) { if (expression) return { expression, - ctx: { sources, symbols: symbolTable, binder }, + ctx: { sources, symbols: symbolTable, binder, selfModel: owner }, binderDiagnostics, sources, table: symbolTable, @@ -83,34 +77,6 @@ function fixture(value: string, local = true) { } describe('syntax-scoped entity resolution', () => { - it('refuses an entity reference in a block attribute, which parses before any binder', () => { - 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.' }, - ], - }); - expect(() => - buildSymbolTable({ - documents: [document], - sources, - pslBlockDescriptors: { - permission: { - name: { required: true }, - kind: 'pslBlock', - keyword: 'permission', - discriminator: 'permission', - parameters: {}, - attributes: { target: () => target }, - }, - }, - }), - ).toThrow(/block attributes carry no entity references/); - }); it('selects the local declaration, including forward references', () => { const { expression, ctx, namespace } = fixture('Shared'); expect(entityRef({ kind: 'model' }).parse(expression, ctx)).toEqual( @@ -199,7 +165,9 @@ describe('syntax-scoped entity resolution', () => { }, controlMutationDefaults: { defaultFunctionRegistry: new Map(), dataTypeEntries: {} }, }); - const ctx = { sources, symbols: symbolTable, binder }; + 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 }), ); From 64438f00cec18919873565fef66eca60433ba49b Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Thu, 24 Sep 2026 19:34:50 +0000 Subject: [PATCH 60/66] refactor(psl): oneOf tries its alternatives in a plain loop Third pass over these twenty lines, so this is the shape that satisfies all three notes at once: no helper indirection, no undefined accumulator, and no function declarations inside parse. The two closures existed only to avoid writing the same long blindCast twice - one around each alternative parse, one around the matched value. Casting the tuple once to a uniform non-empty tuple of alternatives whose output is already the union removes both needs: head and tail destructure off that, the loop calls parse directly, and a match returns its own result unchanged because it is already typed as the output. That leaves one blindCast site in the file where there were two, and the loop reads as a loop. Signed-off-by: Steven McClankerton --- .../src/attribute-spec/combinators/one-of.ts | 32 +++++++------------ 1 file changed, 11 insertions(+), 21 deletions(-) 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 c73c4c4be1f5..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, or, 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,27 +23,16 @@ export function oneOf( label, alternatives: alts, parse: (arg, ctx): Result, readonly PslDiagnostic[]> => { - const attempt = (alt: Alts[number]): Result => { - 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); - return parse(arg, ctx); - }; - const matched = (value: unknown): Result, readonly PslDiagnostic[]> => - 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.' - >(value), - ); - - const [head, ...tail] = alts; - let rejection = attempt(head); - if (rejection.ok) return matched(rejection.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 = attempt(alt); - if (result.ok) return matched(result.value); + const result = alt.parse(arg, ctx); + if (result.ok) return result; rejection = or(rejection, result); } if (!rejection.ok && rejection.failure.length === 0) return notOk([]); From 765bac29ba505d9019186657bee304b4386f55db Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Thu, 24 Sep 2026 19:37:56 +0000 Subject: [PATCH 61/66] chore(drive): record 10(B) dissolution and DoD amendment Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- projects/symbol-table-resolve/design-decisions.md | 2 +- projects/symbol-table-resolve/slices/binder-core/spec.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/projects/symbol-table-resolve/design-decisions.md b/projects/symbol-table-resolve/design-decisions.md index 69c84e8abf07..89996b878ca7 100644 --- a/projects/symbol-table-resolve/design-decisions.md +++ b/projects/symbol-table-resolve/design-decisions.md @@ -88,7 +88,7 @@ Outcome of the design discussion (2026-09-18) that preceded [`spec.md`](./spec.m **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 with no binder read — retyping it would force genericizing `FuncCallSig` across every call site (Mongo nests `optional(entityRef())` in a `funcCall`); its former read was non-load-bearing (D5 review), and the binder still voices entity failures from phase 2. ~~`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. +**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. diff --git a/projects/symbol-table-resolve/slices/binder-core/spec.md b/projects/symbol-table-resolve/slices/binder-core/spec.md index b8c0b01efb04..5ad0b456fab9 100644 --- a/projects/symbol-table-resolve/slices/binder-core/spec.md +++ b/projects/symbol-table-resolve/slices/binder-core/spec.md @@ -42,7 +42,7 @@ One package, one subject: the binder and the node-identity substrate it requires - [ ] 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. -- [ ] The attribute-ctx builders are exported and covered by tests; contexts carry the required binder (decision 10 — `resolveReferencedModel` no longer exists). +- [ ] 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 From 26d59e2b2a95ce57db4b96b9c5bfd289471ea7c4 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 25 Sep 2026 09:23:08 +0000 Subject: [PATCH 62/66] docs(psl-parser): say why the binder walks entities twice Requested by the operator in review: the two-walk ordering exists because attribute reference arguments read the first walk's type resolutions. Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- packages/1-framework/2-authoring/psl-parser/src/binder.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/1-framework/2-authoring/psl-parser/src/binder.ts b/packages/1-framework/2-authoring/psl-parser/src/binder.ts index 5034c4782d09..a628b8bbfc37 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/binder.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/binder.ts @@ -184,6 +184,12 @@ export function createBinder(options: CreateBinderOptions): BinderResult { } } + // Two walks, not one: this first walk resolves every field's type, and the + // second walk's attribute arguments read those results — `@relation(references: + // [id])` looks `id` up on the fields of the referenced model, so the field's + // type must already be bound to know which model's fields to search. Fused + // into one loop, an attribute on an early model pointing at a later model + // would query a resolution that does not exist yet. walkEntities(symbolTable, stack, (entity) => { declarations.set(entity.node.syntax, entity); for (const field of Object.values(entity.fields)) { From 55b05b826103be2edfaa7e33de996dc2df6838c1 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 25 Sep 2026 09:30:59 +0000 Subject: [PATCH 63/66] docs(psl-parser): tighten the two-walk comment Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- .../1-framework/2-authoring/psl-parser/src/binder.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/1-framework/2-authoring/psl-parser/src/binder.ts b/packages/1-framework/2-authoring/psl-parser/src/binder.ts index a628b8bbfc37..0f5180971ae0 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/binder.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/binder.ts @@ -184,12 +184,9 @@ export function createBinder(options: CreateBinderOptions): BinderResult { } } - // Two walks, not one: this first walk resolves every field's type, and the - // second walk's attribute arguments read those results — `@relation(references: - // [id])` looks `id` up on the fields of the referenced model, so the field's - // type must already be bound to know which model's fields to search. Fused - // into one loop, an attribute on an early model pointing at a later model - // would query a resolution that does not exist yet. + // Field types must be fully bound before attributes parse: + // @relation(references: [x]) resolves x against the referenced model's + // fields, which forward references leave unknown until this walk finishes. walkEntities(symbolTable, stack, (entity) => { declarations.set(entity.node.syntax, entity); for (const field of Object.values(entity.fields)) { From f28a06d74e96f4bea550b554bb9d6f3900c333e9 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 25 Sep 2026 09:50:15 +0000 Subject: [PATCH 64/66] docs(psl-parser): rephrase the two-walk comment Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- packages/1-framework/2-authoring/psl-parser/src/binder.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/1-framework/2-authoring/psl-parser/src/binder.ts b/packages/1-framework/2-authoring/psl-parser/src/binder.ts index 0f5180971ae0..937c396ed049 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/binder.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/binder.ts @@ -184,9 +184,9 @@ export function createBinder(options: CreateBinderOptions): BinderResult { } } - // Field types must be fully bound before attributes parse: - // @relation(references: [x]) resolves x against the referenced model's - // fields, which forward references leave unknown until this walk finishes. + // Types bind in this walk; attributes parse in the next one. The order + // matters: @relation(references: [x]) looks x up on the referenced model. + // With one walk, a forward reference would find nothing to look it up on. walkEntities(symbolTable, stack, (entity) => { declarations.set(entity.node.syntax, entity); for (const field of Object.values(entity.fields)) { From c3ec80c65f5597d34de072fe113b5e659d67cca5 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 25 Sep 2026 09:50:59 +0000 Subject: [PATCH 65/66] docs(psl-parser): plain wording for the two-walk comment Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- packages/1-framework/2-authoring/psl-parser/src/binder.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/1-framework/2-authoring/psl-parser/src/binder.ts b/packages/1-framework/2-authoring/psl-parser/src/binder.ts index 937c396ed049..6d4e18c4b9a8 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/binder.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/binder.ts @@ -184,9 +184,9 @@ export function createBinder(options: CreateBinderOptions): BinderResult { } } - // Types bind in this walk; attributes parse in the next one. The order - // matters: @relation(references: [x]) looks x up on the referenced model. - // With one walk, a forward reference would find nothing to look it up on. + // 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)) { From 178a92a198644c846d03fe02d9fe68e2686a7f1f Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 25 Sep 2026 10:05:00 +0000 Subject: [PATCH 66/66] chore(drive): decree completion-on-binder for the lsp slice Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- projects/symbol-table-resolve/plan.md | 1 + 1 file changed, 1 insertion(+) diff --git a/projects/symbol-table-resolve/plan.md b/projects/symbol-table-resolve/plan.md index 29df1390df65..0d378ee1feb6 100644 --- a/projects/symbol-table-resolve/plan.md +++ b/projects/symbol-table-resolve/plan.md @@ -22,6 +22,7 @@ Four slices: one foundation slice delivering the eager binder in `psl-parser` (w - **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)