From e3c4490fe20c32fd7442be907a2b7a7ee2864f71 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Thu, 17 Sep 2026 20:29:07 +0000 Subject: [PATCH 1/9] feat(psl-parser): resolve checked entity references in lexical scope Signed-off-by: Steven McClankerton --- .../2-authoring/psl-parser/README.md | 8 + .../attribute-spec/combinators/entity-ref.ts | 53 ++++- .../attribute-spec/combinators/identifier.ts | 27 ++- .../psl-parser/src/attribute-spec/types.ts | 27 ++- .../psl-parser/src/entity-reference.ts | 58 +++++ .../psl-parser/src/exports/index.ts | 12 + ...bute-spec-combinators.foreign-copy.test.ts | 10 +- .../test/attribute-spec-combinators.test-d.ts | 38 +++- .../test/attribute-spec-combinators.test.ts | 16 +- .../psl-parser/test/entity-reference.test.ts | 215 ++++++++++++++++++ 10 files changed, 430 insertions(+), 34 deletions(-) create mode 100644 packages/1-framework/2-authoring/psl-parser/src/entity-reference.ts create mode 100644 packages/1-framework/2-authoring/psl-parser/test/entity-reference.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..fd9054d842fa 100644 --- a/packages/1-framework/2-authoring/psl-parser/README.md +++ b/packages/1-framework/2-authoring/psl-parser/README.md @@ -52,6 +52,14 @@ Interpretation/validation (for example `@internal/sql-contract-psl`) is responsi - `@internal/psl-parser/syntax` - `@internal/psl-parser/tokenizer` +## Checked references and identifier rules + +Attribute factories can bind `createEntityResolver({ symbols, owner })` after symbol collection, then use `entityRef(expected, resolve)` to parse a checked reference. Selectors are `{ kind: 'model' }`, `{ kind: 'compositeType' }`, `{ kind: 'namedType' }`, or `{ kind: 'block', keyword }`. The result carries the selected `declaration` and lexical `namespace` (undefined at top-level), not storage coordinates. + +Lookup selects the owner's namespace binding first, then top-level, never a sibling namespace. Kind checking applies to that selected binding; a wrong-kind local declaration does not cause fallback. Repeated references from one resolver retain wrapper identity for collection uniqueness. Resolution adds no symbols to expression parse contexts and emits no diagnostics itself; `entityRef` returns source-anchored failures. + +Use `identifier()` for unchecked names (`string`, with `name: undefined` metadata), or `identifier('Pinned', { documentation: 'Meaning of this value.' })` for a literal output and documented pinned metadata. `oneOf(entityRef(expected, resolve), identifier())` prefers a checked identity, falling back to an unchecked string without leaking failed-alternative diagnostics. Family-specific relationship support and storage lowering remain downstream responsibilities. + ## Architecture ```mermaid 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..a7117a572d06 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,25 +1,54 @@ import { notOk, ok, type Result } from '@internal/utils/result'; import type { PslDiagnostic } from '../../diagnostic'; +import type { + DeclarationFor, + EntityResolver, + EntitySelector, + ResolvedEntityReference, +} from '../../entity-reference'; import { IdentifierAst } from '../../syntax/ast/identifier'; import type { AttributeCtx, EntityRefArgType } from '../types'; import { leafDiagnostic } from './diagnostic'; -// A bare model-name reference. Existence of a model with this name is resolved -// downstream (e.g. `resolvePolymorphism`), not here. -export function entityRef(): EntityRefArgType { +export function entityRef( + expected: S, + resolve: EntityResolver, +): EntityRefArgType, AttributeCtx> { + const label = `${expected.kind === 'block' ? expected.keyword : expected.kind} reference`; return { kind: 'entityRef', - label: 'model name', - parse: (arg, ctx): Result => { - const identifier = IdentifierAst.cast(arg.syntax); - if (identifier === undefined) { - return notOk([leafDiagnostic(ctx, arg, 'Expected a model name')]); - } - const name = identifier.name(); + label, + expected, + parse: ( + arg, + ctx, + ): Result>, readonly PslDiagnostic[]> => { + const name = IdentifierAst.cast(arg.syntax)?.name(); if (name === undefined) { - return notOk([leafDiagnostic(ctx, arg, 'Expected a model name')]); + return notOk([leafDiagnostic(ctx, arg, `Expected ${label}`)]); + } + const reference = resolve(name); + if (reference === undefined) { + return notOk([leafDiagnostic(ctx, arg, `Unknown ${label} "${name}"`)]); + } + if (!matchesSelector(reference, expected)) { + const actual = reference.declaration; + const kind = actual.kind === 'block' ? actual.keyword : actual.kind; + return notOk([leafDiagnostic(ctx, arg, `Expected ${label} "${name}", found ${kind}`)]); } - return ok(name); + return ok(reference); }, }; } + +function matchesSelector( + reference: ResolvedEntityReference, + expected: S, +): reference is ResolvedEntityReference> { + const declaration = reference.declaration; + return ( + declaration.kind === expected.kind && + (expected.kind !== 'block' || + (declaration.kind === 'block' && declaration.keyword === expected.keyword)) + ); +} diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/identifier.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/identifier.ts index 2cce7fef7e19..db523fa6ff67 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/identifier.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/identifier.ts @@ -1,22 +1,33 @@ import { notOk, ok, type Result } from '@internal/utils/result'; import type { PslDiagnostic } from '../../diagnostic'; import { IdentifierAst } from '../../syntax/ast/identifier'; -import type { AttributeCtx, IdentifierArgType } from '../types'; +import type { + AttributeCtx, + FixedIdentifierArgType, + IdentifierArgType, + UnrestrictedIdentifierArgType, +} from '../types'; import { leafDiagnostic } from './diagnostic'; +export function identifier(): UnrestrictedIdentifierArgType; export function identifier( name: N, options: { readonly documentation: string }, -): IdentifierArgType { +): FixedIdentifierArgType; +export function identifier( + name?: string, + options?: { readonly documentation: string }, +): IdentifierArgType { + const label = name ?? 'identifier'; return { kind: 'identifier', - label: name, + label, name, - documentation: options.documentation, - parse: (arg, ctx): Result => { - const identifier = IdentifierAst.cast(arg.syntax); - if (identifier !== undefined && identifier.name() === name) return ok(name); - return notOk([leafDiagnostic(ctx, arg, `Expected ${name}`)]); + documentation: options?.documentation ?? '', + parse: (arg, ctx): Result => { + const value = IdentifierAst.cast(arg.syntax)?.name(); + if (value !== undefined && (name === undefined || value === name)) return ok(value); + return notOk([leafDiagnostic(ctx, arg, `Expected ${label}`)]); }, }; } 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..0de475a30fb3 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 @@ -3,6 +3,11 @@ 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 { PslDiagnostic } from '../diagnostic'; +import type { + EntityDeclaration, + EntitySelector, + ResolvedEntityReference, +} from '../entity-reference'; import type { PslSources } from '../source-file'; import type { FieldSymbol, ModelSymbol } from '../symbol-table'; import type { ExpressionAst } from '../syntax/ast/expressions'; @@ -53,9 +58,12 @@ export interface BoolArgType readonly kind: 'bool'; } -export interface EntityRefArgType - extends ArgTypeOutput { +export interface EntityRefArgType< + D extends EntityDeclaration = EntityDeclaration, + Ctx extends AttributeCtx = AttributeCtx, +> extends ArgTypeOutput, Ctx> { readonly kind: 'entityRef'; + readonly expected: EntitySelector; } export interface FieldRefArgType @@ -90,7 +98,7 @@ export interface FuncCallArgType< readonly signature: Signature; } -export interface IdentifierArgType< +export interface FixedIdentifierArgType< Name extends string = string, Ctx extends AttributeCtx = AttributeCtx, > extends ArgTypeOutput { @@ -99,6 +107,17 @@ export interface IdentifierArgType< readonly documentation: string; } +export interface UnrestrictedIdentifierArgType + extends ArgTypeOutput { + readonly kind: 'identifier'; + readonly name: undefined; +} + +export type IdentifierArgType< + Name extends string = string, + Ctx extends AttributeCtx = AttributeCtx, +> = FixedIdentifierArgType | UnrestrictedIdentifierArgType; + export interface IntArgType extends ArgTypeOutput { readonly kind: 'int'; @@ -227,7 +246,7 @@ export type ContextForRequirement = Req extends 'fie export type InspectableArgType = | BoolArgType - | EntityRefArgType + | EntityRefArgType | FieldRefArgType | FuncCallArgType | IdentifierArgType 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 new file mode 100644 index 000000000000..48c769837033 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/entity-reference.ts @@ -0,0 +1,58 @@ +import type { + BlockSymbol, + CompositeTypeSymbol, + ModelSymbol, + NamedTypeSymbol, + NamespaceSymbol, + SymbolTable, + TopLevelScope, +} from './symbol-table'; + +export type EntitySelector = + | { readonly kind: 'model' } + | { readonly kind: 'compositeType' } + | { readonly kind: 'namedType' } + | { readonly kind: 'block'; readonly keyword: string }; + +export type EntityDeclaration = ModelSymbol | CompositeTypeSymbol | NamedTypeSymbol | BlockSymbol; + +export type DeclarationFor = Extract< + EntityDeclaration, + { kind: S['kind'] } +>; + +export interface ResolvedEntityReference { + readonly declaration: D; + readonly namespace: NamespaceSymbol | undefined; +} + +export type EntityResolver = (name: string) => ResolvedEntityReference | undefined; + +export function createEntityResolver(input: { + readonly symbols: SymbolTable; + readonly owner: ModelSymbol | CompositeTypeSymbol | BlockSymbol; +}): EntityResolver { + const { symbols, owner } = input; + const namespace = Object.values(symbols.topLevel.namespaces).find((scope) => + declarationsIn(scope).some((declaration) => declaration === owner), + ); + const references = new Map(); + for (const declaration of declarationsIn(symbols.topLevel)) { + references.set(declaration.name, { declaration, namespace: undefined }); + } + if (namespace !== undefined) { + for (const declaration of declarationsIn(namespace)) { + references.set(declaration.name, { declaration, namespace }); + } + } + return (name) => references.get(name); +} + +function declarationsIn(scope: TopLevelScope | NamespaceSymbol): EntityDeclaration[] { + return [ + ...Object.values(scope.models), + ...Object.values(scope.compositeTypes), + ...Object.values(scope.blocks), + ...('namedTypes' in scope ? Object.values(scope.namedTypes) : []), + ]; +} 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..2440f87ee017 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 @@ -74,8 +74,11 @@ export type { AttributeLevel, AttributeOut, AttributeSpec, + EntityRefArgType, FieldAttributeCtx, + FixedIdentifierArgType, FuncCallSig, + IdentifierArgType, InferAttr, InspectableArgType, ModelAttributeCtx, @@ -90,6 +93,7 @@ export type { RejectingArgType, TaggedLiteralArgType, TypedFuncCall, + UnrestrictedIdentifierArgType, } from '../attribute-spec/types'; export type { DiagnosticSource, PslDiagnostic, PslDiagnosticCollector } from '../diagnostic'; export { @@ -98,6 +102,14 @@ export { diagnosticSource, mapPslDiagnostics, } from '../diagnostic'; +export type { + DeclarationFor, + EntityDeclaration, + EntityResolver, + EntitySelector, + ResolvedEntityReference, +} from '../entity-reference'; +export { createEntityResolver } 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.foreign-copy.test.ts b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.foreign-copy.test.ts index 781a69ca5cba..78423add9ece 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 @@ -65,7 +65,7 @@ describe('combinators dispatch on syntax kind, not on AST class identity', () => 'Cascade', 'Cascade', ], - ['entityRef', entityRef(), 'User', 'User'], + ['unrestricted identifier', identifier(), 'User', 'User'], ['fieldRef', fieldRef(), 'id', 'id'], ['json', json(), '"{\\"a\\":1}"', { a: 1 }], ['list', list(str()), '["a", "b"]', ['a', 'b']], @@ -79,6 +79,14 @@ describe('combinators dispatch on syntax kind, not on AST class identity', () => if (result.ok) expect(result.value).toEqual(expected); }); + it('entityRef accepts a node from another module copy and preserves identity', () => { + const { arg, ctx } = foreignArg('M'); + const reference = { declaration: ctx.selfModel, namespace: undefined }; + const result = entityRef({ kind: 'model' }, () => reference).parse(arg, ctx); + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toBe(reference); + }); + 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.test-d.ts b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.test-d.ts index 15f2fefea7f2..7708839b50bb 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 @@ -3,10 +3,16 @@ import { expectTypeOf, test } from 'vitest'; import type { ArgType, AttributeCtx, + BlockSymbol, + CompositeTypeSymbol, + EntityResolver, FieldAttributeCtx, InspectableArgType, ModelAttributeCtx, + ModelSymbol, + NamedTypeSymbol, OutOf, + ResolvedEntityReference, TypedFuncCall, } from '../src/exports'; import { @@ -35,6 +41,36 @@ test('inspectable lists and records expose ArgType children', () => { expectTypeOf().toEqualTypeOf>(); }); +test('checked reference selectors and wrappers preserve inferred outputs', () => { + const resolve: EntityResolver = () => undefined; + const model = entityRef({ kind: 'model' }, resolve); + const composite = entityRef({ kind: 'compositeType' }, resolve); + const named = entityRef({ kind: 'namedType' }, resolve); + const block = entityRef({ kind: 'block', keyword: 'permission' }, resolve); + const names = identifier(); + const optionalModel = optional(model); + const models = list(model); + const alternative = oneOf(model, names); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf< + ResolvedEntityReference + >(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf(names.name).toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf[]>(); + expectTypeOf>().toEqualTypeOf< + ResolvedEntityReference | string + >(); + expectTypeOf(model.parse).parameter(1).toEqualTypeOf(); + // @ts-expect-error checked references require an expected selector and resolver + entityRef(); + // @ts-expect-error checked references require a resolver + entityRef({ kind: 'model' }); +}); + test('identifier requires semantic value documentation', () => { // @ts-expect-error identifier values require documentation identifier('Undocumented'); @@ -415,7 +451,7 @@ test('optional wrappers retain child metadata and optional markers', () => { test('field references have distinct inspectable kinds', () => { expectTypeOf(fieldRef().kind).toEqualTypeOf<'fieldRef'>(); expectTypeOf(referencedFieldRef().kind).toEqualTypeOf<'referencedFieldRef'>(); - expectTypeOf(entityRef().kind).toEqualTypeOf<'entityRef'>(); + expectTypeOf(entityRef({ kind: 'model' }, () => undefined).kind).toEqualTypeOf<'entityRef'>(); }); test('runtime context metadata is rejected from arg types', () => { 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..46d058937417 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 @@ -683,19 +683,19 @@ describe('fieldRef', () => { }); describe('entityRef', () => { - it('parses a bare identifier into its model name', () => { - const { expr, ctx } = argOf('Task'); - - const result = entityRef().parse(expr, ctx); + it('parses a bare identifier into its resolved model', () => { + const { expr, ctx } = argOf('M'); + const reference = { declaration: ctx.selfModel, namespace: undefined }; + const result = entityRef({ kind: 'model' }, () => reference).parse(expr, ctx); expect(result.ok).toBe(true); - if (result.ok) expect(result.value).toBe('Task'); + if (result.ok) expect(result.value).toBe(reference); }); it('rejects a quoted string literal', () => { const { expr, ctx } = argOf('"Task"'); - const result = entityRef().parse(expr, ctx); + const result = entityRef({ kind: 'model' }, () => undefined).parse(expr, ctx); expect(result.ok).toBe(false); if (!result.ok) { @@ -707,7 +707,7 @@ describe('entityRef', () => { it('rejects a number token', () => { const { expr, ctx } = argOf('42'); - const result = entityRef().parse(expr, ctx); + const result = entityRef({ kind: 'model' }, () => undefined).parse(expr, ctx); expect(result.ok).toBe(false); if (!result.ok) expect(result.failure).toHaveLength(1); @@ -716,7 +716,7 @@ describe('entityRef', () => { it('rejects an array literal', () => { const { expr, ctx } = argOf('[Task]'); - const result = entityRef().parse(expr, ctx); + const result = entityRef({ kind: 'model' }, () => undefined).parse(expr, ctx); expect(result.ok).toBe(false); if (!result.ok) expect(result.failure).toHaveLength(1); 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 new file mode 100644 index 000000000000..27ae80aa1557 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/entity-reference.test.ts @@ -0,0 +1,215 @@ +import { ok } from '@internal/utils/result'; +import { describe, expect, it } from 'vitest'; +import type { EntitySelector } from '../src/exports'; +import { createEntityResolver, entityRef, identifier, list, oneOf } from '../src/exports'; +import { Cursor, parse, parseAttribute } from '../src/parse'; +import { buildSymbolTable } from '../src/symbol-table'; +import { FieldAttributeAst } from '../src/syntax/ast/attributes'; +import { createSyntaxTree } from '../src/syntax/red'; + +function symbols(source: string) { + const { document, sourceFile } = parse(source); + const result = buildSymbolTable({ document, sourceFile, pslBlockDescriptors: {} }); + expect(result.diagnostics).toEqual([]); + return result.table; +} + +function argument(source: string) { + const cursor = new Cursor(`@test(${source})`); + const attribute = FieldAttributeAst.cast(createSyntaxTree(parseAttribute(cursor))); + const expression = [...(attribute?.argList()?.args() ?? [])][0]?.value(); + if (!expression) throw new Error('Missing expression'); + return { expression, ctx: { sourceId: 'references.prisma', sourceFile: cursor.sourceFile } }; +} + +const declarations = [ + 'model Owner {}', + 'model Shared {}', + 'model Global {}', + 'model Fallback {}', + 'type Address {}', + 'types { Email = String }', + 'permission Reader {}', + 'namespace Local {\n model Owner {}\n model Shared {}\n type Global {}\n permission Writer {}\n}', + 'namespace Sibling {\n model Hidden {}\n model Shared {}\n}', +]; + +function fixture(reverse = false) { + const table = symbols((reverse ? [...declarations].reverse() : declarations).join('\n')); + const namespace = table.topLevel.namespaces['Local']; + const owner = namespace?.models['Owner']; + const topOwner = table.topLevel.models['Owner']; + if (!namespace || !owner || !topOwner) throw new Error('Missing owner'); + return { + table, + namespace, + resolve: createEntityResolver({ symbols: table, owner }), + topResolve: createEntityResolver({ symbols: table, owner: topOwner }), + }; +} + +describe('factory-bound entity resolution', () => { + it.each([false, true])( + 'selects lexical identities independently of declaration order (%s)', + (reverse) => { + const { table, namespace, resolve, topResolve } = fixture(reverse); + expect(resolve('Shared')).toEqual({ declaration: namespace.models['Shared'], namespace }); + expect(resolve('Shared')?.declaration).toBe(namespace.models['Shared']); + expect(resolve('Address')).toEqual({ + declaration: table.topLevel.compositeTypes['Address'], + namespace: undefined, + }); + expect(resolve('Email')?.declaration).toBe(table.topLevel.namedTypes['Email']); + expect(resolve('Fallback')?.declaration).toBe(table.topLevel.models['Fallback']); + expect(resolve('Fallback')?.namespace).toBeUndefined(); + expect(resolve('Hidden')).toBeUndefined(); + expect(topResolve('Shared')?.declaration).toBe(table.topLevel.models['Shared']); + expect(topResolve('Writer')).toBeUndefined(); + expect(resolve('Missing')).toBeUndefined(); + expect(resolve('toString')).toBeUndefined(); + expect(resolve('Shared')).toBe(resolve('Shared')); + }, + ); + + it('binds composite and block owners by identity rather than matching top-level names', () => { + const { table, namespace } = fixture(); + const composite = namespace.compositeTypes['Global']; + const block = namespace.blocks['Writer']; + if (!composite || !block) throw new Error('Missing owner'); + for (const owner of [composite, block]) { + const resolve = createEntityResolver({ symbols: table, owner }); + expect(resolve('Shared')?.declaration).toBe(namespace.models['Shared']); + } + }); + + it('rejects the selected wrong-kind shadow instead of falling back', () => { + const { resolve, namespace } = fixture(); + const { expression, ctx } = argument('Global'); + expect(resolve('Global')?.declaration).toBe(namespace.compositeTypes['Global']); + expect(entityRef({ kind: 'model' }, resolve).parse(expression, ctx)).toMatchObject({ + ok: false, + failure: [ + { + message: 'Expected model reference "Global", found compositeType', + sourceId: 'references.prisma', + span: { start: { offset: 6 }, end: { offset: 12 } }, + }, + ], + }); + }); +}); + +describe('checked references and unchecked identifiers', () => { + it.each<[EntitySelector, string]>([ + [{ kind: 'model' }, 'Shared'], + [{ kind: 'compositeType' }, 'Address'], + [{ kind: 'namedType' }, 'Email'], + [{ kind: 'block', keyword: 'permission' }, 'Reader'], + ])('returns the selected identity for %j', (selector, name) => { + const { topResolve } = fixture(); + const { expression, ctx } = argument(name); + const rule = entityRef(selector, topResolve); + expect(rule.expected).toEqual(selector); + const result = rule.parse(expression, ctx); + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toBe(topResolve(name)); + }); + + it.each([ + { kind: 'model' }, + { kind: 'compositeType' }, + { kind: 'namedType' }, + { kind: 'block', keyword: 'permission' }, + ])('rejects a different declaration kind for %j', (selector) => { + const { topResolve } = fixture(); + const name = selector.kind === 'model' ? 'Address' : 'Shared'; + const { expression, ctx } = argument(name); + const result = entityRef(selector, topResolve).parse(expression, ctx); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.failure).toHaveLength(1); + }); + + it('checks contributed block keywords', () => { + const { topResolve } = fixture(); + const { expression, ctx } = argument('Reader'); + expect( + entityRef({ kind: 'block', keyword: 'other' }, topResolve).parse(expression, ctx), + ).toMatchObject({ + ok: false, + failure: [{ message: 'Expected other reference "Reader", found permission' }], + }); + }); + + it('anchors missing references at the expression', () => { + const { resolve } = fixture(); + const { expression, ctx } = argument('Missing'); + expect(entityRef({ kind: 'model' }, resolve).parse(expression, ctx)).toMatchObject({ + ok: false, + failure: [ + { + code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', + message: 'Unknown model reference "Missing"', + sourceId: 'references.prisma', + span: { start: { offset: 6 }, end: { offset: 13 } }, + }, + ], + }); + }); + + it('preserves wrapper identity so repeated list entries fail uniqueness', () => { + const { resolve } = fixture(); + const rule = list(entityRef({ kind: 'model' }, resolve), { unique: true }); + const { expression, ctx } = argument('[Shared, Shared]'); + expect(rule.parse(expression, ctx)).toMatchObject({ + ok: false, + failure: [{ message: 'Duplicate list entry' }], + }); + const distinct = argument('[Shared, Owner]'); + expect(rule.parse(distinct.expression, distinct.ctx)).toEqual( + ok([resolve('Shared'), resolve('Owner')]), + ); + }); + + it.each(['Shared', 'Missing', 'Global'])( + 'keeps reference-first alternatives diagnostic-pure for %s', + (name) => { + const { resolve } = fixture(); + const rule = oneOf(entityRef({ kind: 'model' }, resolve), identifier()); + const { expression, ctx } = argument(name); + expect(rule.parse(expression, ctx)).toEqual(ok(name === 'Shared' ? resolve(name) : name)); + }, + ); + + it('aggregates all-failure alternatives at their source', () => { + const { resolve } = fixture(); + const { expression, ctx } = argument('42'); + expect( + oneOf(entityRef({ kind: 'model' }, resolve), identifier()).parse(expression, ctx), + ).toMatchObject({ + ok: false, + failure: [ + { message: 'Expected one of: model reference | identifier', sourceId: 'references.prisma' }, + ], + }); + }); + + it('exposes unrestricted metadata and preserves exact names', () => { + const { expression, ctx } = argument('External'); + expect(identifier()).toMatchObject({ + kind: 'identifier', + name: undefined, + label: 'identifier', + }); + expect(identifier().parse(expression, ctx)).toEqual(ok('External')); + }); + + it.each(['"Shared"', '42', '[Shared]', 'Shared()', 'true'])( + 'rejects non-identifier syntax %s', + (source) => { + const { resolve } = fixture(); + const { expression, ctx } = argument(source); + expect(entityRef({ kind: 'model' }, resolve).parse(expression, ctx).ok).toBe(false); + expect(identifier().parse(expression, ctx).ok).toBe(false); + }, + ); +}); From 1466e2a0214e4b8f5161702988e907a2ffad7d5c Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Thu, 17 Sep 2026 20:46:38 +0000 Subject: [PATCH 2/9] fix(sql-contract-psl): preserve resolved inheritance identity Signed-off-by: Steven McClankerton --- .../2-sql/2-authoring/contract-psl/README.md | 1 + .../contract-psl/src/interpreter.ts | 188 ++++++++++-------- .../contract-psl/src/sql-attribute-specs.ts | 32 +-- .../test/interpreter.polymorphism.test.ts | 118 ++++++++++- .../test/sql-attribute-specs.test.ts | 32 +++ 5 files changed, 279 insertions(+), 92 deletions(-) diff --git a/packages/2-sql/2-authoring/contract-psl/README.md b/packages/2-sql/2-authoring/contract-psl/README.md index 2e991a189574..3e53003dda10 100644 --- a/packages/2-sql/2-authoring/contract-psl/README.md +++ b/packages/2-sql/2-authoring/contract-psl/README.md @@ -28,6 +28,7 @@ This keeps core/CLI source-agnostic while giving PSL-first SQL users a one-line - Preserve parser diagnostics and add interpreter diagnostics with stable codes - Return `notOk` with structured diagnostics for unsupported constructs - Keep interpretation deterministic for equivalent AST inputs +- Bind `@@base` through the parser's checked model-reference factory: local namespace first, then top-level, never sibling namespaces. Preserve the selected declaration through discriminator association and STI/MTI storage lowering. Missing or wrong-kind references produce source-anchored shared expression diagnostics; discriminator and inheritance semantics remain separate checks. Reference lookup does not extend the contract's supported inheritance relationships. Determinism note: - Relation metadata emission is intentionally **sorted by storage table name, then model name, then relation field name** (not PSL declaration order) so `contract.json` snapshots and hashes are stable across environments. 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 3670cd0ac3e7..f106b9fc3e75 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts @@ -37,6 +37,7 @@ import type { TargetPackRef, } from '@internal/framework-components/components'; import type { + ControlDefaultRegistries, ControlMutationDefaultRegistry, ControlMutationDefaults, MutationDefaultGeneratorDescriptor, @@ -1686,15 +1687,23 @@ type DiscriminatorDeclaration = { readonly span: ContractSourceDiagnosticSpan; }; +type ModelIdentity = { + readonly model: ModelSymbol; + readonly namespaceId: string; + readonly key: string; +}; + type BaseDeclaration = { readonly source: DiagnosticSource; - readonly baseName: string; + readonly base: ModelIdentity; readonly value: string; readonly span: ContractSourceDiagnosticSpan; }; function collectPolymorphismDeclarations( - models: readonly ModelSymbol[], + identities: ReadonlyMap, + symbols: SymbolTable, + controlMutationDefaults: ControlDefaultRegistries, sources: PslSources, diagnostics: PslDiagnosticCollector, ): { @@ -1704,7 +1713,7 @@ function collectPolymorphismDeclarations( const discriminatorDeclarations = new Map(); const baseDeclarations = new Map(); - for (const model of models) { + for (const { model, key } of identities.values()) { const source = diagnosticSource(sources, model.node.syntax); const discriminatorNode = findModelAttributeNode(model, 'discriminator'); if (discriminatorNode !== undefined) { @@ -1725,7 +1734,7 @@ function collectPolymorphismDeclarations( ...source.at(span), }); } else { - discriminatorDeclarations.set(model.name, { fieldName: parsed.field, span, source }); + discriminatorDeclarations.set(key, { fieldName: parsed.field, span, source }); } } } @@ -1734,15 +1743,20 @@ function collectPolymorphismDeclarations( if (baseNode !== undefined) { const parsed = interpretModelAttribute({ node: baseNode, - spec: sqlAttributeSpecs.model.base(), + spec: sqlAttributeSpecs.model.base({ symbols, model, controlMutationDefaults }), model, sources, diagnostics, }); if (parsed !== undefined) { - baseDeclarations.set(model.name, { + const base = identities.get(parsed.base.declaration); + invariant( + base !== undefined, + 'A checked base declaration has a collected SQL model identity', + ); + baseDeclarations.set(key, { source, - baseName: parsed.base, + base, value: parsed.value, span: nodePslSpan(baseNode.syntax, sources), }); @@ -1757,25 +1771,18 @@ function resolvePolymorphism( models: Record, discriminatorDeclarations: Map, baseDeclarations: Map, - modelNames: Set, modelMappings: ReadonlyMap, - modelNamespaceIds: ReadonlyMap, - defaultNamespaceId: string, syntheticPkFieldsByVariant: ReadonlyMap, stiBaseFieldsByBase: ReadonlyMap, diagnostics: PslDiagnosticCollector, ): Record { let patched = models; - const coordinateFor = (modelName: string): string => - modelCoordinateKey(modelNamespaceIds.get(modelName) ?? defaultNamespaceId, modelName); - // STI variant columns were materialised onto the base storage table so the // variants' `storage.fields` resolve. They are storage-only on the base — the // domain field belongs to the variant — so strip them from the base model's // domain + storage field maps (the table column, built upstream, stays). - for (const [baseName, fieldNames] of stiBaseFieldsByBase) { - const baseKey = coordinateFor(baseName); + for (const [baseKey, fieldNames] of stiBaseFieldsByBase) { const baseModel = patched[baseKey]; if (!baseModel || fieldNames.length === 0) continue; patched = { @@ -1784,8 +1791,9 @@ function resolvePolymorphism( }; } - for (const [modelName, decl] of discriminatorDeclarations) { - if (baseDeclarations.has(modelName)) { + for (const [modelKey, decl] of discriminatorDeclarations) { + const modelName = modelMappings.get(modelKey)?.model.name; + if (baseDeclarations.has(modelKey)) { diagnostics.push({ code: 'PSL_DISCRIMINATOR_AND_BASE', message: `Model "${modelName}" cannot have both @@discriminator and @@base`, @@ -1794,14 +1802,16 @@ function resolvePolymorphism( continue; } - const model = patched[coordinateFor(modelName)]; + const model = patched[modelKey]; if (!model) continue; const variants: Record = {}; const seenValues = new Map(); - for (const [variantName, baseDecl] of baseDeclarations) { - if (baseDecl.baseName !== modelName) continue; + for (const [variantKey, baseDecl] of baseDeclarations) { + if (baseDecl.base.key !== modelKey) continue; + const variantName = modelMappings.get(variantKey)?.model.name; + invariant(variantName !== undefined, 'A collected variant has a model mapping'); const existingVariant = seenValues.get(baseDecl.value); if (existingVariant) { @@ -1827,56 +1837,46 @@ function resolvePolymorphism( patched = { ...patched, - [coordinateFor(modelName)]: { ...model, discriminator: { field: decl.fieldName }, variants }, + [modelKey]: { ...model, discriminator: { field: decl.fieldName }, variants }, }; } - 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 (!discriminatorDeclarations.has(baseDecl.baseName)) { + for (const [variantKey, baseDecl] of baseDeclarations) { + const variantMapping = modelMappings.get(variantKey); + const variantName = variantMapping?.model.name; + const baseName = baseDecl.base.model.name; + if (!discriminatorDeclarations.has(baseDecl.base.key)) { diagnostics.push({ code: 'PSL_ORPHANED_BASE', - message: `Model "${variantName}" declares @@base(${baseDecl.baseName}, ...) but "${baseDecl.baseName}" has no @@discriminator`, + message: `Model "${variantName}" declares @@base(${baseName}, ...) but "${baseName}" has no @@discriminator`, ...baseDecl.source.at(baseDecl.span), }); continue; } - if (discriminatorDeclarations.has(variantName)) { + if (discriminatorDeclarations.has(variantKey)) { continue; } - const variantModel = patched[coordinateFor(variantName)]; + const variantModel = patched[variantKey]; if (!variantModel) continue; - const baseMapping = modelMappings.get(baseDecl.baseName); - const variantMapping = modelMappings.get(variantName); + const baseMapping = modelMappings.get(baseDecl.base.key); const hasExplicitMap = variantMapping?.model.attributes.some((attr) => attr.name === 'map') ?? false; const resolvedTable = hasExplicitMap ? variantMapping?.tableName : baseMapping?.tableName; const patchedVariant: ContractModel = { ...variantModel, - base: crossRef( - baseDecl.baseName, - modelNamespaceIds.get(baseDecl.baseName) ?? defaultNamespaceId, - ), + base: crossRef(baseName, baseDecl.base.namespaceId), ...(resolvedTable ? { storage: { ...variantModel.storage, table: resolvedTable } } : {}), }; patched = { ...patched, - [coordinateFor(variantName)]: stripStorageOnlyDomainFields( + [variantKey]: stripStorageOnlyDomainFields( patchedVariant, - syntheticPkFieldsByVariant.get(variantName) ?? [], + syntheticPkFieldsByVariant.get(variantKey) ?? [], ), }; } @@ -1901,20 +1901,24 @@ function resolvePolymorphism( function materializeMtiVariantStorageLinks( modelNodes: readonly ModelNode[], baseDeclarations: ReadonlyMap, - stiVariantNames: ReadonlySet, + stiVariantKeys: ReadonlySet, + defaultNamespaceId: string, ): { modelNodes: ModelNode[]; syntheticPkFieldsByVariant: Map } { - const nodeByModel = new Map(modelNodes.map((node) => [node.modelName, node])); + const keyOf = (node: ModelNode) => + modelCoordinateKey(node.namespaceId ?? defaultNamespaceId, node.modelName); + const nodeByModel = new Map(modelNodes.map((node) => [keyOf(node), node])); const syntheticPkFieldsByVariant = new Map(); const enriched = modelNodes.map((node): ModelNode => { - const baseDecl = baseDeclarations.get(node.modelName); + const variantKey = keyOf(node); + const baseDecl = baseDeclarations.get(variantKey); if (!baseDecl) return node; - const baseNode = nodeByModel.get(baseDecl.baseName); + const baseNode = nodeByModel.get(baseDecl.base.key); if (!baseNode) return node; // Single-table inheritance (no own `@@map`) shares the base table; it gets // its columns materialised onto the base instead (see // {@link materializeStiVariantStorageColumns}), never a link column. - if (stiVariantNames.has(node.modelName)) return node; + if (stiVariantKeys.has(variantKey)) return node; const basePrimaryKey = baseNode.id; if (!basePrimaryKey || basePrimaryKey.columns.length === 0) return node; @@ -1936,7 +1940,7 @@ function materializeMtiVariantStorageLinks( if (linkFields.length === 0) return node; syntheticPkFieldsByVariant.set( - node.modelName, + variantKey, linkFields.map((field) => field.fieldName), ); @@ -1990,25 +1994,28 @@ function materializeMtiVariantStorageLinks( function materializeStiVariantStorageColumns( modelNodes: readonly ModelNode[], baseDeclarations: ReadonlyMap, - stiVariantNames: ReadonlySet, + stiVariantKeys: ReadonlySet, + defaultNamespaceId: string, ): { modelNodes: ModelNode[]; stiBaseFieldsByBase: Map } { - if (stiVariantNames.size === 0) { + if (stiVariantKeys.size === 0) { return { modelNodes: [...modelNodes], stiBaseFieldsByBase: new Map() }; } - const nodeByModel = new Map(modelNodes.map((node) => [node.modelName, node])); + const keyOf = (node: ModelNode) => + modelCoordinateKey(node.namespaceId ?? defaultNamespaceId, node.modelName); + const nodeByModel = new Map(modelNodes.map((node) => [keyOf(node), node])); type StiColumn = ModelNode['fields'][number]; const stiColumnsByBase = new Map(); - for (const variantName of stiVariantNames) { - const variantNode = nodeByModel.get(variantName); - const baseDecl = baseDeclarations.get(variantName); + for (const variantKey of stiVariantKeys) { + const variantNode = nodeByModel.get(variantKey); + const baseDecl = baseDeclarations.get(variantKey); if (!variantNode || !baseDecl) continue; - const baseNode = nodeByModel.get(baseDecl.baseName); + const baseNode = nodeByModel.get(baseDecl.base.key); if (!baseNode) continue; const baseColumns = new Set(baseNode.fields.map((field) => field.columnName)); - const claimed = stiColumnsByBase.get(baseDecl.baseName) ?? []; + const claimed = stiColumnsByBase.get(baseDecl.base.key) ?? []; const claimedColumns = new Set(claimed.map((field) => field.columnName)); for (const field of variantNode.fields) { @@ -2018,7 +2025,7 @@ function materializeStiVariantStorageColumns( claimedColumns.add(field.columnName); claimed.push({ ...field, nullable: true }); } - stiColumnsByBase.set(baseDecl.baseName, claimed); + stiColumnsByBase.set(baseDecl.base.key, claimed); } // The materialised columns exist on the base STORAGE table so the variants' @@ -2036,10 +2043,10 @@ function materializeStiVariantStorageColumns( const enriched = modelNodes.map((node): ModelNode => { // STI variant: contributes a domain model but no storage table of its own. - if (stiVariantNames.has(node.modelName)) { + if (stiVariantKeys.has(keyOf(node))) { return { ...node, sharesBaseTable: true }; } - const stiColumns = stiColumnsByBase.get(node.modelName); + const stiColumns = stiColumnsByBase.get(keyOf(node)); if (!stiColumns || stiColumns.length === 0) return node; return { ...node, fields: [...node.fields, ...stiColumns] }; }); @@ -2440,7 +2447,7 @@ export function interpretPslDocumentToSqlContract( diagnostics, input.sources, ); - // Bare-name view for unqualified relation targets and polymorphism, where + // Bare-name view for unqualified relation targets, where // resolution is by bare model name. When a bare name is shared across // namespaces this collapses to the last entry; qualified relation targets // and per-model lowering use the coordinate-keyed map above instead. @@ -2561,8 +2568,23 @@ export function interpretPslDocumentToSqlContract( } } + const modelIdentities = new Map( + modelEntries.map(({ model, namespaceId }) => { + const resolvedNamespaceId = namespaceId ?? defaultNamespaceId; + return [ + model, + { + model, + namespaceId: resolvedNamespaceId, + key: modelCoordinateKey(resolvedNamespaceId, model.name), + }, + ]; + }), + ); const { discriminatorDeclarations, baseDeclarations } = collectPolymorphismDeclarations( - models, + modelIdentities, + input.symbolTable, + { defaultFunctionRegistry, defaultLiteralTagRegistry }, input.sources, diagnostics, ); @@ -2573,13 +2595,13 @@ export function interpretPslDocumentToSqlContract( // because a no-`@@map` STI variant still gets its own verbatim default table // name (`defaultTableName`) that differs from the base before // `resolvePolymorphism` rewrites it onto the base table. - const stiVariantNames = new Set(); - for (const variantName of baseDeclarations.keys()) { - const variantMapping = modelMappings.get(variantName); + const stiVariantKeys = new Set(); + for (const variantKey of baseDeclarations.keys()) { + const variantMapping = modelMappingsByCoordinate.get(variantKey); const hasExplicitMap = variantMapping?.model.attributes.some((attr) => attr.name === 'map') ?? false; if (!hasExplicitMap) { - stiVariantNames.add(variantName); + stiVariantKeys.add(variantKey); } } @@ -2589,19 +2611,20 @@ export function interpretPslDocumentToSqlContract( // dropping it at build time would defeat the whole point of `@@check`. // Catch it here, while the PSL source still has the `@@check` attribute's // span and the base model's name in hand. - for (const variantName of stiVariantNames) { - const variantMapping = modelMappings.get(variantName); + for (const variantKey of stiVariantKeys) { + const variantMapping = modelMappingsByCoordinate.get(variantKey); if (variantMapping === undefined) continue; - const baseDecl = baseDeclarations.get(variantName); + const variantName = variantMapping.model.name; + const baseDecl = baseDeclarations.get(variantKey); invariant( baseDecl !== undefined, - `stiVariantNames is derived from baseDeclarations.keys(), so "${variantName}" must have a base declaration`, + `stiVariantKeys is derived from baseDeclarations.keys(), so "${variantName}" must have a base declaration`, ); for (const attribute of variantMapping.model.node.attributes()) { if (attribute.name()?.isSimpleName('check') !== true) continue; diagnostics.push({ code: PSL_CHECK_ON_STI_VARIANT, - message: `Model "${variantName}" declares "@@check", but it shares its base model "${baseDecl.baseName}"'s storage table (single-table inheritance via @@base) and has no table of its own to declare a check constraint on. Declare the check on "${baseDecl.baseName}" instead.`, + message: `Model "${variantName}" declares "@@check", but it shares its base model "${baseDecl.base.model.name}"'s storage table (single-table inheritance via @@base) and has no table of its own to declare a check constraint on. Declare the check on "${baseDecl.base.model.name}" instead.`, ...diagnosticSource(input.sources, attribute.syntax).at( nodePslSpan(attribute.syntax, input.sources), ), @@ -2610,9 +2633,19 @@ export function interpretPslDocumentToSqlContract( } const { modelNodes: mtiLinkedModelNodes, syntheticPkFieldsByVariant } = - materializeMtiVariantStorageLinks(modelNodes, baseDeclarations, stiVariantNames); + materializeMtiVariantStorageLinks( + modelNodes, + baseDeclarations, + stiVariantKeys, + defaultNamespaceId, + ); const { modelNodes: stiColumnModelNodes, stiBaseFieldsByBase } = - materializeStiVariantStorageColumns(mtiLinkedModelNodes, baseDeclarations, stiVariantNames); + materializeStiVariantStorageColumns( + mtiLinkedModelNodes, + baseDeclarations, + stiVariantKeys, + defaultNamespaceId, + ); const valueObjects = buildValueObjects({ compositeTypes, @@ -2731,10 +2764,7 @@ export function interpretPslDocumentToSqlContract( patchedModels, discriminatorDeclarations, baseDeclarations, - modelNames, - modelMappings, - modelNamespaceIds, - input.target.defaultNamespaceId, + modelMappingsByCoordinate, syntheticPkFieldsByVariant, stiBaseFieldsByBase, polyDiagnostics, @@ -2747,10 +2777,10 @@ export function interpretPslDocumentToSqlContract( }); } - const variantModelNames = new Set(baseDeclarations.keys()); const filteredRoots = Object.fromEntries( Object.entries(contract.roots).filter( - ([, crossReference]) => !variantModelNames.has(crossReference.model), + ([, crossReference]) => + !baseDeclarations.has(modelCoordinateKey(crossReference.namespace, crossReference.model)), ), ); 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 c179535a4cd2..2d8de8c2fcb0 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 @@ -24,6 +24,7 @@ import type { import { bool, diagnosticSource, + createEntityResolver, entityRef, fieldAttribute, fieldRef, @@ -519,17 +520,24 @@ const discriminatorModelSpec = modelAttribute('discriminator', { { key: 'field', type: fieldRef(), documentation: 'The discriminator field on this model.' }, ], }); -const baseModelSpec = modelAttribute('base', { - documentation: 'Declares this model as a variant of a base model.', - positional: [ - { key: 'base', type: entityRef(), documentation: 'The base model to inherit from.' }, - { - key: 'value', - type: str(), - documentation: 'The discriminator value identifying this variant.', - }, - ], -}); +function baseModelSpec(ctx: AttributeSpecContext) { + const resolve = createEntityResolver({ symbols: ctx.symbols, owner: ctx.model }); + return modelAttribute('base', { + documentation: 'Declares this model as a variant of a base model.', + positional: [ + { + key: 'base', + type: entityRef({ kind: 'model' }, resolve), + documentation: 'The base model to inherit from.', + }, + { + key: 'value', + type: str(), + documentation: 'The discriminator value identifying this variant.', + }, + ], + }); +} function relationAttributeSpan(ctx: FieldAttributeCtx): PslSpan { const node = findFieldAttributeNode(ctx.field, 'relation'); @@ -661,7 +669,7 @@ export const sqlAttributeSpecs = { check: () => checkModelSpec, control: () => controlModelSpec, discriminator: () => discriminatorModelSpec, - base: () => baseModelSpec, + base: baseModelSpec, }, field: { map: () => mapFieldSpec, 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 3a984980ad62..395d48e72b7d 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 @@ -1,6 +1,7 @@ import { crossRef } from '@internal/contract/types'; import { validateContractDomain } from '@internal/contract/validate-domain'; import type { SqlModelStorage, SqlStorage } from '@internal/sql-contract/types'; +import { validateSqlContractFully } from '@internal/sql-contract/validators'; import { describe, expect, it } from 'vitest'; import { createTestSqlNamespace } from '../../../1-core/contract/test/test-support'; import { @@ -41,6 +42,120 @@ describe('interpretPslDocumentToSqlContract — polymorphism', () => { ...input, }); + it.each([false, true])( + 'keeps same-named inheritance graphs independent across namespaces (%s)', + (reverse) => { + const schemas = ['alpha', 'beta'].map( + (namespace) => `namespace ${namespace} { + model Bug { + ${namespace}Detail String @map("${namespace}_detail") + @@base(Task, "bug") + } + model Feature { + ${namespace}Priority Int + @@base(Task, "feature") + @@map("${namespace}_features") + } + model Task { + ${namespace}Id Int @id @map("${namespace}_id") + ${namespace}Kind String + @@discriminator(${namespace}Kind) + @@map("${namespace}_tasks") + } +}`, + ); + const standalone = 'model Bug {\n id Int @id\n @@map("standalone_bug")\n}'; + const interpret = (schema: string) => { + const result = interpretPslDocumentToSqlContract({ + ...symbolTableInputFromParseArgs({ schema, sourceId: 'schema.prisma' }), + controlMutationDefaults: builtinControlMutationDefaults, + }); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error(JSON.stringify(result.failure)); + return result.value; + }; + const combined = interpret( + [standalone, ...(reverse ? [...schemas].reverse() : schemas)].join('\n'), + ); + expect(() => validateContractDomain(combined)).not.toThrow(); + const envelope: unknown = JSON.parse(JSON.stringify(combined)); + expect(() => validateSqlContractFully(envelope)).not.toThrow(); + for (const [index, namespace] of ['alpha', 'beta'].entries()) { + const isolated = interpret(schemas[index]!); + expect(combined.domain.namespaces[namespace]).toEqual( + isolated.domain.namespaces[namespace], + ); + const storage = combined.storage as SqlStorage; + expect(storage.namespaces[namespace]).toEqual( + (isolated.storage as SqlStorage).namespaces[namespace], + ); + expect(combined.domain.namespaces[namespace]?.models['Task']).toMatchObject({ + discriminator: { field: `${namespace}Kind` }, + variants: { Bug: { value: 'bug' }, Feature: { value: 'feature' } }, + }); + expect(combined.domain.namespaces[namespace]?.models['Bug']).toMatchObject({ + base: crossRef('Task', namespace), + storage: { table: `${namespace}_tasks` }, + }); + expect( + storage.namespaces[namespace]?.entries.table?.[`${namespace}_features`], + ).toMatchObject({ + primaryKey: { columns: [`${namespace}_id`] }, + foreignKeys: [ + { + target: { + tableName: `${namespace}_tasks`, + columns: [`${namespace}_id`], + namespaceId: namespace, + }, + }, + ], + }); + } + expect(combined.roots).toEqual({ + standalone_bug: crossRef('Bug', 'public'), + alpha_tasks: crossRef('Task', 'alpha'), + beta_tasks: crossRef('Task', 'beta'), + }); + }, + ); + + it.each([ + [ + 'type Base { value String }', + 'model Variant { id Int @id\n @@base(Base, "v") }', + 'Expected model reference "Base", found compositeType', + ], + [ + 'namespace sibling { model Base { id Int @id } }', + 'namespace local { model Variant { id Int @id\n @@base(Base, "v") } }', + 'Unknown model reference "Base"', + ], + [ + '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', + ], + ])('reports checked-reference failure for %s', (base, variant, message) => { + const schema = `${base}\n${variant}`; + const result = interpretPslDocumentToSqlContract({ + ...symbolTableInputFromParseArgs({ schema, sourceId: 'schema.prisma' }), + controlMutationDefaults: builtinControlMutationDefaults, + }); + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.failure.diagnostics).toEqual([ + expect.objectContaining({ + code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', + message, + sourceId: 'schema.prisma', + span: expect.objectContaining({ + start: expect.objectContaining({ offset: schema.indexOf('@@base(Base') + 7 }), + }), + }), + ]); + }); + it('ignores polymorphism collection when the schema has no models', () => { const document = symbolTableInputFromParseArgs({ schema: `types { @@ -859,7 +974,8 @@ model Bug { expect(result.failure.diagnostics).toEqual( expect.arrayContaining([ expect.objectContaining({ - code: 'PSL_BASE_TARGET_NOT_FOUND', + code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', + message: 'Unknown model reference "NonExistent"', }), ]), ); 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 334acbbd081b..2c4cf429b443 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 @@ -15,7 +15,9 @@ import { describe, expect, it } from 'vitest'; import { fieldSpecContext, findFieldAttributeNode, + findModelAttributeNode, interpretFieldAttribute, + interpretModelAttribute, modelSpecContext, sqlAttributeSpecs, } from '../src/sql-attribute-specs'; @@ -118,6 +120,36 @@ function interpretDefault(schema: string, fieldName: string) { return { value, diagnostics: diagnostics.toExternal() }; } +describe('checked base factory', () => { + it('returns the forward-declared local base identity', () => { + const input = buildSymbolTableInput(`model Base { id Int @id } +namespace scoped { + model Variant { @@base(Base, "variant") } + model Base { id String @id } +}`); + const namespace = input.symbolTable.topLevel.namespaces['scoped']; + const model = namespace?.models['Variant']; + if (!namespace || !model) throw new Error('missing variant'); + const node = findModelAttributeNode(model, 'base'); + if (!node) throw new Error('missing base attribute'); + const diagnostics: ContractSourceDiagnostic[] = []; + const value = interpretModelAttribute({ + node, + spec: sqlAttributeSpecs.model.base( + modelSpecContext({ symbols: input.symbolTable, model, controlMutationDefaults }), + ), + model, + sourceFile: input.sourceFile, + sourceId: input.sourceId, + diagnostics, + }); + expect(diagnostics).toEqual([]); + expect(value?.base.declaration).toBe(namespace.models['Base']); + expect(value?.base.namespace).toBe(namespace); + expect(value?.value).toBe('variant'); + }); +}); + describe('sqlAttributeSpecs', () => { const { symbolTable, model } = project( 'model Post {\n id Int @id\n tags String[]\n}\n', From 9a5d44fd6f3b06f90de55157c407c497418adca3 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Thu, 17 Sep 2026 21:00:52 +0000 Subject: [PATCH 3/9] fix(mongo-contract-psl): distinguish checked bases from wildcard names Signed-off-by: Steven McClankerton --- .../2-authoring/contract-psl/README.md | 4 ++ .../contract-psl/src/interpreter.ts | 72 ++++++++----------- .../contract-psl/src/mongo-attribute-specs.ts | 34 +++++---- .../test/interpreter.attribute-specs.test.ts | 58 ++++++++++++++- .../test/interpreter.polymorphism.test.ts | 70 +++++++++++++++++- .../test/mongo-attribute-specs.test.ts | 41 ++++++++++- 6 files changed, 217 insertions(+), 62 deletions(-) diff --git a/packages/2-mongo-family/2-authoring/contract-psl/README.md b/packages/2-mongo-family/2-authoring/contract-psl/README.md index 755e9886574a..1416259db72c 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/README.md +++ b/packages/2-mongo-family/2-authoring/contract-psl/README.md @@ -10,6 +10,10 @@ PSL-to-Mongo contract interpreter for Prisma 8. Transforms Prisma Schema Languag - **Contract provider**: `mongoContract()` (exported from `./provider`) integrates with the CLI's `prisma contract emit` command, reading a `.prisma` schema file and producing a `ContractConfig` - **Diagnostics**: Emits structured diagnostics for unsupported field types (`PSL_UNSUPPORTED_FIELD_TYPE`), missing `@id` fields (`PSL_MISSING_ID_FIELD`), orphaned backrelations (`PSL_ORPHANED_BACKRELATION`), ambiguous backrelations (`PSL_AMBIGUOUS_BACKRELATION`), and attribute names outside the registered namespace (`PSL_UNSUPPORTED_MODEL_ATTRIBUTE`, `PSL_UNSUPPORTED_FIELD_ATTRIBUTE`) +`@@base` uses a context-bound checked model reference and preserves the selected declaration through discriminator, collection, root and index lowering. Missing or wrong-kind targets produce shared source-anchored expression diagnostics; Mongo's namespace prohibition and single-collection inheritance checks remain separate. + +Wildcard scope is an unchecked identifier expression (`wildcard(field)`), not a model reference. Field existence/indexability and mapped field paths are still checked during index lowering; `wildcard()` continues to index `$**`. + ## Known limitations - **Per-index `collation`**: PSL authoring does not support the `collation` index option. Users requiring per-index collation must use the TypeScript contract builder (`@internal/mongo-contract-ts`). 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..bfefbf3d155d 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 @@ -270,7 +270,7 @@ type DiscriminatorDeclaration = { readonly span: PslSpan; }; type BaseDeclaration = { - readonly baseName: string; + readonly base: ModelSymbol; readonly value: string; readonly collectionName: string; readonly source: DiagnosticSource; @@ -288,11 +288,11 @@ function collectPolymorphismDeclarations( sources: PslSources, diagnostics: PslDiagnosticCollector, ): { - discriminatorDeclarations: Map; - baseDeclarations: Map; + discriminatorDeclarations: Map; + baseDeclarations: Map; } { - const discriminatorDeclarations = new Map(); - const baseDeclarations = new Map(); + const discriminatorDeclarations = new Map(); + const baseDeclarations = new Map(); for (const model of models) { const specContext = specContextFor(model); @@ -316,7 +316,7 @@ function collectPolymorphismDeclarations( ...diagnosticSource(sources, discNode.syntax).at(nodePslSpan(discNode.syntax, sources)), }); } else { - discriminatorDeclarations.set(model.name, { + discriminatorDeclarations.set(model, { fieldName, source: diagnosticSource(sources, discNode.syntax), span: nodePslSpan(discNode.syntax, sources), @@ -336,8 +336,8 @@ function collectPolymorphismDeclarations( if (parsed) { const collectionName = modelMetadataByName.get(model.name)?.collectionName ?? defaultCollectionName(model.name); - baseDeclarations.set(model.name, { - baseName: parsed.base, + baseDeclarations.set(model, { + base: parsed.base.declaration, value: parsed.value, collectionName, source: diagnosticSource(sources, baseNode.syntax), @@ -354,10 +354,8 @@ function resolvePolymorphism(input: { models: Record; roots: Record; collections: Record>; - allModels: readonly ModelSymbol[]; - discriminatorDeclarations: Map; - baseDeclarations: Map; - modelNames: ReadonlySet; + discriminatorDeclarations: Map; + baseDeclarations: Map; indexSpans: Map; modelIndexesByName: Map; modelMetadataByName: ReadonlyMap; @@ -372,9 +370,7 @@ function resolvePolymorphism(input: { const { discriminatorDeclarations, baseDeclarations, - modelNames, - modelMetadataByName, - allModels: allModelViews, + modelMetadataByName,, indexSpans, modelIndexesByName, indexSources, @@ -384,8 +380,9 @@ function resolvePolymorphism(input: { let collections = input.collections; const diagnostics = createPslDiagnosticCollector(input.sources); - for (const [modelName, decl] of discriminatorDeclarations) { - if (baseDeclarations.has(modelName)) { + for (const [declaration, decl] of discriminatorDeclarations) { + const modelName = declaration.name; + if (baseDeclarations.has(declaration)) { diagnostics.push({ code: 'PSL_DISCRIMINATOR_AND_BASE', message: `Model "${modelName}" cannot have both @@discriminator and @@base`, @@ -411,9 +408,9 @@ function resolvePolymorphism(input: { } const variants: Record = {}; - for (const [variantName, baseDecl] of baseDeclarations) { - if (baseDecl.baseName !== modelName) continue; - variants[variantName] = { value: baseDecl.value }; + for (const [variant, baseDecl] of baseDeclarations) { + if (baseDecl.base !== declaration) continue; + variants[variant.name] = { value: baseDecl.value }; } if (Object.keys(variants).length === 0) { @@ -431,38 +428,29 @@ 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 (!discriminatorDeclarations.has(baseDecl.baseName)) { + for (const [variant, baseDecl] of baseDeclarations) { + const variantName = variant.name; + const baseName = baseDecl.base.name; + if (!discriminatorDeclarations.has(baseDecl.base)) { diagnostics.push({ code: 'PSL_ORPHANED_BASE', - message: `Model "${variantName}" declares @@base(${baseDecl.baseName}, ...) but "${baseDecl.baseName}" has no @@discriminator`, + message: `Model "${variantName}" declares @@base(${baseName}, ...) but "${baseName}" has no @@discriminator`, ...baseDecl.source.at(baseDecl.span), }); continue; } - if (discriminatorDeclarations.has(variantName)) { + if (discriminatorDeclarations.has(variant)) { continue; } - const baseModel = patched[baseDecl.baseName]; - const variantModelView = allModelViews.find((m) => m.name === variantName); - if (!variantModelView) continue; - const hasExplicitMap = getAttribute(variantModelView.attributes, 'map') !== undefined; + const baseModel = patched[baseName]; + const hasExplicitMap = getAttribute(variant.attributes, 'map') !== undefined; if (hasExplicitMap && baseModel && baseDecl.collectionName !== baseModel.storage.collection) { diagnostics.push({ code: 'PSL_MONGO_VARIANT_SEPARATE_COLLECTION', - message: `Mongo variant "${variantName}" cannot use a different collection than its base "${baseDecl.baseName}". Mongo only supports single-collection polymorphism.`, + message: `Mongo variant "${variantName}" cannot use a different collection than its base "${baseName}". Mongo only supports single-collection polymorphism.`, ...baseDecl.source.at(baseDecl.span), }); continue; @@ -475,7 +463,7 @@ function resolvePolymorphism(input: { ...patched, [variantName]: { ...variantModel, - base: mongoCrossRef(baseDecl.baseName), + base: mongoCrossRef(baseName), storage: { collection: baseCollection }, }, }; @@ -485,7 +473,7 @@ function resolvePolymorphism(input: { modelMetadataByName.get(variantName)?.collectionName ?? defaultCollectionName(variantName); if (roots[variantCollectionName]?.model === variantName) { if (variantCollectionName === baseCollection && baseModel) { - roots = { ...roots, [variantCollectionName]: mongoCrossRef(baseDecl.baseName) }; + roots = { ...roots, [variantCollectionName]: mongoCrossRef(baseName) }; } else { roots = Object.fromEntries( Object.entries(roots).filter(([key]) => key !== variantCollectionName), @@ -496,7 +484,7 @@ function resolvePolymorphism(input: { const variantOwnIndexes = modelIndexesByName.get(variantName) ?? []; const baseColl = collections[baseCollection]; - const baseModelEntry = patched[baseDecl.baseName]; + const baseModelEntry = patched[baseName]; const discriminatorField = baseModelEntry?.discriminator?.field; const scopedVariantIndexes: MongoIndex[] = []; if (discriminatorField) { @@ -1438,10 +1426,8 @@ export function interpretPslDocumentToMongoContract( models, roots, collections, - allModels, discriminatorDeclarations, baseDeclarations, - modelNames, indexSpans, modelIndexesByName, modelMetadataByName, 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..b7623f56dcad 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 @@ -14,6 +14,7 @@ import type { } from '@internal/psl-parser'; import { bool, + createEntityResolver, entityRef, fieldAttribute, fieldRef, @@ -183,17 +184,24 @@ export const discriminatorModelSpec = modelAttribute('discriminator', { { key: 'field', type: fieldRef(), documentation: 'The discriminator field on this model.' }, ], }); -export const baseModelSpec = modelAttribute('base', { - documentation: 'Declares this model as a variant of a base model.', - positional: [ - { key: 'base', type: entityRef(), documentation: 'The base model to inherit from.' }, - { - key: 'value', - type: str(), - documentation: 'The discriminator value identifying this variant.', - }, - ], -}); +export function baseModelSpec(ctx: AttributeSpecContext) { + const resolve = createEntityResolver({ symbols: ctx.symbols, owner: ctx.model }); + return modelAttribute('base', { + documentation: 'Declares this model as a variant of a base model.', + positional: [ + { + key: 'base', + type: entityRef({ kind: 'model' }, resolve), + documentation: 'The base model to inherit from.', + }, + { + key: 'value', + type: str(), + documentation: 'The discriminator value identifying this variant.', + }, + ], + }); +} const sortSig = { documentation: 'Selects an index field with an explicit sort direction.', @@ -221,7 +229,7 @@ function indexFieldElement( positional: [ { key: 'scope', - type: optional(entityRef()), + type: optional(identifier()), documentation: 'The field path to index recursively. Omit to index all document fields.', }, ], @@ -384,7 +392,7 @@ export const mongoAttributeSpecs = { model: { map: staticModelSpec(mapModelSpec), discriminator: staticModelSpec(discriminatorModelSpec), - base: staticModelSpec(baseModelSpec), + base: baseModelSpec, index: (ctx) => buildIndexModelSpec('index', modelFieldElement(ctx)), unique: (ctx) => buildIndexModelSpec('unique', modelFieldElement(ctx)), textIndex: (ctx) => buildTextIndexModelSpec(modelFieldElement(ctx)), diff --git a/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.attribute-specs.test.ts b/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.attribute-specs.test.ts index 03123e70e1aa..2c4966ad92c8 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.attribute-specs.test.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.attribute-specs.test.ts @@ -1,4 +1,5 @@ import type { ContractSourceDiagnostic } from '@internal/config/config-types'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import { buildSymbolTable } from '@internal/psl-parser'; import { parse } from '@internal/psl-parser/syntax'; import { describe, expect, it } from 'vitest'; @@ -10,14 +11,14 @@ const scalarTypeCodecIds: ReadonlyMap = new Map([ ['ObjectId', 'mongo/objectId@1'], ]); -function diagnosticsOf(schema: string): readonly ContractSourceDiagnostic[] { +function interpret(schema: string) { const { document, sources } = parse(schema, 'schema.prisma'); const { symbolTable } = buildSymbolTable({ documents: [document], sources, pslBlockDescriptors: {}, }); - const result = interpretPslDocumentToMongoContract({ + return interpretPslDocumentToMongoContract({ document, symbolTable, sources, @@ -27,9 +28,62 @@ function diagnosticsOf(schema: string): readonly ContractSourceDiagnostic[] { defaultFunctionRegistry: new Map(), }, }); +} + +function diagnosticsOf(schema: string): readonly ContractSourceDiagnostic[] { + const result = interpret(schema); return result.ok ? [] : result.failure.diagnostics; } +describe('wildcard scope is an unchecked identifier with independent field validation', () => { + it.each([ + ['metadata', 'stored.$**'], + ['', '$**'], + ])('accepts scope %s without a matching model', (scope, path) => { + const result = interpret(`model Event { + id ObjectId @id @map("_id") + metadata String @map("stored") + @@map("events") + @@index([wildcard(${scope})]) +}`); + expect(result.ok).toBe(true); + if (result.ok) + expect(result.value.storage).toMatchObject({ + namespaces: { + [UNBOUND_NAMESPACE_ID]: { + entries: { + collection: { + events: { + indexes: [expect.objectContaining({ keys: [{ field: path, direction: 1 }] })], + }, + }, + }, + }, + }, + }); + }); + + it.each(['missing', 'related'])('retains field/indexability validation for %s', (scope) => { + const diagnostics = diagnosticsOf(`model Related { id ObjectId @id @map("_id") } +model Event { + id ObjectId @id @map("_id") + related Related + @@index([wildcard(${scope})]) +}`); + expect(diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_INDEX_FIELD_NOT_FOUND', + message: expect.stringContaining(scope), + }), + ]), + ); + expect( + diagnostics.some((diagnostic) => diagnostic.code === 'PSL_INVALID_ATTRIBUTE_SYNTAX'), + ).toBe(false); + }); +}); + describe('field-level @id and @unique are interpreted against their specs', () => { it('rejects an argument on @id and no longer counts the field as the id', () => { const diagnostics = diagnosticsOf(` 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 2dea61f25cc6..42a27f39eff8 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 @@ -89,6 +89,69 @@ function interpretOk(schema: string) { } describe('interpretPslDocumentToMongoContract — polymorphism', () => { + it('preserves the whole supported contract when a mapped base is declared after its variant', () => { + const base = `model Task { + id ObjectId @id @map("_id") + kind String @map("task_kind") + @@discriminator(kind) + @@map("tasks") + }`; + const variant = `model Bug { + id ObjectId @id @map("_id") + severity String @map("level") + @@base(Task, "bug") + @@index([severity]) + }`; + const forward = interpretOk(`${variant}\n${base}`); + expect(forward).toEqual(interpretOk(`${base}\n${variant}`)); + expect(modelsOf(forward)['Bug']).toMatchObject({ + base: crossRef('Task', UNBOUND_NAMESPACE_ID), + storage: { collection: 'tasks' }, + }); + expect(modelsOf(forward)['Task']).toMatchObject({ + discriminator: { field: 'task_kind' }, + variants: { Bug: { value: 'bug' } }, + }); + expect(forward.roots).toEqual({ tasks: crossRef('Task', UNBOUND_NAMESPACE_ID) }); + expect(mongoCollectionsOf(forward)['tasks']).toMatchObject({ + indexes: [expect.objectContaining({ partialFilterExpression: { task_kind: 'bug' } })], + }); + }); + + it('reports a wrong-kind base at the reference expression', () => { + const schema = `type Base { value String } +model Variant { + id ObjectId @id @map("_id") + @@base(Base, "v") +}`; + const result = interpret(schema); + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.failure.diagnostics).toEqual([ + expect.objectContaining({ + code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', + message: 'Expected model reference "Base", found compositeType', + span: expect.objectContaining({ + start: expect.objectContaining({ offset: schema.indexOf('@@base(Base') + 7 }), + }), + }), + ]); + }); + + it('keeps namespace rejection even when a same-named top-level base exists', () => { + const result = interpret(`model Base { id ObjectId @id @map("_id") } +namespace scoped { + model Base { id ObjectId @id @map("_id") } + model Variant { id ObjectId @id @map("_id")\n @@base(Base, "v") } +}`); + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'PSL_UNSUPPORTED_NAMESPACE_BLOCK' }), + ]), + ); + }); describe('@@discriminator and @@base — happy paths', () => { it('emits discriminator on base model', () => { const ir = interpretOk(` @@ -382,7 +445,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_INVALID_ATTRIBUTE_SYNTAX', + message: 'Unknown model reference "NonExistent"', + }), + ]), ); }); diff --git a/packages/2-mongo-family/2-authoring/contract-psl/test/mongo-attribute-specs.test.ts b/packages/2-mongo-family/2-authoring/contract-psl/test/mongo-attribute-specs.test.ts index 8641a9e1b842..2db4084bb7af 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 @@ -1,3 +1,4 @@ +import type { ContractSourceDiagnostic } from '@internal/config/config-types'; import type { ArgType, AttributeCtx, @@ -5,12 +6,18 @@ import type { FieldAttributeSpecContext, FuncCallSig, ModelAttributeCtx, + ModelSymbol, Param, + ResolvedEntityReference, } from '@internal/psl-parser'; import { buildSymbolTable } from '@internal/psl-parser'; import { parse } from '@internal/psl-parser/syntax'; -import { describe, expect, it } from 'vitest'; -import { mongoAttributeSpecs } from '../src/mongo-attribute-specs'; +import { describe, expect, expectTypeOf, it } from 'vitest'; +import { + findModelAttributeNode, + interpretModelAttribute, + mongoAttributeSpecs, +} from '../src/mongo-attribute-specs'; interface ListMetadata extends ArgType { readonly kind: 'list'; @@ -100,6 +107,33 @@ function contexts(): { model: AttributeSpecContext; field: FieldAttributeSpecCon } describe('mongoAttributeSpecs', () => { + it('returns the selected forward base declaration instead of a name', () => { + const { document, sourceFile } = parse(`model Variant { @@base(Base, "v") } +model Other { id Int } +model Base { id String }`); + const { table } = buildSymbolTable({ document, sourceFile, pslBlockDescriptors: {} }); + const model = table.topLevel.models['Variant']; + if (!model) throw new Error('missing variant'); + const node = findModelAttributeNode(model, 'base'); + if (!node) throw new Error('missing base'); + const ctx = { ...contexts().model, symbols: table, model }; + const diagnostics: ContractSourceDiagnostic[] = []; + const value = interpretModelAttribute({ + node, + spec: mongoAttributeSpecs.model.base(ctx), + model, + sourceFile, + sourceId: 'test.prisma', + diagnostics, + }); + expectTypeOf(value).toEqualTypeOf< + { base: ResolvedEntityReference; value: string } | undefined + >(); + expect(diagnostics).toEqual([]); + expect(value?.base.declaration).toBe(table.topLevel.models['Base']); + expect(value?.base.namespace).toBeUndefined(); + expect(value?.value).toBe('v'); + }); it('registers every Mongo built-in at its level', () => { expect({ model: Object.keys(mongoAttributeSpecs.model).sort(), @@ -166,7 +200,8 @@ describe('mongoAttributeSpecs', () => { expect(wildcard).toMatchObject({ kind: 'funcCall', name: 'wildcard' }); expect(wildcard.signature.positional?.[0]).toMatchObject({ key: 'scope' }); expect(wildcard.signature.positional?.[0]?.type).toMatchObject({ - kind: 'entityRef', + kind: 'identifier', + name: undefined, optional: true, }); expect( From ca228b3fdcb0608c94e57fedfa1bafefbdcfa06c Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Thu, 17 Sep 2026 21:17:13 +0000 Subject: [PATCH 4/9] fix(language-server): inspect checked references and unrestricted names Signed-off-by: Steven McClankerton --- ... - Declarative attribute specifications.md | 54 ++++++++++----- .../language-server/src/completion-values.ts | 2 +- .../test/attribute-spec-consumability.test.ts | 67 ++++++++++++++++++- .../test/completion-values.test.ts | 14 +++- .../test/signature-help-values.test.ts | 12 ++++ 5 files changed, 131 insertions(+), 18 deletions(-) diff --git a/docs/architecture docs/adrs/ADR 231 - Declarative attribute specifications.md b/docs/architecture docs/adrs/ADR 231 - Declarative attribute specifications.md index 6a3d96788c01..efcdd42975a4 100644 --- a/docs/architecture docs/adrs/ADR 231 - Declarative attribute specifications.md +++ b/docs/architecture docs/adrs/ADR 231 - Declarative attribute specifications.md @@ -4,6 +4,8 @@ **Date:** 2026-06-29 **Accepted:** 2026-08-27 +**Reference API update:** The reference, identifier, factory-context and wildcard sections below describe the current checked-reference API. Existing attribute completion and signature help now inspect these specs; the interpreter-only delivery and language-tooling follow-up discussion elsewhere in this ADR records the original acceptance boundary, not the current editor feature inventory. This update adds no editor semantic diagnostics or generic-block value features. + --- ## At a glance @@ -23,11 +25,11 @@ const sqlRelation = fieldAttribute('relation', { documentation: 'The referential action when the referenced row is deleted.', type: optional( oneOf( - identifier('NoAction'), - identifier('Restrict'), - identifier('Cascade'), - identifier('SetNull'), - identifier('SetDefault'), + identifier('NoAction', { documentation: 'Checks the constraint without propagating changes.' }), + identifier('Restrict', { documentation: 'Rejects changes while referencing rows exist.' }), + identifier('Cascade', { documentation: 'Propagates changes to referencing rows.' }), + identifier('SetNull', { documentation: 'Sets referencing fields to null.' }), + identifier('SetDefault', { documentation: 'Sets referencing fields to their defaults.' }), ), ), }, @@ -97,7 +99,7 @@ interface FieldAttributeCtx extends ModelAttributeCtx { } ``` -A block has no model, so a block attribute is parsed with only the source context. A combinator is usable at any level that carries the facts it declares, and rejected where those facts do not exist. +A block has no model, so a block attribute is parsed with only the source context. A combinator is usable at any level that carries the facts it declares, and rejected where those facts do not exist. Checked entity references bind their resolver in the spec factory, not in these parse contexts: `AttributeSpecContext` supplies `symbols`, `model`, and `controlMutationDefaults` to model factories. The symbol table is not added to `AttributeCtx`. This does not change block-attribute factory timing. A spec fixes the attribute level and name, declares its arguments, and may refine the parsed result: @@ -136,7 +138,8 @@ Positionals are fixed slots with an output key. Variadic positionals are not sup - `numLiteral()` parses any number literal and keeps its source text, for consumers that must not round it through a JavaScript number. - `int({ min, max })` parses an integer with optional inclusive bounds. - `bool()` parses a boolean literal. -- `identifier(name)` matches one exact bare identifier and preserves its literal type. +- `identifier()` accepts an unchecked bare identifier and returns `string`, with `name: undefined` metadata. +- `identifier(name, { documentation })` matches one exact bare identifier and preserves its literal type and documented pinned metadata. Existing completion offers pinned names only, not candidates for unrestricted names. There is no enum-specific combinator. A fixed vocabulary is a `oneOf` over pinned matchers, making the source spelling explicit: @@ -157,9 +160,27 @@ These leaves perform direct AST checks. They do not wrap arktype schemas. `fieldRef()` parses a field-name identifier and validates it against the declaring model, so it is available to model and field attributes alike. `referencedFieldRef()` validates against the relation target, which only a field can resolve; cross-space references may defer the existence check when no referenced model is locally available. Both return the authored field name as a string. -`entityRef()` parses an unresolved model-name string. Existence and family semantics remain downstream concerns. +`entityRef(expected, resolve)` checks existence and the selected declaration's kind. Selectors are `{ kind: 'model' }`, `{ kind: 'compositeType' }`, `{ kind: 'namedType' }`, or `{ kind: 'block', keyword }` for the contributed block keyword. Its inferred output is `ResolvedEntityReference>`: the selected `declaration` plus its lexical `namespace` (undefined at top-level), not physical storage coordinates. + +Model factories bind the complete symbol table and owner identity before interpreting expressions: -The current kit does not return declaration-bearing entity coordinates, provide a document-path scope, or include a codec reference combinator. Those would be separate additions if a future consumer requires them. +```ts +function baseSpec(ctx: AttributeSpecContext) { + const resolve = createEntityResolver({ symbols: ctx.symbols, owner: ctx.model }); + return modelAttribute('base', { + documentation: 'Declares the base model.', + positional: [ + { key: 'base', type: entityRef({ kind: 'model' }, resolve), documentation: 'The model to inherit from.' }, + ], + }); +} +``` + +Resolution selects the containing namespace's binding first, then top-level, never a sibling namespace; top-level owners see only top-level declarations. Kind checking applies after selection, so a wrong-kind local binding does not trigger fallback. Collection before factory interpretation permits forward references. Missing/wrong-kind failures are source-anchored shared expression diagnostics. The resolver itself emits no diagnostics, and repeated references from one resolver retain wrapper identity for collection uniqueness. + +`oneOf(entityRef(expected, resolve), identifier())` prefers a checked identity and otherwise returns an unchecked name without leaking failed-alternative diagnostics. SQL and Mongo base attributes consume the selected model identity through lowering rather than re-resolving its bare name. Family-specific relationship and storage constraints remain separate: checked lookup does not promise cross-namespace inheritance support or change persisted variants. No document-path scope or codec reference combinator is added. + +Existing attribute tooling inspects reference metadata and labels without invoking its parser/resolver. Checked references add no completion candidates or navigation feature. The parse-only editor pipeline still does not execute SQL/Mongo family reference diagnostics. ### Native collections @@ -215,7 +236,7 @@ A spec may be assembled from context known by the owning family before interpret ### SQL defaults -SQL constructs `@default` specs per field. Scalar fields accept flexible string, number, and boolean literals plus one pinned `funcCall(name, signature)` arm for every active default-function registry entry. List fields accept a list of those scalar literals plus the registry calls. Enum fields use one pinned `identifier(member)` arm per enum member. +SQL constructs `@default` specs per field. Scalar fields accept flexible string, number, and boolean literals plus one pinned `funcCall(name, signature)` arm for every active default-function registry entry. List fields accept a list of those scalar literals plus the registry calls. Enum fields use one documented pinned `identifier(member, { documentation })` arm per enum member. ```ts const functionArms = registryEntries.map(([name, entry]) => @@ -223,7 +244,10 @@ const functionArms = registryEntries.map(([name, entry]) => ); const scalarDefault = oneOf(str(), numLiteral(), bool(), ...functionArms); -const enumDefault = oneOf(...enumMembers.map(identifier)); +const enumDefault = oneOf( + identifier('Active', { documentation: 'An active item.' }), + identifier('Archived', { documentation: 'An archived item.' }), +); ``` The number arm is `numLiteral()`, not `num()`. `num()` yields a JavaScript number, which rounds a literal past the safe integer range and drops trailing zeros; `numLiteral()` yields the literal's source text, so a `Decimal` or `BigInt` default keeps every digit as written. What to do with that text is the lowering concern below: the plain number goes to a codec that reads one, and the decimal text to a codec that does not. @@ -237,20 +261,20 @@ Mongo constructs its index field-element grammar from the declaring model's fiel ```ts const sortSig = { documentation: 'Selects an index field with an explicit sort direction.', - named: { sort: { type: oneOf(identifier('Asc'), identifier('Desc')), documentation: 'The index order for this field: `Asc` or `Desc`.' } }, + named: { sort: { type: oneOf(identifier('Asc', { documentation: 'Sort ascending.' }), identifier('Desc', { documentation: 'Sort descending.' })), documentation: 'The index order for this field: `Asc` or `Desc`.' } }, } satisfies FuncCallSig; const indexFieldElement = oneOf( fieldRef(), funcCall('wildcard', { documentation: 'Indexes document fields using a wildcard index.', - positional: [{ key: 'scope', type: optional(entityRef()), documentation: 'The field path to index recursively. Omit for all document fields.' }], + positional: [{ key: 'scope', type: optional(identifier()), documentation: 'The field path to index recursively. Omit for all document fields.' }], }), ...fieldNames.map((name) => funcCall(name, sortSig)), ); ``` -This composition covers bare fields, sorted field calls, and wildcard calls without dedicated `sortedFieldRef` or `wildcardPath` combinators. +This composition covers bare fields, sorted field calls, and wildcard calls without dedicated `sortedFieldRef` or `wildcardPath` combinators. Wildcard scope deliberately accepts an unchecked name, not a model reference. Index lowering still checks field existence/indexability and applies field mappings; omitted scope remains `$**`. --- @@ -320,7 +344,7 @@ The current implementation is sufficient for interpreter consumption but not yet ## Follow-up work - Add central spec discovery and traversable combinator metadata for language-tooling consumers. -- Decide whether reference combinators should expose declaration-bearing results while preserving the interpreter's string-oriented lowering needs. +- Declaration-bearing entity references and factory-bound resolution are implemented as described above; broader reference navigation and generic-block value consumers remain separate work. - Revisit signature-derived `TypedFuncCall` output types if downstream code needs statically discriminated call unions. - Decide whether literal-to-field-type compatibility should remain in lowering or gain a dedicated field-context combinator. diff --git a/packages/1-framework/3-tooling/language-server/src/completion-values.ts b/packages/1-framework/3-tooling/language-server/src/completion-values.ts index a04020f7803a..2458de928912 100644 --- a/packages/1-framework/3-tooling/language-server/src/completion-values.ts +++ b/packages/1-framework/3-tooling/language-server/src/completion-values.ts @@ -146,7 +146,7 @@ function valueItems( } switch (type.kind) { case 'identifier': - return scalarItems(input, [type.name], type.documentation); + return type.name === undefined ? [] : scalarItems(input, [type.name], type.documentation); case 'str': return scalarItems(input, type.value === undefined ? [] : [JSON.stringify(type.value)]); case 'num': diff --git a/packages/1-framework/3-tooling/language-server/test/attribute-spec-consumability.test.ts b/packages/1-framework/3-tooling/language-server/test/attribute-spec-consumability.test.ts index f48f506b8bbd..8464f8355b71 100644 --- a/packages/1-framework/3-tooling/language-server/test/attribute-spec-consumability.test.ts +++ b/packages/1-framework/3-tooling/language-server/test/attribute-spec-consumability.test.ts @@ -1,11 +1,16 @@ import type { PrismaNextConfig } from '@internal/config-loader'; import * as configLoader from '@internal/config-loader'; -import type { AttributeSpecContext } from '@internal/psl-parser'; +import { + assembleAuthoringContributions, + assembleControlMutationDefaults, +} from '@internal/framework-components/control'; +import type { AttributeSpecContext, AttributeSpecNamespace } from '@internal/psl-parser'; import { assembleAttributeSpecs, fieldAttribute, modelAttribute } from '@internal/psl-parser'; import { ok } from '@internal/utils/result'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { resolveConfigInputs } from '../src/config-resolution'; import { runPipeline } from '../src/pipeline'; +import { providePslSignatureHelp } from '../src/signature-help'; vi.mock('@internal/config-loader', { spy: true }); @@ -68,6 +73,66 @@ describe('assembled attribute specs are consumable from a resolved project', () vi.restoreAllMocks(); }); + it.each([ + ['sqlAttributeSpecs', '../../../../2-sql/2-authoring/contract-psl/src/sql-attribute-specs.ts'], + [ + 'mongoAttributeSpecs', + '../../../../2-mongo-family/2-authoring/contract-psl/src/mongo-attribute-specs.ts', + ], + ])( + 'inspects the actual context-bound %s base factory without running family semantics', + async (exportName, relativePath) => { + const module = (await import(new URL(relativePath, import.meta.url).href)) as Record< + string, + AttributeSpecNamespace + >; + const attributeSpecs = module[exportName]; + if (!attributeSpecs) throw new Error('missing attribute namespace'); + const authoringContributions = assembleAuthoringContributions([ + { id: 'actual-family', authoring: { attributeSpecs } }, + ]); + const controlMutationDefaults = assembleControlMutationDefaults([]); + const source = 'model Variant {\n @@base(Missing, "v")\n}\nmodel Base { id Int }'; + const pipeline = runPipeline(source, { + scalarTypes: ['Int'], + pslBlockDescriptors: {}, + authoringContributions, + controlMutationDefaults, + }); + const model = pipeline.symbolTable.topLevel.models['Variant']; + if (!model) throw new Error('missing variant'); + const spec = assembleAttributeSpecs(authoringContributions).model['base']?.({ + symbols: pipeline.symbolTable, + model, + controlMutationDefaults, + }); + expect(spec).toMatchObject({ + name: 'base', + positional: [ + { + key: 'base', + type: { kind: 'entityRef', expected: { kind: 'model' }, label: 'model reference' }, + }, + { key: 'value', type: { kind: 'str' } }, + ], + }); + expect(pipeline.diagnostics).toEqual([]); + const signature = providePslSignatureHelp({ + document: pipeline.document, + sourceFile: pipeline.sourceFile, + position: pipeline.sourceFile.positionAt(source.indexOf('Missing')), + clientSupportsLabelOffsets: true, + candidates: { + symbolTable: pipeline.symbolTable, + pslBlockDescriptors: {}, + authoringContributions, + controlMutationDefaults, + }, + }); + expect(signature?.signatures[0]?.label).toBe('@@base(model reference, string)'); + }, + ); + it('enumerates a contributed model attribute by its claimed name', async () => { vi.spyOn(configLoader, 'loadConfig').mockResolvedValue( ok({ config: pslProjectConfig(), diagnostics: [] }), diff --git a/packages/1-framework/3-tooling/language-server/test/completion-values.test.ts b/packages/1-framework/3-tooling/language-server/test/completion-values.test.ts index 4de7651798c8..d68ad5e4ad94 100644 --- a/packages/1-framework/3-tooling/language-server/test/completion-values.test.ts +++ b/packages/1-framework/3-tooling/language-server/test/completion-values.test.ts @@ -45,6 +45,8 @@ const rejecting: RejectingArgType = { message: 'No available values', parse: rejectedParse, }; +const unchecked = { ...identifier(), parse: rejectedParse }; +const checked = entityRef({ kind: 'model' }, rejectedParse); const direction = oneOf( identifier('Asc', { documentation: 'An accepted identifier in this test grammar.' }), identifier('Desc', { documentation: 'An accepted identifier in this test grammar.' }), @@ -135,6 +137,8 @@ const signature = { all: { type: oneOf( str(), + unchecked, + checked, identifier('Alpha', { documentation: 'An accepted identifier in this test grammar.' }), bool(), num(), @@ -143,13 +147,15 @@ const signature = { documentation: 'A scalar value with enumerated completion candidates.', }, none: { - type: oneOf(str(), num(), int(), json(), entityRef(), rejecting), + type: oneOf(str(), num(), int(), json(), checked, unchecked, rejecting), documentation: 'A free-form value without enumerated candidates.', }, rejected: { type: rejecting, documentation: 'A value that always fails interpretation.' }, recordValues: { type: record(bool()), documentation: 'Boolean values keyed by name.' }, unionLists: { type: oneOf( + list(unchecked), + list(checked), list(identifier('A', { documentation: 'An accepted identifier in this test grammar.' })), list(identifier('B', { documentation: 'An accepted identifier in this test grammar.' })), list(identifier('A', { documentation: 'An accepted identifier in this test grammar.' })), @@ -475,6 +481,12 @@ describe('recursive attribute values', () => { ); }); + it('offers only pinned names when unchecked names and checked references are nested alternatives', () => { + expect(field('none: |').items).toEqual([]); + expect(field('unionLists: [|]').items.map((item) => item.label)).toEqual(['A', 'B']); + expect(rejectedParse).not.toHaveBeenCalled(); + }); + it('never invokes combinator parsing to select alternatives', () => { expect(field('none: |').items).toEqual([]); expect(rejectedParse).not.toHaveBeenCalled(); diff --git a/packages/1-framework/3-tooling/language-server/test/signature-help-values.test.ts b/packages/1-framework/3-tooling/language-server/test/signature-help-values.test.ts index e9b4a5f9349f..1635c577ecae 100644 --- a/packages/1-framework/3-tooling/language-server/test/signature-help-values.test.ts +++ b/packages/1-framework/3-tooling/language-server/test/signature-help-values.test.ts @@ -4,9 +4,11 @@ import { } from '@internal/framework-components/control'; import { buildSymbolTable, + entityRef, fieldAttribute, funcCall, identifier, + list, oneOf, optional, } from '@internal/psl-parser'; @@ -15,6 +17,8 @@ import { expect, it, vi } from 'vitest'; import { MarkupKind } from 'vscode-languageserver'; import { providePslSignatureHelp } from '../src/signature-help'; +const resolveReference = vi.fn(() => undefined); +const reference = entityRef({ kind: 'model' }, resolveReference); const ascending = identifier('Asc', { documentation: 'Sort ascending.' }); const parseIdentifier = vi.fn(ascending.parse); const asc = { ...ascending, parse: parseIdentifier }; @@ -25,6 +29,13 @@ const nested = funcCall('sort', { }); it.each([ + { type: identifier(), args: '|', label: '@probe(identifier)' }, + { type: reference, args: '|', label: '@probe(model reference)' }, + { + type: optional(list(oneOf(reference, identifier()))), + args: '|', + label: '@probe((model reference | identifier[])?)', + }, { type: oneOf(asc, desc), args: '|', label: '@probe(Asc | Desc)' }, { type: optional(asc), args: '|', label: '@probe(Asc?)' }, { type: oneOf(asc, nested), args: 'sort(|)', label: 'sort(Desc)' }, @@ -74,5 +85,6 @@ it.each([ : '**value**\n\nThe declared value.', }); expect(parseIdentifier).not.toHaveBeenCalled(); + expect(resolveReference).not.toHaveBeenCalled(); }, ); From a955899e5f73d206b66451af103d3646dd4b15d3 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 18 Sep 2026 12:28:47 +0000 Subject: [PATCH 5/9] fix(psl-parser): resolve entity references from parse context Signed-off-by: Steven McClankerton --- ... - Declarative attribute specifications.md | 34 +- .../2-authoring/psl-parser/README.md | 6 +- .../attribute-spec/combinators/entity-ref.ts | 5 +- .../psl-parser/src/attribute-spec/types.ts | 3 +- .../psl-parser/src/block-reconstruction.ts | 54 ++- .../psl-parser/src/entity-reference.ts | 79 ++-- .../psl-parser/src/exports/index.ts | 2 - .../psl-parser/src/symbol-table.ts | 10 +- .../test/attribute-spec-block.test.ts | 6 +- ...bute-spec-combinators.foreign-copy.test.ts | 22 +- ...te-spec-combinators.tagged-literal.test.ts | 1 + .../test/attribute-spec-combinators.test-d.ts | 19 +- .../test/attribute-spec-combinators.test.ts | 30 +- .../test/attribute-spec-documentation.test.ts | 3 + .../psl-parser/test/attribute-spec.test.ts | 1 + .../psl-parser/test/entity-reference.test.ts | 351 ++++++++++-------- .../test/completion-values.test.ts | 2 +- .../test/signature-help-values.test.ts | 7 +- .../contract-psl/src/interpreter.ts | 11 +- .../contract-psl/src/mongo-attribute-specs.ts | 15 +- .../test/mongo-attribute-specs.test.ts | 4 +- .../contract-psl/src/interpreter.ts | 15 +- .../contract-psl/src/psl-column-resolution.ts | 1 + .../contract-psl/src/psl-field-resolution.ts | 11 + .../src/psl-relation-resolution.ts | 1 + .../contract-psl/src/sql-attribute-specs.ts | 14 +- .../test/sql-attribute-specs.test.ts | 6 +- 27 files changed, 435 insertions(+), 278 deletions(-) diff --git a/docs/architecture docs/adrs/ADR 231 - Declarative attribute specifications.md b/docs/architecture docs/adrs/ADR 231 - Declarative attribute specifications.md index efcdd42975a4..3a32538dd186 100644 --- a/docs/architecture docs/adrs/ADR 231 - Declarative attribute specifications.md +++ b/docs/architecture docs/adrs/ADR 231 - Declarative attribute specifications.md @@ -4,7 +4,7 @@ **Date:** 2026-06-29 **Accepted:** 2026-08-27 -**Reference API update:** The reference, identifier, factory-context and wildcard sections below describe the current checked-reference API. Existing attribute completion and signature help now inspect these specs; the interpreter-only delivery and language-tooling follow-up discussion elsewhere in this ADR records the original acceptance boundary, not the current editor feature inventory. This update adds no editor semantic diagnostics or generic-block value features. +**Reference API update:** The reference, identifier, parse-context and wildcard sections below describe the current checked-reference API. Existing attribute completion and signature help now inspect these specs; the interpreter-only delivery and language-tooling follow-up discussion elsewhere in this ADR records the original acceptance boundary, not the current editor feature inventory. This update adds no editor semantic diagnostics or generic-block value features. --- @@ -52,7 +52,7 @@ The SQL and Mongo family interpreters are the first consumers. They define their The kit consumes `ExpressionAst` directly. No intermediate argument representation is introduced, and no combinator reparses flattened source text except `json()`, the deliberate quoted-JSON-object exception. -Attributes are a PSL authoring concern, so the kit is in `psl-parser` rather than framework core. Field, model, and block attributes are all constructed through it. A block descriptor declares which attributes its block accepts, and the generic block reconstruction interprets them at parse time. +Attributes are a PSL authoring concern, so the kit is in `psl-parser` rather than framework core. Field, model, and block attributes are all constructed through it. A block descriptor declares which attributes its block accepts, and symbol-table construction interprets them after collecting all declarations. --- @@ -87,6 +87,7 @@ A combinator declares what it reads. The contexts nest by what the site being pa interface AttributeCtx { readonly sourceId: string; readonly sourceFile: SourceFile; + readonly symbols: SymbolTable; } interface ModelAttributeCtx extends AttributeCtx { @@ -99,7 +100,7 @@ interface FieldAttributeCtx extends ModelAttributeCtx { } ``` -A block has no model, so a block attribute is parsed with only the source context. A combinator is usable at any level that carries the facts it declares, and rejected where those facts do not exist. Checked entity references bind their resolver in the spec factory, not in these parse contexts: `AttributeSpecContext` supplies `symbols`, `model`, and `controlMutationDefaults` to model factories. The symbol table is not added to `AttributeCtx`. This does not change block-attribute factory timing. +A block has no model, so a block attribute receives source information and the complete symbol table without a model context. A combinator is usable at any level that carries the facts it declares, and rejected where those facts do not exist. Checked references derive lexical scope from the expression's syntax ancestry; the parse context carries no owner or scope field. Spec factories may still use `AttributeSpecContext` for dynamic grammars, but do not inject reference resolvers. Existing block attributes are interpreted after declaration collection so their parse contexts also support forward references; block-value grammars remain separate. A spec fixes the attribute level and name, declares its arguments, and may refine the parsed result: @@ -160,27 +161,24 @@ These leaves perform direct AST checks. They do not wrap arktype schemas. `fieldRef()` parses a field-name identifier and validates it against the declaring model, so it is available to model and field attributes alike. `referencedFieldRef()` validates against the relation target, which only a field can resolve; cross-space references may defer the existence check when no referenced model is locally available. Both return the authored field name as a string. -`entityRef(expected, resolve)` checks existence and the selected declaration's kind. Selectors are `{ kind: 'model' }`, `{ kind: 'compositeType' }`, `{ kind: 'namedType' }`, or `{ kind: 'block', keyword }` for the contributed block keyword. Its inferred output is `ResolvedEntityReference>`: the selected `declaration` plus its lexical `namespace` (undefined at top-level), not physical storage coordinates. +`entityRef(expected)` checks existence and the selected declaration's kind. Selectors are `{ kind: 'model' }`, `{ kind: 'compositeType' }`, `{ kind: 'namedType' }`, or `{ kind: 'block', keyword }` for the contributed block keyword. Its inferred output is `ResolvedEntityReference>`: the selected `declaration` plus its lexical `namespace` (undefined at top-level), not physical storage coordinates. -Model factories bind the complete symbol table and owner identity before interpreting expressions: +Reference rules need only the expected selector when defining the grammar: ```ts -function baseSpec(ctx: AttributeSpecContext) { - const resolve = createEntityResolver({ symbols: ctx.symbols, owner: ctx.model }); - return modelAttribute('base', { - documentation: 'Declares the base model.', - positional: [ - { key: 'base', type: entityRef({ kind: 'model' }, resolve), documentation: 'The model to inherit from.' }, - ], - }); -} +const baseSpec = modelAttribute('base', { + documentation: 'Declares the base model.', + positional: [ + { key: 'base', type: entityRef({ kind: 'model' }), documentation: 'The model to inherit from.' }, + ], +}); ``` -Resolution selects the containing namespace's binding first, then top-level, never a sibling namespace; top-level owners see only top-level declarations. Kind checking applies after selection, so a wrong-kind local binding does not trigger fallback. Collection before factory interpretation permits forward references. Missing/wrong-kind failures are source-anchored shared expression diagnostics. The resolver itself emits no diagnostics, and repeated references from one resolver retain wrapper identity for collection uniqueness. +At parse time, a shared helper derives the containing namespace through `expression.syntax.findAncestor(NamespaceDeclarationAst.cast)` and selects the collected namespace by name, not red-wrapper identity. Expressions must retain their syntax ancestry and correspond to the supplied symbol table. Own-property-safe lookup selects that namespace's binding first, then top-level, never a sibling namespace; top-level expressions see only top-level declarations. Kind checking applies after selection, so a wrong-kind local binding does not trigger fallback. Collection before interpretation permits forward references. Missing/wrong-kind failures are source-anchored shared expression diagnostics. The helper itself emits no diagnostics. Wrappers are cached at the symbol-scope/declaration boundary, so repeated references retain identity across rules and list elements for collection uniqueness. -`oneOf(entityRef(expected, resolve), identifier())` prefers a checked identity and otherwise returns an unchecked name without leaking failed-alternative diagnostics. SQL and Mongo base attributes consume the selected model identity through lowering rather than re-resolving its bare name. Family-specific relationship and storage constraints remain separate: checked lookup does not promise cross-namespace inheritance support or change persisted variants. No document-path scope or codec reference combinator is added. +`oneOf(entityRef(expected), identifier())` prefers a checked identity and otherwise returns an unchecked name without leaking failed-alternative diagnostics. SQL and Mongo base attributes consume the selected model identity through lowering rather than re-resolving its bare name. Family-specific relationship and storage constraints remain separate: checked lookup does not promise cross-namespace inheritance support or change persisted variants. No document-path scope or codec reference combinator is added. -Existing attribute tooling inspects reference metadata and labels without invoking its parser/resolver. Checked references add no completion candidates or navigation feature. The parse-only editor pipeline still does not execute SQL/Mongo family reference diagnostics. +Existing attribute tooling inspects reference metadata and labels without invoking its parser. Checked references add no completion candidates or navigation feature. The parse-only editor pipeline still does not execute SQL/Mongo family reference diagnostics. ### Native collections @@ -344,7 +342,7 @@ The current implementation is sufficient for interpreter consumption but not yet ## Follow-up work - Add central spec discovery and traversable combinator metadata for language-tooling consumers. -- Declaration-bearing entity references and factory-bound resolution are implemented as described above; broader reference navigation and generic-block value consumers remain separate work. +- Declaration-bearing entity references and parse-context resolution are implemented as described above; broader reference navigation and generic-block value consumers remain separate work. - Revisit signature-derived `TypedFuncCall` output types if downstream code needs statically discriminated call unions. - Decide whether literal-to-field-type compatibility should remain in lowering or gain a dedicated field-context combinator. diff --git a/packages/1-framework/2-authoring/psl-parser/README.md b/packages/1-framework/2-authoring/psl-parser/README.md index fd9054d842fa..56bf50e86a5f 100644 --- a/packages/1-framework/2-authoring/psl-parser/README.md +++ b/packages/1-framework/2-authoring/psl-parser/README.md @@ -54,11 +54,11 @@ Interpretation/validation (for example `@internal/sql-contract-psl`) is responsi ## Checked references and identifier rules -Attribute factories can bind `createEntityResolver({ symbols, owner })` after symbol collection, then use `entityRef(expected, resolve)` to parse a checked reference. Selectors are `{ kind: 'model' }`, `{ kind: 'compositeType' }`, `{ kind: 'namedType' }`, or `{ kind: 'block', keyword }`. The result carries the selected `declaration` and lexical `namespace` (undefined at top-level), not storage coordinates. +Use `entityRef(expected)` to parse a checked reference. Every combinator parse context carries `symbols: SymbolTable` alongside `sourceId` and `sourceFile`; reference rules need no injected resolver. Selectors are `{ kind: 'model' }`, `{ kind: 'compositeType' }`, `{ kind: 'namedType' }`, or `{ kind: 'block', keyword }`. The result carries the selected `declaration` and lexical `namespace` (undefined at top-level), not storage coordinates. -Lookup selects the owner's namespace binding first, then top-level, never a sibling namespace. Kind checking applies to that selected binding; a wrong-kind local declaration does not cause fallback. Repeated references from one resolver retain wrapper identity for collection uniqueness. Resolution adds no symbols to expression parse contexts and emits no diagnostics itself; `entityRef` returns source-anchored failures. +The shared helper derives the containing namespace from the expression's syntax ancestry and selects the collected namespace by name. Expressions must stay attached to the document corresponding to the symbol table. Own-property-safe lookup selects the local binding first, then top-level, never a sibling namespace. Kind checking applies to that selected binding; a wrong-kind local declaration does not cause fallback. Wrappers are stable per symbol scope and declaration across rules and expressions, preserving collection uniqueness. Resolution emits no diagnostics itself; `entityRef` returns source-anchored failures. Declaration collection precedes interpretation, including existing block attributes, so forward references work without adding an owner or scope field to the parse context. -Use `identifier()` for unchecked names (`string`, with `name: undefined` metadata), or `identifier('Pinned', { documentation: 'Meaning of this value.' })` for a literal output and documented pinned metadata. `oneOf(entityRef(expected, resolve), identifier())` prefers a checked identity, falling back to an unchecked string without leaking failed-alternative diagnostics. Family-specific relationship support and storage lowering remain downstream responsibilities. +Use `identifier()` for unchecked names (`string`, with `name: undefined` metadata), or `identifier('Pinned', { documentation: 'Meaning of this value.' })` for a literal output and documented pinned metadata. `oneOf(entityRef(expected), identifier())` prefers a checked identity, falling back to an unchecked string without leaking failed-alternative diagnostics. Family-specific relationship support and storage lowering remain downstream responsibilities. ## Architecture 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 a7117a572d06..8b01564bf2e6 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,17 +2,16 @@ import { notOk, ok, type Result } from '@internal/utils/result'; import type { PslDiagnostic } from '../../diagnostic'; import type { DeclarationFor, - EntityResolver, EntitySelector, ResolvedEntityReference, } from '../../entity-reference'; +import { resolveEntityReference } from '../../entity-reference'; import { IdentifierAst } from '../../syntax/ast/identifier'; import type { AttributeCtx, EntityRefArgType } from '../types'; import { leafDiagnostic } from './diagnostic'; export function entityRef( expected: S, - resolve: EntityResolver, ): EntityRefArgType, AttributeCtx> { const label = `${expected.kind === 'block' ? expected.keyword : expected.kind} reference`; return { @@ -27,7 +26,7 @@ export function entityRef( if (name === undefined) { return notOk([leafDiagnostic(ctx, arg, `Expected ${label}`)]); } - const reference = resolve(name); + const reference = resolveEntityReference(arg, name, ctx.symbols); if (reference === undefined) { return notOk([leafDiagnostic(ctx, arg, `Unknown ${label} "${name}"`)]); } 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 0de475a30fb3..d4576cef638d 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 @@ -9,7 +9,7 @@ import type { ResolvedEntityReference, } from '../entity-reference'; import type { PslSources } from '../source-file'; -import type { FieldSymbol, ModelSymbol } from '../symbol-table'; +import type { FieldSymbol, ModelSymbol, SymbolTable } from '../symbol-table'; import type { ExpressionAst } from '../syntax/ast/expressions'; import type { AstNode } from '../syntax/ast-helpers'; @@ -17,6 +17,7 @@ export type AttributeLevel = 'field' | 'model' | 'block'; export interface AttributeCtx { readonly sources: PslSources; + readonly symbols: SymbolTable; } export interface ModelAttributeCtx extends AttributeCtx { diff --git a/packages/1-framework/2-authoring/psl-parser/src/block-reconstruction.ts b/packages/1-framework/2-authoring/psl-parser/src/block-reconstruction.ts index 85c94770f517..c5fe1fc960cb 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/block-reconstruction.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/block-reconstruction.ts @@ -13,6 +13,7 @@ import type { BlockAttributeSpecFactory } from './attribute-spec/spec-context'; import type { ParseDiagnostic } from './parse'; import { nodePslSpan } from './resolve'; import type { PslSources } from './source-file'; +import type { BlockSymbol, SymbolTable } from './symbol-table'; import type { ModelAttributeAst } from './syntax/ast/attributes'; import type { GenericBlockDeclarationAst, KeyValuePairAst } from './syntax/ast/declarations'; import { ArrayLiteralAst, type ExpressionAst } from './syntax/ast/expressions'; @@ -33,8 +34,7 @@ export function reconstructExtensionBlock( const blockName = node.name()?.name() ?? ''; const blockAttributes: PslExtensionBlockAttribute[] = []; - const attributes: Record = {}; - const seenAttributeNames = new Set(); + for (const attribute of node.attributes()) { const name = attribute.name()?.path().join('.') ?? ''; const args = Array.from(attribute.argList()?.args() ?? [], (arg) => { @@ -47,22 +47,6 @@ export function reconstructExtensionBlock( }); const span = nodePslSpan(attribute.syntax, sources); blockAttributes.push({ name, args, span }); - if (descriptor === undefined) continue; - const parsed = parseBlockAttribute( - attribute, - name, - span, - descriptor, - seenAttributeNames, - keyword, - blockName, - sources, - ); - if (parsed.ok) { - attributes[name] = parsed.value; - } else { - diagnostics.push(...parsed.diagnostics); - } } const parameters: Record = {}; @@ -97,11 +81,40 @@ export function reconstructExtensionBlock( name: blockName, parameters, blockAttributes, - attributes, + attributes: {}, span: nodePslSpan(node.syntax, sources), }; } +export function interpretBlockAttributes( + symbol: BlockSymbol, + descriptor: AuthoringPslBlockDescriptor, + sourceFile: SourceFile, + symbols: SymbolTable, + diagnostics: ParseDiagnostic[], +): void { + const seenNames = new Set(); + for (const attribute of symbol.node.attributes()) { + const name = attribute.name()?.path().join('.') ?? ''; + const parsed = parseBlockAttribute( + attribute, + name, + nodePslSpan(attribute.syntax, sourceFile), + descriptor, + seenNames, + symbol.keyword, + symbol.name, + sourceFile, + symbols, + ); + if (parsed.ok) { + Object.assign(symbol.block.attributes, { [name]: parsed.value }); + } else { + diagnostics.push(...parsed.diagnostics); + } + } +} + function parseBlockAttribute( attribute: ModelAttributeAst, name: string, @@ -111,6 +124,7 @@ function parseBlockAttribute( keyword: string, blockName: string, sources: PslSources, + symbols: SymbolTable, ): | { readonly ok: true; readonly value: PslExtensionBlockParsedAttribute } | { readonly ok: false; readonly diagnostics: readonly ParseDiagnostic[] } { @@ -148,7 +162,7 @@ function parseBlockAttribute( BlockAttributeSpecFactory, 'framework core cannot name AttributeSpec, so block-attribute factories transit the descriptor erased as unknown; this is the single point that restores the factory type the descriptor surface documents' >(declared[name]); - const result = interpretAttribute(attribute, factory(), { sources }); + const result = interpretAttribute(attribute, factory(), { sources, symbols }); if (!result.ok) { return { ok: false, 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 48c769837033..2b975a95dc3d 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 @@ -7,6 +7,8 @@ import type { SymbolTable, TopLevelScope, } from './symbol-table'; +import { NamespaceDeclarationAst } from './syntax/ast/declarations'; +import type { ExpressionAst } from './syntax/ast/expressions'; export type EntitySelector = | { readonly kind: 'model' } @@ -26,33 +28,62 @@ export interface ResolvedEntityReference ResolvedEntityReference | undefined; +const references = new WeakMap< + TopLevelScope | NamespaceSymbol, + WeakMap +>(); -export function createEntityResolver(input: { - readonly symbols: SymbolTable; - readonly owner: ModelSymbol | CompositeTypeSymbol | BlockSymbol; -}): EntityResolver { - const { symbols, owner } = input; - const namespace = Object.values(symbols.topLevel.namespaces).find((scope) => - declarationsIn(scope).some((declaration) => declaration === owner), - ); - const references = new Map(); - for (const declaration of declarationsIn(symbols.topLevel)) { - references.set(declaration.name, { declaration, namespace: undefined }); - } +export function resolveEntityReference( + expression: ExpressionAst, + name: string, + symbols: SymbolTable, +): ResolvedEntityReference | undefined { + const namespaceName = expression.syntax + .findAncestor(NamespaceDeclarationAst.cast) + ?.name() + ?.name(); + const namespace = + namespaceName === undefined ? undefined : ownValue(symbols.topLevel.namespaces, namespaceName); if (namespace !== undefined) { - for (const declaration of declarationsIn(namespace)) { - references.set(declaration.name, { declaration, namespace }); - } + const declaration = declarationIn(namespace, name); + if (declaration !== undefined) return referenceFor(namespace, declaration, namespace); } - return (name) => references.get(name); + const declaration = declarationIn(symbols.topLevel, name); + return declaration === undefined + ? undefined + : referenceFor(symbols.topLevel, declaration, undefined); } -function declarationsIn(scope: TopLevelScope | NamespaceSymbol): EntityDeclaration[] { - return [ - ...Object.values(scope.models), - ...Object.values(scope.compositeTypes), - ...Object.values(scope.blocks), - ...('namedTypes' in scope ? Object.values(scope.namedTypes) : []), - ]; +function ownValue(values: Readonly>, name: string): T | undefined { + return Object.hasOwn(values, name) ? values[name] : undefined; +} + +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 referenceFor( + scope: TopLevelScope | NamespaceSymbol, + declaration: EntityDeclaration, + namespace: NamespaceSymbol | undefined, +): ResolvedEntityReference { + let byDeclaration = references.get(scope); + if (byDeclaration === undefined) { + byDeclaration = new WeakMap(); + references.set(scope, byDeclaration); + } + let reference = byDeclaration.get(declaration); + if (reference === undefined) { + reference = { declaration, namespace }; + byDeclaration.set(declaration, reference); + } + return reference; } diff --git a/packages/1-framework/2-authoring/psl-parser/src/exports/index.ts b/packages/1-framework/2-authoring/psl-parser/src/exports/index.ts index 2440f87ee017..728b1cd31b90 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 @@ -105,11 +105,9 @@ export { export type { DeclarationFor, EntityDeclaration, - EntityResolver, EntitySelector, ResolvedEntityReference, } from '../entity-reference'; -export { createEntityResolver } from '../entity-reference'; export { findBlockDescriptor, validateExtensionBlockFromSymbol } from '../extension-block'; export { keywordPslSpan, 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..8203eefa592a 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 @@ -1,6 +1,6 @@ import type { AuthoringPslBlockDescriptorNamespace } from '@internal/framework-components/authoring'; import type { PslExtensionBlock, PslSpan } from '@internal/framework-components/psl-ast'; -import { reconstructExtensionBlock } from './block-reconstruction'; +import { interpretBlockAttributes, reconstructExtensionBlock } from './block-reconstruction'; import { findBlockDescriptor } from './extension-block'; import type { ParseDiagnostic } from './parse'; import { @@ -211,6 +211,14 @@ export function buildSymbolTable(options: BuildSymbolTableOptions): SymbolTableR const symbolTable: SymbolTable = { topLevel: { namespaces, namedTypes, blocks, models, compositeTypes }, }; + for (const scope of [table.topLevel, ...Object.values(namespaces)]) { + for (const block of Object.values(scope.blocks)) { + const descriptor = findBlockDescriptor(pslBlockDescriptors, block.keyword); + if (descriptor !== undefined) { + interpretBlockAttributes(block, descriptor, sourceFile, table, diagnostics); + } + } + } return { symbolTable, diagnostics }; } diff --git a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-block.test.ts b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-block.test.ts index b3c07df2e441..f8f8502d3435 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-block.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-block.test.ts @@ -13,7 +13,11 @@ function blockAttr(source: string): { node: ModelAttributeAst; ctx: AttributeCtx if (!node) throw new Error('expected a block attribute'); return { node, - ctx: { sources: new PslSources([[root, cursor.sourceFile]]) }, + ctx: { sources: new PslSources([[root, cursor.sourceFile]]), + symbols: { + topLevel: { namespaces: {}, models: {}, compositeTypes: {}, namedTypes: {}, blocks: {} }, + }, + }, }; } 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 78423add9ece..424c22bd9fef 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 @@ -14,13 +14,10 @@ import { record, str, } from '../src/exports'; -import { Cursor, parse, parseAttribute } from '../src/parse'; -import { PslSources } from '../src/source-file'; +import { parse } from '../src/parse'; import { buildSymbolTable } from '../src/symbol-table'; -import { FieldAttributeAst } from '../src/syntax/ast/attributes'; import type { ExpressionAst } from '../src/syntax/ast/expressions'; import type { SyntaxNode } from '../src/syntax/red'; -import { createSyntaxTree } from '../src/syntax/red'; class ForeignCopyOfAnAstNode { readonly syntax: SyntaxNode; @@ -30,12 +27,7 @@ class ForeignCopyOfAnAstNode { } function foreignArg(source: string): { arg: ExpressionAst; ctx: ModelAttributeCtx } { - const cursor = new Cursor('schema.prisma', `@demo(${source})`); - const root = createSyntaxTree(parseAttribute(cursor)); - const node = FieldAttributeAst.cast(root); - const value = Array.from(node?.argList()?.args() ?? [])[0]?.value(); - if (value === undefined) throw new Error('expected one argument'); - const { document, sources } = parse('model M {\n id Int @id\n}\n', 'test.psl'); + const { document, sources } = parse(`model M {\n id Int @demo(${source})\n}\n`, 'schema.prisma'); const { symbolTable } = buildSymbolTable({ documents: [document], sources, @@ -43,10 +35,14 @@ function foreignArg(source: string): { arg: ExpressionAst; ctx: ModelAttributeCt }); const selfModel = symbolTable.topLevel.models['M']; if (selfModel === undefined) throw new Error('expected model M'); + const node = selfModel.fields['id']?.node.attributes()[Symbol.iterator]().next().value; + const value = node?.argList()?.args()[Symbol.iterator]().next().value?.value(); + if (value === undefined) throw new Error('expected one argument'); return { arg: new ForeignCopyOfAnAstNode(value.syntax) as unknown as ExpressionAst, ctx: { - sources: new PslSources([[root, cursor.sourceFile]]), + sources, + symbols: symbolTable, selfModel, }, }; @@ -82,9 +78,9 @@ describe('combinators dispatch on syntax kind, not on AST class identity', () => it('entityRef accepts a node from another module copy and preserves identity', () => { const { arg, ctx } = foreignArg('M'); const reference = { declaration: ctx.selfModel, namespace: undefined }; - const result = entityRef({ kind: 'model' }, () => reference).parse(arg, ctx); + const result = entityRef({ kind: 'model' }).parse(arg, ctx); expect(result.ok).toBe(true); - if (result.ok) expect(result.value).toBe(reference); + if (result.ok) expect(result.value).toEqual(reference); }); it('funcCall accepts a node from another module copy', () => { diff --git a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.tagged-literal.test.ts b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.tagged-literal.test.ts index dd3f0c23e741..abf0eff74bf5 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 @@ -21,6 +21,7 @@ function makeCtx(sources: PslSources): FieldAttributeCtx { if (!field) throw new Error('expected field id on model M'); return { sources, + symbols: table, selfModel, field, resolveReferencedModel: () => undefined, 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 7708839b50bb..b7700bc63789 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 @@ -5,7 +5,6 @@ import type { AttributeCtx, BlockSymbol, CompositeTypeSymbol, - EntityResolver, FieldAttributeCtx, InspectableArgType, ModelAttributeCtx, @@ -42,11 +41,10 @@ test('inspectable lists and records expose ArgType children', () => { }); test('checked reference selectors and wrappers preserve inferred outputs', () => { - const resolve: EntityResolver = () => undefined; - const model = entityRef({ kind: 'model' }, resolve); - const composite = entityRef({ kind: 'compositeType' }, resolve); - const named = entityRef({ kind: 'namedType' }, resolve); - const block = entityRef({ kind: 'block', keyword: 'permission' }, resolve); + const model = entityRef({ kind: 'model' }); + const composite = entityRef({ kind: 'compositeType' }); + const named = entityRef({ kind: 'namedType' }); + const block = entityRef({ kind: 'block', keyword: 'permission' }); const names = identifier(); const optionalModel = optional(model); const models = list(model); @@ -65,10 +63,11 @@ test('checked reference selectors and wrappers preserve inferred outputs', () => ResolvedEntityReference | string >(); expectTypeOf(model.parse).parameter(1).toEqualTypeOf(); - // @ts-expect-error checked references require an expected selector and resolver + expectTypeOf().toEqualTypeOf<'sourceId' | 'sourceFile' | 'symbols'>(); + // @ts-expect-error checked references require an expected selector entityRef(); - // @ts-expect-error checked references require a resolver - entityRef({ kind: 'model' }); + // @ts-expect-error checked references do not accept injected resolvers + entityRef({ kind: 'model' }, () => undefined); }); test('identifier requires semantic value documentation', () => { @@ -451,7 +450,7 @@ test('optional wrappers retain child metadata and optional markers', () => { test('field references have distinct inspectable kinds', () => { expectTypeOf(fieldRef().kind).toEqualTypeOf<'fieldRef'>(); expectTypeOf(referencedFieldRef().kind).toEqualTypeOf<'referencedFieldRef'>(); - expectTypeOf(entityRef({ kind: 'model' }, () => undefined).kind).toEqualTypeOf<'entityRef'>(); + expectTypeOf(entityRef({ kind: 'model' }).kind).toEqualTypeOf<'entityRef'>(); }); test('runtime context metadata is rejected from arg types', () => { 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 46d058937417..dd96f2ab3eb6 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 @@ -42,6 +42,7 @@ function makeCtx(sources: PslSources): FieldAttributeCtx { if (!field) throw new Error('expected field id on model M'); return { sources, + symbols: table, selfModel, field, resolveReferencedModel: () => undefined, @@ -683,19 +684,30 @@ describe('fieldRef', () => { }); describe('entityRef', () => { + function referenceArg(value: string) { + const { document, sourceFile } = parse(`model M {\n id Int @x(${value})\n}`); + const { table } = buildSymbolTable({ document, sourceFile, pslBlockDescriptors: {} }); + const selfModel = table.topLevel.models['M']; + const field = selfModel?.fields['id']; + 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: { sourceId: 'schema.prisma', sourceFile, symbols: table, selfModel } }; + } + it('parses a bare identifier into its resolved model', () => { - const { expr, ctx } = argOf('M'); + const { expr, ctx } = referenceArg('M'); const reference = { declaration: ctx.selfModel, namespace: undefined }; - const result = entityRef({ kind: 'model' }, () => reference).parse(expr, ctx); + const result = entityRef({ kind: 'model' }).parse(expr, ctx); expect(result.ok).toBe(true); - if (result.ok) expect(result.value).toBe(reference); + if (result.ok) expect(result.value).toEqual(reference); }); it('rejects a quoted string literal', () => { - const { expr, ctx } = argOf('"Task"'); + const { expr, ctx } = referenceArg('"Task"'); - const result = entityRef({ kind: 'model' }, () => undefined).parse(expr, ctx); + const result = entityRef({ kind: 'model' }).parse(expr, ctx); expect(result.ok).toBe(false); if (!result.ok) { @@ -705,18 +717,18 @@ describe('entityRef', () => { }); it('rejects a number token', () => { - const { expr, ctx } = argOf('42'); + const { expr, ctx } = referenceArg('42'); - const result = entityRef({ kind: 'model' }, () => undefined).parse(expr, ctx); + const result = entityRef({ kind: 'model' }).parse(expr, ctx); expect(result.ok).toBe(false); if (!result.ok) expect(result.failure).toHaveLength(1); }); it('rejects an array literal', () => { - const { expr, ctx } = argOf('[Task]'); + const { expr, ctx } = referenceArg('[Task]'); - const result = entityRef({ kind: 'model' }, () => undefined).parse(expr, ctx); + const result = entityRef({ kind: 'model' }).parse(expr, ctx); expect(result.ok).toBe(false); if (!result.ok) expect(result.failure).toHaveLength(1); diff --git a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-documentation.test.ts b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-documentation.test.ts index 6cafb4e96d53..fa51d931257d 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-documentation.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-documentation.test.ts @@ -63,6 +63,9 @@ describe('declaration documentation', () => { if (!node) throw new Error('expected a block attribute'); const result = interpretAttribute(node, spec, { sources: new PslSources([[root, cursor.sourceFile]]), + symbols: { + topLevel: { namespaces: {}, models: {}, compositeTypes: {}, namedTypes: {}, blocks: {} }, + }, }); expect(result.assertOk()).toStrictEqual({ value: { 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..641397ab193e 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 @@ -30,6 +30,7 @@ function makeCtx(sources: PslSources): FieldAttributeCtx { if (!field) throw new Error('expected field id on model M'); return { sources, + symbols: table, selfModel, field, resolveReferencedModel: () => undefined, 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 27ae80aa1557..0ea626923ffe 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,118 +1,156 @@ import { ok } from '@internal/utils/result'; import { describe, expect, it } from 'vitest'; import type { EntitySelector } from '../src/exports'; -import { createEntityResolver, entityRef, identifier, list, oneOf } from '../src/exports'; -import { Cursor, parse, parseAttribute } from '../src/parse'; +import { blockAttribute, entityRef, identifier, list, oneOf } from '../src/exports'; +import { parse } from '../src/parse'; import { buildSymbolTable } from '../src/symbol-table'; -import { FieldAttributeAst } from '../src/syntax/ast/attributes'; -import { createSyntaxTree } from '../src/syntax/red'; - -function symbols(source: string) { - const { document, sourceFile } = parse(source); - const result = buildSymbolTable({ document, sourceFile, pslBlockDescriptors: {} }); - expect(result.diagnostics).toEqual([]); - return result.table; -} - -function argument(source: string) { - const cursor = new Cursor(`@test(${source})`); - const attribute = FieldAttributeAst.cast(createSyntaxTree(parseAttribute(cursor))); - const expression = [...(attribute?.argList()?.args() ?? [])][0]?.value(); - if (!expression) throw new Error('Missing expression'); - return { expression, ctx: { sourceId: 'references.prisma', sourceFile: cursor.sourceFile } }; -} +import { ModelAttributeAst } from '../src/syntax/ast/attributes'; +import { IdentifierAst } from '../src/syntax/ast/identifier'; +import { SyntaxNode } from '../src/syntax/red'; -const declarations = [ - 'model Owner {}', - 'model Shared {}', - 'model Global {}', - 'model Fallback {}', - 'type Address {}', - 'types { Email = String }', - 'permission Reader {}', - 'namespace Local {\n model Owner {}\n model Shared {}\n type Global {}\n permission Writer {}\n}', - 'namespace Sibling {\n model Hidden {}\n model Shared {}\n}', -]; - -function fixture(reverse = false) { - const table = symbols((reverse ? [...declarations].reverse() : declarations).join('\n')); - const namespace = table.topLevel.namespaces['Local']; - const owner = namespace?.models['Owner']; - const topOwner = table.topLevel.models['Owner']; - if (!namespace || !owner || !topOwner) throw new Error('Missing owner'); - return { - table, - namespace, - resolve: createEntityResolver({ symbols: table, owner }), - topResolve: createEntityResolver({ symbols: table, owner: topOwner }), - }; -} - -describe('factory-bound entity resolution', () => { - it.each([false, true])( - 'selects lexical identities independently of declaration order (%s)', - (reverse) => { - const { table, namespace, resolve, topResolve } = fixture(reverse); - expect(resolve('Shared')).toEqual({ declaration: namespace.models['Shared'], namespace }); - expect(resolve('Shared')?.declaration).toBe(namespace.models['Shared']); - expect(resolve('Address')).toEqual({ - declaration: table.topLevel.compositeTypes['Address'], - namespace: undefined, - }); - expect(resolve('Email')?.declaration).toBe(table.topLevel.namedTypes['Email']); - expect(resolve('Fallback')?.declaration).toBe(table.topLevel.models['Fallback']); - expect(resolve('Fallback')?.namespace).toBeUndefined(); - expect(resolve('Hidden')).toBeUndefined(); - expect(topResolve('Shared')?.declaration).toBe(table.topLevel.models['Shared']); - expect(topResolve('Writer')).toBeUndefined(); - expect(resolve('Missing')).toBeUndefined(); - expect(resolve('toString')).toBeUndefined(); - expect(resolve('Shared')).toBe(resolve('Shared')); - }, +function fixture(value: string, local = true, reverse = false) { + const members = [ + ...(local ? [`model Owner {\n @@test(${value})\n}`] : []), + 'model Shared {}', + 'type Global {}', + 'permission Writer {}', + ]; + const declarations = [ + 'model Shared {}', + 'model Global {}', + 'model Fallback {}', + 'type Address {}', + 'types { Email = String }', + 'permission Reader {}', + `namespace Local {\n${(reverse ? members.reverse() : members).join('\n')}\n}`, + 'namespace Sibling {\n model Hidden {}\n model Shared {}\n}', + ...(local ? [] : [`model Owner {\n @@test(${value})\n}`]), + ]; + const { document, sourceFile } = parse( + (reverse ? declarations.reverse() : declarations).join('\n'), ); - - it('binds composite and block owners by identity rather than matching top-level names', () => { - const { table, namespace } = fixture(); - const composite = namespace.compositeTypes['Global']; - const block = namespace.blocks['Writer']; - if (!composite || !block) throw new Error('Missing owner'); - for (const owner of [composite, block]) { - const resolve = createEntityResolver({ symbols: table, owner }); - expect(resolve('Shared')?.declaration).toBe(namespace.models['Shared']); - } + const { table, diagnostics } = buildSymbolTable({ + document, + sourceFile, + pslBlockDescriptors: {}, }); + expect(diagnostics).toEqual([]); + const namespace = table.topLevel.namespaces['Local']; + if (!namespace) throw new Error('Missing namespace'); + for (const syntax of document.syntax.descendants()) { + if (!(syntax instanceof SyntaxNode)) continue; + const attribute = ModelAttributeAst.cast(syntax); + const expression = attribute?.argList()?.args()[Symbol.iterator]().next().value?.value(); + if (expression) + return { + expression, + ctx: { sourceId: 'references.prisma', sourceFile, symbols: table }, + table, + namespace, + }; + } + throw new Error('Missing expression'); +} - it('rejects the selected wrong-kind shadow instead of falling back', () => { - const { resolve, namespace } = fixture(); - const { expression, ctx } = argument('Global'); - expect(resolve('Global')?.declaration).toBe(namespace.compositeTypes['Global']); - expect(entityRef({ kind: 'model' }, resolve).parse(expression, ctx)).toMatchObject({ - ok: false, - failure: [ - { - message: 'Expected model reference "Global", found compositeType', - sourceId: 'references.prisma', - span: { start: { offset: 6 }, end: { offset: 12 } }, - }, +describe('syntax-scoped entity resolution', () => { + it('supplies the completed table to existing block attribute rules', () => { + const { document, sourceFile } = parse( + 'namespace Local {\n permission Reader {\n @@target(Later)\n }\n model Later {}\n}', + ); + const target = blockAttribute('target', { + documentation: 'Names a model.', + positional: [ + { key: 'model', type: entityRef({ kind: 'model' }), documentation: 'The selected model.' }, ], }); + const result = buildSymbolTable({ + document, + sourceFile, + pslBlockDescriptors: { + permission: { + name: { required: true }, + kind: 'pslBlock', + keyword: 'permission', + discriminator: 'permission', + parameters: {}, + attributes: { target: () => target }, + }, + }, + }); + expect(result.diagnostics).toEqual([]); + const namespace = result.table.topLevel.namespaces['Local']; + expect(namespace?.blocks['Reader']?.block.attributes['target']?.args).toEqual({ + model: { declaration: namespace?.models['Later'], namespace }, + }); + }); + it.each([false, true])('selects local declarations regardless of order (%s)', (reverse) => { + const { expression, ctx, namespace } = fixture('Shared', true, reverse); + expect(entityRef({ kind: 'model' }).parse(expression, ctx)).toEqual( + ok({ + declaration: namespace.models['Shared'], + namespace, + }), + ); }); -}); -describe('checked references and unchecked identifiers', () => { it.each<[EntitySelector, string]>([ - [{ kind: 'model' }, 'Shared'], + [{ kind: 'model' }, 'Fallback'], [{ kind: 'compositeType' }, 'Address'], [{ kind: 'namedType' }, 'Email'], [{ kind: 'block', keyword: 'permission' }, 'Reader'], - ])('returns the selected identity for %j', (selector, name) => { - const { topResolve } = fixture(); - const { expression, ctx } = argument(name); - const rule = entityRef(selector, topResolve); + ])('falls back to top-level %j', (selector, name) => { + const { expression, ctx } = fixture(name); + const rule = entityRef(selector); expect(rule.expected).toEqual(selector); - const result = rule.parse(expression, ctx); - expect(result.ok).toBe(true); - if (result.ok) expect(result.value).toBe(topResolve(name)); + expect(rule.parse(expression, ctx)).toMatchObject({ + ok: true, + value: { declaration: { name, kind: selector.kind }, namespace: undefined }, + }); + }); + + it('selects only top-level declarations outside namespaces', () => { + const { expression, ctx, table } = fixture('Shared', false); + expect(entityRef({ kind: 'model' }).parse(expression, ctx)).toEqual( + ok({ + declaration: table.topLevel.models['Shared'], + namespace: undefined, + }), + ); + }); + + it.each(['Hidden', 'Missing', 'toString', 'constructor', '__proto__'])( + 'rejects unavailable and inherited names: %s', + (name) => { + const { expression, ctx } = fixture(name); + expect(entityRef({ kind: 'model' }).parse(expression, ctx)).toMatchObject({ + ok: false, + failure: [ + { + message: `Unknown model reference "${name}"`, + sourceId: 'references.prisma', + span: { + start: { offset: expression.syntax.offset }, + end: { offset: expression.syntax.endOffset }, + }, + }, + ], + }); + }, + ); + + it('does not search child namespaces from top-level', () => { + const { expression, ctx } = fixture('Writer', false); + expect(entityRef({ kind: 'block', keyword: 'permission' }).parse(expression, ctx).ok).toBe( + false, + ); + }); + + it('checks kind after selecting the local binding', () => { + const { expression, ctx } = fixture('Global'); + expect(entityRef({ kind: 'model' }).parse(expression, ctx)).toMatchObject({ + ok: false, + failure: [{ message: 'Expected model reference "Global", found compositeType' }], + }); }); it.each([ @@ -121,71 +159,83 @@ describe('checked references and unchecked identifiers', () => { { kind: 'namedType' }, { kind: 'block', keyword: 'permission' }, ])('rejects a different declaration kind for %j', (selector) => { - const { topResolve } = fixture(); - const name = selector.kind === 'model' ? 'Address' : 'Shared'; - const { expression, ctx } = argument(name); - const result = entityRef(selector, topResolve).parse(expression, ctx); + const { expression, ctx } = fixture(selector.kind === 'model' ? 'Address' : 'Shared'); + const result = entityRef(selector).parse(expression, ctx); expect(result.ok).toBe(false); if (!result.ok) expect(result.failure).toHaveLength(1); }); + it('reuses one grammar across documents without retaining another scope', () => { + const rule = entityRef({ kind: 'model' }); + const local = fixture('Shared'); + const top = fixture('Shared', false); + expect(rule.parse(local.expression, local.ctx)).toEqual( + ok({ + declaration: local.namespace.models['Shared'], + namespace: local.namespace, + }), + ); + expect(rule.parse(top.expression, top.ctx)).toEqual( + ok({ + declaration: top.table.topLevel.models['Shared'], + namespace: undefined, + }), + ); + }); + it('checks contributed block keywords', () => { - const { topResolve } = fixture(); - const { expression, ctx } = argument('Reader'); - expect( - entityRef({ kind: 'block', keyword: 'other' }, topResolve).parse(expression, ctx), - ).toMatchObject({ + const { expression, ctx } = fixture('Reader'); + expect(entityRef({ kind: 'block', keyword: 'other' }).parse(expression, ctx)).toMatchObject({ ok: false, failure: [{ message: 'Expected other reference "Reader", found permission' }], }); }); - it('anchors missing references at the expression', () => { - const { resolve } = fixture(); - const { expression, ctx } = argument('Missing'); - expect(entityRef({ kind: 'model' }, resolve).parse(expression, ctx)).toMatchObject({ + it('shares wrapper identity across rules and repeated expressions', () => { + const { expression, ctx } = fixture('[Shared, Shared]'); + expect( + list(entityRef({ kind: 'model' }), { unique: true }).parse(expression, ctx), + ).toMatchObject({ ok: false, - failure: [ - { - code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', - message: 'Unknown model reference "Missing"', - sourceId: 'references.prisma', - span: { start: { offset: 6 }, end: { offset: 13 } }, - }, - ], + failure: [{ message: 'Duplicate list entry' }], }); + const single = fixture('Shared'); + const first = entityRef({ kind: 'model' }).parse(single.expression, single.ctx); + const second = entityRef({ kind: 'model' }).parse(single.expression, single.ctx); + if (!first.ok || !second.ok) throw new Error('Missing reference'); + expect(first.value).toBe(second.value); }); - it('preserves wrapper identity so repeated list entries fail uniqueness', () => { - const { resolve } = fixture(); - const rule = list(entityRef({ kind: 'model' }, resolve), { unique: true }); - const { expression, ctx } = argument('[Shared, Shared]'); - expect(rule.parse(expression, ctx)).toMatchObject({ - ok: false, - failure: [{ message: 'Duplicate list entry' }], - }); - const distinct = argument('[Shared, Owner]'); - expect(rule.parse(distinct.expression, distinct.ctx)).toEqual( - ok([resolve('Shared'), resolve('Owner')]), + it('accepts structural expressions from another AST copy', () => { + const { expression, ctx, namespace } = fixture('Shared'); + const identifier = IdentifierAst.cast(expression.syntax); + if (!identifier) throw new Error('Missing identifier'); + const foreign = { + syntax: identifier.syntax, + name: () => identifier.name(), + token: () => identifier.token(), + }; + expect(foreign).not.toBeInstanceOf(IdentifierAst); + expect(entityRef({ kind: 'model' }).parse(foreign, ctx)).toEqual( + ok({ + declaration: namespace.models['Shared'], + namespace, + }), ); }); +}); - it.each(['Shared', 'Missing', 'Global'])( - 'keeps reference-first alternatives diagnostic-pure for %s', - (name) => { - const { resolve } = fixture(); - const rule = oneOf(entityRef({ kind: 'model' }, resolve), identifier()); - const { expression, ctx } = argument(name); - expect(rule.parse(expression, ctx)).toEqual(ok(name === 'Shared' ? resolve(name) : name)); - }, - ); +describe('checked references and unchecked identifiers', () => { + it.each(['Shared', 'Missing', 'Global'])('keeps alternatives diagnostic-pure for %s', (name) => { + const { expression, ctx, namespace } = fixture(name); + expect(oneOf(entityRef({ kind: 'model' }), identifier()).parse(expression, ctx)).toEqual( + ok(name === 'Shared' ? { declaration: namespace.models['Shared'], namespace } : name), + ); + }); it('aggregates all-failure alternatives at their source', () => { - const { resolve } = fixture(); - const { expression, ctx } = argument('42'); - expect( - oneOf(entityRef({ kind: 'model' }, resolve), identifier()).parse(expression, ctx), - ).toMatchObject({ + const { expression, ctx } = fixture('42'); + expect(oneOf(entityRef({ kind: 'model' }), identifier()).parse(expression, ctx)).toMatchObject({ ok: false, failure: [ { message: 'Expected one of: model reference | identifier', sourceId: 'references.prisma' }, @@ -193,8 +243,8 @@ describe('checked references and unchecked identifiers', () => { }); }); - it('exposes unrestricted metadata and preserves exact names', () => { - const { expression, ctx } = argument('External'); + it('preserves unrestricted identifier metadata and names', () => { + const { expression, ctx } = fixture('External'); expect(identifier()).toMatchObject({ kind: 'identifier', name: undefined, @@ -204,11 +254,10 @@ describe('checked references and unchecked identifiers', () => { }); it.each(['"Shared"', '42', '[Shared]', 'Shared()', 'true'])( - 'rejects non-identifier syntax %s', - (source) => { - const { resolve } = fixture(); - const { expression, ctx } = argument(source); - expect(entityRef({ kind: 'model' }, resolve).parse(expression, ctx).ok).toBe(false); + 'rejects non-identifiers: %s', + (value) => { + const { expression, ctx } = fixture(value); + expect(entityRef({ kind: 'model' }).parse(expression, ctx).ok).toBe(false); expect(identifier().parse(expression, ctx).ok).toBe(false); }, ); diff --git a/packages/1-framework/3-tooling/language-server/test/completion-values.test.ts b/packages/1-framework/3-tooling/language-server/test/completion-values.test.ts index d68ad5e4ad94..3da3e68fe67b 100644 --- a/packages/1-framework/3-tooling/language-server/test/completion-values.test.ts +++ b/packages/1-framework/3-tooling/language-server/test/completion-values.test.ts @@ -46,7 +46,7 @@ const rejecting: RejectingArgType = { parse: rejectedParse, }; const unchecked = { ...identifier(), parse: rejectedParse }; -const checked = entityRef({ kind: 'model' }, rejectedParse); +const checked = { ...entityRef({ kind: 'model' }), parse: rejectedParse }; const direction = oneOf( identifier('Asc', { documentation: 'An accepted identifier in this test grammar.' }), identifier('Desc', { documentation: 'An accepted identifier in this test grammar.' }), diff --git a/packages/1-framework/3-tooling/language-server/test/signature-help-values.test.ts b/packages/1-framework/3-tooling/language-server/test/signature-help-values.test.ts index 1635c577ecae..4ab13b9b380b 100644 --- a/packages/1-framework/3-tooling/language-server/test/signature-help-values.test.ts +++ b/packages/1-framework/3-tooling/language-server/test/signature-help-values.test.ts @@ -17,8 +17,9 @@ import { expect, it, vi } from 'vitest'; import { MarkupKind } from 'vscode-languageserver'; import { providePslSignatureHelp } from '../src/signature-help'; -const resolveReference = vi.fn(() => undefined); -const reference = entityRef({ kind: 'model' }, resolveReference); +const referenceRule = entityRef({ kind: 'model' }); +const parseReference = vi.fn(referenceRule.parse); +const reference = { ...referenceRule, parse: parseReference }; const ascending = identifier('Asc', { documentation: 'Sort ascending.' }); const parseIdentifier = vi.fn(ascending.parse); const asc = { ...ascending, parse: parseIdentifier }; @@ -85,6 +86,6 @@ it.each([ : '**value**\n\nThe declared value.', }); expect(parseIdentifier).not.toHaveBeenCalled(); - expect(resolveReference).not.toHaveBeenCalled(); + expect(parseReference).not.toHaveBeenCalled(); }, ); 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 bfefbf3d155d..12909f9723ca 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 @@ -222,6 +222,7 @@ function resolveFieldMappings(input: { const mapped = (mapNode ? interpretFieldAttribute({ + symbols: specContext.symbols, node: mapNode, spec: mongoAttributeSpecs.field.map({ ...specContext, field }), model, @@ -245,6 +246,7 @@ function resolveCollectionName(input: { const mapNode = findModelAttributeNode(model, 'map'); const name = mapNode ? interpretModelAttribute({ + symbols: specContext.symbols, node: mapNode, spec: mongoAttributeSpecs.model.map(specContext), model, @@ -299,6 +301,7 @@ function collectPolymorphismDeclarations( const discNode = findModelAttributeNode(model, 'discriminator'); if (discNode) { const parsed = interpretModelAttribute({ + symbols: specContext.symbols, node: discNode, spec: mongoAttributeSpecs.model.discriminator(specContext), model, @@ -327,8 +330,9 @@ function collectPolymorphismDeclarations( const baseNode = findModelAttributeNode(model, 'base'); if (baseNode) { const parsed = interpretModelAttribute({ + symbols: specContext.symbols, node: baseNode, - spec: mongoAttributeSpecs.model.base(specContext), + spec: mongoAttributeSpecs.model.base(), model, sources, diagnostics, @@ -871,6 +875,7 @@ function collectIndexes( const uniqueNode = findFieldAttributeNode(field, 'unique'); if (!uniqueNode) continue; const unique = interpretFieldAttribute({ + symbols: specContext.symbols, node: uniqueNode, spec: mongoAttributeSpecs.field.unique({ ...specContext, field }), model: pslModel, @@ -907,6 +912,7 @@ function collectIndexes( let index: MongoIndex | undefined; if (attr.name === 'textIndex') { const parsed = interpretModelAttribute({ + symbols: specContext.symbols, node, spec: mongoAttributeSpecs.model.textIndex(specContext), model: pslModel, @@ -927,6 +933,7 @@ function collectIndexes( } else { const unique = attr.name === 'unique'; const parsed = interpretModelAttribute({ + symbols: specContext.symbols, node, spec: unique ? mongoAttributeSpecs.model.unique(specContext) @@ -1178,6 +1185,7 @@ export function interpretPslDocumentToMongoContract( const relationNode = findFieldAttributeNode(field, 'relation'); const relation = relationNode ? interpretFieldAttribute({ + symbols: specContext.symbols, node: relationNode, spec: mongoAttributeSpecs.field.relation({ ...specContext, field }), model: pslModel, @@ -1267,6 +1275,7 @@ export function interpretPslDocumentToMongoContract( if (!idNode) return false; return ( interpretFieldAttribute({ + symbols: specContext.symbols, node: idNode, spec: mongoAttributeSpecs.field.id({ ...specContext, field }), model: pslModel, 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 b7623f56dcad..0f319faa5db6 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 @@ -10,11 +10,11 @@ import type { InferAttr, ModelAttributeCtx, ModelSymbol, + SymbolTable, TypedFuncCall, } from '@internal/psl-parser'; import { bool, - createEntityResolver, entityRef, fieldAttribute, fieldRef, @@ -56,16 +56,19 @@ export function findFieldAttributeNode( } function buildModelAttributeCtx(input: { + readonly symbols: SymbolTable; readonly selfModel: ModelSymbol; readonly sources: PslSources; }): ModelAttributeCtx { return { sources: input.sources, selfModel: input.selfModel, + symbols: input.symbols, }; } function buildFieldAttributeCtx(input: { + readonly symbols: SymbolTable; readonly selfModel: ModelSymbol; readonly field: FieldSymbol; readonly sources: PslSources; @@ -76,6 +79,7 @@ function buildFieldAttributeCtx(input: { selfModel: input.selfModel, resolveReferencedModel: input.resolveReferencedModel ?? (() => undefined), field: input.field, + symbols: input.symbols, }; } @@ -83,6 +87,7 @@ function buildFieldAttributeCtx(input: { // failures into `diagnostics`. Returns the typed value, or `undefined` on // failure so the caller can apply its own default/absence handling. export function interpretModelAttribute(input: { + readonly symbols: SymbolTable; readonly node: ModelAttributeAst; readonly spec: AttributeSpec; readonly model: ModelSymbol; @@ -93,6 +98,7 @@ export function interpretModelAttribute(input: { input.node, input.spec, buildModelAttributeCtx({ + symbols: input.symbols, selfModel: input.model, sources: input.sources, }), @@ -108,6 +114,7 @@ export function interpretModelAttribute(input: { // failures into `diagnostics`. Returns the typed value, or `undefined` on // failure so the caller can apply its own default/absence handling. export function interpretFieldAttribute(input: { + readonly symbols: SymbolTable; readonly node: FieldAttributeAst; readonly spec: AttributeSpec; readonly model: ModelSymbol; @@ -120,6 +127,7 @@ export function interpretFieldAttribute(input: { input.node, input.spec, buildFieldAttributeCtx({ + symbols: input.symbols, selfModel: input.model, field: input.field, sources: input.sources, @@ -184,14 +192,13 @@ export const discriminatorModelSpec = modelAttribute('discriminator', { { key: 'field', type: fieldRef(), documentation: 'The discriminator field on this model.' }, ], }); -export function baseModelSpec(ctx: AttributeSpecContext) { - const resolve = createEntityResolver({ symbols: ctx.symbols, owner: ctx.model }); +export function baseModelSpec() { return modelAttribute('base', { documentation: 'Declares this model as a variant of a base model.', positional: [ { key: 'base', - type: entityRef({ kind: 'model' }, resolve), + type: entityRef({ kind: 'model' }), documentation: 'The base model to inherit from.', }, { 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 2db4084bb7af..bdae14c8d067 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 @@ -116,11 +116,11 @@ model Base { id String }`); if (!model) throw new Error('missing variant'); const node = findModelAttributeNode(model, 'base'); if (!node) throw new Error('missing base'); - const ctx = { ...contexts().model, symbols: table, model }; const diagnostics: ContractSourceDiagnostic[] = []; const value = interpretModelAttribute({ node, - spec: mongoAttributeSpecs.model.base(ctx), + symbols: table, + spec: mongoAttributeSpecs.model.base(), model, sourceFile, sourceId: 'test.prisma', 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 f106b9fc3e75..8de0c8e3edb6 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts @@ -37,7 +37,6 @@ import type { TargetPackRef, } from '@internal/framework-components/components'; import type { - ControlDefaultRegistries, ControlMutationDefaultRegistry, ControlMutationDefaults, MutationDefaultGeneratorDescriptor, @@ -934,6 +933,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult node, spec: sqlAttributeSpecs.model.control(), model, + symbols: input.symbolTable, sources: input.sources, diagnostics, }); @@ -969,6 +969,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult node, spec: sqlAttributeSpecs.model.id(), model, + symbols: input.symbolTable, sources: input.sources, diagnostics, }); @@ -1013,6 +1014,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult node, spec: sqlAttributeSpecs.model.unique(), model, + symbols: input.symbolTable, sources: input.sources, diagnostics, }); @@ -1046,6 +1048,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult node, spec: sqlAttributeSpecs.model.index(), model, + symbols: input.symbolTable, sources: input.sources, diagnostics, }); @@ -1105,6 +1108,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult node, spec: sqlAttributeSpecs.model.check(), model, + symbols: input.symbolTable, sources: input.sources, diagnostics, }); @@ -1154,6 +1158,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult }, }), model, + symbols: input.symbolTable, sources: input.sources, diagnostics, }); @@ -1703,7 +1708,6 @@ type BaseDeclaration = { function collectPolymorphismDeclarations( identities: ReadonlyMap, symbols: SymbolTable, - controlMutationDefaults: ControlDefaultRegistries, sources: PslSources, diagnostics: PslDiagnosticCollector, ): { @@ -1719,6 +1723,7 @@ function collectPolymorphismDeclarations( if (discriminatorNode !== undefined) { const parsed = interpretModelAttribute({ node: discriminatorNode, + symbols, spec: sqlAttributeSpecs.model.discriminator(), model, sources, @@ -1743,7 +1748,8 @@ function collectPolymorphismDeclarations( if (baseNode !== undefined) { const parsed = interpretModelAttribute({ node: baseNode, - spec: sqlAttributeSpecs.model.base({ symbols, model, controlMutationDefaults }), + symbols, + spec: sqlAttributeSpecs.model.base(), model, sources, diagnostics, @@ -2292,6 +2298,7 @@ export function interpretPslDocumentToSqlContract( // `modelMappingsByCoordinate` further down; this call discards its own // diagnostics so nothing is reported twice. const earlyModelMappingsByCoordinate = buildModelMappings( + input.symbolTable, modelEntries, defaultNamespaceId, createPslDiagnosticCollector(input.sources), @@ -2442,6 +2449,7 @@ export function interpretPslDocumentToSqlContract( const storageTypes = { ...namedTypeResult.storageTypes }; const modelMappingsByCoordinate = buildModelMappings( + input.symbolTable, modelEntries, defaultNamespaceId, diagnostics, @@ -2584,7 +2592,6 @@ export function interpretPslDocumentToSqlContract( const { discriminatorDeclarations, baseDeclarations } = collectPolymorphismDeclarations( modelIdentities, input.symbolTable, - { defaultFunctionRegistry, defaultLiteralTagRegistry }, input.sources, diagnostics, ); 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 a69cdb9abae1..fb572004d684 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 @@ -789,6 +789,7 @@ export function lowerDefaultForField(input: { }), ); const interpreted = interpretFieldAttribute({ + symbols: input.symbolTable, node, spec, model: input.model, 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 ecd14a416880..8288e0287805 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 @@ -79,6 +79,7 @@ function lowerEnumDefaultForField(input: { const interpreted = interpretFieldAttribute({ node, spec, + symbols: input.symbolTable, model, field, sources: input.sources, @@ -270,6 +271,7 @@ function validateFieldAttributes(input: { } function extractFieldConstraintNames(input: { + readonly symbolTable: SymbolTable; readonly model: ModelSymbol; readonly field: FieldSymbol; readonly sources: PslSources; @@ -288,6 +290,7 @@ function extractFieldConstraintNames(input: { ? undefined : interpretFieldAttribute({ node: idNode, + symbols: input.symbolTable, spec: sqlAttributeSpecs.field.id(), model: input.model, field: input.field, @@ -300,6 +303,7 @@ function extractFieldConstraintNames(input: { ? undefined : interpretFieldAttribute({ node: uniqueNode, + symbols: input.symbolTable, spec: sqlAttributeSpecs.field.unique(), model: input.model, field: input.field, @@ -321,6 +325,7 @@ type NoCheckKind = 'membership' | 'elementNotNull'; * the only form the definition tree carries. */ function lowerNoCheckForField(input: { + readonly symbolTable: SymbolTable; readonly model: ModelSymbol; readonly field: FieldSymbol; readonly sources: PslSources; @@ -333,6 +338,7 @@ function lowerNoCheckForField(input: { const interpreted = interpretFieldAttribute({ node, spec: sqlAttributeSpecs.field.noCheck(), + symbols: input.symbolTable, model: input.model, field: input.field, sources: input.sources, @@ -628,6 +634,7 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv } const mappedColumnName = mapping.fieldColumns.get(field.name) ?? field.name; const { idAttribute, uniqueAttribute, idName, uniqueName } = extractFieldConstraintNames({ + symbolTable: input.symbolTable, model, field, sources: input.sources, @@ -673,6 +680,7 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv const fieldDefaultValue = presetContributions?.default ?? loweredDefault.defaultValue; const noCheckKinds = modelDerivesChecks ? lowerNoCheckForField({ + symbolTable: input.symbolTable, model, field, sources: input.sources, @@ -707,6 +715,7 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv } export function buildModelMappings( + symbols: SymbolTable, modelEntries: readonly ModelNamespaceEntry[], defaultNamespaceId: string, diagnostics: PslDiagnosticCollector, @@ -720,6 +729,7 @@ export function buildModelMappings( ? defaultTableName(model.name) : (interpretModelAttribute({ node: mapNode, + symbols, spec: sqlAttributeSpecs.model.map(), model, sources, @@ -733,6 +743,7 @@ export function buildModelMappings( ? field.name : (interpretFieldAttribute({ node: fieldMapNode, + symbols, spec: sqlAttributeSpecs.field.map(), model, field, 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..c769c953d27f 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 @@ -96,6 +96,7 @@ export function interpretRelationAttribute(input: { const node = findFieldAttributeNode(input.field, 'relation'); if (node === undefined) return undefined; return interpretFieldAttribute({ + symbols: input.symbols, node, spec: sqlAttributeSpecs.field.relation(), model: input.selfModel, 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 2d8de8c2fcb0..72fd6eff8d30 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 @@ -24,7 +24,6 @@ import type { import { bool, diagnosticSource, - createEntityResolver, entityRef, fieldAttribute, fieldRef, @@ -74,16 +73,19 @@ export function findFieldAttributeNode( } function buildModelAttributeCtx(input: { + readonly symbols: SymbolTable; readonly selfModel: ModelSymbol; readonly sources: PslSources; }): ModelAttributeCtx { return { sources: input.sources, selfModel: input.selfModel, + symbols: input.symbols, }; } function buildFieldAttributeCtx(input: { + readonly symbols: SymbolTable; readonly selfModel: ModelSymbol; readonly field: FieldSymbol; readonly sources: PslSources; @@ -94,6 +96,7 @@ function buildFieldAttributeCtx(input: { selfModel: input.selfModel, resolveReferencedModel: input.resolveReferencedModel ?? (() => undefined), field: input.field, + symbols: input.symbols, }; } @@ -101,6 +104,7 @@ function buildFieldAttributeCtx(input: { // failures into `diagnostics`. Returns the typed value, or `undefined` on // failure so the caller can apply its own default/absence handling. export function interpretModelAttribute(input: { + readonly symbols: SymbolTable; readonly node: ModelAttributeAst; readonly spec: AttributeSpec; readonly model: ModelSymbol; @@ -111,6 +115,7 @@ export function interpretModelAttribute(input: { input.node, input.spec, buildModelAttributeCtx({ + symbols: input.symbols, selfModel: input.model, sources: input.sources, }), @@ -126,6 +131,7 @@ export function interpretModelAttribute(input: { // failures into `diagnostics`. Returns the typed value, or `undefined` on // failure so the caller can apply its own default/absence handling. export function interpretFieldAttribute(input: { + readonly symbols: SymbolTable; readonly node: FieldAttributeAst; readonly spec: AttributeSpec; readonly model: ModelSymbol; @@ -138,6 +144,7 @@ export function interpretFieldAttribute(input: { input.node, input.spec, buildFieldAttributeCtx({ + symbols: input.symbols, selfModel: input.model, field: input.field, sources: input.sources, @@ -520,14 +527,13 @@ const discriminatorModelSpec = modelAttribute('discriminator', { { key: 'field', type: fieldRef(), documentation: 'The discriminator field on this model.' }, ], }); -function baseModelSpec(ctx: AttributeSpecContext) { - const resolve = createEntityResolver({ symbols: ctx.symbols, owner: ctx.model }); +function baseModelSpec() { return modelAttribute('base', { documentation: 'Declares this model as a variant of a base model.', positional: [ { key: 'base', - type: entityRef({ kind: 'model' }, resolve), + type: entityRef({ kind: 'model' }), documentation: 'The base model to inherit from.', }, { 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 2c4cf429b443..cf1c4cc1ca12 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 @@ -108,6 +108,7 @@ function interpretDefault(schema: string, fieldName: string) { if (node === undefined) throw new Error('no @default on field'); const diagnostics = createPslDiagnosticCollector(sources); const value = interpretFieldAttribute({ + symbols: symbolTable, node, spec: sqlAttributeSpecs.field.default( fieldSpecContext({ symbols: symbolTable, model, field: target, controlMutationDefaults }), @@ -135,9 +136,8 @@ namespace scoped { const diagnostics: ContractSourceDiagnostic[] = []; const value = interpretModelAttribute({ node, - spec: sqlAttributeSpecs.model.base( - modelSpecContext({ symbols: input.symbolTable, model, controlMutationDefaults }), - ), + symbols: input.symbolTable, + spec: sqlAttributeSpecs.model.base(), model, sourceFile: input.sourceFile, sourceId: input.sourceId, From e4243a10cf23ef11cb7c7254b03b381c39dad23f Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 18 Sep 2026 12:51:17 +0000 Subject: [PATCH 6/9] fix(psl-parser): interpret every accepted block attribute Signed-off-by: Steven McClankerton --- .../psl-parser/src/symbol-table.ts | 34 ++++-- ...ol-table.block-attribute-traversal.test.ts | 103 ++++++++++++++++++ 2 files changed, 128 insertions(+), 9 deletions(-) create mode 100644 packages/1-framework/2-authoring/psl-parser/test/symbol-table.block-attribute-traversal.test.ts 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 8203eefa592a..15062d1c0401 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 @@ -135,6 +135,7 @@ export interface SymbolTableResult { export function buildSymbolTable(options: BuildSymbolTableOptions): SymbolTableResult { const { documents, sources, pslBlockDescriptors } = options; const diagnostics: ParseDiagnostic[] = []; + const collectedBlocks: BlockSymbol[] = []; const namespaces: Record = Object.create(null); const namedTypes: Record = {}; @@ -176,7 +177,13 @@ export function buildSymbolTable(options: BuildSymbolTableOptions): SymbolTableR } else if (declaration instanceof GenericBlockDeclarationAst) { const name = claim(topLevelNames, declaration.name()); if (name !== undefined) { - blocks[name] = buildBlock(name, declaration, sources, pslBlockDescriptors, diagnostics); + blocks[name] = buildBlock( + name, + declaration, sources, + pslBlockDescriptors, + diagnostics, + collectedBlocks, + ); } } else if (declaration instanceof NamespaceDeclarationAst) { const declaredName = declaration.name()?.name(); @@ -195,7 +202,13 @@ export function buildSymbolTable(options: BuildSymbolTableOptions): SymbolTableR }; namespaces[name] = namespace; } - extendNamespace(namespace, declaration, diagnostics, sources, pslBlockDescriptors); + extendNamespace( + namespace, + declaration, + diagnostics, sources, + pslBlockDescriptors, + collectedBlocks, + ); } else if (declaration instanceof TypesBlockAst) { for (const binding of declaration.declarations()) { const name = claim(topLevelNames, binding.name()); @@ -211,12 +224,10 @@ export function buildSymbolTable(options: BuildSymbolTableOptions): SymbolTableR const symbolTable: SymbolTable = { topLevel: { namespaces, namedTypes, blocks, models, compositeTypes }, }; - for (const scope of [table.topLevel, ...Object.values(namespaces)]) { - for (const block of Object.values(scope.blocks)) { - const descriptor = findBlockDescriptor(pslBlockDescriptors, block.keyword); - if (descriptor !== undefined) { - interpretBlockAttributes(block, descriptor, sourceFile, table, diagnostics); - } + for (const block of collectedBlocks) { + const descriptor = findBlockDescriptor(pslBlockDescriptors, block.keyword); + if (descriptor !== undefined) { + interpretBlockAttributes(block, descriptor, sourceFile, table, diagnostics); } } return { symbolTable, diagnostics }; @@ -260,10 +271,11 @@ function buildBlock( sources: PslSources, pslBlockDescriptors: AuthoringPslBlockDescriptorNamespace, diagnostics: ParseDiagnostic[], + collectedBlocks: BlockSymbol[], ): BlockSymbol { const keyword = node.keyword()?.text ?? ''; const descriptor = findBlockDescriptor(pslBlockDescriptors, keyword); - return { + const symbol: BlockSymbol = { kind: 'block', name, keyword, @@ -271,6 +283,8 @@ function buildBlock( span: nodePslSpan(node.syntax, sources), block: reconstructExtensionBlock(node, descriptor, sources, diagnostics), }; + collectedBlocks.push(symbol); + return symbol; } function extendNamespace( @@ -279,6 +293,7 @@ function extendNamespace( diagnostics: ParseDiagnostic[], sources: PslSources, pslBlockDescriptors: AuthoringPslBlockDescriptorNamespace, + collectedBlocks: BlockSymbol[], ): void { const { models, compositeTypes, blocks } = namespace; namespace.declarations.push({ node, span: nodePslSpan(node.syntax, sources) }); @@ -313,6 +328,7 @@ function extendNamespace( sources, pslBlockDescriptors, diagnostics, + collectedBlocks, ); } } diff --git a/packages/1-framework/2-authoring/psl-parser/test/symbol-table.block-attribute-traversal.test.ts b/packages/1-framework/2-authoring/psl-parser/test/symbol-table.block-attribute-traversal.test.ts new file mode 100644 index 000000000000..0c76b0acf189 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/symbol-table.block-attribute-traversal.test.ts @@ -0,0 +1,103 @@ +import type { AuthoringPslBlockDescriptorNamespace } from '@internal/framework-components/authoring'; +import { describe, expect, it, vi } from 'vitest'; +import { blockAttribute, str } from '../src/exports'; +import { parse } from '../src/parse'; +import { type BlockSymbol, buildSymbolTable } from '../src/symbol-table'; + +const locations = [ + { namespace: undefined, name: 'Gear' }, + { namespace: undefined, name: '__proto__' }, + { namespace: 'Local', name: '__proto__' }, + { namespace: '__proto__', name: 'Gear' }, + { namespace: '__proto__', name: '__proto__' }, +]; + +function fixture( + namespace: string | undefined, + name: string, + attributes: string, + duplicate = false, +) { + const interpretedSymbols: (BlockSymbol | undefined)[] = []; + const spec = blockAttribute('map', { + documentation: 'Maps a widget.', + positional: [{ key: 'name', type: str(), documentation: 'The storage name.' }], + refine: (_args, ctx) => { + const scope = + namespace === undefined ? ctx.symbols.topLevel : ctx.symbols.topLevel.namespaces[namespace]; + interpretedSymbols.push(scope?.blocks[name]); + return []; + }, + }); + const factory = vi.fn(() => spec); + const descriptors: AuthoringPslBlockDescriptorNamespace = { + widget: { + kind: 'pslBlock', + keyword: 'widget', + discriminator: 'widget', + name: { required: true }, + parameters: {}, + attributes: { map: factory }, + }, + }; + const block = `widget ${name} {\n${attributes}\n}`; + const declarations = duplicate ? `${block}\nwidget ${name} {\n@@missing()\n}` : block; + const source = + namespace === undefined ? declarations : `namespace ${namespace} {\n${declarations}\n}`; + const { document, sourceFile, diagnostics: parseDiagnostics } = parse(source); + expect(parseDiagnostics).toEqual([]); + const result = buildSymbolTable({ document, sourceFile, pslBlockDescriptors: descriptors }); + const scope = + namespace === undefined ? result.table.topLevel : result.table.topLevel.namespaces[namespace]; + return { ...result, block: scope?.blocks[name], factory, interpretedSymbols }; +} + +describe.each(locations)( + 'block attribute traversal in $namespace for $name', + ({ namespace, name }) => { + it('interprets accepted attributes once and reports every unknown occurrence', () => { + const result = fixture( + namespace, + name, + '@@map("first")\n@@missing()\n@@missing()\n@@map("second")', + ); + expect(result.diagnostics.map(({ code, message }) => ({ code, message }))).toEqual([ + { + code: 'PSL_EXTENSION_UNKNOWN_BLOCK_ATTRIBUTE', + message: `Unknown attribute "@@missing" in "widget" block "${name}"`, + }, + { + code: 'PSL_EXTENSION_UNKNOWN_BLOCK_ATTRIBUTE', + message: `Unknown attribute "@@missing" in "widget" block "${name}"`, + }, + { + code: 'PSL_INVALID_EXTENSION_BLOCK_ATTRIBUTE', + message: `Duplicate attribute "@@map" in "widget" block "${name}"; first occurrence wins`, + }, + ]); + expect(result.block?.block.attributes['map']?.args).toEqual({ name: 'first' }); + expect(result.factory).toHaveBeenCalledTimes(1); + }); + + it('retains failed-first attribute recovery without retrying its duplicate', () => { + const result = fixture(namespace, name, '@@map()\n@@map("second")'); + expect(result.diagnostics.map(({ code }) => code)).toEqual([ + 'PSL_INVALID_ATTRIBUTE_SYNTAX', + 'PSL_INVALID_EXTENSION_BLOCK_ATTRIBUTE', + ]); + expect(result.block?.block.attributes).toEqual({}); + expect(result.factory).toHaveBeenCalledTimes(1); + }); + + it('interprets only the first accepted block declaration', () => { + const result = fixture(namespace, name, '@@map("first")', true); + expect(result.diagnostics.map(({ code, message }) => ({ code, message }))).toEqual([ + { code: 'PSL_DUPLICATE_DECLARATION', message: `Duplicate declaration of "${name}"` }, + ]); + expect(result.block?.block.attributes['map']?.args).toEqual({ name: 'first' }); + expect(result.factory).toHaveBeenCalledTimes(1); + expect(result.interpretedSymbols).toHaveLength(1); + expect(result.interpretedSymbols[0]).toBe(result.block); + }); + }, +); From ee5f15f7ce0b411298846f6796094b41ef2fc949 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 18 Sep 2026 15:42:44 +0000 Subject: [PATCH 7/9] fix(psl): address checked-reference review feedback - restore ADR 231 and package READMEs to design-level content - replace boolean it.each parameterizations with plain tests - reword base-resolution invariants to name the missing entry - store symbol-table declarations in prototype-free maps so __proto__ declarations stay resolvable through checked references - parenthesize union element labels before the list suffix in signature help Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- ... - Declarative attribute specifications.md | 40 +++--- .../2-authoring/psl-parser/README.md | 8 -- .../src/attribute-spec/combinators/list.ts | 2 +- .../psl-parser/src/symbol-table.ts | 10 +- .../psl-parser/test/entity-reference.test.ts | 37 +++++- ...ol-table.block-attribute-traversal.test.ts | 3 +- .../test/attribute-spec-consumability.test.ts | 125 ++++++++++-------- .../test/signature-help-values.test.ts | 2 +- .../2-authoring/contract-psl/README.md | 4 - .../2-sql/2-authoring/contract-psl/README.md | 1 - .../contract-psl/src/interpreter.ts | 7 +- .../test/interpreter.polymorphism.test.ts | 95 ++++++------- 12 files changed, 172 insertions(+), 162 deletions(-) diff --git a/docs/architecture docs/adrs/ADR 231 - Declarative attribute specifications.md b/docs/architecture docs/adrs/ADR 231 - Declarative attribute specifications.md index 3a32538dd186..d0f20e126bb6 100644 --- a/docs/architecture docs/adrs/ADR 231 - Declarative attribute specifications.md +++ b/docs/architecture docs/adrs/ADR 231 - Declarative attribute specifications.md @@ -4,8 +4,6 @@ **Date:** 2026-06-29 **Accepted:** 2026-08-27 -**Reference API update:** The reference, identifier, parse-context and wildcard sections below describe the current checked-reference API. Existing attribute completion and signature help now inspect these specs; the interpreter-only delivery and language-tooling follow-up discussion elsewhere in this ADR records the original acceptance boundary, not the current editor feature inventory. This update adds no editor semantic diagnostics or generic-block value features. - --- ## At a glance @@ -25,11 +23,11 @@ const sqlRelation = fieldAttribute('relation', { documentation: 'The referential action when the referenced row is deleted.', type: optional( oneOf( - identifier('NoAction', { documentation: 'Checks the constraint without propagating changes.' }), - identifier('Restrict', { documentation: 'Rejects changes while referencing rows exist.' }), - identifier('Cascade', { documentation: 'Propagates changes to referencing rows.' }), - identifier('SetNull', { documentation: 'Sets referencing fields to null.' }), - identifier('SetDefault', { documentation: 'Sets referencing fields to their defaults.' }), + identifier('NoAction'), + identifier('Restrict'), + identifier('Cascade'), + identifier('SetNull'), + identifier('SetDefault'), ), ), }, @@ -100,7 +98,7 @@ interface FieldAttributeCtx extends ModelAttributeCtx { } ``` -A block has no model, so a block attribute receives source information and the complete symbol table without a model context. A combinator is usable at any level that carries the facts it declares, and rejected where those facts do not exist. Checked references derive lexical scope from the expression's syntax ancestry; the parse context carries no owner or scope field. Spec factories may still use `AttributeSpecContext` for dynamic grammars, but do not inject reference resolvers. Existing block attributes are interpreted after declaration collection so their parse contexts also support forward references; block-value grammars remain separate. +A block has no model, so a block attribute is parsed without a model context. A combinator is usable at any level that carries the facts it declares, and rejected where those facts do not exist. Checked references derive their lexical scope from the expression's syntax ancestry; the parse context carries no owner or scope field. A spec fixes the attribute level and name, declares its arguments, and may refine the parsed result: @@ -139,8 +137,8 @@ Positionals are fixed slots with an output key. Variadic positionals are not sup - `numLiteral()` parses any number literal and keeps its source text, for consumers that must not round it through a JavaScript number. - `int({ min, max })` parses an integer with optional inclusive bounds. - `bool()` parses a boolean literal. -- `identifier()` accepts an unchecked bare identifier and returns `string`, with `name: undefined` metadata. -- `identifier(name, { documentation })` matches one exact bare identifier and preserves its literal type and documented pinned metadata. Existing completion offers pinned names only, not candidates for unrestricted names. +- `identifier()` accepts any bare identifier and returns its name as a string. +- `identifier(name)` matches one exact bare identifier and preserves its literal type. There is no enum-specific combinator. A fixed vocabulary is a `oneOf` over pinned matchers, making the source spelling explicit: @@ -161,9 +159,7 @@ These leaves perform direct AST checks. They do not wrap arktype schemas. `fieldRef()` parses a field-name identifier and validates it against the declaring model, so it is available to model and field attributes alike. `referencedFieldRef()` validates against the relation target, which only a field can resolve; cross-space references may defer the existence check when no referenced model is locally available. Both return the authored field name as a string. -`entityRef(expected)` checks existence and the selected declaration's kind. Selectors are `{ kind: 'model' }`, `{ kind: 'compositeType' }`, `{ kind: 'namedType' }`, or `{ kind: 'block', keyword }` for the contributed block keyword. Its inferred output is `ResolvedEntityReference>`: the selected `declaration` plus its lexical `namespace` (undefined at top-level), not physical storage coordinates. - -Reference rules need only the expected selector when defining the grammar: +`entityRef(expected)` checks that the referenced declaration exists and has the expected kind: `{ kind: 'model' }`, `{ kind: 'compositeType' }`, `{ kind: 'namedType' }`, or `{ kind: 'block', keyword }`. It returns the selected declaration plus its lexical namespace (undefined at top level). Resolution prefers the containing namespace's declaration, then top level, never a sibling namespace; forward references are allowed, and missing or wrong-kind targets produce source-anchored expression diagnostics. ```ts const baseSpec = modelAttribute('base', { @@ -174,11 +170,9 @@ const baseSpec = modelAttribute('base', { }); ``` -At parse time, a shared helper derives the containing namespace through `expression.syntax.findAncestor(NamespaceDeclarationAst.cast)` and selects the collected namespace by name, not red-wrapper identity. Expressions must retain their syntax ancestry and correspond to the supplied symbol table. Own-property-safe lookup selects that namespace's binding first, then top-level, never a sibling namespace; top-level expressions see only top-level declarations. Kind checking applies after selection, so a wrong-kind local binding does not trigger fallback. Collection before interpretation permits forward references. Missing/wrong-kind failures are source-anchored shared expression diagnostics. The helper itself emits no diagnostics. Wrappers are cached at the symbol-scope/declaration boundary, so repeated references retain identity across rules and list elements for collection uniqueness. +`oneOf(entityRef(expected), identifier())` prefers a checked identity and otherwise returns an unchecked name, without leaking failed-alternative diagnostics. -`oneOf(entityRef(expected), identifier())` prefers a checked identity and otherwise returns an unchecked name without leaking failed-alternative diagnostics. SQL and Mongo base attributes consume the selected model identity through lowering rather than re-resolving its bare name. Family-specific relationship and storage constraints remain separate: checked lookup does not promise cross-namespace inheritance support or change persisted variants. No document-path scope or codec reference combinator is added. - -Existing attribute tooling inspects reference metadata and labels without invoking its parser. Checked references add no completion candidates or navigation feature. The parse-only editor pipeline still does not execute SQL/Mongo family reference diagnostics. +The current kit does not provide a document-path scope or include a codec reference combinator. Those would be separate additions if a future consumer requires them. ### Native collections @@ -234,7 +228,7 @@ A spec may be assembled from context known by the owning family before interpret ### SQL defaults -SQL constructs `@default` specs per field. Scalar fields accept flexible string, number, and boolean literals plus one pinned `funcCall(name, signature)` arm for every active default-function registry entry. List fields accept a list of those scalar literals plus the registry calls. Enum fields use one documented pinned `identifier(member, { documentation })` arm per enum member. +SQL constructs `@default` specs per field. Scalar fields accept flexible string, number, and boolean literals plus one pinned `funcCall(name, signature)` arm for every active default-function registry entry. List fields accept a list of those scalar literals plus the registry calls. Enum fields use one pinned `identifier(member)` arm per enum member. ```ts const functionArms = registryEntries.map(([name, entry]) => @@ -242,10 +236,7 @@ const functionArms = registryEntries.map(([name, entry]) => ); const scalarDefault = oneOf(str(), numLiteral(), bool(), ...functionArms); -const enumDefault = oneOf( - identifier('Active', { documentation: 'An active item.' }), - identifier('Archived', { documentation: 'An archived item.' }), -); +const enumDefault = oneOf(...enumMembers.map(identifier)); ``` The number arm is `numLiteral()`, not `num()`. `num()` yields a JavaScript number, which rounds a literal past the safe integer range and drops trailing zeros; `numLiteral()` yields the literal's source text, so a `Decimal` or `BigInt` default keeps every digit as written. What to do with that text is the lowering concern below: the plain number goes to a codec that reads one, and the decimal text to a codec that does not. @@ -259,7 +250,7 @@ Mongo constructs its index field-element grammar from the declaring model's fiel ```ts const sortSig = { documentation: 'Selects an index field with an explicit sort direction.', - named: { sort: { type: oneOf(identifier('Asc', { documentation: 'Sort ascending.' }), identifier('Desc', { documentation: 'Sort descending.' })), documentation: 'The index order for this field: `Asc` or `Desc`.' } }, + named: { sort: { type: oneOf(identifier('Asc'), identifier('Desc')), documentation: 'The index order for this field: `Asc` or `Desc`.' } }, } satisfies FuncCallSig; const indexFieldElement = oneOf( @@ -272,7 +263,7 @@ const indexFieldElement = oneOf( ); ``` -This composition covers bare fields, sorted field calls, and wildcard calls without dedicated `sortedFieldRef` or `wildcardPath` combinators. Wildcard scope deliberately accepts an unchecked name, not a model reference. Index lowering still checks field existence/indexability and applies field mappings; omitted scope remains `$**`. +This composition covers bare fields, sorted field calls, and wildcard calls without dedicated `sortedFieldRef` or `wildcardPath` combinators. --- @@ -342,7 +333,6 @@ The current implementation is sufficient for interpreter consumption but not yet ## Follow-up work - Add central spec discovery and traversable combinator metadata for language-tooling consumers. -- Declaration-bearing entity references and parse-context resolution are implemented as described above; broader reference navigation and generic-block value consumers remain separate work. - Revisit signature-derived `TypedFuncCall` output types if downstream code needs statically discriminated call unions. - Decide whether literal-to-field-type compatibility should remain in lowering or gain a dedicated field-context combinator. diff --git a/packages/1-framework/2-authoring/psl-parser/README.md b/packages/1-framework/2-authoring/psl-parser/README.md index 56bf50e86a5f..9c05d230c892 100644 --- a/packages/1-framework/2-authoring/psl-parser/README.md +++ b/packages/1-framework/2-authoring/psl-parser/README.md @@ -52,14 +52,6 @@ Interpretation/validation (for example `@internal/sql-contract-psl`) is responsi - `@internal/psl-parser/syntax` - `@internal/psl-parser/tokenizer` -## Checked references and identifier rules - -Use `entityRef(expected)` to parse a checked reference. Every combinator parse context carries `symbols: SymbolTable` alongside `sourceId` and `sourceFile`; reference rules need no injected resolver. Selectors are `{ kind: 'model' }`, `{ kind: 'compositeType' }`, `{ kind: 'namedType' }`, or `{ kind: 'block', keyword }`. The result carries the selected `declaration` and lexical `namespace` (undefined at top-level), not storage coordinates. - -The shared helper derives the containing namespace from the expression's syntax ancestry and selects the collected namespace by name. Expressions must stay attached to the document corresponding to the symbol table. Own-property-safe lookup selects the local binding first, then top-level, never a sibling namespace. Kind checking applies to that selected binding; a wrong-kind local declaration does not cause fallback. Wrappers are stable per symbol scope and declaration across rules and expressions, preserving collection uniqueness. Resolution emits no diagnostics itself; `entityRef` returns source-anchored failures. Declaration collection precedes interpretation, including existing block attributes, so forward references work without adding an owner or scope field to the parse context. - -Use `identifier()` for unchecked names (`string`, with `name: undefined` metadata), or `identifier('Pinned', { documentation: 'Meaning of this value.' })` for a literal output and documented pinned metadata. `oneOf(entityRef(expected), identifier())` prefers a checked identity, falling back to an unchecked string without leaking failed-alternative diagnostics. Family-specific relationship support and storage lowering remain downstream responsibilities. - ## Architecture ```mermaid 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 0f890586cb4f..433bb986908e 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 @@ -19,7 +19,7 @@ export function list( const unique = opts?.unique ?? false; return { kind: 'list', - label: opts?.label ?? `${of.label}[]`, + label: opts?.label ?? (of.label.includes(' | ') ? `(${of.label})[]` : `${of.label}[]`), of, allowEmpty, unique, 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 15062d1c0401..fac4f94be2be 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 @@ -138,10 +138,10 @@ export function buildSymbolTable(options: BuildSymbolTableOptions): SymbolTableR const collectedBlocks: BlockSymbol[] = []; 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) { @@ -340,7 +340,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/entity-reference.test.ts b/packages/1-framework/2-authoring/psl-parser/test/entity-reference.test.ts index 0ea626923ffe..f54e7fafefe1 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 @@ -8,7 +8,7 @@ import { ModelAttributeAst } from '../src/syntax/ast/attributes'; import { IdentifierAst } from '../src/syntax/ast/identifier'; import { SyntaxNode } from '../src/syntax/red'; -function fixture(value: string, local = true, reverse = false) { +function fixture(value: string, local = true) { const members = [ ...(local ? [`model Owner {\n @@test(${value})\n}`] : []), 'model Shared {}', @@ -22,13 +22,11 @@ function fixture(value: string, local = true, reverse = false) { 'type Address {}', 'types { Email = String }', 'permission Reader {}', - `namespace Local {\n${(reverse ? members.reverse() : members).join('\n')}\n}`, + `namespace Local {\n${members.join('\n')}\n}`, 'namespace Sibling {\n model Hidden {}\n model Shared {}\n}', ...(local ? [] : [`model Owner {\n @@test(${value})\n}`]), ]; - const { document, sourceFile } = parse( - (reverse ? declarations.reverse() : declarations).join('\n'), - ); + const { document, sourceFile } = parse(declarations.join('\n')); const { table, diagnostics } = buildSymbolTable({ document, sourceFile, @@ -83,8 +81,8 @@ describe('syntax-scoped entity resolution', () => { model: { declaration: namespace?.models['Later'], namespace }, }); }); - it.each([false, true])('selects local declarations regardless of order (%s)', (reverse) => { - const { expression, ctx, namespace } = fixture('Shared', true, reverse); + it('selects the local declaration, including forward references', () => { + const { expression, ctx, namespace } = fixture('Shared'); expect(entityRef({ kind: 'model' }).parse(expression, ctx)).toEqual( ok({ declaration: namespace.models['Shared'], @@ -138,6 +136,31 @@ describe('syntax-scoped entity resolution', () => { }, ); + it('resolves a declared __proto__ model as an own map entry', () => { + const { document, sourceFile } = parse( + 'model __proto__ {}\nmodel Owner {\n @@test(__proto__)\n}', + ); + const { table, diagnostics } = buildSymbolTable({ + document, + sourceFile, + pslBlockDescriptors: {}, + }); + expect(diagnostics).toEqual([]); + expect(Object.hasOwn(table.topLevel.models, '__proto__')).toBe(true); + for (const syntax of document.syntax.descendants()) { + if (!(syntax instanceof SyntaxNode)) continue; + const attribute = ModelAttributeAst.cast(syntax); + const expression = attribute?.argList()?.args()[Symbol.iterator]().next().value?.value(); + if (!expression) continue; + const ctx = { sourceId: 'references.prisma', sourceFile, symbols: table }; + expect(entityRef({ kind: 'model' }).parse(expression, ctx)).toEqual( + ok({ declaration: table.topLevel.models['__proto__'], namespace: undefined }), + ); + return; + } + throw new Error('Missing expression'); + }); + it('does not search child namespaces from top-level', () => { const { expression, ctx } = fixture('Writer', false); expect(entityRef({ kind: 'block', keyword: 'permission' }).parse(expression, ctx).ok).toBe( diff --git a/packages/1-framework/2-authoring/psl-parser/test/symbol-table.block-attribute-traversal.test.ts b/packages/1-framework/2-authoring/psl-parser/test/symbol-table.block-attribute-traversal.test.ts index 0c76b0acf189..2465cc72594a 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/symbol-table.block-attribute-traversal.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/symbol-table.block-attribute-traversal.test.ts @@ -49,7 +49,7 @@ function fixture( const result = buildSymbolTable({ document, sourceFile, pslBlockDescriptors: descriptors }); const scope = namespace === undefined ? result.table.topLevel : result.table.topLevel.namespaces[namespace]; - return { ...result, block: scope?.blocks[name], factory, interpretedSymbols }; + return { ...result, scope, block: scope?.blocks[name], factory, interpretedSymbols }; } describe.each(locations)( @@ -77,6 +77,7 @@ describe.each(locations)( ]); expect(result.block?.block.attributes['map']?.args).toEqual({ name: 'first' }); expect(result.factory).toHaveBeenCalledTimes(1); + expect(result.scope && Object.hasOwn(result.scope.blocks, name)).toBe(true); }); it('retains failed-first attribute recovery without retrying its duplicate', () => { diff --git a/packages/1-framework/3-tooling/language-server/test/attribute-spec-consumability.test.ts b/packages/1-framework/3-tooling/language-server/test/attribute-spec-consumability.test.ts index 8464f8355b71..52c2f1b98178 100644 --- a/packages/1-framework/3-tooling/language-server/test/attribute-spec-consumability.test.ts +++ b/packages/1-framework/3-tooling/language-server/test/attribute-spec-consumability.test.ts @@ -73,65 +73,78 @@ describe('assembled attribute specs are consumable from a resolved project', () vi.restoreAllMocks(); }); - it.each([ - ['sqlAttributeSpecs', '../../../../2-sql/2-authoring/contract-psl/src/sql-attribute-specs.ts'], - [ - 'mongoAttributeSpecs', - '../../../../2-mongo-family/2-authoring/contract-psl/src/mongo-attribute-specs.ts', - ], - ])( - 'inspects the actual context-bound %s base factory without running family semantics', - async (exportName, relativePath) => { - const module = (await import(new URL(relativePath, import.meta.url).href)) as Record< - string, - AttributeSpecNamespace - >; - const attributeSpecs = module[exportName]; - if (!attributeSpecs) throw new Error('missing attribute namespace'); - const authoringContributions = assembleAuthoringContributions([ - { id: 'actual-family', authoring: { attributeSpecs } }, - ]); - const controlMutationDefaults = assembleControlMutationDefaults([]); - const source = 'model Variant {\n @@base(Missing, "v")\n}\nmodel Base { id Int }'; - const pipeline = runPipeline(source, { - scalarTypes: ['Int'], + async function importAttributeSpecs(relativePath: string, exportName: string) { + const module = (await import(new URL(relativePath, import.meta.url).href)) as Record< + string, + AttributeSpecNamespace + >; + const attributeSpecs = module[exportName]; + if (!attributeSpecs) throw new Error('missing attribute namespace'); + return attributeSpecs; + } + + function expectBaseSpecSignatureHelp(attributeSpecs: AttributeSpecNamespace) { + const authoringContributions = assembleAuthoringContributions([ + { id: 'actual-family', authoring: { attributeSpecs } }, + ]); + const controlMutationDefaults = assembleControlMutationDefaults([]); + const source = 'model Variant {\n @@base(Missing, "v")\n}\nmodel Base { id Int }'; + const pipeline = runPipeline(source, { + scalarTypes: ['Int'], + pslBlockDescriptors: {}, + authoringContributions, + controlMutationDefaults, + }); + const model = pipeline.symbolTable.topLevel.models['Variant']; + if (!model) throw new Error('missing variant'); + const spec = assembleAttributeSpecs(authoringContributions).model['base']?.({ + symbols: pipeline.symbolTable, + model, + controlMutationDefaults, + }); + expect(spec).toMatchObject({ + name: 'base', + positional: [ + { + key: 'base', + type: { kind: 'entityRef', expected: { kind: 'model' }, label: 'model reference' }, + }, + { key: 'value', type: { kind: 'str' } }, + ], + }); + expect(pipeline.diagnostics).toEqual([]); + const signature = providePslSignatureHelp({ + document: pipeline.document, + sourceFile: pipeline.sourceFile, + position: pipeline.sourceFile.positionAt(source.indexOf('Missing')), + clientSupportsLabelOffsets: true, + candidates: { + symbolTable: pipeline.symbolTable, pslBlockDescriptors: {}, authoringContributions, controlMutationDefaults, - }); - const model = pipeline.symbolTable.topLevel.models['Variant']; - if (!model) throw new Error('missing variant'); - const spec = assembleAttributeSpecs(authoringContributions).model['base']?.({ - symbols: pipeline.symbolTable, - model, - controlMutationDefaults, - }); - expect(spec).toMatchObject({ - name: 'base', - positional: [ - { - key: 'base', - type: { kind: 'entityRef', expected: { kind: 'model' }, label: 'model reference' }, - }, - { key: 'value', type: { kind: 'str' } }, - ], - }); - expect(pipeline.diagnostics).toEqual([]); - const signature = providePslSignatureHelp({ - document: pipeline.document, - sourceFile: pipeline.sourceFile, - position: pipeline.sourceFile.positionAt(source.indexOf('Missing')), - clientSupportsLabelOffsets: true, - candidates: { - symbolTable: pipeline.symbolTable, - pslBlockDescriptors: {}, - authoringContributions, - controlMutationDefaults, - }, - }); - expect(signature?.signatures[0]?.label).toBe('@@base(model reference, string)'); - }, - ); + }, + }); + expect(signature?.signatures[0]?.label).toBe('@@base(model reference, string)'); + } + + it('provides signature help from the SQL @@base spec factory', async () => { + expectBaseSpecSignatureHelp( + await importAttributeSpecs( + '../../../../2-sql/2-authoring/contract-psl/src/sql-attribute-specs.ts', + 'sqlAttributeSpecs', + ), + ); + }); + + it('provides signature help from the Mongo @@base spec factory', async () => { + expectBaseSpecSignatureHelp( + await importAttributeSpecs( + '../../../../2-mongo-family/2-authoring/contract-psl/src/mongo-attribute-specs.ts', + 'mongoAttributeSpecs', + ), + ); + }); it('enumerates a contributed model attribute by its claimed name', async () => { vi.spyOn(configLoader, 'loadConfig').mockResolvedValue( diff --git a/packages/1-framework/3-tooling/language-server/test/signature-help-values.test.ts b/packages/1-framework/3-tooling/language-server/test/signature-help-values.test.ts index 4ab13b9b380b..d6998b04e7ed 100644 --- a/packages/1-framework/3-tooling/language-server/test/signature-help-values.test.ts +++ b/packages/1-framework/3-tooling/language-server/test/signature-help-values.test.ts @@ -35,7 +35,7 @@ it.each([ { type: optional(list(oneOf(reference, identifier()))), args: '|', - label: '@probe((model reference | identifier[])?)', + label: '@probe(((model reference | identifier)[])?)', }, { type: oneOf(asc, desc), args: '|', label: '@probe(Asc | Desc)' }, { type: optional(asc), args: '|', label: '@probe(Asc?)' }, diff --git a/packages/2-mongo-family/2-authoring/contract-psl/README.md b/packages/2-mongo-family/2-authoring/contract-psl/README.md index 1416259db72c..755e9886574a 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/README.md +++ b/packages/2-mongo-family/2-authoring/contract-psl/README.md @@ -10,10 +10,6 @@ PSL-to-Mongo contract interpreter for Prisma 8. Transforms Prisma Schema Languag - **Contract provider**: `mongoContract()` (exported from `./provider`) integrates with the CLI's `prisma contract emit` command, reading a `.prisma` schema file and producing a `ContractConfig` - **Diagnostics**: Emits structured diagnostics for unsupported field types (`PSL_UNSUPPORTED_FIELD_TYPE`), missing `@id` fields (`PSL_MISSING_ID_FIELD`), orphaned backrelations (`PSL_ORPHANED_BACKRELATION`), ambiguous backrelations (`PSL_AMBIGUOUS_BACKRELATION`), and attribute names outside the registered namespace (`PSL_UNSUPPORTED_MODEL_ATTRIBUTE`, `PSL_UNSUPPORTED_FIELD_ATTRIBUTE`) -`@@base` uses a context-bound checked model reference and preserves the selected declaration through discriminator, collection, root and index lowering. Missing or wrong-kind targets produce shared source-anchored expression diagnostics; Mongo's namespace prohibition and single-collection inheritance checks remain separate. - -Wildcard scope is an unchecked identifier expression (`wildcard(field)`), not a model reference. Field existence/indexability and mapped field paths are still checked during index lowering; `wildcard()` continues to index `$**`. - ## Known limitations - **Per-index `collation`**: PSL authoring does not support the `collation` index option. Users requiring per-index collation must use the TypeScript contract builder (`@internal/mongo-contract-ts`). diff --git a/packages/2-sql/2-authoring/contract-psl/README.md b/packages/2-sql/2-authoring/contract-psl/README.md index 3e53003dda10..2e991a189574 100644 --- a/packages/2-sql/2-authoring/contract-psl/README.md +++ b/packages/2-sql/2-authoring/contract-psl/README.md @@ -28,7 +28,6 @@ This keeps core/CLI source-agnostic while giving PSL-first SQL users a one-line - Preserve parser diagnostics and add interpreter diagnostics with stable codes - Return `notOk` with structured diagnostics for unsupported constructs - Keep interpretation deterministic for equivalent AST inputs -- Bind `@@base` through the parser's checked model-reference factory: local namespace first, then top-level, never sibling namespaces. Preserve the selected declaration through discriminator association and STI/MTI storage lowering. Missing or wrong-kind references produce source-anchored shared expression diagnostics; discriminator and inheritance semantics remain separate checks. Reference lookup does not extend the contract's supported inheritance relationships. Determinism note: - Relation metadata emission is intentionally **sorted by storage table name, then model name, then relation field name** (not PSL declaration order) so `contract.json` snapshots and hashes are stable across environments. 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 8de0c8e3edb6..4476b091cfbb 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts @@ -1758,7 +1758,7 @@ function collectPolymorphismDeclarations( const base = identities.get(parsed.base.declaration); invariant( base !== undefined, - 'A checked base declaration has a collected SQL model identity', + `Resolved base model "${parsed.base.declaration.name}" is missing from the collected model identities`, ); baseDeclarations.set(key, { source, @@ -1817,7 +1817,10 @@ function resolvePolymorphism( for (const [variantKey, baseDecl] of baseDeclarations) { if (baseDecl.base.key !== modelKey) continue; const variantName = modelMappings.get(variantKey)?.model.name; - invariant(variantName !== undefined, 'A collected variant has a model mapping'); + invariant( + variantName !== undefined, + `Variant "${variantKey}" is missing from the model mappings`, + ); const existingVariant = seenValues.get(baseDecl.value); if (existingVariant) { 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 395d48e72b7d..0e99c4d3a17e 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 @@ -42,11 +42,8 @@ describe('interpretPslDocumentToSqlContract — polymorphism', () => { ...input, }); - it.each([false, true])( - 'keeps same-named inheritance graphs independent across namespaces (%s)', - (reverse) => { - const schemas = ['alpha', 'beta'].map( - (namespace) => `namespace ${namespace} { + it('keeps same-named inheritance graphs independent across namespaces', () => { + const inheritanceSchema = (namespace: string) => `namespace ${namespace} { model Bug { ${namespace}Detail String @map("${namespace}_detail") @@base(Task, "bug") @@ -62,44 +59,40 @@ describe('interpretPslDocumentToSqlContract — polymorphism', () => { @@discriminator(${namespace}Kind) @@map("${namespace}_tasks") } -}`, - ); - const standalone = 'model Bug {\n id Int @id\n @@map("standalone_bug")\n}'; - const interpret = (schema: string) => { - const result = interpretPslDocumentToSqlContract({ - ...symbolTableInputFromParseArgs({ schema, sourceId: 'schema.prisma' }), - controlMutationDefaults: builtinControlMutationDefaults, - }); - expect(result.ok).toBe(true); - if (!result.ok) throw new Error(JSON.stringify(result.failure)); - return result.value; - }; - const combined = interpret( - [standalone, ...(reverse ? [...schemas].reverse() : schemas)].join('\n'), +}`; + const standalone = 'model Bug {\n id Int @id\n @@map("standalone_bug")\n}'; + const interpret = (schema: string) => { + const result = interpretPslDocumentToSqlContract({ + ...symbolTableInputFromParseArgs({ schema, sourceId: 'schema.prisma' }), + controlMutationDefaults: builtinControlMutationDefaults, + }); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error(JSON.stringify(result.failure)); + return result.value; + }; + const combined = interpret( + [standalone, inheritanceSchema('alpha'), inheritanceSchema('beta')].join('\n'), + ); + expect(() => validateContractDomain(combined)).not.toThrow(); + const envelope: unknown = JSON.parse(JSON.stringify(combined)); + expect(() => validateSqlContractFully(envelope)).not.toThrow(); + for (const namespace of ['alpha', 'beta']) { + const isolated = interpret(inheritanceSchema(namespace)); + expect(combined.domain.namespaces[namespace]).toEqual(isolated.domain.namespaces[namespace]); + const storage = combined.storage as SqlStorage; + expect(storage.namespaces[namespace]).toEqual( + (isolated.storage as SqlStorage).namespaces[namespace], ); - expect(() => validateContractDomain(combined)).not.toThrow(); - const envelope: unknown = JSON.parse(JSON.stringify(combined)); - expect(() => validateSqlContractFully(envelope)).not.toThrow(); - for (const [index, namespace] of ['alpha', 'beta'].entries()) { - const isolated = interpret(schemas[index]!); - expect(combined.domain.namespaces[namespace]).toEqual( - isolated.domain.namespaces[namespace], - ); - const storage = combined.storage as SqlStorage; - expect(storage.namespaces[namespace]).toEqual( - (isolated.storage as SqlStorage).namespaces[namespace], - ); - expect(combined.domain.namespaces[namespace]?.models['Task']).toMatchObject({ - discriminator: { field: `${namespace}Kind` }, - variants: { Bug: { value: 'bug' }, Feature: { value: 'feature' } }, - }); - expect(combined.domain.namespaces[namespace]?.models['Bug']).toMatchObject({ - base: crossRef('Task', namespace), - storage: { table: `${namespace}_tasks` }, - }); - expect( - storage.namespaces[namespace]?.entries.table?.[`${namespace}_features`], - ).toMatchObject({ + expect(combined.domain.namespaces[namespace]?.models['Task']).toMatchObject({ + discriminator: { field: `${namespace}Kind` }, + variants: { Bug: { value: 'bug' }, Feature: { value: 'feature' } }, + }); + expect(combined.domain.namespaces[namespace]?.models['Bug']).toMatchObject({ + base: crossRef('Task', namespace), + storage: { table: `${namespace}_tasks` }, + }); + expect(storage.namespaces[namespace]?.entries.table?.[`${namespace}_features`]).toMatchObject( + { primaryKey: { columns: [`${namespace}_id`] }, foreignKeys: [ { @@ -110,15 +103,15 @@ describe('interpretPslDocumentToSqlContract — polymorphism', () => { }, }, ], - }); - } - expect(combined.roots).toEqual({ - standalone_bug: crossRef('Bug', 'public'), - alpha_tasks: crossRef('Task', 'alpha'), - beta_tasks: crossRef('Task', 'beta'), - }); - }, - ); + }, + ); + } + expect(combined.roots).toEqual({ + standalone_bug: crossRef('Bug', 'public'), + alpha_tasks: crossRef('Task', 'alpha'), + beta_tasks: crossRef('Task', 'beta'), + }); + }); it.each([ [ From 8044454f057ec2922a189ecea5f2e124dde8506c Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Tue, 22 Sep 2026 09:33:24 +0000 Subject: [PATCH 8/9] fix(psl): adapt checked references to multi-source parsing The provenance refactor moved parsing onto PslSources and multi-document symbol tables. Checked-reference code and tests now build contexts from sources and symbols, block-attribute interpretation reads spans through the sources registry, leafDiagnostic requires only the sources it uses, and the __proto__ regression test reads the declaration without the deprecated accessor. Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- .../attribute-spec/combinators/diagnostic.ts | 2 +- .../psl-parser/src/block-reconstruction.ts | 6 +- .../psl-parser/src/symbol-table.ts | 8 ++- .../test/attribute-spec-block.test.ts | 3 +- ...te-spec-combinators.tagged-literal.test.ts | 2 +- .../test/attribute-spec-combinators.test-d.ts | 2 +- .../test/attribute-spec-combinators.test.ts | 14 +++-- .../psl-parser/test/attribute-spec.test.ts | 2 +- .../psl-parser/test/entity-reference.test.ts | 56 +++++++++++-------- ...ol-table.block-attribute-traversal.test.ts | 12 +++- .../test/attribute-spec-consumability.test.ts | 2 +- .../contract-psl/src/interpreter.ts | 2 +- .../test/mongo-attribute-specs.test.ts | 29 ++++++---- .../test/sql-attribute-specs.test.ts | 7 +-- 14 files changed, 86 insertions(+), 61 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..e4920b97a4ec 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 @@ -7,7 +7,7 @@ import type { AttributeCtx } from '../types'; export const ATTRIBUTE_DIAGNOSTIC_CODE: PslDiagnosticCode = 'PSL_INVALID_ATTRIBUTE_SYNTAX'; export function leafDiagnostic( - ctx: AttributeCtx, + ctx: Pick, node: AstNode, message: string, code: PslDiagnostic['code'] = ATTRIBUTE_DIAGNOSTIC_CODE, diff --git a/packages/1-framework/2-authoring/psl-parser/src/block-reconstruction.ts b/packages/1-framework/2-authoring/psl-parser/src/block-reconstruction.ts index c5fe1fc960cb..b63e7ae6797a 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/block-reconstruction.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/block-reconstruction.ts @@ -89,7 +89,7 @@ export function reconstructExtensionBlock( export function interpretBlockAttributes( symbol: BlockSymbol, descriptor: AuthoringPslBlockDescriptor, - sourceFile: SourceFile, + sources: PslSources, symbols: SymbolTable, diagnostics: ParseDiagnostic[], ): void { @@ -99,12 +99,12 @@ export function interpretBlockAttributes( const parsed = parseBlockAttribute( attribute, name, - nodePslSpan(attribute.syntax, sourceFile), + nodePslSpan(attribute.syntax, sources), descriptor, seenNames, symbol.keyword, symbol.name, - sourceFile, + sources, symbols, ); if (parsed.ok) { 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 fac4f94be2be..efc9fb8bddfd 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 @@ -179,7 +179,8 @@ export function buildSymbolTable(options: BuildSymbolTableOptions): SymbolTableR if (name !== undefined) { blocks[name] = buildBlock( name, - declaration, sources, + declaration, + sources, pslBlockDescriptors, diagnostics, collectedBlocks, @@ -205,7 +206,8 @@ export function buildSymbolTable(options: BuildSymbolTableOptions): SymbolTableR extendNamespace( namespace, declaration, - diagnostics, sources, + diagnostics, + sources, pslBlockDescriptors, collectedBlocks, ); @@ -227,7 +229,7 @@ export function buildSymbolTable(options: BuildSymbolTableOptions): SymbolTableR for (const block of collectedBlocks) { const descriptor = findBlockDescriptor(pslBlockDescriptors, block.keyword); if (descriptor !== undefined) { - interpretBlockAttributes(block, descriptor, sourceFile, table, diagnostics); + interpretBlockAttributes(block, descriptor, sources, symbolTable, diagnostics); } } return { symbolTable, diagnostics }; diff --git a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-block.test.ts b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-block.test.ts index f8f8502d3435..a18ab583d5d7 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-block.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-block.test.ts @@ -13,7 +13,8 @@ function blockAttr(source: string): { node: ModelAttributeAst; ctx: AttributeCtx if (!node) throw new Error('expected a block attribute'); return { node, - ctx: { sources: new PslSources([[root, cursor.sourceFile]]), + ctx: { + sources: new PslSources([[root, cursor.sourceFile]]), symbols: { topLevel: { namespaces: {}, models: {}, compositeTypes: {}, namedTypes: {}, blocks: {} }, }, 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 abf0eff74bf5..9b0bf42321bc 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 @@ -21,7 +21,7 @@ function makeCtx(sources: PslSources): FieldAttributeCtx { if (!field) throw new Error('expected field id on model M'); return { sources, - symbols: table, + symbols: symbolTable, selfModel, field, resolveReferencedModel: () => undefined, 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 b7700bc63789..61fd220d9fa8 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<'sourceId' | 'sourceFile' | 'symbols'>(); + expectTypeOf().toEqualTypeOf<'sources' | 'symbols'>(); // @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 dd96f2ab3eb6..1a9854b658c0 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 @@ -42,7 +42,7 @@ function makeCtx(sources: PslSources): FieldAttributeCtx { if (!field) throw new Error('expected field id on model M'); return { sources, - symbols: table, + symbols: symbolTable, selfModel, field, resolveReferencedModel: () => undefined, @@ -685,14 +685,18 @@ describe('fieldRef', () => { describe('entityRef', () => { function referenceArg(value: string) { - const { document, sourceFile } = parse(`model M {\n id Int @x(${value})\n}`); - const { table } = buildSymbolTable({ document, sourceFile, pslBlockDescriptors: {} }); - const selfModel = table.topLevel.models['M']; + const { document, sources } = parse(`model M {\n id Int @x(${value})\n}`, 'schema.prisma'); + const { symbolTable } = buildSymbolTable({ + documents: [document], + sources, + pslBlockDescriptors: {}, + }); + const selfModel = symbolTable.topLevel.models['M']; const field = selfModel?.fields['id']; 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: { sourceId: 'schema.prisma', sourceFile, symbols: table, selfModel } }; + return { expr, ctx: { sources, symbols: symbolTable, selfModel } }; } it('parses a bare identifier into its resolved model', () => { 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 641397ab193e..515906ca69b4 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 @@ -30,7 +30,7 @@ function makeCtx(sources: PslSources): FieldAttributeCtx { if (!field) throw new Error('expected field id on model M'); return { sources, - symbols: table, + symbols: symbolTable, selfModel, field, resolveReferencedModel: () => undefined, 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 f54e7fafefe1..dd23205ed7aa 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 @@ -26,14 +26,14 @@ function fixture(value: string, local = true) { 'namespace Sibling {\n model Hidden {}\n model Shared {}\n}', ...(local ? [] : [`model Owner {\n @@test(${value})\n}`]), ]; - const { document, sourceFile } = parse(declarations.join('\n')); - const { table, diagnostics } = buildSymbolTable({ - document, - sourceFile, + const { document, sources } = parse(declarations.join('\n'), 'references.prisma'); + const { symbolTable, diagnostics } = buildSymbolTable({ + documents: [document], + sources, pslBlockDescriptors: {}, }); expect(diagnostics).toEqual([]); - const namespace = table.topLevel.namespaces['Local']; + const namespace = symbolTable.topLevel.namespaces['Local']; if (!namespace) throw new Error('Missing namespace'); for (const syntax of document.syntax.descendants()) { if (!(syntax instanceof SyntaxNode)) continue; @@ -42,8 +42,9 @@ function fixture(value: string, local = true) { if (expression) return { expression, - ctx: { sourceId: 'references.prisma', sourceFile, symbols: table }, - table, + ctx: { sources, symbols: symbolTable }, + sources, + table: symbolTable, namespace, }; } @@ -52,8 +53,9 @@ function fixture(value: string, local = true) { describe('syntax-scoped entity resolution', () => { it('supplies the completed table to existing block attribute rules', () => { - const { document, sourceFile } = parse( + 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.', @@ -62,8 +64,8 @@ describe('syntax-scoped entity resolution', () => { ], }); const result = buildSymbolTable({ - document, - sourceFile, + documents: [document], + sources, pslBlockDescriptors: { permission: { name: { required: true }, @@ -76,7 +78,7 @@ describe('syntax-scoped entity resolution', () => { }, }); expect(result.diagnostics).toEqual([]); - const namespace = result.table.topLevel.namespaces['Local']; + const namespace = result.symbolTable.topLevel.namespaces['Local']; expect(namespace?.blocks['Reader']?.block.attributes['target']?.args).toEqual({ model: { declaration: namespace?.models['Later'], namespace }, }); @@ -119,16 +121,17 @@ describe('syntax-scoped entity resolution', () => { it.each(['Hidden', 'Missing', 'toString', 'constructor', '__proto__'])( 'rejects unavailable and inherited names: %s', (name) => { - const { expression, ctx } = fixture(name); + const { expression, ctx, sources } = fixture(name); + const sourceFile = sources.sourceFileFor(expression.syntax); expect(entityRef({ kind: 'model' }).parse(expression, ctx)).toMatchObject({ ok: false, failure: [ { message: `Unknown model reference "${name}"`, - sourceId: 'references.prisma', - span: { - start: { offset: expression.syntax.offset }, - end: { offset: expression.syntax.endOffset }, + filename: 'references.prisma', + range: { + start: sourceFile.positionAt(expression.syntax.offset), + end: sourceFile.positionAt(expression.syntax.endOffset), }, }, ], @@ -137,24 +140,29 @@ describe('syntax-scoped entity resolution', () => { ); it('resolves a declared __proto__ model as an own map entry', () => { - const { document, sourceFile } = parse( + const { document, sources } = parse( 'model __proto__ {}\nmodel Owner {\n @@test(__proto__)\n}', + 'references.prisma', ); - const { table, diagnostics } = buildSymbolTable({ - document, - sourceFile, + const { symbolTable, diagnostics } = buildSymbolTable({ + documents: [document], + sources, pslBlockDescriptors: {}, }); expect(diagnostics).toEqual([]); - expect(Object.hasOwn(table.topLevel.models, '__proto__')).toBe(true); + expect(Object.hasOwn(symbolTable.topLevel.models, '__proto__')).toBe(true); + const declaration = Object.entries(symbolTable.topLevel.models).find( + ([name]) => name === '__proto__', + )?.[1]; + expect(declaration).toBeDefined(); for (const syntax of document.syntax.descendants()) { if (!(syntax instanceof SyntaxNode)) continue; const attribute = ModelAttributeAst.cast(syntax); const expression = attribute?.argList()?.args()[Symbol.iterator]().next().value?.value(); if (!expression) continue; - const ctx = { sourceId: 'references.prisma', sourceFile, symbols: table }; + const ctx = { sources, symbols: symbolTable }; expect(entityRef({ kind: 'model' }).parse(expression, ctx)).toEqual( - ok({ declaration: table.topLevel.models['__proto__'], namespace: undefined }), + ok({ declaration, namespace: undefined }), ); return; } @@ -261,7 +269,7 @@ describe('checked references and unchecked identifiers', () => { expect(oneOf(entityRef({ kind: 'model' }), identifier()).parse(expression, ctx)).toMatchObject({ ok: false, failure: [ - { message: 'Expected one of: model reference | identifier', sourceId: 'references.prisma' }, + { message: 'Expected one of: model reference | identifier', filename: 'references.prisma' }, ], }); }); diff --git a/packages/1-framework/2-authoring/psl-parser/test/symbol-table.block-attribute-traversal.test.ts b/packages/1-framework/2-authoring/psl-parser/test/symbol-table.block-attribute-traversal.test.ts index 2465cc72594a..5b7dcbd36175 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/symbol-table.block-attribute-traversal.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/symbol-table.block-attribute-traversal.test.ts @@ -44,11 +44,17 @@ function fixture( const declarations = duplicate ? `${block}\nwidget ${name} {\n@@missing()\n}` : block; const source = namespace === undefined ? declarations : `namespace ${namespace} {\n${declarations}\n}`; - const { document, sourceFile, diagnostics: parseDiagnostics } = parse(source); + const { document, sources, diagnostics: parseDiagnostics } = parse(source, 'widgets.prisma'); expect(parseDiagnostics).toEqual([]); - const result = buildSymbolTable({ document, sourceFile, pslBlockDescriptors: descriptors }); + const result = buildSymbolTable({ + documents: [document], + sources, + pslBlockDescriptors: descriptors, + }); const scope = - namespace === undefined ? result.table.topLevel : result.table.topLevel.namespaces[namespace]; + namespace === undefined + ? result.symbolTable.topLevel + : result.symbolTable.topLevel.namespaces[namespace]; return { ...result, scope, block: scope?.blocks[name], factory, interpretedSymbols }; } diff --git a/packages/1-framework/3-tooling/language-server/test/attribute-spec-consumability.test.ts b/packages/1-framework/3-tooling/language-server/test/attribute-spec-consumability.test.ts index 52c2f1b98178..a241760a00b3 100644 --- a/packages/1-framework/3-tooling/language-server/test/attribute-spec-consumability.test.ts +++ b/packages/1-framework/3-tooling/language-server/test/attribute-spec-consumability.test.ts @@ -89,7 +89,7 @@ describe('assembled attribute specs are consumable from a resolved project', () ]); const controlMutationDefaults = assembleControlMutationDefaults([]); const source = 'model Variant {\n @@base(Missing, "v")\n}\nmodel Base { id Int }'; - const pipeline = runPipeline(source, { + const pipeline = runPipeline('schema.prisma', source, { scalarTypes: ['Int'], pslBlockDescriptors: {}, authoringContributions, 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 12909f9723ca..32038528a3f2 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 @@ -374,7 +374,7 @@ function resolvePolymorphism(input: { const { discriminatorDeclarations, baseDeclarations, - modelMetadataByName,, + modelMetadataByName, indexSpans, modelIndexesByName, indexSources, 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 bdae14c8d067..fadb7f8880ad 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 @@ -1,4 +1,3 @@ -import type { ContractSourceDiagnostic } from '@internal/config/config-types'; import type { ArgType, AttributeCtx, @@ -10,7 +9,7 @@ import type { Param, ResolvedEntityReference, } from '@internal/psl-parser'; -import { buildSymbolTable } from '@internal/psl-parser'; +import { buildSymbolTable, createPslDiagnosticCollector } from '@internal/psl-parser'; import { parse } from '@internal/psl-parser/syntax'; import { describe, expect, expectTypeOf, it } from 'vitest'; import { @@ -108,29 +107,35 @@ function contexts(): { model: AttributeSpecContext; field: FieldAttributeSpecCon describe('mongoAttributeSpecs', () => { it('returns the selected forward base declaration instead of a name', () => { - const { document, sourceFile } = parse(`model Variant { @@base(Base, "v") } + const { document, sources } = parse( + `model Variant { @@base(Base, "v") } model Other { id Int } -model Base { id String }`); - const { table } = buildSymbolTable({ document, sourceFile, pslBlockDescriptors: {} }); - const model = table.topLevel.models['Variant']; +model Base { id String }`, + 'test.prisma', + ); + const { symbolTable } = buildSymbolTable({ + documents: [document], + sources, + pslBlockDescriptors: {}, + }); + const model = symbolTable.topLevel.models['Variant']; if (!model) throw new Error('missing variant'); const node = findModelAttributeNode(model, 'base'); if (!node) throw new Error('missing base'); - const diagnostics: ContractSourceDiagnostic[] = []; + const diagnostics = createPslDiagnosticCollector(sources); const value = interpretModelAttribute({ node, - symbols: table, + symbols: symbolTable, spec: mongoAttributeSpecs.model.base(), model, - sourceFile, - sourceId: 'test.prisma', + sources, diagnostics, }); expectTypeOf(value).toEqualTypeOf< { base: ResolvedEntityReference; value: string } | undefined >(); - expect(diagnostics).toEqual([]); - expect(value?.base.declaration).toBe(table.topLevel.models['Base']); + expect(diagnostics.toExternal()).toEqual([]); + expect(value?.base.declaration).toBe(symbolTable.topLevel.models['Base']); expect(value?.base.namespace).toBeUndefined(); expect(value?.value).toBe('v'); }); 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 cf1c4cc1ca12..b9982ca881e5 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 @@ -133,17 +133,16 @@ namespace scoped { if (!namespace || !model) throw new Error('missing variant'); const node = findModelAttributeNode(model, 'base'); if (!node) throw new Error('missing base attribute'); - const diagnostics: ContractSourceDiagnostic[] = []; + const diagnostics = createPslDiagnosticCollector(input.sources); const value = interpretModelAttribute({ node, symbols: input.symbolTable, spec: sqlAttributeSpecs.model.base(), model, - sourceFile: input.sourceFile, - sourceId: input.sourceId, + sources: input.sources, diagnostics, }); - expect(diagnostics).toEqual([]); + expect(diagnostics.toExternal()).toEqual([]); expect(value?.base.declaration).toBe(namespace.models['Base']); expect(value?.base.namespace).toBe(namespace); expect(value?.value).toBe('variant'); From a3d67494d6113f40a2a57ba021476cce18f18733 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Tue, 22 Sep 2026 10:01:09 +0000 Subject: [PATCH 9/9] test(language-server): build default registries with data-type entries ADR 254 replaced the literal-tag registry with data-type entries in ControlDefaultRegistries, so the base-spec factory test assembles that shape from the stack defaults. Co-Authored-By: Claude Fable 5 Signed-off-by: Steven McClankerton --- .../test/attribute-spec-consumability.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/1-framework/3-tooling/language-server/test/attribute-spec-consumability.test.ts b/packages/1-framework/3-tooling/language-server/test/attribute-spec-consumability.test.ts index a241760a00b3..15ab1d1ffe04 100644 --- a/packages/1-framework/3-tooling/language-server/test/attribute-spec-consumability.test.ts +++ b/packages/1-framework/3-tooling/language-server/test/attribute-spec-consumability.test.ts @@ -100,7 +100,10 @@ describe('assembled attribute specs are consumable from a resolved project', () const spec = assembleAttributeSpecs(authoringContributions).model['base']?.({ symbols: pipeline.symbolTable, model, - controlMutationDefaults, + controlMutationDefaults: { + defaultFunctionRegistry: controlMutationDefaults.defaultFunctionRegistry, + dataTypeEntries: {}, + }, }); expect(spec).toMatchObject({ name: 'base',