From c67ef0e18a90f521df9b918a904257a3140217f6 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 29 Jun 2026 18:01:42 +0000 Subject: [PATCH 01/26] test(psl-parser): specify the declarative attribute-spec engine and inference Signed-off-by: Serhii Tatarintsev --- .../psl-parser/test/attribute-spec.test-d.ts | 62 ++++ .../psl-parser/test/attribute-spec.test.ts | 301 ++++++++++++++++++ 2 files changed, 363 insertions(+) create mode 100644 packages/1-framework/2-authoring/psl-parser/test/attribute-spec.test-d.ts create mode 100644 packages/1-framework/2-authoring/psl-parser/test/attribute-spec.test.ts diff --git a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec.test-d.ts b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec.test-d.ts new file mode 100644 index 0000000000..5b6529964f --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec.test-d.ts @@ -0,0 +1,62 @@ +import type { PslDiagnostic } from '@prisma-next/framework-components/psl-ast'; +import { ok, type Result } from '@prisma-next/utils/result'; +import { expectTypeOf, test } from 'vitest'; +import type { ArgType, InferAttr } from '../src/exports'; +import { fieldAttribute, optional } from '../src/exports'; + +function leaf(kind: string, value: T): ArgType { + return { + kind, + label: kind, + parse: (): Result => ok(value), + }; +} + +const str = (): ArgType => leaf('str', ''); +const int = (): ArgType => leaf('int', 0); + +test('a required named param becomes a required property', () => { + const spec = fieldAttribute('demo', { named: { name: str() } }); + expectTypeOf>().toEqualTypeOf<{ name: string }>(); +}); + +test('an optional named param becomes an optional property', () => { + const spec = fieldAttribute('demo', { named: { name: optional(str()) } }); + expectTypeOf>().toEqualTypeOf<{ name?: string }>(); +}); + +test('mixed required and optional named params keep their modifiers', () => { + const spec = fieldAttribute('demo', { + named: { name: str(), count: optional(int()) }, + }); + expectTypeOf>().toEqualTypeOf<{ name: string; count?: number }>(); +}); + +test('a positional slot contributes its key into the same keyspace', () => { + const spec = fieldAttribute('demo', { + positional: [{ key: 'name', type: str() }], + }); + expectTypeOf>().toEqualTypeOf<{ name: string }>(); +}); + +test('an optional positional slot contributes an optional property', () => { + const spec = fieldAttribute('demo', { + positional: [{ key: 'name', type: optional(str()) }], + }); + expectTypeOf>().toEqualTypeOf<{ name?: string }>(); +}); + +test('a positional-or-named alias collapses to one property', () => { + const spec = fieldAttribute('demo', { + positional: [{ key: 'name', type: optional(str()) }], + named: { name: optional(str()), map: optional(str()) }, + }); + expectTypeOf>().toEqualTypeOf<{ name?: string; map?: string }>(); +}); + +test('a variadic positional slot contributes an array property', () => { + const spec = fieldAttribute('demo', { + positional: [{ key: 'tags', type: str(), variadic: true }], + }); + expectTypeOf>().toEqualTypeOf<{ tags: readonly string[] }>(); +}); 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 new file mode 100644 index 0000000000..e34ea118a1 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec.test.ts @@ -0,0 +1,301 @@ +import type { PslDiagnostic } from '@prisma-next/framework-components/psl-ast'; +import { notOk, ok, type Result } from '@prisma-next/utils/result'; +import { describe, expect, it } from 'vitest'; +import type { ArgType, InterpretCtx } from '../src/exports'; +import { fieldAttribute, interpretAttribute, nodePslSpan, optional } from '../src/exports'; +import { Cursor, parse, parseAttribute } from '../src/parse'; +import type { SourceFile } from '../src/source-file'; +import { buildSymbolTable } from '../src/symbol-table'; +import { FieldAttributeAst } from '../src/syntax/ast/attributes'; +import { StringLiteralExprAst } from '../src/syntax/ast/expressions'; +import { createSyntaxTree } from '../src/syntax/red'; + +function makeCtx(sourceFile: SourceFile): InterpretCtx { + const { document, sourceFile: modelSource } = parse('model M {\n id Int @id\n}\n'); + const { table } = buildSymbolTable({ + document, + sourceFile: modelSource, + scalarTypes: ['String', 'Int'], + pslBlockDescriptors: {}, + }); + const selfModel = table.topLevel.models.M; + if (!selfModel) throw new Error('expected model M in the symbol table'); + return { + level: 'field', + sourceId: 'schema.prisma', + sourceFile, + symbols: table, + selfModel, + resolveReferencedModel: () => undefined, + }; +} + +function fieldAttr(source: string): { node: FieldAttributeAst; ctx: InterpretCtx } { + const cursor = new Cursor(source); + const node = FieldAttributeAst.cast(createSyntaxTree(parseAttribute(cursor))); + if (!node) throw new Error('expected a field attribute'); + return { node, ctx: makeCtx(cursor.sourceFile) }; +} + +/** Parses a quoted-string argument into its decoded value; otherwise fails purely. */ +function str(): ArgType { + return { + kind: 'str', + label: 'string', + parse: (arg, ctx): Result => { + if (arg instanceof StringLiteralExprAst) { + const value = arg.value(); + if (value !== undefined) return ok(value); + } + return notOk([ + { + code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', + message: 'expected a quoted string', + sourceId: ctx.sourceId, + span: nodePslSpan(arg.syntax, ctx.sourceFile), + }, + ]); + }, + }; +} + +const FAILING_DIAGNOSTIC: PslDiagnostic = { + code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', + message: 'this leaf always fails', + sourceId: 'schema.prisma', + span: { start: { offset: 0, line: 1, column: 1 }, end: { offset: 0, line: 1, column: 1 } }, +}; + +/** A leaf that always fails, returning its diagnostic in the Result rather than a sink. */ +function failing(): ArgType { + return { + kind: 'failing', + label: 'failing', + parse: (): Result => notOk([FAILING_DIAGNOSTIC]), + }; +} + +describe('interpretAttribute positional binding', () => { + it('binds a positional argument into its slot key', () => { + const { node, ctx } = fieldAttr('@rel("Posts")'); + const spec = fieldAttribute('rel', { positional: [{ key: 'name', type: str() }] }); + + const result = interpretAttribute(node, spec, ctx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toEqual({ name: 'Posts' }); + }); + + it('collects a variadic positional slot into an array', () => { + const { node, ctx } = fieldAttr('@rel("a", "b")'); + const spec = fieldAttribute('rel', { + positional: [{ key: 'tags', type: str(), variadic: true }], + }); + + const result = interpretAttribute(node, spec, ctx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toEqual({ tags: ['a', 'b'] }); + }); + + it('rejects more positional arguments than declared slots', () => { + const { node, ctx } = fieldAttr('@rel("a", "b")'); + const spec = fieldAttribute('rel', { + positional: [{ key: 'name', type: optional(str()) }], + }); + + const result = interpretAttribute(node, spec, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure).toHaveLength(1); + expect(result.failure[0]?.code).toBe('PSL_INVALID_ATTRIBUTE_SYNTAX'); + expect(result.failure[0]?.span).toEqual(nodePslSpan(node.syntax, ctx.sourceFile)); + } + }); +}); + +describe('interpretAttribute named binding', () => { + it('binds named arguments by key', () => { + const { node, ctx } = fieldAttr('@rel(name: "Posts", map: "fk")'); + const spec = fieldAttribute('rel', { + named: { name: optional(str()), map: optional(str()) }, + }); + + const result = interpretAttribute(node, spec, ctx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toEqual({ name: 'Posts', map: 'fk' }); + }); + + it('rejects an unknown named argument anchored to the argument span', () => { + const { node, ctx } = fieldAttr('@rel(foo: "x")'); + const spec = fieldAttribute('rel', { + named: { name: optional(str()) }, + diagnosticCode: 'PSL_INVALID_RELATION_ATTRIBUTE', + }); + + const result = interpretAttribute(node, spec, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure).toHaveLength(1); + expect(result.failure[0]?.code).toBe('PSL_INVALID_RELATION_ATTRIBUTE'); + expect(result.failure[0]?.message).toContain('foo'); + expect(result.failure[0]?.span).not.toEqual(nodePslSpan(node.syntax, ctx.sourceFile)); + } + }); +}); + +describe('interpretAttribute positional-or-named alias', () => { + it('merges a positional and named value that agree', () => { + const { node, ctx } = fieldAttr('@rel("Posts", name: "Posts")'); + const spec = fieldAttribute('rel', { + positional: [{ key: 'name', type: optional(str()) }], + named: { name: optional(str()) }, + }); + + const result = interpretAttribute(node, spec, ctx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toEqual({ name: 'Posts' }); + }); + + it('reports a conflict when positional and named values disagree', () => { + const { node, ctx } = fieldAttr('@rel("A", name: "B")'); + const spec = fieldAttribute('rel', { + positional: [{ key: 'name', type: optional(str()) }], + named: { name: optional(str()) }, + diagnosticCode: 'PSL_INVALID_RELATION_ATTRIBUTE', + }); + + const result = interpretAttribute(node, spec, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure).toHaveLength(1); + expect(result.failure[0]?.code).toBe('PSL_INVALID_RELATION_ATTRIBUTE'); + expect(result.failure[0]?.span).toEqual(nodePslSpan(node.syntax, ctx.sourceFile)); + } + }); +}); + +describe('interpretAttribute optional and default application', () => { + it('applies a default for an absent optional argument', () => { + const { node, ctx } = fieldAttr('@rel()'); + const spec = fieldAttribute('rel', { + named: { map: optional(str(), 'default_fk') }, + }); + + const result = interpretAttribute(node, spec, ctx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toEqual({ map: 'default_fk' }); + }); + + it('omits an absent optional argument with no default', () => { + const { node, ctx } = fieldAttr('@rel()'); + const spec = fieldAttribute('rel', { named: { name: optional(str()) } }); + + const result = interpretAttribute(node, spec, ctx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toEqual({}); + }); + + it('overrides a default when the argument is present', () => { + const { node, ctx } = fieldAttr('@rel(map: "explicit")'); + const spec = fieldAttribute('rel', { + named: { map: optional(str(), 'default_fk') }, + }); + + const result = interpretAttribute(node, spec, ctx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toEqual({ map: 'explicit' }); + }); + + it('reports a missing required argument', () => { + const { node, ctx } = fieldAttr('@rel()'); + const spec = fieldAttribute('rel', { named: { name: str() } }); + + const result = interpretAttribute(node, spec, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.failure).toHaveLength(1); + }); +}); + +describe('interpretAttribute refine', () => { + it('runs refine on the parsed output and surfaces its diagnostics', () => { + const { node, ctx } = fieldAttr('@rel(name: "bad")'); + const seen: string[] = []; + const spec = fieldAttribute('rel', { + named: { name: optional(str()) }, + refine: (parsed, refineCtx): readonly PslDiagnostic[] => { + if (parsed.name !== undefined) seen.push(parsed.name); + return [ + { + code: 'PSL_INVALID_RELATION_ATTRIBUTE', + message: 'refine rejected the value', + sourceId: refineCtx.sourceId, + span: nodePslSpan(node.syntax, refineCtx.sourceFile), + }, + ]; + }, + }); + + const result = interpretAttribute(node, spec, ctx); + + expect(seen).toEqual(['bad']); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure).toHaveLength(1); + expect(result.failure[0]?.message).toBe('refine rejected the value'); + } + }); + + it('returns ok when refine reports no diagnostics', () => { + const { node, ctx } = fieldAttr('@rel(name: "ok")'); + const spec = fieldAttribute('rel', { + named: { name: optional(str()) }, + refine: (): readonly PslDiagnostic[] => [], + }); + + const result = interpretAttribute(node, spec, ctx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toEqual({ name: 'ok' }); + }); + + it('does not run refine when an argument fails to parse', () => { + const { node, ctx } = fieldAttr('@rel(name: "x")'); + let refined = false; + const spec = fieldAttribute('rel', { + named: { name: failing() }, + refine: (): readonly PslDiagnostic[] => { + refined = true; + return []; + }, + }); + + const result = interpretAttribute(node, spec, ctx); + + expect(refined).toBe(false); + expect(result.ok).toBe(false); + }); +}); + +describe('interpretAttribute leaf purity', () => { + it('threads a failing leaf diagnostic through the Result rather than a sink', () => { + const { node, ctx } = fieldAttr('@rel(name: "x")'); + const spec = fieldAttribute('rel', { named: { name: failing() } }); + + const result = interpretAttribute(node, spec, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure).toEqual([FAILING_DIAGNOSTIC]); + } + }); +}); From d3172fc1c78fb516c39af7946031f1241418b157 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 29 Jun 2026 18:01:48 +0000 Subject: [PATCH 02/26] feat(psl-parser): add declarative attribute-spec engine and core types Signed-off-by: Serhii Tatarintsev --- .../src/attribute-spec/field-attribute.ts | 34 +++ .../src/attribute-spec/interpret.ts | 237 ++++++++++++++++++ .../psl-parser/src/attribute-spec/optional.ts | 13 + .../psl-parser/src/attribute-spec/types.ts | 119 +++++++++ .../psl-parser/src/exports/index.ts | 17 ++ 5 files changed, 420 insertions(+) create mode 100644 packages/1-framework/2-authoring/psl-parser/src/attribute-spec/field-attribute.ts create mode 100644 packages/1-framework/2-authoring/psl-parser/src/attribute-spec/interpret.ts create mode 100644 packages/1-framework/2-authoring/psl-parser/src/attribute-spec/optional.ts create mode 100644 packages/1-framework/2-authoring/psl-parser/src/attribute-spec/types.ts diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/field-attribute.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/field-attribute.ts new file mode 100644 index 0000000000..596000c327 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/field-attribute.ts @@ -0,0 +1,34 @@ +import type { PslDiagnostic, PslDiagnosticCode } from '@prisma-next/framework-components/psl-ast'; +import type { AttributeOut, AttributeSpec, InterpretCtx, Param, PositionalParam } from './types'; + +interface FieldAttributeConfig< + Pos extends readonly PositionalParam[], + Named extends Record>, +> { + readonly positional?: Pos; + readonly named?: Named; + readonly refine?: ( + parsed: AttributeOut, + ctx: InterpretCtx, + ) => readonly PslDiagnostic[]; + readonly diagnosticCode?: PslDiagnosticCode; +} + +/** + * Builds a field-level `AttributeSpec`. The output type is inferred from the + * positional and named parameters, so there is no hand-written output interface + * to drift from the spec. + */ +export function fieldAttribute< + const Pos extends readonly PositionalParam[] = readonly [], + const Named extends Record> = Record, +>(name: string, config: FieldAttributeConfig): AttributeSpec> { + return { + level: 'field', + name, + positional: config.positional ?? [], + named: config.named ?? {}, + ...(config.refine !== undefined ? { refine: config.refine } : {}), + ...(config.diagnosticCode !== undefined ? { diagnosticCode: config.diagnosticCode } : {}), + }; +} diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/interpret.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/interpret.ts new file mode 100644 index 0000000000..fec07c5cf8 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/interpret.ts @@ -0,0 +1,237 @@ +import type { + PslDiagnostic, + PslDiagnosticCode, + PslSpan, +} from '@prisma-next/framework-components/psl-ast'; +import { blindCast } from '@prisma-next/utils/casts'; +import { notOk, ok, type Result } from '@prisma-next/utils/result'; +import { nodePslSpan } from '../resolve'; +import type { FieldAttributeAst, ModelAttributeAst } from '../syntax/ast/attributes'; +import type { AttributeArgAst } from '../syntax/ast/expressions'; +import type { ArgType, AttributeSpec, InterpretCtx, OptionalParam, Param } from './types'; + +const DEFAULT_STRUCTURAL_CODE: PslDiagnosticCode = 'PSL_INVALID_ATTRIBUTE_SYNTAX'; + +/** + * Interprets an attribute node against its spec: binds positional arguments in + * order and named arguments by key into one flat output keyspace, honours the + * positional-or-named alias, rejects unknown named arguments, applies optional + * defaults, then runs `refine`. Returns the spec-inferred output or diagnostics. + */ +export function interpretAttribute( + attrNode: FieldAttributeAst | ModelAttributeAst, + spec: AttributeSpec, + ctx: InterpretCtx, +): Result { + const diagnostics: PslDiagnostic[] = []; + const code = spec.diagnosticCode ?? DEFAULT_STRUCTURAL_CODE; + const attributeSpan = nodePslSpan(attrNode.syntax, ctx.sourceFile); + + const positionalArgs: AttributeArgAst[] = []; + const namedArgs: AttributeArgAst[] = []; + for (const arg of attrNode.argList()?.args() ?? []) { + if (arg.name() === undefined) positionalArgs.push(arg); + else namedArgs.push(arg); + } + + const namedSeen = new Set(); + const namedParsed = new Map(); + for (const arg of namedArgs) { + const key = arg.name()?.name(); + if (key === undefined) continue; + const param = Object.hasOwn(spec.named, key) ? spec.named[key] : undefined; + if (param === undefined) { + diagnostics.push( + diagnostic( + code, + `Attribute "${spec.name}" received unknown argument "${key}"`, + ctx, + nodePslSpan(arg.syntax, ctx.sourceFile), + ), + ); + continue; + } + if (namedSeen.has(key)) continue; + namedSeen.add(key); + const result = parseArgValue(arg, argTypeOf(param), ctx, diagnostics, code); + if (result.ok) namedParsed.set(key, result.value); + } + + const positionalSeen = new Set(); + const positionalParsed = new Map(); + let index = 0; + for (const param of spec.positional) { + if (param.variadic) { + const collected: unknown[] = []; + for (; index < positionalArgs.length; index++) { + const arg = positionalArgs[index]; + if (arg === undefined) continue; + const result = parseArgValue(arg, argTypeOf(param.type), ctx, diagnostics, code); + if (result.ok) collected.push(result.value); + } + positionalSeen.add(param.key); + positionalParsed.set(param.key, collected); + continue; + } + const arg = positionalArgs[index]; + if (arg === undefined) continue; + index += 1; + positionalSeen.add(param.key); + const result = parseArgValue(arg, argTypeOf(param.type), ctx, diagnostics, code); + if (result.ok) positionalParsed.set(param.key, result.value); + } + if (index < positionalArgs.length) { + diagnostics.push( + diagnostic( + code, + `Attribute "${spec.name}" received too many positional arguments`, + ctx, + attributeSpan, + ), + ); + } + + const output: Record = {}; + const handled = new Set(); + const resolveKey = ( + key: string, + positionalParam: Param | undefined, + namedParam: Param | undefined, + ): void => { + if (handled.has(key)) return; + handled.add(key); + const fromPositional = positionalSeen.has(key); + const fromNamed = namedSeen.has(key); + + if (fromPositional && fromNamed) { + const hasPositional = positionalParsed.has(key); + const hasNamed = namedParsed.has(key); + if ( + hasPositional && + hasNamed && + !argValuesEqual(positionalParsed.get(key), namedParsed.get(key)) + ) { + diagnostics.push( + diagnostic( + code, + `Attribute "${spec.name}" has conflicting positional and named values for "${key}"`, + ctx, + attributeSpan, + ), + ); + } + if (hasNamed) output[key] = namedParsed.get(key); + else if (hasPositional) output[key] = positionalParsed.get(key); + return; + } + if (fromNamed) { + if (namedParsed.has(key)) output[key] = namedParsed.get(key); + return; + } + if (fromPositional) { + if (positionalParsed.has(key)) output[key] = positionalParsed.get(key); + return; + } + + const effective = namedParam ?? positionalParam; + if (effective === undefined) return; + if (isOptionalParam(effective)) { + if (effective.hasDefault) output[key] = effective.defaultValue; + return; + } + diagnostics.push( + diagnostic( + code, + `Attribute "${spec.name}" is missing required argument "${key}"`, + ctx, + attributeSpan, + ), + ); + }; + + for (const param of spec.positional) { + const namedParam = Object.hasOwn(spec.named, param.key) ? spec.named[param.key] : undefined; + resolveKey(param.key, param.type, namedParam); + } + for (const key of Object.keys(spec.named)) { + resolveKey(key, undefined, spec.named[key]); + } + + if (diagnostics.length > 0) { + return notOk(diagnostics); + } + + const value = blindCast< + Out, + 'The engine builds the output object structurally from the spec; TypeScript cannot relate the dynamically-keyed record to the spec-inferred output type.' + >(output); + if (spec.refine !== undefined) { + const refineDiagnostics = spec.refine(value, ctx); + if (refineDiagnostics.length > 0) { + return notOk(refineDiagnostics); + } + } + return ok(value); +} + +function parseArgValue( + arg: AttributeArgAst, + argType: ArgType, + ctx: InterpretCtx, + diagnostics: PslDiagnostic[], + code: PslDiagnosticCode, +): Result { + const value = arg.value(); + if (value === undefined) { + const missing = diagnostic( + code, + 'Attribute argument is missing a value', + ctx, + nodePslSpan(arg.syntax, ctx.sourceFile), + ); + diagnostics.push(missing); + return notOk([missing]); + } + const result = argType.parse(value, ctx); + if (!result.ok) { + for (const failure of result.failure) diagnostics.push(failure); + } + return result; +} + +function isOptionalParam(param: Param): param is OptionalParam { + return 'optional' in param && param.optional === true; +} + +function argTypeOf(param: Param): ArgType { + return isOptionalParam(param) ? param.type : param; +} + +function diagnostic( + code: PslDiagnosticCode, + message: string, + ctx: InterpretCtx, + span: PslSpan, +): PslDiagnostic { + return { code, message, sourceId: ctx.sourceId, span }; +} + +function argValuesEqual(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true; + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((element, i) => argValuesEqual(element, b[i])); + } + if (isPlainRecord(a) && isPlainRecord(b)) { + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + return ( + aKeys.length === bKeys.length && + aKeys.every((key) => Object.hasOwn(b, key) && argValuesEqual(a[key], b[key])) + ); + } + return false; +} + +function isPlainRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/optional.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/optional.ts new file mode 100644 index 0000000000..76d91e3302 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/optional.ts @@ -0,0 +1,13 @@ +import type { ArgType, OptionalParam } from './types'; + +/** + * Marks a parameter optional. With a second argument, the value is applied as a + * default when the argument is absent; without one, an absent argument leaves + * the output property unset. + */ +export function optional(type: ArgType, ...rest: [defaultValue: T] | []): OptionalParam { + if (rest.length === 0) { + return { optional: true, type, hasDefault: false }; + } + return { optional: true, type, hasDefault: true, defaultValue: rest[0] }; +} 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 new file mode 100644 index 0000000000..2ba058a101 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/types.ts @@ -0,0 +1,119 @@ +import type { PslDiagnostic, PslDiagnosticCode } from '@prisma-next/framework-components/psl-ast'; +import type { Result } from '@prisma-next/utils/result'; +import type { SourceFile } from '../source-file'; +import type { FieldSymbol, ModelSymbol, SymbolTable } from '../symbol-table'; +import type { ExpressionAst } from '../syntax/ast/expressions'; + +/** Flattens an intersection of mapped types into a single readable object type. */ +type Simplify = { [K in keyof T]: T[K] } & {}; + +type UnionToIntersection = (U extends unknown ? (k: U) => void : never) extends ( + k: infer I, +) => void + ? I + : never; + +export type AttributeLevel = 'field' | 'model' | 'block'; + +/** + * Parses one attribute argument from the parser's CST into a value of type `T`, + * carrying that type at the type level so an attribute's output object can be + * inferred from its spec. + * + * Parsing is pure: a leaf returns its diagnostics in the `Result` rather than + * pushing them into a shared sink, so an alternative can be tried and discarded + * without leaving stray errors behind. + */ +export interface ArgType { + /** Discriminant for visitor dispatch (print, completion, doc generation). */ + readonly kind: string; + /** Human-readable label, for "expected …" diagnostics. */ + readonly label: string; + /** Phantom carrier for `T`; never read at runtime. */ + readonly _out?: T; + parse(arg: ExpressionAst, ctx: InterpretCtx): Result; +} + +/** + * The resolution context threaded through every `parse`. It carries the source + * coordinates the engine anchors diagnostics to (`sourceId` + `sourceFile`) and + * the PSL symbol-table handles reference combinators resolve against. + * + * Deliberately lean: codec-lookup / default-function-registry handles are added + * only once a combinator needs them, so the kit does not pull those dependencies + * into the parser layer before they are used. + */ +export interface InterpretCtx { + readonly level: AttributeLevel; + /** Identifier of the source the attribute was parsed from; stamped onto diagnostics. */ + readonly sourceId: string; + /** The parsed source, used to resolve node offsets into diagnostic spans. */ + readonly sourceFile: SourceFile; + readonly symbols: SymbolTable; + /** The declaring model; the resolution target for a self-scoped field reference. */ + readonly selfModel: ModelSymbol; + /** A relation's target model; the resolution target for a referenced-scoped field reference. */ + resolveReferencedModel(): ModelSymbol | undefined; + /** The resolved declaring field; present only at field level. */ + readonly field?: FieldSymbol; +} + +/** An optional parameter, optionally carrying a default applied when the argument is absent. */ +export interface OptionalParam { + readonly optional: true; + readonly type: ArgType; + readonly hasDefault: boolean; + readonly defaultValue?: T; +} + +/** A parameter is a bare `ArgType` (required) or an `optional(...)` wrapper. */ +export type Param = ArgType | OptionalParam; + +export interface PositionalParam { + /** The output key this slot writes into. */ + readonly key: string; + readonly type: Param; + /** A trailing rest slot that consumes every remaining positional argument. */ + readonly variadic?: boolean; +} + +export interface AttributeSpec { + readonly level: AttributeLevel; + readonly name: string; + readonly positional: readonly PositionalParam[]; + readonly named: Readonly>>; + readonly refine?: (parsed: Out, ctx: InterpretCtx) => readonly PslDiagnostic[]; + /** + * Code for engine-emitted structural diagnostics (unknown argument, excess + * positional argument, alias conflict). Defaults to `PSL_INVALID_ATTRIBUTE_SYNTAX`; + * an attribute that must preserve a specific code overrides it here. + */ + readonly diagnosticCode?: PslDiagnosticCode; +} + +export type OutOf

= + P extends OptionalParam ? T : P extends ArgType ? T : never; + +export type NamedOut>> = Simplify< + { [K in keyof N as N[K] extends OptionalParam ? never : K]: OutOf } & { + [K in keyof N as N[K] extends OptionalParam ? K : never]?: OutOf; + } +>; + +type PosEntryObject = E extends { variadic: true } + ? { [K in E['key']]: readonly OutOf[] } + : E['type'] extends OptionalParam + ? { [K in E['key']]?: OutOf } + : { [K in E['key']]: OutOf }; + +export type PosOut = Simplify< + UnionToIntersection<{ [I in keyof Pos]: PosEntryObject }[number]> +>; + +export type AttributeOut< + Pos extends readonly PositionalParam[], + Named extends Record>, +> = Simplify & NamedOut>; + +export type InferAttr> = + S extends AttributeSpec ? Out : never; 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 7ff2425a76..5019e73a31 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 @@ -36,6 +36,23 @@ export { namespacePslExtensionBlocks, } from '@prisma-next/framework-components/psl-ast'; export { getPositionalArgument, parseQuotedStringLiteral } from '../attribute-helpers'; +export { fieldAttribute } from '../attribute-spec/field-attribute'; +export { interpretAttribute } from '../attribute-spec/interpret'; +export { optional } from '../attribute-spec/optional'; +export type { + ArgType, + AttributeLevel, + AttributeOut, + AttributeSpec, + InferAttr, + InterpretCtx, + NamedOut, + OptionalParam, + OutOf, + Param, + PositionalParam, + PosOut, +} from '../attribute-spec/types'; export { findBlockDescriptor, validateExtensionBlockFromSymbol } from '../extension-block'; export { keywordPslSpan, From ce2fa8cffe2862bebb9eb931303458c5aaa271fb Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 29 Jun 2026 19:34:09 +0000 Subject: [PATCH 03/26] feat(psl-parser): thread the active attribute diagnostic code to leaves Add diagnosticCode to InterpretCtx and have interpretAttribute populate a leaf-facing context from the spec before each leaf parse, so a leaf emits with the attribute code rather than a hard-coded generic. Signed-off-by: Serhii Tatarintsev --- .../src/attribute-spec/interpret.ts | 9 ++-- .../psl-parser/src/attribute-spec/types.ts | 8 ++++ .../psl-parser/test/attribute-spec.test.ts | 44 ++++++++++++++++++- 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/interpret.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/interpret.ts index fec07c5cf8..4d8b87ead9 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/interpret.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/interpret.ts @@ -25,6 +25,7 @@ export function interpretAttribute( ): Result { const diagnostics: PslDiagnostic[] = []; const code = spec.diagnosticCode ?? DEFAULT_STRUCTURAL_CODE; + const leafCtx: InterpretCtx = { ...ctx, diagnosticCode: code }; const attributeSpan = nodePslSpan(attrNode.syntax, ctx.sourceFile); const positionalArgs: AttributeArgAst[] = []; @@ -53,7 +54,7 @@ export function interpretAttribute( } if (namedSeen.has(key)) continue; namedSeen.add(key); - const result = parseArgValue(arg, argTypeOf(param), ctx, diagnostics, code); + const result = parseArgValue(arg, argTypeOf(param), leafCtx, diagnostics, code); if (result.ok) namedParsed.set(key, result.value); } @@ -66,7 +67,7 @@ export function interpretAttribute( for (; index < positionalArgs.length; index++) { const arg = positionalArgs[index]; if (arg === undefined) continue; - const result = parseArgValue(arg, argTypeOf(param.type), ctx, diagnostics, code); + const result = parseArgValue(arg, argTypeOf(param.type), leafCtx, diagnostics, code); if (result.ok) collected.push(result.value); } positionalSeen.add(param.key); @@ -77,7 +78,7 @@ export function interpretAttribute( if (arg === undefined) continue; index += 1; positionalSeen.add(param.key); - const result = parseArgValue(arg, argTypeOf(param.type), ctx, diagnostics, code); + const result = parseArgValue(arg, argTypeOf(param.type), leafCtx, diagnostics, code); if (result.ok) positionalParsed.set(param.key, result.value); } if (index < positionalArgs.length) { @@ -166,7 +167,7 @@ export function interpretAttribute( 'The engine builds the output object structurally from the spec; TypeScript cannot relate the dynamically-keyed record to the spec-inferred output type.' >(output); if (spec.refine !== undefined) { - const refineDiagnostics = spec.refine(value, ctx); + const refineDiagnostics = spec.refine(value, leafCtx); if (refineDiagnostics.length > 0) { return notOk(refineDiagnostics); } 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 2ba058a101..3b611ed24c 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 @@ -45,6 +45,14 @@ export interface ArgType { */ export interface InterpretCtx { readonly level: AttributeLevel; + /** + * The code a leaf stamps onto the diagnostics it emits. `interpretAttribute` + * populates it from the active spec's `diagnosticCode` before calling any + * leaf, so a combinator emits with the attribute's code rather than a + * hard-coded generic. When a leaf is exercised directly (outside the engine), + * the caller sets it. + */ + readonly diagnosticCode: PslDiagnosticCode; /** Identifier of the source the attribute was parsed from; stamped onto diagnostics. */ readonly sourceId: string; /** The parsed source, used to resolve node offsets into diagnostic spans. */ 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 e34ea118a1..5e0df1981f 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 @@ -10,7 +10,10 @@ import { FieldAttributeAst } from '../src/syntax/ast/attributes'; import { StringLiteralExprAst } from '../src/syntax/ast/expressions'; import { createSyntaxTree } from '../src/syntax/red'; -function makeCtx(sourceFile: SourceFile): InterpretCtx { +function makeCtx( + sourceFile: SourceFile, + diagnosticCode: PslDiagnostic['code'] = 'PSL_INVALID_ATTRIBUTE_SYNTAX', +): InterpretCtx { const { document, sourceFile: modelSource } = parse('model M {\n id Int @id\n}\n'); const { table } = buildSymbolTable({ document, @@ -27,6 +30,7 @@ function makeCtx(sourceFile: SourceFile): InterpretCtx { symbols: table, selfModel, resolveReferencedModel: () => undefined, + diagnosticCode, }; } @@ -299,3 +303,41 @@ describe('interpretAttribute leaf purity', () => { } }); }); + +/** A leaf that emits a diagnostic carrying whatever code the context threads to it. */ +function codeEcho(): ArgType { + return { + kind: 'codeEcho', + label: 'codeEcho', + parse: (arg, ctx): Result => + notOk([ + { + code: ctx.diagnosticCode, + message: 'leaf emitted with the threaded code', + sourceId: ctx.sourceId, + span: nodePslSpan(arg.syntax, ctx.sourceFile), + }, + ]), + }; +} + +describe('interpretAttribute diagnostic-code threading', () => { + it('drives the leaf context code from the spec, overriding the caller baseline', () => { + const cursor = new Cursor('@rel(name: "x")'); + const node = FieldAttributeAst.cast(createSyntaxTree(parseAttribute(cursor))); + if (!node) throw new Error('expected a field attribute'); + const ctx = makeCtx(cursor.sourceFile, 'PSL_INVALID_ATTRIBUTE_SYNTAX'); + const spec = fieldAttribute('rel', { + named: { name: codeEcho() }, + diagnosticCode: 'PSL_INVALID_RELATION_ATTRIBUTE', + }); + + const result = interpretAttribute(node, spec, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure).toHaveLength(1); + expect(result.failure[0]?.code).toBe('PSL_INVALID_RELATION_ATTRIBUTE'); + } + }); +}); From 7018247879c59ba69c2dcd2aafe402da2c13f82a Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 29 Jun 2026 19:38:26 +0000 Subject: [PATCH 04/26] feat(psl-parser): add the str/enumOf/fieldRef/list argument combinators Author the domain combinators @relation needs as ArgTypes over the expression AST, beside the attribute-spec engine, and export them from the package surface. enumOf types a homogeneous or mixed string/number set via a const tuple and a membership type-guard (no arktype: the value is already a parsed primitive and the check is a trivial closed set). fieldRef returns the bare name and carries scope metadata, deliberately leaving field-existence validation to the downstream interpreter. list lifts an element combinator over an array literal with nonEmpty/unique. Leaves emit through a shared helper stamped with ctx.diagnosticCode. Signed-off-by: Serhii Tatarintsev --- .../attribute-spec/combinators/diagnostic.ts | 18 ++ .../src/attribute-spec/combinators/enum-of.ts | 37 ++++ .../attribute-spec/combinators/field-ref.ts | 38 ++++ .../src/attribute-spec/combinators/list.ts | 49 +++++ .../src/attribute-spec/combinators/str.ts | 20 ++ .../psl-parser/src/exports/index.ts | 6 + .../test/attribute-spec-combinators.test-d.ts | 15 ++ .../test/attribute-spec-combinators.test.ts | 208 ++++++++++++++++++ 8 files changed, 391 insertions(+) create mode 100644 packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/diagnostic.ts create mode 100644 packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/enum-of.ts create mode 100644 packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/field-ref.ts create mode 100644 packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/list.ts create mode 100644 packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/str.ts create mode 100644 packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.test-d.ts create mode 100644 packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.test.ts 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 new file mode 100644 index 0000000000..eab4a25a66 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/diagnostic.ts @@ -0,0 +1,18 @@ +import type { PslDiagnostic } from '@prisma-next/framework-components/psl-ast'; +import { nodePslSpan } from '../../resolve'; +import type { AstNode } from '../../syntax/ast-helpers'; +import type { InterpretCtx } from '../types'; + +/** + * Builds a leaf diagnostic anchored to the offending `node`, stamped with the + * code threaded through `ctx`. Combinators emit through this helper so every + * leaf carries the active attribute's code rather than a hard-coded generic. + */ +export function leafDiagnostic(ctx: InterpretCtx, node: AstNode, message: string): PslDiagnostic { + return { + code: ctx.diagnosticCode, + message, + sourceId: ctx.sourceId, + span: nodePslSpan(node.syntax, ctx.sourceFile), + }; +} diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/enum-of.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/enum-of.ts new file mode 100644 index 0000000000..da0c567697 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/enum-of.ts @@ -0,0 +1,37 @@ +import type { PslDiagnostic } from '@prisma-next/framework-components/psl-ast'; +import { notOk, ok, type Result } from '@prisma-next/utils/result'; +import { NumberLiteralExprAst, StringLiteralExprAst } from '../../syntax/ast/expressions'; +import type { ArgType } from '../types'; +import { leafDiagnostic } from './diagnostic'; + +type EnumMember = string | number; + +/** + * Parses a string- or number-literal argument that is a member of a fixed set. + * Members may mix strings and numbers, so a single `enumOf` types a homogeneous + * or a mixed set; the matched member is returned with its literal type preserved. + */ +export function enumOf( + ...values: Values +): ArgType { + const members: readonly EnumMember[] = values; + const label = members + .map((member) => (typeof member === 'string' ? `"${member}"` : String(member))) + .join(' | '); + const isMember = (candidate: EnumMember): candidate is Values[number] => + members.includes(candidate); + return { + kind: 'enumOf', + label, + parse: (arg, ctx): Result => { + const value = + arg instanceof StringLiteralExprAst + ? arg.value() + : arg instanceof NumberLiteralExprAst + ? arg.value() + : undefined; + if (value !== undefined && isMember(value)) return ok(value); + return notOk([leafDiagnostic(ctx, arg, `Expected one of: ${label}`)]); + }, + }; +} diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/field-ref.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/field-ref.ts new file mode 100644 index 0000000000..bcad878410 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/field-ref.ts @@ -0,0 +1,38 @@ +import type { PslDiagnostic } from '@prisma-next/framework-components/psl-ast'; +import { notOk, ok, type Result } from '@prisma-next/utils/result'; +import { IdentifierAst } from '../../syntax/ast/identifier'; +import type { ArgType } from '../types'; +import { leafDiagnostic } from './diagnostic'; + +/** + * The entity a field name resolves against: the declaring model (`'self'`) or a + * relation's target model (`'referenced'`). Carried for the language server; + * the value parsed at runtime is just the name. + */ +export type FieldRefScope = 'self' | 'referenced'; + +/** A field-name combinator tagged with the scope its name resolves against. */ +export interface FieldRefArgType extends ArgType { + readonly scope: FieldRefScope; +} + +/** + * Parses a bare identifier into the field name. Existence is deliberately not + * checked here: the downstream interpreter validates the field against the + * scoped entity, so a parse-time check would emit a second diagnostic for the + * same fault. + */ +export function fieldRef(scope: FieldRefScope): FieldRefArgType { + return { + kind: 'fieldRef', + label: 'field name', + scope, + parse: (arg, ctx): Result => { + if (arg instanceof IdentifierAst) { + const name = arg.name(); + if (name !== undefined) return ok(name); + } + return notOk([leafDiagnostic(ctx, arg, 'Expected a field name')]); + }, + }; +} 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 new file mode 100644 index 0000000000..59574133f3 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/list.ts @@ -0,0 +1,49 @@ +import type { PslDiagnostic } from '@prisma-next/framework-components/psl-ast'; +import { notOk, ok, type Result } from '@prisma-next/utils/result'; +import { ArrayLiteralAst, type ExpressionAst } from '../../syntax/ast/expressions'; +import type { ArgType } from '../types'; +import { leafDiagnostic } from './diagnostic'; + +/** Surface constraints a `list` enforces over its parsed elements. */ +export interface ListOptions { + readonly nonEmpty?: boolean; + readonly unique?: boolean; +} + +/** + * Lifts an element combinator over a `[…]` array literal into `T[]`, threading + * each element through `of` and enforcing the optional surface constraints. + * Element errors are collected and propagated; `nonEmpty` and `unique` add their + * own diagnostics, anchored to the array and to each offending element. + */ +export function list(of: ArgType, opts?: ListOptions): ArgType { + return { + kind: 'list', + label: `${of.label}[]`, + parse: (arg, ctx): Result => { + if (!(arg instanceof ArrayLiteralAst)) { + return notOk([leafDiagnostic(ctx, arg, `Expected a list of ${of.label}`)]); + } + const elements = [...arg.elements()]; + const diagnostics: PslDiagnostic[] = []; + const parsed: { node: ExpressionAst; value: T }[] = []; + for (const element of elements) { + const result = of.parse(element, ctx); + if (result.ok) parsed.push({ node: element, value: result.value }); + else diagnostics.push(...result.failure); + } + if (opts?.nonEmpty === true && elements.length === 0) { + diagnostics.push(leafDiagnostic(ctx, arg, 'Expected a non-empty list')); + } + if (opts?.unique === true) { + const seen = new Set(); + for (const { node, value } of parsed) { + if (seen.has(value)) diagnostics.push(leafDiagnostic(ctx, node, 'Duplicate list entry')); + else seen.add(value); + } + } + if (diagnostics.length > 0) return notOk(diagnostics); + return ok(parsed.map((entry) => entry.value)); + }, + }; +} diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/str.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/str.ts new file mode 100644 index 0000000000..2b87d724c0 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/str.ts @@ -0,0 +1,20 @@ +import type { PslDiagnostic } from '@prisma-next/framework-components/psl-ast'; +import { notOk, ok, type Result } from '@prisma-next/utils/result'; +import { StringLiteralExprAst } from '../../syntax/ast/expressions'; +import type { ArgType } from '../types'; +import { leafDiagnostic } from './diagnostic'; + +/** Parses a quoted string-literal argument into its decoded value. */ +export function str(): ArgType { + return { + kind: 'str', + label: 'string', + parse: (arg, ctx): Result => { + if (arg instanceof StringLiteralExprAst) { + const value = arg.value(); + if (value !== undefined) return ok(value); + } + return notOk([leafDiagnostic(ctx, arg, 'Expected a string literal')]); + }, + }; +} 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 5019e73a31..0b3444a3e0 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 @@ -36,6 +36,12 @@ export { namespacePslExtensionBlocks, } from '@prisma-next/framework-components/psl-ast'; export { getPositionalArgument, parseQuotedStringLiteral } from '../attribute-helpers'; +export { enumOf } from '../attribute-spec/combinators/enum-of'; +export type { FieldRefArgType, FieldRefScope } from '../attribute-spec/combinators/field-ref'; +export { fieldRef } from '../attribute-spec/combinators/field-ref'; +export type { ListOptions } from '../attribute-spec/combinators/list'; +export { list } from '../attribute-spec/combinators/list'; +export { str } from '../attribute-spec/combinators/str'; export { fieldAttribute } from '../attribute-spec/field-attribute'; export { interpretAttribute } from '../attribute-spec/interpret'; export { optional } from '../attribute-spec/optional'; 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 new file mode 100644 index 0000000000..11d9465ab4 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.test-d.ts @@ -0,0 +1,15 @@ +import { expectTypeOf, test } from 'vitest'; +import type { ArgType } from '../src/exports'; +import { enumOf, list, str } from '../src/exports'; + +test('enumOf preserves a homogeneous string member union', () => { + expectTypeOf(enumOf('NoAction', 'Cascade')).toEqualTypeOf>(); +}); + +test('enumOf carries a mixed string/number member union', () => { + expectTypeOf(enumOf('text', 1, -1)).toEqualTypeOf>(); +}); + +test('list infers an array of its element type', () => { + expectTypeOf(list(str())).toEqualTypeOf>(); +}); 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 new file mode 100644 index 0000000000..1c9b9c6b5a --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.test.ts @@ -0,0 +1,208 @@ +import type { PslDiagnostic } from '@prisma-next/framework-components/psl-ast'; +import { describe, expect, it } from 'vitest'; +import type { InterpretCtx } from '../src/exports'; +import { enumOf, fieldAttribute, fieldRef, interpretAttribute, list, str } from '../src/exports'; +import { Cursor, parse, parseAttribute } from '../src/parse'; +import type { SourceFile } from '../src/source-file'; +import { buildSymbolTable } from '../src/symbol-table'; +import { FieldAttributeAst } from '../src/syntax/ast/attributes'; +import type { ExpressionAst } from '../src/syntax/ast/expressions'; +import { createSyntaxTree } from '../src/syntax/red'; + +function makeCtx( + sourceFile: SourceFile, + diagnosticCode: PslDiagnostic['code'] = 'PSL_INVALID_ATTRIBUTE_SYNTAX', +): InterpretCtx { + const { document, sourceFile: modelSource } = parse('model M {\n id Int @id\n}\n'); + const { table } = buildSymbolTable({ + document, + sourceFile: modelSource, + scalarTypes: ['String', 'Int'], + pslBlockDescriptors: {}, + }); + const selfModel = table.topLevel.models.M; + if (!selfModel) throw new Error('expected model M in the symbol table'); + return { + level: 'field', + sourceId: 'schema.prisma', + sourceFile, + symbols: table, + selfModel, + resolveReferencedModel: () => undefined, + diagnosticCode, + }; +} + +/** Parses `@x()` and returns the first argument's expression plus a context. */ +function argOf(exprSource: string): { expr: ExpressionAst; ctx: InterpretCtx } { + const cursor = new Cursor(`@x(${exprSource})`); + const node = FieldAttributeAst.cast(createSyntaxTree(parseAttribute(cursor))); + if (!node) throw new Error('expected a field attribute'); + const first = [...(node.argList()?.args() ?? [])][0]; + const expr = first?.value(); + if (!expr) throw new Error('expected an argument expression'); + return { expr, ctx: makeCtx(cursor.sourceFile) }; +} + +describe('str', () => { + it('parses a quoted string into its value', () => { + const { expr, ctx } = argOf('"Posts"'); + + const result = str().parse(expr, ctx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toBe('Posts'); + }); + + it('rejects a non-string token with the threaded code', () => { + const { expr, ctx } = argOf('42'); + + const result = str().parse(expr, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure).toHaveLength(1); + expect(result.failure[0]?.code).toBe('PSL_INVALID_ATTRIBUTE_SYNTAX'); + } + }); +}); + +describe('enumOf', () => { + it('accepts a string member of a mixed set', () => { + const { expr, ctx } = argOf('"Cascade"'); + + const result = enumOf('Cascade', 'SetNull', 1, 2).parse(expr, ctx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toBe('Cascade'); + }); + + it('accepts a number member of a mixed set', () => { + const { expr, ctx } = argOf('2'); + + const result = enumOf('Cascade', 'SetNull', 1, 2).parse(expr, ctx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toBe(2); + }); + + it('rejects a non-member of the set', () => { + const { expr, ctx } = argOf('"Nope"'); + + const result = enumOf('Cascade', 'SetNull').parse(expr, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.failure[0]?.code).toBe('PSL_INVALID_ATTRIBUTE_SYNTAX'); + }); + + it('rejects a token of the wrong kind', () => { + const { expr, ctx } = argOf('["Cascade"]'); + + const result = enumOf('Cascade', 1).parse(expr, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.failure).toHaveLength(1); + }); +}); + +describe('fieldRef', () => { + it('returns the bare identifier name', () => { + const { expr, ctx } = argOf('title'); + + const result = fieldRef('self').parse(expr, ctx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toBe('title'); + }); + + it('returns the name without emitting an existence diagnostic for an unknown field', () => { + const { expr, ctx } = argOf('ghostField'); + + const result = fieldRef('self').parse(expr, ctx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toBe('ghostField'); + }); + + it('carries the scope as combinator metadata', () => { + expect(fieldRef('self').scope).toBe('self'); + expect(fieldRef('referenced').scope).toBe('referenced'); + }); + + it('rejects a non-identifier token', () => { + const { expr, ctx } = argOf('"title"'); + + const result = fieldRef('self').parse(expr, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.failure[0]?.code).toBe('PSL_INVALID_ATTRIBUTE_SYNTAX'); + }); +}); + +describe('list', () => { + it('maps each element through the element combinator', () => { + const { expr, ctx } = argOf('["a", "b"]'); + + const result = list(str()).parse(expr, ctx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toEqual(['a', 'b']); + }); + + it('rejects an empty list when nonEmpty is set', () => { + const { expr, ctx } = argOf('[]'); + + const result = list(str(), { nonEmpty: true }).parse(expr, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.failure).toHaveLength(1); + }); + + it('rejects duplicates when unique is set, anchored per offending element', () => { + const { expr, ctx } = argOf('["a", "a"]'); + + const result = list(str(), { unique: true }).parse(expr, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.failure).toHaveLength(1); + }); + + it('propagates an element parse error', () => { + const { expr, ctx } = argOf('["a", 1]'); + + const result = list(str()).parse(expr, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.failure[0]?.code).toBe('PSL_INVALID_ATTRIBUTE_SYNTAX'); + }); + + it('rejects a non-array argument', () => { + const { expr, ctx } = argOf('"a"'); + + const result = list(str()).parse(expr, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.failure).toHaveLength(1); + }); +}); + +describe('combinator code parity through interpretAttribute', () => { + it('emits a leaf diagnostic carrying the spec diagnostic code', () => { + const cursor = new Cursor('@rel(1)'); + const node = FieldAttributeAst.cast(createSyntaxTree(parseAttribute(cursor))); + if (!node) throw new Error('expected a field attribute'); + const ctx = makeCtx(cursor.sourceFile); + const spec = fieldAttribute('rel', { + positional: [{ key: 'name', type: str() }], + diagnosticCode: 'PSL_INVALID_RELATION_ATTRIBUTE', + }); + + const result = interpretAttribute(node, spec, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure).toHaveLength(1); + expect(result.failure[0]?.code).toBe('PSL_INVALID_RELATION_ATTRIBUTE'); + } + }); +}); From 319ee7c6813bd8b8250bb5087754bb17e5350249 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 29 Jun 2026 20:13:22 +0000 Subject: [PATCH 05/26] feat(psl-parser): add identifierName leaf and unblock InferAttr on refine specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SQL @relation spec parses bare-identifier referential actions (onDelete: Cascade) whose set is validated downstream, so it needs a leaf that reads an IdentifierAst to its name with no parse-time check — fieldRef minus the resolution scope. Add identifierName() and export it. InferAttr previously constrained its parameter to AttributeSpec, but Out sits contravariantly in AttributeSpec.refine, so any refine- carrying spec is not assignable to AttributeSpec and the constraint rejected it. Drop the constraint; inference still recovers Out precisely. Adds a type-test for a refine-carrying spec. Signed-off-by: Serhii Tatarintsev --- .../combinators/identifier-name.ts | 25 ++++++++++++ .../psl-parser/src/attribute-spec/types.ts | 10 ++++- .../psl-parser/src/exports/index.ts | 1 + .../test/attribute-spec-combinators.test.ts | 39 ++++++++++++++++++- .../psl-parser/test/attribute-spec.test-d.ts | 11 ++++++ 5 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/identifier-name.ts diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/identifier-name.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/identifier-name.ts new file mode 100644 index 0000000000..470b7ab019 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/identifier-name.ts @@ -0,0 +1,25 @@ +import type { PslDiagnostic } from '@prisma-next/framework-components/psl-ast'; +import { notOk, ok, type Result } from '@prisma-next/utils/result'; +import { IdentifierAst } from '../../syntax/ast/identifier'; +import type { ArgType } from '../types'; +import { leafDiagnostic } from './diagnostic'; + +/** + * Parses a bare identifier into its name, with no set or existence check. Used + * for an argument whose identifier is validated downstream — e.g. a referential + * action routed to its normaliser — so a parse-time check would emit a second + * diagnostic for the same fault. Like `fieldRef` minus the resolution scope. + */ +export function identifierName(): ArgType { + return { + kind: 'identifierName', + label: 'identifier', + parse: (arg, ctx): Result => { + if (arg instanceof IdentifierAst) { + const name = arg.name(); + if (name !== undefined) return ok(name); + } + return notOk([leafDiagnostic(ctx, arg, 'Expected an identifier')]); + }, + }; +} 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 3b611ed24c..6ebae95e78 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 @@ -123,5 +123,11 @@ export type AttributeOut< Named extends Record>, > = Simplify & NamedOut>; -export type InferAttr> = - S extends AttributeSpec ? Out : never; +/** + * Recovers a spec's inferred output type. The parameter is intentionally + * unconstrained: `Out` sits contravariantly in `AttributeSpec.refine`, so a + * `refine`-carrying `AttributeSpec` is not assignable to + * `AttributeSpec` and a constraint would reject every spec that uses a + * cross-argument `refine`. Inference still recovers `Out` precisely. + */ +export type InferAttr = S extends AttributeSpec ? Out : never; 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 0b3444a3e0..5900b4196b 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 @@ -39,6 +39,7 @@ export { getPositionalArgument, parseQuotedStringLiteral } from '../attribute-he export { enumOf } from '../attribute-spec/combinators/enum-of'; export type { FieldRefArgType, FieldRefScope } from '../attribute-spec/combinators/field-ref'; export { fieldRef } from '../attribute-spec/combinators/field-ref'; +export { identifierName } from '../attribute-spec/combinators/identifier-name'; export type { ListOptions } from '../attribute-spec/combinators/list'; export { list } from '../attribute-spec/combinators/list'; export { str } from '../attribute-spec/combinators/str'; 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 1c9b9c6b5a..e51eb02197 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.test.ts @@ -1,7 +1,15 @@ import type { PslDiagnostic } from '@prisma-next/framework-components/psl-ast'; import { describe, expect, it } from 'vitest'; import type { InterpretCtx } from '../src/exports'; -import { enumOf, fieldAttribute, fieldRef, interpretAttribute, list, str } from '../src/exports'; +import { + enumOf, + fieldAttribute, + fieldRef, + identifierName, + interpretAttribute, + list, + str, +} from '../src/exports'; import { Cursor, parse, parseAttribute } from '../src/parse'; import type { SourceFile } from '../src/source-file'; import { buildSymbolTable } from '../src/symbol-table'; @@ -139,6 +147,35 @@ describe('fieldRef', () => { }); }); +describe('identifierName', () => { + it('returns the bare identifier name', () => { + const { expr, ctx } = argOf('Cascade'); + + const result = identifierName().parse(expr, ctx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toBe('Cascade'); + }); + + it('returns an unknown identifier name without a set or existence check', () => { + const { expr, ctx } = argOf('WeirdAction'); + + const result = identifierName().parse(expr, ctx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toBe('WeirdAction'); + }); + + it('rejects a quoted string with the threaded code', () => { + const { expr, ctx } = argOf('"Cascade"'); + + const result = identifierName().parse(expr, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.failure[0]?.code).toBe('PSL_INVALID_ATTRIBUTE_SYNTAX'); + }); +}); + describe('list', () => { it('maps each element through the element combinator', () => { const { expr, ctx } = argOf('["a", "b"]'); diff --git a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec.test-d.ts b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec.test-d.ts index 5b6529964f..ba8f730be5 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec.test-d.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec.test-d.ts @@ -60,3 +60,14 @@ test('a variadic positional slot contributes an array property', () => { }); expectTypeOf>().toEqualTypeOf<{ tags: readonly string[] }>(); }); + +test('a spec carrying a refine still infers its output', () => { + const spec = fieldAttribute('demo', { + named: { name: optional(str()) }, + refine: (parsed) => { + void parsed.name; + return []; + }, + }); + expectTypeOf>().toEqualTypeOf<{ name?: string }>(); +}); From 98032c8cbc9b0123b019d696a151d5eb6d595ad7 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 29 Jun 2026 20:13:43 +0000 Subject: [PATCH 06/26] refactor(sql-contract-psl): lower @relation through the declarative attribute spec Replace the hand-written parseRelationAttribute with a sqlRelation AttributeSpec interpreted through the kit. The engine binds the positional-or-named name, fields/references field lists, map, and the bare-identifier onDelete/onUpdate; a refine holds the fields/references both-or-neither rule. Referential-action set validation stays downstream in normalizeReferentialAction (unchanged), preserving its code and span. interpretRelationAttribute assembles the InterpretCtx from interpreter state (declaring model, field, symbol table, source file) and maps the inferred output onto ParsedRelationAttribute. The three @relation call sites in interpreter.ts route through it; BuildModelNodeInput gains the source file and symbol table to assemble the context. Diagnostic codes and spans stay byte-identical for every @relation error path: the relations, many-to-many, and diagnostics suites pass unchanged and fixtures:check is clean. parseRelationAttribute is deleted; the helpers it used remain (other callers). Signed-off-by: Serhii Tatarintsev --- .../contract-psl/src/interpreter.ts | 33 ++- .../src/psl-relation-resolution.ts | 274 +++++++++--------- 2 files changed, 159 insertions(+), 148 deletions(-) diff --git a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts index ebdedb647a..b2ad091948 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts @@ -97,9 +97,9 @@ import { applyBackrelationCandidates, type FkRelationMetadata, indexFkRelations, + interpretRelationAttribute, type ModelBackrelationCandidate, normalizeReferentialAction, - parseRelationAttribute, validateNavigationListFieldAttributes, } from './psl-relation-resolution'; @@ -439,6 +439,8 @@ interface BuildModelNodeInput { readonly generatorDescriptorById: ReadonlyMap; readonly scalarTypeDescriptors: ReadonlyMap; readonly sourceId: string; + readonly sourceFile: SourceFile; + readonly symbolTable: SymbolTable; readonly diagnostics: ContractSourceDiagnostic[]; /** Resolved namespace id keyed by model name — used to stamp the target namespace on FKs. */ readonly modelNamespaceIds: ReadonlyMap; @@ -516,10 +518,11 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult const relationAttribute = getAttribute(field.attributes, 'relation'); let relationName: string | undefined; if (relationAttribute) { - const parsedRelation = parseRelationAttribute({ - attribute: relationAttribute, - modelName: model.name, - fieldName: field.name, + const parsedRelation = interpretRelationAttribute({ + selfModel: model, + field, + symbols: input.symbolTable, + sourceFile: input.sourceFile, sourceId, diagnostics, }); @@ -845,10 +848,11 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult continue; } - const parsedRelation = parseRelationAttribute({ - attribute: relationAttribute.relation, - modelName: model.name, - fieldName: relationAttribute.field.name, + const parsedRelation = interpretRelationAttribute({ + selfModel: model, + field: relationAttribute.field, + symbols: input.symbolTable, + sourceFile: input.sourceFile, sourceId, diagnostics, }); @@ -1016,10 +1020,11 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult continue; } - const parsedRelation = parseRelationAttribute({ - attribute: relationAttribute.relation, - modelName: model.name, - fieldName: relationAttribute.field.name, + const parsedRelation = interpretRelationAttribute({ + selfModel: model, + field: relationAttribute.field, + symbols: input.symbolTable, + sourceFile: input.sourceFile, sourceId, diagnostics, }); @@ -1914,6 +1919,8 @@ export function interpretPslDocumentToSqlContract( generatorDescriptorById, scalarTypeDescriptors: input.scalarTypeDescriptors, sourceId, + sourceFile, + symbolTable: input.symbolTable, diagnostics, modelNamespaceIds, ...(enumHandlesByName.size > 0 ? { enumHandles: enumHandlesByName } : {}), 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 9c8f8ddd7b..29b0af2aeb 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts @@ -1,18 +1,30 @@ import type { ContractSourceDiagnostic } from '@prisma-next/config/config-types'; import type { AuthoringContributions } from '@prisma-next/framework-components/authoring'; -import type { FieldSymbol, PslSpan, ResolvedAttribute } from '@prisma-next/psl-parser'; +import type { + FieldSymbol, + InferAttr, + InterpretCtx, + ModelSymbol, + PslDiagnostic, + PslSpan, + SymbolTable, +} from '@prisma-next/psl-parser'; +import { + fieldAttribute, + fieldRef, + identifierName, + interpretAttribute, + list, + nodePslSpan, + optional, + str, +} from '@prisma-next/psl-parser'; +import type { FieldAttributeAst, SourceFile } from '@prisma-next/psl-parser/syntax'; import type { ReferentialAction } from '@prisma-next/sql-contract/types'; import type { RelationNode } from '@prisma-next/sql-contract-ts/contract-builder'; import { assertDefined, invariant } from '@prisma-next/utils/assertions'; import { ifDefined } from '@prisma-next/utils/defined'; -import { - getNamedArgument, - getPositionalArgumentEntry, - parseFieldList, - parseQuotedStringLiteral, - unquoteStringLiteral, -} from './psl-attribute-parsing'; import { checkUncomposedNamespace, reportUncomposedNamespace } from './psl-column-resolution'; export const REFERENTIAL_ACTION_MAP: Record = { @@ -90,150 +102,142 @@ export function normalizeReferentialAction(input: { return undefined; } -export function parseRelationAttribute(input: { - readonly attribute: ResolvedAttribute; - readonly modelName: string; - readonly fieldName: string; - readonly sourceId: string; - readonly diagnostics: ContractSourceDiagnostic[]; -}): ParsedRelationAttribute | undefined { - const positionalEntries = input.attribute.args.filter((arg) => arg.kind === 'positional'); - if (positionalEntries.length > 1) { - input.diagnostics.push({ - code: 'PSL_INVALID_RELATION_ATTRIBUTE', - message: `Relation field "${input.modelName}.${input.fieldName}" has too many positional arguments`, - sourceId: input.sourceId, - span: input.attribute.span, - }); - return undefined; +/** + * Cross-argument rules the engine cannot express through per-argument leaves: + * `fields` and `references` are both-or-neither. Anchored to the attribute span + * with the legacy code, preserving codes-and-spans parity for that error path. + */ +function relationInvariants( + parsed: { readonly fields?: readonly string[]; readonly references?: readonly string[] }, + ctx: InterpretCtx, +): readonly PslDiagnostic[] { + const hasFields = parsed.fields !== undefined; + const hasReferences = parsed.references !== undefined; + if (hasFields !== hasReferences) { + return [ + { + code: 'PSL_INVALID_RELATION_ATTRIBUTE', + message: `Relation field "${ctx.selfModel.name}.${ctx.field?.name ?? ''}" requires fields and references arguments`, + sourceId: ctx.sourceId, + span: relationAttributeSpan(ctx), + }, + ]; } + return []; +} - let relationNameFromPositional: string | undefined; - const positionalNameEntry = getPositionalArgumentEntry(input.attribute); - if (positionalNameEntry) { - const parsedName = parseQuotedStringLiteral(positionalNameEntry.value); - if (!parsedName) { - input.diagnostics.push({ - code: 'PSL_INVALID_RELATION_ATTRIBUTE', - message: `Relation field "${input.modelName}.${input.fieldName}" positional relation name must be a quoted string literal`, - sourceId: input.sourceId, - span: positionalNameEntry.span, - }); - return undefined; +/** + * Declarative replacement for the hand-written `@relation` parser. The engine + * binds the positional-or-named `name`, the `fields`/`references` field lists, + * `map`, and the bare-identifier referential actions; `relationInvariants` + * holds the both-or-neither rule. The action set itself is validated downstream + * by `normalizeReferentialAction`, so the actions are parsed to their raw + * identifier name here without a parse-time set check. + */ +const sqlRelation = fieldAttribute('relation', { + positional: [{ key: 'name', type: optional(str()) }], + named: { + name: optional(str()), + fields: optional(list(fieldRef('self'), { nonEmpty: true })), + references: optional(list(fieldRef('referenced'), { nonEmpty: true })), + map: optional(str()), + onDelete: optional(identifierName()), + onUpdate: optional(identifierName()), + }, + refine: relationInvariants, + diagnosticCode: 'PSL_INVALID_RELATION_ATTRIBUTE', +}); + +type SqlRelationOutput = InferAttr; + +function findRelationAttributeNode(field: FieldSymbol): FieldAttributeAst | undefined { + for (const attribute of field.node.attributes()) { + if (attribute.name()?.path().join('.') === 'relation') { + return attribute; } - relationNameFromPositional = parsedName; } + return undefined; +} - for (const arg of input.attribute.args) { - if (arg.kind === 'positional') { - continue; - } - if ( - arg.name !== 'name' && - arg.name !== 'fields' && - arg.name !== 'references' && - arg.name !== 'map' && - arg.name !== 'onDelete' && - arg.name !== 'onUpdate' - ) { - input.diagnostics.push({ - code: 'PSL_INVALID_RELATION_ATTRIBUTE', - message: `Relation field "${input.modelName}.${input.fieldName}" has unsupported argument "${arg.name}"`, - sourceId: input.sourceId, - span: arg.span, - }); - return undefined; +function relationAttributeSpan(ctx: InterpretCtx): PslSpan { + const field = ctx.field; + if (field !== undefined) { + const node = findRelationAttributeNode(field); + if (node !== undefined) { + return nodePslSpan(node.syntax, ctx.sourceFile); } + return field.span; } + return ctx.selfModel.span; +} - const namedRelationNameRaw = getNamedArgument(input.attribute, 'name'); - const namedRelationName = namedRelationNameRaw - ? parseQuotedStringLiteral(namedRelationNameRaw) - : undefined; - if (namedRelationNameRaw && !namedRelationName) { - input.diagnostics.push({ - code: 'PSL_INVALID_RELATION_ATTRIBUTE', - message: `Relation field "${input.modelName}.${input.fieldName}" named relation name must be a quoted string literal`, - sourceId: input.sourceId, - span: input.attribute.span, - }); - return undefined; +function resolveReferencedModel(symbols: SymbolTable, field: FieldSymbol): ModelSymbol | undefined { + const topLevel = symbols.topLevel.models[field.typeName]; + if (topLevel !== undefined) { + return topLevel; } - - if ( - relationNameFromPositional && - namedRelationName && - relationNameFromPositional !== namedRelationName - ) { - input.diagnostics.push({ - code: 'PSL_INVALID_RELATION_ATTRIBUTE', - message: `Relation field "${input.modelName}.${input.fieldName}" has conflicting positional and named relation names`, - sourceId: input.sourceId, - span: input.attribute.span, - }); - return undefined; + for (const namespace of Object.values(symbols.topLevel.namespaces)) { + const model = namespace.models[field.typeName]; + if (model !== undefined) { + return model; + } } - const relationName = namedRelationName ?? relationNameFromPositional; + return undefined; +} - const constraintNameRaw = getNamedArgument(input.attribute, 'map'); - const constraintName = constraintNameRaw - ? parseQuotedStringLiteral(constraintNameRaw) - : undefined; - if (constraintNameRaw && !constraintName) { - input.diagnostics.push({ - code: 'PSL_INVALID_RELATION_ATTRIBUTE', - message: `Relation field "${input.modelName}.${input.fieldName}" map argument must be a quoted string literal`, - sourceId: input.sourceId, - span: input.attribute.span, - }); - return undefined; - } +function buildRelationInterpretCtx(input: { + readonly selfModel: ModelSymbol; + readonly field: FieldSymbol; + readonly symbols: SymbolTable; + readonly sourceFile: SourceFile; + readonly sourceId: string; +}): InterpretCtx { + return { + level: 'field', + diagnosticCode: 'PSL_INVALID_RELATION_ATTRIBUTE', + sourceId: input.sourceId, + sourceFile: input.sourceFile, + symbols: input.symbols, + selfModel: input.selfModel, + field: input.field, + resolveReferencedModel: () => resolveReferencedModel(input.symbols, input.field), + }; +} - const fieldsRaw = getNamedArgument(input.attribute, 'fields'); - const referencesRaw = getNamedArgument(input.attribute, 'references'); - if ((fieldsRaw && !referencesRaw) || (!fieldsRaw && referencesRaw)) { - input.diagnostics.push({ - code: 'PSL_INVALID_RELATION_ATTRIBUTE', - message: `Relation field "${input.modelName}.${input.fieldName}" requires fields and references arguments`, - sourceId: input.sourceId, - span: input.attribute.span, - }); +/** + * Validates and lowers a field's `@relation` attribute through the declarative + * `sqlRelation` spec, mapping the inferred output onto `ParsedRelationAttribute`. + * The engine aggregates every error path's diagnostics; like the previous + * first-error parser, the caller skips the field when this returns `undefined`. + */ +export function interpretRelationAttribute(input: { + readonly selfModel: ModelSymbol; + readonly field: FieldSymbol; + readonly symbols: SymbolTable; + readonly sourceFile: SourceFile; + readonly sourceId: string; + readonly diagnostics: ContractSourceDiagnostic[]; +}): ParsedRelationAttribute | undefined { + const attributeNode = findRelationAttributeNode(input.field); + if (attributeNode === undefined) { return undefined; } - - let fields: readonly string[] | undefined; - let references: readonly string[] | undefined; - if (fieldsRaw && referencesRaw) { - const parsedFields = parseFieldList(fieldsRaw); - const parsedReferences = parseFieldList(referencesRaw); - if ( - !parsedFields || - !parsedReferences || - parsedFields.length === 0 || - parsedReferences.length === 0 - ) { - input.diagnostics.push({ - code: 'PSL_INVALID_RELATION_ATTRIBUTE', - message: `Relation field "${input.modelName}.${input.fieldName}" requires bracketed fields and references lists`, - sourceId: input.sourceId, - span: input.attribute.span, - }); - return undefined; + const ctx = buildRelationInterpretCtx(input); + const result = interpretAttribute(attributeNode, sqlRelation, ctx); + if (!result.ok) { + for (const failure of result.failure) { + input.diagnostics.push(failure); } - fields = parsedFields; - references = parsedReferences; + return undefined; } - - const onDeleteArgument = getNamedArgument(input.attribute, 'onDelete'); - const onUpdateArgument = getNamedArgument(input.attribute, 'onUpdate'); - + const parsed: SqlRelationOutput = result.value; return { - ...ifDefined('relationName', relationName), - ...ifDefined('fields', fields), - ...ifDefined('references', references), - ...ifDefined('constraintName', constraintName), - ...ifDefined('onDelete', onDeleteArgument ? unquoteStringLiteral(onDeleteArgument) : undefined), - ...ifDefined('onUpdate', onUpdateArgument ? unquoteStringLiteral(onUpdateArgument) : undefined), + ...ifDefined('relationName', parsed.name), + ...ifDefined('fields', parsed.fields), + ...ifDefined('references', parsed.references), + ...ifDefined('constraintName', parsed.map), + ...ifDefined('onDelete', parsed.onDelete), + ...ifDefined('onUpdate', parsed.onUpdate), }; } From de7934321633ebd5c4c9a294bfe41d53030899b9 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 07:19:31 +0000 Subject: [PATCH 07/26] docs(typed-attribute-parsers): project + slice-1 artifacts (spec, plan, review, briefs) Drive project/slice paper trail for the attribute-spec-kit slice: project spec + plan, slice spec + dispatch plan, dispatch briefs (D2/D3), and the code-review log. Transient project artifacts (removed at project close); no source references them. Signed-off-by: Serhii Tatarintsev --- projects/typed-attribute-parsers/plan.md | 45 ++++++++ .../dispatches/02-combinators.md | 47 ++++++++ .../dispatches/03-migrate-relation.md | 66 +++++++++++ .../slices/attribute-spec-kit/plan.md | 31 ++++++ .../slices/attribute-spec-kit/spec.md | 83 ++++++++++++++ projects/typed-attribute-parsers/spec.md | 103 ++++++++++++++++++ 6 files changed, 375 insertions(+) create mode 100644 projects/typed-attribute-parsers/plan.md create mode 100644 projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/02-combinators.md create mode 100644 projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/03-migrate-relation.md create mode 100644 projects/typed-attribute-parsers/slices/attribute-spec-kit/plan.md create mode 100644 projects/typed-attribute-parsers/slices/attribute-spec-kit/spec.md create mode 100644 projects/typed-attribute-parsers/spec.md diff --git a/projects/typed-attribute-parsers/plan.md b/projects/typed-attribute-parsers/plan.md new file mode 100644 index 0000000000..4f5d3855ab --- /dev/null +++ b/projects/typed-attribute-parsers/plan.md @@ -0,0 +1,45 @@ +# typed-attribute-parsers — Plan + +**Spec:** `projects/typed-attribute-parsers/spec.md` +**Linear issue:** [TML-2956](https://linear.app/prisma-company/issue/TML-2956) (under project _Language Tools Support Prisma Next PSL_) + +## At a glance + +Three slices in a substrate-then-consumers shape: slice 1 lands the combinator kit + `interpretAttribute` in `psl-parser`, proven by migrating `@relation` end-to-end in the SQL family; slices 2 and 3 then migrate the remaining SQL and Mongo attributes respectively, in parallel, each deleting its family's legacy parsing helpers. + +## Composition + +### Stack (deliver in order) + +1. **Slice `attribute-spec-kit`** — Linear: _TBD_ + - **Outcome:** The combinator kit (`str`, `int`, `bool`, `enumOf`, `json`, `entityRef`, `fieldRef`, `codecRef`, `list`, `map`, `record`, `oneOf`, `funcCall`/`funcCallFrom`), `AttributeSpec`, the three constructors (`fieldAttribute`/`modelAttribute`/`blockAttribute`), `interpretAttribute`, `InferAttr`, and the `InterpretCtx` contract exist in `psl-parser`, consuming the parser's `ExpressionAst` directly. `@relation` in the **SQL** family is validated and lowered via a spec through `interpretAttribute`, producing byte-identical contract output and identical diagnostics to the hand-written path it replaces. + - **Builds on:** None. + - **Hands to:** (a) the kit + `interpretAttribute` + `InferAttr` API exported from `psl-parser`; (b) the `InterpretCtx` wiring recipe — how a family interpreter assembles `SymbolTable` / declaring model / referenced-model resolver / declaring field / codec lookup / default-fn registry at an attribute call site; (c) the migration recipe — route a call site from `readResolvedArgList` + string helpers to `interpretAttribute(cstNode, spec, ctx)`, then retire the now-dead helper. + - **Focus:** The generic engine and exactly one representative attribute (`@relation` — the richest: positional+named alias, `fieldRef('self')`/`fieldRef('referenced')` scopes, `enumOf` actions, `list` with `nonEmpty`, and a `refine` for the both-or-neither rule). The remaining SQL attributes and all Mongo attributes are deliberately left to slices 2 and 3. No language-server consumer (project non-goal). + +### Parallel group A (builds on slice 1; independent of group B) + +- **Slice `sql-attributes`** — Linear: _TBD_ + - **Outcome:** Every remaining field-, model-, and block-level attribute the **SQL** family interprets — `@id`/`@@id`, `@unique`/`@@unique`, `@@index`, `@default`, `@map`/`@@map`, `@@control`, `@@discriminator`, `@@base` — is described by a spec and lowered via `interpretAttribute`. The SQL family's hand-written argument-parsing helpers (`psl-attribute-parsing.ts` string parsers, and the per-attribute `getNamedArgument`/`getPositionalArgument` re-parsing in `interpreter.ts`, `psl-field-resolution.ts`, `psl-relation-resolution.ts`) are deleted for every migrated attribute. + - **Builds on:** Slice 1's kit API + `InterpretCtx` wiring recipe + migration recipe. + - **Hands to:** SQL family fully spec-driven; no legacy SQL attribute-argument parser remains (grep gate). + - **Focus:** SQL family only (`packages/2-sql/2-authoring/contract-psl`). The model-level aggregation that enforces cross-attribute rules stays untouched (spec decision); only single-attribute cross-argument rules move into each spec's `refine`. `@db.*` native types remain out of scope. + +### Parallel group B (builds on slice 1; independent of group A) + +- **Slice `mongo-attributes`** — Linear: _TBD_ + - **Outcome:** Every field-, model-, and block-level attribute the **Mongo** family interprets — `@id`, `@unique`/`@@unique`, `@@index`, `@@textIndex`, `@relation`, `@map`/`@@map`, `@@discriminator`, `@@base` — is described by a spec and lowered via `interpretAttribute`, including the mixed string/number index `type` as a single `enumOf` and the index-element `oneOf`. The Mongo family's hand-written helpers (`psl-helpers.ts` parsers, `parseIndexFieldList`, the local `parseRelationAttribute`) are deleted for every migrated attribute. + - **Builds on:** Slice 1's kit API + `InterpretCtx` wiring recipe + migration recipe. + - **Hands to:** Mongo family fully spec-driven; no legacy Mongo attribute-argument parser remains (grep gate). + - **Focus:** Mongo family only (`packages/2-mongo-family/2-authoring/contract-psl`). Mongo migrates its own `@relation` spec (distinct value shapes from SQL's). The "at most one `@@textIndex` per collection" rule stays in Mongo's existing model-level aggregation, not in a per-attribute `refine` (spec decision). + +## Dependencies (external) + +- [x] **Linear tracking** — single umbrella issue [TML-2956](https://linear.app/prisma-company/issue/TML-2956) under the _Language Tools Support Prisma Next PSL_ project, assigned to @tatarintsev. (Per-slice sub-issues not yet created; the operator opted for one umbrella ticket over a Linear Project + three sub-issues.) +- [x] **ADR 231 — Declarative attribute specifications** — settled (`Proposed`); this project is its first implementation and advances its status at close-out. + +## Sequencing rationale + +Slice 1 is the substrate every consumer depends on, so it must land first — this is the migration-shaped "substrate change → consumer migration" pattern, which always serialises at the substrate boundary. `@relation` is folded into slice 1 (rather than a pure kit-only slice) so the slice is *Valuable* on its own: it ships a working consumer and proves the seam end-to-end, not "preparation for slice 2." + +Slices 2 and 3 run in parallel because they touch **disjoint family packages** (`packages/2-sql` vs `packages/2-mongo-family`) and share no mutable surface beyond slice 1's already-merged kit — the "different operation families parallelise well" heuristic. Neither consumes the other's hand-off. Serializing them would forfeit throughput the dependency graph permits. diff --git a/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/02-combinators.md b/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/02-combinators.md new file mode 100644 index 0000000000..be8b5bad0d --- /dev/null +++ b/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/02-combinators.md @@ -0,0 +1,47 @@ +# Brief: D2 — the `@relation` combinators + +> Implementer note: you are a **fresh** implementer (the prior D1 implementer's session became inaccessible). You have no project transcript — read the context paths below, especially the on-disk D1 engine, before editing. + +## Context paths (read before editing) +- **The D1 engine you build on** (committed, on disk): `packages/1-framework/2-authoring/psl-parser/src/attribute-spec/` — read `types.ts`, `interpret.ts`, `optional.ts`, `field-attribute.ts`, and exports in `src/exports/index.ts`. Tests: `packages/1-framework/2-authoring/psl-parser/test/attribute-spec.test.ts` + `attribute-spec.test-d.ts`. +- Slice spec: `projects/typed-attribute-parsers/slices/attribute-spec-kit/spec.md`; slice plan §Dispatch 2: `projects/typed-attribute-parsers/slices/attribute-spec-kit/plan.md`. +- ADR 231: `docs/architecture docs/adrs/ADR 231 - Declarative attribute specifications.md`. +- CST types exported from `packages/1-framework/2-authoring/psl-parser/src/exports/syntax.ts` (`StringLiteralExprAst`, `NumberLiteralExprAst`, `ArrayLiteralAst`, `IdentifierAst`, `ExpressionAst`, …). Span helper `nodePslSpan(node, sourceFile)` in `src/resolve.ts`. Diagnostics are `PslDiagnostic`; failure channel `Result` from `@prisma-next/utils/result`. + +Engine facts (verify against the code): `ArgType { kind; label; _out?; parse(arg: ExpressionAst, ctx: InterpretCtx): Result }`. `InterpretCtx` currently `{ level, sourceId, sourceFile, symbols, selfModel, resolveReferencedModel(), field? }`. `AttributeSpec` has `diagnosticCode?` (defaults `PSL_INVALID_ATTRIBUTE_SYNTAX`). + +## Task +Author the four domain combinators `@relation` needs, as `ArgType`s over `ExpressionAst`, in a new module beside the engine (e.g. `src/attribute-spec/combinators/`); export each from the package public surface; unit-test each: + +- **`str()`** — `StringLiteralExprAst` → string value; non-string-literal → diagnostic. +- **`enumOf(...values)`** — `StringLiteralExprAst` or `NumberLiteralExprAst` whose value is a member of the fixed set (members may be mixed string/number per ADR 231); non-member / wrong-token → diagnostic. Build it generically; whether `@relation` uses it for `onDelete`/`onUpdate` is a D3 wiring decision. +- **`fieldRef(scope)`**, scope `'self' | 'referenced'` — bare `IdentifierAst` → the field **name string**. **Do NOT resolve or validate field existence at parse time** — the SQL interpreter validates existence downstream; a parse-time check would emit new diagnostics and break `@relation` parity. Carry `scope` as combinator metadata (for the future language server); the parsed value is just the name. Non-identifier → diagnostic. +- **`list(of, opts?)`**, `opts?: { nonEmpty?: boolean; unique?: boolean }` — reads an `ArrayLiteralAst`, maps each element through the element `ArgType` `of`, returns `T[]`; `nonEmpty` → diagnostic on empty; `unique` → diagnostic on duplicates; non-array → diagnostic. Build `unique` too (slices 2–3 need it). + +## Codes parity (load-bearing) +Diagnostic **codes** must stay identical; legacy `@relation` errors all use `PSL_INVALID_RELATION_ATTRIBUTE`. Leaf-emitted diagnostics must carry the **attribute's** code, not a hard-coded generic. Thread the spec's `diagnosticCode` to the leaves — cleanest shape: add `diagnosticCode` to `InterpretCtx` and have `interpretAttribute` populate it from the spec before calling any leaf's `parse`, so each combinator emits with `ctx.diagnosticCode`. Pick the cleanest shape against the D1 engine; name it in your report (it's the D3 hand-off). Leaf-diagnostic spans anchor to the offending element/arg node via `nodePslSpan(node, ctx.sourceFile)`. + +## Scope +**In:** the four combinators + their unit tests; the `diagnosticCode` threading (or equivalent); exports in `src/exports/`. +**Out:** ANY interpreter change and the `sqlRelation` spec itself (that's D3); the rest of ADR 231's alphabet (`int`, `bool`, `json`, `map`, `record`, `entityRef`, `codecRef`, `oneOf`, `funcCall`, `modelAttribute`, `blockAttribute`); `@db.*`; field-existence resolution. + +## Completed when +- [ ] `str`, `enumOf`, `fieldRef`, `list` exported from `psl-parser` and usable as `Param`s in an `AttributeSpec`. +- [ ] Unit tests per combinator: parse success + each diagnostic path; `enumOf` covers a mixed string/number set; `list` covers `nonEmpty` + `unique` + element-error propagation; `fieldRef` returns the name and emits NO existence diagnostic. +- [ ] A test proves a leaf diagnostic carries the attribute's `diagnosticCode` end-to-end through `interpretAttribute`. +- [ ] Gate green: `pnpm --filter @prisma-next/psl-parser typecheck && pnpm --filter @prisma-next/psl-parser test && pnpm --filter @prisma-next/psl-parser lint`. + +## Standing instruction +Stay focused on the goal; control scope. Trivial-and-related fixes that serve the goal go in with a one-line note in your wrap-up; anything pulling you off the goal halts and surfaces. + +## Constraints +- No `any`; no bare `as` (narrow `blindCast`/`castAs` from `@prisma-next/utils/casts` with a reason, or types that avoid the cast); arktype not zod — where a leaf reduces to a context-free value check (`enumOf`'s literal set), ADR 231 suggests backing it with an arktype `Type`; use judgment for a small fixed set vs a plain membership check, and note the choice; no file-extension imports; tests-first. +- Explicit-staging commits (`git add `, never `-A`/`.`); no amend; **no push**. +- Read-only on `projects/typed-attribute-parsers/reviews/**`, `spec.md`, plan files. +- Run the "no transient project IDs in code" scan on your `+` diff before declaring done. + +## Operational metadata +- **Model tier:** mid (routine combinators against a settled contract). +- **Halt conditions:** a combinator can't emit code-parity diagnostics without an engine change you can't make cleanly; the diff drifts into interpreter / `sqlRelation` territory (that's D3); an `ExpressionAst` shape you need isn't exported. + +Return the structured report per your persona's § Return shape; note the `diagnosticCode`-threading shape you landed and the final exported combinator signatures (the D3 hand-off). diff --git a/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/03-migrate-relation.md b/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/03-migrate-relation.md new file mode 100644 index 0000000000..1c364b3db4 --- /dev/null +++ b/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/03-migrate-relation.md @@ -0,0 +1,66 @@ +# Brief: D3 — migrate SQL `@relation` to a spec; delete `parseRelationAttribute` + +> Fresh implementer (session resume is unavailable). Read the context paths first; all prior work is committed. + +## Context paths (read before editing) +- **The kit you consume** (committed): `packages/1-framework/2-authoring/psl-parser/src/attribute-spec/` — `types.ts` (`ArgType`, `AttributeSpec`, `Param`, `InterpretCtx`, `InferAttr`), `interpret.ts` (`interpretAttribute`), `field-attribute.ts` (`fieldAttribute`), `optional.ts`, `combinators/` (`str`, `enumOf`, `fieldRef`, `list`). All exported from `@prisma-next/psl-parser` (`src/exports/index.ts`). +- **The code you replace:** `packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts` — `parseRelationAttribute` (the hand-written parser) and `normalizeReferentialAction` (KEEP this — it stays the referential-action validator). Read both in full. +- **The call sites:** `packages/2-sql/2-authoring/contract-psl/src/interpreter.ts` — three `parseRelationAttribute({ attribute, modelName, fieldName, sourceId, diagnostics })` calls (around the `buildModelNodeFromPsl` relation paths). Also `psl-field-resolution.ts` / `psl-relation-resolution.ts` `validateNavigationListFieldAttributes` for surrounding context. +- Slice spec (esp. § Resolved decisions): `projects/typed-attribute-parsers/slices/attribute-spec-kit/spec.md`. ADR 231. +- The interpreter receives `symbolTable: SymbolTable` + `sourceFile: SourceFile` (see `InterpretPslDocumentToSqlContractInput`) — so the CST attribute node and everything `InterpretCtx` needs is in reach. Diagnostics here are `ContractSourceDiagnostic` (`{ code, message, sourceId, span }`); `PslDiagnostic` has the same shape — map between them at the call site as needed. + +## Task +Replace the hand-written `parseRelationAttribute` with a declarative `sqlRelation` `AttributeSpec` lowered through `interpretAttribute`, preserving **byte-identical diagnostic codes and spans** for every `@relation` error path (message text may change per the project parity bar). Then delete `parseRelationAttribute` and any helper it alone used. + +### The spec +Define `sqlRelation` (e.g. in a new `src/attribute-specs.ts` or co-located in `psl-relation-resolution.ts`): +``` +fieldAttribute('relation', { + positional: [{ key: 'name', type: optional(str()) }], // positional-or-named alias for name + named: { + name: optional(str()), + fields: optional(list(fieldRef('self'), { nonEmpty: true })), + references: optional(list(fieldRef('referenced'), { nonEmpty: true })), + map: optional(str()), + onDelete: optional(), + onUpdate: optional(), + }, + refine: relationInvariants, + diagnosticCode: 'PSL_INVALID_RELATION_ATTRIBUTE', +}) +``` +- **`onDelete`/`onUpdate` (resolved decision):** these are **bare identifiers** (`onDelete: Cascade`), and the action set is validated **downstream** by the existing `normalizeReferentialAction` (which emits `PSL_UNSUPPORTED_REFERENTIAL_ACTION`). Do NOT use `enumOf` for them (it would change the code at parse time and break parity). Parse them to the **raw identifier name string** (no set check) and route the result to `normalizeReferentialAction` unchanged. If the kit has no bare-identifier-name leaf, add a small one in `psl-parser` combinators (reads an `IdentifierAst` → its name, no validation; analogous to `fieldRef` minus the scope) and export it; unit-test it. +- **`refine: relationInvariants`** holds the cross-argument rules: `fields`/`references` both-or-neither (legacy code `PSL_INVALID_RELATION_ATTRIBUTE`, anchored to the attribute span). The positional-vs-named `name` conflict is handled by the **engine's alias mechanism** (already built) — verify it emits with `diagnosticCode` + attribute-span; if its span/anchoring diverges from legacy, reconcile. +- The interpreted output (`InferAttr` = `{ name?, fields?, references?, map?, onDelete?, onUpdate? }`) is mapped at the call site to today's `ParsedRelationAttribute` (`name → relationName`, `map → constraintName`, rest 1:1). + +### InterpretCtx assembly +At each call site, build an `InterpretCtx` from interpreter state: `level: 'field'`, `sourceId`, `sourceFile`, `symbols` (the SymbolTable), `selfModel` (the declaring model symbol), `resolveReferencedModel()` (the relation's target model — use the field's type name to resolve, as the interpreter already does elsewhere), optional `field`, and a baseline `diagnosticCode` (the engine overrides it from the spec). Factor the assembly into a small helper if it's repeated across the three call sites. + +## Parity reconciliation (the load-bearing carry-overs) +Verify against the diagnostics + relations fixtures/tests and reconcile: +1. **Codes + spans byte-identical** for every `@relation` error path: positional-name-not-a-string, named-name-not-a-string, conflicting names, unknown argument, fields-xor-references, empty/non-bracketed fields or references, map-not-a-string, too-many-positional, bad referential action. For each, confirm the legacy code + span are reproduced. Where the engine anchors a span differently than legacy, prefer adjusting the spec/call-site; a minimal, noted engine span tweak is acceptable only if unavoidable. +2. **Aggregate-all vs first-error:** the engine returns ALL diagnostics; legacy returned on the FIRST error (then the caller skipped the field). If a fixture has a `@relation` with multiple simultaneous errors, the diagnostic SET may grow. If you find such a case, **halt and surface** the specific fixture delta rather than silently rewriting it — the orchestrator decides whether the richer diagnostics are an acceptable, intentional fixture update. +3. **Duplicate named args:** the engine silently drops duplicates (no diagnostic). Confirm this matches legacy `@relation` behaviour (legacy used `getNamedArgument` = first match) or that the upstream parser already rejects duplicates. Note the finding. + +## Scope +**In:** `sqlRelation` spec; route the three `@relation` call sites through `interpretAttribute` + map the output to `ParsedRelationAttribute`; `InterpretCtx` assembly helper; delete `parseRelationAttribute` and any now-dead helper it alone used (check `getPositionalArgumentEntry`, `parseFieldList`, etc. — delete only if `@relation` was their sole caller; otherwise leave for slice 2); add the bare-identifier-name leaf to `psl-parser` if needed. Keep `normalizeReferentialAction`. +**Out:** all other SQL attributes (`@id`, `@unique`, `@@index`, `@default`, `@map`, `@@control`, `@@discriminator`, `@@base`) — slice 2; Mongo — slice 3; the rest of ADR 231's alphabet; `@db.*`. + +## Completed when +- [ ] `@relation` is validated + lowered via `interpretAttribute(sqlRelation)`; `parseRelationAttribute` deleted. +- [ ] `rg "parseRelationAttribute"` returns zero results (outside this brief's own text). +- [ ] Diagnostic **codes + spans** byte-identical for every `@relation` error path (verified against `interpreter.relations.test.ts`, `interpreter.relations.many-to-many.test.ts`, `interpreter.diagnostics.test.ts`). +- [ ] Gate green: `pnpm --filter @prisma-next/contract-psl-sql test` (or the package's actual name — confirm via its `package.json`); `pnpm fixtures:check`; and after `pnpm --filter @prisma-next/psl-parser build`, a workspace `pnpm typecheck` (cross-package consumer check, since `psl-parser`'s exported types changed). `pnpm --filter @prisma-next/psl-parser test` + lint if you added the bare-identifier leaf. + +## Standing instruction +Stay focused on the goal; control scope. Trivial-and-related fixes serving the goal go in with a one-line note; anything pulling you off the goal — especially migrating a second attribute — halts and surfaces. + +## Constraints +- No `any`; no bare `as` (narrow `blindCast`/`castAs` with reason, or types that avoid it); no file-extension imports; no reexport outside `exports/`; tests-first where you add new kit surface. +- Explicit-staging commits, no amend, **no push**. Read-only on `projects/typed-attribute-parsers/reviews/**`, `spec.md`, plan files. Run the transient-ID scan on your `+` diff. + +## Operational metadata +- **Model tier:** thorough (parity-critical, judgment-heavy migration across packages). +- **Halt conditions:** a fixture's `@relation` diagnostic SET changes (aggregate-all case — surface it, decision #2 above); a span can't be reproduced without a non-trivial engine change; deleting a helper would break a non-`@relation` caller (leave it, note it for slice 2); the diff drifts into a second attribute. + +Return the structured report per § Return shape; explicitly report the parity verification (each error path: code + span identical?), the duplicate-named-arg finding, and any fixture delta you surfaced. diff --git a/projects/typed-attribute-parsers/slices/attribute-spec-kit/plan.md b/projects/typed-attribute-parsers/slices/attribute-spec-kit/plan.md new file mode 100644 index 0000000000..3e9b69aea8 --- /dev/null +++ b/projects/typed-attribute-parsers/slices/attribute-spec-kit/plan.md @@ -0,0 +1,31 @@ +# Slice: attribute-spec-kit — Dispatch plan + +**Slice spec:** `projects/typed-attribute-parsers/slices/attribute-spec-kit/spec.md` + +Sandwich shape: engine → combinators → consumer migration. 3 dispatches, sequential. + +### Dispatch 1: Engine + core types + +- **Outcome:** `psl-parser` exports `ArgType`, `AttributeSpec`, `Param`/`optional`, `fieldAttribute`, `interpretAttribute`, `InferAttr`, and `InterpretCtx`. The engine parses positional + named arguments (including the positional-or-named alias) into a flat typed object, runs the optional `refine`, and returns `Result, Diagnostic[]>` — proven against a trivial in-test stub `ArgType` and `InferAttr` type-level tests. No domain combinators yet. +- **Builds on:** The spec's chosen design; the `ExpressionAst` exports. +- **Hands to:** The `ArgType` contract (`parse(arg: ExpressionAst, ctx) → Result`) + the engine, so dispatch 2 can author real combinators against a stable interface. +- **Focus:** Engine + types only. Message-templating machinery is included only if Open Question 1 resolves to "strict message parity." +- **Gate:** `cd packages/1-framework/2-authoring/psl-parser && pnpm typecheck && pnpm test`; `pnpm --filter @prisma-next/psl-parser lint`. + +### Dispatch 2: The `@relation` combinators + +- **Outcome:** `str`, `enumOf(...values)`, `fieldRef(scope)`, and `list(of, { nonEmpty })` exist as `ArgType`s over `ExpressionAst`, each with unit tests covering parse success + each diagnostic path. `fieldRef` carries its scope (`'self'` / `'referenced'`) and resolves against `InterpretCtx`. +- **Builds on:** Dispatch 1's `ArgType` contract + engine. +- **Hands to:** The combinator set sufficient to express `sqlRelation`. +- **Focus:** Only the four combinators `@relation` needs. The rest of ADR 231's alphabet is out (slices 2–3). +- **Gate:** psl-parser typecheck + test + lint. + +### Dispatch 3: Migrate SQL `@relation`; delete the legacy parser + +- **Outcome:** `sqlRelation` spec defined; the `@relation` call sites in `packages/2-sql/.../interpreter.ts` + `psl-relation-resolution.ts` route through `interpretAttribute` with an assembled `InterpretCtx`; `parseRelationAttribute` (and helpers it alone used) deleted; diagnostic codes + spans byte-identical (message-text per Open Question 1). +- **Builds on:** Dispatch 2's combinator set. +- **Hands to:** SQL `@relation` validated via spec; legacy parser gone — the migration recipe slices 2–3 follow. +- **Focus:** `@relation` only. Other SQL attributes stay on their legacy paths (slice 2). +- **Gate:** `pnpm --filter @prisma-next/contract-psl-sql test` (relations + diagnostics suites); `pnpm fixtures:check`; `rg "parseRelationAttribute"` empty; workspace `pnpm typecheck` after `psl-parser` build (cross-package consumer check). + +_(Final `hands to` ⊇ slice-DoD: legacy parser removed (D3), kit exported + tested (D1–D2), parity gates green (D3). Complete.)_ diff --git a/projects/typed-attribute-parsers/slices/attribute-spec-kit/spec.md b/projects/typed-attribute-parsers/slices/attribute-spec-kit/spec.md new file mode 100644 index 0000000000..a959ad46c5 --- /dev/null +++ b/projects/typed-attribute-parsers/slices/attribute-spec-kit/spec.md @@ -0,0 +1,83 @@ +# Slice: attribute-spec-kit + +_(In-project slice. Parent: `projects/typed-attribute-parsers/`. Outcome it contributes: stands up the declarative-attribute engine the whole project builds on, proven by one real attribute.)_ + +## At a glance + +Build the combinator kit + `interpretAttribute` + `InferAttr` + `InterpretCtx` in `psl-parser`, and migrate the SQL family's `@relation` from the hand-written `parseRelationAttribute` (in `packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts`) to a declarative `AttributeSpec` lowered through `interpretAttribute`. After this slice, `@relation` (SQL) is validated and lowered via a spec, and the engine + the combinators `@relation` needs exist for slices 2–3 to consume. + +## Chosen design + +The engine consumes the parser's `ExpressionAst` CST directly (already exported from `psl-parser`). An `ArgType` parses one argument; an `AttributeSpec` lists positional params + named params + an optional `refine`; `interpretAttribute(attrNode, spec, ctx)` returns `Result, Diagnostic[]>`. + +The SQL `@relation` spec replaces `parseRelationAttribute`: + +```ts +const sqlRelation = fieldAttribute('relation', { + positional: [{ key: 'name', type: optional(str()) }], // positional-or-named alias for `name` + named: { + name: optional(str()), + fields: optional(list(fieldRef('self'), { nonEmpty: true })), + references: optional(list(fieldRef('referenced'), { nonEmpty: true })), + map: optional(str()), + onDelete: optional(enumOf('NoAction', 'Restrict', 'Cascade', 'SetNull', 'SetDefault')), + onUpdate: optional(enumOf('NoAction', 'Restrict', 'Cascade', 'SetNull', 'SetDefault')), + }, + refine: relationInvariants, // fields/references both-or-neither; positional/named name conflict +}); +``` + +`interpretAttribute(relationNode, sqlRelation, ctx)` yields the same shape `ParsedRelationAttribute` carries today (`{ relationName?, fields?, references?, constraintName?, onDelete?, onUpdate? }`), mapped at the call site. `InterpretCtx` is assembled from data the SQL interpreter already holds (symbol table, declaring model, referenced-model resolver, declaring field, source id). + +**Minimal kit, grown by consumers.** Slice 1 ships the engine plus only the combinators `@relation` needs — `str`, `enumOf`, `fieldRef(scope)`, `list({ nonEmpty })`, `optional`, `fieldAttribute`. The rest of ADR 231's alphabet (`int`, `bool`, `json`, `map`, `record`, `entityRef`, `codecRef`, `oneOf`, `funcCall`/`funcCallFrom`, `modelAttribute`, `blockAttribute`) is added by slices 2–3 as the attributes they migrate require it. This keeps slice 1 reviewable. + +## Coherence rationale + +One reviewer holds it in one sitting: a new authoring surface (`psl-parser` kit) plus its first consumer (`@relation`), reviewed together so the engine is judged against a real attribute rather than in the abstract. The legacy `parseRelationAttribute` is deleted in the same PR, so there is never a second live validation path for `@relation`. + +## Scope + +**In:** +- `psl-parser`: `ArgType`, `AttributeSpec`, `Param`/`optional`, `fieldAttribute`, `interpretAttribute`, `InferAttr`, `InterpretCtx`, and the combinators `str`, `enumOf`, `fieldRef`, `list` — with unit + type-level tests; exported from `psl-parser`'s public surface. +- `packages/2-sql/2-authoring/contract-psl`: `sqlRelation` spec; route the `@relation` call sites in `interpreter.ts` / `psl-relation-resolution.ts` through `interpretAttribute`; assemble `InterpretCtx`; delete `parseRelationAttribute` (and any now-dead helpers it alone used). + +**Out:** +- All other SQL attributes (`@id`, `@unique`, `@@index`, `@default`, `@map`, `@@control`, `@@discriminator`, `@@base`) — slice 2. +- All Mongo attributes — slice 3. +- The unused-by-`@relation` combinators (`int`, `bool`, `json`, `map`, `record`, `entityRef`, `codecRef`, `oneOf`, `funcCall`, `modelAttribute`, `blockAttribute`). +- Language-server consumers; `@db.*`; the TS builder surface. + +## Pre-investigated edge cases + +| Edge case | Disposition | Notes | +| --------- | ----------- | ----- | +| Positional + named `name` both present and disagreeing | Must preserve | Existing code emits `PSL_INVALID_RELATION_ATTRIBUTE` "conflicting positional and named relation names"; reproduce via `refine` or the alias merge. | +| `fields` without `references` (or vice-versa) | Must preserve | Existing both-or-neither check, code `PSL_INVALID_RELATION_ATTRIBUTE`; lives in `refine`. | +| Unknown named argument (e.g. `@relation(foo: 1)`) | Must preserve | Existing code rejects with `PSL_INVALID_RELATION_ATTRIBUTE`; the engine's named-map closedness must reject it (see Open Question on message text). | +| `onDelete`/`onUpdate` value not in the action set | Must preserve code | Today `PSL_UNSUPPORTED_REFERENTIAL_ACTION` is raised *downstream* by `normalizeReferentialAction`, not at parse; decide whether `enumOf` raises at parse with the same code or the value passes through to the existing normaliser. | + +## Slice-specific done conditions + +- [ ] `rg "parseRelationAttribute"` returns zero results outside its deleted definition. +- [ ] `pnpm fixtures:check` clean and the SQL interpreter relations suites pass (`interpreter.relations.test.ts`, `interpreter.relations.many-to-many.test.ts`, `interpreter.diagnostics.test.ts`). +- [ ] Diagnostic **codes and spans** for every `@relation` error path are byte-identical to pre-slice behaviour. Message text may change to the kit's phrasing (see Resolved decision 1) but must stay clear and actionable; updated test assertions are reviewed as intentional. + +## Resolved decisions + +1. **Diagnostic-message parity — codes + spans only (operator-authorised, 2026-06-29).** Codes + spans are the hard parity gate. Cross-argument messages emitted from hand-written `refine` (both-or-neither, name-conflict) should stay close to the existing text, but generic-combinator-emitted messages (unknown-arg, malformed-list, bad-enum-value) may adopt the kit's phrasing as long as they remain clear. Affected interpreter-test message assertions are updated as intentional, reviewer-approved changes. The engine does **not** need subject-label message-templating in slice 1. This relaxes the project's original "identical messages" cross-cutting requirement, which has been amended accordingly. + +## Resolved decisions (cont.) + +2. **`onDelete`/`onUpdate` keep `normalizeReferentialAction` as validator (resolved at D3).** `onDelete: Cascade` is a **bare identifier**, and legacy validates the action set *downstream* via `normalizeReferentialAction`, emitting `PSL_UNSUPPORTED_REFERENTIAL_ACTION`. Using `enumOf` (set-validation at parse) would change that code and break the codes-parity bar. So the `sqlRelation` spec parses `onDelete`/`onUpdate` to the **raw identifier name** (a bare-identifier leaf, no parse-time set check) and routes the value to the existing `normalizeReferentialAction` unchanged — exact code/span/message parity. `enumOf` is NOT used for `@relation` actions (it remains for slices 2–3). D3 adds the small bare-identifier-name leaf if one isn't already present. + +## Open Questions + +None — all resolved. + +## References + +- Parent project: `projects/typed-attribute-parsers/spec.md` +- Linear issue: [TML-2956](https://linear.app/prisma-company/issue/TML-2956) +- ADR 231 — `docs/architecture docs/adrs/ADR 231 - Declarative attribute specifications.md` +- Legacy parser being replaced: `packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts` (`parseRelationAttribute`, `normalizeReferentialAction`) +- Engine input type: `ExpressionAst` and friends, exported from `packages/1-framework/2-authoring/psl-parser/src/exports/syntax.ts` diff --git a/projects/typed-attribute-parsers/spec.md b/projects/typed-attribute-parsers/spec.md new file mode 100644 index 0000000000..0b5309cb2d --- /dev/null +++ b/projects/typed-attribute-parsers/spec.md @@ -0,0 +1,103 @@ +# typed-attribute-parsers + +## Purpose + +Give every PSL attribute a single declarative description that the family interpreters read to validate and lower its arguments, so that "what an attribute accepts" lives as inspectable data in one place instead of as hand-written parsing code duplicated across the SQL and Mongo interpreters. The why is permanence-of-knowledge: the same description must later be readable by other consumers (the language server) without re-deriving it — this project earns that by making the interpreter the first consumer. + +## At a glance + +Today each interpreter pulls raw argument text out of the AST and re-checks its shape by hand. `@relation`'s `fields` argument is a string that gets `split(',')`; `onDelete` is a string compared against a literal list; an unknown named argument is rejected by ad-hoc code — the same patterns repeated in slightly different ways across `packages/2-sql/.../interpreter.ts` (2155 lines) and the Mongo interpreter, backed by string helpers like `parseFieldList` and `parseQuotedStringLiteral`. + +This project replaces that with one declarative `AttributeSpec` per attribute, composed from a fixed kit of argument combinators (ADR 231), and a single `interpretAttribute(node, spec, ctx)` that returns a strongly-typed object whose shape is **inferred from the spec** (`InferAttr`) — or structured diagnostics. + +``` +// before — hand-written, per attribute, per family +const raw = getNamedArgument(attr, 'fields'); // string +const fields = parseFieldList(raw); // split(',') + trim +const onDelete = normalizeReferentialAction(getNamedArgument(attr, 'onDelete')); +// … unknown-argument rejection, span anchoring, both-or-neither rule, all by hand + +// after — one description the interpreter reads +const sqlRelation = fieldAttribute('relation', { + positional: [{ key: 'name', type: optional(str()) }], + named: { + fields: optional(list(fieldRef('self'), { nonEmpty: true })), + references: optional(list(fieldRef('referenced'), { nonEmpty: true })), + onDelete: optional(enumOf('NoAction', 'Restrict', 'Cascade', 'SetNull', 'SetDefault')), + onUpdate: optional(enumOf('NoAction', 'Restrict', 'Cascade', 'SetNull', 'SetDefault')), + map: optional(str()), + }, + refine: relationInvariants, // fields + references are both-or-neither +}); +const parsed = interpretAttribute(relationNode, sqlRelation, ctx); // typed result | diagnostics +``` + +The emitted contract is unchanged; what changes is how the interpreter arrives at it. The output type is derived from the spec, so it cannot drift from the validation. + +## Non-goals + +- **Language-server integration.** Completion, go-to-definition, find-usages, and hovers over attribute arguments are the follow-up that this project's specs are designed to enable, but no language-server consumer is built here. Specs must carry the structure those features need (reference scopes, enum value sets), but wiring them into the editor is out of scope. +- **`@db.*` native types.** These are attributes on named-type declarations, not on fields or models, and are handled by a separate resolver path (ADR 231 § Out of scope). Untouched. +- **The TypeScript builder authoring surface.** Attributes are a PSL-only concept; the TS builders never use them. No combinator appears in the builder API. +- **New attribute syntax or new attributes.** This project re-expresses the *existing* attribute surface as specs; it does not add, remove, or change which attributes or argument shapes are accepted. +- **Generic-block `key = value` parameters and enum member values.** ADR 231 floats unifying these with the kit as an open question; this project does not pursue it. + +## Place in the larger world + +- **ADR 231 — Declarative attribute specifications** is the architectural driver; this project is its first (interpreter-only) implementation. The ADR is `Proposed`; this project's close-out should move it toward `Accepted` or record divergences. +- **The combinator kit lives in `psl-parser`** (`packages/1-framework/2-authoring/psl-parser`) — the PSL authoring-layer package that already owns the parser, the `ExpressionAst` CST, and the `SymbolTable`. It is not in the target-agnostic framework core, because attributes are PSL-specific. `psl-parser` exports the kit, `AttributeSpec`, `interpretAttribute`, and `InferAttr` alongside its existing AST exports. +- **The two consumers** are the family interpreters: `packages/2-sql/2-authoring/contract-psl` and `packages/2-mongo-family/2-authoring/contract-psl`. Each contributes the specs for the attributes it understands, registered by `(level, name)`; the kit dispatches generically and never learns an attribute's name (the ADR-225 contribution model). +- **Argument representation.** Combinators parse the parser's `ExpressionAst` directly — the CST union (`ArrayLiteralAst`, `ObjectLiteralExprAst`, `StringLiteralExprAst`, `FunctionCallAst`, …) that `psl-parser` already exports, which carries native `[…]` / `{…}` literals and real spans. No new intermediate argument representation is introduced. The migration routes each interpreter call site away from the string-flattened `ResolvedAttribute` (`readResolvedArgList`, which collapses arguments to `value: string`) and toward passing the CST attribute node (`FieldAttributeAst` / `ModelAttributeAst`) into `interpretAttribute`. The interpreter already receives the `SymbolTable` and `SourceFile` rather than a pre-flattened document, so the CST is in reach at every call site. +- **Resolution context.** Reference combinators draw on an `InterpretCtx` carrying the parser's `SymbolTable`, the declaring model, a referenced-model resolver, the declaring field (field level only), a codec lookup, and the default-function registry — all already present in the interpreters' existing wiring. + +## Cross-cutting requirements + +- **Behavioural parity, end to end.** For every attribute migrated, the interpreter produces the identical contract output and identical diagnostic **codes** it produced before. Diagnostic **spans** must be **no coarser** than before — narrower/more-precise spans (the natural result of the kit's per-argument anchoring, per ADR 231's native-literal-spans benefit) are acceptable; widening a span is not. Diagnostic **message text** may change to the combinator kit's phrasing, provided each message stays clear and actionable. Malformed inputs that legacy tolerated by coincidence (e.g. a quoted referential action `onDelete: "Cascade"`) may be rejected by the stricter typed leaves. `pnpm fixtures:check` and the interpreter test suites are the parity gate; no contract-output or diagnostic-code drift is acceptable without an explicit, reviewed rationale. _(Messages-may-change + spans-no-coarser + stricter-malformed-rejection relaxations authorised by operator, 2026-06-29.)_ +- **The spec is the only source of an attribute's argument shape.** Once an attribute is migrated, no hand-written argument-parsing path for it remains. Its output type is `InferAttr`, not a separately maintained interface. +- **Leaf parsing is pure.** A combinator returns diagnostics in a `Result`, never into a shared sink, so `oneOf` can try and discard branches cleanly. No combinator mutates a diagnostics array passed by reference. +- **Generic dispatch.** The kit and `interpretAttribute` never branch on a specific attribute name; families register specs and the engine dispatches structurally. + +## Transitional-shape constraints + +- **Every slice keeps CI green on `main`** — `pnpm typecheck`, `pnpm lint`, `pnpm test:packages`, and `pnpm fixtures:check` all pass at every merge. +- **Incremental, attribute-by-attribute migration.** Specs and hand-written parsing coexist while the migration is in flight; the interpreter may route some attributes through specs and others through legacy code simultaneously. A slice migrates a coherent group of attributes (e.g. all of `@relation`) and deletes the legacy path for exactly that group — never leaving two live validation paths for the same attribute. +- **`interpretAttribute` and the kit land with the first migrated attribute**, leaving the existing `ResolvedAttribute` string-flattening path (`readResolvedArgList` and the string helpers) in place for every not-yet-migrated attribute, so legacy parsing keeps working until its attribute is migrated. Those legacy paths are deleted only when their last caller is migrated. + +## Project Definition of Done + +- [ ] Team-DoD floor items (inherited from [`drive/calibration/dod.md`](../../drive/calibration/dod.md) — repo-wide gates, doc/migration, Linear close-out, manual-QA roll-up, ADR audit). +- [ ] The combinator kit, `AttributeSpec`, `interpretAttribute`, and `InferAttr` exist in `psl-parser` (exported alongside its existing AST exports) with unit tests covering each combinator's parse + diagnostic behaviour. +- [ ] Every field-, model-, and block-level attribute interpreted by the **SQL** family is described by a spec and lowered via `interpretAttribute`; the corresponding hand-written argument-parsing helpers are deleted. +- [ ] Every field-, model-, and block-level attribute interpreted by the **Mongo** family is described by a spec and lowered via `interpretAttribute`; the corresponding hand-written argument-parsing helpers are deleted. +- [ ] `pnpm fixtures:check` is clean and the SQL + Mongo interpreter test suites pass with no diagnostic-parity regressions. +- [ ] No remaining caller of the removed string helpers (`parseFieldList`, `parseAttributeFieldList`, per-attribute `getNamedArgument`/`getPositionalArgument` re-parsing) for any migrated attribute; a grep gate confirms this. +- [ ] ADR 231 updated to reflect what shipped (status advanced and/or divergences recorded). + +### Contract-impact + +The **emitted contract is unchanged** — this is a refactor of how interpreters validate arguments, gated by `fixtures:check`. There is no new argument representation: combinators consume the existing `ExpressionAst` CST. The internal change is that migrated interpreter call sites stop flattening attributes to `ResolvedAttribute` strings and instead pass CST attribute nodes into `interpretAttribute`. `ResolvedAttribute` / `readResolvedArgList` and the string helpers remain until their last caller is migrated, then are deleted. + +### Adapter-impact + +No `packages/3-targets/**` adapter is touched. The interpreters being migrated live in the family **authoring** layer (`packages/2-sql`, `packages/2-mongo-family`), upstream of the target adapters; adapter behaviour is reached only through the unchanged contract. + +## Resolved decisions + +- **Argument representation — `ExpressionAst`, no intermediate form.** Combinators consume the parser's `ExpressionAst` CST directly. No `PslArgAst` or other intermediate value is introduced. (Folded into _Place in the larger world_ and _Contract-impact_.) +- **Kit package — inside `psl-parser`.** The kit, `AttributeSpec`, `interpretAttribute`, and `InferAttr` ship from `psl-parser`, which already owns `ExpressionAst` and the `SymbolTable`. No new package. +- **Migration ordering — deferred to planning.** Which attribute groups become slices, and in what order, is a `drive-plan-project` concern, not a spec-level decision. +- **`refine` vs. model-level aggregation — single-attribute rules only.** A single-attribute cross-argument rule — `@relation`'s "`fields` and `references` are both-or-neither" — is implemented as the spec's `refine(parsed, ctx)` callback: a function that runs *after* every argument has parsed, receives the fully-typed result object, and returns diagnostics that no single combinator could produce (each combinator sees only its own argument). That is what "the rule lives in `refine`" means — it is a field on the `AttributeSpec`, not inline interpreter code. A rule that spans *several attributes on one model* — "at most one `@@textIndex` per collection" — is not attribute-level and stays in the existing model-level aggregation that runs above the individual `interpretAttribute` calls. Decision: move single-attribute cross-argument rules into `refine`; build no new aggregator and leave today's model-level checks untouched. + +## Open Questions + +None — design settled. Migration sequencing is handed to `drive-plan-project`. + +## References + +- ADR 231 — [Declarative attribute specifications](../../docs/architecture%20docs/adrs/ADR%20231%20-%20Declarative%20attribute%20specifications.md) (the architectural driver; advance its status at close-out). +- ADR 225 — Three-layer extensibility for pack-contributed entity kinds (the contribution/registration model the kit follows). +- ADR 224 — Control policy: framework-locked vocabulary, family-owned dispatch (the `@@control` value set this kit types as `enumOf(...)`). +- ADR 221 — Contract IR: uniform entity coordinate (the coordinate model reference combinators write into). +- Current interpreters: `packages/2-sql/2-authoring/contract-psl/src/interpreter.ts`, `packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts`. +- Current arg flattening: `packages/1-framework/2-authoring/psl-parser/src/resolve.ts` (`readResolvedArgList`); string helpers in `packages/2-sql/2-authoring/contract-psl/src/psl-attribute-parsing.ts`. +- Linear issue: [TML-2956](https://linear.app/prisma-company/issue/TML-2956) (under project _Language Tools Support Prisma Next PSL_, Terminal team). From 3a0b448679143f9bee19724b25b5634d9b5bb01b Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 09:37:52 +0000 Subject: [PATCH 08/26] fix(psl-parser): reject duplicate attribute arguments unconditionally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A named key supplied twice, or a key supplied both positionally and by name, is now always a structural error — previously a duplicate named key was silently dropped and a positional/named alias only conflicted when the values differed. Drop the now-dead argValuesEqual/isPlainRecord helpers. Signed-off-by: Serhii Tatarintsev --- .../src/attribute-spec/interpret.ts | 58 ++++++------------- .../psl-parser/test/attribute-spec.test.ts | 52 +++++++++++++++-- 2 files changed, 65 insertions(+), 45 deletions(-) diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/interpret.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/interpret.ts index 4d8b87ead9..ca99fdfa37 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/interpret.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/interpret.ts @@ -52,7 +52,17 @@ export function interpretAttribute( ); continue; } - if (namedSeen.has(key)) continue; + if (namedSeen.has(key)) { + diagnostics.push( + diagnostic( + code, + `Attribute "${spec.name}" received duplicate argument "${key}"`, + ctx, + nodePslSpan(arg.syntax, ctx.sourceFile), + ), + ); + continue; + } namedSeen.add(key); const result = parseArgValue(arg, argTypeOf(param), leafCtx, diagnostics, code); if (result.ok) namedParsed.set(key, result.value); @@ -105,24 +115,14 @@ export function interpretAttribute( const fromNamed = namedSeen.has(key); if (fromPositional && fromNamed) { - const hasPositional = positionalParsed.has(key); - const hasNamed = namedParsed.has(key); - if ( - hasPositional && - hasNamed && - !argValuesEqual(positionalParsed.get(key), namedParsed.get(key)) - ) { - diagnostics.push( - diagnostic( - code, - `Attribute "${spec.name}" has conflicting positional and named values for "${key}"`, - ctx, - attributeSpan, - ), - ); - } - if (hasNamed) output[key] = namedParsed.get(key); - else if (hasPositional) output[key] = positionalParsed.get(key); + diagnostics.push( + diagnostic( + code, + `Attribute "${spec.name}" received duplicate values for "${key}" both positionally and by name`, + ctx, + attributeSpan, + ), + ); return; } if (fromNamed) { @@ -216,23 +216,3 @@ function diagnostic( ): PslDiagnostic { return { code, message, sourceId: ctx.sourceId, span }; } - -function argValuesEqual(a: unknown, b: unknown): boolean { - if (Object.is(a, b)) return true; - if (Array.isArray(a) && Array.isArray(b)) { - return a.length === b.length && a.every((element, i) => argValuesEqual(element, b[i])); - } - if (isPlainRecord(a) && isPlainRecord(b)) { - const aKeys = Object.keys(a); - const bKeys = Object.keys(b); - return ( - aKeys.length === bKeys.length && - aKeys.every((key) => Object.hasOwn(b, key) && argValuesEqual(a[key], b[key])) - ); - } - return false; -} - -function isPlainRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(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 5e0df1981f..dee7a3b239 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 @@ -151,21 +151,26 @@ describe('interpretAttribute named binding', () => { }); }); -describe('interpretAttribute positional-or-named alias', () => { - it('merges a positional and named value that agree', () => { - const { node, ctx } = fieldAttr('@rel("Posts", name: "Posts")'); +describe('interpretAttribute positional-or-named duplicate', () => { + it('rejects a key supplied both positionally and by name even when the values agree', () => { + const { node, ctx } = fieldAttr('@rel("Foo", name: "Foo")'); const spec = fieldAttribute('rel', { positional: [{ key: 'name', type: optional(str()) }], named: { name: optional(str()) }, + diagnosticCode: 'PSL_INVALID_RELATION_ATTRIBUTE', }); const result = interpretAttribute(node, spec, ctx); - expect(result.ok).toBe(true); - if (result.ok) expect(result.value).toEqual({ name: 'Posts' }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure).toHaveLength(1); + expect(result.failure[0]?.code).toBe('PSL_INVALID_RELATION_ATTRIBUTE'); + expect(result.failure[0]?.span).toEqual(nodePslSpan(node.syntax, ctx.sourceFile)); + } }); - it('reports a conflict when positional and named values disagree', () => { + it('rejects a key supplied both positionally and by name when the values disagree', () => { const { node, ctx } = fieldAttr('@rel("A", name: "B")'); const spec = fieldAttribute('rel', { positional: [{ key: 'name', type: optional(str()) }], @@ -184,6 +189,41 @@ describe('interpretAttribute positional-or-named alias', () => { }); }); +describe('interpretAttribute duplicate named arguments', () => { + it('rejects a named key supplied twice with differing values, anchored to the duplicate', () => { + const { node, ctx } = fieldAttr('@rel(name: "A", name: "B")'); + const spec = fieldAttribute('rel', { + named: { name: optional(str()) }, + diagnosticCode: 'PSL_INVALID_RELATION_ATTRIBUTE', + }); + + const result = interpretAttribute(node, spec, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure).toHaveLength(1); + expect(result.failure[0]?.code).toBe('PSL_INVALID_RELATION_ATTRIBUTE'); + expect(result.failure[0]?.span).not.toEqual(nodePslSpan(node.syntax, ctx.sourceFile)); + } + }); + + it('rejects a named key supplied twice even when the values are equal', () => { + const { node, ctx } = fieldAttr('@rel(name: "A", name: "A")'); + const spec = fieldAttribute('rel', { + named: { name: optional(str()) }, + diagnosticCode: 'PSL_INVALID_RELATION_ATTRIBUTE', + }); + + const result = interpretAttribute(node, spec, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure).toHaveLength(1); + expect(result.failure[0]?.code).toBe('PSL_INVALID_RELATION_ATTRIBUTE'); + } + }); +}); + describe('interpretAttribute optional and default application', () => { it('applies a default for an absent optional argument', () => { const { node, ctx } = fieldAttr('@rel()'); From 906e26d8c71870db7ff91577b60ea004e88fabf4 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 09:38:58 +0000 Subject: [PATCH 09/26] perf(psl-parser): drop the redundant list element copy list() spread arg.elements() into a second array only to read its length for the nonEmpty check. Iterate the generator once and track a count instead; behaviour is identical. Signed-off-by: Serhii Tatarintsev --- .../psl-parser/src/attribute-spec/combinators/list.ts | 7 ++++--- .../psl-parser/test/attribute-spec-combinators.test.ts | 9 +++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) 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 59574133f3..7e0bc49a37 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 @@ -24,15 +24,16 @@ export function list(of: ArgType, opts?: ListOptions): ArgType { if (!(arg instanceof ArrayLiteralAst)) { return notOk([leafDiagnostic(ctx, arg, `Expected a list of ${of.label}`)]); } - const elements = [...arg.elements()]; const diagnostics: PslDiagnostic[] = []; const parsed: { node: ExpressionAst; value: T }[] = []; - for (const element of elements) { + let count = 0; + for (const element of arg.elements()) { + count += 1; const result = of.parse(element, ctx); if (result.ok) parsed.push({ node: element, value: result.value }); else diagnostics.push(...result.failure); } - if (opts?.nonEmpty === true && elements.length === 0) { + if (opts?.nonEmpty === true && count === 0) { diagnostics.push(leafDiagnostic(ctx, arg, 'Expected a non-empty list')); } if (opts?.unique === true) { 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 e51eb02197..9b8f1026eb 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 @@ -195,6 +195,15 @@ describe('list', () => { if (!result.ok) expect(result.failure).toHaveLength(1); }); + it('accepts a populated list when nonEmpty is set', () => { + const { expr, ctx } = argOf('["a", "b"]'); + + const result = list(str(), { nonEmpty: true }).parse(expr, ctx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toEqual(['a', 'b']); + }); + it('rejects duplicates when unique is set, anchored per offending element', () => { const { expr, ctx } = argOf('["a", "a"]'); From 225d2b7cbd360e943c2a9314d72952a4a37d1267 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 09:39:50 +0000 Subject: [PATCH 10/26] fix(sql-contract-psl): reject duplicate columns in @relation fields/references Mark the @relation fields and references lists unique:true so a repeated FK column name is rejected at parse and can never reach foreignKeyNodes. Signed-off-by: Serhii Tatarintsev --- .../src/psl-relation-resolution.ts | 4 +-- .../test/interpreter.relations.test.ts | 34 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) 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 29b0af2aeb..0dc4b144e1 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 @@ -138,8 +138,8 @@ const sqlRelation = fieldAttribute('relation', { positional: [{ key: 'name', type: optional(str()) }], named: { name: optional(str()), - fields: optional(list(fieldRef('self'), { nonEmpty: true })), - references: optional(list(fieldRef('referenced'), { nonEmpty: true })), + fields: optional(list(fieldRef('self'), { nonEmpty: true, unique: true })), + references: optional(list(fieldRef('referenced'), { nonEmpty: true, unique: true })), map: optional(str()), onDelete: optional(identifierName()), onUpdate: optional(identifierName()), diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts index 62c74619f6..6e6c888a64 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts @@ -390,6 +390,40 @@ model Post { ); }); + it('returns diagnostics when relation fields repeats a column name', () => { + const document = symbolTableInputFromParseArgs({ + schema: `model User { + id Int @id +} + +model Post { + id Int @id + userId Int + user User @relation(fields: [userId, userId], references: [id]) +} +`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ + ...baseInput, + ...document, + controlMutationDefaults: builtinControlMutationDefaults, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure.summary).toBe('PSL to SQL contract interpretation failed'); + expect(result.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_INVALID_RELATION_ATTRIBUTE', + message: expect.stringContaining('Duplicate'), + }), + ]), + ); + }); + it('returns diagnostics when relation omits required fields argument', () => { const document = symbolTableInputFromParseArgs({ schema: `model User { From 7cb5f75de5a9af1eb2f77ccb28f8c32d9dce6a65 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 09:43:58 +0000 Subject: [PATCH 11/26] refactor(sql-contract-psl): validate referential actions via enumOf Extend enumOf to accept a bare identifier matching a string member, then route @relation onDelete/onUpdate through enumOf instead of the bespoke identifierName leaf. A bad action now errors at parse with the attribute code (PSL_INVALID_RELATION_ATTRIBUTE) rather than downstream PSL_UNSUPPORTED_REFERENTIAL_ACTION; normalizeReferentialAction becomes a pure token-to-action mapper. Delete identifierName and its export. Signed-off-by: Serhii Tatarintsev --- .../src/attribute-spec/combinators/enum-of.ts | 13 +++-- .../combinators/identifier-name.ts | 25 -------- .../psl-parser/src/exports/index.ts | 1 - .../test/attribute-spec-combinators.test.ts | 57 +++++++------------ .../contract-psl/src/interpreter.ts | 40 ++----------- .../src/psl-relation-resolution.ts | 41 +++++-------- .../test/interpreter.relations.test.ts | 2 +- 7 files changed, 47 insertions(+), 132 deletions(-) delete mode 100644 packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/identifier-name.ts diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/enum-of.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/enum-of.ts index da0c567697..44ff692efe 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/enum-of.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/enum-of.ts @@ -1,15 +1,18 @@ import type { PslDiagnostic } from '@prisma-next/framework-components/psl-ast'; import { notOk, ok, type Result } from '@prisma-next/utils/result'; import { NumberLiteralExprAst, StringLiteralExprAst } from '../../syntax/ast/expressions'; +import { IdentifierAst } from '../../syntax/ast/identifier'; import type { ArgType } from '../types'; import { leafDiagnostic } from './diagnostic'; type EnumMember = string | number; /** - * Parses a string- or number-literal argument that is a member of a fixed set. - * Members may mix strings and numbers, so a single `enumOf` types a homogeneous - * or a mixed set; the matched member is returned with its literal type preserved. + * Parses an argument that is a member of a fixed set. The member may be written + * as a string literal, a number literal, or — matching a string member — a bare + * identifier (e.g. a referential action `Cascade`). Members may mix strings and + * numbers, so a single `enumOf` types a homogeneous or a mixed set; the matched + * member is returned with its literal type preserved. */ export function enumOf( ...values: Values @@ -29,7 +32,9 @@ export function enumOf( ? arg.value() : arg instanceof NumberLiteralExprAst ? arg.value() - : undefined; + : arg instanceof IdentifierAst + ? arg.name() + : undefined; if (value !== undefined && isMember(value)) return ok(value); return notOk([leafDiagnostic(ctx, arg, `Expected one of: ${label}`)]); }, diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/identifier-name.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/identifier-name.ts deleted file mode 100644 index 470b7ab019..0000000000 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/identifier-name.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { PslDiagnostic } from '@prisma-next/framework-components/psl-ast'; -import { notOk, ok, type Result } from '@prisma-next/utils/result'; -import { IdentifierAst } from '../../syntax/ast/identifier'; -import type { ArgType } from '../types'; -import { leafDiagnostic } from './diagnostic'; - -/** - * Parses a bare identifier into its name, with no set or existence check. Used - * for an argument whose identifier is validated downstream — e.g. a referential - * action routed to its normaliser — so a parse-time check would emit a second - * diagnostic for the same fault. Like `fieldRef` minus the resolution scope. - */ -export function identifierName(): ArgType { - return { - kind: 'identifierName', - label: 'identifier', - parse: (arg, ctx): Result => { - if (arg instanceof IdentifierAst) { - const name = arg.name(); - if (name !== undefined) return ok(name); - } - return notOk([leafDiagnostic(ctx, arg, 'Expected an identifier')]); - }, - }; -} 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 5900b4196b..0b3444a3e0 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 @@ -39,7 +39,6 @@ export { getPositionalArgument, parseQuotedStringLiteral } from '../attribute-he export { enumOf } from '../attribute-spec/combinators/enum-of'; export type { FieldRefArgType, FieldRefScope } from '../attribute-spec/combinators/field-ref'; export { fieldRef } from '../attribute-spec/combinators/field-ref'; -export { identifierName } from '../attribute-spec/combinators/identifier-name'; export type { ListOptions } from '../attribute-spec/combinators/list'; export { list } from '../attribute-spec/combinators/list'; export { str } from '../attribute-spec/combinators/str'; 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 9b8f1026eb..0c86ed1228 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.test.ts @@ -1,15 +1,7 @@ import type { PslDiagnostic } from '@prisma-next/framework-components/psl-ast'; import { describe, expect, it } from 'vitest'; import type { InterpretCtx } from '../src/exports'; -import { - enumOf, - fieldAttribute, - fieldRef, - identifierName, - interpretAttribute, - list, - str, -} from '../src/exports'; +import { enumOf, fieldAttribute, fieldRef, interpretAttribute, list, str } from '../src/exports'; import { Cursor, parse, parseAttribute } from '../src/parse'; import type { SourceFile } from '../src/source-file'; import { buildSymbolTable } from '../src/symbol-table'; @@ -111,6 +103,24 @@ describe('enumOf', () => { expect(result.ok).toBe(false); if (!result.ok) expect(result.failure).toHaveLength(1); }); + + it('accepts a bare identifier matching a string member', () => { + const { expr, ctx } = argOf('Cascade'); + + const result = enumOf('Cascade', 'SetNull').parse(expr, ctx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toBe('Cascade'); + }); + + it('rejects a bare identifier matching no member', () => { + const { expr, ctx } = argOf('WeirdAction'); + + const result = enumOf('Cascade', 'SetNull').parse(expr, ctx); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.failure[0]?.code).toBe('PSL_INVALID_ATTRIBUTE_SYNTAX'); + }); }); describe('fieldRef', () => { @@ -147,35 +157,6 @@ describe('fieldRef', () => { }); }); -describe('identifierName', () => { - it('returns the bare identifier name', () => { - const { expr, ctx } = argOf('Cascade'); - - const result = identifierName().parse(expr, ctx); - - expect(result.ok).toBe(true); - if (result.ok) expect(result.value).toBe('Cascade'); - }); - - it('returns an unknown identifier name without a set or existence check', () => { - const { expr, ctx } = argOf('WeirdAction'); - - const result = identifierName().parse(expr, ctx); - - expect(result.ok).toBe(true); - if (result.ok) expect(result.value).toBe('WeirdAction'); - }); - - it('rejects a quoted string with the threaded code', () => { - const { expr, ctx } = argOf('"Cascade"'); - - const result = identifierName().parse(expr, ctx); - - expect(result.ok).toBe(false); - if (!result.ok) expect(result.failure[0]?.code).toBe('PSL_INVALID_ATTRIBUTE_SYNTAX'); - }); -}); - describe('list', () => { it('maps each element through the element combinator', () => { const { expr, ctx } = argOf('["a", "b"]'); 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 b2ad091948..f7b1ed7335 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts @@ -898,26 +898,10 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult } const onDelete = parsedRelation.onDelete - ? normalizeReferentialAction({ - modelName: model.name, - fieldName: relationAttribute.field.name, - actionName: 'onDelete', - actionToken: parsedRelation.onDelete, - sourceId, - span: relationAttribute.field.span, - diagnostics, - }) + ? normalizeReferentialAction(parsedRelation.onDelete) : undefined; const onUpdate = parsedRelation.onUpdate - ? normalizeReferentialAction({ - modelName: model.name, - fieldName: relationAttribute.field.name, - actionName: 'onUpdate', - actionToken: parsedRelation.onUpdate, - sourceId, - span: relationAttribute.field.span, - diagnostics, - }) + ? normalizeReferentialAction(parsedRelation.onUpdate) : undefined; // Target namespace: use the colon-prefix namespace qualifier, or `__unbound__` when the @@ -1092,26 +1076,10 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult } const onDelete = parsedRelation.onDelete - ? normalizeReferentialAction({ - modelName: model.name, - fieldName: relationAttribute.field.name, - actionName: 'onDelete', - actionToken: parsedRelation.onDelete, - sourceId, - span: relationAttribute.field.span, - diagnostics, - }) + ? normalizeReferentialAction(parsedRelation.onDelete) : undefined; const onUpdate = parsedRelation.onUpdate - ? normalizeReferentialAction({ - modelName: model.name, - fieldName: relationAttribute.field.name, - actionName: 'onUpdate', - actionToken: parsedRelation.onUpdate, - sourceId, - span: relationAttribute.field.span, - diagnostics, - }) + ? normalizeReferentialAction(parsedRelation.onUpdate) : undefined; const targetNamespaceId = 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 0dc4b144e1..ed1b8ee502 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 @@ -10,9 +10,9 @@ import type { SymbolTable, } from '@prisma-next/psl-parser'; import { + enumOf, fieldAttribute, fieldRef, - identifierName, interpretAttribute, list, nodePslSpan, @@ -79,27 +79,13 @@ export function fkRelationPairKey(declaringModelName: string, targetModelName: s return `${declaringModelName}::${targetModelName}`; } -export function normalizeReferentialAction(input: { - readonly modelName: string; - readonly fieldName: string; - readonly actionName: 'onDelete' | 'onUpdate'; - readonly actionToken: string; - readonly sourceId: string; - readonly span: PslSpan; - readonly diagnostics: ContractSourceDiagnostic[]; -}): ReferentialAction | undefined { - const normalized = REFERENTIAL_ACTION_MAP[input.actionToken]; - if (normalized) { - return normalized; - } - - input.diagnostics.push({ - code: 'PSL_UNSUPPORTED_REFERENTIAL_ACTION', - message: `Relation field "${input.modelName}.${input.fieldName}" has unsupported ${input.actionName} action "${input.actionToken}"`, - sourceId: input.sourceId, - span: input.span, - }); - return undefined; +/** + * Maps a validated referential-action token to its contract action. The action + * set is validated upstream by the `@relation` spec's `enumOf`, so this is a + * pure lookup with no second validation path. + */ +export function normalizeReferentialAction(actionToken: string): ReferentialAction | undefined { + return REFERENTIAL_ACTION_MAP[actionToken]; } /** @@ -130,9 +116,10 @@ function relationInvariants( * Declarative replacement for the hand-written `@relation` parser. The engine * binds the positional-or-named `name`, the `fields`/`references` field lists, * `map`, and the bare-identifier referential actions; `relationInvariants` - * holds the both-or-neither rule. The action set itself is validated downstream - * by `normalizeReferentialAction`, so the actions are parsed to their raw - * identifier name here without a parse-time set check. + * holds the both-or-neither rule. The action set is validated at parse via + * `enumOf`, which accepts the bare identifier and rejects an unknown action + * with the attribute's own code; `normalizeReferentialAction` then maps the + * validated token to its contract action. */ const sqlRelation = fieldAttribute('relation', { positional: [{ key: 'name', type: optional(str()) }], @@ -141,8 +128,8 @@ const sqlRelation = fieldAttribute('relation', { fields: optional(list(fieldRef('self'), { nonEmpty: true, unique: true })), references: optional(list(fieldRef('referenced'), { nonEmpty: true, unique: true })), map: optional(str()), - onDelete: optional(identifierName()), - onUpdate: optional(identifierName()), + onDelete: optional(enumOf('NoAction', 'Restrict', 'Cascade', 'SetNull', 'SetDefault')), + onUpdate: optional(enumOf('NoAction', 'Restrict', 'Cascade', 'SetNull', 'SetDefault')), }, refine: relationInvariants, diagnosticCode: 'PSL_INVALID_RELATION_ATTRIBUTE', diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts index 6e6c888a64..7c42c7ea79 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts @@ -311,7 +311,7 @@ model Post { expect(result.failure.diagnostics).toEqual( expect.arrayContaining([ expect.objectContaining({ - code: 'PSL_UNSUPPORTED_REFERENTIAL_ACTION', + code: 'PSL_INVALID_RELATION_ATTRIBUTE', sourceId: 'schema.prisma', }), ]), From b917c7c800b125aeb7724869f5535adf2afaf394 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 09:46:06 +0000 Subject: [PATCH 12/26] feat(psl-parser): resolve fieldRef against the scoped symbol table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fieldRef now resolves a field name against its scoped model — self against the declaring model, referenced against the relation target — and emits the field-existence diagnostic at parse with the attribute code. A referenced target out of scope (cross-space) is still carried through unchecked. For @relation this short-circuits the parse before the downstream column resolution, so a missing field yields exactly one diagnostic; the downstream mapping is retained for column lookup. Signed-off-by: Serhii Tatarintsev --- .../attribute-spec/combinators/field-ref.ts | 28 ++++++++++++----- .../test/attribute-spec-combinators.test.ts | 30 ++++++++++++++++--- .../test/interpreter.relations.test.ts | 12 +++----- 3 files changed, 50 insertions(+), 20 deletions(-) diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/field-ref.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/field-ref.ts index bcad878410..303557fdbd 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/field-ref.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/field-ref.ts @@ -17,10 +17,13 @@ export interface FieldRefArgType extends ArgType { } /** - * Parses a bare identifier into the field name. Existence is deliberately not - * checked here: the downstream interpreter validates the field against the - * scoped entity, so a parse-time check would emit a second diagnostic for the - * same fault. + * Parses a bare identifier into a field name and resolves it against the scoped + * model: `'self'` against the declaring model, `'referenced'` against a + * relation's target. A name absent from the resolved model emits the + * field-existence diagnostic here, anchored to the identifier. When the + * referenced model is out of scope (e.g. a cross-space target the parser cannot + * see), existence cannot be checked, so the name is carried through unchecked + * and validated where the target is known. The parsed value is always the name. */ export function fieldRef(scope: FieldRefScope): FieldRefArgType { return { @@ -28,11 +31,20 @@ export function fieldRef(scope: FieldRefScope): FieldRefArgType { label: 'field name', scope, parse: (arg, ctx): Result => { - if (arg instanceof IdentifierAst) { - const name = arg.name(); - if (name !== undefined) return ok(name); + if (!(arg instanceof IdentifierAst)) { + return notOk([leafDiagnostic(ctx, arg, 'Expected a field name')]); } - return notOk([leafDiagnostic(ctx, arg, 'Expected a field name')]); + const name = arg.name(); + if (name === undefined) { + return notOk([leafDiagnostic(ctx, arg, 'Expected a field name')]); + } + const model = scope === 'self' ? ctx.selfModel : ctx.resolveReferencedModel(); + if (model !== undefined && !Object.hasOwn(model.fields, name)) { + return notOk([ + leafDiagnostic(ctx, arg, `Field "${name}" does not exist on model "${model.name}"`), + ]); + } + return ok(name); }, }; } 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 0c86ed1228..b31256d35f 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 @@ -124,20 +124,42 @@ describe('enumOf', () => { }); describe('fieldRef', () => { - it('returns the bare identifier name', () => { - const { expr, ctx } = argOf('title'); + it('resolves a field that exists on the self model', () => { + const { expr, ctx } = argOf('id'); const result = fieldRef('self').parse(expr, ctx); expect(result.ok).toBe(true); - if (result.ok) expect(result.value).toBe('title'); + if (result.ok) expect(result.value).toBe('id'); }); - it('returns the name without emitting an existence diagnostic for an unknown field', () => { + it('emits an existence diagnostic for a field missing from the self model', () => { const { expr, ctx } = argOf('ghostField'); const result = fieldRef('self').parse(expr, ctx); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure).toHaveLength(1); + expect(result.failure[0]?.code).toBe('PSL_INVALID_ATTRIBUTE_SYNTAX'); + } + }); + + it('resolves a field against the referenced model when it is in scope', () => { + const { expr, ctx } = argOf('id'); + const referencedCtx: InterpretCtx = { ...ctx, resolveReferencedModel: () => ctx.selfModel }; + + const result = fieldRef('referenced').parse(expr, referencedCtx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toBe('id'); + }); + + it('carries a referenced name through when the referenced model is out of scope', () => { + const { expr, ctx } = argOf('ghostField'); + + const result = fieldRef('referenced').parse(expr, ctx); + expect(result.ok).toBe(true); if (result.ok) expect(result.value).toBe('ghostField'); }); diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts index 7c42c7ea79..71de972487 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.relations.test.ts @@ -345,10 +345,8 @@ model Post { expect(result.failure.diagnostics).toEqual( expect.arrayContaining([ expect.objectContaining({ - code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', - message: expect.stringContaining( - 'Relation field "Post.user" references unknown field "Post.missingUserId"', - ), + code: 'PSL_INVALID_RELATION_ATTRIBUTE', + message: expect.stringContaining('Field "missingUserId" does not exist on model "Post"'), }), ]), ); @@ -381,10 +379,8 @@ model Post { expect(result.failure.diagnostics).toEqual( expect.arrayContaining([ expect.objectContaining({ - code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT', - message: expect.stringContaining( - 'Relation field "Post.user" references unknown field "User.missingId"', - ), + code: 'PSL_INVALID_RELATION_ATTRIBUTE', + message: expect.stringContaining('Field "missingId" does not exist on model "User"'), }), ]), ); From 8ff2118439ce9e0f46681d6f4f278e3e31269b3d Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 09:55:44 +0000 Subject: [PATCH 13/26] docs(typed-attribute-parsers): record D4 review-round-1 dispatch + parity flags Signed-off-by: Serhii Tatarintsev --- .../dispatches/04-address-review-r1.md | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/04-address-review-r1.md diff --git a/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/04-address-review-r1.md b/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/04-address-review-r1.md new file mode 100644 index 0000000000..f00387a0b1 --- /dev/null +++ b/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/04-address-review-r1.md @@ -0,0 +1,57 @@ +# Brief: D4 — address PR #891 review round 1 + +> Fresh implementer (session resume unavailable). Read the committed kit + the commented files first. This addresses a review round on the open slice-1 PR; changes land on branch `tml-2956-typed-attribute-parsers` and update the PR. **Do NOT post to GitHub or resolve review threads** — only push commits. + +## Context +- PR #891 (slice 1). Reviewers: CodeRabbit (bot) + the operator (SevInf). Files: `packages/1-framework/2-authoring/psl-parser/src/attribute-spec/` (engine + combinators), `packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts` (the `sqlRelation` spec), `interpreter.ts`. +- Slice spec + the project parity bar: `projects/typed-attribute-parsers/slices/attribute-spec-kit/spec.md`, `projects/typed-attribute-parsers/spec.md` (codes byte-identical; spans no-coarser; messages may change; stricter rejection of malformed input is allowed). + +## Tasks (address each; commit coherently) + +### T1 — Reject duplicate arguments unconditionally (interpret.ts) +- The named-argument loop (`if (namedSeen.has(key)) continue;`, ~line 55) currently **silently drops** a duplicate named key. Change it to emit a structural diagnostic (code `ctx`/`spec.diagnosticCode`, anchored to the duplicate arg's span via `nodePslSpan(arg.syntax, ctx.sourceFile)`) and not count the duplicate as a successful parse. +- The positional-vs-named alias merge in `resolveKey` (~lines 107-123) currently only emits a conflict when the two values **differ** (`!argValuesEqual(...)`). Per the operator: a key supplied **both** positionally and by name is a duplicate — reject it **regardless of value equality**. Emit the conflict/duplicate diagnostic whenever `fromPositional && fromNamed`. +- **Remove the now-dead `argValuesEqual` + `isPlainRecord` helpers** (they existed only for the value-equality escape hatch). +- Add/adjust unit tests: `name: "A", name: "B"` and `name: "A", name: "A"` both rejected; `@rel("Foo", name: "Foo")` (positional + named, equal) rejected. + +### T2 — `unique: true` on relation lists (psl-relation-resolution.ts, the `sqlRelation` spec) +- Change `fields: optional(list(fieldRef('self'), { nonEmpty: true }))` → add `unique: true`; same for `references`. So duplicate FK column names can't reach `foreignKeyNodes`. + +### T3 — Drop the unnecessary list copy (list.ts:27) +- `const elements = [...arg.elements()]` copies the iterable. If `ArrayLiteralAst.elements()` already returns an array, iterate it directly (drop the spread). If it's a generator and you only need it for the `nonEmpty` length check, track an element count in the existing parse loop instead of materialising a second array. Keep behaviour identical; just remove the redundant allocation. + +### T4 — Use `enumOf` for referential actions; delete `identifierName` (operator question identifier-name.ts:13) +- The operator's point: a referential action (`onDelete: Cascade`) is a bare-identifier enum and should go through `enumOf`, not a bespoke `identifierName` leaf. **Extend `enumOf`** to also accept a bare `IdentifierAst` whose text matches a **string** member (in addition to the existing `StringLiteralExprAst`/`NumberLiteralExprAst` handling) — additive, must not regress existing `enumOf` tests. +- In `sqlRelation`, change `onDelete`/`onUpdate` to `optional(enumOf('NoAction', 'Restrict', 'Cascade', 'SetNull', 'SetDefault'))`. Map the validated action to the `ReferentialAction` via the existing `REFERENTIAL_ACTION_MAP` (keep `normalizeReferentialAction` as the pure token→action mapper, or inline the map — your call; do not keep a redundant second validation path). +- **Delete `identifierName` + its tests + its export.** +- **Parity flag (report this):** a bad referential action now errors at parse via `enumOf` with the attribute's code (`PSL_INVALID_RELATION_ATTRIBUTE`) instead of downstream `PSL_UNSUPPORTED_REFERENTIAL_ACTION`. If any test/fixture asserts `PSL_UNSUPPORTED_REFERENTIAL_ACTION`, update it intentionally and **report the exact count + files** so the orchestrator can relay to the operator. If that code turns out to be load-bearing elsewhere (non-`@relation`), **halt and surface** instead of deleting its only producer. + +### T5 — `fieldRef` resolves via the symbol table (operator question field-ref.ts:30) +- The operator wants `fieldRef` to actually resolve the field against the symbol table it has in `ctx` (`selfModel` for `'self'`, `resolveReferencedModel()` for `'referenced'`), not treat the name as opaque. Implement resolution: look the field up on the scoped model; **if it doesn't resolve, emit the field-existence diagnostic here** (code = `ctx.diagnosticCode`, span = the identifier's span). +- **Reconcile downstream to avoid double diagnostics:** the SQL interpreter currently validates relation `fields`/`references` existence downstream (the `localColumns`/`referencedColumns` resolution in `interpreter.ts`). With `fieldRef` now validating, remove/skip that **duplicate** existence check **for the relation `@relation` path only**, so a missing field yields exactly one diagnostic. Keep the column-name mapping (the resolved field still maps to its column). +- Preserve diagnostic **code + span** parity for the missing-field case (verify against `interpreter.relations.test.ts` / `interpreter.diagnostics.test.ts`); if the diagnostic's code/span/source must shift, update assertions intentionally and report. +- **Halt and surface if** this reconciliation requires large interpreter surgery, touches non-relation field resolution, or can't preserve the cross-space/referenced-model resolution the interpreter already does. Better to surface than to sprawl. +- Keep `fieldRef`'s parsed value as the **name string** (so the `ParsedRelationAttribute` mapping is unchanged); resolution drives validation, not the return shape. + +## Scope +**In:** the five tasks above (psl-parser engine + `list`/`enumOf`/`field-ref` combinators, delete `identifier-name`; the `sqlRelation` spec + the relation call-site existence-check reconciliation in `interpreter.ts`). Tests for each. +**Out:** other attributes (slices 2–3); the rest of ADR 231's alphabet; Mongo; `@db.*`. Do not migrate a second attribute. + +## Completed when +- [ ] T1–T5 done; `identifierName` fully removed (`rg identifierName` zero). +- [ ] `rg "argValuesEqual"` zero (helper removed). +- [ ] Unit tests updated/added for T1, T3, T4 (enumOf bare-identifier), T5 (fieldRef resolution: resolves a real field; emits one diagnostic for a missing field). +- [ ] Gates green: `pnpm --filter @prisma-next/psl-parser typecheck && test && lint`; `pnpm --filter @prisma-next/sql-contract-psl test` (or the package's real name — confirm); `pnpm fixtures:check`; after `pnpm --filter @prisma-next/psl-parser build`, workspace `pnpm typecheck`. +- [ ] Report: the T4 parity flag (code change + any updated assertions/fixtures, with counts) and the T5 reconciliation (what downstream check was removed, diagnostic parity result). + +## Standing instruction +Stay focused on the goal; control scope. Halt + surface (don't sprawl) if T5's downstream reconciliation balloons or T4's code change hits load-bearing non-relation uses. + +## Constraints +No `any`; no bare `as` (narrow `blindCast`/`castAs` with reason, or types that avoid it); no file-ext imports; no reexport outside `exports/`; tests-first for new behaviour. Explicit-staging commits, no amend, **no push** (the orchestrator pushes). Read-only on `projects/**/reviews/**`, `spec.md`, plan files. Run the transient-ID scan on your `+` diff. Do NOT post to GitHub or touch review threads. + +## Operational metadata +- **Model tier:** thorough (parity-sensitive, cross-package, two design changes). +- **Halt conditions:** T5 downstream reconciliation requires large surgery / touches non-relation paths; T4 reveals `PSL_UNSUPPORTED_REFERENTIAL_ACTION` is load-bearing elsewhere; any fixture's contract output (not just diagnostics) changes. + +Return the structured report per § Return shape, with explicit per-task results (T1–T5), the parity flags, and commit SHAs. From d7e05ce80ad07e7358385e164f30fede0e39cacd Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 13:17:41 +0000 Subject: [PATCH 14/26] refactor(psl-parser): restrict enumOf to bare identifiers and number literals enumOf no longer accepts a quoted string as a member. A string member is matched only when written as a bare identifier (e.g. Cascade); a quoted argument is rejected with the leaf diagnostic. Number members continue to match number literals. Signed-off-by: Serhii Tatarintsev --- .../src/attribute-spec/combinators/enum-of.ts | 22 +++++++++---------- .../test/attribute-spec-combinators.test.ts | 8 +++---- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/enum-of.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/enum-of.ts index 44ff692efe..e847e52a74 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/enum-of.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/enum-of.ts @@ -1,6 +1,6 @@ import type { PslDiagnostic } from '@prisma-next/framework-components/psl-ast'; import { notOk, ok, type Result } from '@prisma-next/utils/result'; -import { NumberLiteralExprAst, StringLiteralExprAst } from '../../syntax/ast/expressions'; +import { NumberLiteralExprAst } from '../../syntax/ast/expressions'; import { IdentifierAst } from '../../syntax/ast/identifier'; import type { ArgType } from '../types'; import { leafDiagnostic } from './diagnostic'; @@ -8,11 +8,11 @@ import { leafDiagnostic } from './diagnostic'; type EnumMember = string | number; /** - * Parses an argument that is a member of a fixed set. The member may be written - * as a string literal, a number literal, or — matching a string member — a bare - * identifier (e.g. a referential action `Cascade`). Members may mix strings and - * numbers, so a single `enumOf` types a homogeneous or a mixed set; the matched - * member is returned with its literal type preserved. + * Parses an argument that is a member of a fixed set. A string member is matched + * only when written as a bare identifier (e.g. a referential action `Cascade`); a + * quoted string is rejected. A number member matches a number literal (`1`, `-1`). + * Members may mix strings and numbers, so a single `enumOf` types a homogeneous or + * a mixed set; the matched member is returned with its literal type preserved. */ export function enumOf( ...values: Values @@ -28,13 +28,11 @@ export function enumOf( label, parse: (arg, ctx): Result => { const value = - arg instanceof StringLiteralExprAst + arg instanceof NumberLiteralExprAst ? arg.value() - : arg instanceof NumberLiteralExprAst - ? arg.value() - : arg instanceof IdentifierAst - ? arg.name() - : undefined; + : arg instanceof IdentifierAst + ? arg.name() + : undefined; if (value !== undefined && isMember(value)) return ok(value); return notOk([leafDiagnostic(ctx, arg, `Expected one of: ${label}`)]); }, 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 b31256d35f..579f586a26 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 @@ -68,8 +68,8 @@ describe('str', () => { }); describe('enumOf', () => { - it('accepts a string member of a mixed set', () => { - const { expr, ctx } = argOf('"Cascade"'); + it('accepts a bare-identifier string member of a mixed set', () => { + const { expr, ctx } = argOf('Cascade'); const result = enumOf('Cascade', 'SetNull', 1, 2).parse(expr, ctx); @@ -86,8 +86,8 @@ describe('enumOf', () => { if (result.ok) expect(result.value).toBe(2); }); - it('rejects a non-member of the set', () => { - const { expr, ctx } = argOf('"Nope"'); + it('rejects a quoted string member', () => { + const { expr, ctx } = argOf('"Cascade"'); const result = enumOf('Cascade', 'SetNull').parse(expr, ctx); From 6a28ef83dc9cece961a98ea726e6a5e5653a01e4 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 13:26:26 +0000 Subject: [PATCH 15/26] docs(typed-attribute-parsers): record D5 enumOf restriction + slice-3 string-set follow-up Signed-off-by: Serhii Tatarintsev --- projects/typed-attribute-parsers/plan.md | 1 + 1 file changed, 1 insertion(+) diff --git a/projects/typed-attribute-parsers/plan.md b/projects/typed-attribute-parsers/plan.md index 4f5d3855ab..b709cd8a69 100644 --- a/projects/typed-attribute-parsers/plan.md +++ b/projects/typed-attribute-parsers/plan.md @@ -32,6 +32,7 @@ Three slices in a substrate-then-consumers shape: slice 1 lands the combinator k - **Builds on:** Slice 1's kit API + `InterpretCtx` wiring recipe + migration recipe. - **Hands to:** Mongo family fully spec-driven; no legacy Mongo attribute-argument parser remains (grep gate). - **Focus:** Mongo family only (`packages/2-mongo-family/2-authoring/contract-psl`). Mongo migrates its own `@relation` spec (distinct value shapes from SQL's). The "at most one `@@textIndex` per collection" rule stays in Mongo's existing model-level aggregation, not in a per-attribute `refine` (spec decision). + - **Carry-in from slice 1 (D5):** `enumOf` was restricted to bare identifiers + number literals (no quoted strings). Mongo's index `type` set (`1`, `-1`, `"text"`, `"2dsphere"`, `"2d"`, `"hashed"`) has **quoted-string** members that can't be bare identifiers (digit-leading) — so slice 3 needs a **dedicated string-literal-set combinator** (e.g. `stringEnumOf`) for that case rather than `enumOf`. ## Dependencies (external) From 003a5c8e61d61623747cf77e5ed004f77a672700 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 14:05:44 +0000 Subject: [PATCH 16/26] Replace enumOf with composable oneOf + identifier combinators Introduce oneOf(...alts) (ordered try-each, union output type, single aggregate diagnostic on total failure) and identifier(name) (pinned bare-identifier matcher with a literal output type). Rewire the relation attribute onDelete/onUpdate from enumOf to oneOf(identifier(...)), leaving the referential-action output union and normalizeReferentialAction mapping unchanged. Delete enumOf now that its behaviour is composed from the two primitives. Signed-off-by: Serhii Tatarintsev --- .../src/attribute-spec/combinators/enum-of.ts | 40 ---------- .../attribute-spec/combinators/identifier.ts | 23 ++++++ .../src/attribute-spec/combinators/one-of.ts | 38 +++++++++ .../psl-parser/src/exports/index.ts | 3 +- .../test/attribute-spec-combinators.test-d.ts | 12 +-- .../test/attribute-spec-combinators.test.ts | 77 +++++++++++++------ .../src/psl-relation-resolution.ts | 33 ++++++-- 7 files changed, 150 insertions(+), 76 deletions(-) delete mode 100644 packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/enum-of.ts create mode 100644 packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/identifier.ts create mode 100644 packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/one-of.ts diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/enum-of.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/enum-of.ts deleted file mode 100644 index e847e52a74..0000000000 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/enum-of.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { PslDiagnostic } from '@prisma-next/framework-components/psl-ast'; -import { notOk, ok, type Result } from '@prisma-next/utils/result'; -import { NumberLiteralExprAst } from '../../syntax/ast/expressions'; -import { IdentifierAst } from '../../syntax/ast/identifier'; -import type { ArgType } from '../types'; -import { leafDiagnostic } from './diagnostic'; - -type EnumMember = string | number; - -/** - * Parses an argument that is a member of a fixed set. A string member is matched - * only when written as a bare identifier (e.g. a referential action `Cascade`); a - * quoted string is rejected. A number member matches a number literal (`1`, `-1`). - * Members may mix strings and numbers, so a single `enumOf` types a homogeneous or - * a mixed set; the matched member is returned with its literal type preserved. - */ -export function enumOf( - ...values: Values -): ArgType { - const members: readonly EnumMember[] = values; - const label = members - .map((member) => (typeof member === 'string' ? `"${member}"` : String(member))) - .join(' | '); - const isMember = (candidate: EnumMember): candidate is Values[number] => - members.includes(candidate); - return { - kind: 'enumOf', - label, - parse: (arg, ctx): Result => { - const value = - arg instanceof NumberLiteralExprAst - ? arg.value() - : arg instanceof IdentifierAst - ? arg.name() - : undefined; - if (value !== undefined && isMember(value)) return ok(value); - return notOk([leafDiagnostic(ctx, arg, `Expected one of: ${label}`)]); - }, - }; -} 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 new file mode 100644 index 0000000000..061d2c195f --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/identifier.ts @@ -0,0 +1,23 @@ +import type { PslDiagnostic } from '@prisma-next/framework-components/psl-ast'; +import { notOk, ok, type Result } from '@prisma-next/utils/result'; +import { IdentifierAst } from '../../syntax/ast/identifier'; +import type { ArgType } from '../types'; +import { leafDiagnostic } from './diagnostic'; + +/** + * Matches a bare identifier whose name equals `name`, returning that name with + * its literal type preserved. Pinned-only: there is no open form, so several + * `identifier`s composed under `oneOf` infer the precise union of names. A + * non-identifier token, or an identifier with a different name, is rejected with + * the threaded code, anchored to the argument node. + */ +export function identifier(name: N): ArgType { + return { + kind: 'identifier', + label: name, + parse: (arg, ctx): Result => { + if (arg instanceof IdentifierAst && arg.name() === name) return ok(name); + return notOk([leafDiagnostic(ctx, arg, `Expected ${name}`)]); + }, + }; +} diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/one-of.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/one-of.ts new file mode 100644 index 0000000000..a129ec8353 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/one-of.ts @@ -0,0 +1,38 @@ +import type { PslDiagnostic } from '@prisma-next/framework-components/psl-ast'; +import { blindCast } from '@prisma-next/utils/casts'; +import { notOk, ok, type Result } from '@prisma-next/utils/result'; +import type { ArgType, OutOf } from '../types'; +import { leafDiagnostic } from './diagnostic'; + +/** + * Tries each alternative in order and returns the first one that succeeds; the + * output type is the union of the alternatives' output types. Because leaves are + * `Result`-pure — a failed branch returns its diagnostics rather than pushing + * them into a sink — a discarded alternative leaves no stray errors behind, so + * when every alternative fails `oneOf` emits a single aggregate diagnostic + * (listing the alternatives' labels) with the threaded code, anchored to the + * argument node, rather than leaking the branches' internal failures. + */ +export function oneOf[]>( + ...alts: Alts +): ArgType> { + const label = alts.map((alt) => alt.label).join(' | '); + return { + kind: 'oneOf', + label, + parse: (arg, ctx): Result, readonly PslDiagnostic[]> => { + for (const alt of alts) { + const result = alt.parse(arg, ctx); + if (result.ok) { + return ok( + blindCast< + OutOf, + 'The matched value comes from an alternative whose output type is a member of the union, but iterating the tuple widens each element to ArgType, erasing that relationship.' + >(result.value), + ); + } + } + return notOk([leafDiagnostic(ctx, arg, `Expected one of: ${label}`)]); + }, + }; +} 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 0b3444a3e0..5e9bee0f51 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 @@ -36,11 +36,12 @@ export { namespacePslExtensionBlocks, } from '@prisma-next/framework-components/psl-ast'; export { getPositionalArgument, parseQuotedStringLiteral } from '../attribute-helpers'; -export { enumOf } from '../attribute-spec/combinators/enum-of'; export type { FieldRefArgType, FieldRefScope } from '../attribute-spec/combinators/field-ref'; export { fieldRef } from '../attribute-spec/combinators/field-ref'; +export { identifier } from '../attribute-spec/combinators/identifier'; export type { ListOptions } from '../attribute-spec/combinators/list'; export { list } from '../attribute-spec/combinators/list'; +export { oneOf } from '../attribute-spec/combinators/one-of'; export { str } from '../attribute-spec/combinators/str'; export { fieldAttribute } from '../attribute-spec/field-attribute'; export { interpretAttribute } from '../attribute-spec/interpret'; 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 11d9465ab4..40aafe9f7f 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 @@ -1,13 +1,15 @@ import { expectTypeOf, test } from 'vitest'; import type { ArgType } from '../src/exports'; -import { enumOf, list, str } from '../src/exports'; +import { identifier, list, oneOf, str } from '../src/exports'; -test('enumOf preserves a homogeneous string member union', () => { - expectTypeOf(enumOf('NoAction', 'Cascade')).toEqualTypeOf>(); +test('identifier pins its name as the output literal type', () => { + expectTypeOf(identifier('NoAction')).toEqualTypeOf>(); }); -test('enumOf carries a mixed string/number member union', () => { - expectTypeOf(enumOf('text', 1, -1)).toEqualTypeOf>(); +test('oneOf infers the union of its alternatives output types', () => { + expectTypeOf(oneOf(identifier('NoAction'), identifier('Cascade'))).toEqualTypeOf< + ArgType<'NoAction' | 'Cascade'> + >(); }); test('list infers an array of its element type', () => { 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 579f586a26..ff0ba56a78 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.test.ts @@ -1,7 +1,17 @@ import type { PslDiagnostic } from '@prisma-next/framework-components/psl-ast'; +import { ok } from '@prisma-next/utils/result'; import { describe, expect, it } from 'vitest'; -import type { InterpretCtx } from '../src/exports'; -import { enumOf, fieldAttribute, fieldRef, interpretAttribute, list, str } from '../src/exports'; +import type { ArgType, InterpretCtx } from '../src/exports'; +import { + fieldAttribute, + fieldRef, + identifier, + interpretAttribute, + list, + nodePslSpan, + oneOf, + str, +} from '../src/exports'; import { Cursor, parse, parseAttribute } from '../src/parse'; import type { SourceFile } from '../src/source-file'; import { buildSymbolTable } from '../src/symbol-table'; @@ -67,59 +77,82 @@ describe('str', () => { }); }); -describe('enumOf', () => { - it('accepts a bare-identifier string member of a mixed set', () => { +describe('identifier', () => { + it('matches a bare identifier equal to the pinned name', () => { const { expr, ctx } = argOf('Cascade'); - const result = enumOf('Cascade', 'SetNull', 1, 2).parse(expr, ctx); + const result = identifier('Cascade').parse(expr, ctx); expect(result.ok).toBe(true); if (result.ok) expect(result.value).toBe('Cascade'); }); - it('accepts a number member of a mixed set', () => { - const { expr, ctx } = argOf('2'); + it('rejects a bare identifier with a different name', () => { + const { expr, ctx } = argOf('Cascade'); - const result = enumOf('Cascade', 'SetNull', 1, 2).parse(expr, ctx); + const result = identifier('NoAction').parse(expr, ctx); - expect(result.ok).toBe(true); - if (result.ok) expect(result.value).toBe(2); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure).toHaveLength(1); + expect(result.failure[0]?.code).toBe('PSL_INVALID_ATTRIBUTE_SYNTAX'); + } }); - it('rejects a quoted string member', () => { + it('rejects a quoted string with the same characters', () => { const { expr, ctx } = argOf('"Cascade"'); - const result = enumOf('Cascade', 'SetNull').parse(expr, ctx); + const result = identifier('Cascade').parse(expr, ctx); expect(result.ok).toBe(false); - if (!result.ok) expect(result.failure[0]?.code).toBe('PSL_INVALID_ATTRIBUTE_SYNTAX'); + if (!result.ok) expect(result.failure).toHaveLength(1); }); - it('rejects a token of the wrong kind', () => { - const { expr, ctx } = argOf('["Cascade"]'); + it('rejects a number token', () => { + const { expr, ctx } = argOf('1'); - const result = enumOf('Cascade', 1).parse(expr, ctx); + const result = identifier('Cascade').parse(expr, ctx); expect(result.ok).toBe(false); if (!result.ok) expect(result.failure).toHaveLength(1); }); +}); - it('accepts a bare identifier matching a string member', () => { +describe('oneOf', () => { + it('returns the first alternative that succeeds', () => { const { expr, ctx } = argOf('Cascade'); + const first: ArgType<'first'> = { kind: 'const', label: 'first', parse: () => ok('first') }; + const second: ArgType<'second'> = { kind: 'const', label: 'second', parse: () => ok('second') }; - const result = enumOf('Cascade', 'SetNull').parse(expr, ctx); + const result = oneOf(first, second).parse(expr, ctx); expect(result.ok).toBe(true); - if (result.ok) expect(result.value).toBe('Cascade'); + if (result.ok) expect(result.value).toBe('first'); }); - it('rejects a bare identifier matching no member', () => { + it('matches whichever alternative accepts the argument', () => { + const { expr, ctx } = argOf('SetNull'); + + const result = oneOf(identifier('Cascade'), identifier('SetNull')).parse(expr, ctx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toBe('SetNull'); + }); + + it('emits a single aggregate diagnostic anchored to the arg node when every alternative fails', () => { const { expr, ctx } = argOf('WeirdAction'); + const relationCtx: InterpretCtx = { ...ctx, diagnosticCode: 'PSL_INVALID_RELATION_ATTRIBUTE' }; - const result = enumOf('Cascade', 'SetNull').parse(expr, ctx); + const result = oneOf(identifier('Cascade'), identifier('SetNull')).parse(expr, relationCtx); expect(result.ok).toBe(false); - if (!result.ok) expect(result.failure[0]?.code).toBe('PSL_INVALID_ATTRIBUTE_SYNTAX'); + if (!result.ok) { + expect(result.failure).toHaveLength(1); + expect(result.failure[0]?.code).toBe('PSL_INVALID_RELATION_ATTRIBUTE'); + expect(result.failure[0]?.span).toEqual(nodePslSpan(expr.syntax, ctx.sourceFile)); + expect(result.failure[0]?.message).toContain('Cascade'); + expect(result.failure[0]?.message).toContain('SetNull'); + } }); }); 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 ed1b8ee502..b28247b8ba 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 @@ -10,12 +10,13 @@ import type { SymbolTable, } from '@prisma-next/psl-parser'; import { - enumOf, fieldAttribute, fieldRef, + identifier, interpretAttribute, list, nodePslSpan, + oneOf, optional, str, } from '@prisma-next/psl-parser'; @@ -81,8 +82,8 @@ export function fkRelationPairKey(declaringModelName: string, targetModelName: s /** * Maps a validated referential-action token to its contract action. The action - * set is validated upstream by the `@relation` spec's `enumOf`, so this is a - * pure lookup with no second validation path. + * set is validated upstream by the `@relation` spec's `oneOf(identifier(...))`, + * so this is a pure lookup with no second validation path. */ export function normalizeReferentialAction(actionToken: string): ReferentialAction | undefined { return REFERENTIAL_ACTION_MAP[actionToken]; @@ -117,9 +118,9 @@ function relationInvariants( * binds the positional-or-named `name`, the `fields`/`references` field lists, * `map`, and the bare-identifier referential actions; `relationInvariants` * holds the both-or-neither rule. The action set is validated at parse via - * `enumOf`, which accepts the bare identifier and rejects an unknown action - * with the attribute's own code; `normalizeReferentialAction` then maps the - * validated token to its contract action. + * `oneOf` over one `identifier` per action, which accepts the bare identifier + * and rejects an unknown action with the attribute's own code; + * `normalizeReferentialAction` then maps the validated token to its contract action. */ const sqlRelation = fieldAttribute('relation', { positional: [{ key: 'name', type: optional(str()) }], @@ -128,8 +129,24 @@ const sqlRelation = fieldAttribute('relation', { fields: optional(list(fieldRef('self'), { nonEmpty: true, unique: true })), references: optional(list(fieldRef('referenced'), { nonEmpty: true, unique: true })), map: optional(str()), - onDelete: optional(enumOf('NoAction', 'Restrict', 'Cascade', 'SetNull', 'SetDefault')), - onUpdate: optional(enumOf('NoAction', 'Restrict', 'Cascade', 'SetNull', 'SetDefault')), + onDelete: optional( + oneOf( + identifier('NoAction'), + identifier('Restrict'), + identifier('Cascade'), + identifier('SetNull'), + identifier('SetDefault'), + ), + ), + onUpdate: optional( + oneOf( + identifier('NoAction'), + identifier('Restrict'), + identifier('Cascade'), + identifier('SetNull'), + identifier('SetDefault'), + ), + ), }, refine: relationInvariants, diagnosticCode: 'PSL_INVALID_RELATION_ATTRIBUTE', From d6fb2e5ac557007b5bc129aef65522eff95767e5 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 14:07:51 +0000 Subject: [PATCH 17/26] docs(typed-attribute-parsers): record D6 enumOf->oneOf+identifier; flag ADR 231 reconcile at close-out Signed-off-by: Serhii Tatarintsev --- projects/typed-attribute-parsers/plan.md | 2 +- .../dispatches/06-oneof-identifier.md | 49 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/06-oneof-identifier.md diff --git a/projects/typed-attribute-parsers/plan.md b/projects/typed-attribute-parsers/plan.md index b709cd8a69..ec0b873fa4 100644 --- a/projects/typed-attribute-parsers/plan.md +++ b/projects/typed-attribute-parsers/plan.md @@ -32,7 +32,7 @@ Three slices in a substrate-then-consumers shape: slice 1 lands the combinator k - **Builds on:** Slice 1's kit API + `InterpretCtx` wiring recipe + migration recipe. - **Hands to:** Mongo family fully spec-driven; no legacy Mongo attribute-argument parser remains (grep gate). - **Focus:** Mongo family only (`packages/2-mongo-family/2-authoring/contract-psl`). Mongo migrates its own `@relation` spec (distinct value shapes from SQL's). The "at most one `@@textIndex` per collection" rule stays in Mongo's existing model-level aggregation, not in a per-attribute `refine` (spec decision). - - **Carry-in from slice 1 (D5):** `enumOf` was restricted to bare identifiers + number literals (no quoted strings). Mongo's index `type` set (`1`, `-1`, `"text"`, `"2dsphere"`, `"2d"`, `"hashed"`) has **quoted-string** members that can't be bare identifiers (digit-leading) — so slice 3 needs a **dedicated string-literal-set combinator** (e.g. `stringEnumOf`) for that case rather than `enumOf`. + - **Carry-in from slice 1 (D6):** `enumOf` was **removed**; enums are now `oneOf` over per-member matchers (`identifier(name)` for bare identifiers; pinned `str(value)` / `num(value)` for literals). Mongo's index `type` set (`1`, `-1`, `"text"`, `"2dsphere"`, `"2d"`, `"hashed"`) becomes `oneOf(num(1), num(-1), str('text'), str('2dsphere'), str('2d'), str('hashed'))`. Slice 1 built `oneOf` + `identifier`; **slice 3 builds the pinned `str(value)` / `num(value)` forms** (their first consumer is this index-type set — digit-leading members like `"2dsphere"` can't be bare identifiers, so they're quoted-string literals). ## Dependencies (external) diff --git a/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/06-oneof-identifier.md b/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/06-oneof-identifier.md new file mode 100644 index 0000000000..7d4450fd46 --- /dev/null +++ b/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/06-oneof-identifier.md @@ -0,0 +1,49 @@ +# Brief: D6 — replace `enumOf` with `oneOf` + `identifier` + +> Fresh implementer (session resume unavailable). On the open slice-1 PR #891 branch `tml-2956-typed-attribute-parsers`. Operator-directed design change. Do NOT push or touch GitHub — the orchestrator pushes. + +## Context +- Kit: `packages/1-framework/2-authoring/psl-parser/src/attribute-spec/` — engine (`interpret.ts`), combinators (`combinators/`: `str`, `enumOf`, `fieldRef`, `list`, `diagnostic`), types (`types.ts`), exports (`src/exports/index.ts`). Tests under the package's `test/`. +- `enumOf`'s only consumer is `sqlRelation`'s `onDelete`/`onUpdate` (`packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts`), matching bare-identifier actions; the validated name maps to a `ReferentialAction` via `normalizeReferentialAction` (now a pure token→action map). +- Leaves are `Result`-pure (return diagnostics, never push a sink) — this is what lets `oneOf` backtrack. Leaf diagnostics carry `ctx.diagnosticCode` and anchor via `nodePslSpan(node, ctx.sourceFile)`. + +## Decision (operator) +Replace the bespoke `enumOf` with two composable primitives joined by `oneOf` — ADR 231 principle #4 (compose, don't special-case). + +## Tasks + +### T1 — `oneOf(...alts)` combinator +- New combinator: tries each alternative's `parse` in order; **first success wins**; if all fail, emits **one** diagnostic (e.g. `Expected one of: `, aggregating the alternatives' `label`s) with `ctx.diagnosticCode`, anchored to the arg node. Because leaves are `Result`-pure, a failed branch leaves no diagnostics behind — do not leak the alternatives' internal failures; emit only the single aggregate. +- Type: `oneOf[]>(...alts: Alts): ArgType>` (union of the alternatives' output types; `OutOf = A extends ArgType ? X : never`). Confirm the inferred output is the union of members. +- Unit tests: first-match-wins; all-fail → single aggregate diagnostic (code = the threaded `diagnosticCode`, span = arg node); type-level test that the output is the union of the alternatives. + +### T2 — `identifier(name)` combinator +- New combinator: matches a bare `IdentifierAst` whose name **equals** `name`; returns that name. Pinned-only (no open form). Non-identifier OR identifier with a different name → diagnostic (`ctx.diagnosticCode`, span = arg node). +- Type: `identifier(name: N): ArgType` (so `oneOf` over several `identifier`s infers the precise union). +- Unit tests: matches the exact identifier; rejects a different identifier; rejects a non-identifier (e.g. a quoted string / number); the returned value is the pinned literal type. + +### T3 — Rewire referential actions +- In `sqlRelation`, change `onDelete`/`onUpdate` from `optional(enumOf('NoAction', …))` to: + `optional(oneOf(identifier('NoAction'), identifier('Restrict'), identifier('Cascade'), identifier('SetNull'), identifier('SetDefault')))`. +- The inferred output union (`'NoAction' | 'Restrict' | 'Cascade' | 'SetNull' | 'SetDefault'`) must be unchanged from what `enumOf` produced, so the call-site mapping through `normalizeReferentialAction` is untouched. Verify `onDelete: Cascade` parses and maps; a bad action (`WeirdAction`) yields one diagnostic with code `PSL_INVALID_RELATION_ATTRIBUTE` (the existing assertion in `interpreter.relations.test.ts` — message may differ, code must hold). + +### T4 — Delete `enumOf` +- Remove `combinators/enum-of.ts`, its export, and its unit tests (the behaviour is now covered by `oneOf` + `identifier` tests). `rg "enumOf"` → zero. + +## Scope +**In:** `oneOf` + `identifier` combinators + tests; rewire `sqlRelation` actions; delete `enumOf`; exports. **Out:** the pinned `str(value)`/`num(value)` literal matchers (their first consumer is Mongo's index `type` in slice 3 — do NOT build them now, no caller); `str()` stays the open string matcher unchanged; everything else (other attributes, Mongo, `@db.*`). + +## Completed when +- [ ] `oneOf` + `identifier` exported and usable as `Param`s; `enumOf` gone (`rg enumOf` zero). +- [ ] `onDelete`/`onUpdate` use `oneOf(identifier(...))`; output union unchanged; SQL relations + diagnostics suites green (the bad-action case still emits `PSL_INVALID_RELATION_ATTRIBUTE`). +- [ ] Unit + type-level tests for `oneOf` (union inference, first-match, aggregate diagnostic) and `identifier` (pinned match/mismatch, literal type). +- [ ] Gates: `pnpm --filter @prisma-next/psl-parser typecheck && test && lint`; `pnpm --filter @prisma-next/sql-contract-psl test`; `pnpm fixtures:check`; after `pnpm --filter @prisma-next/psl-parser build`, workspace `pnpm typecheck`. + +## Constraints +No `any`; no bare `as` (narrow `blindCast`/`castAs` with reason, or types that avoid it); no file-ext imports; no reexport outside `exports/`; tests-first. Explicit-staging commits, no amend, **no push**. Read-only on `projects/**/reviews/**`, `spec.md`, plan files. Transient-ID scan on the `+` diff. Do NOT post to GitHub. + +## Operational metadata +- **Model tier:** mid (two combinators against a settled contract + a one-line spec rewire). +- **Halt conditions:** `oneOf`'s union type inference can't be expressed without `any`/a broad cast; deleting `enumOf` breaks a consumer you didn't expect (grep first); the spec rewire changes the `onDelete`/`onUpdate` output type (it must stay the same union). + +Return the structured report per § Return shape: per-task results, the `oneOf` diagnostic shape + type-inference approach, confirmation the action union is unchanged, and commit SHA(s). From 5f87537ee9629212746e26624c9f81ee2debdd14 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 15:11:34 +0000 Subject: [PATCH 18/26] docs(adr-231): reconcile with shipped oneOf/identifier design Replace the bespoke enumOf combinator throughout ADR 231 with the shipped composition: enums are oneOf over per-member matchers -- identifier(name) for bare identifiers, pinned str(value)/num(value) for quoted-string/number members. Updates the relation code sample, the at-a-glance and language-server narratives, the scalars kit description, and the ADR 224 reference line; the rejected-alternative entry now records the dedicated enum leaf as the rejected option in favour of oneOf composition (principle #4). InferAttr union types are unchanged. Signed-off-by: Serhii Tatarintsev --- ...1 - Declarative attribute specifications.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 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 9eb9140dae..1149f32878 100644 --- a/docs/architecture docs/adrs/ADR 231 - Declarative attribute specifications.md +++ b/docs/architecture docs/adrs/ADR 231 - Declarative attribute specifications.md @@ -30,8 +30,8 @@ const sqlRelation = fieldAttribute('relation', { fields: optional(list(fieldRef('self'), { nonEmpty: true })), references: optional(list(fieldRef('referenced'), { nonEmpty: true })), map: optional(str()), - onDelete: optional(enumOf('NoAction', 'Restrict', 'Cascade', 'SetNull', 'SetDefault')), - onUpdate: optional(enumOf('NoAction', 'Restrict', 'Cascade', 'SetNull', 'SetDefault')), + onDelete: optional(oneOf(identifier('NoAction'), identifier('Restrict'), identifier('Cascade'), identifier('SetNull'), identifier('SetDefault'))), + onUpdate: optional(oneOf(identifier('NoAction'), identifier('Restrict'), identifier('Cascade'), identifier('SetNull'), identifier('SetDefault'))), }, refine: relationInvariants, }); @@ -51,9 +51,9 @@ At runtime, `interpretAttribute(node, sqlRelation, ctx)` turns the parsed AST no } ``` -Notice three things that the rest of this document builds up. The argument value types (`str()`, `enumOf(...)`, `list(fieldRef('self'))`) are **combinators** drawn from a fixed framework kit. A combinator like `fieldRef` carries a **scope** that says which entity a field name resolves against. And cross-argument rules that no single argument can express — `fields` and `references` must appear together — live in a `refine` step. +Notice three things that the rest of this document builds up. The argument value types (`str()`, `oneOf(identifier(...))`, `list(fieldRef('self'))`) are **combinators** drawn from a fixed framework kit. A combinator like `fieldRef` carries a **scope** that says which entity a field name resolves against. And cross-argument rules that no single argument can express — `fields` and `references` must appear together — live in a `refine` step. -That same spec is what the language server reads. Because `onDelete` is declared as `enumOf('NoAction', ...)`, the editor can complete its values; because `fields` is declared as `list(fieldRef('self'))`, the editor knows each entry names a field of the model and can resolve it to a definition or find its other uses — none of which the interpreter's hand-written validation could ever expose. +That same spec is what the language server reads. Because `onDelete` is declared as `oneOf(identifier('NoAction'), ...)`, the editor enumerates the alternatives' pinned values; because `fields` is declared as `list(fieldRef('self'))`, the editor knows each entry names a field of the model and can resolve it to a definition or find its other uses — none of which the interpreter's hand-written validation could ever expose. The design covers the full spectrum of the current attribute syntax with one exception, `@db.*` native types, which are not attributes on fields or models at all (see [Out of scope](#out-of-scope-db-native-types)). @@ -94,9 +94,9 @@ interface ArgType { The kit divides into four groups. -A combinator owns the work arktype cannot: parsing a PSL AST argument from source, resolving names against the symbol table and registries, anchoring diagnostics to source spans, and carrying the domain metadata (its `kind`, a reference's scope) the language server switches on. Where a leaf reduces to a context-free check on an already-parsed value — a literal set, a numeric range, the shape of a JSON object — it delegates that check, and its type inference, to an arktype `Type` it wraps. So `enumOf` is backed by an arktype literal union, `int({ min, max })` by arktype's numeric constraints, and `json()` by an arktype object schema, while the combinator around them supplies the parse, the context, the spans, and the metadata. This mirrors the existing authoring pattern, where a contributed entity's `validatorSchema` is an arktype `Type` that validates structured input before a factory runs. +A combinator owns the work arktype cannot: parsing a PSL AST argument from source, resolving names against the symbol table and registries, anchoring diagnostics to source spans, and carrying the domain metadata (its `kind`, a reference's scope) the language server switches on. Where a leaf reduces to a context-free check on an already-parsed value — a literal, a numeric range, the shape of a JSON object — it delegates that check, and its type inference, to an arktype `Type` it wraps. So a pinned `identifier(name)` / `str(value)` / `num(value)` is backed by an arktype literal, `int({ min, max })` by arktype's numeric constraints, and `json()` by an arktype object schema, while the combinator around them supplies the parse, the context, the spans, and the metadata. This mirrors the existing authoring pattern, where a contributed entity's `validatorSchema` is an arktype `Type` that validates structured input before a factory runs. -**Scalars** read a single token. `str()` parses a quoted string. `int({ min, max })` parses a number. `bool()` parses a boolean. `enumOf(...values)` parses a member of a fixed literal set — and because a member may be a string or a number, the set may be homogeneous *or* mixed. Mongo's index `type`, which accepts the numbers `1`/`-1` and the strings `"text"`/`"2dsphere"`/`"2d"`/`"hashed"`, is a single `enumOf(1, -1, 'text', '2dsphere', '2d', 'hashed')`. `json()` reads an opaque JSON value from a quoted string; it is the one place a structured value is text-encoded (see [Surface policy](#surface-policy-native-literals-with-one-text-exception)). +**Scalars** read a single token. `str()` parses any quoted string, and `str(value)` pins a specific one. `int({ min, max })` parses a number. `bool()` parses a boolean. `identifier(name)` matches a specific bare identifier, typed `ArgType`, and `num(value)` matches a specific number literal. There is no dedicated enum leaf: a fixed literal set is `oneOf` over these pinned matchers, and because each member is its own matcher the set may be homogeneous *or* mixed — with the quoted-vs-bare surface explicit per member rather than guessed from a value's JS type. Mongo's index `type`, which accepts the numbers `1`/`-1` and the strings `"text"`/`"2dsphere"`/`"2d"`/`"hashed"`, is `oneOf(num(1), num(-1), str('text'), str('2dsphere'), str('2d'), str('hashed'))`. `json()` reads an opaque JSON value from a quoted string; it is the one place a structured value is text-encoded (see [Surface policy](#surface-policy-native-literals-with-one-text-exception)). **References** resolve a name to an **entity coordinate** — the contract's uniform `(namespace, kind, name)` address for *any* entity, whether a model, an enum, or a pack-contributed entity kind (ADR 221, ADR 224). A reference is not special-cased to models. The coordinate carries one optional extension, a **`field`** element, for a reference that names a field *within* an entity. So `entityRef({ scope })` resolves an entity by name, and its field-bearing form `fieldRef({ scope })` resolves a field within the scoped entity and fills in the coordinate's `field` element; `scope` is `'self'` (the entity declaring the attribute), `'referenced'` (a relation's target entity), or `'document'` (a free path, for wildcard projections). `codecRef()` resolves a registered codec id — a registry reference, not a contract-entity coordinate. @@ -201,7 +201,7 @@ The primary reason a spec is *declarative* rather than a parsing function is tha Today attribute arguments are opaque to the language server. It can offer little more than the attribute name, because everything past the opening parenthesis is validated by interpreter code it cannot introspect. The same spec the interpreters run answers the questions an editor needs to ask: -- **Autocompletion.** The `named` map lists the legal argument names for an attribute, so the editor completes `fie` to `fields:` inside `@relation(...)`. A value typed `enumOf('NoAction', 'Restrict', 'Cascade', 'SetNull', 'SetDefault')` enumerates its own completions, so the editor offers exactly those after `onDelete:`. The combinator's `label` supplies the hover text. +- **Autocompletion.** The `named` map lists the legal argument names for an attribute, so the editor completes `fie` to `fields:` inside `@relation(...)`. A value typed `oneOf(identifier('NoAction'), identifier('Restrict'), identifier('Cascade'), identifier('SetNull'), identifier('SetDefault'))` enumerates its alternatives' pinned values, so the editor offers exactly those after `onDelete:`. The combinator's `label` supplies the hover text. - **Go-to-definition and find-usages.** A combinator declares not just that an argument is a name, but *what kind of name and where it resolves*. `fields: list(fieldRef('self'))` says each entry names a field of the enclosing model; `references: list(fieldRef('referenced'))` says each names a field of the relation's target model; `@@base`'s `entityRef()` names another entity. From that, the language server resolves the symbol under the cursor to its declaration, and finds every other attribute argument that references the same field or entity — neither of which the interpreter's hand-written validation could ever expose, because it discards that structure as soon as it has checked it. - **Diagnostics parity.** The editor reports the *same* errors the interpreter would, from the same spec, rather than a thinner approximation maintained separately. @@ -243,7 +243,7 @@ The cost is a new layer of indirection. Reading what an attribute accepts means **Dispatch `oneOf` with a recognition predicate.** Give each combinator a `recognizes(arg)` method so `oneOf` commits to one branch by AST shape before parsing, yielding more targeted errors. Rejected for now: it doubles the leaf contract (a recognizer that must stay in sync with the parser) for a benefit — sharper errors on malformed input — that a small closed grammar does not need. Ordered try-each over diagnostic-pure branches is simpler, and a recognizer can be added later without changing the leaf contract if error quality demands it. -**Separate `enumOf` and `numEnum`.** A string-literal enum and a numeric-literal enum as distinct combinators. Rejected: the only real difference is which token surface each member reads, which a single `enumOf` decides per member from the member's own type. Collapsing them also handles mixed string/number sets — Mongo's index `type` — that two separate combinators cannot express cleanly. +**A dedicated enum leaf.** A single combinator for a fixed literal set, deciding each member's token surface from its JS type. Rejected in favour of `oneOf` over `identifier` / pinned `str` / `num`: composition (principle #4) expresses homogeneous and mixed sets uniformly, makes the quoted-vs-bare surface explicit per member rather than inferred from a value's type, and reuses the `oneOf` sum the design already needs for `@default` and index elements. Mixed string/number sets — Mongo's index `type` — remain expressible, now as `oneOf(num(1), num(-1), str('text'), …)`. **`json(codecId)` validated by a codec.** Let the JSON leaf decode through a named codec. Rejected: no attribute in scope needs codec-validated JSON — `filter` is opaque pass-through, and `@@type` takes a codec *id* (a `codecRef`), not a codec-validated value. Codec-bound decoding is a separate concern that belongs to generic-block parameters and enum member values, and if those are folded in later they get their own primitive rather than overloading `json()`. @@ -268,7 +268,7 @@ The cost is a new layer of indirection. Reading what an attribute accepts means ## References - [ADR 225 — Three-layer extensibility for pack-contributed entity kinds](ADR%20225%20-%20Three-layer%20extensibility%20for%20pack-contributed%20entity%20kinds.md) — the contribution model this design follows: a framework-defined extension point that families and targets register into, dispatched structurally so the framework learns no per-kind names. Attribute specs are registered the same way entity kinds are. -- [ADR 224 — Control policy: a framework-locked vocabulary with family-owned dispatch](ADR%20224%20-%20Control%20Policy%20—%20framework-locked%20vocabulary%20and%20family-owned%20dispatch.md) — the `@@control()` attribute whose value set this design types as `enumOf('managed', 'tolerated', 'external', 'observed')`, and the framework-vocabulary / family-dispatch split this design mirrors, here as a PSL-layer kit with family-owned specs. +- [ADR 224 — Control policy: a framework-locked vocabulary with family-owned dispatch](ADR%20224%20-%20Control%20Policy%20—%20framework-locked%20vocabulary%20and%20family-owned%20dispatch.md) — the `@@control()` attribute whose value set this design types as `oneOf(identifier('managed'), identifier('tolerated'), identifier('external'), identifier('observed'))`, and the framework-vocabulary / family-dispatch split this design mirrors, here as a PSL-layer kit with family-owned specs. - [ADR 221 — Contract IR: two planes with a uniform entity coordinate](ADR%20221%20-%20Contract%20IR%20two%20planes%20with%20uniform%20entity%20coordinate%20and%20pack-contributed%20entity%20kinds.md) — the coordinate model attribute resolution writes into. - [ADR 126 — PSL top-level block SPI](ADR%20126%20-%20PSL%20top-level%20block%20SPI.md) — the descriptor SPI for generic blocks, whose `key = value` parameters are the subject of an open question above. - [Pattern: Frozen-class AST + visitor](../patterns/frozen-class-ast.md) — the dispatch pattern for the `ArgType` combinator union across parse, print, and completion sites. From be6135165515c5524516490a49289fa85d3a8f3b Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 15:12:16 +0000 Subject: [PATCH 19/26] docs(typed-attribute-parsers): record D7 ADR 231 reconciliation dispatch Signed-off-by: Serhii Tatarintsev --- .../dispatches/07-adr-reconcile.md | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/07-adr-reconcile.md diff --git a/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/07-adr-reconcile.md b/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/07-adr-reconcile.md new file mode 100644 index 0000000000..74d0f4a309 --- /dev/null +++ b/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/07-adr-reconcile.md @@ -0,0 +1,43 @@ +# Brief: D7 — reconcile ADR 231 with the shipped `oneOf`/`identifier` design + +> Fresh implementer. Documentation-only dispatch on the slice-1 PR branch. Editing the architecture doc `docs/architecture docs/ADR 231 - Declarative attribute specifications.md` is operator-authorised (overrides the `AGENTS.md` "ask first" default for this edit). Do NOT push or touch GitHub. + +## Why +The shipped kit replaced the bespoke `enumOf` combinator with **composition**: enums are now `oneOf` over per-member matchers — `identifier(name)` for bare-identifier members, and pinned `str(value)` / `num(value)` for quoted-string / number-literal members. ADR 231 still documents `enumOf` throughout. Update the ADR so it matches what shipped (ADR principle #4, "compose, don't special-case", taken to its conclusion: there is no enum leaf). + +## The shipped design (what the ADR should now say) +- **No `enumOf` combinator.** An enum is expressed by `oneOf` over matchers: + - `identifier('Cascade')` — matches the bare identifier `Cascade` (typed `ArgType<'Cascade'>`). + - `str('text')` — matches the quoted string `"text"`; `str()` (no arg) remains the open "any string literal" matcher. + - `num(1)` / `num(-1)` — matches a specific number literal. + - (No `bool` matcher — not needed.) +- A bare-identifier enum (referential actions): `oneOf(identifier('NoAction'), identifier('Restrict'), identifier('Cascade'), identifier('SetNull'), identifier('SetDefault'))`. +- A mixed quoted-string/number set (Mongo index `type`): `oneOf(num(1), num(-1), str('text'), str('2dsphere'), str('2d'), str('hashed'))`. The quoted-vs-bare surface is now **explicit per member** (`str('text')` vs `identifier('Cascade')`), which is strictly more precise than `enumOf` guessing from the member's JS type. +- `oneOf` (already in the ADR) is the sum: ordered try-each over `Result`-pure leaves, first success wins, one aggregate `expected one of …` diagnostic on total failure. Its output type is the union of the alternatives' output types — so an editor can still enumerate the legal completions (each alternative is a pinned matcher with a known value). + +## Edits to make (find every `enumOf` mention; these are the known sites — search for `enumOf` to be exhaustive) +1. **§ At a glance** — the `sqlRelation` code sample: `onDelete`/`onUpdate` change from `optional(enumOf('NoAction', …))` to `optional(oneOf(identifier('NoAction'), identifier('Restrict'), identifier('Cascade'), identifier('SetNull'), identifier('SetDefault')))`. Update the `InferAttr` comment block if it references the enum shape (the union type is unchanged). +2. **§ At a glance** narrative ("Notice three things…") — where it lists `enumOf(...)` as an example combinator, replace with the `oneOf(identifier(...))` composition; keep the point that the value types are combinators. +3. **§ At a glance** — "because `onDelete` is declared as `enumOf(...)`, the editor can complete its values" → reframe: declared as `oneOf(identifier('NoAction'), …)`, the editor enumerates the alternatives' pinned values. +4. **§ The combinator kit → Scalars** — the sentence introducing `enumOf(...values)` and the Mongo `enumOf(1, -1, 'text', …)` example. Replace with the `str(value?)` / `num(value?)` / `identifier(name)` matchers and the `oneOf(...)`-composes-enums explanation; Mongo index `type` becomes the `oneOf(num(1), num(-1), str('text'), …)` form. Note `str()` open vs `str(value)` pinned. +5. **§ One spec, two consumers (language-server)** — the `enumOf('NoAction', …)` completion example → `oneOf(identifier('NoAction'), …)`; the editor still derives completions from the alternatives' pinned values. +6. **§ Alternatives considered → "Separate `enumOf` and `numEnum`"** — this rejected-alternative is now obsolete (there is no enum leaf at all). Replace it with a rejected-alternative entry that records the actual decision: **"A dedicated `enumOf` leaf"** — rejected in favour of `oneOf` over `identifier` / pinned `str` / `num`, because composition (principle #4) expresses homogeneous and mixed sets uniformly, makes the quoted-vs-bare surface explicit per member, and reuses the `oneOf` sum the design already needs for `@default` and index elements. (Preserve the insight that mixed string/number sets must be expressible — now via `oneOf(num(...), str(...))`.) +7. **§ References (ADR 224 line)** — `@@control(...)` "value set this design types as `enumOf('managed', 'tolerated', 'external', 'observed')`" → `oneOf(identifier('managed'), identifier('tolerated'), identifier('external'), identifier('observed'))`. +8. Any other `enumOf` occurrence the search turns up — reconcile consistently. + +## Scope +**In:** `docs/architecture docs/ADR 231 - Declarative attribute specifications.md` only — prose + code samples reconciled to the `oneOf`/`identifier`/`str`/`num` design. **Out:** code, tests, other docs, ADR status line (leave `Status: Proposed` as-is unless it already says otherwise — implementation tracking is a close-out concern). Do not invent design beyond what's described here; if you find an `enumOf` use case this model can't express, **halt and surface** rather than guessing. + +## Completed when +- [ ] `rg "enumOf" "docs/architecture docs/ADR 231 - Declarative attribute specifications.md"` → zero. +- [ ] Every code sample + narrative uses `oneOf` / `identifier` / `str` / `num` consistently; the doc reads coherently (no dangling references to a removed leaf). +- [ ] No other file changed. + +## Constraints +- Markdown only; no code/test changes. Follow `markdown-no-artificial-line-wraps` (don't hard-wrap prose). Explicit-staging commit (`git add` the ADR path only), no amend, **no push**. Do NOT touch GitHub. Transient-ID scan is N/A (no source), but don't introduce `projects/…` paths into the ADR. + +## Operational metadata +- **Model tier:** mid (bounded doc reconciliation, but requires faithful design understanding). +- **Halt conditions:** an `enumOf` use case that `oneOf` + the matchers can't express; any temptation to change code/tests to match the doc (the doc follows the code, not vice-versa). + +Return: the list of sites changed, the `rg enumOf` result, and the commit SHA. From 3ac48c82d1bc9064fbf8f71d73a044cdda0468d3 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 15:21:28 +0000 Subject: [PATCH 20/26] refactor(utils): centralize Simplify/UnionToIntersection type helpers Add a shared @prisma-next/utils/types module and consume it from the attribute-spec types instead of redefining the helpers locally. Signed-off-by: Serhii Tatarintsev --- .../1-framework/0-foundation/utils/package.json | 1 + .../0-foundation/utils/src/exports/types.ts | 1 + .../1-framework/0-foundation/utils/src/types.ts | 9 +++++++++ .../0-foundation/utils/test/types.test-d.ts | 17 +++++++++++++++++ .../0-foundation/utils/tsdown.config.ts | 1 + .../psl-parser/src/attribute-spec/types.ts | 10 +--------- 6 files changed, 30 insertions(+), 9 deletions(-) create mode 100644 packages/1-framework/0-foundation/utils/src/exports/types.ts create mode 100644 packages/1-framework/0-foundation/utils/src/types.ts create mode 100644 packages/1-framework/0-foundation/utils/test/types.test-d.ts diff --git a/packages/1-framework/0-foundation/utils/package.json b/packages/1-framework/0-foundation/utils/package.json index 7b94a8ff3c..41284cda81 100644 --- a/packages/1-framework/0-foundation/utils/package.json +++ b/packages/1-framework/0-foundation/utils/package.json @@ -45,6 +45,7 @@ "./redact-db-url": "./dist/redact-db-url.mjs", "./result": "./dist/result.mjs", "./simplify-deep": "./dist/simplify-deep.mjs", + "./types": "./dist/types.mjs", "./package.json": "./package.json" }, "engines": { diff --git a/packages/1-framework/0-foundation/utils/src/exports/types.ts b/packages/1-framework/0-foundation/utils/src/exports/types.ts new file mode 100644 index 0000000000..ed1538047c --- /dev/null +++ b/packages/1-framework/0-foundation/utils/src/exports/types.ts @@ -0,0 +1 @@ +export type { Simplify, UnionToIntersection } from '../types'; diff --git a/packages/1-framework/0-foundation/utils/src/types.ts b/packages/1-framework/0-foundation/utils/src/types.ts new file mode 100644 index 0000000000..7d179368a2 --- /dev/null +++ b/packages/1-framework/0-foundation/utils/src/types.ts @@ -0,0 +1,9 @@ +/** Flattens an intersection of mapped types into a single readable object type. */ +export type Simplify = { [K in keyof T]: T[K] } & {}; + +/** Collapses a union into the intersection of its members. */ +export type UnionToIntersection = (U extends unknown ? (k: U) => void : never) extends ( + k: infer I, +) => void + ? I + : never; diff --git a/packages/1-framework/0-foundation/utils/test/types.test-d.ts b/packages/1-framework/0-foundation/utils/test/types.test-d.ts new file mode 100644 index 0000000000..ac0845a2ba --- /dev/null +++ b/packages/1-framework/0-foundation/utils/test/types.test-d.ts @@ -0,0 +1,17 @@ +import { expectTypeOf, test } from 'vitest'; +import type { Simplify, UnionToIntersection } from '../src/types'; + +test('Simplify flattens an intersection into a single object type', () => { + type Input = { a: number } & { b: string }; + expectTypeOf>().toEqualTypeOf<{ a: number; b: string }>(); +}); + +test('Simplify preserves optional modifiers', () => { + type Input = { a: number } & { b?: string }; + expectTypeOf>().toEqualTypeOf<{ a: number; b?: string }>(); +}); + +test('UnionToIntersection collapses a union of objects into their intersection', () => { + type Input = { a: number } | { b: string }; + expectTypeOf>().toEqualTypeOf<{ a: number } & { b: string }>(); +}); diff --git a/packages/1-framework/0-foundation/utils/tsdown.config.ts b/packages/1-framework/0-foundation/utils/tsdown.config.ts index 3cea866a51..f5053808d1 100644 --- a/packages/1-framework/0-foundation/utils/tsdown.config.ts +++ b/packages/1-framework/0-foundation/utils/tsdown.config.ts @@ -14,5 +14,6 @@ export default defineConfig({ 'src/exports/result.ts', 'src/exports/redact-db-url.ts', 'src/exports/simplify-deep.ts', + 'src/exports/types.ts', ], }); 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 6ebae95e78..4404762019 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 @@ -1,18 +1,10 @@ import type { PslDiagnostic, PslDiagnosticCode } from '@prisma-next/framework-components/psl-ast'; import type { Result } from '@prisma-next/utils/result'; +import type { Simplify, UnionToIntersection } from '@prisma-next/utils/types'; import type { SourceFile } from '../source-file'; import type { FieldSymbol, ModelSymbol, SymbolTable } from '../symbol-table'; import type { ExpressionAst } from '../syntax/ast/expressions'; -/** Flattens an intersection of mapped types into a single readable object type. */ -type Simplify = { [K in keyof T]: T[K] } & {}; - -type UnionToIntersection = (U extends unknown ? (k: U) => void : never) extends ( - k: infer I, -) => void - ? I - : never; - export type AttributeLevel = 'field' | 'model' | 'block'; /** From e8ee5a98d233931375644708a1685d10964e9d73 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 15:21:58 +0000 Subject: [PATCH 21/26] refactor(psl-parser): require oneOf to have at least one alternative Type the rest parameter as a non-empty tuple so oneOf() with no alternatives is a compile error. Signed-off-by: Serhii Tatarintsev --- .../psl-parser/src/attribute-spec/combinators/one-of.ts | 2 +- .../psl-parser/test/attribute-spec-combinators.test-d.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/one-of.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/one-of.ts index a129ec8353..1521bf1049 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/one-of.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/one-of.ts @@ -13,7 +13,7 @@ import { leafDiagnostic } from './diagnostic'; * (listing the alternatives' labels) with the threaded code, anchored to the * argument node, rather than leaking the branches' internal failures. */ -export function oneOf[]>( +export function oneOf, ...ArgType[]]>( ...alts: Alts ): ArgType> { const label = alts.map((alt) => alt.label).join(' | '); 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 40aafe9f7f..c875dfb2fa 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 @@ -12,6 +12,11 @@ test('oneOf infers the union of its alternatives output types', () => { >(); }); +test('oneOf with no alternatives is a compile error', () => { + // @ts-expect-error oneOf requires at least one alternative + oneOf(); +}); + test('list infers an array of its element type', () => { expectTypeOf(list(str())).toEqualTypeOf>(); }); From ad546562df5009c3e018c52d13000c41a2597e3a Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 15:22:40 +0000 Subject: [PATCH 22/26] refactor(psl-parser): remove variadic positional support No attribute uses a variadic positional slot. Drop PositionalParam.variadic, the variadic branch in the output-type mapping, and the engine handling. YAGNI. Signed-off-by: Serhii Tatarintsev --- .../psl-parser/src/attribute-spec/interpret.ts | 12 ------------ .../psl-parser/src/attribute-spec/types.ts | 7 ++----- .../psl-parser/test/attribute-spec.test-d.ts | 7 ------- .../psl-parser/test/attribute-spec.test.ts | 12 ------------ 4 files changed, 2 insertions(+), 36 deletions(-) diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/interpret.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/interpret.ts index ca99fdfa37..4fef1c1636 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/interpret.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/interpret.ts @@ -72,18 +72,6 @@ export function interpretAttribute( const positionalParsed = new Map(); let index = 0; for (const param of spec.positional) { - if (param.variadic) { - const collected: unknown[] = []; - for (; index < positionalArgs.length; index++) { - const arg = positionalArgs[index]; - if (arg === undefined) continue; - const result = parseArgValue(arg, argTypeOf(param.type), leafCtx, diagnostics, code); - if (result.ok) collected.push(result.value); - } - positionalSeen.add(param.key); - positionalParsed.set(param.key, collected); - continue; - } const arg = positionalArgs[index]; if (arg === undefined) continue; index += 1; 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 4404762019..ffcb769b83 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 @@ -73,8 +73,6 @@ export interface PositionalParam { /** The output key this slot writes into. */ readonly key: string; readonly type: Param; - /** A trailing rest slot that consumes every remaining positional argument. */ - readonly variadic?: boolean; } export interface AttributeSpec { @@ -100,9 +98,8 @@ export type NamedOut>> = Simplify< } >; -type PosEntryObject = E extends { variadic: true } - ? { [K in E['key']]: readonly OutOf[] } - : E['type'] extends OptionalParam +type PosEntryObject = + E['type'] extends OptionalParam ? { [K in E['key']]?: OutOf } : { [K in E['key']]: OutOf }; diff --git a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec.test-d.ts b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec.test-d.ts index ba8f730be5..454f639160 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec.test-d.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec.test-d.ts @@ -54,13 +54,6 @@ test('a positional-or-named alias collapses to one property', () => { expectTypeOf>().toEqualTypeOf<{ name?: string; map?: string }>(); }); -test('a variadic positional slot contributes an array property', () => { - const spec = fieldAttribute('demo', { - positional: [{ key: 'tags', type: str(), variadic: true }], - }); - expectTypeOf>().toEqualTypeOf<{ tags: readonly string[] }>(); -}); - test('a spec carrying a refine still infers its output', () => { const spec = fieldAttribute('demo', { named: { name: optional(str()) }, 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 dee7a3b239..b4e3a19a45 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 @@ -90,18 +90,6 @@ describe('interpretAttribute positional binding', () => { if (result.ok) expect(result.value).toEqual({ name: 'Posts' }); }); - it('collects a variadic positional slot into an array', () => { - const { node, ctx } = fieldAttr('@rel("a", "b")'); - const spec = fieldAttribute('rel', { - positional: [{ key: 'tags', type: str(), variadic: true }], - }); - - const result = interpretAttribute(node, spec, ctx); - - expect(result.ok).toBe(true); - if (result.ok) expect(result.value).toEqual({ tags: ['a', 'b'] }); - }); - it('rejects more positional arguments than declared slots', () => { const { node, ctx } = fieldAttr('@rel("a", "b")'); const spec = fieldAttribute('rel', { From 8fecd956f62ab95a641ad7084bec00d44b856336 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 15:25:01 +0000 Subject: [PATCH 23/26] refactor(psl-parser): model optional params as a flavoured ArgType Replace OptionalParam with OptionalArgType extending ArgType, collapsing the Param union to ArgType. optional() now spreads the wrapped type and adds the optionality marker; the engine detects optionality from the marker and parses the param directly. Signed-off-by: Serhii Tatarintsev --- .../src/attribute-spec/interpret.ts | 14 ++++------- .../psl-parser/src/attribute-spec/optional.ts | 15 ++++++------ .../psl-parser/src/attribute-spec/types.ts | 23 +++++++++++-------- .../psl-parser/src/exports/index.ts | 2 +- 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/interpret.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/interpret.ts index 4fef1c1636..a42054a734 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/interpret.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/interpret.ts @@ -8,7 +8,7 @@ import { notOk, ok, type Result } from '@prisma-next/utils/result'; import { nodePslSpan } from '../resolve'; import type { FieldAttributeAst, ModelAttributeAst } from '../syntax/ast/attributes'; import type { AttributeArgAst } from '../syntax/ast/expressions'; -import type { ArgType, AttributeSpec, InterpretCtx, OptionalParam, Param } from './types'; +import type { ArgType, AttributeSpec, InterpretCtx, OptionalArgType, Param } from './types'; const DEFAULT_STRUCTURAL_CODE: PslDiagnosticCode = 'PSL_INVALID_ATTRIBUTE_SYNTAX'; @@ -64,7 +64,7 @@ export function interpretAttribute( continue; } namedSeen.add(key); - const result = parseArgValue(arg, argTypeOf(param), leafCtx, diagnostics, code); + const result = parseArgValue(arg, param, leafCtx, diagnostics, code); if (result.ok) namedParsed.set(key, result.value); } @@ -76,7 +76,7 @@ export function interpretAttribute( if (arg === undefined) continue; index += 1; positionalSeen.add(param.key); - const result = parseArgValue(arg, argTypeOf(param.type), leafCtx, diagnostics, code); + const result = parseArgValue(arg, param.type, leafCtx, diagnostics, code); if (result.ok) positionalParsed.set(param.key, result.value); } if (index < positionalArgs.length) { @@ -124,7 +124,7 @@ export function interpretAttribute( const effective = namedParam ?? positionalParam; if (effective === undefined) return; - if (isOptionalParam(effective)) { + if (isOptionalArgType(effective)) { if (effective.hasDefault) output[key] = effective.defaultValue; return; } @@ -188,14 +188,10 @@ function parseArgValue( return result; } -function isOptionalParam(param: Param): param is OptionalParam { +function isOptionalArgType(param: Param): param is OptionalArgType { return 'optional' in param && param.optional === true; } -function argTypeOf(param: Param): ArgType { - return isOptionalParam(param) ? param.type : param; -} - function diagnostic( code: PslDiagnosticCode, message: string, diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/optional.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/optional.ts index 76d91e3302..ef4aa9a37c 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/optional.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/optional.ts @@ -1,13 +1,14 @@ -import type { ArgType, OptionalParam } from './types'; +import type { ArgType, OptionalArgType } from './types'; /** - * Marks a parameter optional. With a second argument, the value is applied as a - * default when the argument is absent; without one, an absent argument leaves - * the output property unset. + * Marks a parameter optional, returning a flavoured `ArgType` that parses like + * the wrapped type. With a second argument, the value is applied as a default + * when the argument is absent; without one, an absent argument leaves the output + * property unset. */ -export function optional(type: ArgType, ...rest: [defaultValue: T] | []): OptionalParam { +export function optional(type: ArgType, ...rest: [defaultValue: T] | []): OptionalArgType { if (rest.length === 0) { - return { optional: true, type, hasDefault: false }; + return { ...type, optional: true, hasDefault: false }; } - return { optional: true, type, hasDefault: true, defaultValue: rest[0] }; + return { ...type, optional: true, hasDefault: true, defaultValue: rest[0] }; } 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 ffcb769b83..0835b16c21 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 @@ -58,16 +58,20 @@ export interface InterpretCtx { readonly field?: FieldSymbol; } -/** An optional parameter, optionally carrying a default applied when the argument is absent. */ -export interface OptionalParam { +/** + * An optional parameter: a flavoured `ArgType` carrying the optionality marker + * and, when `hasDefault`, the default value applied when the argument is absent. + * Because it extends `ArgType`, the engine parses it directly and detects + * optionality from the `optional` marker. + */ +export interface OptionalArgType extends ArgType { readonly optional: true; - readonly type: ArgType; readonly hasDefault: boolean; readonly defaultValue?: T; } -/** A parameter is a bare `ArgType` (required) or an `optional(...)` wrapper. */ -export type Param = ArgType | OptionalParam; +/** A parameter slot's arg type; an `optional(...)` param is a flavoured `ArgType`. */ +export type Param = ArgType; export interface PositionalParam { /** The output key this slot writes into. */ @@ -89,17 +93,16 @@ export interface AttributeSpec { readonly diagnosticCode?: PslDiagnosticCode; } -export type OutOf

= - P extends OptionalParam ? T : P extends ArgType ? T : never; +export type OutOf

= P extends ArgType ? T : never; export type NamedOut>> = Simplify< - { [K in keyof N as N[K] extends OptionalParam ? never : K]: OutOf } & { - [K in keyof N as N[K] extends OptionalParam ? K : never]?: OutOf; + { [K in keyof N as N[K] extends OptionalArgType ? never : K]: OutOf } & { + [K in keyof N as N[K] extends OptionalArgType ? K : never]?: OutOf; } >; type PosEntryObject = - E['type'] extends OptionalParam + E['type'] extends OptionalArgType ? { [K in E['key']]?: OutOf } : { [K in E['key']]: OutOf }; 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 5e9bee0f51..f48f3ec85d 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 @@ -54,7 +54,7 @@ export type { InferAttr, InterpretCtx, NamedOut, - OptionalParam, + OptionalArgType, OutOf, Param, PositionalParam, From c120b85b4a36c2739c6aaf34b04796b46657246f Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 15:29:21 +0000 Subject: [PATCH 24/26] refactor(psl-parser): interpret attributes in a single pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the positional/named/resolve three-pass engine with one pass over the source arguments that builds the output map and reports duplicates, unknowns, and excess positionals inline, then applies optional defaults and missing-required diagnostics. Preserves diagnostic codes and keeps each error path’s span no coarser than before. Signed-off-by: Serhii Tatarintsev --- .../src/attribute-spec/interpret.ts | 177 ++++++++++-------- 1 file changed, 100 insertions(+), 77 deletions(-) diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/interpret.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/interpret.ts index a42054a734..ba16ced6dc 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/interpret.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/interpret.ts @@ -12,11 +12,19 @@ import type { ArgType, AttributeSpec, InterpretCtx, OptionalArgType, Param } fro const DEFAULT_STRUCTURAL_CODE: PslDiagnosticCode = 'PSL_INVALID_ATTRIBUTE_SYNTAX'; +/** How an output key was bound, so a later collision is reported as the right kind. */ +type Origin = 'positional' | 'named'; + /** - * Interprets an attribute node against its spec: binds positional arguments in - * order and named arguments by key into one flat output keyspace, honours the - * positional-or-named alias, rejects unknown named arguments, applies optional - * defaults, then runs `refine`. Returns the spec-inferred output or diagnostics. + * Interprets an attribute node against its spec in a single pass over the source + * arguments. A positional argument binds to the next unconsumed positional + * slot's key; a named argument binds to its key. Both write into one flat output + * map guarded by one `seen` map: a key bound twice — whether positional-then-named + * (the alias) or a repeated named key — is reported inline and skipped. Unknown + * named keys and excess positional arguments are reported as they are + * encountered. After the pass, optional defaults fill absent keys, missing + * required keys are reported, then `refine` runs. Returns the spec-inferred + * output or the accumulated diagnostics. */ export function interpretAttribute( attrNode: FieldAttributeAst | ModelAttributeAst, @@ -28,100 +36,83 @@ export function interpretAttribute( const leafCtx: InterpretCtx = { ...ctx, diagnosticCode: code }; const attributeSpan = nodePslSpan(attrNode.syntax, ctx.sourceFile); - const positionalArgs: AttributeArgAst[] = []; - const namedArgs: AttributeArgAst[] = []; + const output: Record = {}; + const seen = new Map(); + let positionalSlot = 0; + let reportedExcess = false; + for (const arg of attrNode.argList()?.args() ?? []) { - if (arg.name() === undefined) positionalArgs.push(arg); - else namedArgs.push(arg); - } + const name = arg.name()?.name(); + + if (name === undefined) { + const param = spec.positional[positionalSlot]; + if (param === undefined) { + if (!reportedExcess) { + diagnostics.push( + diagnostic( + code, + `Attribute "${spec.name}" received too many positional arguments`, + ctx, + attributeSpan, + ), + ); + reportedExcess = true; + } + continue; + } + positionalSlot += 1; + if (seen.has(param.key)) { + diagnostics.push( + duplicateDiagnostic( + param.key, + seen.get(param.key), + false, + spec.name, + ctx, + arg, + attributeSpan, + code, + ), + ); + continue; + } + seen.set(param.key, 'positional'); + const result = parseArgValue(arg, param.type, leafCtx, diagnostics, code); + if (result.ok) output[param.key] = result.value; + continue; + } - const namedSeen = new Set(); - const namedParsed = new Map(); - for (const arg of namedArgs) { - const key = arg.name()?.name(); - if (key === undefined) continue; - const param = Object.hasOwn(spec.named, key) ? spec.named[key] : undefined; + const param = Object.hasOwn(spec.named, name) ? spec.named[name] : undefined; if (param === undefined) { diagnostics.push( diagnostic( code, - `Attribute "${spec.name}" received unknown argument "${key}"`, + `Attribute "${spec.name}" received unknown argument "${name}"`, ctx, nodePslSpan(arg.syntax, ctx.sourceFile), ), ); continue; } - if (namedSeen.has(key)) { + if (seen.has(name)) { diagnostics.push( - diagnostic( - code, - `Attribute "${spec.name}" received duplicate argument "${key}"`, - ctx, - nodePslSpan(arg.syntax, ctx.sourceFile), - ), + duplicateDiagnostic(name, seen.get(name), true, spec.name, ctx, arg, attributeSpan, code), ); continue; } - namedSeen.add(key); + seen.set(name, 'named'); const result = parseArgValue(arg, param, leafCtx, diagnostics, code); - if (result.ok) namedParsed.set(key, result.value); - } - - const positionalSeen = new Set(); - const positionalParsed = new Map(); - let index = 0; - for (const param of spec.positional) { - const arg = positionalArgs[index]; - if (arg === undefined) continue; - index += 1; - positionalSeen.add(param.key); - const result = parseArgValue(arg, param.type, leafCtx, diagnostics, code); - if (result.ok) positionalParsed.set(param.key, result.value); - } - if (index < positionalArgs.length) { - diagnostics.push( - diagnostic( - code, - `Attribute "${spec.name}" received too many positional arguments`, - ctx, - attributeSpan, - ), - ); + if (result.ok) output[name] = result.value; } - const output: Record = {}; - const handled = new Set(); - const resolveKey = ( + const finalized = new Set(); + const finalizeAbsentKey = ( key: string, positionalParam: Param | undefined, namedParam: Param | undefined, ): void => { - if (handled.has(key)) return; - handled.add(key); - const fromPositional = positionalSeen.has(key); - const fromNamed = namedSeen.has(key); - - if (fromPositional && fromNamed) { - diagnostics.push( - diagnostic( - code, - `Attribute "${spec.name}" received duplicate values for "${key}" both positionally and by name`, - ctx, - attributeSpan, - ), - ); - return; - } - if (fromNamed) { - if (namedParsed.has(key)) output[key] = namedParsed.get(key); - return; - } - if (fromPositional) { - if (positionalParsed.has(key)) output[key] = positionalParsed.get(key); - return; - } - + if (finalized.has(key) || seen.has(key)) return; + finalized.add(key); const effective = namedParam ?? positionalParam; if (effective === undefined) return; if (isOptionalArgType(effective)) { @@ -140,10 +131,10 @@ export function interpretAttribute( for (const param of spec.positional) { const namedParam = Object.hasOwn(spec.named, param.key) ? spec.named[param.key] : undefined; - resolveKey(param.key, param.type, namedParam); + finalizeAbsentKey(param.key, param.type, namedParam); } for (const key of Object.keys(spec.named)) { - resolveKey(key, undefined, spec.named[key]); + finalizeAbsentKey(key, undefined, spec.named[key]); } if (diagnostics.length > 0) { @@ -163,6 +154,38 @@ export function interpretAttribute( return ok(value); } +/** + * Reports a key bound twice. A repeated named key is anchored to the offending + * argument; an alias collision between a positional and a named binding (in + * either order) is anchored to the whole attribute, keeping each error path's + * span no coarser than the previous engine. + */ +function duplicateDiagnostic( + key: string, + storedOrigin: Origin | undefined, + currentIsNamed: boolean, + attributeName: string, + ctx: InterpretCtx, + arg: AttributeArgAst, + attributeSpan: PslSpan, + code: PslDiagnosticCode, +): PslDiagnostic { + if (currentIsNamed && storedOrigin === 'named') { + return diagnostic( + code, + `Attribute "${attributeName}" received duplicate argument "${key}"`, + ctx, + nodePslSpan(arg.syntax, ctx.sourceFile), + ); + } + return diagnostic( + code, + `Attribute "${attributeName}" received duplicate values for "${key}" both positionally and by name`, + ctx, + attributeSpan, + ); +} + function parseArgValue( arg: AttributeArgAst, argType: ArgType, From 236fe2db4c9bc8251fd10a74322d144e2542dcfc Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 15:31:57 +0000 Subject: [PATCH 25/26] refactor(sql-contract-psl): consume the relation spec output directly Drop the ParsedRelationAttribute rename wrapper that mapped name->relationName and map->constraintName. interpretRelationAttribute now returns the spec output (SqlRelationOutput); the interpreter reads name and map directly. Signed-off-by: Serhii Tatarintsev --- .../contract-psl/src/interpreter.ts | 8 ++--- .../src/psl-relation-resolution.ts | 29 ++++--------------- 2 files changed, 10 insertions(+), 27 deletions(-) diff --git a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts index f7b1ed7335..77d2160ef5 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts @@ -547,7 +547,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult }); continue; } - relationName = parsedRelation.relationName; + relationName = parsedRelation.name; } if (!attributesValid) { continue; @@ -945,7 +945,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult namespaceId: crossTargetNamespaceId, spaceId: fieldTypeContractSpaceId, }, - ...ifDefined('name', parsedRelation.constraintName), + ...ifDefined('name', parsedRelation.map), ...ifDefined('onDelete', onDelete), ...ifDefined('onUpdate', onUpdate), }); @@ -1094,7 +1094,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult columns: referencedColumns, ...ifDefined('namespaceId', targetNamespaceId), }, - ...ifDefined('name', parsedRelation.constraintName), + ...ifDefined('name', parsedRelation.map), ...ifDefined('onDelete', onDelete), ...ifDefined('onUpdate', onUpdate), }); @@ -1107,7 +1107,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult targetModelName: targetMapping.model.name, targetTableName: targetMapping.tableName, ...ifDefined('targetNamespaceId', targetNamespaceId), - ...ifDefined('relationName', parsedRelation.relationName), + ...ifDefined('relationName', parsedRelation.name), localColumns, referencedColumns, }); 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 b28247b8ba..d276f6e53f 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 @@ -41,15 +41,6 @@ export const REFERENTIAL_ACTION_MAP: Record; +export type SqlRelationOutput = InferAttr; function findRelationAttributeNode(field: FieldSymbol): FieldAttributeAst | undefined { for (const attribute of field.node.attributes()) { @@ -210,9 +201,9 @@ function buildRelationInterpretCtx(input: { /** * Validates and lowers a field's `@relation` attribute through the declarative - * `sqlRelation` spec, mapping the inferred output onto `ParsedRelationAttribute`. - * The engine aggregates every error path's diagnostics; like the previous - * first-error parser, the caller skips the field when this returns `undefined`. + * `sqlRelation` spec, returning the spec's inferred output directly. The engine + * aggregates every error path's diagnostics; like the previous first-error + * parser, the caller skips the field when this returns `undefined`. */ export function interpretRelationAttribute(input: { readonly selfModel: ModelSymbol; @@ -221,7 +212,7 @@ export function interpretRelationAttribute(input: { readonly sourceFile: SourceFile; readonly sourceId: string; readonly diagnostics: ContractSourceDiagnostic[]; -}): ParsedRelationAttribute | undefined { +}): SqlRelationOutput | undefined { const attributeNode = findRelationAttributeNode(input.field); if (attributeNode === undefined) { return undefined; @@ -234,15 +225,7 @@ export function interpretRelationAttribute(input: { } return undefined; } - const parsed: SqlRelationOutput = result.value; - return { - ...ifDefined('relationName', parsed.name), - ...ifDefined('fields', parsed.fields), - ...ifDefined('references', parsed.references), - ...ifDefined('constraintName', parsed.map), - ...ifDefined('onDelete', parsed.onDelete), - ...ifDefined('onUpdate', parsed.onUpdate), - }; + return result.value; } export function indexFkRelations(input: { From 48bcda1f67c408dfc53b3d8eebdc0a843aeb6ac8 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 15:40:43 +0000 Subject: [PATCH 26/26] docs(typed-attribute-parsers): record D8 review-round-2 (engine simplification) Signed-off-by: Serhii Tatarintsev --- .../dispatches/08-address-review-r2.md | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/08-address-review-r2.md diff --git a/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/08-address-review-r2.md b/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/08-address-review-r2.md new file mode 100644 index 0000000000..9c4b78bcf2 --- /dev/null +++ b/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/08-address-review-r2.md @@ -0,0 +1,54 @@ +# Brief: D8 — address PR #891 review round 2 (engine simplification) + +> Fresh implementer. On the slice-1 PR branch `tml-2956-typed-attribute-parsers`. These are the operator's (and CodeRabbit's) **unresolved** review comments — all to be addressed. The 509-test psl-parser suite + the SQL suites are your safety net; keep them green and update them where the reshape demands. Do NOT push or touch GitHub. + +## Context +- Engine + types: `packages/1-framework/2-authoring/psl-parser/src/attribute-spec/` — `interpret.ts`, `types.ts`, `optional.ts`, `combinators/one-of.ts`. Tests under the package's `test/`. +- The SQL `@relation` consumer: `packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts` (the `sqlRelation` spec + `interpretRelationAttribute` wrapper) and its call sites in `interpreter.ts`. +- Parity bar (operator-amended): contract output + diagnostic **codes** identical; **spans** no-coarser; **messages** may change. + +## Tasks + +### T1 — Single-pass engine (`interpret.ts`) — operator's main note +The engine currently does one pass over positional args, one over named args, then a third `resolveKey` merge pass. Rewrite `interpretAttribute` as **a single pass that builds one output map and reports duplicates as it goes**: +- Walk the attribute's args in source order. A positional arg binds to the next unconsumed positional slot's `key`; a named arg binds to its `key`. +- Maintain one `output` map + a `seen` set. If a key is already `seen` when you go to set it (whether from positional-then-named alias, or a repeated named key), emit the duplicate/conflict diagnostic **inline** and skip — this subsumes the old alias-conflict and duplicate-named handling (already operator-confirmed: reject duplicates regardless of value equality). +- Unknown named key → diagnostic; excess positional (no slot left) → diagnostic. +- After the pass: apply `optional` defaults for absent keys; emit missing-required diagnostics; then run `refine`. +- Delete the now-dead `positionalParsed`/`namedParsed`/`resolveKey` machinery. The behaviour (codes, spans, the set of diagnostics for each error path) must stay within the parity bar; keep all engine + relation tests green. + +### T2 — Reuse shared type helpers (`types.ts:8,10`) +`Simplify` and `UnionToIntersection` are redefined locally but exist in ~7 places across the repo with no canonical home. **Centralize** them in `@prisma-next/utils` (psl-parser already depends on it — add a small `types` module/export there, e.g. `@prisma-next/utils/types`) and import them in `attribute-spec/types.ts`; do not keep the local redefinitions. Scope: just centralize + import here — do NOT migrate the other 6 copies (out of scope; note as a possible future cleanup). If `@prisma-next/utils` is the wrong home per `lint:deps` layering, surface the finding rather than forcing a bad dependency. + +### T3 — Remove variadic positional support (`types.ts:85`) +No attribute uses a variadic positional (`@@index([a,b])` is a single positional bound to a `list`, not a variadic; `@@base(Base, "v")` is two fixed positionals). Remove `PositionalParam.variadic`, the variadic branch in `PosEntryObject`/`PosOut`, and the engine's variadic handling. YAGNI — re-add only when a real variadic attribute appears. + +### T4 — `optional` is an `ArgType` (`optional.ts:8`) +Model `optional(t)` as an `ArgType`, not a separate `OptionalParam`/`Param` union member. Target shape: an `OptionalArgType extends ArgType` carrying `{ optional: true; hasDefault: boolean; defaultValue?: T }`, so `Param` collapses to just `ArgType` (an optional param is a flavoured `ArgType`). The engine detects optionality via the marker on the `ArgType`; `NamedOut`/`PosOut` key their optional-property mapping off `OptionalArgType` instead of `OptionalParam`. Keep `optional(t)` / `optional(t, default)` call-shape and inferred types unchanged for consumers (`sqlRelation` must still type-check identically and infer the same output union). + +### T5 — Remove the `map`→`constraintName` rename wrapper (`psl-relation-resolution.ts:217`) +`interpretRelationAttribute`'s output mapping only renames `name`→`relationName` and `map`→`constraintName`. Remove that renaming layer and consume `interpretAttribute`'s result **directly**: align the downstream shape with the spec output keys (`name`, `map`, `fields`, `references`, `onDelete`, `onUpdate`) — update `ParsedRelationAttribute` (rename its `relationName`→`name`, `constraintName`→`map`, or drop it in favour of `SqlRelationOutput`) and the downstream field accesses across `interpreter.ts`. Keep the genuinely-needed plumbing (`findRelationAttributeNode`, `InterpretCtx` assembly) — inline or as small helpers — but no value-renaming pass. (The "same for Mongo" note is slice 3 — Mongo isn't migrated yet; ignore here.) **Halt and surface** if the downstream `relationName`/`constraintName` consumers fan out further than the relation path expects. + +### T6 — `oneOf` requires ≥1 alternative (`one-of.ts:19`) +Make the rest parameter a non-empty tuple so `oneOf()` (zero args) is a **compile error**: `oneOf, ...ArgType[]]>(...alts: Alts)`. Keep the union-output typing. + +## Orchestrator decision on the remaining CodeRabbit comment (do NOT re-add) +CodeRabbit `psl-relation-resolution.ts:89` asks to restore `PSL_UNSUPPORTED_REFERENTIAL_ACTION`. **Decision: do not restore it.** The operator deliberately moved referential-action validation into `oneOf(identifier(...))` (D6); a bad action now reports the attribute's `PSL_INVALID_RELATION_ATTRIBUTE` at parse, which is consistent with the operator's simplification and the amended parity bar. Restoring the specific code would re-add downstream validation or a per-argument code override — exactly the special-casing being removed. Leave as-is; do not change the test assertion back. + +## Completed when +- [ ] Engine is single-pass; `resolveKey`/`positionalParsed`/`namedParsed` gone (`rg "resolveKey\|positionalParsed\|namedParsed"` zero). +- [ ] `Simplify`/`UnionToIntersection` imported from a shared home; not redefined in `attribute-spec/types.ts`. +- [ ] `variadic` removed everywhere (`rg variadic packages/1-framework/2-authoring/psl-parser` zero). +- [ ] `optional` returns an `ArgType`; `OptionalParam`/`Param`-union collapsed; `sqlRelation` infers the same output type. +- [ ] `interpretRelationAttribute` renaming layer gone; downstream uses the spec output keys; relation suites green. +- [ ] `oneOf()` with zero args is a compile error. +- [ ] Gates: `pnpm --filter @prisma-next/psl-parser typecheck && test && lint`; `pnpm --filter @prisma-next/sql-contract-psl test`; `pnpm fixtures:check`; after `pnpm --filter @prisma-next/psl-parser build` (and `@prisma-next/utils` build if you added an export there), workspace `pnpm typecheck`; `pnpm lint:deps` (T2 adds a dependency edge — verify it's clean). + +## Constraints +No `any`; no bare `as` (narrow `blindCast`/`castAs` with reason, or types that avoid it); no file-ext imports; no reexport outside `exports/`; tests-first for the reshaped surfaces. Explicit-staging commits (one per task or coherent group), no amend, **no push**. Read-only on `projects/**/reviews/**`, `spec.md`, plan files. Transient-ID scan on the `+` diff. Do NOT post to GitHub or resolve threads. + +## Operational metadata +- **Model tier:** thorough (core-engine reshape + cross-package consumer change). +- **Halt conditions:** T4 (optional-as-ArgType) can't preserve the inferred output types without `any`/broad casts; T5 downstream renaming fans out beyond the relation path; T2 reuse would force a layering violation; any task changes contract output (not just diagnostics). + +Return the structured report per § Return shape: per-task (T1–T6) results, the single-pass + optional-as-ArgType designs you landed, confirmation `sqlRelation` infers the same output, the CodeRabbit:89 disposition restated, and commit SHAs.