From 0ebffcc3e3e979c4cb43178df3f0e1a22820f287 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 29 Jun 2026 18:01:42 +0000 Subject: [PATCH 01/38] 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 ac65535953c1e293c1dfa5ab64522231fc726da9 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 29 Jun 2026 18:01:48 +0000 Subject: [PATCH 02/38] 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 e3908d2f0ca134411b1eb127088a9ddf4b553c2e Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 29 Jun 2026 19:34:09 +0000 Subject: [PATCH 03/38] 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 e6b6ef640ea969fd166c1b92a85e1cd559190f42 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 29 Jun 2026 19:38:26 +0000 Subject: [PATCH 04/38] 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 a5c7c8d4797a913f8249dc7148e94547c208b0ca Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 29 Jun 2026 20:13:22 +0000 Subject: [PATCH 05/38] 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 39090125d5d16a87c8b0d032ae9adcff46c6550d Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 29 Jun 2026 20:13:43 +0000 Subject: [PATCH 06/38] 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 3b26cd9c70..ad2f412665 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts @@ -101,9 +101,9 @@ import { applyBackrelationCandidates, type FkRelationMetadata, indexFkRelations, + interpretRelationAttribute, type ModelBackrelationCandidate, normalizeReferentialAction, - parseRelationAttribute, validateNavigationListFieldAttributes, } from './psl-relation-resolution'; @@ -446,6 +446,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; @@ -525,10 +527,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, }); @@ -854,10 +857,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, }); @@ -1025,10 +1029,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, }); @@ -1927,6 +1932,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 12b8c8c4c9ce1b80166f8aa862fcc6f516be9e1f Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 07:19:31 +0000 Subject: [PATCH 07/38] 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 8135b016d1c1d2ecfd9240515dd8ef2da721d98c Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 09:37:52 +0000 Subject: [PATCH 08/38] 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 312f6af391f330f846109c691b638406a425e131 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 09:38:58 +0000 Subject: [PATCH 09/38] 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 6c2b82bc828a0f0b999dba6ce0b151229f996525 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 09:39:50 +0000 Subject: [PATCH 10/38] 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 d335fac908..8dff831e4f 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 @@ -391,6 +391,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 dc0cd1df19e17736313d6abd3a87c4198103b23c Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 09:43:58 +0000 Subject: [PATCH 11/38] 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 ad2f412665..191d2427ec 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts @@ -907,26 +907,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 @@ -1101,26 +1085,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 8dff831e4f..e61b6e85d4 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 @@ -312,7 +312,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 458296922cf3cd0a0bf94598244dbb461aa4ef57 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 09:46:06 +0000 Subject: [PATCH 12/38] 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 e61b6e85d4..cc87086e87 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 @@ -346,10 +346,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"'), }), ]), ); @@ -382,10 +380,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 00c1c947059df552497c49207562ca353822b5a8 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 09:55:44 +0000 Subject: [PATCH 13/38] 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 284a01e2a9bd9f6da51feac62053ed772de60dfa Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 13:17:41 +0000 Subject: [PATCH 14/38] 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 abdbd6223bc5051352ed75ea1a8673dfaceb1f31 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 13:26:26 +0000 Subject: [PATCH 15/38] 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 ee0f300aa92c0522297c6254cf7fefdb71f54478 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 14:05:44 +0000 Subject: [PATCH 16/38] 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 5efc6b763cdf764e8e5a5f95e42e36b8150f7786 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 14:07:51 +0000 Subject: [PATCH 17/38] 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 f83a943aceb45176a52c8f4361e558f6271e85e6 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 15:11:34 +0000 Subject: [PATCH 18/38] 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 3f4521b55b116b1ede0c789fbf3ab30adc2254c6 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 15:12:16 +0000 Subject: [PATCH 19/38] 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 36c79524fc08a4dedbe8d21013233f943126a27c Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 15:21:28 +0000 Subject: [PATCH 20/38] 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 a78d83041040b21378f44a199dbef20cf4e5246b Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 15:21:58 +0000 Subject: [PATCH 21/38] 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 265b6acdb38c854ac29c215202440de080baee35 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 15:22:40 +0000 Subject: [PATCH 22/38] 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 bd2787216644522ba63cbbd8f8d0f0764a7d042e Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 15:25:01 +0000 Subject: [PATCH 23/38] 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 ec15eb330d58c6224c9c2ca5b4f08f9e16f6c42c Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 15:29:21 +0000 Subject: [PATCH 24/38] 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 3af8524ed95b8e6b8880244008239fe234c9a0dd Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 15:31:57 +0000 Subject: [PATCH 25/38] 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 191d2427ec..0147d1f272 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts @@ -556,7 +556,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult }); continue; } - relationName = parsedRelation.relationName; + relationName = parsedRelation.name; } if (!attributesValid) { continue; @@ -954,7 +954,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult namespaceId: crossTargetNamespaceId, spaceId: fieldTypeContractSpaceId, }, - ...ifDefined('name', parsedRelation.constraintName), + ...ifDefined('name', parsedRelation.map), ...ifDefined('onDelete', onDelete), ...ifDefined('onUpdate', onUpdate), }); @@ -1103,7 +1103,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), }); @@ -1116,7 +1116,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 b68274ed91b0703e02eeb997c45f18e43a84abe9 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 30 Jun 2026 15:40:43 +0000 Subject: [PATCH 26/38] 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. From d92b34ae08c4a43bfdc9f8b1630b71151e85e38a Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 6 Jul 2026 10:05:31 +0000 Subject: [PATCH 27/38] refactor(psl-parser): prune narration comments from attribute-spec kit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove added comments that narrate a body, restate a signature/type, enumerate usages, or reference removed/overruled code; trim mixed comments down to the non-obvious "why". Comment-only change — no code, type, or behaviour is touched. Signed-off-by: Serhii Tatarintsev --- .../0-foundation/utils/src/types.ts | 2 - .../attribute-spec/combinators/diagnostic.ts | 5 +- .../attribute-spec/combinators/field-ref.ts | 17 ++----- .../attribute-spec/combinators/identifier.ts | 7 +-- .../src/attribute-spec/combinators/list.ts | 7 --- .../src/attribute-spec/combinators/one-of.ts | 10 ++-- .../src/attribute-spec/combinators/str.ts | 1 - .../src/attribute-spec/field-attribute.ts | 5 -- .../src/attribute-spec/interpret.ts | 19 +------- .../psl-parser/src/attribute-spec/optional.ts | 6 --- .../psl-parser/src/attribute-spec/types.ts | 47 +++++-------------- .../test/attribute-spec-combinators.test.ts | 1 - .../psl-parser/test/attribute-spec.test.ts | 3 -- .../src/psl-relation-resolution.ts | 24 ++-------- 14 files changed, 29 insertions(+), 125 deletions(-) diff --git a/packages/1-framework/0-foundation/utils/src/types.ts b/packages/1-framework/0-foundation/utils/src/types.ts index 7d179368a2..da29ccad3b 100644 --- a/packages/1-framework/0-foundation/utils/src/types.ts +++ b/packages/1-framework/0-foundation/utils/src/types.ts @@ -1,7 +1,5 @@ -/** 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 diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/diagnostic.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/diagnostic.ts index eab4a25a66..20624be111 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/diagnostic.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/diagnostic.ts @@ -4,9 +4,8 @@ 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. + * 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 { 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 303557fdbd..b23866121c 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 @@ -4,26 +4,17 @@ 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. - */ +/** 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 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. + * 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. */ export function fieldRef(scope: FieldRefScope): FieldRefArgType { return { diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/identifier.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/identifier.ts index 061d2c195f..dd9d9ebdfc 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/identifier.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/identifier.ts @@ -5,11 +5,8 @@ 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. + * Pinned-only: there is no open form, so several `identifier`s composed under + * `oneOf` infer the precise union of names. */ export function identifier(name: N): ArgType { return { 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 7e0bc49a37..5edffacae3 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 @@ -4,18 +4,11 @@ import { ArrayLiteralAst, type ExpressionAst } from '../../syntax/ast/expression 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', 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 1521bf1049..9f7aeb6b61 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 @@ -5,13 +5,9 @@ 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. + * 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. */ export function oneOf, ...ArgType[]]>( ...alts: Alts 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 index 2b87d724c0..8b4b16f5fd 100644 --- 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 @@ -4,7 +4,6 @@ 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', diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/field-attribute.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/field-attribute.ts index 596000c327..eac8c368af 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/field-attribute.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/field-attribute.ts @@ -14,11 +14,6 @@ interface FieldAttributeConfig< 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, 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 ba16ced6dc..cb7680f464 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,20 +12,9 @@ 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. */ +/** Tracked so a later collision is reported as the right kind. */ type Origin = 'positional' | 'named'; -/** - * 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, spec: AttributeSpec, @@ -154,12 +143,6 @@ 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, 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 ef4aa9a37c..8744bd3e9c 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,11 +1,5 @@ import type { ArgType, OptionalArgType } from './types'; -/** - * 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] | []): OptionalArgType { if (rest.length === 0) { return { ...type, optional: true, hasDefault: false }; 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 0835b16c21..74afccb6a1 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 @@ -8,18 +8,13 @@ import type { ExpressionAst } from '../syntax/ast/expressions'; 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). */ + /** Discriminant for visitor dispatch. */ readonly kind: string; - /** Human-readable label, for "expected …" diagnostics. */ readonly label: string; /** Phantom carrier for `T`; never read at runtime. */ readonly _out?: T; @@ -27,10 +22,6 @@ export interface ArgType { } /** - * 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. @@ -38,31 +29,22 @@ 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. + * `interpretAttribute` populates this from the active spec's `diagnosticCode` + * so a combinator emits with the attribute's code rather than a hard-coded + * generic. */ 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. */ 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: 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. + * Because it extends `ArgType`, the engine parses an optional parameter directly + * and detects optionality from the `optional` marker. */ export interface OptionalArgType extends ArgType { readonly optional: true; @@ -70,11 +52,9 @@ export interface OptionalArgType extends ArgType { readonly defaultValue?: T; } -/** 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. */ readonly key: string; readonly type: Param; } @@ -86,9 +66,8 @@ export interface AttributeSpec { 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. + * Defaults to `PSL_INVALID_ATTRIBUTE_SYNTAX`; an attribute that must preserve a + * specific code overrides it here. */ readonly diagnosticCode?: PslDiagnosticCode; } @@ -116,10 +95,10 @@ export type AttributeOut< > = Simplify & NamedOut>; /** - * 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. + * 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/test/attribute-spec-combinators.test.ts b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.test.ts index ff0ba56a78..bc16281cdc 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 @@ -43,7 +43,6 @@ function makeCtx( }; } -/** 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))); 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 b4e3a19a45..5a81e07a72 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 @@ -41,7 +41,6 @@ function fieldAttr(source: string): { node: FieldAttributeAst; ctx: InterpretCtx return { node, ctx: makeCtx(cursor.sourceFile) }; } -/** Parses a quoted-string argument into its decoded value; otherwise fails purely. */ function str(): ArgType { return { kind: 'str', @@ -70,7 +69,6 @@ const FAILING_DIAGNOSTIC: PslDiagnostic = { 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', @@ -332,7 +330,6 @@ describe('interpretAttribute leaf purity', () => { }); }); -/** A leaf that emits a diagnostic carrying whatever code the context threads to it. */ function codeEcho(): ArgType { return { kind: 'codeEcho', 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 d276f6e53f..6ea0c78ca4 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 @@ -72,9 +72,9 @@ 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 `oneOf(identifier(...))`, - * so this is a pure lookup with no second validation path. + * The action 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]; @@ -82,8 +82,7 @@ export function normalizeReferentialAction(actionToken: string): ReferentialActi /** * 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. + * `fields` and `references` are both-or-neither. */ function relationInvariants( parsed: { readonly fields?: readonly string[]; readonly references?: readonly string[] }, @@ -104,15 +103,6 @@ function relationInvariants( return []; } -/** - * 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 is validated at parse via - * `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()) }], named: { @@ -199,12 +189,6 @@ function buildRelationInterpretCtx(input: { }; } -/** - * Validates and lowers a field's `@relation` attribute through the declarative - * `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; readonly field: FieldSymbol; From f4d12c0419651d84b709c3815e51f5f4c829d808 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 6 Jul 2026 10:06:47 +0000 Subject: [PATCH 28/38] docs(typed-attribute-parsers): record D9 comment-pruning dispatch Signed-off-by: Serhii Tatarintsev --- .../dispatches/09-prune-comments.md | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/09-prune-comments.md diff --git a/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/09-prune-comments.md b/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/09-prune-comments.md new file mode 100644 index 0000000000..e1a46a779c --- /dev/null +++ b/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/09-prune-comments.md @@ -0,0 +1,51 @@ +# Brief: D9 — prune added comments on PR #891 + +> On the slice-1 PR branch `tml-2956-typed-attribute-parsers`. **Comment-deletion only — do not change any code, types, or behaviour.** Do NOT push or touch GitHub. + +## Task +Go through **every comment ADDED by this branch** and remove the ones that don't earn their place. Get the added comments with: +`git --no-pager diff origin/main...HEAD --diff-filter=d -- 'packages/**/*.ts'` (source + test `.ts` under `packages/**`). Work file-by-file through the added `//` and `/** */` comments. + +### Remove a comment if it does any of these (the operator's criteria): +1. **Narrates what a function/method body does** (step-by-step of the implementation). +2. **Restates information already obvious from the signature or type** (name, params, return type). +3. **Enumerates the usages** of an internal type/function. +4. **Refers to transient artifacts** — specs, Linear issues, review comments, dispatch/project docs, operator decisions. +5. **Refers to removed code or an overruled decision** — e.g. "the previous engine", "was X before", "no longer …". + +### Keep a comment ONLY if it answers **why** a specific line/function exists **and that why is not obvious**. +- If a comment is **mixed** (some narration + a real non-obvious why), **trim it to just the why** rather than deleting wholesale. +- When in doubt between keep and remove, **remove** — the operator wants a lean result. + +## Calibration (from this diff — apply the same judgment everywhere) + +**REMOVE (narration / restatement):** +- `str.ts`: "Parses a quoted string-literal argument into its decoded value." (restates `str(): ArgType`). +- `list.ts` JSDoc: "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…" (narrates the body). +- `interpret.ts` `interpretAttribute` JSDoc: the paragraph narrating the single-pass algorithm ("A positional argument binds to the next unconsumed slot… After the pass, optional defaults fill…") — body narration. +- `field-attribute.ts`: "Builds a field-level AttributeSpec. The output type is inferred from the positional and named parameters…" (restates signature). +- `types.ts` field docs that restate: "Human-readable label, for 'expected …' diagnostics", "Identifier of the source… stamped onto diagnostics", "The output key this slot writes into", "The declaring model; the resolution target for a self-scoped field reference" (restate the field name/type). +- `utils/src/types.ts`: "Flattens an intersection of mapped types into a single readable object type." / "Collapses a union into the intersection of its members." (WHAT, not WHY). + +**KEEP (non-obvious why):** +- `types.ts` `_out` field: "Phantom carrier for T; never read at runtime." (explains why an unused field exists). +- `types.ts` `InterpretCtx` "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." (why the ctx is minimal). +- `types.ts` `diagnosticCode` field: "…so a combinator emits with the attribute's code rather than a hard-coded generic." (why the field is threaded). +- `types.ts` `InferAttr`: "The parameter is intentionally unconstrained: Out sits contravariantly in refine, so a constraint would reject every spec that uses a cross-argument refine." (non-obvious type why). +- `field-ref.ts`: the note that a cross-space referenced model is out of scope so existence is carried through unchecked (non-obvious why the miss is silent) — trim any body-narration around it, keep the why. + +**TRIM (mixed):** +- `interpret.ts` `duplicateDiagnostic` JSDoc: "…keeping each error path's span no coarser than the previous engine." — "the previous engine" is a removed-code reference (criterion 5). Keep the *why* the span anchoring differs (repeated-named → arg node; alias collision → whole attribute) only if non-obvious; drop the "previous engine" reference. If what remains is just narration, remove it. +- `one-of.ts` JSDoc: mostly narrates "tries each in order, first wins". Trim to the one non-obvious point (Result-pure leaves ⇒ a discarded branch leaves no stray diagnostics) if you judge it non-obvious; otherwise remove. + +## Constraints +- **Only delete/trim comments.** No code, type, signature, or behaviour change. A JSDoc that's the only thing above an export may be removed entirely — that's fine. +- Don't touch comments that existed before this branch (only ones ADDED by `origin/main...HEAD`). +- Explicit-staging commit(s) with sign-off (`git commit -s`), no push. Read-only on `projects/**`, `spec.md`, plan files (this task is about `packages/**` code comments, not the project docs). + +## Completed when +- [ ] Every added comment reviewed against the criteria; narration/restatement/enumeration/transient-ref/removed-ref comments removed; mixed ones trimmed to the why; only non-obvious why-comments remain. +- [ ] Gates green (proving no code was accidentally deleted): `pnpm --filter @prisma-next/psl-parser typecheck && test && lint`; `pnpm --filter @prisma-next/sql-contract-psl typecheck && test`; `pnpm --filter @prisma-next/utils typecheck`. + +## Report +Return a per-file summary: which comments you REMOVED, which you TRIMMED (before→after gist), and which you KEPT with the one-line why each survived. Commit SHA(s). Flag any comment you were genuinely torn on for the orchestrator's call. Model tier: thorough (judgment per comment). From bd182291110fe6482ef9cddff4f1b27798ca11bb Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 6 Jul 2026 10:19:50 +0000 Subject: [PATCH 29/38] refactor(psl-parser): unify duplicate-argument check in interpretAttribute Resolve (key, param) per positional/named branch, then run a single seen check that emits one uniform duplicate diagnostic anchored to the offending arg node. Drop the Origin type (seen is now a Set) and duplicateDiagnostic entirely; the named-vs-positional distinction no longer affects the message or span. Alias-collision diagnostics now anchor to the duplicate arg node rather than the whole attribute. Signed-off-by: Serhii Tatarintsev --- .../src/attribute-spec/interpret.ts | 76 +++++-------------- .../psl-parser/test/attribute-spec.test.ts | 8 +- 2 files changed, 24 insertions(+), 60 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 cb7680f464..c1dfe906bf 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,9 +12,6 @@ import type { ArgType, AttributeSpec, InterpretCtx, OptionalArgType, Param } fro const DEFAULT_STRUCTURAL_CODE: PslDiagnosticCode = 'PSL_INVALID_ATTRIBUTE_SYNTAX'; -/** Tracked so a later collision is reported as the right kind. */ -type Origin = 'positional' | 'named'; - export function interpretAttribute( attrNode: FieldAttributeAst | ModelAttributeAst, spec: AttributeSpec, @@ -26,16 +23,18 @@ export function interpretAttribute( const attributeSpan = nodePslSpan(attrNode.syntax, ctx.sourceFile); const output: Record = {}; - const seen = new Map(); + const seen = new Set(); let positionalSlot = 0; let reportedExcess = false; for (const arg of attrNode.argList()?.args() ?? []) { const name = arg.name()?.name(); + let key: string; + let param: Param; if (name === undefined) { - const param = spec.positional[positionalSlot]; - if (param === undefined) { + const posParam = spec.positional[positionalSlot]; + if (posParam === undefined) { if (!reportedExcess) { diagnostics.push( diagnostic( @@ -50,48 +49,39 @@ export function interpretAttribute( continue; } positionalSlot += 1; - if (seen.has(param.key)) { + key = posParam.key; + param = posParam.type; + } else { + const namedParam = Object.hasOwn(spec.named, name) ? spec.named[name] : undefined; + if (namedParam === undefined) { diagnostics.push( - duplicateDiagnostic( - param.key, - seen.get(param.key), - false, - spec.name, - ctx, - arg, - attributeSpan, + diagnostic( code, + `Attribute "${spec.name}" received unknown argument "${name}"`, + ctx, + nodePslSpan(arg.syntax, ctx.sourceFile), ), ); continue; } - seen.set(param.key, 'positional'); - const result = parseArgValue(arg, param.type, leafCtx, diagnostics, code); - if (result.ok) output[param.key] = result.value; - continue; + key = name; + param = namedParam; } - const param = Object.hasOwn(spec.named, name) ? spec.named[name] : undefined; - if (param === undefined) { + if (seen.has(key)) { diagnostics.push( diagnostic( code, - `Attribute "${spec.name}" received unknown argument "${name}"`, + `Attribute "${spec.name}" received duplicate argument "${key}"`, ctx, nodePslSpan(arg.syntax, ctx.sourceFile), ), ); continue; } - if (seen.has(name)) { - diagnostics.push( - duplicateDiagnostic(name, seen.get(name), true, spec.name, ctx, arg, attributeSpan, code), - ); - continue; - } - seen.set(name, 'named'); + seen.add(key); const result = parseArgValue(arg, param, leafCtx, diagnostics, code); - if (result.ok) output[name] = result.value; + if (result.ok) output[key] = result.value; } const finalized = new Set(); @@ -143,32 +133,6 @@ export function interpretAttribute( return ok(value); } -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, 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 5a81e07a72..c42ba207b7 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 @@ -138,7 +138,7 @@ describe('interpretAttribute named binding', () => { }); describe('interpretAttribute positional-or-named duplicate', () => { - it('rejects a key supplied both positionally and by name even when the values agree', () => { + it('rejects a key supplied both positionally and by name even when the values agree, anchored to the duplicate', () => { const { node, ctx } = fieldAttr('@rel("Foo", name: "Foo")'); const spec = fieldAttribute('rel', { positional: [{ key: 'name', type: optional(str()) }], @@ -152,11 +152,11 @@ describe('interpretAttribute positional-or-named duplicate', () => { 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)); + expect(result.failure[0]?.span).not.toEqual(nodePslSpan(node.syntax, ctx.sourceFile)); } }); - it('rejects a key supplied both positionally and by name when the values disagree', () => { + it('rejects a key supplied both positionally and by name when the values disagree, anchored to the duplicate', () => { const { node, ctx } = fieldAttr('@rel("A", name: "B")'); const spec = fieldAttribute('rel', { positional: [{ key: 'name', type: optional(str()) }], @@ -170,7 +170,7 @@ describe('interpretAttribute positional-or-named duplicate', () => { 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)); + expect(result.failure[0]?.span).not.toEqual(nodePslSpan(node.syntax, ctx.sourceFile)); } }); }); From 3cb42a82e9cd9d4c62dc86583d18f69a525453f8 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Mon, 6 Jul 2026 10:20:47 +0000 Subject: [PATCH 30/38] docs(typed-attribute-parsers): record D10 duplicate-check unification Signed-off-by: Serhii Tatarintsev --- .../dispatches/10-unify-duplicate-check.md | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/10-unify-duplicate-check.md diff --git a/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/10-unify-duplicate-check.md b/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/10-unify-duplicate-check.md new file mode 100644 index 0000000000..de63bf0d2d --- /dev/null +++ b/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/10-unify-duplicate-check.md @@ -0,0 +1,40 @@ +# Brief: D10 — unify the engine's duplicate-argument check (PR #891 review) + +> On the slice-1 PR branch `tml-2956-typed-attribute-parsers`. Operator review comments on `interpret.ts`. Do NOT push or touch GitHub. + +## Context +`packages/1-framework/2-authoring/psl-parser/src/attribute-spec/interpret.ts` — the single-pass `interpretAttribute`. It currently has **two separate `seen` checks** (one in the positional branch ~L53, one in the named branch ~L86) and tracks an `Origin` (`'positional' | 'named'`) so `duplicateDiagnostic` can vary its message/span by how each side was bound. + +Operator comments: +- **`interpret.ts:86`** — "Can't you check this once at the start of the loop instead of doing separate check for named and positional args? `const key = param.key ?? arg.name(); if (seen.has(key)) { … }`" +- **`interpret.ts:92`** — "Why does it matter if the argument is named?" (i.e. drop the named-vs-positional distinction in the duplicate handling). + +## Task +Refactor the loop so the duplicate check happens **once**, uniformly: +- Per iteration, resolve `(key, param)` in the two entry branches, keeping their necessary bailouts: + - **Positional** (`arg.name()` undefined): take `spec.positional[positionalSlot]`; if none, emit the "too many positional arguments" diagnostic (still once, flag-guarded) and `continue`; else `key = posParam.key`, `param = posParam.type`, advance `positionalSlot`. + - **Named**: look up `spec.named[name]`; if unknown, emit the "unknown argument" diagnostic and `continue`; else `key = name`, `param = that`. +- **Then a single** `if (seen.has(key))` → emit **one uniform** duplicate diagnostic **anchored to the current arg node** (`nodePslSpan(arg.syntax, ctx.sourceFile)`), and `continue`. Otherwise `seen.add(key)` and parse the value into `output[key]`. +- **Drop the `Origin` type**, make `seen` a `Set`, and **delete `duplicateDiagnostic`'s two-branch logic** — replace with one message (e.g. `Attribute "" received duplicate argument ""`). The named-vs-positional distinction goes away entirely (answers comment :92: it doesn't matter). + +## Parity / test impact (verify + update intentionally) +- **Repeated named key** (`name: "A", name: "B"`): still arg-node span, "duplicate argument" message — unchanged. +- **Positional+named alias collision** (e.g. `@relation("Foo", name: "Bar")`): was anchored to the **whole attribute** with a "both positionally and by name" message; now anchors to the **offending arg node** (narrower → within the "spans no-coarser" bar) with the uniform message (messages may change). Update any unit test / interpreter test / fixture assertion for this case intentionally, and confirm `@relation`'s positional-or-named `name` still works and a real conflict still produces exactly one diagnostic with code `PSL_INVALID_RELATION_ATTRIBUTE`. + +## Out of scope +- The `interpretRelationAttribute` wrapper (`psl-relation-resolution.ts:192`): its original complaint (renaming `map`→`constraintName`) was already removed in an earlier dispatch — it now returns `SqlRelationOutput` directly and only assembles the `InterpretCtx` + threads diagnostics. **Leave it** (it's necessary glue, not renaming). Do not remove it. +- Everything else (other attributes, Mongo, the rest of the kit). + +## Completed when +- [ ] One `seen` check in the loop; `Origin` gone; `seen` is a `Set`; `duplicateDiagnostic`'s branching removed (one uniform duplicate diagnostic anchored to the arg node). +- [ ] `rg "Origin\b" packages/1-framework/2-authoring/psl-parser/src/attribute-spec/interpret.ts` → zero (the removed 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` (keep the existing justified `blindCast` on the output object); no file-ext imports; tests-first for the changed behaviour. Explicit-staging commit with sign-off, no amend, **no push**. Read-only on `projects/**`, `spec.md`, plan files. Transient-ID scan on the `+` diff. Do NOT post to GitHub. + +## Operational metadata +- **Model tier:** mid (bounded engine refactor with test updates). +- **Halt conditions:** unifying the span breaks a case that can't stay within the "spans no-coarser" bar (surface it); a fixture's contract output (not just diagnostics) changes. + +Return the structured report: the unified-loop shape you landed, which test assertions changed (alias case), confirmation `@relation` still validates, and commit SHA. From 9d33740a8aa691e2ef9b3590358aaba23d97d067 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 7 Jul 2026 09:42:56 +0000 Subject: [PATCH 31/38] refactor(psl-parser): drop dead InterpretCtx.symbols field and reword type-bound comment Remove the never-read `symbols: SymbolTable` field from `InterpretCtx` (combinators resolve via `selfModel` / `resolveReferencedModel()`), drop it from the ctx built in `buildRelationInterpretCtx` and the two test ctx helpers, and stop importing `SymbolTable` where it is now unused. Reword the `InferAttr` doc comment to say "type bound" instead of "constraint", since it describes a TypeScript generic bound, not an SQL constraint. These remove two false-positive hits from the framework-vocabulary ratchet. The one irreducible residual is the framework-neutral `../symbol-table` module path (matches "table"), which cannot go without renaming a shared module; the ratchet requires count === threshold, so bump the threshold 967 -> 968 by exactly that residual. No behaviour change. Signed-off-by: Serhii Tatarintsev --- .../2-authoring/psl-parser/src/attribute-spec/types.ts | 5 ++--- .../psl-parser/test/attribute-spec-combinators.test.ts | 1 - .../2-authoring/psl-parser/test/attribute-spec.test.ts | 1 - .../2-authoring/contract-psl/src/psl-relation-resolution.ts | 1 - scripts/lint-framework-vocabulary.config.json | 2 +- 5 files changed, 3 insertions(+), 7 deletions(-) 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 74afccb6a1..1c1dee77b1 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/types.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/types.ts @@ -2,7 +2,7 @@ import type { PslDiagnostic, PslDiagnosticCode } from '@prisma-next/framework-co 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 { FieldSymbol, ModelSymbol } from '../symbol-table'; import type { ExpressionAst } from '../syntax/ast/expressions'; export type AttributeLevel = 'field' | 'model' | 'block'; @@ -36,7 +36,6 @@ export interface InterpretCtx { readonly diagnosticCode: PslDiagnosticCode; readonly sourceId: string; readonly sourceFile: SourceFile; - readonly symbols: SymbolTable; readonly selfModel: ModelSymbol; resolveReferencedModel(): ModelSymbol | undefined; readonly field?: FieldSymbol; @@ -97,7 +96,7 @@ export type AttributeOut< /** * 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 + assignable to `AttributeSpec` and a type bound would reject every * spec that uses a cross-argument `refine`. Inference still recovers `Out` * precisely. */ 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 bc16281cdc..cd452d605f 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 @@ -36,7 +36,6 @@ function makeCtx( level: 'field', sourceId: 'schema.prisma', sourceFile, - symbols: table, selfModel, resolveReferencedModel: () => undefined, diagnosticCode, 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 c42ba207b7..74227790bb 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 @@ -27,7 +27,6 @@ function makeCtx( level: 'field', sourceId: 'schema.prisma', sourceFile, - symbols: table, selfModel, resolveReferencedModel: () => undefined, diagnosticCode, 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 6ea0c78ca4..bd953ffa5b 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 @@ -182,7 +182,6 @@ function buildRelationInterpretCtx(input: { 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), diff --git a/scripts/lint-framework-vocabulary.config.json b/scripts/lint-framework-vocabulary.config.json index ae3fe0dbb5..2099fd461e 100644 --- a/scripts/lint-framework-vocabulary.config.json +++ b/scripts/lint-framework-vocabulary.config.json @@ -30,7 +30,7 @@ "foreignKey", "foreign key" ], - "threshold": 967 + "threshold": 968 } ] } From d6c02e316b9f31d9f36a44ea7fbd1efb73c0b468 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 7 Jul 2026 09:44:23 +0000 Subject: [PATCH 32/38] docs(typed-attribute-parsers): record D11 vocab-ratchet reconciliation Signed-off-by: Serhii Tatarintsev --- .../dispatches/11-vocab-ratchet.md | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/11-vocab-ratchet.md diff --git a/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/11-vocab-ratchet.md b/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/11-vocab-ratchet.md new file mode 100644 index 0000000000..664950c4a9 --- /dev/null +++ b/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/11-vocab-ratchet.md @@ -0,0 +1,35 @@ +# Brief: D11 — satisfy the framework-vocabulary ratchet (post-rebase) + +> On the rebased slice-1 branch `tml-2956-typed-attribute-parsers`. Main's new PR #918 added `pnpm lint:framework-vocabulary` (`scripts/lint-framework-vocabulary.mjs` + `.config.json`), which forbids family terms from growing in `packages/1-framework`. Our new code trips it by **+3** (count 970 vs threshold 967) — all false positives. Fix honestly, minimize any threshold bump. Do NOT push or touch GitHub. + +## The 3 false-positive hits (all in `packages/1-framework/2-authoring/psl-parser/src/attribute-spec/types.ts`) +1. Line ~5 — `import type { …, SymbolTable } from '../symbol-table'` → matches "table". +2. Line ~39 — `readonly symbols: SymbolTable;` on `InterpretCtx` → matches "table". +3. Line ~100 — doc comment "…a constraint would reject every spec…" → matches "constraint" (it means a TypeScript generic bound, not SQL CONSTRAINT). + +## Tasks +### T1 — Remove the dead `InterpretCtx.symbols` field +`InterpretCtx.symbols: SymbolTable` is **never read** anywhere in the attribute-spec kit (combinators resolve via `selfModel` / `resolveReferencedModel()`; verify with `rg "\\.symbols" packages/1-framework/2-authoring/psl-parser/src/attribute-spec`). Remove it: +- Delete the `symbols: SymbolTable` field from `InterpretCtx` in `types.ts`, and drop `SymbolTable` from that file's `../symbol-table` import (keep `FieldSymbol`, `ModelSymbol` — still used). This also tightens the "deliberately lean" ctx as its own doc claims. +- In `packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts`, `buildRelationInterpretCtx` sets `symbols: input.symbols` — remove that property from the returned ctx. Keep the `input.symbols` parameter (the `resolveReferencedModel` closure still captures it). Confirm no other `InterpretCtx` construction sets `symbols`. + +### T2 — Reword the "constraint" prose +In `types.ts`, the `InferAttr` doc comment uses "constraint" for a TS generic bound. Reword to a framework-neutral term (e.g. "an upper bound would reject" / "a type bound would reject") — behaviour-neutral, and honestly avoids an SQL-ish word in a framework package. + +### T3 — Re-run the ratchet; minimize any residual +Run `pnpm lint:framework-vocabulary`. After T1+T2 the count should drop by ~2–3. If it now **passes**, do nothing to the config. If a small **irreducible** residual remains (e.g. the `'../symbol-table'` module path still contributes one "table" hit that can't be removed without renaming a shared framework module — do NOT rename it), then lower the `threshold` in `scripts/lint-framework-vocabulary.config.json` by **exactly** that residual, and add a one-line justification in your report (the residual is a false positive on the framework-neutral `symbol-table` module path). Report the before/after threshold and the exact residual. Do not bump the threshold for anything you could have removed via T1/T2. + +## Completed when +- [ ] `InterpretCtx.symbols` removed (`rg "symbols" packages/1-framework/2-authoring/psl-parser/src/attribute-spec/types.ts` → zero); `buildRelationInterpretCtx` no longer sets it. +- [ ] "constraint" reworded in the `InferAttr` comment. +- [ ] `pnpm lint:framework-vocabulary` passes (with a minimal, justified threshold delta only if an irreducible residual remains). +- [ ] Gates: `pnpm --filter @prisma-next/psl-parser typecheck && test && lint`; `pnpm --filter @prisma-next/sql-contract-psl typecheck && test`; `pnpm fixtures:check`; after `pnpm --filter @prisma-next/psl-parser build`, workspace `pnpm typecheck`. + +## Constraints +No `any`; no bare `as`; no file-ext imports; no behaviour change (removing a dead field + rewording a comment must not alter parsing). Explicit-staging commit(s) with sign-off, no amend, **no push**. Read-only on `projects/**`, `spec.md`, plan files. Do NOT rename the `symbol-table` module or any shared type. Do NOT touch GitHub. + +## Operational metadata +- **Model tier:** mid. +- **Halt conditions:** `ctx.symbols` turns out to be read somewhere (then it's not dead — surface it); the ratchet can't be satisfied without renaming a shared framework module or a threshold bump larger than the irreducible residual. + +Return: confirmation `ctx.symbols` was dead + removed, the reworded comment, the final `lint:framework-vocabulary` result (and any threshold delta with justification), all gate results, and commit SHA(s). From f06dae49096c85c7ae2cbff3ae62ee430526a739 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 7 Jul 2026 14:13:56 +0000 Subject: [PATCH 33/38] chore(lint): shield framework-neutral compounds in vocabulary ratchet Add optional per-scope `allow` compounds to lint-framework-vocabulary. A forbidden-term match is ignored when its matched token range is fully contained within an allowed compound range on the same line, so `SymbolTable`/`symbol-table` stop being false-positive `table` hits while a bare `table` elsewhere still counts. Absent `allow` preserves existing behaviour. Allow `SymbolTable` in packages/1-framework and recompute the threshold to the accurate count (968 to 906; 62 shielded lines). Signed-off-by: Serhii Tatarintsev --- scripts/lint-framework-vocabulary.config.json | 3 +- scripts/lint-framework-vocabulary.mjs | 29 +++++++++++++++---- scripts/lint-framework-vocabulary.test.mjs | 22 ++++++++++++++ 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/scripts/lint-framework-vocabulary.config.json b/scripts/lint-framework-vocabulary.config.json index 2099fd461e..6c0f4bfc62 100644 --- a/scripts/lint-framework-vocabulary.config.json +++ b/scripts/lint-framework-vocabulary.config.json @@ -30,7 +30,8 @@ "foreignKey", "foreign key" ], - "threshold": 968 + "allow": ["SymbolTable"], + "threshold": 906 } ] } diff --git a/scripts/lint-framework-vocabulary.mjs b/scripts/lint-framework-vocabulary.mjs index ba63e2be7d..920aecffa8 100644 --- a/scripts/lint-framework-vocabulary.mjs +++ b/scripts/lint-framework-vocabulary.mjs @@ -66,8 +66,10 @@ function tokenMatches(lineToken, termToken) { return lineToken === termToken || lineToken === `${termToken}s`; } -// True if the term's token sequence appears as a consecutive subsequence of the line's tokens. -export function lineMatchesTermTokens(lineTokens, tt) { +// Every [start, end) token range where the term's token sequence appears as a +// consecutive subsequence of the line's tokens (end exclusive). +export function termMatchRanges(lineTokens, tt) { + const ranges = []; for (let i = 0; i + tt.length <= lineTokens.length; i++) { let ok = true; for (let j = 0; j < tt.length; j++) { @@ -76,21 +78,38 @@ export function lineMatchesTermTokens(lineTokens, tt) { break; } } - if (ok) return true; + if (ok) ranges.push([i, i + tt.length]); } - return false; + return ranges; +} + +// True if the term's token sequence appears as a consecutive subsequence of the line's tokens. +export function lineMatchesTermTokens(lineTokens, tt) { + return termMatchRanges(lineTokens, tt).length > 0; } // Distinct matching lines. Each returned entry is one line (counted once even if several terms match it). +// +// An optional `scope.allow` lists framework-neutral compound terms (e.g. `SymbolTable`). +// A forbidden-term occurrence is shielded — and does not count — when its matched token +// range is fully contained within a single allowed compound's range on the same line. This +// lets `SymbolTable`/`symbol-table` stop matching the bare `table` term while a bare `table` +// elsewhere on the line (or file) still counts. Absent/empty `allow` ⇒ unchanged behaviour. export function findMatchingLines(content, scope) { const termSeqs = scope.forbidden.map(termTokens); + const allowSeqs = (scope.allow ?? []).map(termTokens); const out = []; const lines = content.split('\n'); for (let i = 0; i < lines.length; i++) { const lineTokens = tokenize(lines[i]); + const allowRanges = allowSeqs.flatMap((tt) => termMatchRanges(lineTokens, tt)); const matched = []; for (let k = 0; k < termSeqs.length; k++) { - if (lineMatchesTermTokens(lineTokens, termSeqs[k])) matched.push(scope.forbidden[k]); + const ranges = termMatchRanges(lineTokens, termSeqs[k]); + const hasUnshielded = ranges.some( + (r) => !allowRanges.some((a) => a[0] <= r[0] && r[1] <= a[1]), + ); + if (hasUnshielded) matched.push(scope.forbidden[k]); } if (matched.length > 0) out.push({ line: i + 1, terms: matched, text: lines[i].trim() }); } diff --git a/scripts/lint-framework-vocabulary.test.mjs b/scripts/lint-framework-vocabulary.test.mjs index 13d97d0e0f..d07f693fc2 100644 --- a/scripts/lint-framework-vocabulary.test.mjs +++ b/scripts/lint-framework-vocabulary.test.mjs @@ -155,6 +155,28 @@ describe('token matcher — distinct-line counting', () => { }); }); +describe('allow shielding', () => { + it('shields a forbidden token whose range is covered by an allowed compound', () => { + const scope = { forbidden: ['table'], allow: ['SymbolTable'] }; + assert.deepEqual(findMatchingLines('const t: SymbolTable = x;\n', scope), []); + assert.deepEqual(findMatchingLines('import { X } from "./symbol-table";\n', scope), []); + }); + + it('still counts a bare forbidden token elsewhere', () => { + const scope = { forbidden: ['table'], allow: ['SymbolTable'] }; + const matches = findMatchingLines('interface Table { id: string }\n', scope); + assert.equal(matches.length, 1); + assert.deepEqual(matches[0].terms, ['table']); + }); + + it('counts a bare forbidden token on a line that also has an allowed compound', () => { + const scope = { forbidden: ['table'], allow: ['SymbolTable'] }; + const matches = findMatchingLines('const s: SymbolTable = table;\n', scope); + assert.equal(matches.length, 1); + assert.deepEqual(matches[0].terms, ['table']); + }); +}); + describe('tokenize', () => { it('splits camelCase, digit humps, and non-alphanumerics into lowercase tokens', () => { assert.deepEqual(tokenize('getNativeType()'), ['get', 'native', 'type']); From 4eaf232b44a0a0d77166f5fad613eccecdf70fde Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 7 Jul 2026 14:15:28 +0000 Subject: [PATCH 34/38] docs(typed-attribute-parsers): record D12 vocab-ratchet allowlist Signed-off-by: Serhii Tatarintsev --- .../dispatches/12-vocab-allowlist.md | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/12-vocab-allowlist.md diff --git a/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/12-vocab-allowlist.md b/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/12-vocab-allowlist.md new file mode 100644 index 0000000000..85d5d9c4e3 --- /dev/null +++ b/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/12-vocab-allowlist.md @@ -0,0 +1,45 @@ +# Brief: D12 — add a proper allowlist to the framework-vocabulary ratchet + +> On the slice-1 PR branch `tml-2956-typed-attribute-parsers`. Operator-authorised change to main's PR #918 tooling. Replaces the earlier `threshold` bump (a blunt +1) with a real allowlist so framework-neutral compounds like `SymbolTable` stop being false-positive "table" hits. Do NOT push or touch GitHub. + +## Background +`scripts/lint-framework-vocabulary.mjs` counts distinct lines in `packages/1-framework` where a forbidden term's token sequence appears (tokenizer splits camelCase + non-alphanumerics, lowercases). `SymbolTable` and the module path `symbol-table` both tokenize to `['symbol','table']`, so they match the forbidden term `table` even though `SymbolTable` is a framework-neutral PSL parser type, not SQL-family vocabulary. Today the only knob is `threshold`; an earlier dispatch bumped it `967→968` to absorb one such false positive. The operator wants a proper allowlist instead. + +## Tasks + +### T1 — Add allowlist support to the script (backward-compatible) +In `scripts/lint-framework-vocabulary.mjs`, add support for an optional per-scope `allow: string[]` of framework-neutral **compound terms**. Semantics: +- An `allow` term tokenizes the same way as a forbidden term (reuse `termTokens`). +- On each line, compute the token ranges covered by any `allow` term occurrence (consecutive-subsequence match, same matcher as forbidden). +- A forbidden-term match counts **only if its matched token range is NOT fully contained within an allowed range**. A line counts if it has ≥1 such uncovered forbidden match. +- So `allow: ["SymbolTable"]` (tokens `['symbol','table']`) shields the `table` token wherever it is immediately preceded by `symbol` (covering both the `SymbolTable` identifier and the `symbol-table` module path), while a bare `table` (real SQL vocabulary) elsewhere still counts. +- Absent/empty `allow` ⇒ current behaviour exactly (backward-compatible). Keep all existing exports and their signatures; `findMatchingLines(content, scope)` already receives `scope`, so read `scope.allow` there. + +### T2 — Add the allowlist to the config + recompute the threshold +In `scripts/lint-framework-vocabulary.config.json`: +- Add `"allow": ["SymbolTable"]` to the `packages/1-framework` scope. (Only add entries that are genuine framework-neutral false positives; `SymbolTable` is the known one. Do not allow bare forbidden terms.) +- Run `node scripts/lint-framework-vocabulary.mjs --list`, get the new accurate `count` (it will drop — every `SymbolTable`/`symbol-table` line across framework, pre-existing + ours, is now shielded), and set `"threshold"` to that new count. This **replaces** the earlier `967→968` bump with the accurate lower number. Report the old and new threshold + how many lines the allowlist shielded. + +### T3 — Extend the ratchet's own test +`scripts/lint-framework-vocabulary.test.mjs` covers the script. Add a focused test for the allow behaviour: a line containing an allowed compound (`SymbolTable` / `symbol-table`) does NOT count, while a line with the bare forbidden term (`table`) still does. Keep existing tests passing. + +## Do NOT +- Do not revert the earlier honest cleanups (the removed dead `InterpretCtx.symbols` field stays removed; the "type bound" comment reword stays). The allowlist is about the ratchet's accuracy, not undoing those. +- Do not allow-list bare forbidden terms or anything that would mask genuine family-vocabulary leakage. +- Do not rename any shared module or type. + +## Completed when +- [ ] `scripts/lint-framework-vocabulary.mjs` supports `scope.allow` with the range-shielding semantics above; absent `allow` = unchanged behaviour. +- [ ] Config has `"allow": ["SymbolTable"]` and a `threshold` recomputed to the new accurate count. +- [ ] `node scripts/lint-framework-vocabulary.test.mjs` (however the repo runs it — check `package.json` / how #918 wired it; likely `node --test` or a vitest) passes, including the new allow test. +- [ ] `pnpm lint:framework-vocabulary` passes (count === threshold). +- [ ] Gates unaffected: `pnpm --filter @prisma-next/psl-parser typecheck && test && lint`; `pnpm --filter @prisma-next/sql-contract-psl test`; `pnpm fixtures:check`; workspace `pnpm typecheck` (after psl-parser build). + +## Constraints +No `any` in the script beyond its existing style (it's plain JS/ESM — match the file's conventions). Explicit-staging commit(s) with sign-off, no amend, **no push**. Read-only on `projects/**`, `spec.md`, plan files. Do NOT touch GitHub. + +## Operational metadata +- **Model tier:** thorough (touches shared tooling + its test; correctness of the shielding logic matters). +- **Halt conditions:** the allow-shielding would mask a genuine forbidden-term line (i.e. `SymbolTable` allow accidentally hides a real `table` violation) — surface it; the ratchet's test harness can't be run/extended cleanly. + +Return: the shielding logic you implemented, the old→new threshold + shielded-line count, the new test, all gate results, and commit SHA(s). From a9fad4512a6a78ae9b54122dce2688be427178c9 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 7 Jul 2026 14:37:38 +0000 Subject: [PATCH 35/38] refactor(psl-parser): relocate attribute-spec whys to plain inline comments Move the genuine non-obvious rationale from JSDoc doc-headers onto the exact lines that implement each behaviour, in plain language, and drop headers that merely narrated or documented another symbol. The diagnosticCode why moves onto interpretAttribute's leafCtx line; oneOf's fallthrough, fieldRef's existence guard, and the InferAttr contravariance bound each carry a terse inline note. Comment-only: no code, signature, or behaviour change. Signed-off-by: Serhii Tatarintsev --- .../attribute-spec/combinators/diagnostic.ts | 5 +-- .../attribute-spec/combinators/field-ref.ts | 7 +--- .../attribute-spec/combinators/identifier.ts | 5 +-- .../src/attribute-spec/combinators/one-of.ts | 6 +--- .../src/attribute-spec/interpret.ts | 1 + .../psl-parser/src/attribute-spec/types.ts | 36 +++---------------- .../src/psl-relation-resolution.ts | 11 ++---- 7 files changed, 11 insertions(+), 60 deletions(-) diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/diagnostic.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/diagnostic.ts index 20624be111..f8b150e0c4 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/diagnostic.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/diagnostic.ts @@ -3,12 +3,9 @@ import { nodePslSpan } from '../../resolve'; import type { AstNode } from '../../syntax/ast-helpers'; import type { InterpretCtx } from '../types'; -/** - * 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 { + // every leaf routes through here, so none hard-codes a code — use the attribute's, set on ctx by interpretAttribute. code: ctx.diagnosticCode, message, sourceId: ctx.sourceId, 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 b23866121c..fc9e28116a 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 @@ -4,18 +4,12 @@ import { IdentifierAst } from '../../syntax/ast/identifier'; import type { ArgType } from '../types'; import { leafDiagnostic } from './diagnostic'; -/** Carried for the language server; the value parsed at runtime is just the name. */ export type FieldRefScope = 'self' | 'referenced'; export interface FieldRefArgType extends ArgType { readonly scope: FieldRefScope; } -/** - * 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. - */ export function fieldRef(scope: FieldRefScope): FieldRefArgType { return { kind: 'fieldRef', @@ -30,6 +24,7 @@ export function fieldRef(scope: FieldRefScope): FieldRefArgType { return notOk([leafDiagnostic(ctx, arg, 'Expected a field name')]); } const model = scope === 'self' ? ctx.selfModel : ctx.resolveReferencedModel(); + // A referenced model in another space can't be resolved here (resolveReferencedModel returns undefined); skip the existence check — it runs where that model is known. if (model !== undefined && !Object.hasOwn(model.fields, name)) { return notOk([ leafDiagnostic(ctx, arg, `Field "${name}" does not exist on model "${model.name}"`), diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/identifier.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/identifier.ts index dd9d9ebdfc..6ba07a772d 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/identifier.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/identifier.ts @@ -4,10 +4,7 @@ import { IdentifierAst } from '../../syntax/ast/identifier'; import type { ArgType } from '../types'; import { leafDiagnostic } from './diagnostic'; -/** - * Pinned-only: there is no open form, so several `identifier`s composed under - * `oneOf` infer the precise union of names. - */ +// `const N` keeps each name's literal type, so `oneOf(identifier('a'), identifier('b'))` infers `'a' | 'b'`. export function identifier(name: N): ArgType { return { kind: 'identifier', 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 9f7aeb6b61..b228b76f7d 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 @@ -4,11 +4,6 @@ import { notOk, ok, type Result } from '@prisma-next/utils/result'; import type { ArgType, OutOf } from '../types'; import { leafDiagnostic } from './diagnostic'; -/** - * 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. - */ export function oneOf, ...ArgType[]]>( ...alts: Alts ): ArgType> { @@ -28,6 +23,7 @@ export function oneOf, ...ArgType( ): Result { const diagnostics: PslDiagnostic[] = []; const code = spec.diagnosticCode ?? DEFAULT_STRUCTURAL_CODE; + // stamp the spec's code onto ctx so leaf diagnostics carry this attribute's code, not a generic one. const leafCtx: InterpretCtx = { ...ctx, diagnosticCode: code }; const attributeSpan = nodePslSpan(attrNode.syntax, ctx.sourceFile); 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 1c1dee77b1..e27ae50150 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 @@ -7,32 +7,17 @@ import type { ExpressionAst } from '../syntax/ast/expressions'; export type AttributeLevel = 'field' | 'model' | 'block'; -/** - * 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. */ readonly kind: string; readonly label: string; - /** Phantom carrier for `T`; never read at runtime. */ + // phantom carrier for `T`; never read at runtime. readonly _out?: T; + // returns diagnostics rather than pushing to a shared list, so `oneOf` can try and discard a failed branch. parse(arg: ExpressionAst, ctx: InterpretCtx): Result; } -/** - * 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; - /** - * `interpretAttribute` populates this from the active spec's `diagnosticCode` - * so a combinator emits with the attribute's code rather than a hard-coded - * generic. - */ readonly diagnosticCode: PslDiagnosticCode; readonly sourceId: string; readonly sourceFile: SourceFile; @@ -41,11 +26,8 @@ export interface InterpretCtx { readonly field?: FieldSymbol; } -/** - * Because it extends `ArgType`, the engine parses an optional parameter directly - * and detects optionality from the `optional` marker. - */ export interface OptionalArgType extends ArgType { + // the engine detects optionality by checking for this marker (`'optional' in param`). readonly optional: true; readonly hasDefault: boolean; readonly defaultValue?: T; @@ -64,10 +46,6 @@ export interface AttributeSpec { readonly positional: readonly PositionalParam[]; readonly named: Readonly>>; readonly refine?: (parsed: Out, ctx: InterpretCtx) => readonly PslDiagnostic[]; - /** - * Defaults to `PSL_INVALID_ATTRIBUTE_SYNTAX`; an attribute that must preserve a - * specific code overrides it here. - */ readonly diagnosticCode?: PslDiagnosticCode; } @@ -93,11 +71,5 @@ export type AttributeOut< Named extends Record>, > = Simplify & NamedOut>; -/** - * The parameter is intentionally unconstrained: `Out` sits contravariantly in - * `AttributeSpec.refine`, so a `refine`-carrying `AttributeSpec` is not - assignable to `AttributeSpec` and a type bound would reject every - * spec that uses a cross-argument `refine`. Inference still recovers `Out` - * precisely. - */ +// `S` is unconstrained on purpose: `refine` makes `Out` contravariant, so a bound like `S extends AttributeSpec` would reject every spec that uses `refine`. export type InferAttr = S extends AttributeSpec ? Out : never; 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 bd953ffa5b..5b484bba03 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 @@ -71,25 +71,18 @@ export function fkRelationPairKey(declaringModelName: string, targetModelName: s return `${declaringModelName}::${targetModelName}`; } -/** - * The action 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 { + // the token is already validated by the `@relation` spec's `oneOf(identifier(...))`, so this is just a lookup — no second validation path here. return REFERENTIAL_ACTION_MAP[actionToken]; } -/** - * Cross-argument rules the engine cannot express through per-argument leaves: - * `fields` and `references` are both-or-neither. - */ 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; + // `fields` and `references` must be both set or both absent — a cross-argument rule that per-argument parsing can't enforce. if (hasFields !== hasReferences) { return [ { From af8febd9118572c6ae7348fa1ee4346e7932b45e Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 7 Jul 2026 14:38:49 +0000 Subject: [PATCH 36/38] docs(typed-attribute-parsers): record D13 comment revision Signed-off-by: Serhii Tatarintsev --- .../dispatches/13-comment-revision.md | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/13-comment-revision.md diff --git a/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/13-comment-revision.md b/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/13-comment-revision.md new file mode 100644 index 0000000000..64b374a8bf --- /dev/null +++ b/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/13-comment-revision.md @@ -0,0 +1,57 @@ +# Brief: D13 — full revision of comments added in this PR + +> On the slice-1 PR branch `tml-2956-typed-attribute-parsers`. Comment-only revision — change no code/behaviour. Do NOT push or touch GitHub. + +## Operator direction (verbatim intent) +The "why" comments kept so far are written as **JSDoc doc-comments** (above the function/type), often with **opaque jargon** and sometimes explaining *another* symbol's behaviour. Do a **full revision of every comment this PR added**: +1. A genuine, non-obvious **"why" belongs as a terse inline comment at the specific line that implements that behaviour** — not as a doc-header on the whole function/type. +2. **Plain language, no unexplained jargon.** Kill "pinned-only", "open form", "Result-pure", etc. — either explain the mechanism in ordinary words or drop it. +3. A comment must not document a *different* symbol's behaviour (e.g. `oneOf`'s rationale living on `identifier`). +4. Remaining doc-headers that merely narrate/restate get removed (as in the earlier pruning pass) — this pass additionally *relocates* the survivors inline. + +Operator's three explicit complaints (address these exactly): +- `identifier.ts` — "Wtf is pinned only? Wtf is open form? Why is doc for `oneOf` in the `identifier` method?" +- `field-ref.ts` — the function doc-comment "does not belong to a doc comment". +- `one-of.ts` — "wtf is result-pure?" + +## Per-file target relocations (apply this, then sweep the rest of the added comments the same way) + +**`combinators/identifier.ts`** — delete the header doc. The only non-obvious thing is the `const N` type param; put a short inline comment on the signature line, e.g.: +`// `const N` keeps each name's literal type, so `oneOf(identifier('a'), identifier('b'))` infers `'a' | 'b'`.` + +**`combinators/field-ref.ts`** — delete the header doc; delete the `FieldRefScope` doc (its purpose is obvious from `scope === 'self' ? ctx.selfModel : ctx.resolveReferencedModel()`). Put the real why **inline at the `if (model !== undefined && …)` existence check**, plain, e.g.: +`// A cross-space target can't be resolved here (resolveReferencedModel returns undefined); skip the existence check — it runs where the target model is known.` + +**`combinators/one-of.ts`** — delete the header doc. Put a plain inline comment at the fallthrough `return notOk([… aggregate …])` (and/or the loop) explaining the mechanism without "Result-pure", e.g.: +`// Each alternative returns its own diagnostics rather than writing to a shared list, so failed attempts leave nothing behind; if none match we report a single aggregate error.` + +**`attribute-spec/types.ts`** — this file is mostly type declarations; relocate the survivors and drop the rest: +- `ArgType` header ("Parsing is pure…"): drop the header; if worth keeping, a short inline note on the `parse(...)` line — "returns diagnostics rather than pushing to a shared list, so `oneOf` can discard a failed branch". Plain. +- `kind` field: reduce to a terse inline "discriminant for print/completion dispatch" or drop. +- `_out` field: keep terse inline "phantom carrier for `T`; never read at runtime" (it explains a genuinely puzzling unused field). +- `InterpretCtx` "Deliberately lean…" header: **drop** — it defends an absence (future fields), not a why about existing code. +- `diagnosticCode` field doc: move the why to **`interpret.ts`** at the `const leafCtx = { ...ctx, diagnosticCode: code }` line — "stamp the spec's code onto ctx so leaf diagnostics carry the attribute's code, not a generic one". Leave the field itself with at most a terse note. +- `OptionalArgType` "Because it extends ArgType…" header: reduce to a terse inline note on the `optional: true` marker, or drop (the engine's `'optional' in param` check makes it clear). +- `AttributeSpec.diagnosticCode` doc ("Defaults to…"): drop or reduce to terse; the default lives in `interpret.ts` (`spec.diagnosticCode ?? DEFAULT_STRUCTURAL_CODE`). +- `InferAttr` header (the contravariance why): this **is** a why about that exact line — move it inline onto the `export type InferAttr = …` line, condensed + plain, e.g.: + `// S is unconstrained on purpose: refine makes Out contravariant, so `S extends AttributeSpec` would reject every spec that uses refine.` + +**`combinators/diagnostic.ts`** — reduce `leafDiagnostic`'s doc to a terse note (or inline at the stamping line): it stamps `ctx.diagnosticCode` so every leaf diagnostic carries the attribute's code. + +**Sweep everything else** added by this PR (`git --no-pager diff origin/main...HEAD -- 'packages/**/*.ts'`, plus `psl-relation-resolution.ts`'s `relationInvariants` / `normalizeReferentialAction` notes): apply the same rule — inline terse why at the implementing line, plain language, or remove. + +## Constraints & gotchas +- **Comment-only.** No code, signature, or behaviour change. (Cast-reason strings inside `blindCast` are code arguments, not comments — leave them.) +- **Re-run the vocabulary ratchet.** The ratchet counts *file lines* containing forbidden tokens, so editing comments can change the count. After the revision run `pnpm lint:framework-vocabulary`; if `count` changed, set `threshold` in `scripts/lint-framework-vocabulary.config.json` to the new count (the `allow: ["SymbolTable"]` entry stays). Report the old→new threshold. +- Explicit-staging commit with sign-off, no amend, **no push**. Read-only on `projects/**`, `spec.md`, plan files. Do NOT touch GitHub. + +## Completed when +- [ ] Every added comment reviewed; non-obvious whys are terse **inline** comments at their implementing line in plain language; no `pinned-only`/`open form`/`Result-pure`-style jargon remains; no comment documents another symbol's behaviour. +- [ ] `pnpm lint:framework-vocabulary` passes (threshold updated if the count moved). +- [ ] Gates (prove no code changed): `pnpm --filter @prisma-next/psl-parser typecheck && test && lint`; `pnpm --filter @prisma-next/sql-contract-psl test`; `pnpm fixtures:check`; workspace `pnpm typecheck` after psl-parser build. + +## Operational metadata +- **Model tier:** thorough (judgment per comment + placement). +- **Halt conditions:** a relocation would require a code change to make sense (surface it); the ratchet can't be satisfied by a threshold update matching the new count. + +Return a per-file summary: what moved inline (before doc → after inline, with the line it now sits on), what was dropped, the ratchet old→new threshold, gate results, and commit SHA. From 845426a37502aabde8733266b379f9fd568d6035 Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 7 Jul 2026 15:04:03 +0000 Subject: [PATCH 37/38] refactor(psl-parser): unite attribute diagnostics on a single code Remove the per-attribute diagnosticCode customization from the attribute-spec kit. AttributeSpec.diagnosticCode and InterpretCtx.diagnosticCode are deleted along with the leafCtx threading in interpretAttribute; structural and leaf diagnostics now stamp one constant, ATTRIBUTE_DIAGNOSTIC_CODE (PSL_INVALID_ATTRIBUTE_SYNTAX), and ctx is passed unchanged to leaves and refine. sqlRelation no longer sets a per-attribute code and relationInvariants + buildRelationInterpretCtx drop PSL_INVALID_RELATION_ATTRIBUTE, so @relation error paths routed through the engine now report the unified code. The interpreter.ts semantic relation checks keep their own codes and messages. Also purge comments that explain via a not-taken alternative (the const N note, the oneOf shared-list fallthrough note, the ArgType.parse shared-list note, and the leaf/ctx code-threading notes). Contract output is unchanged; only diagnostic codes in @relation error tests move to the unified code. Signed-off-by: Serhii Tatarintsev --- .../attribute-spec/combinators/diagnostic.ts | 7 ++- .../attribute-spec/combinators/identifier.ts | 1 - .../src/attribute-spec/combinators/one-of.ts | 1 - .../src/attribute-spec/field-attribute.ts | 4 +- .../src/attribute-spec/interpret.ts | 31 ++-------- .../psl-parser/src/attribute-spec/types.ts | 5 +- .../test/attribute-spec-combinators.test.ts | 19 ++---- .../psl-parser/test/attribute-spec.test.ts | 58 ++----------------- .../src/psl-relation-resolution.ts | 4 +- .../test/interpreter.relations.test.ts | 10 ++-- 10 files changed, 30 insertions(+), 110 deletions(-) diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/diagnostic.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/diagnostic.ts index f8b150e0c4..5545431bfb 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/diagnostic.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/diagnostic.ts @@ -1,12 +1,13 @@ -import type { PslDiagnostic } from '@prisma-next/framework-components/psl-ast'; +import type { PslDiagnostic, PslDiagnosticCode } from '@prisma-next/framework-components/psl-ast'; import { nodePslSpan } from '../../resolve'; import type { AstNode } from '../../syntax/ast-helpers'; import type { InterpretCtx } from '../types'; +export const ATTRIBUTE_DIAGNOSTIC_CODE: PslDiagnosticCode = 'PSL_INVALID_ATTRIBUTE_SYNTAX'; + export function leafDiagnostic(ctx: InterpretCtx, node: AstNode, message: string): PslDiagnostic { return { - // every leaf routes through here, so none hard-codes a code — use the attribute's, set on ctx by interpretAttribute. - code: ctx.diagnosticCode, + code: ATTRIBUTE_DIAGNOSTIC_CODE, message, sourceId: ctx.sourceId, span: nodePslSpan(node.syntax, ctx.sourceFile), diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/identifier.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/identifier.ts index 6ba07a772d..b3a1532099 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/identifier.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/identifier.ts @@ -4,7 +4,6 @@ import { IdentifierAst } from '../../syntax/ast/identifier'; import type { ArgType } from '../types'; import { leafDiagnostic } from './diagnostic'; -// `const N` keeps each name's literal type, so `oneOf(identifier('a'), identifier('b'))` infers `'a' | 'b'`. export function identifier(name: N): ArgType { return { kind: 'identifier', 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 b228b76f7d..803f1b85b5 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 @@ -23,7 +23,6 @@ export function oneOf, ...ArgType, ctx: InterpretCtx, ) => readonly PslDiagnostic[]; - readonly diagnosticCode?: PslDiagnosticCode; } export function fieldAttribute< @@ -24,6 +23,5 @@ export function fieldAttribute< 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 index d7a892e021..a3be92e424 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/interpret.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/interpret.ts @@ -1,26 +1,18 @@ -import type { - PslDiagnostic, - PslDiagnosticCode, - PslSpan, -} from '@prisma-next/framework-components/psl-ast'; +import type { PslDiagnostic, 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 { ATTRIBUTE_DIAGNOSTIC_CODE } from './combinators/diagnostic'; import type { ArgType, AttributeSpec, InterpretCtx, OptionalArgType, Param } from './types'; -const DEFAULT_STRUCTURAL_CODE: PslDiagnosticCode = 'PSL_INVALID_ATTRIBUTE_SYNTAX'; - export function interpretAttribute( attrNode: FieldAttributeAst | ModelAttributeAst, spec: AttributeSpec, ctx: InterpretCtx, ): Result { const diagnostics: PslDiagnostic[] = []; - const code = spec.diagnosticCode ?? DEFAULT_STRUCTURAL_CODE; - // stamp the spec's code onto ctx so leaf diagnostics carry this attribute's code, not a generic one. - const leafCtx: InterpretCtx = { ...ctx, diagnosticCode: code }; const attributeSpan = nodePslSpan(attrNode.syntax, ctx.sourceFile); const output: Record = {}; @@ -39,7 +31,6 @@ export function interpretAttribute( if (!reportedExcess) { diagnostics.push( diagnostic( - code, `Attribute "${spec.name}" received too many positional arguments`, ctx, attributeSpan, @@ -57,7 +48,6 @@ export function interpretAttribute( if (namedParam === undefined) { diagnostics.push( diagnostic( - code, `Attribute "${spec.name}" received unknown argument "${name}"`, ctx, nodePslSpan(arg.syntax, ctx.sourceFile), @@ -72,7 +62,6 @@ export function interpretAttribute( if (seen.has(key)) { diagnostics.push( diagnostic( - code, `Attribute "${spec.name}" received duplicate argument "${key}"`, ctx, nodePslSpan(arg.syntax, ctx.sourceFile), @@ -81,7 +70,7 @@ export function interpretAttribute( continue; } seen.add(key); - const result = parseArgValue(arg, param, leafCtx, diagnostics, code); + const result = parseArgValue(arg, param, ctx, diagnostics); if (result.ok) output[key] = result.value; } @@ -101,7 +90,6 @@ export function interpretAttribute( } diagnostics.push( diagnostic( - code, `Attribute "${spec.name}" is missing required argument "${key}"`, ctx, attributeSpan, @@ -126,7 +114,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, leafCtx); + const refineDiagnostics = spec.refine(value, ctx); if (refineDiagnostics.length > 0) { return notOk(refineDiagnostics); } @@ -139,12 +127,10 @@ function parseArgValue( 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), @@ -163,11 +149,6 @@ function isOptionalArgType(param: Param): param is OptionalArgType { readonly label: string; // phantom carrier for `T`; never read at runtime. readonly _out?: T; - // returns diagnostics rather than pushing to a shared list, so `oneOf` can try and discard a failed branch. parse(arg: ExpressionAst, ctx: InterpretCtx): Result; } export interface InterpretCtx { readonly level: AttributeLevel; - readonly diagnosticCode: PslDiagnosticCode; readonly sourceId: string; readonly sourceFile: SourceFile; readonly selfModel: ModelSymbol; @@ -46,7 +44,6 @@ export interface AttributeSpec { readonly positional: readonly PositionalParam[]; readonly named: Readonly>>; readonly refine?: (parsed: Out, ctx: InterpretCtx) => readonly PslDiagnostic[]; - readonly diagnosticCode?: PslDiagnosticCode; } export type OutOf

= P extends ArgType ? T : never; 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 cd452d605f..711a168b02 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,4 +1,3 @@ -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 { ArgType, InterpretCtx } from '../src/exports'; @@ -19,10 +18,7 @@ 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 { +function makeCtx(sourceFile: SourceFile): InterpretCtx { const { document, sourceFile: modelSource } = parse('model M {\n id Int @id\n}\n'); const { table } = buildSymbolTable({ document, @@ -38,7 +34,6 @@ function makeCtx( sourceFile, selfModel, resolveReferencedModel: () => undefined, - diagnosticCode, }; } @@ -139,14 +134,13 @@ describe('oneOf', () => { 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 = oneOf(identifier('Cascade'), identifier('SetNull')).parse(expr, relationCtx); + const result = oneOf(identifier('Cascade'), identifier('SetNull')).parse(expr, 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]?.code).toBe('PSL_INVALID_ATTRIBUTE_SYNTAX'); 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'); @@ -266,15 +260,14 @@ describe('list', () => { }); }); -describe('combinator code parity through interpretAttribute', () => { - it('emits a leaf diagnostic carrying the spec diagnostic code', () => { +describe('combinator code through interpretAttribute', () => { + it('emits a leaf diagnostic carrying the unified attribute 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); @@ -282,7 +275,7 @@ describe('combinator code parity through interpretAttribute', () => { 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]?.code).toBe('PSL_INVALID_ATTRIBUTE_SYNTAX'); } }); }); 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 74227790bb..2f4f9e373d 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,10 +10,7 @@ import { FieldAttributeAst } from '../src/syntax/ast/attributes'; import { StringLiteralExprAst } from '../src/syntax/ast/expressions'; import { createSyntaxTree } from '../src/syntax/red'; -function makeCtx( - sourceFile: SourceFile, - diagnosticCode: PslDiagnostic['code'] = 'PSL_INVALID_ATTRIBUTE_SYNTAX', -): InterpretCtx { +function makeCtx(sourceFile: SourceFile): InterpretCtx { const { document, sourceFile: modelSource } = parse('model M {\n id Int @id\n}\n'); const { table } = buildSymbolTable({ document, @@ -29,7 +26,6 @@ function makeCtx( sourceFile, selfModel, resolveReferencedModel: () => undefined, - diagnosticCode, }; } @@ -121,7 +117,6 @@ describe('interpretAttribute named binding', () => { 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); @@ -129,7 +124,7 @@ describe('interpretAttribute named binding', () => { 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]?.code).toBe('PSL_INVALID_ATTRIBUTE_SYNTAX'); expect(result.failure[0]?.message).toContain('foo'); expect(result.failure[0]?.span).not.toEqual(nodePslSpan(node.syntax, ctx.sourceFile)); } @@ -142,7 +137,6 @@ describe('interpretAttribute positional-or-named duplicate', () => { 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); @@ -150,7 +144,7 @@ describe('interpretAttribute positional-or-named duplicate', () => { 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]?.code).toBe('PSL_INVALID_ATTRIBUTE_SYNTAX'); expect(result.failure[0]?.span).not.toEqual(nodePslSpan(node.syntax, ctx.sourceFile)); } }); @@ -160,7 +154,6 @@ describe('interpretAttribute positional-or-named duplicate', () => { 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); @@ -168,7 +161,7 @@ describe('interpretAttribute positional-or-named duplicate', () => { 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]?.code).toBe('PSL_INVALID_ATTRIBUTE_SYNTAX'); expect(result.failure[0]?.span).not.toEqual(nodePslSpan(node.syntax, ctx.sourceFile)); } }); @@ -179,7 +172,6 @@ describe('interpretAttribute duplicate named arguments', () => { 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); @@ -187,7 +179,7 @@ describe('interpretAttribute duplicate named arguments', () => { 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]?.code).toBe('PSL_INVALID_ATTRIBUTE_SYNTAX'); expect(result.failure[0]?.span).not.toEqual(nodePslSpan(node.syntax, ctx.sourceFile)); } }); @@ -196,7 +188,6 @@ describe('interpretAttribute duplicate named arguments', () => { 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); @@ -204,7 +195,7 @@ describe('interpretAttribute duplicate named arguments', () => { 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]?.code).toBe('PSL_INVALID_ATTRIBUTE_SYNTAX'); } }); }); @@ -328,40 +319,3 @@ describe('interpretAttribute leaf purity', () => { } }); }); - -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'); - } - }); -}); 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 5b484bba03..e81d407470 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 @@ -86,7 +86,7 @@ function relationInvariants( if (hasFields !== hasReferences) { return [ { - code: 'PSL_INVALID_RELATION_ATTRIBUTE', + code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', message: `Relation field "${ctx.selfModel.name}.${ctx.field?.name ?? ''}" requires fields and references arguments`, sourceId: ctx.sourceId, span: relationAttributeSpan(ctx), @@ -123,7 +123,6 @@ const sqlRelation = fieldAttribute('relation', { ), }, refine: relationInvariants, - diagnosticCode: 'PSL_INVALID_RELATION_ATTRIBUTE', }); export type SqlRelationOutput = InferAttr; @@ -172,7 +171,6 @@ function buildRelationInterpretCtx(input: { }): InterpretCtx { return { level: 'field', - diagnosticCode: 'PSL_INVALID_RELATION_ATTRIBUTE', sourceId: input.sourceId, sourceFile: input.sourceFile, selfModel: input.selfModel, 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 cc87086e87..66376f686c 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 @@ -312,7 +312,7 @@ model Post { expect(result.failure.diagnostics).toEqual( expect.arrayContaining([ expect.objectContaining({ - code: 'PSL_INVALID_RELATION_ATTRIBUTE', + code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', sourceId: 'schema.prisma', }), ]), @@ -346,7 +346,7 @@ model Post { expect(result.failure.diagnostics).toEqual( expect.arrayContaining([ expect.objectContaining({ - code: 'PSL_INVALID_RELATION_ATTRIBUTE', + code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', message: expect.stringContaining('Field "missingUserId" does not exist on model "Post"'), }), ]), @@ -380,7 +380,7 @@ model Post { expect(result.failure.diagnostics).toEqual( expect.arrayContaining([ expect.objectContaining({ - code: 'PSL_INVALID_RELATION_ATTRIBUTE', + code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', message: expect.stringContaining('Field "missingId" does not exist on model "User"'), }), ]), @@ -414,7 +414,7 @@ model Post { expect(result.failure.diagnostics).toEqual( expect.arrayContaining([ expect.objectContaining({ - code: 'PSL_INVALID_RELATION_ATTRIBUTE', + code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', message: expect.stringContaining('Duplicate'), }), ]), @@ -448,7 +448,7 @@ model Post { expect(result.failure.diagnostics).toEqual( expect.arrayContaining([ expect.objectContaining({ - code: 'PSL_INVALID_RELATION_ATTRIBUTE', + code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', message: 'Relation field "Post.user" requires fields and references arguments', }), ]), From f73f05aca982e7e097db90291c5c263dfb339e6e Mon Sep 17 00:00:00 2001 From: Serhii Tatarintsev Date: Tue, 7 Jul 2026 15:05:45 +0000 Subject: [PATCH 38/38] docs(typed-attribute-parsers): record D14 unite diagnostic code + comment purge Signed-off-by: Serhii Tatarintsev --- .../dispatches/14-unite-diagnostic-code.md | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/14-unite-diagnostic-code.md diff --git a/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/14-unite-diagnostic-code.md b/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/14-unite-diagnostic-code.md new file mode 100644 index 0000000000..56ee9f43bf --- /dev/null +++ b/projects/typed-attribute-parsers/slices/attribute-spec-kit/dispatches/14-unite-diagnostic-code.md @@ -0,0 +1,38 @@ +# Brief: D14 — unite on a single diagnostic code + purge not-taken-alternative comments + +> On the slice-1 PR branch `tml-2956-typed-attribute-parsers`. Operator review batch. Do NOT push or touch GitHub. + +## Part A — comment purge (comment-only) +The operator wants comments that explain by **contrasting with an alternative we never wrote** removed, plus two specific ones: +- **`combinators/identifier.ts`** — remove the `const N` comment ("just explains how TS works"). +- **`combinators/one-of.ts`** — remove the inline comment on the fallthrough `return notOk(...)` ("doesn't add value on top of the code"; it also uses the "shared list" contrast). +- **Sweep every remaining comment** added by this PR (`git --no-pager diff origin/main...HEAD -- 'packages/**/*.ts'`) that explains via a not-taken alternative — phrases like "rather than pushing to a shared list", "instead of writing to a shared list/sink", "rather than a hard-coded generic". Remove them. In particular check `attribute-spec/types.ts` (the `ArgType.parse` note) and `combinators/diagnostic.ts` / `interpret.ts`. + +## Part B — unite on a single diagnostic code (code change; operator-directed) +The operator asks: "Why does `diagnosticCode` need to be customizable? Can't we just unite on a single `PSL_INVALID_ATTRIBUTE`?" The per-attribute `diagnosticCode` exists only to preserve legacy codes-parity for `@relation` (`PSL_INVALID_RELATION_ATTRIBUTE`). Remove the customization; use **one constant code** for all attribute-spec structural + leaf diagnostics. + +- Pick the single code: check `PslDiagnosticCode` (in `@prisma-next/framework-components/psl-ast`) for the best existing generic — prefer one literally meaning "invalid attribute". If a `PSL_INVALID_ATTRIBUTE` exists, use it; otherwise use the current default `PSL_INVALID_ATTRIBUTE_SYNTAX` uniformly. Note which you chose and why. +- **Remove `AttributeSpec.diagnosticCode`** (types.ts) and its usage; **remove `InterpretCtx.diagnosticCode`** and the `leafCtx = { ...ctx, diagnosticCode: code }` threading in `interpret.ts` — the engine emits structural diagnostics with the single constant directly, and passes `ctx` (unchanged) to leaves. +- **Leaves** (`leafDiagnostic` in `combinators/diagnostic.ts`) stamp the single constant instead of `ctx.diagnosticCode`. If `leafDiagnostic` no longer needs anything attribute-specific from ctx for the code, simplify accordingly. +- **SQL `sqlRelation`** (`psl-relation-resolution.ts`): remove `diagnosticCode: 'PSL_INVALID_RELATION_ATTRIBUTE'` from the spec; `relationInvariants` (the both-or-neither refine) currently hard-codes `PSL_INVALID_RELATION_ATTRIBUTE` — change it to the single unified code; `buildRelationInterpretCtx` — remove the `diagnosticCode` property. +- **Tests/fixtures:** any assertion expecting `PSL_INVALID_RELATION_ATTRIBUTE` for a `@relation` error path now expects the unified code. Update them intentionally (this is authorised — it further relaxes the codes-parity bar). Report the count of changed assertions and whether any fixture changed (contract output must NOT change — only diagnostic codes in tests). + +## Parity note +This changes `@relation` error **codes** from `PSL_INVALID_RELATION_ATTRIBUTE` to the single generic code. Contract output is unaffected. `pnpm fixtures:check` must stay clean (fixtures are valid schemas; they don't exercise these error paths). If any fixture *does* change, halt and surface. + +## Vocabulary ratchet +Removing the `diagnosticCode` field/comments involves no forbidden vocabulary, so the count likely stays 906. Re-run `pnpm lint:framework-vocabulary`; if the count moved, update `threshold` to the new count (keep `allow: ["SymbolTable"]`). + +## Completed when +- [ ] Part A: the two named comments removed; no "rather than/instead of … shared list/sink" or "rather than a hard-coded generic" comment remains (`rg -n "shared list|shared sink|hard-coded generic|rather than pushing" packages/1-framework/2-authoring/psl-parser` → zero). +- [ ] Part B: `AttributeSpec.diagnosticCode` and `InterpretCtx.diagnosticCode` gone (`rg -n "diagnosticCode" packages/1-framework/2-authoring/psl-parser/src/attribute-spec packages/2-sql/2-authoring/contract-psl/src` → zero); all attribute-spec diagnostics use one constant code; `sqlRelation` no longer sets a per-attribute code. +- [ ] Gates: `pnpm --filter @prisma-next/psl-parser typecheck && test && lint`; `pnpm --filter @prisma-next/sql-contract-psl test`; `pnpm fixtures:check` (clean); `pnpm lint:framework-vocabulary`; workspace `pnpm typecheck` after psl-parser build. + +## Constraints +No `any`; no bare `as`; no file-ext imports; tests-first where behaviour (the code emitted) changes. Explicit-staging commit(s) with sign-off, no amend, **no push**. Read-only on `projects/**`, `spec.md`, plan files. Do NOT touch GitHub. + +## Operational metadata +- **Model tier:** thorough (engine + SQL-spec + test change with a diagnostic-code semantics shift). +- **Halt conditions:** removing the per-attribute code breaks a NON-`@relation` consumer that depends on a specific code (surface it); a fixture's contract output changes; no single generic code fits cleanly (surface the `PslDiagnosticCode` options). + +Return: the single code you chose (+ why), confirmation `diagnosticCode` is fully gone, the count of updated test assertions, ratchet result, all gate results, and commit SHA(s).