From 2bb6abdc1118f39f292d38d13fa2bd58029711f0 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 28 Aug 2026 15:20:10 +0000 Subject: [PATCH 01/15] TML-3230: open the block-attributes-on-kit slice (spec, plan, trace) Signed-off-by: Steven McClankerton --- .../slices/block-attributes-on-kit/plan.md | 64 +++++++++ .../slices/block-attributes-on-kit/spec.md | 121 ++++++++++++++++++ projects/attribute-registry/trace.jsonl | 3 + 3 files changed, 188 insertions(+) create mode 100644 projects/attribute-registry/slices/block-attributes-on-kit/plan.md create mode 100644 projects/attribute-registry/slices/block-attributes-on-kit/spec.md diff --git a/projects/attribute-registry/slices/block-attributes-on-kit/plan.md b/projects/attribute-registry/slices/block-attributes-on-kit/plan.md new file mode 100644 index 000000000000..5257509b11ad --- /dev/null +++ b/projects/attribute-registry/slices/block-attributes-on-kit/plan.md @@ -0,0 +1,64 @@ +# block-attributes-on-kit — Dispatch plan + +**Spec:** [`./spec.md`](./spec.md) · **Linear:** [TML-3230](https://linear.app/prisma-company/issue/TML-3230) + +Sequence: kit (psl-parser) → node/descriptor substrate with every constructor adapted (core + fan-out) → reconstruction fills the substrate (psl-parser) → declarations + readers switch (families + postgres). Sandwich-shaped; each hand-off is a state the workspace typechecks and tests green in. + +## Dispatch plan + +### Dispatch 1: block-level-kit + +- **Outcome:** `@internal/psl-parser` ships `BlockInterpretCtx`, ctx-generic `ArgType` / `AttributeSpec` / `PositionalParam` / `Param` (defaulting to `InterpretCtx`; `parse` a property function type), ctx-generic `interpretArgs` / `interpretAttribute`, `leafDiagnostic` over the base ctx, model-free combinators returning `ArgType`, `blockAttribute()`, and `BlockAttributeSpecFactory` — all exported; a `*.test-d.ts` pins that a `fieldRef` param is rejected inside `blockAttribute` and that a block spec is accepted where an `AttributeSpec` is expected; a behavioural test interprets a `ModelAttributeAst` through a block spec with a ctx that has no `selfModel`. Every existing psl-parser, contract-psl (sql + mongo) and postgres consumer of the kit typechecks unchanged — such that *the kit's ctx requirement is expressed in types (a spec cannot demand a model it will never receive) rather than at runtime*. +- **Builds on:** spec § Chosen design 1. +- **Hands to:** the block-level spec vocabulary the descriptors (D4) type against and the reconstruction (D3) runs. +- **Focus:** `packages/1-framework/2-authoring/psl-parser` only (`src/attribute-spec/**`, `src/exports/index.ts`, `test/`). No descriptor, node, or consumer changes. +- **Gates:** `cd packages/1-framework/2-authoring/psl-parser && pnpm typecheck` (incl. test project) · `pnpm --filter @internal/psl-parser lint` · `pnpm --filter @internal/psl-parser test` · workspace `pnpm typecheck` (kit consumers) · grep: `rg 'selfModel' packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators` still names only `field-ref.ts`. + +### Dispatch 2: block-attribute-substrate + +- **Outcome:** `AuthoringPslBlockDescriptor.attributes?` (erased), `PslExtensionBlock.attributes` (required) + `PslExtensionBlockParsedAttribute`, and `PSL_EXTENSION_UNKNOWN_BLOCK_ATTRIBUTE` exist in `@internal/framework-components` and are exported; the descriptor walker accepts the key; `reconstructExtensionBlock` sets `attributes: {}`; every in-repo `PslExtensionBlock` literal (psl-infer builders, supabase script, printer/framework/family/validator tests) carries `attributes` — such that *the node is total: a consumer reads `block.attributes` without an existence check, and nothing but the generic machinery can produce a block node lacking it*. `resolveEnumCodecId` is untouched here. +- **Builds on:** none in-slice (core substrate); D1 only for the vocabulary the doc contract names. +- **Hands to:** a workspace where `attributes` is present (empty) on every block node — the surface D3 fills and D4 reads. +- **Focus:** `@internal/framework-components` (`src/shared/psl-extension-block.ts`, `src/shared/framework-authoring.ts`, exports, tests) + the mechanical fan-out to node constructors across packages. Mechanical-fan-out shape: one transformation (`attributes: {}` or the synthesised `map` entry beside the existing `blockAttributes` entry), no judgment. +- **Gates:** `pnpm build` of `framework-components` then workspace `pnpm typecheck` · `pnpm --filter @internal/framework-components lint && test` · `pnpm --filter @internal/target-postgres test` (psl-infer print tests) · `pnpm lint:deps` · grep: `rg -n 'blockAttributes:' packages --type ts` count equals `rg -n 'attributes:' … ` count at the same sites (every constructor adapted). + +### Dispatch 3: reconstruct-parses-block-attributes + +- **Outcome:** `reconstructExtensionBlock` runs the descriptor's block-attribute factories through `interpretAttribute` with a `BlockInterpretCtx`, fills `attributes`, converts kit diagnostics to `ParseDiagnostic`s (code widened to `PslDiagnostic['code']`), diagnoses unknown names with `PSL_EXTENSION_UNKNOWN_BLOCK_ATTRIBUTE` and duplicates first-wins with `PSL_INVALID_EXTENSION_BLOCK_ATTRIBUTE`; the one new `blindCast` narrows the erased factory. Tests (`symbol-table.test.ts` or a sibling file) cover: parsed value + span, unknown name, duplicate, arity/type failure surfacing as a symbol-table diagnostic, descriptor without `attributes`, descriptor `undefined` — such that *the LSP pipeline and the build see identical block-attribute diagnostics because both run `buildSymbolTable`*. +- **Builds on:** D1's kit + D2's substrate. +- **Hands to:** symbol tables whose block nodes carry parsed attributes for every declared attribute; nothing declares any yet, so every `@@` line diagnoses as unknown until D4 lands (D3 and D4 must merge together — D3 is not a shippable stop; the slice PR is the unit). +- **Focus:** `@internal/psl-parser` (`src/block-reconstruction.ts`, `src/parse.ts` code type, `src/symbol-table.ts` if `sourceId` must thread, tests). No consumer changes. +- **Gates:** psl-parser typecheck/lint/test · workspace `pnpm typecheck` (`ParseDiagnostic.code` widening) · `pnpm --filter @internal/language-server test` · grep: `rg 'blindCast' packages/1-framework/2-authoring/psl-parser/src` count = base + 1. + +### Dispatch 4: declare-and-read + +- **Outcome:** SQL and Mongo family `enum` descriptors declare `@@type`; postgres `policy_*` and `native_enum` descriptors declare `@@map` (policy with the non-empty `refine` → `PSL_POLICY_INVALID_MAP`); `resolveEnumCodecId`, `lowerRlsPolicyFromBlock`, `lowerNativeEnumFromBlock` read `block.attributes`; `PSL_NATIVE_ENUM_INVALID_MAP` removed; `@internal/family-mongo` depends on `@internal/psl-parser`; tests moved to the symbol-table stage; `rg 'blockAttributes\.find' packages test examples` → 0; `pnpm fixtures:check` clean — such that *every block attribute has exactly one parser (the kit) and consumers read data, never source text*. +- **Builds on:** D3's filled `attributes`; D1's `blockAttribute`/`str`. +- **Hands to:** slice DoD (all four slice-specific conditions). +- **Focus:** `family-sql`, `family-mongo` (`src/core/authoring-entity-types.ts`, `package.json`), `framework-components` `resolveEnumCodecId`, `target-postgres` `src/core/authoring.ts` + `test/psl-policy-map-authoring.test.ts` + `test/psl-native-enum-authoring.test.ts` + enum tests. Postgres edits stay inside the two `@@map` sites and the descriptor literals (slice A runs concurrently on `authoring.ts`'s `@@rls` region). +- **Gates:** `pnpm typecheck` · lint for the four touched packages · `pnpm --filter @internal/family-sql test`, `@internal/family-mongo`, `@internal/target-postgres`, `@internal/sql-contract-psl`, `@internal/mongo-contract-psl` · `pnpm lint:deps` · `pnpm fixtures:check` · the grep gate. + +## Handoff-contract checks + +- **Linearity:** D1 → D3 (kit), D2 → D3 (substrate), D3 → D4. D2 does not build on D1 (independent core change) — the two could land in either order; D4 reads both. +- **Completeness:** slice-DoD ← D4 (grep gate, fixtures, diagnostics through `buildSymbolTable`), D3 (the single narrow), D2/D1 (substrate + kit). Cast budget verified slice-wide at close. + +## Calibration references (thread into briefs) + +Failure modes ([`drive/calibration/failure-modes.md`](../../../../drive/calibration/failure-modes.md)): + +- **F5** — destructive git operations forbidden. +- **F3** — broken consumers of the required `attributes` field and of `ArgType.parse` discovered by `rg`, not by repeated test runs. +- **F11** — placement pinned: kit + reconstruction in `psl-parser`; descriptor/node/diagnostic-code in `framework-components`; specs declared where the descriptors live. +- **F16/F17** — property statements in every outcome above; a self-acknowledged layering comment is a HALT. Core never imports `psl-parser`; the block factory is erased in core and narrowed once. +- **F13/F15** — D3's tests must go red if reconstruction stops running the factories or stops diagnosing unknown names; D4's migrated tests must fail if the kit-parsed value is bypassed. +- **F14** — gates mirror CI (lint per package; typecheck covers `test/`); sync `origin/main` before slice-close validation and push. +- **F24/F25** — build `framework-components` before trusting downstream red. +- **F26** — any reviewer finding on a constructor site or a narrow is a class; sweep the diff. + +Grep-library ([`drive/calibration/grep-library.md`](../../../../drive/calibration/grep-library.md)): cross-cutting anti-patterns (file-extension imports, `: any`, `@ts-expect-error` outside `*.test-d.ts`); transient project refs in long-lived files; slice-specific `blockAttributes\.find` gate and the `blindCast` budget. + +## Open items + +- **For D3:** decide whether `BuildSymbolTableOptions` needs a `sourceId` for the block ctx or whether reconstruction uses a fixed placeholder — the kit's `sourceId` never reaches a `ParseDiagnostic` (range-only), so a placeholder is acceptable if no option exists. +- **For D4 / slice A coordination:** both slices edit `target-postgres/src/core/authoring.ts`; this slice touches only the six descriptor literals and the two `@@map` sites. If A merges first, rebase before slice-close validation. diff --git a/projects/attribute-registry/slices/block-attributes-on-kit/spec.md b/projects/attribute-registry/slices/block-attributes-on-kit/spec.md new file mode 100644 index 000000000000..f0a5377faa3b --- /dev/null +++ b/projects/attribute-registry/slices/block-attributes-on-kit/spec.md @@ -0,0 +1,121 @@ +# Slice: block-attributes-on-kit + +Parent project: `projects/attribute-registry/`. Outcome this slice contributes: block-level attributes (`@@type` on `enum`, `@@map` on postgres extension blocks) are declared on their block descriptors, parsed through the attribute-spec kit, and read by every consumer as plain data — the last hand-parsed attribute surface is gone. + +## At a glance + +The kit gains a block-level interpret ctx (no `selfModel`) and a `blockAttribute()` constructor; `AuthoringPslBlockDescriptor` gains `attributes`, a sibling of `parameters`; `reconstructExtensionBlock` runs the declared specs and attaches the parsed values to `PslExtensionBlock.attributes`; the three hand-parsing sites (`resolveEnumCodecId`, postgres policy `@@map`, postgres `native_enum` `@@map`) read the parsed values; unknown block-attribute names diagnose against the descriptor's `attributes` keys at symbol-table time, where the LSP already surfaces symbol-table diagnostics. + +## Chosen design + +Grounding note (per `drive/spec/README.md`): every path and line below was verified against `origin/main` at `af6042b5` on 2026-08-28; re-verify at dispatch time. + +### 1. Block-level interpret ctx + ctx-generic kit — `@internal/psl-parser` + +`src/attribute-spec/types.ts` today requires `selfModel` on `InterpretCtx` (`:20–27`), which an `enum` block cannot supply. The ctx splits into a base the block level can construct and the existing model/field ctx that extends it; the kit's types carry the ctx as a type parameter defaulting to today's shape so no existing call site changes: + +```ts +export interface BlockInterpretCtx { + readonly level: AttributeLevel; // 'block' at the block level; the union already carries it (:9) + readonly sourceId: string; + readonly sourceFile: SourceFile; +} +export interface InterpretCtx extends BlockInterpretCtx { + readonly selfModel: ModelSymbol; + resolveReferencedModel(): ModelSymbol | undefined; + readonly field?: FieldSymbol; +} +export interface ArgType { + readonly kind: string; + readonly label: string; + readonly _out?: T; + readonly parse: (arg: ExpressionAst, ctx: Ctx) => Result; +} +export interface AttributeSpec { … refine?: (parsed: Out, ctx: Ctx, attributeNode: AstNode) => … } +``` + +- `parse` becomes a property function type (today a method signature, `:16`), so the ctx parameter is checked contravariantly: an `ArgType` is usable in any spec, an `ArgType` (`fieldRef`, `entityRef`, `funcCall`) is rejected inside a block spec. Pinned in a `*.test-d.ts`. +- Combinators that never read `selfModel` (`str`, `int`, `num`, `bool`, `json`, `identifier`, `oneOf`, `list`, `record`, `optional`; verified: only `field-ref.ts:26` reads `selfModel`/`resolveReferencedModel`, `diagnostic.ts` and `func-call.ts` read `sourceId`/`sourceFile`) return `ArgType`; `list`/`record`/`optional` forward their element's ctx type. `leafDiagnostic` takes `BlockInterpretCtx`. +- `interpretArgs` / `interpretAttribute` (`interpret.ts:27,128`) become generic over `Ctx`; behaviour byte-identical. +- New `src/attribute-spec/block-attribute.ts`: `blockAttribute(name, { positional?, named?, refine? })` mirrors `modelAttribute` (`model-attribute.ts`) with `level: 'block'` and `Ctx = BlockInterpretCtx`. +- `src/attribute-spec/spec-context.ts` gains the erased block factory contract, the block-level sibling of `ModelAttributeSpecFactory` (registry-core § Chosen design 1): `export type BlockAttributeSpecFactory = () => AttributeSpec`. Nullary: the descriptor a block factory hangs on is already the scoping fact (design-decisions § 8), and no shipped block attribute reads anything else. A parameter is added additively when a factory needs one; `() => X` stays assignable to `(ctx) => X`. +- Barrel `src/exports/index.ts` exports `blockAttribute`, `BlockInterpretCtx`, `BlockAttributeSpecFactory`. + +### 2. Descriptor + node substrate — `@internal/framework-components` + +- `AuthoringPslBlockDescriptor` (`src/shared/framework-authoring.ts:432`) gains `readonly attributes?: Readonly>` — attribute name → erased `BlockAttributeSpecFactory`. Erased for the same reason as `AuthoringModelAttributeDescriptor.spec` and `AuthoringContributions.attributeSpecs`: `AttributeSpec` lives in `psl-parser`; core transits, `psl-parser` narrows. Absent means "this block declares no attributes" — every `@@` line is then unknown. +- `PslExtensionBlock` (`src/shared/psl-extension-block.ts:288`) gains `readonly attributes: Readonly>` where `PslExtensionBlockParsedAttribute = { readonly args: Readonly>; readonly span: PslSpan }` — the kit's bound output keyed by parameter key, plus the attribute's span for consumers that anchor diagnostics. `blockAttributes` stays: it is the source-shaped record the printer round-trips (`psl-printer/src/serialize-print-document.ts:166`) and the `psl-infer` builders synthesise (`postgres/src/core/psl-infer/infer-enum-blocks.ts:87`, `infer-policy-blocks.ts:128`, `extensions/supabase/scripts/generate-contract.ts:240`); those builders populate `attributes` alongside it. +- `PslDiagnosticCode` gains `PSL_EXTENSION_UNKNOWN_BLOCK_ATTRIBUTE`. +- The descriptor well-formedness walker (`isWellFormedDescriptor` `:717`, `collectPslBlockDescriptorEntries` `:1157`) accepts the optional key; the `pslBlock` types test (`test/psl-block-descriptor.types.test.ts`) pins the shape. +- `resolveEnumCodecId` (`:328–364`) reads `block.attributes['type']`, narrowing `args['codecId']` with `typeof === 'string'` (core reads data; it cannot name the spec's output type). `codecSpan` becomes the attribute span — the one anchor the parsed record carries. The unquoted-argument branch (`PSL_ENUM_MISSING_TYPE` at `:355`) is now the kit's `PSL_INVALID_ATTRIBUTE_SYNTAX` at symbol-table time; the "no members" reuse of that code (`family-sql/src/core/authoring-entity-types.ts:123`, mongo `:123`) is untouched. + +### 3. Kit parsing at reconstruction — `@internal/psl-parser` + +`reconstructExtensionBlock` (`src/block-reconstruction.ts:20`) already has the descriptor, the `ModelAttributeAst` nodes (`GenericBlockDeclarationAst.attributes()`, `syntax/ast/declarations.ts:228`), and the `SourceFile`. For each `@@` line: + +1. Name absent from `descriptor.attributes` (or descriptor has none) → push `PSL_EXTENSION_UNKNOWN_BLOCK_ATTRIBUTE` naming the keyword, block name, and attribute, anchored on the attribute; no entry in `attributes`. +2. Known → the single documented narrow in this slice turns the erased entry into `BlockAttributeSpecFactory` (same reason string family as `assemble.ts`), the factory runs, and `interpretAttribute(node, spec, { level: 'block', sourceId, sourceFile })` binds the args. Success → `attributes[name] = { args, span }`. Failure → the kit's `PslDiagnostic`s are converted to `ParseDiagnostic`s (`range` from the span's offsets via `sourceFile.positionAt`) and pushed. `ParseDiagnostic.code` (`src/parse.ts:12`) widens to `PslDiagnostic['code']` so a spec `refine` can carry a contributed code (postgres uses this for `@@map("")`); every mapper (`contract-psl/src/provider.ts:52`, mongo `provider.ts:28`, `language-server/src/diagnostic-mapping.ts:21`) already treats the code as an opaque string. +3. Duplicate `@@name` → first wins, later occurrences diagnose with the same unknown-attribute code family? No — a duplicate is `PSL_INVALID_EXTENSION_BLOCK_ATTRIBUTE` (existing code, "invalid syntax" on the second line), mirroring `PSL_EXTENSION_DUPLICATE_PARAMETER`'s first-wins rule. + +Descriptor `undefined` (unknown keyword) → `attributes` is `{}`, no attribute diagnostics; the unknown keyword is diagnosed elsewhere. `sourceId` at reconstruction is the value `BuildSymbolTableOptions` carries or `'unknown'` — verify at dispatch which; the kit's span is what the range conversion uses, `sourceId` is dropped on the way to `ParseDiagnostic`. + +### 4. Declarations + consumer migration — families and postgres + +- SQL family `enum` descriptor (`family-sql/src/core/authoring-entity-types.ts:145`) and Mongo family `enum` descriptor (`family-mongo/…:145`) declare `attributes: { type: () => enumTypeSpec }` with `enumTypeSpec = blockAttribute('type', { positional: [{ key: 'codecId', type: str() }] })`. `@internal/family-sql` already depends on `@internal/psl-parser`; `@internal/family-mongo` gains the dependency (`pnpm install`, lockfile via pnpm). +- Postgres (`target-postgres/src/core/authoring.ts`): the five `policy_*` descriptors (`:542–607`) and `native_enum` (`:619`) declare `attributes: { map: … }`. Policy `@@map` keeps its non-empty rule as a `refine` emitting `PSL_POLICY_INVALID_MAP` anchored on the attribute; `native_enum` `@@map` is `str()` only, so `PSL_NATIVE_ENUM_INVALID_MAP` (`:56`) is removed — the arity/quoting failures it covered are the kit's `PSL_INVALID_ATTRIBUTE_SYNTAX` now. `lowerRlsPolicyFromBlock` (`:273`) and `lowerNativeEnumFromBlock` (`:331`) read `block.attributes['map']?.args['name']` with a `typeof === 'string'` narrow; `unwrapQuotedString` stays for parameter values. +- Tests adapt to the new stage: `@@map()` / `@@map(foo)` are symbol-table diagnostics (`psl-policy-map-authoring.test.ts:257–300`, `psl-native-enum-authoring.test.ts:349`), `@@map("")` is `PSL_POLICY_INVALID_MAP` at symbol-table time, and the family enum tests' synthetic blocks (`family-sql/test/authoring-entity-types.enum.test.ts:42`) carry `attributes`. +- Grep gate: `rg 'blockAttributes\.find' packages` → zero. + +## Coherence rationale + +One reviewable unit because the descriptor key, the node field, the reconstruction that fills it, and the three readers are one contract: shipping the kit + substrate without the readers leaves two parsers for the same attribute (the exact drift this project exists to remove), and shipping the readers without the reconstruction has nothing to read. Every commit inside the slice is a stable state (kit → substrate with empty `attributes` → reconstruction fills it → readers switch), which is what makes it one PR rather than one dispatch. + +## Scope + +**In:** +- `@internal/psl-parser`: ctx split, ctx-generic kit types, combinator return types, `blockAttribute`, `BlockAttributeSpecFactory`, reconstruction parsing + diagnostics, `ParseDiagnostic.code` widening, exports, tests (`test/attribute-spec*.test.ts`, `test/symbol-table.test.ts`, a `*.test-d.ts` ctx-variance pin). +- `@internal/framework-components`: descriptor `attributes`, node `attributes` + `PslExtensionBlockParsedAttribute`, diagnostic code, walker, `resolveEnumCodecId`, exports, tests. +- Every in-repo `PslExtensionBlock` literal gains `attributes` (psl-infer builders, supabase script, printer/framework/family tests). +- `@internal/family-sql`, `@internal/family-mongo`: enum descriptor `attributes`; mongo gains the psl-parser dependency. +- `@internal/target-postgres`: descriptor `attributes`, two `@@map` readers, tests. + +**Out:** +- Field/model attribute registration and unknown-name diagnostics (slices `sql-attributes-registered`, `mongo-attributes-registered`). +- Block attributes entering `assembleAttributeSpecs` or `AuthoringContributions.attributeSpecs` — never (design-decisions § 8). +- LSP completion of block attributes (`completion-provider.ts:224` keeps reading `parameters` only) — Language Tools work on top of this slice. +- Printing from parsed values — the printer keeps round-tripping `blockAttributes`. +- Wiring `validateExtensionBlock` into a production path (it has no production caller today; not this slice's concern). + +## Pre-investigated edge cases + +| Edge case | Disposition | Notes | +| --------- | ----------- | ----- | +| `@@map` on a family `enum` block | Diagnoses as unknown | Grep of every `*.psl`/`*.prisma` under `packages`, `test`, `examples`: extension-ish blocks carry only `@@type` (32) and `@@map` (14, all on `native_enum`/`policy_*`); the one `enum … @@map` hit is a comment in `issues-28591-mapped-enums/_fixture/contract.prisma`. No fixture regresses. | +| `@@type` diagnostics move from interpretation to symbol-table stage | Accepted | Contract emission fails on either stage; the LSP shows both. Tests asserting the old stage move with it. | +| `codecSpan` anchor widens from the argument to the attribute | Accepted | The parsed record carries one span. Family enum tests use a zero span throughout. | + +## Slice-specific done conditions + +- [ ] `rg 'blockAttributes\.find' packages test examples` returns zero hits (project-DoD grep gate). +- [ ] `pnpm fixtures:check` clean — no emitted contract changes (project transitional-shape constraint). +- [ ] Net-new `blindCast` in this slice ≤ 1 (the reconstruction narrow of the erased block factory). +- [ ] Unknown block-attribute names diagnose with `PSL_EXTENSION_UNKNOWN_BLOCK_ATTRIBUTE` through `buildSymbolTable` — the path the LSP pipeline (`language-server/src/pipeline.ts:33`) already runs. + +## Contract impact + +No contract entities, kinds, or emitted artifacts change (`fixtures:check`). Authoring SPI in `@internal/framework-components`: `AuthoringPslBlockDescriptor.attributes` added (optional); `PslExtensionBlock.attributes` added (required — every constructor of the node adapts); one new framework diagnostic code. `@internal/psl-parser` kit: `ArgType`/`AttributeSpec` gain a defaulted `Ctx` parameter; `ArgType.parse` becomes a property; `ParseDiagnostic.code` widens. + +## Adapter impact + +**postgres** only: six block descriptors declare `@@map`, two lowering sites read parsed values, one diagnostic code removed. sqlite/mongo target packs contribute no block descriptors. + +## Open Questions + +1. Should `native_enum` `@@map("")` also be rejected? Working position: no — keep today's behaviour (an empty type name was accepted before and is not this slice's rule to add); the policy refine is a migration of an existing rule, not a new one. + +## References + +- Parent project: [`projects/attribute-registry/spec.md`](../../spec.md) § Block-level attributes; [`design-decisions.md`](../../design-decisions.md) § 8. +- Sibling slice: [`../registry-core/spec.md`](../registry-core/spec.md) § Chosen design 1 (erased factory precedent, `Out = never` rationale). +- Linear: [TML-3230](https://linear.app/prisma-company/issue/TML-3230) +- ADRs: [ADR 231](../../../../docs/architecture%20docs/adrs/ADR%20231%20-%20Declarative%20attribute%20specifications.md) diff --git a/projects/attribute-registry/trace.jsonl b/projects/attribute-registry/trace.jsonl index 11904ca55aa3..4a70d8a343d8 100644 --- a/projects/attribute-registry/trace.jsonl +++ b/projects/attribute-registry/trace.jsonl @@ -97,3 +97,6 @@ {"event_id":"b8f17bc8-e9d3-4a90-a78a-d5d125c019b4","schema_version":"1","ts":"2026-08-28T15:46:11.346Z","project_run_id":"attribute-registry","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"088d74c9-8cc7-4e68-8a3f-12fc369a72e3","round_id":"12aec78b-8e62-4c09-bd42-1600ebd15552","brief_byte_length":552,"brief_content_hash":"19d6b54d49b8ebdc5c601aa5b49196099e3c3c32412c798973bbaa40a7381efe","brief_disposition":"initial"} {"event_id":"6563175c-3c6f-4ce7-904e-3c3ae2214072","schema_version":"1","ts":"2026-08-28T16:05:42.169Z","project_run_id":"attribute-registry","orchestrator_agent_id":null,"event_type":"round-end","dispatch_id":"088d74c9-8cc7-4e68-8a3f-12fc369a72e3","round_id":"12aec78b-8e62-4c09-bd42-1600ebd15552","verdict":"satisfied","findings_filed":0,"wall_clock_ms":1173157} {"event_id":"5135962b-9914-4134-bf3b-9f9882297484","schema_version":"1","ts":"2026-08-28T16:05:43.351Z","project_run_id":"attribute-registry","orchestrator_agent_id":null,"event_type":"dispatch-end","dispatch_id":"088d74c9-8cc7-4e68-8a3f-12fc369a72e3","result":"completed","wall_clock_ms":1173157} +{"event_id":"efff0c20-b9ae-446d-a60d-4edea023b33c","schema_version":"1","ts":"2026-08-28T15:10:25.565Z","project_run_id":"attribute-registry","orchestrator_agent_id":null,"event_type":"slice-started","slice_slug":"block-attributes-on-kit","slice_index":4,"linear_ref":"TML-3230"} +{"event_id":"aacdd74c-519d-4844-a30c-f9b4fe1373e1","schema_version":"1","ts":"2026-08-28T15:19:16.351Z","project_run_id":"attribute-registry","orchestrator_agent_id":null,"event_type":"spec-authored","spec_kind":"slice","spec_path":"projects/attribute-registry/slices/block-attributes-on-kit/spec.md","byte_length":14630,"edge_cases_count":3,"open_questions_count":1,"dod_items_count":4} +{"event_id":"6be1dc47-e427-45b8-908f-f02164543997","schema_version":"1","ts":"2026-08-28T15:20:10.849Z","project_run_id":"attribute-registry","orchestrator_agent_id":null,"event_type":"plan-authored","plan_kind":"slice","plan_path":"projects/attribute-registry/slices/block-attributes-on-kit/plan.md","byte_length":9419,"dispatch_count":4,"slice_count":null,"dispatch_size_distribution":{"S":0,"M":3,"L":1,"XL":0},"open_items_count":2} \ No newline at end of file From fa35da25ab55353b967235bd92e4e3a3ee92af07 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 28 Aug 2026 15:29:21 +0000 Subject: [PATCH 02/15] TML-3230: give the attribute-spec kit a block-level interpret ctx and blockAttribute() Signed-off-by: Steven McClankerton --- .../src/attribute-spec/block-attribute.ts | 38 ++++++++++ .../src/attribute-spec/combinators/bool.ts | 4 +- .../attribute-spec/combinators/diagnostic.ts | 4 +- .../attribute-spec/combinators/identifier.ts | 4 +- .../src/attribute-spec/combinators/int.ts | 4 +- .../src/attribute-spec/combinators/json.ts | 4 +- .../src/attribute-spec/combinators/list.ts | 7 +- .../src/attribute-spec/combinators/num.ts | 8 +-- .../src/attribute-spec/combinators/one-of.ts | 11 ++- .../src/attribute-spec/combinators/record.ts | 6 +- .../src/attribute-spec/combinators/str.ts | 8 +-- .../src/attribute-spec/interpret.ts | 38 +++++----- .../psl-parser/src/attribute-spec/optional.ts | 7 +- .../src/attribute-spec/spec-context.ts | 4 +- .../psl-parser/src/attribute-spec/types.ts | 62 ++++++++-------- .../psl-parser/src/exports/index.ts | 3 + .../test/attribute-spec-block.test-d.ts | 58 +++++++++++++++ .../test/attribute-spec-block.test.ts | 71 +++++++++++++++++++ .../test/attribute-spec-combinators.test-d.ts | 8 +-- 19 files changed, 270 insertions(+), 79 deletions(-) create mode 100644 packages/1-framework/2-authoring/psl-parser/src/attribute-spec/block-attribute.ts create mode 100644 packages/1-framework/2-authoring/psl-parser/test/attribute-spec-block.test-d.ts create mode 100644 packages/1-framework/2-authoring/psl-parser/test/attribute-spec-block.test.ts diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/block-attribute.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/block-attribute.ts new file mode 100644 index 000000000000..48d9b607fc85 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/block-attribute.ts @@ -0,0 +1,38 @@ +import type { PslDiagnostic } from '@internal/framework-components/psl-ast'; +import type { AstNode } from '../syntax/ast-helpers'; +import type { + AttributeOut, + AttributeSpec, + BlockInterpretCtx, + Param, + PositionalParam, +} from './types'; + +interface BlockAttributeConfig< + Pos extends readonly PositionalParam[], + Named extends Record>, +> { + readonly positional?: Pos; + readonly named?: Named; + readonly refine?: ( + parsed: AttributeOut, + ctx: BlockInterpretCtx, + attributeNode: AstNode, + ) => readonly PslDiagnostic[]; +} + +export function blockAttribute< + const Pos extends readonly PositionalParam[] = readonly [], + const Named extends Record> = Record, +>( + name: string, + config: BlockAttributeConfig, +): AttributeSpec, BlockInterpretCtx> { + return { + level: 'block', + name, + positional: config.positional ?? [], + named: config.named ?? {}, + ...(config.refine !== undefined ? { refine: config.refine } : {}), + }; +} diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/bool.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/bool.ts index d75c252ce85a..13c3bab6adb0 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/bool.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/bool.ts @@ -1,10 +1,10 @@ import type { PslDiagnostic } from '@internal/framework-components/psl-ast'; import { notOk, ok, type Result } from '@internal/utils/result'; import { BooleanLiteralExprAst } from '../../syntax/ast/expressions'; -import type { ArgType } from '../types'; +import type { ArgType, BlockInterpretCtx } from '../types'; import { leafDiagnostic } from './diagnostic'; -export function bool(): ArgType { +export function bool(): ArgType { return { kind: 'bool', label: 'boolean', 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 42f24bedae9d..3ce2433ecdb4 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,12 @@ import type { PslDiagnostic, PslDiagnosticCode } from '@internal/framework-components/psl-ast'; import { nodePslSpan } from '../../resolve'; import type { AstNode } from '../../syntax/ast-helpers'; -import type { InterpretCtx } from '../types'; +import type { BlockInterpretCtx } from '../types'; export const ATTRIBUTE_DIAGNOSTIC_CODE: PslDiagnosticCode = 'PSL_INVALID_ATTRIBUTE_SYNTAX'; export function leafDiagnostic( - ctx: InterpretCtx, + ctx: BlockInterpretCtx, node: AstNode, message: string, code: PslDiagnostic['code'] = ATTRIBUTE_DIAGNOSTIC_CODE, 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 61a9ec59c880..953c0728ed61 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/identifier.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/identifier.ts @@ -1,10 +1,10 @@ import type { PslDiagnostic } from '@internal/framework-components/psl-ast'; import { notOk, ok, type Result } from '@internal/utils/result'; import { IdentifierAst } from '../../syntax/ast/identifier'; -import type { ArgType } from '../types'; +import type { ArgType, BlockInterpretCtx } from '../types'; import { leafDiagnostic } from './diagnostic'; -export function identifier(name: N): ArgType { +export function identifier(name: N): ArgType { return { kind: 'identifier', label: name, diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/int.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/int.ts index c202d4f44aec..8f0a6927ad25 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/int.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/int.ts @@ -1,12 +1,12 @@ import type { PslDiagnostic } from '@internal/framework-components/psl-ast'; import { notOk, ok, type Result } from '@internal/utils/result'; import { NumberLiteralExprAst } from '../../syntax/ast/expressions'; -import type { ArgType } from '../types'; +import type { ArgType, BlockInterpretCtx } from '../types'; import { leafDiagnostic } from './diagnostic'; // An integer literal reduced to its numeric value. Passing `min`/`max` additionally rejects // out-of-range integers with a distinct range message, leaving the integer-only check intact. -export function int(opts?: { min?: number; max?: number }): ArgType { +export function int(opts?: { min?: number; max?: number }): ArgType { const min = opts?.min; const max = opts?.max; return { diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/json.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/json.ts index 607896c128de..06873104bf89 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/json.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/json.ts @@ -2,7 +2,7 @@ import type { PslDiagnostic } from '@internal/framework-components/psl-ast'; import { blindCast } from '@internal/utils/casts'; import { notOk, ok, type Result } from '@internal/utils/result'; import { StringLiteralExprAst } from '../../syntax/ast/expressions'; -import type { ArgType } from '../types'; +import type { ArgType, BlockInterpretCtx } from '../types'; import { leafDiagnostic } from './diagnostic'; /** @@ -10,7 +10,7 @@ import { leafDiagnostic } from './diagnostic'; * exception (e.g. an index `filter` / `weights` argument). The string is decoded by the parser, * then JSON-parsed; a non-object (array/scalar) or invalid JSON is a diagnostic. */ -export function json(): ArgType> { +export function json(): ArgType, BlockInterpretCtx> { return { kind: 'json', label: 'JSON object', 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 8f58a16f158b..014dd84d2601 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/list.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/list.ts @@ -1,7 +1,7 @@ import type { PslDiagnostic } from '@internal/framework-components/psl-ast'; import { notOk, ok, type Result } from '@internal/utils/result'; import { ArrayLiteralAst, type ExpressionAst } from '../../syntax/ast/expressions'; -import type { ArgType } from '../types'; +import type { ArgType, BlockInterpretCtx } from '../types'; import { leafDiagnostic } from './diagnostic'; export interface ListOptions { @@ -9,7 +9,10 @@ export interface ListOptions { readonly unique?: boolean; } -export function list(of: ArgType, opts?: ListOptions): ArgType { +export function list( + of: ArgType, + opts?: ListOptions, +): ArgType { return { kind: 'list', label: `${of.label}[]`, diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/num.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/num.ts index 8a29262936ca..6625d3f4baeb 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/num.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/num.ts @@ -1,13 +1,13 @@ import type { PslDiagnostic } from '@internal/framework-components/psl-ast'; import { notOk, ok, type Result } from '@internal/utils/result'; import { NumberLiteralExprAst } from '../../syntax/ast/expressions'; -import type { ArgType } from '../types'; +import type { ArgType, BlockInterpretCtx } from '../types'; import { leafDiagnostic } from './diagnostic'; /** The pinned form retains its value as the output literal type. */ -export function num(): ArgType; -export function num(value: T): ArgType; -export function num(value?: T): ArgType { +export function num(): ArgType; +export function num(value: T): ArgType; +export function num(value?: T): ArgType { return { kind: 'num', label: value === undefined ? 'number' : String(value), diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/one-of.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/one-of.ts index 7c524e846324..4c4c417f3c49 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/one-of.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/one-of.ts @@ -1,9 +1,18 @@ import type { PslDiagnostic } from '@internal/framework-components/psl-ast'; import { blindCast } from '@internal/utils/casts'; import { notOk, ok, type Result } from '@internal/utils/result'; -import type { ArgType, OutOf } from '../types'; +import type { ArgType, BlockInterpretCtx, OutOf } from '../types'; import { leafDiagnostic } from './diagnostic'; +export function oneOf< + Alts extends readonly [ + ArgType, + ...ArgType[], + ], +>(...alts: Alts): ArgType, BlockInterpretCtx>; +export function oneOf, ...ArgType[]]>( + ...alts: Alts +): ArgType>; export function oneOf, ...ArgType[]]>( ...alts: Alts ): ArgType> { diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/record.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/record.ts index a291782f05df..dd1f7c9ee14b 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/record.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/record.ts @@ -1,10 +1,12 @@ import type { PslDiagnostic } from '@internal/framework-components/psl-ast'; import { notOk, ok, type Result } from '@internal/utils/result'; import { ObjectLiteralExprAst } from '../../syntax/ast/expressions'; -import type { ArgType } from '../types'; +import type { ArgType, BlockInterpretCtx } from '../types'; import { leafDiagnostic } from './diagnostic'; -export function record(of: ArgType): ArgType> { +export function record( + of: ArgType, +): ArgType, Ctx> { return { kind: 'record', label: `{ [key]: ${of.label} }`, 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 1472f511c2bd..7d48ff70b5c2 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 @@ -1,13 +1,13 @@ import type { PslDiagnostic } from '@internal/framework-components/psl-ast'; import { notOk, ok, type Result } from '@internal/utils/result'; import { StringLiteralExprAst } from '../../syntax/ast/expressions'; -import type { ArgType } from '../types'; +import type { ArgType, BlockInterpretCtx } from '../types'; import { leafDiagnostic } from './diagnostic'; /** The pinned form retains its value as the output literal type. */ -export function str(): ArgType; -export function str(value: T): ArgType; -export function str(value?: T): ArgType { +export function str(): ArgType; +export function str(value: T): ArgType; +export function str(value?: T): ArgType { return { kind: 'str', label: value === undefined ? 'string' : JSON.stringify(value), 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 1ef5d7e70467..28526c1a4f61 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 { ATTRIBUTE_DIAGNOSTIC_CODE } from './combinators/diagnostic'; import type { ArgType, AttributeSpec, - InterpretCtx, + BlockInterpretCtx, OptionalArgType, Param, PositionalParam, @@ -17,16 +17,16 @@ import type { // The positional/named argument-binding for an attribute or a function call. `name` labels the // callee in binding diagnostics (`Attribute "" …`); `span` anchors the arity diagnostics // (too-many / missing) that have no per-argument node to point at. -export interface ArgBindingSpec { +export interface ArgBindingSpec { readonly name: string; - readonly positional: readonly PositionalParam[]; - readonly named: Readonly>>; + readonly positional: readonly PositionalParam[]; + readonly named: Readonly>>; } -export function interpretArgs( +export function interpretArgs( args: Iterable, - spec: ArgBindingSpec, - ctx: InterpretCtx, + spec: ArgBindingSpec, + ctx: Ctx, span: PslSpan, ): Result, readonly PslDiagnostic[]> { const diagnostics: PslDiagnostic[] = []; @@ -40,7 +40,7 @@ export function interpretArgs( const name = arg.name()?.name(); let key: string; - let param: Param; + let param: Param; if (name === undefined) { const posParam = spec.positional[positionalSlot]; if (posParam === undefined) { @@ -93,8 +93,8 @@ export function interpretArgs( const finalized = new Set(); const finalizeAbsentKey = ( key: string, - positionalParam: Param | undefined, - namedParam: Param | undefined, + positionalParam: Param | undefined, + namedParam: Param | undefined, ): void => { if (finalized.has(key) || seen.has(key)) return; finalized.add(key); @@ -123,10 +123,10 @@ export function interpretArgs( return ok(output); } -export function interpretAttribute( +export function interpretAttribute( attrNode: FieldAttributeAst | ModelAttributeAst, - spec: AttributeSpec, - ctx: InterpretCtx, + spec: AttributeSpec, + ctx: Ctx, ): Result { const attributeSpan = nodePslSpan(attrNode.syntax, ctx.sourceFile); const bound = interpretArgs(attrNode.argList()?.args() ?? [], spec, ctx, attributeSpan); @@ -145,10 +145,10 @@ export function interpretAttribute( return ok(value); } -function parseArgValue( +function parseArgValue( arg: AttributeArgAst, - argType: ArgType, - ctx: InterpretCtx, + argType: ArgType, + ctx: Ctx, diagnostics: PslDiagnostic[], ): Result { const value = arg.value(); @@ -168,10 +168,12 @@ function parseArgValue( return result; } -function isOptionalArgType(param: Param): param is OptionalArgType { +function isOptionalArgType( + param: Param, +): param is OptionalArgType { return 'optional' in param && param.optional === true; } -function diagnostic(message: string, ctx: InterpretCtx, span: PslSpan): PslDiagnostic { +function diagnostic(message: string, ctx: BlockInterpretCtx, span: PslSpan): PslDiagnostic { return { code: ATTRIBUTE_DIAGNOSTIC_CODE, message, sourceId: ctx.sourceId, span }; } 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 8744bd3e9cd1..1470d28895c3 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,6 +1,9 @@ -import type { ArgType, OptionalArgType } from './types'; +import type { ArgType, BlockInterpretCtx, OptionalArgType } from './types'; -export function optional(type: ArgType, ...rest: [defaultValue: T] | []): OptionalArgType { +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/spec-context.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/spec-context.ts index 101a7b1eda7a..6112fd02893c 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/spec-context.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/spec-context.ts @@ -1,6 +1,6 @@ import type { ControlMutationDefaultRegistry } from '@internal/framework-components/control'; import type { FieldSymbol, ModelSymbol, SymbolTable } from '../symbol-table'; -import type { AttributeSpec } from './types'; +import type { AttributeSpec, BlockInterpretCtx } from './types'; export interface AttributeSpecContext { readonly symbols: SymbolTable; @@ -20,3 +20,5 @@ export interface AttributeSpecNamespace { readonly model: Readonly>; readonly field: Readonly>; } + +export type BlockAttributeSpecFactory = () => AttributeSpec; 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 cb94d7ef907f..32588c8b8ada 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,75 +8,75 @@ import type { AstNode } from '../syntax/ast-helpers'; export type AttributeLevel = 'field' | 'model' | 'block'; -export interface ArgType { - readonly kind: string; - readonly label: string; - // phantom carrier for `T`; never read at runtime. - readonly _out?: T; - parse(arg: ExpressionAst, ctx: InterpretCtx): Result; -} - -export interface InterpretCtx { +export interface BlockInterpretCtx { readonly level: AttributeLevel; readonly sourceId: string; readonly sourceFile: SourceFile; +} + +export interface InterpretCtx extends BlockInterpretCtx { readonly selfModel: ModelSymbol; resolveReferencedModel(): ModelSymbol | undefined; readonly field?: FieldSymbol; } -export interface OptionalArgType extends ArgType { +export interface ArgType { + readonly kind: string; + readonly label: string; + // phantom carrier for `T`; never read at runtime. + readonly _out?: T; + readonly parse: (arg: ExpressionAst, ctx: Ctx) => Result; +} + +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; } -export type Param = ArgType; +export type Param = ArgType; -export interface PositionalParam { +export interface PositionalParam { readonly key: string; - readonly type: Param; + readonly type: Param; } -export interface AttributeSpec { +export interface AttributeSpec { readonly level: AttributeLevel; readonly name: string; - readonly positional: readonly PositionalParam[]; - readonly named: Readonly>>; + readonly positional: readonly PositionalParam[]; + readonly named: Readonly>>; /** * Cross-argument validation after all arguments parse. `attributeNode` is * the attribute's own AST node so refines can span-anchor their * diagnostics at the attribute rather than the enclosing model. */ - readonly refine?: ( - parsed: Out, - ctx: InterpretCtx, - attributeNode: AstNode, - ) => readonly PslDiagnostic[]; + readonly refine?: (parsed: Out, ctx: Ctx, attributeNode: AstNode) => readonly PslDiagnostic[]; } -export type OutOf

= 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 OptionalArgType ? never : K]: OutOf } & { - [K in keyof N as N[K] extends OptionalArgType ? K : never]?: OutOf; +export type NamedOut>> = Simplify< + { [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 OptionalArgType +type PosEntryObject> = + E['type'] extends OptionalArgType ? { [K in E['key']]?: OutOf } : { [K in E['key']]: OutOf }; -export type PosOut = Simplify< +export type PosOut[]> = Simplify< UnionToIntersection<{ [I in keyof Pos]: PosEntryObject }[number]> >; export type AttributeOut< - Pos extends readonly PositionalParam[], - Named extends Record>, + Pos extends readonly PositionalParam[], + Named extends Record>, > = Simplify & NamedOut>; // `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; +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 eb7b2ad33927..3cc1e2badadb 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 @@ -38,6 +38,7 @@ export { export { getPositionalArgument, parseQuotedStringLiteral } from '../attribute-helpers'; export type { AssembledAttributeSpecs } from '../attribute-spec/assemble'; export { assembleAttributeSpecs } from '../attribute-spec/assemble'; +export { blockAttribute } from '../attribute-spec/block-attribute'; export { bool } from '../attribute-spec/combinators/bool'; export { leafDiagnostic } from '../attribute-spec/combinators/diagnostic'; export { entityRef } from '../attribute-spec/combinators/entity-ref'; @@ -62,6 +63,7 @@ export { optional } from '../attribute-spec/optional'; export type { AttributeSpecContext, AttributeSpecNamespace, + BlockAttributeSpecFactory, FieldAttributeSpecContext, FieldAttributeSpecFactory, ModelAttributeSpecFactory, @@ -71,6 +73,7 @@ export type { AttributeLevel, AttributeOut, AttributeSpec, + BlockInterpretCtx, InferAttr, InterpretCtx, NamedOut, diff --git a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-block.test-d.ts b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-block.test-d.ts new file mode 100644 index 000000000000..2a69e3275774 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-block.test-d.ts @@ -0,0 +1,58 @@ +import { expectTypeOf, test } from 'vitest'; +import type { + ArgType, + AttributeSpec, + BlockAttributeSpecFactory, + BlockInterpretCtx, + InferAttr, + InterpretCtx, +} from '../src/exports'; +import { blockAttribute, fieldRef, list, modelAttribute, optional, str } from '../src/exports'; + +test('blockAttribute infers its output like modelAttribute', () => { + const blockSpec = blockAttribute('map', { + positional: [{ key: 'name', type: str() }], + named: { schema: optional(str()) }, + }); + const modelSpec = modelAttribute('map', { + positional: [{ key: 'name', type: str() }], + named: { schema: optional(str()) }, + }); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf<{ + name: string; + readonly schema?: string; + }>(); +}); + +test('a model-free combinator parses over the block ctx', () => { + expectTypeOf(str()).toEqualTypeOf>(); + expectTypeOf(list(str())).toEqualTypeOf>(); +}); + +test('a combinator that reads the model is rejected inside a block spec', () => { + blockAttribute('bad', { + // @ts-expect-error fieldRef needs selfModel, which a block never has + positional: [{ key: 'field', type: fieldRef('self') }], + }); +}); + +test('a block spec is accepted where a model-level spec is expected', () => { + const blockSpec = blockAttribute('map', { positional: [{ key: 'name', type: str() }] }); + expectTypeOf(blockSpec).toMatchTypeOf>(); +}); + +test('a model-level spec is rejected where a block spec is expected', () => { + const modelSpec = modelAttribute('map', { + positional: [{ key: 'field', type: fieldRef('self') }], + }); + expectTypeOf(modelSpec).not.toMatchTypeOf>(); +}); + +test('a nullary factory over a block spec satisfies BlockAttributeSpecFactory', () => { + const factory = () => blockAttribute('map', { positional: [{ key: 'name', type: str() }] }); + expectTypeOf(factory).toMatchTypeOf(); + const modelFactory = () => + modelAttribute('map', { positional: [{ key: 'field', type: fieldRef('self') }] }); + expectTypeOf(modelFactory).not.toMatchTypeOf(); +}); diff --git a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-block.test.ts b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-block.test.ts new file mode 100644 index 000000000000..29cbe9ce812e --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-block.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import type { BlockInterpretCtx } from '../src/exports'; +import { blockAttribute, interpretAttribute, leafDiagnostic, str } from '../src/exports'; +import { Cursor, parseAttribute } from '../src/parse'; +import { ModelAttributeAst } from '../src/syntax/ast/attributes'; +import { createSyntaxTree } from '../src/syntax/red'; + +function blockAttr(source: string): { node: ModelAttributeAst; ctx: BlockInterpretCtx } { + const cursor = new Cursor(source); + const node = ModelAttributeAst.cast(createSyntaxTree(parseAttribute(cursor))); + if (!node) throw new Error('expected a block attribute'); + return { + node, + ctx: { level: 'block', sourceId: 'schema.prisma', sourceFile: cursor.sourceFile }, + }; +} + +describe('blockAttribute', () => { + it('builds a block-level spec', () => { + const spec = blockAttribute('type', { positional: [{ key: 'codecId', type: str() }] }); + + expect(spec).toMatchObject({ level: 'block', name: 'type', named: {} }); + expect(spec.positional.map((param) => param.key)).toEqual(['codecId']); + }); + + it('interprets a @@ attribute with a ctx that has no model', () => { + const { node, ctx } = blockAttr('@@type("pg/text@1")'); + const spec = blockAttribute('type', { positional: [{ key: 'codecId', type: str() }] }); + + const result = interpretAttribute(node, spec, ctx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toEqual({ codecId: 'pg/text@1' }); + }); + + it('reports a missing argument anchored on the attribute', () => { + const { node, ctx } = blockAttr('@@map()'); + const spec = blockAttribute('map', { positional: [{ key: 'name', type: str() }] }); + + const result = interpretAttribute(node, spec, ctx); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure).toEqual([ + expect.objectContaining({ + code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', + message: 'Attribute "map" is missing required argument "name"', + sourceId: 'schema.prisma', + }), + ]); + }); + + it('runs refine over the block ctx', () => { + const { node, ctx } = blockAttr('@@map("")'); + const spec = blockAttribute('map', { + positional: [{ key: 'name', type: str() }], + refine: (parsed, refineCtx, attributeNode) => + parsed.name === '' + ? [leafDiagnostic(refineCtx, attributeNode, 'empty name', 'PSL_MAP_EMPTY')] + : [], + }); + + const result = interpretAttribute(node, spec, ctx); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.failure).toEqual([ + expect.objectContaining({ code: 'PSL_MAP_EMPTY', message: 'empty name' }), + ]); + }); +}); 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 ab4483d50e9c..7c9b622015c0 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,14 +1,14 @@ import { expectTypeOf, test } from 'vitest'; -import type { ArgType, OutOf } from '../src/exports'; +import type { ArgType, BlockInterpretCtx, OutOf } from '../src/exports'; import { identifier, list, num, oneOf, str } from '../src/exports'; test('identifier pins its name as the output literal type', () => { - expectTypeOf(identifier('NoAction')).toEqualTypeOf>(); + expectTypeOf(identifier('NoAction')).toEqualTypeOf>(); }); test('oneOf infers the union of its alternatives output types', () => { expectTypeOf(oneOf(identifier('NoAction'), identifier('Cascade'))).toEqualTypeOf< - ArgType<'NoAction' | 'Cascade'> + ArgType<'NoAction' | 'Cascade', BlockInterpretCtx> >(); }); @@ -36,5 +36,5 @@ test('oneOf with no alternatives is a compile error', () => { }); test('list infers an array of its element type', () => { - expectTypeOf(list(str())).toEqualTypeOf>(); + expectTypeOf(list(str())).toEqualTypeOf>(); }); From bf7218f6edd2adef7ee126e7340c347213997f3e Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 28 Aug 2026 15:47:19 +0000 Subject: [PATCH 03/15] TML-3230: carry parsed block attributes on the extension-block node and its descriptor Signed-off-by: Steven McClankerton --- .../src/control/psl-ast.ts | 1 + .../src/exports/authoring.ts | 1 + .../src/shared/framework-authoring.ts | 8 +++- .../src/shared/psl-extension-block.ts | 7 ++++ .../test/control-stack.test.ts | 42 +++++++++++++++++++ .../framework-components.authoring.test.ts | 1 + .../framework-components/test/psl-ast.test.ts | 10 ++++- .../test/psl-block-descriptor.types.test.ts | 29 +++++++++++++ .../psl-extension-block-validator.test.ts | 5 +++ .../psl-parser/src/block-reconstruction.ts | 1 + .../generic-extension-block-printer.test.ts | 17 ++++++++ ...rint-psl.duplicate-namespace-names.test.ts | 1 + .../test/authoring-entity-types.enum.test.ts | 1 + .../test/authoring-entity-types.enum.test.ts | 1 + .../supabase/scripts/generate-contract.ts | 1 + .../src/core/psl-infer/infer-enum-blocks.ts | 2 + .../src/core/psl-infer/infer-policy-blocks.ts | 1 + .../print-psl.top-level-blocks.test.ts | 1 + .../dispatches/01-block-level-kit.md | 26 ++++++++++++ .../02-block-attribute-substrate.md | 23 ++++++++++ projects/attribute-registry/trace.jsonl | 9 +++- 21 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 projects/attribute-registry/slices/block-attributes-on-kit/dispatches/01-block-level-kit.md create mode 100644 projects/attribute-registry/slices/block-attributes-on-kit/dispatches/02-block-attribute-substrate.md diff --git a/packages/1-framework/1-core/framework-components/src/control/psl-ast.ts b/packages/1-framework/1-core/framework-components/src/control/psl-ast.ts index 590bfd7c8d50..c23fc3064c35 100644 --- a/packages/1-framework/1-core/framework-components/src/control/psl-ast.ts +++ b/packages/1-framework/1-core/framework-components/src/control/psl-ast.ts @@ -16,6 +16,7 @@ export type { PslExtensionBlockParamRef, PslExtensionBlockParamScalarValue, PslExtensionBlockParamValue, + PslExtensionBlockParsedAttribute, PslPosition, PslSpan, } from '../shared/psl-extension-block'; diff --git a/packages/1-framework/1-core/framework-components/src/exports/authoring.ts b/packages/1-framework/1-core/framework-components/src/exports/authoring.ts index aea9eb7e79fe..d70efe0566e4 100644 --- a/packages/1-framework/1-core/framework-components/src/exports/authoring.ts +++ b/packages/1-framework/1-core/framework-components/src/exports/authoring.ts @@ -63,4 +63,5 @@ export type { PslExtensionBlockParamRef, PslExtensionBlockParamScalarValue, PslExtensionBlockParamValue, + PslExtensionBlockParsedAttribute, } from '../shared/psl-extension-block'; diff --git a/packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts b/packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts index 1cb868416f7b..86dd1adb35d3 100644 --- a/packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts +++ b/packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts @@ -464,6 +464,7 @@ export interface AuthoringPslBlockDescriptor { readonly parameter: string; readonly attribute: string; }; + readonly attributes?: Readonly>; } export type AuthoringPslBlockDescriptorNamespace = { @@ -735,7 +736,12 @@ function isWellFormedDescriptor(value: unknown, descriptorKind: string): boolean if (!('required' in name) || typeof name.required !== 'boolean') return false; if (!('parameters' in value)) return false; const parameters = value.parameters; - return typeof parameters === 'object' && parameters !== null && !Array.isArray(parameters); + if (typeof parameters !== 'object' || parameters === null || Array.isArray(parameters)) { + return false; + } + if (!('attributes' in value) || value.attributes === undefined) return true; + const attributes = value.attributes; + return typeof attributes === 'object' && attributes !== null && !Array.isArray(attributes); } case 'modelAttribute': { if ( diff --git a/packages/1-framework/1-core/framework-components/src/shared/psl-extension-block.ts b/packages/1-framework/1-core/framework-components/src/shared/psl-extension-block.ts index 3da366ac3b0c..b67d7e631c33 100644 --- a/packages/1-framework/1-core/framework-components/src/shared/psl-extension-block.ts +++ b/packages/1-framework/1-core/framework-components/src/shared/psl-extension-block.ts @@ -111,6 +111,7 @@ export type PslDiagnosticCode = * A `@@`-prefixed block-attribute line inside an extension block has invalid syntax. */ | 'PSL_INVALID_EXTENSION_BLOCK_ATTRIBUTE' + | 'PSL_EXTENSION_UNKNOWN_BLOCK_ATTRIBUTE' /** * Duplicate scopes are top level, namespace body, or block fields; diagnostics * are first-wins and anchored on later name spans. @@ -251,6 +252,11 @@ export interface PslExtensionBlockAttribute { readonly span: PslSpan; } +export interface PslExtensionBlockParsedAttribute { + readonly args: Readonly>; + readonly span: PslSpan; +} + /** * Base shape for a uniform extension-contributed top-level PSL block * node, as produced by the generic framework parser and consumed by the @@ -294,5 +300,6 @@ export interface PslExtensionBlock { readonly name: string; readonly parameters: Record; readonly blockAttributes: readonly PslExtensionBlockAttribute[]; + readonly attributes: Readonly>; readonly span: PslSpan; } diff --git a/packages/1-framework/1-core/framework-components/test/control-stack.test.ts b/packages/1-framework/1-core/framework-components/test/control-stack.test.ts index 106365c8e0ab..156629582f99 100644 --- a/packages/1-framework/1-core/framework-components/test/control-stack.test.ts +++ b/packages/1-framework/1-core/framework-components/test/control-stack.test.ts @@ -722,6 +722,48 @@ describe('assembleAuthoringContributions', () => { ).toThrow(/Malformed authoring pslBlock contribution at "broken"/); }); + it('keeps a pslBlockDescriptors entry that declares block attributes', () => { + const mapFactory = () => ({ level: 'block', name: 'map' }); + const result = assembleAuthoringContributions([ + createDescriptor({ + authoring: { + entityTypes: { + foo: { kind: 'entity', discriminator: 'fake-foo', output: { factory: () => ({}) } }, + }, + pslBlockDescriptors: { + fooBlock: { + ...makeDeclarativePslBlockDescriptor('fake-foo'), + attributes: { map: mapFactory }, + }, + }, + }, + }), + ]); + expect(result.pslBlockDescriptors['fooBlock']).toMatchObject({ + attributes: { map: mapFactory }, + }); + }); + + it('rejects a pslBlockDescriptors entry whose attributes is not a record', () => { + expect(() => + assembleAuthoringContributions([ + createDescriptor({ + authoring: { + entityTypes: { + foo: { kind: 'entity', discriminator: 'fake-foo', output: { factory: () => ({}) } }, + }, + pslBlockDescriptors: { + fooBlock: { + ...makeDeclarativePslBlockDescriptor('fake-foo'), + attributes: 'map', + } as unknown as never, + }, + }, + }), + ]), + ).toThrow(/Malformed authoring pslBlock contribution at "fooBlock"/); + }); + it('descends into a pslBlockDescriptors sub-namespace whose key is "kind" or "discriminator" without triggering malformed check', () => { // A sub-namespace keyed "kind" or "discriminator" that does not itself // look like a descriptor must descend normally. diff --git a/packages/1-framework/1-core/framework-components/test/framework-components.authoring.test.ts b/packages/1-framework/1-core/framework-components/test/framework-components.authoring.test.ts index 13ce576cae5f..375ebeaf047f 100644 --- a/packages/1-framework/1-core/framework-components/test/framework-components.authoring.test.ts +++ b/packages/1-framework/1-core/framework-components/test/framework-components.authoring.test.ts @@ -983,6 +983,7 @@ describe('classifyEnumMemberType', () => { name: 'TestEnum', parameters, blockAttributes: [], + attributes: {}, span: testSpan, }; } diff --git a/packages/1-framework/1-core/framework-components/test/psl-ast.test.ts b/packages/1-framework/1-core/framework-components/test/psl-ast.test.ts index 991481a6cadc..11b1c599de87 100644 --- a/packages/1-framework/1-core/framework-components/test/psl-ast.test.ts +++ b/packages/1-framework/1-core/framework-components/test/psl-ast.test.ts @@ -21,7 +21,15 @@ function makeExtensionBlock( name: string, keyword: string = discriminator, ): PslExtensionBlock { - return { kind: discriminator, keyword, name, parameters: {}, blockAttributes: [], span: SPAN }; + return { + kind: discriminator, + keyword, + name, + parameters: {}, + blockAttributes: [], + attributes: {}, + span: SPAN, + }; } describe('makePslNamespace / makePslNamespaceEntries', () => { diff --git a/packages/1-framework/1-core/framework-components/test/psl-block-descriptor.types.test.ts b/packages/1-framework/1-core/framework-components/test/psl-block-descriptor.types.test.ts index 84f29ec3f20c..7ea0d4564fd7 100644 --- a/packages/1-framework/1-core/framework-components/test/psl-block-descriptor.types.test.ts +++ b/packages/1-framework/1-core/framework-components/test/psl-block-descriptor.types.test.ts @@ -11,6 +11,8 @@ import type { PslBlockParamOption, PslBlockParamRef, PslBlockParamValue, + PslExtensionBlock, + PslExtensionBlockParsedAttribute, } from '../src/shared/psl-extension-block'; describe('PslBlockParam discriminated union', () => { @@ -157,3 +159,30 @@ describe('isAuthoringPslBlockDescriptor', () => { } }); }); + +describe('block attributes', () => { + it('a descriptor declares its block attributes as erased factories, sibling of parameters', () => { + const descriptor = { + kind: 'pslBlock', + keyword: 'native_enum', + discriminator: 'native_enum', + name: { required: true }, + parameters: {}, + attributes: { map: () => ({ level: 'block', name: 'map' }) }, + } as const; + expectTypeOf(descriptor).toMatchTypeOf(); + expectTypeOf().toEqualTypeOf< + Readonly> | undefined + >(); + }); + + it('a block node carries its parsed attributes as plain data keyed by attribute name', () => { + expectTypeOf().toEqualTypeOf< + Readonly> + >(); + expectTypeOf().toEqualTypeOf< + Readonly> + >(); + expectTypeOf>().not.toMatchTypeOf(); + }); +}); diff --git a/packages/1-framework/1-core/framework-components/test/psl-extension-block-validator.test.ts b/packages/1-framework/1-core/framework-components/test/psl-extension-block-validator.test.ts index f3c2336265bd..79588b7c42e7 100644 --- a/packages/1-framework/1-core/framework-components/test/psl-extension-block-validator.test.ts +++ b/packages/1-framework/1-core/framework-components/test/psl-extension-block-validator.test.ts @@ -127,6 +127,7 @@ function validNode(): PslExtensionBlock { using: { kind: 'value', raw: '"auth.uid() = user_id"', span: stubSpan() }, }, blockAttributes: [], + attributes: {}, }; } @@ -511,6 +512,7 @@ describe('validateExtensionBlock', () => { target: { kind: 'ref', identifier: 'Post', span: stubSpan() }, }, blockAttributes: [], + attributes: {}, }; const diagnostics = validateExtensionBlock( @@ -550,6 +552,7 @@ describe('validateExtensionBlock', () => { target: { kind: 'ref', identifier: 'Ghost', span: stubSpan() }, }, blockAttributes: [], + attributes: {}, }; const diagnostics = validateExtensionBlock( @@ -638,6 +641,7 @@ describe('validateExtensionBlock', () => { }, }, blockAttributes: [], + attributes: {}, }; const diagnostics = validateExtensionBlock(node, listDescriptor, SOURCE_ID, codecLookup); @@ -686,6 +690,7 @@ describe('validateExtensionBlock', () => { using: { kind: 'value', raw: 'not_quoted', span: stubSpan() }, }, blockAttributes: [], + attributes: {}, // target (required) is missing // using (required) — present but invalid }; diff --git a/packages/1-framework/2-authoring/psl-parser/src/block-reconstruction.ts b/packages/1-framework/2-authoring/psl-parser/src/block-reconstruction.ts index ed998d948b31..fe8557376e1b 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/block-reconstruction.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/block-reconstruction.ts @@ -75,6 +75,7 @@ export function reconstructExtensionBlock( name: blockName, parameters, blockAttributes, + attributes: {}, span: nodePslSpan(node.syntax, sourceFile), }; } diff --git a/packages/1-framework/2-authoring/psl-printer/test/generic-extension-block-printer.test.ts b/packages/1-framework/2-authoring/psl-printer/test/generic-extension-block-printer.test.ts index 14f04963d103..3b42ce7e9b28 100644 --- a/packages/1-framework/2-authoring/psl-printer/test/generic-extension-block-printer.test.ts +++ b/packages/1-framework/2-authoring/psl-printer/test/generic-extension-block-printer.test.ts @@ -146,6 +146,7 @@ describe('generic extension-block printer (P2)', () => { using: valueParam('"auth.uid() = author_id"'), }, blockAttributes: [], + attributes: {}, span: STUB_SPAN, }; @@ -179,6 +180,7 @@ describe('generic extension-block printer (P2)', () => { using: valueParam('"true"'), }, blockAttributes: [], + attributes: {}, span: STUB_SPAN, }; @@ -260,6 +262,7 @@ describe('generic extension-block printer (P2)', () => { using: valueParam(raw), }, blockAttributes: [], + attributes: {}, span: STUB_SPAN, }; const ast = { @@ -294,6 +297,7 @@ describe('generic extension-block printer (P2)', () => { name: 'NumericPolicy', parameters: { target: refParam('Post'), using: valueParam('42') }, blockAttributes: [], + attributes: {}, span: STUB_SPAN, }; const ast = { @@ -329,6 +333,7 @@ describe('generic extension-block printer (P2)', () => { target: refParam('Post'), }, blockAttributes: [], + attributes: {}, span: STUB_SPAN, }; const ast = { @@ -362,6 +367,7 @@ describe('generic extension-block printer (P2)', () => { name: 'EdgeCase', parameters, blockAttributes: [], + attributes: {}, span: STUB_SPAN, }; return { @@ -478,6 +484,7 @@ describe('generic extension-block printer (P2)', () => { span: STUB_SPAN, }, ], + attributes: {}, span: STUB_SPAN, }; @@ -502,6 +509,7 @@ describe('generic extension-block printer (P2)', () => { name: 'Status', parameters: { draft: valueParam('"draft"'), done: valueParam('"done"') }, blockAttributes: [], + attributes: {}, span: STUB_SPAN, }; @@ -529,6 +537,7 @@ describe('generic extension-block printer (P2)', () => { plain: valueParam('"plain"'), }, blockAttributes: [], + attributes: {}, span: STUB_SPAN, }; @@ -562,6 +571,7 @@ describe('generic extension-block printer (P2)', () => { }, }, blockAttributes: [], + attributes: {}, span: STUB_SPAN, }; @@ -598,6 +608,7 @@ describe('generic extension-block printer (P2)', () => { }, { name: 'something', args: [], span: STUB_SPAN }, ], + attributes: {}, span: STUB_SPAN, }; @@ -627,6 +638,7 @@ describe('generic extension-block printer (P2)', () => { }, }, blockAttributes: [], + attributes: {}, span: STUB_SPAN, }; @@ -662,6 +674,7 @@ describe('generic extension-block printer (P2)', () => { name: 'Mix', parameters: { label: valueParam('"declared"'), extra: valueParam('"variadic"') }, blockAttributes: [], + attributes: {}, span: STUB_SPAN, }; @@ -688,6 +701,7 @@ describe('generic extension-block printer (P2)', () => { name: 'OrphanBlock', parameters: {}, blockAttributes: [], + attributes: {}, span: STUB_SPAN, }; @@ -735,6 +749,7 @@ describe('generic extension-block printer (P2)', () => { name: 'Mismatched', parameters: {}, blockAttributes: [], + attributes: {}, span: STUB_SPAN, }; @@ -776,6 +791,7 @@ describe('generic extension-block printer (P2)', () => { name: 'Round', parameters: {}, blockAttributes: [], + attributes: {}, span: STUB_SPAN, }; const square: PslExtensionBlock = { @@ -784,6 +800,7 @@ describe('generic extension-block printer (P2)', () => { name: 'Boxy', parameters: {}, blockAttributes: [], + attributes: {}, span: STUB_SPAN, }; diff --git a/packages/1-framework/2-authoring/psl-printer/test/print-psl.duplicate-namespace-names.test.ts b/packages/1-framework/2-authoring/psl-printer/test/print-psl.duplicate-namespace-names.test.ts index 3e91f949dcad..f59143dfbc56 100644 --- a/packages/1-framework/2-authoring/psl-printer/test/print-psl.duplicate-namespace-names.test.ts +++ b/packages/1-framework/2-authoring/psl-printer/test/print-psl.duplicate-namespace-names.test.ts @@ -72,6 +72,7 @@ function widgetBlock(name: string, member: string): PslExtensionBlock { name, parameters: { [member]: { kind: 'value', raw: JSON.stringify(member), span: ZERO_SPAN } }, blockAttributes: [], + attributes: {}, span: ZERO_SPAN, }; } diff --git a/packages/2-mongo-family/9-family/test/authoring-entity-types.enum.test.ts b/packages/2-mongo-family/9-family/test/authoring-entity-types.enum.test.ts index 694b39a27f70..0227c4cdc635 100644 --- a/packages/2-mongo-family/9-family/test/authoring-entity-types.enum.test.ts +++ b/packages/2-mongo-family/9-family/test/authoring-entity-types.enum.test.ts @@ -40,6 +40,7 @@ function enumBlock(input: { name: input.name, parameters: input.parameters, blockAttributes: input.typeCodecId !== undefined ? [typeAttr(input.typeCodecId)] : [], + attributes: {}, span: SPAN, }; } diff --git a/packages/2-sql/9-family/test/authoring-entity-types.enum.test.ts b/packages/2-sql/9-family/test/authoring-entity-types.enum.test.ts index cc04076ad230..d6215544b144 100644 --- a/packages/2-sql/9-family/test/authoring-entity-types.enum.test.ts +++ b/packages/2-sql/9-family/test/authoring-entity-types.enum.test.ts @@ -40,6 +40,7 @@ function enumBlock(input: { name: input.name, parameters: input.parameters, blockAttributes: input.typeCodecId !== undefined ? [typeAttr(input.typeCodecId)] : [], + attributes: {}, span: SPAN, }; } diff --git a/packages/3-extensions/supabase/scripts/generate-contract.ts b/packages/3-extensions/supabase/scripts/generate-contract.ts index ff758d996a2c..c0a4e9937bee 100644 --- a/packages/3-extensions/supabase/scripts/generate-contract.ts +++ b/packages/3-extensions/supabase/scripts/generate-contract.ts @@ -238,6 +238,7 @@ function roleExtensionBlock(name: string): PslExtensionBlock { name, parameters: {}, blockAttributes: [], + attributes: {}, span: SYNTHETIC_SPAN, }; } diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-enum-blocks.ts b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-enum-blocks.ts index 2185b3cdcd4d..b439ebffba22 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-enum-blocks.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-enum-blocks.ts @@ -100,6 +100,8 @@ function buildNativeEnumBlock( span: SYNTHETIC_SPAN, }, ], + attributes: + name === typeName ? {} : { map: { args: { name: typeName }, span: SYNTHETIC_SPAN } }, span: SYNTHETIC_SPAN, }; } diff --git a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-policy-blocks.ts b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-policy-blocks.ts index bef98c5a7c18..678883a819b2 100644 --- a/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-policy-blocks.ts +++ b/packages/3-targets/3-targets/postgres/src/core/psl-infer/infer-policy-blocks.ts @@ -138,6 +138,7 @@ export function buildPolicyBlocks( span: SYNTHETIC_SPAN, }, ], + attributes: { map: { args: { name: policy.name }, span: SYNTHETIC_SPAN } }, span: SYNTHETIC_SPAN, }); } diff --git a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.top-level-blocks.test.ts b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.top-level-blocks.test.ts index 36134baf9e18..dce445a68ceb 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.top-level-blocks.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-infer/print-psl/print-psl.top-level-blocks.test.ts @@ -169,6 +169,7 @@ function enumBlock(name: string, members: Record): PslExtensionB span: ZERO_SPAN, }, ], + attributes: {}, span: ZERO_SPAN, }; } diff --git a/projects/attribute-registry/slices/block-attributes-on-kit/dispatches/01-block-level-kit.md b/projects/attribute-registry/slices/block-attributes-on-kit/dispatches/01-block-level-kit.md new file mode 100644 index 000000000000..7d40d956f9b9 --- /dev/null +++ b/projects/attribute-registry/slices/block-attributes-on-kit/dispatches/01-block-level-kit.md @@ -0,0 +1,26 @@ +# Dispatch 1 — block-level-kit + +Slice plan: `projects/attribute-registry/slices/block-attributes-on-kit/plan.md` § Dispatch 1. Spec: `../spec.md` § Chosen design 1. + +## Task + +Give the attribute-spec kit a block-level interpret ctx and a `blockAttribute()` constructor, carrying the ctx requirement in the types — such that a spec cannot demand a model it will never receive, and every existing consumer typechecks unchanged. + +## Outcome / property + +`ArgType`, `Param`, `PositionalParam`, `AttributeSpec` take `Ctx extends BlockInterpretCtx = InterpretCtx`; `ArgType.parse` is a property function type (contravariant ctx); model-free combinators return `ArgType`; `interpretArgs` / `interpretAttribute` / `leafDiagnostic` are ctx-generic; `blockAttribute()` builds `AttributeSpec` with `level: 'block'`; `BlockAttributeSpecFactory = () => AttributeSpec`; all exported from the barrel. + +## Completed when + +- [ ] `test/attribute-spec-block.test-d.ts`: `fieldRef` inside `blockAttribute` is a type error; a block spec is assignable to `AttributeSpec`; `blockAttribute` infers its output like `modelAttribute`. +- [ ] `test/attribute-spec-block.test.ts`: `interpretAttribute` over a `@@` node with a ctx lacking `selfModel` binds args and runs `refine`. +- [ ] Gates: psl-parser typecheck (incl. test), lint, test; workspace `pnpm typecheck`; grep `selfModel` in combinators names only `field-ref.ts`. + +## Edge cases + +| Case | Disposition | +| --- | --- | +| F5 destructive git | forbidden | +| F3 broken consumers | discover via `rg 'parse\(' … ` and workspace typecheck, not repeated test runs | +| F16 layering comment | HALT | +| No code comments (operator rule) | none added | diff --git a/projects/attribute-registry/slices/block-attributes-on-kit/dispatches/02-block-attribute-substrate.md b/projects/attribute-registry/slices/block-attributes-on-kit/dispatches/02-block-attribute-substrate.md new file mode 100644 index 000000000000..ecdf41bc1c6a --- /dev/null +++ b/projects/attribute-registry/slices/block-attributes-on-kit/dispatches/02-block-attribute-substrate.md @@ -0,0 +1,23 @@ +# Dispatch 2 — block-attribute-substrate + +Slice plan: `../plan.md` § Dispatch 2. Spec: `../spec.md` § Chosen design 2. + +## Task + +Add the erased `attributes` key to `AuthoringPslBlockDescriptor`, the required `attributes` record (+ `PslExtensionBlockParsedAttribute`) to `PslExtensionBlock`, and `PSL_EXTENSION_UNKNOWN_BLOCK_ATTRIBUTE` to the framework code set; adapt every in-repo block-node constructor — such that the node is total (consumers read `block.attributes` without an existence check) and core never names `AttributeSpec`. + +## Completed when + +- [ ] `psl-block-descriptor.types.test.ts` pins the optional erased `attributes` key and the required node field. +- [ ] `control-stack.test.ts`: a descriptor with `attributes` assembles and keeps the key; a non-object `attributes` is rejected as malformed. +- [ ] Every `blockAttributes:` literal site also carries `attributes:` (grep counts equal); psl-infer builders synthesise the parsed `map` entry. +- [ ] Gates: framework-components build → workspace typecheck; framework-components lint/test; target-postgres test; lint:deps. + +## Edge cases + +| Case | Disposition | +| --- | --- | +| F5 destructive git | forbidden | +| F3 broken constructors | `rg 'blockAttributes:'` enumerates them; workspace typecheck confirms | +| F16 layering comment | HALT | +| No code comments | none added; no doc bullets added either | diff --git a/projects/attribute-registry/trace.jsonl b/projects/attribute-registry/trace.jsonl index 4a70d8a343d8..9a77dfe4d96c 100644 --- a/projects/attribute-registry/trace.jsonl +++ b/projects/attribute-registry/trace.jsonl @@ -99,4 +99,11 @@ {"event_id":"5135962b-9914-4134-bf3b-9f9882297484","schema_version":"1","ts":"2026-08-28T16:05:43.351Z","project_run_id":"attribute-registry","orchestrator_agent_id":null,"event_type":"dispatch-end","dispatch_id":"088d74c9-8cc7-4e68-8a3f-12fc369a72e3","result":"completed","wall_clock_ms":1173157} {"event_id":"efff0c20-b9ae-446d-a60d-4edea023b33c","schema_version":"1","ts":"2026-08-28T15:10:25.565Z","project_run_id":"attribute-registry","orchestrator_agent_id":null,"event_type":"slice-started","slice_slug":"block-attributes-on-kit","slice_index":4,"linear_ref":"TML-3230"} {"event_id":"aacdd74c-519d-4844-a30c-f9b4fe1373e1","schema_version":"1","ts":"2026-08-28T15:19:16.351Z","project_run_id":"attribute-registry","orchestrator_agent_id":null,"event_type":"spec-authored","spec_kind":"slice","spec_path":"projects/attribute-registry/slices/block-attributes-on-kit/spec.md","byte_length":14630,"edge_cases_count":3,"open_questions_count":1,"dod_items_count":4} -{"event_id":"6be1dc47-e427-45b8-908f-f02164543997","schema_version":"1","ts":"2026-08-28T15:20:10.849Z","project_run_id":"attribute-registry","orchestrator_agent_id":null,"event_type":"plan-authored","plan_kind":"slice","plan_path":"projects/attribute-registry/slices/block-attributes-on-kit/plan.md","byte_length":9419,"dispatch_count":4,"slice_count":null,"dispatch_size_distribution":{"S":0,"M":3,"L":1,"XL":0},"open_items_count":2} \ No newline at end of file +{"event_id":"6be1dc47-e427-45b8-908f-f02164543997","schema_version":"1","ts":"2026-08-28T15:20:10.849Z","project_run_id":"attribute-registry","orchestrator_agent_id":null,"event_type":"plan-authored","plan_kind":"slice","plan_path":"projects/attribute-registry/slices/block-attributes-on-kit/plan.md","byte_length":9419,"dispatch_count":4,"slice_count":null,"dispatch_size_distribution":{"S":0,"M":3,"L":1,"XL":0},"open_items_count":2}{"event_id":"a2bdca92-8999-4b86-8647-a1367ef7eb63","schema_version":"1","ts":"2026-08-28T15:20:57.526Z","project_run_id":"attribute-registry","orchestrator_agent_id":null,"event_type":"dispatch-start","dispatch_id":"c668b3f6-822b-4190-8847-1eb3bac5f0df","dispatch_name":"block-level-kit","subagent_type":"fork","model":"fable","parent_dispatch_id":null} +{"event_id":"36b1ddcd-21ba-4ef7-8234-91b58984ced7","schema_version":"1","ts":"2026-08-28T15:20:58.196Z","project_run_id":"attribute-registry","orchestrator_agent_id":null,"event_type":"round-start","dispatch_id":"c668b3f6-822b-4190-8847-1eb3bac5f0df","round_id":"178a7d13-6bb1-4061-8045-5ff0f1319742","round_number":1} +{"event_id":"1623e49f-c25d-49c4-9104-53381782800e","schema_version":"1","ts":"2026-08-28T15:20:58.745Z","project_run_id":"attribute-registry","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"c668b3f6-822b-4190-8847-1eb3bac5f0df","round_id":"178a7d13-6bb1-4061-8045-5ff0f1319742","brief_byte_length":1755,"brief_content_hash":"f101f65593ae7371bc4240b0bfd8636e7648076aa92c1da56dc175c7f6d6552f","brief_disposition":"initial"} +{"event_id":"021891dc-4eaa-43cb-8a5d-938b42c5a0b6","schema_version":"1","ts":"2026-08-28T15:30:00.622Z","project_run_id":"attribute-registry","orchestrator_agent_id":null,"event_type":"round-end","dispatch_id":"c668b3f6-822b-4190-8847-1eb3bac5f0df","round_id":"178a7d13-6bb1-4061-8045-5ff0f1319742","verdict":"satisfied","findings_filed":0,"wall_clock_ms":538561} +{"event_id":"952c659c-a68f-409d-b91e-2d6e9b2f0033","schema_version":"1","ts":"2026-08-28T15:30:03.738Z","project_run_id":"attribute-registry","orchestrator_agent_id":null,"event_type":"dispatch-end","dispatch_id":"c668b3f6-822b-4190-8847-1eb3bac5f0df","result":"completed","wall_clock_ms":539231} +{"event_id":"9984e637-c1a9-4238-b186-ca7156864a51","schema_version":"1","ts":"2026-08-28T15:31:09.553Z","project_run_id":"attribute-registry","orchestrator_agent_id":null,"event_type":"dispatch-start","dispatch_id":"3afd0d4a-33af-4e47-852f-be525de7d6b8","dispatch_name":"block-attribute-substrate","subagent_type":"fork","model":"fable","parent_dispatch_id":"c668b3f6-822b-4190-8847-1eb3bac5f0df"} +{"event_id":"4b991804-d802-49fb-9245-bf998dc695e1","schema_version":"1","ts":"2026-08-28T15:31:11.676Z","project_run_id":"attribute-registry","orchestrator_agent_id":null,"event_type":"round-start","dispatch_id":"3afd0d4a-33af-4e47-852f-be525de7d6b8","round_id":"c7393b8e-9ac7-4b2d-9cbc-9e011e474234","round_number":1} +{"event_id":"37c9f532-c992-4f54-a24c-3e2b81e64d07","schema_version":"1","ts":"2026-08-28T15:31:13.890Z","project_run_id":"attribute-registry","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"3afd0d4a-33af-4e47-852f-be525de7d6b8","round_id":"c7393b8e-9ac7-4b2d-9cbc-9e011e474234","brief_byte_length":1377,"brief_content_hash":"7425bf244522043a951a9fb84617e1a44b906beb96e249936b9b910225e8e567","brief_disposition":"initial"} From e5683fe3f07fe96a1f6dc4f7e18a71cd6a5c19f0 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 28 Aug 2026 15:57:11 +0000 Subject: [PATCH 04/15] TML-3230: parse block attributes through the kit at symbol-table time Signed-off-by: Steven McClankerton --- .../psl-parser/src/block-reconstruction.ts | 105 +++++++++++++++- .../2-authoring/psl-parser/src/parse.ts | 4 +- .../psl-parser/test/symbol-table.test.ts | 116 ++++++++++++++++++ .../03-reconstruct-parses-block-attributes.md | 22 ++++ projects/attribute-registry/trace.jsonl | 5 + 5 files changed, 244 insertions(+), 8 deletions(-) create mode 100644 projects/attribute-registry/slices/block-attributes-on-kit/dispatches/03-reconstruct-parses-block-attributes.md diff --git a/packages/1-framework/2-authoring/psl-parser/src/block-reconstruction.ts b/packages/1-framework/2-authoring/psl-parser/src/block-reconstruction.ts index fe8557376e1b..f6f46d9018c3 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/block-reconstruction.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/block-reconstruction.ts @@ -1,18 +1,26 @@ import type { AuthoringPslBlockDescriptor } from '@internal/framework-components/authoring'; import type { PslBlockParam, + PslDiagnostic, PslExtensionBlock, PslExtensionBlockAttribute, PslExtensionBlockParamValue, + PslExtensionBlockParsedAttribute, PslSpan, } from '@internal/framework-components/psl-ast'; +import { blindCast } from '@internal/utils/casts'; +import { interpretAttribute } from './attribute-spec/interpret'; +import type { BlockAttributeSpecFactory } from './attribute-spec/spec-context'; import type { ParseDiagnostic } from './parse'; import { nodePslSpan } from './resolve'; -import type { SourceFile } from './source-file'; +import type { Range, SourceFile } from './source-file'; +import type { ModelAttributeAst } from './syntax/ast/attributes'; import type { GenericBlockDeclarationAst, KeyValuePairAst } from './syntax/ast/declarations'; import { ArrayLiteralAst, type ExpressionAst } from './syntax/ast/expressions'; import { printSyntax } from './syntax/ast-helpers'; +const BLOCK_ATTRIBUTE_SOURCE_ID = 'unknown'; + /** * Descriptor-free and unknown parameters become `value` stubs so validation can * report them via key-set comparison. Duplicate member names are first-wins. @@ -27,6 +35,7 @@ export function reconstructExtensionBlock( const blockName = node.name()?.name() ?? ''; const blockAttributes: PslExtensionBlockAttribute[] = []; + const attributes: Record = {}; for (const attribute of node.attributes()) { const name = attribute.name()?.path().join('.') ?? ''; const args = Array.from(attribute.argList()?.args() ?? [], (arg) => { @@ -37,11 +46,24 @@ export function reconstructExtensionBlock( span: nodePslSpan(arg.syntax, sourceFile), }; }); - blockAttributes.push({ + const span = nodePslSpan(attribute.syntax, sourceFile); + blockAttributes.push({ name, args, span }); + if (descriptor === undefined) continue; + const parsed = parseBlockAttribute( + attribute, name, - args, - span: nodePslSpan(attribute.syntax, sourceFile), - }); + span, + descriptor, + attributes, + keyword, + blockName, + sourceFile, + ); + if (parsed.ok) { + attributes[name] = parsed.value; + } else { + diagnostics.push(...parsed.diagnostics); + } } const parameters: Record = {}; @@ -75,11 +97,82 @@ export function reconstructExtensionBlock( name: blockName, parameters, blockAttributes, - attributes: {}, + attributes, span: nodePslSpan(node.syntax, sourceFile), }; } +function parseBlockAttribute( + attribute: ModelAttributeAst, + name: string, + span: PslSpan, + descriptor: AuthoringPslBlockDescriptor, + parsedSoFar: Readonly>, + keyword: string, + blockName: string, + sourceFile: SourceFile, +): + | { readonly ok: true; readonly value: PslExtensionBlockParsedAttribute } + | { readonly ok: false; readonly diagnostics: readonly ParseDiagnostic[] } { + const range = pslSpanToRange(span, sourceFile); + const declared = descriptor.attributes ?? {}; + if (!Object.hasOwn(declared, name)) { + return { + ok: false, + diagnostics: [ + { + code: 'PSL_EXTENSION_UNKNOWN_BLOCK_ATTRIBUTE', + message: `Unknown attribute "@@${name}" in "${keyword}" block "${blockName}"`, + range, + }, + ], + }; + } + if (Object.hasOwn(parsedSoFar, name)) { + return { + ok: false, + diagnostics: [ + { + code: 'PSL_INVALID_EXTENSION_BLOCK_ATTRIBUTE', + message: `Duplicate attribute "@@${name}" in "${keyword}" block "${blockName}"; first occurrence wins`, + range, + }, + ], + }; + } + const factory = blindCast< + BlockAttributeSpecFactory, + 'framework core cannot name AttributeSpec, so block-attribute factories transit the descriptor erased as unknown; this is the single point that restores the factory type the descriptor surface documents' + >(declared[name]); + const result = interpretAttribute(attribute, factory(), { + level: 'block', + sourceId: BLOCK_ATTRIBUTE_SOURCE_ID, + sourceFile, + }); + if (!result.ok) { + return { + ok: false, + diagnostics: result.failure.map((diagnostic) => toParseDiagnostic(diagnostic, sourceFile)), + }; + } + return { ok: true, value: { args: result.value, span } }; +} + +function toParseDiagnostic(diagnostic: PslDiagnostic, sourceFile: SourceFile): ParseDiagnostic { + return { + code: diagnostic.code, + message: diagnostic.message, + range: pslSpanToRange(diagnostic.span, sourceFile), + }; +} + +function pslSpanToRange(span: PslSpan, sourceFile: SourceFile): Range { + return { + start: sourceFile.positionAt(span.start.offset), + end: sourceFile.positionAt(span.end.offset), + }; +} + function reconstructParamValue( entry: KeyValuePairAst, param: PslBlockParam | undefined, diff --git a/packages/1-framework/2-authoring/psl-parser/src/parse.ts b/packages/1-framework/2-authoring/psl-parser/src/parse.ts index 8973e14fc68f..b202f8e88c4c 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/parse.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/parse.ts @@ -1,4 +1,4 @@ -import type { PslDiagnosticCode } from '@internal/framework-components/psl-ast'; +import type { PslDiagnostic, PslDiagnosticCode } from '@internal/framework-components/psl-ast'; import { UNSPECIFIED_PSL_NAMESPACE_ID } from '@internal/framework-components/psl-ast'; import { type Range, SourceFile } from './source-file'; import { DocumentAst } from './syntax/ast/declarations'; @@ -9,7 +9,7 @@ import type { SyntaxKind } from './syntax/syntax-kind'; import { isTerminatedStringLiteral, type Token, Tokenizer, type TokenKind } from './tokenizer'; export interface ParseDiagnostic { - readonly code: PslDiagnosticCode; + readonly code: PslDiagnostic['code']; readonly message: string; readonly range: Range; } diff --git a/packages/1-framework/2-authoring/psl-parser/test/symbol-table.test.ts b/packages/1-framework/2-authoring/psl-parser/test/symbol-table.test.ts index 7c6b29c395a1..76809b9e3d32 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/symbol-table.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/symbol-table.test.ts @@ -4,6 +4,9 @@ import type { } from '@internal/framework-components/authoring'; import type { Codec, CodecLookup } from '@internal/framework-components/codec'; import { describe, expect, it } from 'vitest'; +import { blockAttribute } from '../src/attribute-spec/block-attribute'; +import { leafDiagnostic } from '../src/attribute-spec/combinators/diagnostic'; +import { str } from '../src/attribute-spec/combinators/str'; import { validateExtensionBlockFromSymbol } from '../src/extension-block'; import { parse } from '../src/parse'; import { buildSymbolTable } from '../src/symbol-table'; @@ -672,3 +675,116 @@ describe('buildSymbolTable() — N:1 keywords sharing one discriminator', () => expect(boxy?.block.keyword).toBe('shape_square'); }); }); + +describe('buildSymbolTable() — block attributes parsed through the kit', () => { + const mapSpec = blockAttribute('map', { + positional: [{ key: 'name', type: str() }], + refine: (parsed, ctx, node) => + parsed.name === '' ? [leafDiagnostic(ctx, node, 'empty', 'PSL_FIXTURE_EMPTY_MAP')] : [], + }); + const WIDGET_DESCRIPTORS: AuthoringPslBlockDescriptorNamespace = { + widget: { + kind: 'pslBlock', + keyword: 'widget', + discriminator: 'widget', + name: { required: true }, + parameters: {}, + variadicParameters: true, + attributes: { map: () => mapSpec }, + }, + }; + const BARE_DESCRIPTORS: AuthoringPslBlockDescriptorNamespace = { + widget: { + kind: 'pslBlock', + keyword: 'widget', + discriminator: 'widget', + name: { required: true }, + parameters: {}, + }, + }; + + it('attaches the parsed arguments and the attribute span as plain data', () => { + const result = build( + ['widget Gear {', ' teeth = 12', ' @@map("gear_wheel")', '}'].join('\n'), + WIDGET_DESCRIPTORS, + ); + + expect(result.diagnostics).toEqual([]); + expect(result.table.topLevel.blocks['Gear']?.block.attributes).toEqual({ + map: { + args: { name: 'gear_wheel' }, + span: { + start: { offset: 29, line: 3, column: 3 }, + end: { offset: 48, line: 3, column: 22 }, + }, + }, + }); + }); + + it('diagnoses an attribute the descriptor does not declare, anchored on the attribute', () => { + const result = build(['widget Gear {', ' @@schema("x")', '}'].join('\n'), WIDGET_DESCRIPTORS); + + expect(result.diagnostics).toEqual([ + { + code: 'PSL_EXTENSION_UNKNOWN_BLOCK_ATTRIBUTE', + message: 'Unknown attribute "@@schema" in "widget" block "Gear"', + range: { start: { line: 1, character: 2 }, end: { line: 1, character: 15 } }, + }, + ]); + expect(result.table.topLevel.blocks['Gear']?.block.attributes).toEqual({}); + }); + + it('treats every attribute as unknown when the descriptor declares none', () => { + const result = build(['widget Gear {', ' @@map("x")', '}'].join('\n'), BARE_DESCRIPTORS); + + expect(result.diagnostics.map((diagnostic) => diagnostic.code)).toEqual([ + 'PSL_EXTENSION_UNKNOWN_BLOCK_ATTRIBUTE', + ]); + }); + + it('keeps the first occurrence of a duplicated attribute and diagnoses the rest', () => { + const result = build( + ['widget Gear {', ' @@map("first")', ' @@map("second")', '}'].join('\n'), + WIDGET_DESCRIPTORS, + ); + + expect(result.diagnostics).toEqual([ + expect.objectContaining({ + code: 'PSL_INVALID_EXTENSION_BLOCK_ATTRIBUTE', + message: 'Duplicate attribute "@@map" in "widget" block "Gear"; first occurrence wins', + range: { start: { line: 2, character: 2 }, end: { line: 2, character: 17 } }, + }), + ]); + expect(result.table.topLevel.blocks['Gear']?.block.attributes['map']?.args).toEqual({ + name: 'first', + }); + }); + + it('surfaces a kit binding failure as a symbol-table diagnostic and omits the attribute', () => { + const result = build(['widget Gear {', ' @@map()', '}'].join('\n'), WIDGET_DESCRIPTORS); + + expect(result.diagnostics).toEqual([ + { + code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', + message: 'Attribute "map" is missing required argument "name"', + range: { start: { line: 1, character: 2 }, end: { line: 1, character: 9 } }, + }, + ]); + expect(result.table.topLevel.blocks['Gear']?.block.attributes).toEqual({}); + }); + + it('carries a refine diagnostic code contributed by the spec', () => { + const result = build(['widget Gear {', ' @@map("")', '}'].join('\n'), WIDGET_DESCRIPTORS); + + expect(result.diagnostics).toEqual([ + expect.objectContaining({ code: 'PSL_FIXTURE_EMPTY_MAP', message: 'empty' }), + ]); + }); + + it('parses nothing for a block whose keyword has no descriptor', () => { + const result = build(['gizmo Gear {', ' @@map("x")', '}'].join('\n'), {}); + + expect(result.diagnostics).toEqual([]); + expect(result.table.topLevel.blocks['Gear']?.block.attributes).toEqual({}); + }); +}); diff --git a/projects/attribute-registry/slices/block-attributes-on-kit/dispatches/03-reconstruct-parses-block-attributes.md b/projects/attribute-registry/slices/block-attributes-on-kit/dispatches/03-reconstruct-parses-block-attributes.md new file mode 100644 index 000000000000..5e91f9c31dba --- /dev/null +++ b/projects/attribute-registry/slices/block-attributes-on-kit/dispatches/03-reconstruct-parses-block-attributes.md @@ -0,0 +1,22 @@ +# Dispatch 3 — reconstruct-parses-block-attributes + +Slice plan: `../plan.md` § Dispatch 3. Spec: `../spec.md` § Chosen design 3. + +## Task + +`reconstructExtensionBlock` runs the descriptor's block-attribute factories through the kit and fills `PslExtensionBlock.attributes`; unknown names and duplicates diagnose; kit failures surface as `ParseDiagnostic`s — such that the build and the LSP see identical block-attribute diagnostics because both run `buildSymbolTable`. + +## Completed when + +- [ ] `symbol-table.test.ts` covers: parsed value + span; unknown name → `PSL_EXTENSION_UNKNOWN_BLOCK_ATTRIBUTE`; duplicate → `PSL_INVALID_EXTENSION_BLOCK_ATTRIBUTE` first-wins; arity failure → `PSL_INVALID_ATTRIBUTE_SYNTAX`; refine code carried; descriptor without `attributes`; descriptor `undefined`. +- [ ] Exactly one new `blindCast` in psl-parser `src/`. +- [ ] Gates: psl-parser typecheck/lint/test; workspace typecheck; language-server test. + +## Edge cases + +| Case | Disposition | +| --- | --- | +| F5 destructive git | forbidden | +| `sourceId` for the block ctx | `'unknown'` placeholder, the interpreters' existing idiom; dropped on the way to `ParseDiagnostic` | +| F16 layering comment | HALT | +| No code comments | none added | diff --git a/projects/attribute-registry/trace.jsonl b/projects/attribute-registry/trace.jsonl index 9a77dfe4d96c..4284432225c1 100644 --- a/projects/attribute-registry/trace.jsonl +++ b/projects/attribute-registry/trace.jsonl @@ -107,3 +107,8 @@ {"event_id":"9984e637-c1a9-4238-b186-ca7156864a51","schema_version":"1","ts":"2026-08-28T15:31:09.553Z","project_run_id":"attribute-registry","orchestrator_agent_id":null,"event_type":"dispatch-start","dispatch_id":"3afd0d4a-33af-4e47-852f-be525de7d6b8","dispatch_name":"block-attribute-substrate","subagent_type":"fork","model":"fable","parent_dispatch_id":"c668b3f6-822b-4190-8847-1eb3bac5f0df"} {"event_id":"4b991804-d802-49fb-9245-bf998dc695e1","schema_version":"1","ts":"2026-08-28T15:31:11.676Z","project_run_id":"attribute-registry","orchestrator_agent_id":null,"event_type":"round-start","dispatch_id":"3afd0d4a-33af-4e47-852f-be525de7d6b8","round_id":"c7393b8e-9ac7-4b2d-9cbc-9e011e474234","round_number":1} {"event_id":"37c9f532-c992-4f54-a24c-3e2b81e64d07","schema_version":"1","ts":"2026-08-28T15:31:13.890Z","project_run_id":"attribute-registry","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"3afd0d4a-33af-4e47-852f-be525de7d6b8","round_id":"c7393b8e-9ac7-4b2d-9cbc-9e011e474234","brief_byte_length":1377,"brief_content_hash":"7425bf244522043a951a9fb84617e1a44b906beb96e249936b9b910225e8e567","brief_disposition":"initial"} +{"event_id":"a38e1df4-d1ee-401c-b662-8fe1dbf838f3","schema_version":"1","ts":"2026-08-28T15:48:06.683Z","project_run_id":"attribute-registry","orchestrator_agent_id":null,"event_type":"round-end","dispatch_id":"3afd0d4a-33af-4e47-852f-be525de7d6b8","round_id":"c7393b8e-9ac7-4b2d-9cbc-9e011e474234","verdict":"satisfied","findings_filed":0,"wall_clock_ms":1011537} +{"event_id":"21162349-313c-4fde-b0a8-6e9b288b3bf5","schema_version":"1","ts":"2026-08-28T15:48:10.695Z","project_run_id":"attribute-registry","orchestrator_agent_id":null,"event_type":"dispatch-end","dispatch_id":"3afd0d4a-33af-4e47-852f-be525de7d6b8","result":"completed","wall_clock_ms":1013660} +{"event_id":"98f34edd-e6ae-4777-b814-0e0a0b73ae28","schema_version":"1","ts":"2026-08-28T15:49:43.911Z","project_run_id":"attribute-registry","orchestrator_agent_id":null,"event_type":"dispatch-start","dispatch_id":"1e6cd6bb-7774-4cf5-9145-14f1649ea53c","dispatch_name":"reconstruct-parses-block-attributes","subagent_type":"fork","model":"fable","parent_dispatch_id":"3afd0d4a-33af-4e47-852f-be525de7d6b8"} +{"event_id":"091a943f-8d3b-4970-acbc-019a6cd8c3f6","schema_version":"1","ts":"2026-08-28T15:49:45.639Z","project_run_id":"attribute-registry","orchestrator_agent_id":null,"event_type":"round-start","dispatch_id":"1e6cd6bb-7774-4cf5-9145-14f1649ea53c","round_id":"0179971d-247e-428c-92f6-477beed78450","round_number":1} +{"event_id":"6cafaf51-23d3-4da1-8d19-e69209a609d4","schema_version":"1","ts":"2026-08-28T15:49:46.886Z","project_run_id":"attribute-registry","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"1e6cd6bb-7774-4cf5-9145-14f1649ea53c","round_id":"0179971d-247e-428c-92f6-477beed78450","brief_byte_length":1236,"brief_content_hash":"953bd3075b44f82812116ad9b280244af418a784334453788e5bf3cff62fa7ac","brief_disposition":"initial"} From ceb61a4035924557f0fb8c6d844e17abd759dd3a Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 28 Aug 2026 16:21:54 +0000 Subject: [PATCH 05/15] TML-3230: declare block attributes on their descriptors and read the kit-parsed values Signed-off-by: Steven McClankerton --- .../src/shared/framework-authoring.ts | 12 ++-- packages/2-mongo-family/9-family/package.json | 1 + .../src/core/authoring-entity-types.ts | 6 ++ .../test/authoring-entity-types.enum.test.ts | 5 +- .../2-authoring/contract-psl/test/fixtures.ts | 13 ++++ .../test/interpreter.enum.test.ts | 14 +--- .../test/interpreter.no-check.test.ts | 12 +--- .../contract-psl/test/ts-psl-parity.test.ts | 2 + packages/2-sql/9-family/package.json | 2 +- .../src/core/authoring-entity-types.ts | 6 ++ .../3-targets/postgres/src/core/authoring.ts | 72 ++++++++++--------- .../test/psl-native-enum-authoring.test.ts | 15 ++-- .../test/psl-policy-map-authoring.test.ts | 53 +++++++------- pnpm-lock.yaml | 9 ++- 14 files changed, 121 insertions(+), 101 deletions(-) diff --git a/packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts b/packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts index 86dd1adb35d3..9212346e586a 100644 --- a/packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts +++ b/packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts @@ -330,7 +330,7 @@ export function resolveEnumCodecId( ctx: AuthoringEntityContext, ): { readonly codecId: string; readonly codecSpan: PslSpan } | undefined { const sourceId = ctx.sourceId ?? 'unknown'; - const typeAttr = block.blockAttributes.find((a) => a.name === 'type'); + const typeAttr = block.attributes['type']; if (typeAttr === undefined) { const inferredKind = classifyEnumMemberType(block); @@ -346,12 +346,8 @@ export function resolveEnumCodecId( return { codecId: ctx.enumInferenceCodecs[inferredKind], codecSpan: block.span }; } - const rawCodecArg = typeAttr.args[0]?.value; - const codecId = - rawCodecArg?.startsWith('"') && rawCodecArg.endsWith('"') && rawCodecArg.length >= 2 - ? rawCodecArg.slice(1, -1) - : undefined; - if (codecId === undefined) { + const codecId = typeAttr.args['codecId']; + if (typeof codecId !== 'string') { ctx.diagnostics?.push({ code: 'PSL_ENUM_MISSING_TYPE', message: `enum "${block.name}" @@type attribute must have a quoted codec id argument`, @@ -360,7 +356,7 @@ export function resolveEnumCodecId( }); return undefined; } - return { codecId, codecSpan: typeAttr.args[0]?.span ?? typeAttr.span }; + return { codecId, codecSpan: typeAttr.span }; } export interface AuthoringEntityTypeTemplateOutput { diff --git a/packages/2-mongo-family/9-family/package.json b/packages/2-mongo-family/9-family/package.json index b82293bd4f59..0302d0b163ff 100644 --- a/packages/2-mongo-family/9-family/package.json +++ b/packages/2-mongo-family/9-family/package.json @@ -25,6 +25,7 @@ "@internal/mongo-query-ast": "workspace:8.0.0-rc.8", "@internal/mongo-schema-ir": "workspace:8.0.0-rc.8", "@internal/mongo-value": "workspace:8.0.0-rc.8", + "@internal/psl-parser": "workspace:8.0.0-rc.8", "@internal/utils": "workspace:8.0.0-rc.8", "arktype": "catalog:", "mongodb": "catalog:", diff --git a/packages/2-mongo-family/9-family/src/core/authoring-entity-types.ts b/packages/2-mongo-family/9-family/src/core/authoring-entity-types.ts index 9e0db79bbd38..5555ccaba722 100644 --- a/packages/2-mongo-family/9-family/src/core/authoring-entity-types.ts +++ b/packages/2-mongo-family/9-family/src/core/authoring-entity-types.ts @@ -8,6 +8,7 @@ import { resolveEnumCodecId, } from '@internal/framework-components/authoring'; import { type EnumTypeHandle, enumType } from '@internal/mongo-contract-ts/contract-builder'; +import { blockAttribute, str } from '@internal/psl-parser'; import { blindCast } from '@internal/utils/casts'; export const mongoFamilyEnumEntityDescriptor = { @@ -141,6 +142,10 @@ export const mongoFamilyEntityTypes: AuthoringEntityTypeNamespace = { enum: mongoFamilyEnumEntityDescriptor, }; +const enumTypeBlockAttribute = blockAttribute('type', { + positional: [{ key: 'codecId', type: str() }], +}); + export const mongoFamilyPslBlockDescriptors = { enum: { kind: 'pslBlock', @@ -149,5 +154,6 @@ export const mongoFamilyPslBlockDescriptors = { name: { required: true }, parameters: {}, variadicParameters: true, + attributes: { type: () => enumTypeBlockAttribute }, }, } as const satisfies AuthoringPslBlockDescriptorNamespace; diff --git a/packages/2-mongo-family/9-family/test/authoring-entity-types.enum.test.ts b/packages/2-mongo-family/9-family/test/authoring-entity-types.enum.test.ts index 0227c4cdc635..6088d32add83 100644 --- a/packages/2-mongo-family/9-family/test/authoring-entity-types.enum.test.ts +++ b/packages/2-mongo-family/9-family/test/authoring-entity-types.enum.test.ts @@ -40,7 +40,10 @@ function enumBlock(input: { name: input.name, parameters: input.parameters, blockAttributes: input.typeCodecId !== undefined ? [typeAttr(input.typeCodecId)] : [], - attributes: {}, + attributes: + input.typeCodecId !== undefined + ? { type: { args: { codecId: input.typeCodecId }, span: SPAN } } + : {}, span: SPAN, }; } diff --git a/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts b/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts index 79d2351fe910..f109a0e68b3e 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/fixtures.ts @@ -28,6 +28,7 @@ import type { } from '@internal/framework-components/control'; import type { FuncCallSig, SymbolTable } from '@internal/psl-parser'; import { + blockAttribute, buildSymbolTable, int, num, @@ -162,6 +163,18 @@ function testEnumFactory( ); } +export const testEnumPslBlockDescriptor = { + kind: 'pslBlock' as const, + keyword: 'enum', + discriminator: 'enum', + name: { required: true }, + parameters: {}, + variadicParameters: true, + attributes: { + type: () => blockAttribute('type', { positional: [{ key: 'codecId', type: str() }] }), + }, +}; + export const testEnumEntityContributions = { enum: { kind: 'entity' as const, diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.enum.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.enum.test.ts index a42c02d3ec43..78da506ad6e2 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.enum.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.enum.test.ts @@ -25,6 +25,7 @@ import { sqliteTarget, symbolTableInputFromParseArgs, testEnumEntityContributions, + testEnumPslBlockDescriptor, testRenderCheckExpressions, } from './fixtures'; @@ -84,20 +85,11 @@ const testCodecLookup: CodecLookup = { renderOutputTypeFor: () => undefined, }; -const enumPslBlockDescriptor = { - kind: 'pslBlock' as const, - keyword: 'enum', - discriminator: 'enum', - name: { required: true }, - parameters: {}, - variadicParameters: true, -}; - const authoringContributions = { entityTypes: testEnumEntityContributions, field: {}, type: {}, - pslBlockDescriptors: { enum: enumPslBlockDescriptor }, + pslBlockDescriptors: { enum: testEnumPslBlockDescriptor }, }; const builtinControlMutationDefaults = createBuiltinLikeControlMutationDefaults(); @@ -522,7 +514,7 @@ model Post { entityTypes: entityTypesWithoutEnum, field: {}, type: {}, - pslBlockDescriptors: { enum: enumPslBlockDescriptor }, + pslBlockDescriptors: { enum: testEnumPslBlockDescriptor }, }, }, ); diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.no-check.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.no-check.test.ts index 8afdf5f36349..423296b063ca 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.no-check.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.no-check.test.ts @@ -17,6 +17,7 @@ import { postgresTargetRenderingChecks, symbolTableInputFromParseArgs, testEnumEntityContributions, + testEnumPslBlockDescriptor, testRenderCheckExpressions, } from './fixtures'; @@ -53,21 +54,12 @@ const testCodecLookup: CodecLookup = { renderOutputTypeFor: () => undefined, }; -const enumPslBlockDescriptor = { - kind: 'pslBlock' as const, - keyword: 'enum', - discriminator: 'enum', - name: { required: true }, - parameters: {}, - variadicParameters: true, -}; - const authoringContributions = { entityTypes: testEnumEntityContributions, field: {}, type: {}, valueObjectStorageType: 'Jsonb', - pslBlockDescriptors: { enum: enumPslBlockDescriptor }, + pslBlockDescriptors: { enum: testEnumPslBlockDescriptor }, }; const builtinControlMutationDefaults = createBuiltinLikeControlMutationDefaults(); diff --git a/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts b/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts index 922629cb2b5d..19cad739437d 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/ts-psl-parity.test.ts @@ -19,6 +19,7 @@ import { temporalCodecPresetMirrors, temporalConvenienceMirrors, testEnumEntityContributions, + testEnumPslBlockDescriptor, } from './fixtures'; const sqlFamilyPack = { @@ -61,6 +62,7 @@ const portablePostgresTargetPack = { defaultNamespaceId: 'public', authoring: { entityTypes: testEnumEntityContributions, + pslBlockDescriptors: { enum: testEnumPslBlockDescriptor }, type: {}, }, } as const satisfies TargetPackRef<'sql', 'postgres'>; diff --git a/packages/2-sql/9-family/package.json b/packages/2-sql/9-family/package.json index 06f15465b23e..90d0ad644721 100644 --- a/packages/2-sql/9-family/package.json +++ b/packages/2-sql/9-family/package.json @@ -19,6 +19,7 @@ "@internal/framework-components": "workspace:8.0.0-rc.8", "@internal/migration-tools": "workspace:8.0.0-rc.8", "@internal/operations": "workspace:8.0.0-rc.8", + "@internal/psl-parser": "workspace:8.0.0-rc.8", "@internal/sql-contract": "workspace:8.0.0-rc.8", "@internal/sql-contract-emitter": "workspace:8.0.0-rc.8", "@internal/sql-contract-psl": "workspace:8.0.0-rc.8", @@ -33,7 +34,6 @@ }, "devDependencies": { "@internal/driver-postgres": "workspace:8.0.0-rc.8", - "@internal/psl-parser": "workspace:8.0.0-rc.8", "@internal/psl-printer": "workspace:8.0.0-rc.8", "@repo/test-utils": "workspace:8.0.0-rc.8", "@repo/tsconfig": "workspace:8.0.0-rc.8", diff --git a/packages/2-sql/9-family/src/core/authoring-entity-types.ts b/packages/2-sql/9-family/src/core/authoring-entity-types.ts index 7f62962a0894..8e4d39b86cf3 100644 --- a/packages/2-sql/9-family/src/core/authoring-entity-types.ts +++ b/packages/2-sql/9-family/src/core/authoring-entity-types.ts @@ -7,6 +7,7 @@ import { type PslExtensionBlock, resolveEnumCodecId, } from '@internal/framework-components/authoring'; +import { blockAttribute, str } from '@internal/psl-parser'; import { type EnumTypeHandle, enumType } from '@internal/sql-contract-ts/contract-builder'; import { blindCast } from '@internal/utils/casts'; @@ -141,6 +142,10 @@ export const sqlFamilyEntityTypes: AuthoringEntityTypeNamespace = { enum: sqlFamilyEnumEntityDescriptor, }; +const enumTypeBlockAttribute = blockAttribute('type', { + positional: [{ key: 'codecId', type: str() }], +}); + export const sqlFamilyPslBlockDescriptors = { enum: { kind: 'pslBlock', @@ -149,5 +154,6 @@ export const sqlFamilyPslBlockDescriptors = { name: { required: true }, parameters: {}, variadicParameters: true, + attributes: { type: () => enumTypeBlockAttribute }, }, } as const satisfies AuthoringPslBlockDescriptorNamespace; diff --git a/packages/3-targets/3-targets/postgres/src/core/authoring.ts b/packages/3-targets/3-targets/postgres/src/core/authoring.ts index 27415a119c69..1aebeab380ce 100644 --- a/packages/3-targets/3-targets/postgres/src/core/authoring.ts +++ b/packages/3-targets/3-targets/postgres/src/core/authoring.ts @@ -17,7 +17,7 @@ import type { import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; import type { ContributedPslDiagnosticCode } from '@internal/framework-components/psl-ast'; import type { ModelAttributeSpecFactory } from '@internal/psl-parser'; -import { modelAttribute } from '@internal/psl-parser'; +import { blockAttribute, leafDiagnostic, modelAttribute, str } from '@internal/psl-parser'; import type { EntityHandleLoweringInput, LoweredPackEntity, @@ -27,7 +27,7 @@ import type { import { exactNameBodyWarning } from '@internal/sql-contract/index-naming'; import type { SqlValueSetDerivingEntityTypeOutput } from '@internal/sql-contract/value-set-derivation-hook'; import { assertWireNamePrefixLength, normalizeSqlBody } from '@internal/sql-schema-ir/naming'; -import { assertDefined } from '@internal/utils/assertions'; +import { assertDefined, invariant } from '@internal/utils/assertions'; import { blindCast } from '@internal/utils/casts'; import { ifDefined } from '@internal/utils/defined'; import { PG_ENUM_CODEC_ID } from './codec-ids'; @@ -53,7 +53,6 @@ import { computeContentHash, POLICY_OPERATION_PREDICATES } from './rls/canonical const PSL_RLS_PREDICATE_NOT_FOR_OPERATION: ContributedPslDiagnosticCode = 'PSL_RLS_PREDICATE_NOT_FOR_OPERATION'; const PSL_POLICY_INVALID_MAP: ContributedPslDiagnosticCode = 'PSL_POLICY_INVALID_MAP'; -const PSL_NATIVE_ENUM_INVALID_MAP: ContributedPslDiagnosticCode = 'PSL_NATIVE_ENUM_INVALID_MAP'; const PSL_NATIVE_ENUM_BARE_MEMBER: ContributedPslDiagnosticCode = 'PSL_NATIVE_ENUM_BARE_MEMBER'; const PSL_EXTENSION_INVALID_VALUE: ContributedPslDiagnosticCode = 'PSL_EXTENSION_INVALID_VALUE'; const PSL_NATIVE_ENUM_DUPLICATE_MEMBER_VALUE: ContributedPslDiagnosticCode = @@ -270,22 +269,10 @@ function lowerRlsPolicyFromBlock( // and no wire-prefix length cap (exact names are verbatim physical names, // same stance as index `map:`). The block head stays the source-level // logical identifier, so head-keyed duplicate checking is unchanged. - const mapAttr = block.blockAttributes.find((a) => a.name === 'map'); - if (mapAttr) { - const rawArg = mapAttr.args[0]?.value; - const exactName = - rawArg?.startsWith('"') && rawArg.endsWith('"') && rawArg.length >= 2 - ? unwrapQuotedString(rawArg) - : undefined; - if (exactName === undefined || exactName === '') { - ctx.diagnostics?.push({ - code: PSL_POLICY_INVALID_MAP, - message: `\`${block.keyword}\` policy "${block.name}" @@map attribute must have a quoted, non-empty policy-name argument`, - sourceId: ctx.sourceId ?? 'unknown', - span: mapAttr.span, - }); - return undefined; - } + const mapAttr = block.attributes['map']; + if (mapAttr !== undefined) { + const exactName = mapAttr.args['name']; + invariant(typeof exactName === 'string', '@@map on a policy block parses one string argument'); ctx.warnings?.push(exactNameBodyWarning('policy', exactName)); return new PostgresRlsPolicy({ naming: { kind: 'exact', name: exactName }, @@ -328,20 +315,14 @@ function lowerNativeEnumFromBlock( const sourceId = ctx.sourceId ?? 'unknown'; const diagnostics = ctx.diagnostics; - const mapAttr = block.blockAttributes.find((a) => a.name === 'map'); + const mapAttr = block.attributes['map']; let typeName = block.name; - if (mapAttr) { - const rawArg = mapAttr.args[0]?.value; - const mapped = rawArg !== undefined ? unwrapQuotedString(rawArg) : undefined; - if (mapped === undefined) { - diagnostics?.push({ - code: PSL_NATIVE_ENUM_INVALID_MAP, - message: `native_enum "${block.name}" @@map attribute must have a quoted type-name argument`, - sourceId, - span: mapAttr.span, - }); - return undefined; - } + if (mapAttr !== undefined) { + const mapped = mapAttr.args['name']; + invariant( + typeof mapped === 'string', + '@@map on a native_enum block parses one string argument', + ); typeName = mapped; } @@ -532,6 +513,27 @@ const policyPermissiveParam = { kind: 'value', codecId: 'pg/bool@1' } as const; // the model and the policy prefix. const policyRequiresRls = { parameter: 'target', attribute: 'rls' } as const; +const policyMapAttribute = blockAttribute('map', { + positional: [{ key: 'name', type: str() }], + refine: (parsed, ctx, attributeNode) => + parsed.name === '' + ? [ + leafDiagnostic( + ctx, + attributeNode, + '@@map policy name must be a non-empty string', + PSL_POLICY_INVALID_MAP, + ), + ] + : [], +}); + +const policyBlockAttributes = { map: () => policyMapAttribute }; + +const nativeEnumMapAttribute = blockAttribute('map', { + positional: [{ key: 'name', type: str() }], +}); + export const postgresAuthoringPslBlockDescriptors = { // The predicate param set per keyword mirrors Postgres: SELECT/DELETE take // USING only; INSERT takes WITH CHECK only; UPDATE/ALL take both. The @@ -551,6 +553,7 @@ export const postgresAuthoringPslBlockDescriptors = { permissive: policyPermissiveParam, }, requiresModelAttribute: policyRequiresRls, + attributes: policyBlockAttributes, }, policy_delete: { kind: 'pslBlock', @@ -564,6 +567,7 @@ export const postgresAuthoringPslBlockDescriptors = { permissive: policyPermissiveParam, }, requiresModelAttribute: policyRequiresRls, + attributes: policyBlockAttributes, }, policy_insert: { kind: 'pslBlock', @@ -577,6 +581,7 @@ export const postgresAuthoringPslBlockDescriptors = { permissive: policyPermissiveParam, }, requiresModelAttribute: policyRequiresRls, + attributes: policyBlockAttributes, }, policy_update: { kind: 'pslBlock', @@ -591,6 +596,7 @@ export const postgresAuthoringPslBlockDescriptors = { permissive: policyPermissiveParam, }, requiresModelAttribute: policyRequiresRls, + attributes: policyBlockAttributes, }, policy_all: { kind: 'pslBlock', @@ -605,6 +611,7 @@ export const postgresAuthoringPslBlockDescriptors = { permissive: policyPermissiveParam, }, requiresModelAttribute: policyRequiresRls, + attributes: policyBlockAttributes, }, /** * PSL block descriptor for `native_enum`. @@ -623,6 +630,7 @@ export const postgresAuthoringPslBlockDescriptors = { name: { required: true }, parameters: {}, variadicParameters: true, + attributes: { map: () => nativeEnumMapAttribute }, }, /** * PSL block descriptor for `role` (e.g. `role anon {}`). Name-only, no diff --git a/packages/3-targets/3-targets/postgres/test/psl-native-enum-authoring.test.ts b/packages/3-targets/3-targets/postgres/test/psl-native-enum-authoring.test.ts index 93cfe1abdd2a..78dfd546e8eb 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-native-enum-authoring.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-native-enum-authoring.test.ts @@ -346,7 +346,7 @@ namespace auth { ); }); - it('an argument-less @@map() emits PSL_NATIVE_ENUM_INVALID_MAP', () => { + it('an argument-less @@map() is a symbol-table diagnostic from the kit', () => { const source = ` namespace auth { native_enum AalLevel { @@ -355,13 +355,14 @@ namespace auth { } } `; - const result = interpret(source); + const { diagnostics } = parsePsl(source); - expect(result.ok).toBe(false); - if (result.ok) return; - expect(result.failure.diagnostics).toEqual( - expect.arrayContaining([expect.objectContaining({ code: 'PSL_NATIVE_ENUM_INVALID_MAP' })]), - ); + expect(diagnostics).toEqual([ + expect.objectContaining({ + code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', + message: 'Attribute "map" is missing required argument "name"', + }), + ]); }); it('a non-string member value emits PSL_EXTENSION_INVALID_VALUE', () => { diff --git a/packages/3-targets/3-targets/postgres/test/psl-policy-map-authoring.test.ts b/packages/3-targets/3-targets/postgres/test/psl-policy-map-authoring.test.ts index 21c5e8bc2ec8..5cc5e82a40a3 100644 --- a/packages/3-targets/3-targets/postgres/test/psl-policy-map-authoring.test.ts +++ b/packages/3-targets/3-targets/postgres/test/psl-policy-map-authoring.test.ts @@ -56,6 +56,15 @@ const scalarColumnDescriptors = new Map { - const result = interpret( + it('an argument-less @@map() is a symbol-table diagnostic from the kit', () => { + const { diagnostics } = parsePsl( policyDoc(` policy_select p_read { target = profile @@ -265,20 +274,16 @@ policy_select p_read { } `), ); - expect(result.ok).toBe(false); - if (result.ok) return; - expect(result.failure.diagnostics).toContainEqual( + expect(diagnostics).toEqual([ expect.objectContaining({ - code: 'PSL_POLICY_INVALID_MAP', - message: - '`policy_select` policy "p_read" @@map attribute must have a quoted, non-empty policy-name argument', - span: expect.anything(), + code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', + message: 'Attribute "map" is missing required argument "name"', }), - ); + ]); }); - it('an unquoted @@map(foo) argument is PSL_POLICY_INVALID_MAP and the policy is skipped', () => { - const result = interpret( + it('an unquoted @@map(foo) argument is a symbol-table diagnostic from the kit', () => { + const { diagnostics } = parsePsl( policyDoc(` policy_select p_read { target = profile @@ -288,20 +293,16 @@ policy_select p_read { } `), ); - expect(result.ok).toBe(false); - if (result.ok) return; - expect(result.failure.diagnostics).toContainEqual( + expect(diagnostics).toEqual([ expect.objectContaining({ - code: 'PSL_POLICY_INVALID_MAP', - message: - '`policy_select` policy "p_read" @@map attribute must have a quoted, non-empty policy-name argument', - span: expect.anything(), + code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', + message: 'Expected a string literal', }), - ); + ]); }); it('an empty @@map("") argument is PSL_POLICY_INVALID_MAP — an empty string is not a legal physical name', () => { - const result = interpret( + const { diagnostics } = parsePsl( policyDoc(` policy_select p_read { target = profile @@ -311,16 +312,12 @@ policy_select p_read { } `), ); - expect(result.ok).toBe(false); - if (result.ok) return; - expect(result.failure.diagnostics).toContainEqual( + expect(diagnostics).toEqual([ expect.objectContaining({ code: 'PSL_POLICY_INVALID_MAP', - message: - '`policy_select` policy "p_read" @@map attribute must have a quoted, non-empty policy-name argument', - span: expect.anything(), + message: '@@map policy name must be a non-empty string', }), - ); + ]); }); it('without @@map the wire lowering is unchanged — head prefix, wire name', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d0059b90bc9..11963383256b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2237,6 +2237,9 @@ importers: '@internal/mongo-value': specifier: workspace:8.0.0-rc.8 version: link:../1-foundation/mongo-value + '@internal/psl-parser': + specifier: workspace:8.0.0-rc.8 + version: link:../../1-framework/2-authoring/psl-parser '@internal/utils': specifier: workspace:8.0.0-rc.8 version: link:../../1-framework/0-foundation/utils @@ -2730,6 +2733,9 @@ importers: '@internal/operations': specifier: workspace:8.0.0-rc.8 version: link:../../1-framework/1-core/operations + '@internal/psl-parser': + specifier: workspace:8.0.0-rc.8 + version: link:../../1-framework/2-authoring/psl-parser '@internal/sql-contract': specifier: workspace:8.0.0-rc.8 version: link:../1-core/contract @@ -2767,9 +2773,6 @@ importers: '@internal/driver-postgres': specifier: workspace:8.0.0-rc.8 version: link:../../3-targets/7-drivers/postgres - '@internal/psl-parser': - specifier: workspace:8.0.0-rc.8 - version: link:../../1-framework/2-authoring/psl-parser '@internal/psl-printer': specifier: workspace:8.0.0-rc.8 version: link:../../1-framework/2-authoring/psl-printer From cf7ed13551ed7e09e231ac30f989bc147cf90831 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 28 Aug 2026 16:22:00 +0000 Subject: [PATCH 06/15] TML-3230: dispatch attribute combinators on syntax kind instead of AST class identity Signed-off-by: Steven McClankerton --- ...bute-spec-combinators.foreign-copy.test.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.foreign-copy.test.ts diff --git a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.foreign-copy.test.ts b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.foreign-copy.test.ts new file mode 100644 index 000000000000..cde85d9ab694 --- /dev/null +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.foreign-copy.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import type { InterpretCtx } from '../src/exports'; +import { + bool, + entityRef, + fieldRef, + funcCall, + identifier, + int, + json, + list, + num, + record, + str, +} from '../src/exports'; +import { Cursor, parse, parseAttribute } from '../src/parse'; +import { buildSymbolTable } from '../src/symbol-table'; +import { FieldAttributeAst } from '../src/syntax/ast/attributes'; +import type { ExpressionAst } from '../src/syntax/ast/expressions'; +import type { SyntaxNode } from '../src/syntax/red'; +import { createSyntaxTree } from '../src/syntax/red'; + +class ForeignCopyOfAnAstNode { + readonly syntax: SyntaxNode; + constructor(syntax: SyntaxNode) { + this.syntax = syntax; + } +} + +function foreignArg(source: string): { arg: ExpressionAst; ctx: InterpretCtx } { + const cursor = new Cursor(`@demo(${source})`); + const node = FieldAttributeAst.cast(createSyntaxTree(parseAttribute(cursor))); + const value = Array.from(node?.argList()?.args() ?? [])[0]?.value(); + if (value === undefined) throw new Error('expected one argument'); + const { document, sourceFile } = parse('model M {\n id Int @id\n}\n'); + const { table } = buildSymbolTable({ document, sourceFile, pslBlockDescriptors: {} }); + const selfModel = table.topLevel.models['M']; + if (selfModel === undefined) throw new Error('expected model M'); + return { + arg: new ForeignCopyOfAnAstNode(value.syntax) as unknown as ExpressionAst, + ctx: { + level: 'field', + sourceId: 'schema.prisma', + sourceFile: cursor.sourceFile, + selfModel, + resolveReferencedModel: () => undefined, + }, + }; +} + +describe('combinators dispatch on syntax kind, not on AST class identity', () => { + it.each([ + ['str', str(), '"x"', 'x'], + ['int', int(), '3', 3], + ['num', num(), '2.5', 2.5], + ['bool', bool(), 'true', true], + ['identifier', identifier('Cascade'), 'Cascade', 'Cascade'], + ['entityRef', entityRef(), 'User', 'User'], + ['fieldRef', fieldRef('self'), 'id', 'id'], + ['json', json(), '"{\\"a\\":1}"', { a: 1 }], + ['list', list(str()), '["a", "b"]', ['a', 'b']], + ['record', record(int()), '{ a: 1 }', { a: 1 }], + ])('%s accepts a node from another module copy', (_name, argType, source, expected) => { + const { arg, ctx } = foreignArg(source); + + const result = argType.parse(arg, ctx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toEqual(expected); + }); + + it('funcCall accepts a node from another module copy', () => { + const { arg, ctx } = foreignArg('now()'); + + const result = funcCall('now', {}).parse(arg, ctx); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toMatchObject({ fn: 'now', args: {} }); + }); +}); From 6eadfa75f2e9bdb564047e087b6f2bb4440f7d9c Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 28 Aug 2026 16:22:28 +0000 Subject: [PATCH 07/15] TML-3230: carry the parsed @@type on the sql family enum test blocks Signed-off-by: Steven McClankerton --- .../2-sql/9-family/test/authoring-entity-types.enum.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/2-sql/9-family/test/authoring-entity-types.enum.test.ts b/packages/2-sql/9-family/test/authoring-entity-types.enum.test.ts index d6215544b144..368f1818e226 100644 --- a/packages/2-sql/9-family/test/authoring-entity-types.enum.test.ts +++ b/packages/2-sql/9-family/test/authoring-entity-types.enum.test.ts @@ -40,7 +40,10 @@ function enumBlock(input: { name: input.name, parameters: input.parameters, blockAttributes: input.typeCodecId !== undefined ? [typeAttr(input.typeCodecId)] : [], - attributes: {}, + attributes: + input.typeCodecId !== undefined + ? { type: { args: { codecId: input.typeCodecId }, span: SPAN } } + : {}, span: SPAN, }; } From 96e697fe0b6cfc58e7a47573ec9926a7507db2fa Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 28 Aug 2026 16:22:29 +0000 Subject: [PATCH 08/15] TML-3230: record the block-attributes-on-kit dispatch loop in the project workspace Signed-off-by: Steven McClankerton --- .../dispatches/04-declare-and-read.md | 22 +++++++++++++++++++ .../slices/block-attributes-on-kit/plan.md | 1 + .../slices/block-attributes-on-kit/spec.md | 2 ++ projects/attribute-registry/trace.jsonl | 9 ++++++++ 4 files changed, 34 insertions(+) create mode 100644 projects/attribute-registry/slices/block-attributes-on-kit/dispatches/04-declare-and-read.md diff --git a/projects/attribute-registry/slices/block-attributes-on-kit/dispatches/04-declare-and-read.md b/projects/attribute-registry/slices/block-attributes-on-kit/dispatches/04-declare-and-read.md new file mode 100644 index 000000000000..438857e35d88 --- /dev/null +++ b/projects/attribute-registry/slices/block-attributes-on-kit/dispatches/04-declare-and-read.md @@ -0,0 +1,22 @@ +# Dispatch 4 — declare-and-read + +Slice plan: `../plan.md` § Dispatch 4. Spec: `../spec.md` § Chosen design 4. + +## Task + +Declare `@@type` on both family `enum` descriptors and `@@map` on postgres `policy_*` / `native_enum`; switch `resolveEnumCodecId` and the two postgres lowerings to `block.attributes`; drop `PSL_NATIVE_ENUM_INVALID_MAP`; move the affected tests to the symbol-table stage — such that every block attribute has exactly one parser (the kit) and consumers read data, never source text. + +## Completed when + +- [ ] `rg 'blockAttributes\.find' packages test examples` → 0. +- [ ] Family enum tests, contract-psl enum/no-check/parity tests, postgres map tests green against descriptors that declare their attributes. +- [ ] Gates: `pnpm build` → workspace typecheck; lint for family-sql, family-mongo, framework-components, target-postgres, sql-contract-psl; tests for those + mongo-contract-psl + language-server; `pnpm lint:deps`; `pnpm fixtures:check`. + +## Edge cases + +| Case | Disposition | +| --- | --- | +| F5 destructive git | forbidden | +| Test-local `enum` descriptors (contract-psl, parity) | gain the `type` attribute via a shared fixture descriptor | +| Kit guarantees `str()` output | readers assert with `invariant`, not a defensive branch | +| No code comments | none added | diff --git a/projects/attribute-registry/slices/block-attributes-on-kit/plan.md b/projects/attribute-registry/slices/block-attributes-on-kit/plan.md index 5257509b11ad..63ea454b37ff 100644 --- a/projects/attribute-registry/slices/block-attributes-on-kit/plan.md +++ b/projects/attribute-registry/slices/block-attributes-on-kit/plan.md @@ -35,6 +35,7 @@ Sequence: kit (psl-parser) → node/descriptor substrate with every constructor - **Outcome:** SQL and Mongo family `enum` descriptors declare `@@type`; postgres `policy_*` and `native_enum` descriptors declare `@@map` (policy with the non-empty `refine` → `PSL_POLICY_INVALID_MAP`); `resolveEnumCodecId`, `lowerRlsPolicyFromBlock`, `lowerNativeEnumFromBlock` read `block.attributes`; `PSL_NATIVE_ENUM_INVALID_MAP` removed; `@internal/family-mongo` depends on `@internal/psl-parser`; tests moved to the symbol-table stage; `rg 'blockAttributes\.find' packages test examples` → 0; `pnpm fixtures:check` clean — such that *every block attribute has exactly one parser (the kit) and consumers read data, never source text*. - **Builds on:** D3's filled `attributes`; D1's `blockAttribute`/`str`. - **Hands to:** slice DoD (all four slice-specific conditions). +- **Added in flight (D4 R1):** the kit's combinators switch from `instanceof` to `XAst.cast(arg.syntax)` and `@internal/family-sql` moves `psl-parser` from devDependencies to dependencies — `fixtures:check` exposed two-copy module resolution (see spec § Pre-investigated edge cases). Own commit; regression test `attribute-spec-combinators.foreign-copy.test.ts`. - **Focus:** `family-sql`, `family-mongo` (`src/core/authoring-entity-types.ts`, `package.json`), `framework-components` `resolveEnumCodecId`, `target-postgres` `src/core/authoring.ts` + `test/psl-policy-map-authoring.test.ts` + `test/psl-native-enum-authoring.test.ts` + enum tests. Postgres edits stay inside the two `@@map` sites and the descriptor literals (slice A runs concurrently on `authoring.ts`'s `@@rls` region). - **Gates:** `pnpm typecheck` · lint for the four touched packages · `pnpm --filter @internal/family-sql test`, `@internal/family-mongo`, `@internal/target-postgres`, `@internal/sql-contract-psl`, `@internal/mongo-contract-psl` · `pnpm lint:deps` · `pnpm fixtures:check` · the grep gate. diff --git a/projects/attribute-registry/slices/block-attributes-on-kit/spec.md b/projects/attribute-registry/slices/block-attributes-on-kit/spec.md index f0a5377faa3b..8ea19d3dedc7 100644 --- a/projects/attribute-registry/slices/block-attributes-on-kit/spec.md +++ b/projects/attribute-registry/slices/block-attributes-on-kit/spec.md @@ -40,6 +40,7 @@ export interface AttributeSpec AttributeSpec`. Nullary: the descriptor a block factory hangs on is already the scoping fact (design-decisions § 8), and no shipped block attribute reads anything else. A parameter is added additively when a factory needs one; `() => X` stays assignable to `(ctx) => X`. - Barrel `src/exports/index.ts` exports `blockAttribute`, `BlockInterpretCtx`, `BlockAttributeSpecFactory`. +- Combinators dispatch on syntax kind (`XAst.cast(arg.syntax)`), never on AST class identity (`arg instanceof XAst`). Amended at D4 (falsified assumption, I12): a family pack's spec and the parser can come from two module copies of `psl-parser` — `@internal/family-sql` had the package as a devDependency, so tsdown inlined a second copy into its dist, and `scripts/regen-example-migrations.mjs` deliberately pairs `src/` providers with the published `@prisma/orm-*` bundles (which carry `@prisma/orm-framework`'s copy). Under either, `instanceof` rejects every argument. Kind dispatch is copy-independent; `@internal/family-sql` moves `psl-parser` to `dependencies` so its dist externalises the package. Pinned by `test/attribute-spec-combinators.foreign-copy.test.ts`. ### 2. Descriptor + node substrate — `@internal/framework-components` @@ -93,6 +94,7 @@ One reviewable unit because the descriptor key, the node field, the reconstructi | `@@map` on a family `enum` block | Diagnoses as unknown | Grep of every `*.psl`/`*.prisma` under `packages`, `test`, `examples`: extension-ish blocks carry only `@@type` (32) and `@@map` (14, all on `native_enum`/`policy_*`); the one `enum … @@map` hit is a comment in `issues-28591-mapped-enums/_fixture/contract.prisma`. No fixture regresses. | | `@@type` diagnostics move from interpretation to symbol-table stage | Accepted | Contract emission fails on either stage; the LSP shows both. Tests asserting the old stage move with it. | | `codecSpan` anchor widens from the argument to the attribute | Accepted | The parsed record carries one span. Family enum tests use a zero span throughout. | +| Spec and parser from different `psl-parser` module copies | Fixed in-slice (D4) | Found by `pnpm fixtures:check`: three example migration regens emit `PSL_INVALID_ATTRIBUTE_SYNTAX` on every `@@type`. See § Chosen design 1, last bullet. | ## Slice-specific done conditions diff --git a/projects/attribute-registry/trace.jsonl b/projects/attribute-registry/trace.jsonl index 4284432225c1..f0e77d14508a 100644 --- a/projects/attribute-registry/trace.jsonl +++ b/projects/attribute-registry/trace.jsonl @@ -112,3 +112,12 @@ {"event_id":"98f34edd-e6ae-4777-b814-0e0a0b73ae28","schema_version":"1","ts":"2026-08-28T15:49:43.911Z","project_run_id":"attribute-registry","orchestrator_agent_id":null,"event_type":"dispatch-start","dispatch_id":"1e6cd6bb-7774-4cf5-9145-14f1649ea53c","dispatch_name":"reconstruct-parses-block-attributes","subagent_type":"fork","model":"fable","parent_dispatch_id":"3afd0d4a-33af-4e47-852f-be525de7d6b8"} {"event_id":"091a943f-8d3b-4970-acbc-019a6cd8c3f6","schema_version":"1","ts":"2026-08-28T15:49:45.639Z","project_run_id":"attribute-registry","orchestrator_agent_id":null,"event_type":"round-start","dispatch_id":"1e6cd6bb-7774-4cf5-9145-14f1649ea53c","round_id":"0179971d-247e-428c-92f6-477beed78450","round_number":1} {"event_id":"6cafaf51-23d3-4da1-8d19-e69209a609d4","schema_version":"1","ts":"2026-08-28T15:49:46.886Z","project_run_id":"attribute-registry","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"1e6cd6bb-7774-4cf5-9145-14f1649ea53c","round_id":"0179971d-247e-428c-92f6-477beed78450","brief_byte_length":1236,"brief_content_hash":"953bd3075b44f82812116ad9b280244af418a784334453788e5bf3cff62fa7ac","brief_disposition":"initial"} +{"event_id":"faefb5ae-43c3-4caa-9b8a-1232fb518851","schema_version":"1","ts":"2026-08-28T15:57:21.357Z","project_run_id":"attribute-registry","orchestrator_agent_id":null,"event_type":"round-end","dispatch_id":"1e6cd6bb-7774-4cf5-9145-14f1649ea53c","round_id":"0179971d-247e-428c-92f6-477beed78450","verdict":"satisfied","findings_filed":0,"wall_clock_ms":454243} +{"event_id":"fcb08176-1e2d-4d17-a47b-4c0c46a67fe8","schema_version":"1","ts":"2026-08-28T15:57:22.302Z","project_run_id":"attribute-registry","orchestrator_agent_id":null,"event_type":"dispatch-end","dispatch_id":"1e6cd6bb-7774-4cf5-9145-14f1649ea53c","result":"completed","wall_clock_ms":455971} +{"event_id":"eefe5031-2521-461d-9e51-15836c8485b0","schema_version":"1","ts":"2026-08-28T16:00:41.731Z","project_run_id":"attribute-registry","orchestrator_agent_id":null,"event_type":"dispatch-start","dispatch_id":"10052fa4-35c3-4c20-8c97-868dd0175bd2","dispatch_name":"declare-and-read","subagent_type":"fork","model":"fable","parent_dispatch_id":"1e6cd6bb-7774-4cf5-9145-14f1649ea53c"} +{"event_id":"797f4de0-d67a-4d33-a749-fc29c7ab9d57","schema_version":"1","ts":"2026-08-28T16:00:42.252Z","project_run_id":"attribute-registry","orchestrator_agent_id":null,"event_type":"round-start","dispatch_id":"10052fa4-35c3-4c20-8c97-868dd0175bd2","round_id":"2c36ae99-1316-415b-9b98-fb9302b5bd32","round_number":1} +{"event_id":"ff4c7498-cfa4-45b2-a6dd-39cd223fe35b","schema_version":"1","ts":"2026-08-28T16:00:42.890Z","project_run_id":"attribute-registry","orchestrator_agent_id":null,"event_type":"brief-issued","dispatch_id":"10052fa4-35c3-4c20-8c97-868dd0175bd2","round_id":"2c36ae99-1316-415b-9b98-fb9302b5bd32","brief_byte_length":1306,"brief_content_hash":"70884d3359651725eb17c3a61aa3b2d468b4a5808b0889d80f370f79ded03367","brief_disposition":"initial"} +{"event_id":"f93622cc-f61e-4752-8f45-dec5f61210d2","schema_version":"1","ts":"2026-08-28T16:21:53.483Z","project_run_id":"attribute-registry","orchestrator_agent_id":null,"event_type":"spec-amended","spec_kind":"slice","spec_path":"projects/attribute-registry/slices/block-attributes-on-kit/spec.md","byte_length":15644,"bytes_delta":1014,"edge_cases_count":4,"open_questions_count":1,"dod_items_count":4,"reason":"falsified-assumption","sections_changed":["Chosen design","Pre-investigated edge cases"]} +{"event_id":"e7727eed-5d5a-41a0-adc9-b72a66395455","schema_version":"1","ts":"2026-08-28T16:21:53.973Z","project_run_id":"attribute-registry","orchestrator_agent_id":null,"event_type":"plan-amended","plan_kind":"slice","plan_path":"projects/attribute-registry/slices/block-attributes-on-kit/plan.md","byte_length":9785,"bytes_delta":366,"dispatch_count":4,"slice_count":null,"dispatch_size_distribution":{"S":0,"M":3,"L":1,"XL":0},"open_items_count":2,"reason":"falsified-assumption","dispatches_added":0,"dispatches_removed":0,"dispatches_resized":1} +{"event_id":"071fac43-cc04-40a3-b8df-f5d9b55c7e56","schema_version":"1","ts":"2026-08-28T16:22:11.831Z","project_run_id":"attribute-registry","orchestrator_agent_id":null,"event_type":"round-end","dispatch_id":"10052fa4-35c3-4c20-8c97-868dd0175bd2","round_id":"2c36ae99-1316-415b-9b98-fb9302b5bd32","verdict":"satisfied","findings_filed":0,"wall_clock_ms":1289122} +{"event_id":"06d3e827-6b5e-47e5-875d-22871bc0cae6","schema_version":"1","ts":"2026-08-28T16:22:12.296Z","project_run_id":"attribute-registry","orchestrator_agent_id":null,"event_type":"dispatch-end","dispatch_id":"10052fa4-35c3-4c20-8c97-868dd0175bd2","result":"completed","wall_clock_ms":1289643} From d51f7b5a4b2ffaf7304865934747473f77b102b1 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 28 Aug 2026 16:42:35 +0000 Subject: [PATCH 09/15] TML-3230: assert kit-parsed enum codec ids, reject non-function block-attribute factories at assembly, amend ADR 126/231 Signed-off-by: Steven McClankerton --- .../adrs/ADR 126 - PSL top-level block SPI.md | 2 +- ... - Declarative attribute specifications.md | 3 +- .../src/shared/framework-authoring.ts | 16 ++++----- .../test/control-stack.test.ts | 23 ++++++++++++ ...-entity-types.enum-block-attribute.test.ts | 36 +++++++++++++++++++ 5 files changed, 67 insertions(+), 13 deletions(-) create mode 100644 packages/2-sql/9-family/test/authoring-entity-types.enum-block-attribute.test.ts diff --git a/docs/architecture docs/adrs/ADR 126 - PSL top-level block SPI.md b/docs/architecture docs/adrs/ADR 126 - PSL top-level block SPI.md index eebd2473c7ab..38e917277583 100644 --- a/docs/architecture docs/adrs/ADR 126 - PSL top-level block SPI.md +++ b/docs/architecture docs/adrs/ADR 126 - PSL top-level block SPI.md @@ -66,7 +66,7 @@ A parameter is one of four kinds. The split is principled, not incidental: ## How the framework interprets a block -**Parse.** On an unknown top-level keyword, the framework looks it up in the `pslBlockDescriptors` registry. If a descriptor claims it, the generic parser reads the block into a `PslExtensionBlock` node — a name plus a `parameters` map keyed by parameter name. No extension code runs. +**Parse.** On an unknown top-level keyword, the framework looks it up in the `pslBlockDescriptors` registry. If a descriptor claims it, the generic parser reads the block into a `PslExtensionBlock` node — a name, a `parameters` map keyed by parameter name, and an `attributes` map holding the `@@` attributes interpreted through the specs the descriptor declares in `attributes`. No extension code runs. **Validate.** The validator checks, at parse time and with source spans: unknown parameters; missing required parameters; an `option` value outside the declared set; a `value` the codec's `decodeJson` rejects; and a `ref` that doesn't resolve within its declared scope. 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 c5c3cca51f55..b2b089b1d9d7 100644 --- a/docs/architecture docs/adrs/ADR 231 - Declarative attribute specifications.md +++ b/docs/architecture docs/adrs/ADR 231 - Declarative attribute specifications.md @@ -46,7 +46,7 @@ The SQL and Mongo family interpreters are the first consumers. They define their The kit consumes `ExpressionAst` directly. No intermediate argument representation is introduced, and no combinator reparses flattened source text except `json()`, the deliberate quoted-JSON-object exception. -Attributes are a PSL authoring concern, so the kit lives in `psl-parser` rather than framework core. The current constructors cover field and model attributes. `AttributeLevel` reserves a block level, but generic-block attribute construction and interpretation remain future work. +Attributes are a PSL authoring concern, so the kit is in `psl-parser` rather than framework core. The constructors cover field, model, and block attributes: `blockAttribute()` builds a spec over `BlockInterpretCtx` (no `selfModel`), a block descriptor declares its attributes on `AuthoringPslBlockDescriptor.attributes` as nullary factories, and the generic block reconstruction interprets them into `PslExtensionBlock.attributes`. --- @@ -298,7 +298,6 @@ The current implementation is sufficient for interpreter consumption but not yet - Add central spec discovery and traversable combinator metadata for language-tooling consumers. - Decide whether reference combinators should expose declaration-bearing results while preserving the interpreter's string-oriented lowering needs. -- Add block-level construction and interpretation if generic-block attributes adopt this mechanism. - Revisit signature-derived `TypedFuncCall` output types if downstream code needs statically discriminated call unions. - Decide whether literal-to-field-type compatibility should remain in lowering or gain a dedicated field-context combinator. diff --git a/packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts b/packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts index 9212346e586a..ecc848fe6c28 100644 --- a/packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts +++ b/packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts @@ -7,6 +7,7 @@ import { isColumnDefaultLiteralInputValue, isExecutionMutationDefaultValue, } from '@internal/contract/types'; +import { invariant } from '@internal/utils/assertions'; import { blindCast } from '@internal/utils/casts'; import { ifDefined } from '@internal/utils/defined'; import { InternalError } from '@internal/utils/internal-error'; @@ -347,15 +348,7 @@ export function resolveEnumCodecId( } const codecId = typeAttr.args['codecId']; - if (typeof codecId !== 'string') { - ctx.diagnostics?.push({ - code: 'PSL_ENUM_MISSING_TYPE', - message: `enum "${block.name}" @@type attribute must have a quoted codec id argument`, - sourceId, - span: typeAttr.span, - }); - return undefined; - } + invariant(typeof codecId === 'string', '@@type on an enum block parses one string argument'); return { codecId, codecSpan: typeAttr.span }; } @@ -737,7 +730,10 @@ function isWellFormedDescriptor(value: unknown, descriptorKind: string): boolean } if (!('attributes' in value) || value.attributes === undefined) return true; const attributes = value.attributes; - return typeof attributes === 'object' && attributes !== null && !Array.isArray(attributes); + if (typeof attributes !== 'object' || attributes === null || Array.isArray(attributes)) { + return false; + } + return Object.values(attributes).every((factory) => typeof factory === 'function'); } case 'modelAttribute': { if ( diff --git a/packages/1-framework/1-core/framework-components/test/control-stack.test.ts b/packages/1-framework/1-core/framework-components/test/control-stack.test.ts index 156629582f99..b3a2df664ba6 100644 --- a/packages/1-framework/1-core/framework-components/test/control-stack.test.ts +++ b/packages/1-framework/1-core/framework-components/test/control-stack.test.ts @@ -744,6 +744,29 @@ describe('assembleAuthoringContributions', () => { }); }); + it.each([ + ['an undefined factory', { map: undefined }], + ['a non-function factory', { map: 'map' }], + ])('rejects a pslBlockDescriptors entry whose attributes carries %s', (_label, attributes) => { + expect(() => + assembleAuthoringContributions([ + createDescriptor({ + authoring: { + entityTypes: { + foo: { kind: 'entity', discriminator: 'fake-foo', output: { factory: () => ({}) } }, + }, + pslBlockDescriptors: { + fooBlock: { + ...makeDeclarativePslBlockDescriptor('fake-foo'), + attributes, + } as unknown as never, + }, + }, + }), + ]), + ).toThrow(/Malformed authoring pslBlock contribution at "fooBlock"/); + }); + it('rejects a pslBlockDescriptors entry whose attributes is not a record', () => { expect(() => assembleAuthoringContributions([ diff --git a/packages/2-sql/9-family/test/authoring-entity-types.enum-block-attribute.test.ts b/packages/2-sql/9-family/test/authoring-entity-types.enum-block-attribute.test.ts new file mode 100644 index 000000000000..1318cbb5fc4e --- /dev/null +++ b/packages/2-sql/9-family/test/authoring-entity-types.enum-block-attribute.test.ts @@ -0,0 +1,36 @@ +import { buildSymbolTable } from '@internal/psl-parser'; +import { parse } from '@internal/psl-parser/syntax'; +import { describe, expect, it } from 'vitest'; +import { sqlFamilyPslBlockDescriptors } from '../src/core/authoring-entity-types'; + +function build(source: string) { + const { document, sourceFile } = parse(source); + return buildSymbolTable({ + document, + sourceFile, + pslBlockDescriptors: sqlFamilyPslBlockDescriptors, + }); +} + +describe('enum @@type through the family descriptor', () => { + it('parses the codec id into the block attributes', () => { + const result = build('enum Role {\n @@type("pg/text@1")\n Admin\n}'); + + expect(result.diagnostics).toEqual([]); + expect(result.table.topLevel.blocks['Role']?.block.attributes['type']?.args).toEqual({ + codecId: 'pg/text@1', + }); + }); + + it('rejects a non-string argument at symbol-table time', () => { + const result = build('enum Role {\n @@type(foo)\n Admin\n}'); + + expect(result.diagnostics).toEqual([ + expect.objectContaining({ + code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', + message: 'Expected a string literal', + }), + ]); + expect(result.table.topLevel.blocks['Role']?.block.attributes).toEqual({}); + }); +}); From 1755491221b103cec6b5c4b6d2643552c07d6247 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Fri, 28 Aug 2026 16:42:40 +0000 Subject: [PATCH 10/15] TML-3230: record extension upgrade instructions for the block-attribute node, code, and ArgType changes Signed-off-by: Steven McClankerton --- projects/attribute-registry/plan.md | 2 +- ...02-block-attribute-node-and-descriptor.md} | 2 +- .../slices/block-attributes-on-kit/plan.md | 14 +++---- .../slices/block-attributes-on-kit/spec.md | 6 +-- .../8.0.0-rc.8-to-8.0.0-rc.9/instructions.md | 37 +++++++++++++++++++ 5 files changed, 49 insertions(+), 12 deletions(-) rename projects/attribute-registry/slices/block-attributes-on-kit/dispatches/{02-block-attribute-substrate.md => 02-block-attribute-node-and-descriptor.md} (96%) diff --git a/projects/attribute-registry/plan.md b/projects/attribute-registry/plan.md index a5121277d252..f57937d2ba6a 100644 --- a/projects/attribute-registry/plan.md +++ b/projects/attribute-registry/plan.md @@ -36,7 +36,7 @@ Four slices: one shared-machinery slice first (uniform ctx + assembler + ADR 236 ### Parallel group C (after `registry-core`) - **Slice `block-attributes-on-kit`** — Linear: [TML-3230](https://linear.app/prisma-company/issue/TML-3230) — `slices/block-attributes-on-kit/` - - **Outcome:** Block-level attributes ride the kit: `blockAttribute()` constructor + block-level ctx variant (no `selfModel` requirement), `AuthoringPslBlockDescriptor.attributes` (sibling of `parameters`) declaring `@@type` and extension-block `@@map`, kit-parsed typed values attached to `PslExtensionBlock` as plain data, and all three hand-parsing sites migrated (`framework-authoring.ts:333`, postgres `authoring.ts:272,330`) — no `blockAttributes.find` outside the generic machinery. Unknown block-attribute names diagnose against descriptor keys; consumers read a block's attributes off its descriptor (never the flat assembled structure). + - **Outcome:** Block-level attributes are declared on the kit: `blockAttribute()` constructor + block-level ctx variant (no `selfModel` requirement), `AuthoringPslBlockDescriptor.attributes` (sibling of `parameters`) declaring `@@type` and extension-block `@@map`, kit-parsed typed values attached to `PslExtensionBlock` as plain data, and all three hand-parsing sites migrated (`framework-authoring.ts:333`, postgres `authoring.ts:272,330`) — no `blockAttributes.find` outside the generic machinery. Unknown block-attribute names diagnose against descriptor keys; consumers read a block's attributes off its descriptor (never the flat assembled structure). - **Builds on:** `registry-core`'s kit + ctx machinery (block-level ctx variant extends it). - **Hands to:** Project-DoD items: block-attribute declaration, hand-parse grep gate, block-level unknown-name diagnostics. - **Focus:** Block level only; flat field/model registration belongs to groups A/B. diff --git a/projects/attribute-registry/slices/block-attributes-on-kit/dispatches/02-block-attribute-substrate.md b/projects/attribute-registry/slices/block-attributes-on-kit/dispatches/02-block-attribute-node-and-descriptor.md similarity index 96% rename from projects/attribute-registry/slices/block-attributes-on-kit/dispatches/02-block-attribute-substrate.md rename to projects/attribute-registry/slices/block-attributes-on-kit/dispatches/02-block-attribute-node-and-descriptor.md index ecdf41bc1c6a..93f093a7c941 100644 --- a/projects/attribute-registry/slices/block-attributes-on-kit/dispatches/02-block-attribute-substrate.md +++ b/projects/attribute-registry/slices/block-attributes-on-kit/dispatches/02-block-attribute-node-and-descriptor.md @@ -1,4 +1,4 @@ -# Dispatch 2 — block-attribute-substrate +# Dispatch 2 — block-attribute-node-and-descriptor Slice plan: `../plan.md` § Dispatch 2. Spec: `../spec.md` § Chosen design 2. diff --git a/projects/attribute-registry/slices/block-attributes-on-kit/plan.md b/projects/attribute-registry/slices/block-attributes-on-kit/plan.md index 63ea454b37ff..a86fa1ba3f32 100644 --- a/projects/attribute-registry/slices/block-attributes-on-kit/plan.md +++ b/projects/attribute-registry/slices/block-attributes-on-kit/plan.md @@ -2,7 +2,7 @@ **Spec:** [`./spec.md`](./spec.md) · **Linear:** [TML-3230](https://linear.app/prisma-company/issue/TML-3230) -Sequence: kit (psl-parser) → node/descriptor substrate with every constructor adapted (core + fan-out) → reconstruction fills the substrate (psl-parser) → declarations + readers switch (families + postgres). Sandwich-shaped; each hand-off is a state the workspace typechecks and tests green in. +Sequence: kit (psl-parser) → node and descriptor fields with every constructor adapted (core + fan-out) → reconstruction fills the node field (psl-parser) → declarations + readers switch (families + postgres). Sandwich-shaped; each hand-off is a state the workspace typechecks and tests green in. ## Dispatch plan @@ -14,10 +14,10 @@ Sequence: kit (psl-parser) → node/descriptor substrate with every constructor - **Focus:** `packages/1-framework/2-authoring/psl-parser` only (`src/attribute-spec/**`, `src/exports/index.ts`, `test/`). No descriptor, node, or consumer changes. - **Gates:** `cd packages/1-framework/2-authoring/psl-parser && pnpm typecheck` (incl. test project) · `pnpm --filter @internal/psl-parser lint` · `pnpm --filter @internal/psl-parser test` · workspace `pnpm typecheck` (kit consumers) · grep: `rg 'selfModel' packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators` still names only `field-ref.ts`. -### Dispatch 2: block-attribute-substrate +### Dispatch 2: block-attribute-node-and-descriptor - **Outcome:** `AuthoringPslBlockDescriptor.attributes?` (erased), `PslExtensionBlock.attributes` (required) + `PslExtensionBlockParsedAttribute`, and `PSL_EXTENSION_UNKNOWN_BLOCK_ATTRIBUTE` exist in `@internal/framework-components` and are exported; the descriptor walker accepts the key; `reconstructExtensionBlock` sets `attributes: {}`; every in-repo `PslExtensionBlock` literal (psl-infer builders, supabase script, printer/framework/family/validator tests) carries `attributes` — such that *the node is total: a consumer reads `block.attributes` without an existence check, and nothing but the generic machinery can produce a block node lacking it*. `resolveEnumCodecId` is untouched here. -- **Builds on:** none in-slice (core substrate); D1 only for the vocabulary the doc contract names. +- **Builds on:** none in-slice (core-only change); D1 only for the vocabulary the doc contract names. - **Hands to:** a workspace where `attributes` is present (empty) on every block node — the surface D3 fills and D4 reads. - **Focus:** `@internal/framework-components` (`src/shared/psl-extension-block.ts`, `src/shared/framework-authoring.ts`, exports, tests) + the mechanical fan-out to node constructors across packages. Mechanical-fan-out shape: one transformation (`attributes: {}` or the synthesised `map` entry beside the existing `blockAttributes` entry), no judgment. - **Gates:** `pnpm build` of `framework-components` then workspace `pnpm typecheck` · `pnpm --filter @internal/framework-components lint && test` · `pnpm --filter @internal/target-postgres test` (psl-infer print tests) · `pnpm lint:deps` · grep: `rg -n 'blockAttributes:' packages --type ts` count equals `rg -n 'attributes:' … ` count at the same sites (every constructor adapted). @@ -25,7 +25,7 @@ Sequence: kit (psl-parser) → node/descriptor substrate with every constructor ### Dispatch 3: reconstruct-parses-block-attributes - **Outcome:** `reconstructExtensionBlock` runs the descriptor's block-attribute factories through `interpretAttribute` with a `BlockInterpretCtx`, fills `attributes`, converts kit diagnostics to `ParseDiagnostic`s (code widened to `PslDiagnostic['code']`), diagnoses unknown names with `PSL_EXTENSION_UNKNOWN_BLOCK_ATTRIBUTE` and duplicates first-wins with `PSL_INVALID_EXTENSION_BLOCK_ATTRIBUTE`; the one new `blindCast` narrows the erased factory. Tests (`symbol-table.test.ts` or a sibling file) cover: parsed value + span, unknown name, duplicate, arity/type failure surfacing as a symbol-table diagnostic, descriptor without `attributes`, descriptor `undefined` — such that *the LSP pipeline and the build see identical block-attribute diagnostics because both run `buildSymbolTable`*. -- **Builds on:** D1's kit + D2's substrate. +- **Builds on:** D1's kit + D2's node and descriptor fields. - **Hands to:** symbol tables whose block nodes carry parsed attributes for every declared attribute; nothing declares any yet, so every `@@` line diagnoses as unknown until D4 lands (D3 and D4 must merge together — D3 is not a shippable stop; the slice PR is the unit). - **Focus:** `@internal/psl-parser` (`src/block-reconstruction.ts`, `src/parse.ts` code type, `src/symbol-table.ts` if `sourceId` must thread, tests). No consumer changes. - **Gates:** psl-parser typecheck/lint/test · workspace `pnpm typecheck` (`ParseDiagnostic.code` widening) · `pnpm --filter @internal/language-server test` · grep: `rg 'blindCast' packages/1-framework/2-authoring/psl-parser/src` count = base + 1. @@ -41,8 +41,8 @@ Sequence: kit (psl-parser) → node/descriptor substrate with every constructor ## Handoff-contract checks -- **Linearity:** D1 → D3 (kit), D2 → D3 (substrate), D3 → D4. D2 does not build on D1 (independent core change) — the two could land in either order; D4 reads both. -- **Completeness:** slice-DoD ← D4 (grep gate, fixtures, diagnostics through `buildSymbolTable`), D3 (the single narrow), D2/D1 (substrate + kit). Cast budget verified slice-wide at close. +- **Linearity:** D1 → D3 (kit), D2 → D3 (node and descriptor fields), D3 → D4. D2 does not build on D1 (independent core change) — the two could land in either order; D4 reads both. +- **Completeness:** slice-DoD ← D4 (grep gate, fixtures, diagnostics through `buildSymbolTable`), D3 (the single narrow), D2/D1 (node/descriptor fields + kit). Cast budget verified slice-wide at close. ## Calibration references (thread into briefs) @@ -50,7 +50,7 @@ Failure modes ([`drive/calibration/failure-modes.md`](../../../../drive/calibrat - **F5** — destructive git operations forbidden. - **F3** — broken consumers of the required `attributes` field and of `ArgType.parse` discovered by `rg`, not by repeated test runs. -- **F11** — placement pinned: kit + reconstruction in `psl-parser`; descriptor/node/diagnostic-code in `framework-components`; specs declared where the descriptors live. +- **F11** — placement pinned: kit + reconstruction in `psl-parser`; descriptor/node/diagnostic-code in `framework-components`; specs declared in the files that define the descriptors. - **F16/F17** — property statements in every outcome above; a self-acknowledged layering comment is a HALT. Core never imports `psl-parser`; the block factory is erased in core and narrowed once. - **F13/F15** — D3's tests must go red if reconstruction stops running the factories or stops diagnosing unknown names; D4's migrated tests must fail if the kit-parsed value is bypassed. - **F14** — gates mirror CI (lint per package; typecheck covers `test/`); sync `origin/main` before slice-close validation and push. diff --git a/projects/attribute-registry/slices/block-attributes-on-kit/spec.md b/projects/attribute-registry/slices/block-attributes-on-kit/spec.md index 8ea19d3dedc7..28deac59ee88 100644 --- a/projects/attribute-registry/slices/block-attributes-on-kit/spec.md +++ b/projects/attribute-registry/slices/block-attributes-on-kit/spec.md @@ -42,9 +42,9 @@ export interface AttributeSpec>` — attribute name → erased `BlockAttributeSpecFactory`. Erased for the same reason as `AuthoringModelAttributeDescriptor.spec` and `AuthoringContributions.attributeSpecs`: `AttributeSpec` lives in `psl-parser`; core transits, `psl-parser` narrows. Absent means "this block declares no attributes" — every `@@` line is then unknown. +- `AuthoringPslBlockDescriptor` (`src/shared/framework-authoring.ts:432`) gains `readonly attributes?: Readonly>` — attribute name → erased `BlockAttributeSpecFactory`. Erased for the same reason as `AuthoringModelAttributeDescriptor.spec` and `AuthoringContributions.attributeSpecs`: `AttributeSpec` is defined in `psl-parser`; core transits, `psl-parser` narrows. Absent means "this block declares no attributes" — every `@@` line is then unknown. - `PslExtensionBlock` (`src/shared/psl-extension-block.ts:288`) gains `readonly attributes: Readonly>` where `PslExtensionBlockParsedAttribute = { readonly args: Readonly>; readonly span: PslSpan }` — the kit's bound output keyed by parameter key, plus the attribute's span for consumers that anchor diagnostics. `blockAttributes` stays: it is the source-shaped record the printer round-trips (`psl-printer/src/serialize-print-document.ts:166`) and the `psl-infer` builders synthesise (`postgres/src/core/psl-infer/infer-enum-blocks.ts:87`, `infer-policy-blocks.ts:128`, `extensions/supabase/scripts/generate-contract.ts:240`); those builders populate `attributes` alongside it. - `PslDiagnosticCode` gains `PSL_EXTENSION_UNKNOWN_BLOCK_ATTRIBUTE`. - The descriptor well-formedness walker (`isWellFormedDescriptor` `:717`, `collectPslBlockDescriptorEntries` `:1157`) accepts the optional key; the `pslBlock` types test (`test/psl-block-descriptor.types.test.ts`) pins the shape. @@ -69,7 +69,7 @@ Descriptor `undefined` (unknown keyword) → `attributes` is `{}`, no attribute ## Coherence rationale -One reviewable unit because the descriptor key, the node field, the reconstruction that fills it, and the three readers are one contract: shipping the kit + substrate without the readers leaves two parsers for the same attribute (the exact drift this project exists to remove), and shipping the readers without the reconstruction has nothing to read. Every commit inside the slice is a stable state (kit → substrate with empty `attributes` → reconstruction fills it → readers switch), which is what makes it one PR rather than one dispatch. +One reviewable unit because the descriptor key, the node field, the reconstruction that fills it, and the three readers are one contract: shipping the kit + node/descriptor fields without the readers leaves two parsers for the same attribute (the exact drift this project exists to remove), and shipping the readers without the reconstruction has nothing to read. Every commit inside the slice is a stable state (kit → node with empty `attributes` → reconstruction fills it → readers switch), which is what makes it one PR rather than one dispatch. ## Scope diff --git a/skills/prisma-8/upgrading/extension/upgrades/8.0.0-rc.8-to-8.0.0-rc.9/instructions.md b/skills/prisma-8/upgrading/extension/upgrades/8.0.0-rc.8-to-8.0.0-rc.9/instructions.md index 12a105bae253..dad2b89acff3 100644 --- a/skills/prisma-8/upgrading/extension/upgrades/8.0.0-rc.8-to-8.0.0-rc.9/instructions.md +++ b/skills/prisma-8/upgrading/extension/upgrades/8.0.0-rc.8-to-8.0.0-rc.9/instructions.md @@ -8,6 +8,31 @@ changes: - id: namespace-qualify-sql-orm-filter-types summary: | SQL ORM reusable filter types now require the domain namespace before the model name: ``. + - id: add-attributes-to-psl-extension-block-literals + summary: | + `PslExtensionBlock` gained a required `attributes` record (attribute name → `{ args, span }`, the kit-parsed values of the block's `@@` attributes). Every hand-built block node — synthesised blocks in scripts, inference builders, and test fixtures — must set `attributes` next to `blockAttributes` (`{}` when the block carries no attributes). + detection: + glob: "**/*.{ts,tsx}" + contains: + - "blockAttributes:" + anyMatch: true + - id: read-native-enum-map-failures-from-the-kit + summary: | + `PSL_NATIVE_ENUM_INVALID_MAP` no longer exists. A malformed `@@map` on a `native_enum` block — and every other malformed block attribute — is reported at symbol-table time as `PSL_INVALID_ATTRIBUTE_SYNTAX`; only the policy `@@map("")` empty-name rule keeps its own code (`PSL_POLICY_INVALID_MAP`). Replace references to the removed code and assert those diagnostics on the `buildSymbolTable` result rather than on the interpretation result. + detection: + glob: "**/*.{ts,tsx}" + contains: + - "PSL_NATIVE_ENUM_INVALID_MAP" + anyMatch: true + - id: arg-type-parse-is-a-property + summary: | + `ArgType.parse` is now a property function type carrying a `Ctx` parameter (`ArgType`), so the ctx an argument type needs is checked contravariantly. A class that implements `ArgType` with a `parse(...)` method, or an object typed against `ArgType` and used inside `blockAttribute()`, must declare `parse` as a function-typed property over the ctx it actually reads (`BlockInterpretCtx` when it never touches `selfModel`). + detection: + glob: "**/*.{ts,tsx}" + contains: + - "implements ArgType" + - "ArgType<" + anyMatch: true --- # 8.0.0-rc.8 → 8.0.0-rc.9 — Extension author upgrade instructions @@ -19,3 +44,15 @@ Find SQL ORM calls to `upsert()`, `createAll()`, and `createAndCount()` whose cr ## `namespace-qualify-sql-orm-filter-types` Find TypeScript references to `ShorthandWhereFilter`, `RelationPredicate`, `RelationPredicateInput`, and `RelationFilterAccessor`. Add the model's domain namespace as the second generic argument and place the model name third. Rewrite `ShorthandWhereFilter` as `ShorthandWhereFilter` and `ShorthandWhereFilter` as `ShorthandWhereFilter`. Rewrite the relation types from `` to ``. For predicates targeting a relation, use the namespace declared by that relation's `to.namespace` coordinate. + +## `add-attributes-to-psl-extension-block-literals` + +Find every object literal typed as `PslExtensionBlock` (they carry `kind`, `keyword`, `name`, `parameters`, `blockAttributes`, `span`). Add `attributes` beside `blockAttributes`. A block with no `@@` attributes takes `attributes: {}`. A block synthesised with a `blockAttributes` entry takes the parsed shape of that entry, keyed by attribute name with the spec's positional keys as `args` — for example a synthesised `@@map("x")` on a `policy_*` or `native_enum` block becomes `attributes: { map: { args: { name: 'x' }, span } }`. Consumers that read a block attribute read `block.attributes[name]?.args`, never `block.blockAttributes`. + +## `read-native-enum-map-failures-from-the-kit` + +Delete every reference to `PSL_NATIVE_ENUM_INVALID_MAP`. Where a test asserted that code after interpreting a document, parse the document and assert `PSL_INVALID_ATTRIBUTE_SYNTAX` on the diagnostics `buildSymbolTable` returns instead; interpretation no longer sees a malformed block attribute. A `@@map(foo)` argument reports `Expected a string literal`; a missing argument reports `Attribute "map" is missing required argument "name"`. + +## `arg-type-parse-is-a-property` + +Find classes declaring `implements ArgType<…>` with a `parse(arg, ctx)` method and object literals typed against `ArgType`. Declare `parse` as a property whose type is `(arg: ExpressionAst, ctx: Ctx) => Result`. Pick `Ctx = BlockInterpretCtx` when the implementation reads only `sourceId` / `sourceFile` (this makes the argument type usable inside `blockAttribute()` specs); keep the default `InterpretCtx` when it reads `selfModel` or `resolveReferencedModel()`. Dispatch on the syntax node with `XAst.cast(arg.syntax)` rather than `arg instanceof XAst` so the argument type keeps working when the spec and the parser come from different module copies. From 68c1f28933756cf99e0ecde3aee27127d455a8ba Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Wed, 9 Sep 2026 09:55:54 +0000 Subject: [PATCH 11/15] TML-3230: split the attribute interpret context by what each level actually has The single InterpretCtx claimed a model context is a kind of block context and made the field optional. It becomes AttributeCtx (source only), ModelAttributeCtx (adds selfModel), and FieldAttributeCtx (adds a required field and resolveReferencedModel). Contexts no longer carry a level, and the five spec generics lose their default type argument so every declaration names the context it reads. fieldRef splits into fieldRef() over the model context and referencedFieldRef() over the field context, and oneOf collapses to one signature whose context is the intersection of its alternatives. Signed-off-by: Steven McClankerton --- ... - Declarative attribute specifications.md | 49 ++++++----- .../src/attribute-spec/block-attribute.ts | 20 ++--- .../src/attribute-spec/combinators/bool.ts | 4 +- .../attribute-spec/combinators/diagnostic.ts | 4 +- .../attribute-spec/combinators/entity-ref.ts | 4 +- .../attribute-spec/combinators/field-ref.ts | 56 +++++++------ .../attribute-spec/combinators/func-call.ts | 10 +-- .../attribute-spec/combinators/identifier.ts | 4 +- .../src/attribute-spec/combinators/int.ts | 4 +- .../src/attribute-spec/combinators/json.ts | 4 +- .../src/attribute-spec/combinators/list.ts | 4 +- .../src/attribute-spec/combinators/num.ts | 8 +- .../src/attribute-spec/combinators/one-of.ts | 29 ++++--- .../src/attribute-spec/combinators/record.ts | 4 +- .../src/attribute-spec/combinators/str.ts | 8 +- .../src/attribute-spec/field-attribute.ts | 23 +++-- .../src/attribute-spec/interpret.ts | 14 ++-- .../src/attribute-spec/model-attribute.ts | 23 +++-- .../psl-parser/src/attribute-spec/optional.ts | 4 +- .../src/attribute-spec/spec-context.ts | 12 ++- .../psl-parser/src/attribute-spec/types.ts | 23 ++--- .../psl-parser/src/block-reconstruction.ts | 1 - .../psl-parser/src/exports/index.ts | 10 ++- .../test/attribute-spec-block.test-d.ts | 84 ++++++++++++++++--- .../test/attribute-spec-block.test.ts | 6 +- ...bute-spec-combinators.foreign-copy.test.ts | 8 +- .../test/attribute-spec-combinators.test-d.ts | 8 +- .../test/attribute-spec-combinators.test.ts | 48 +++++++---- .../psl-parser/test/attribute-spec.test-d.ts | 8 +- .../psl-parser/test/attribute-spec.test.ts | 14 ++-- .../contract-psl/src/mongo-attribute-specs.ts | 44 +++++----- .../contract-psl/src/sql-attribute-specs.ts | 60 +++++++------ .../8.0.0-rc.8-to-8.0.0-rc.9/instructions.md | 32 ++++++- 33 files changed, 384 insertions(+), 250 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 b2b089b1d9d7..e5787a843f98 100644 --- a/docs/architecture docs/adrs/ADR 231 - Declarative attribute specifications.md +++ b/docs/architecture docs/adrs/ADR 231 - Declarative attribute specifications.md @@ -15,8 +15,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(), { nonEmpty: true })), + references: optional(list(referencedFieldRef(), { nonEmpty: true })), map: optional(str()), onDelete: optional( oneOf( @@ -46,7 +46,7 @@ The SQL and Mongo family interpreters are the first consumers. They define their The kit consumes `ExpressionAst` directly. No intermediate argument representation is introduced, and no combinator reparses flattened source text except `json()`, the deliberate quoted-JSON-object exception. -Attributes are a PSL authoring concern, so the kit is in `psl-parser` rather than framework core. The constructors cover field, model, and block attributes: `blockAttribute()` builds a spec over `BlockInterpretCtx` (no `selfModel`), a block descriptor declares its attributes on `AuthoringPslBlockDescriptor.attributes` as nullary factories, and the generic block reconstruction interprets them into `PslExtensionBlock.attributes`. +Attributes are a PSL authoring concern, so the kit is in `psl-parser` rather than framework core. The constructors cover field, model, and block attributes: `blockAttribute()` builds a spec over the bare `AttributeCtx` (no model), a block descriptor declares its attributes on `AuthoringPslBlockDescriptor.attributes` as nullary factories, and the generic block reconstruction interprets them into `PslExtensionBlock.attributes`. --- @@ -67,44 +67,47 @@ Attributes are a PSL authoring concern, so the kit is in `psl-parser` rather tha An argument combinator parses one `ExpressionAst` into `T`: ```ts -interface ArgType { +interface ArgType { readonly kind: string; readonly label: string; readonly _out?: T; - parse(arg: ExpressionAst, ctx: InterpretCtx): Result; + readonly parse: (arg: ExpressionAst, ctx: Ctx) => Result; } ``` -The context contains the source and family symbols needed by the shipped reference combinators: +`Ctx` states what a combinator reads. The three contexts nest by what the site being parsed actually has, so a spec cannot demand facts its level never carries. There is no default type argument: every declaration names its context. ```ts -interface InterpretCtx { - readonly level: 'field' | 'model' | 'block'; +interface AttributeCtx { readonly sourceId: string; readonly sourceFile: SourceFile; +} + +interface ModelAttributeCtx extends AttributeCtx { readonly selfModel: ModelSymbol; +} + +interface FieldAttributeCtx extends ModelAttributeCtx { + readonly field: FieldSymbol; resolveReferencedModel(): ModelSymbol | undefined; - readonly field?: FieldSymbol; } ``` +A block attribute is parsed with an `AttributeCtx` — a block has no model, so there is no separate block context type. `parse` is a property function type, so `Ctx` is checked contravariantly: a combinator over `AttributeCtx` is usable at every level, and one over `FieldAttributeCtx` is rejected in a model or block spec. + A spec fixes the attribute level and name, declares its arguments, and may refine the parsed result: ```ts -interface AttributeSpec { +interface AttributeSpec { readonly level: 'field' | 'model' | 'block'; readonly name: string; - readonly positional: readonly PositionalParam[]; - readonly named: Readonly>>; - readonly refine?: ( - parsed: Out, - ctx: InterpretCtx, - attributeNode: AstNode, - ) => readonly PslDiagnostic[]; + readonly positional: readonly PositionalParam[]; + readonly named: Readonly>>; + readonly refine?: (parsed: Out, ctx: Ctx, attributeNode: AstNode) => readonly PslDiagnostic[]; } ``` -`fieldAttribute` and `modelAttribute` infer `AttributeOut` when constructing a spec. `InferAttr` extracts that `Out` type. Optional parameters are `ArgType` values decorated by `optional(type)` or `optional(type, defaultValue)`; the engine detects the marker when finalizing absent arguments. +`fieldAttribute`, `modelAttribute`, and `blockAttribute` fix `Ctx` to `FieldAttributeCtx`, `ModelAttributeCtx`, and `AttributeCtx` respectively, and infer `AttributeOut` when constructing a spec. `InferAttr` extracts that `Out` type. Optional parameters are `ArgType` values decorated by `optional(type)` or `optional(type, defaultValue)`; the engine detects the marker when finalizing absent arguments. Positionals are fixed slots with an output key. Variadic positionals are not supported. Positional and named parameters may intentionally share a key, which supports the relation-name alias while allowing the engine to diagnose conflicting duplicate values. @@ -137,7 +140,7 @@ These leaves perform direct AST checks. They do not wrap arktype schemas. ### References -`fieldRef('self')` parses a field-name identifier and validates it against the declaring model. `fieldRef('referenced')` validates against the relation target when that model can be resolved; cross-space references may defer the existence check when no referenced model is locally available. Both forms return the authored field name as a string and expose their scope as combinator metadata. +`fieldRef()` parses a field-name identifier and validates it against the declaring model, so it needs only a `ModelAttributeCtx` and is available to model and field attributes alike. `referencedFieldRef()` validates against the relation target, which only a field can resolve, so it takes a `FieldAttributeCtx`; cross-space references may defer the existence check when no referenced model is locally available. Both return the authored field name as a string. `entityRef()` parses an unresolved model-name string. Existence and family semantics remain downstream concerns. @@ -163,7 +166,7 @@ This is intentionally narrower than an arbitrary JSON value. Its shipped use is ### Alternatives -`oneOf(first, ...rest)` tries its alternatives in order and returns the first success. If every alternative fails, it discards the branch diagnostics and emits one aggregate `Expected one of: …` diagnostic assembled from the alternatives' labels. +`oneOf(first, ...rest)` tries its alternatives in order and returns the first success. If every alternative fails, it discards the branch diagnostics and emits one aggregate `Expected one of: …` diagnostic assembled from the alternatives' labels. Its output is the union of the alternatives' outputs and its context is their intersection, so an alternation containing `fieldRef()` is itself model-scoped. This trade-off keeps the leaf contract small and allows backtracking, at the cost of less specific diagnostics for malformed input that resembles one particular branch. @@ -173,8 +176,8 @@ This trade-off keeps the leaf contract small and allows backtracking, at the cos ```ts interface FuncCallSig { - readonly positional?: readonly PositionalParam[]; - readonly named?: Readonly>>; + readonly positional?: readonly PositionalParam[]; + readonly named?: Readonly>>; } interface TypedFuncCall { @@ -219,7 +222,7 @@ const sortSig = { } satisfies FuncCallSig; const indexFieldElement = oneOf( - fieldRef('self'), + fieldRef(), funcCall('wildcard', { positional: [{ key: 'scope', type: optional(entityRef()) }], }), diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/block-attribute.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/block-attribute.ts index 48d9b607fc85..4598a6012232 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/block-attribute.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/block-attribute.ts @@ -1,33 +1,27 @@ import type { PslDiagnostic } from '@internal/framework-components/psl-ast'; import type { AstNode } from '../syntax/ast-helpers'; -import type { - AttributeOut, - AttributeSpec, - BlockInterpretCtx, - Param, - PositionalParam, -} from './types'; +import type { AttributeCtx, AttributeOut, AttributeSpec, Param, PositionalParam } from './types'; interface BlockAttributeConfig< - Pos extends readonly PositionalParam[], - Named extends Record>, + Pos extends readonly PositionalParam[], + Named extends Record>, > { readonly positional?: Pos; readonly named?: Named; readonly refine?: ( parsed: AttributeOut, - ctx: BlockInterpretCtx, + ctx: AttributeCtx, attributeNode: AstNode, ) => readonly PslDiagnostic[]; } export function blockAttribute< - const Pos extends readonly PositionalParam[] = readonly [], - const Named extends Record> = Record, + const Pos extends readonly PositionalParam[] = readonly [], + const Named extends Record> = Record, >( name: string, config: BlockAttributeConfig, -): AttributeSpec, BlockInterpretCtx> { +): AttributeSpec, AttributeCtx> { return { level: 'block', name, diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/bool.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/bool.ts index 13c3bab6adb0..9a5154b170ff 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/bool.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/bool.ts @@ -1,10 +1,10 @@ import type { PslDiagnostic } from '@internal/framework-components/psl-ast'; import { notOk, ok, type Result } from '@internal/utils/result'; import { BooleanLiteralExprAst } from '../../syntax/ast/expressions'; -import type { ArgType, BlockInterpretCtx } from '../types'; +import type { ArgType, AttributeCtx } from '../types'; import { leafDiagnostic } from './diagnostic'; -export function bool(): ArgType { +export function bool(): ArgType { return { kind: 'bool', label: 'boolean', 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 3ce2433ecdb4..e903596c8e21 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,12 @@ import type { PslDiagnostic, PslDiagnosticCode } from '@internal/framework-components/psl-ast'; import { nodePslSpan } from '../../resolve'; import type { AstNode } from '../../syntax/ast-helpers'; -import type { BlockInterpretCtx } from '../types'; +import type { AttributeCtx } from '../types'; export const ATTRIBUTE_DIAGNOSTIC_CODE: PslDiagnosticCode = 'PSL_INVALID_ATTRIBUTE_SYNTAX'; export function leafDiagnostic( - ctx: BlockInterpretCtx, + ctx: AttributeCtx, node: AstNode, message: string, code: PslDiagnostic['code'] = ATTRIBUTE_DIAGNOSTIC_CODE, diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/entity-ref.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/entity-ref.ts index 53312cb3f7ce..bf26448a1a55 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/entity-ref.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/entity-ref.ts @@ -1,12 +1,12 @@ import type { PslDiagnostic } from '@internal/framework-components/psl-ast'; import { notOk, ok, type Result } from '@internal/utils/result'; import { IdentifierAst } from '../../syntax/ast/identifier'; -import type { ArgType } from '../types'; +import type { ArgType, AttributeCtx } from '../types'; import { leafDiagnostic } from './diagnostic'; // A bare model-name reference. Existence of a model with this name is resolved // downstream (e.g. `resolvePolymorphism`), not here. -export function entityRef(): ArgType { +export function entityRef(): ArgType { return { kind: 'entityRef', label: 'model name', diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/field-ref.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/field-ref.ts index 4044da61f932..4457cf04cf02 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/field-ref.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/field-ref.ts @@ -1,37 +1,45 @@ import type { PslDiagnostic } from '@internal/framework-components/psl-ast'; import { notOk, ok, type Result } from '@internal/utils/result'; +import type { ModelSymbol } from '../../symbol-table'; +import type { ExpressionAst } from '../../syntax/ast/expressions'; import { IdentifierAst } from '../../syntax/ast/identifier'; -import type { ArgType } from '../types'; +import type { ArgType, AttributeCtx, FieldAttributeCtx, ModelAttributeCtx } from '../types'; import { leafDiagnostic } from './diagnostic'; -export type FieldRefScope = 'self' | 'referenced'; +function parseFieldName( + arg: ExpressionAst, + ctx: AttributeCtx, + model: ModelSymbol | undefined, +): Result { + const identifier = IdentifierAst.cast(arg.syntax); + if (identifier === undefined) { + return notOk([leafDiagnostic(ctx, arg, 'Expected a field name')]); + } + const name = identifier.name(); + if (name === undefined) { + return notOk([leafDiagnostic(ctx, arg, 'Expected a field name')]); + } + // A referenced model in another space can't be resolved here (resolveReferencedModel returns undefined); skip the existence check — it runs where that model is known. + if (model !== undefined && !Object.hasOwn(model.fields, name)) { + return notOk([ + leafDiagnostic(ctx, arg, `Field "${name}" does not exist on model "${model.name}"`), + ]); + } + return ok(name); +} -export interface FieldRefArgType extends ArgType { - readonly scope: FieldRefScope; +export function fieldRef(): ArgType { + return { + kind: 'fieldRef', + label: 'field name', + parse: (arg, ctx) => parseFieldName(arg, ctx, ctx.selfModel), + }; } -export function fieldRef(scope: FieldRefScope): FieldRefArgType { +export function referencedFieldRef(): ArgType { return { kind: 'fieldRef', label: 'field name', - scope, - parse: (arg, ctx): Result => { - const identifier = IdentifierAst.cast(arg.syntax); - if (identifier === undefined) { - return notOk([leafDiagnostic(ctx, arg, 'Expected a field name')]); - } - const name = identifier.name(); - if (name === undefined) { - 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}"`), - ]); - } - return ok(name); - }, + parse: (arg, ctx) => parseFieldName(arg, ctx, ctx.resolveReferencedModel()), }; } diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/func-call.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/func-call.ts index 8873679e299b..658c156fb39b 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/func-call.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/func-call.ts @@ -4,14 +4,14 @@ import { nodePslSpan } from '../../resolve'; import type { ExpressionAst } from '../../syntax/ast/expressions'; import { FunctionCallAst } from '../../syntax/ast/expressions'; import { interpretArgs } from '../interpret'; -import type { ArgType, InterpretCtx, Param, PositionalParam } from '../types'; +import type { ArgType, AttributeCtx, Param, PositionalParam } from '../types'; import { leafDiagnostic } from './diagnostic'; // The argument signature of a pinned function call — the same positional/named shape an attribute // spec uses. Omitted groups default to empty, so a nullary call needs neither key. export interface FuncCallSig { - readonly positional?: readonly PositionalParam[]; - readonly named?: Readonly>>; + readonly positional?: readonly PositionalParam[]; + readonly named?: Readonly>>; } export interface TypedFuncCall { @@ -22,7 +22,7 @@ export interface TypedFuncCall { // A name-pinned function-call argument — `funcCall('now', {})` matches `now()`, parsing the call's // arguments through `sig`. -export function funcCall(name: string, sig: FuncCallSig): ArgType { +export function funcCall(name: string, sig: FuncCallSig): ArgType { return { kind: 'funcCall', label: `${name}()`, @@ -45,7 +45,7 @@ export function funcCall(name: string, sig: FuncCallSig): ArgType function matchCallee( arg: ExpressionAst, name: string, - ctx: InterpretCtx, + ctx: AttributeCtx, ): Result { const call = FunctionCallAst.cast(arg.syntax); if (call === undefined) { 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 953c0728ed61..71a08787bef2 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/identifier.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/identifier.ts @@ -1,10 +1,10 @@ import type { PslDiagnostic } from '@internal/framework-components/psl-ast'; import { notOk, ok, type Result } from '@internal/utils/result'; import { IdentifierAst } from '../../syntax/ast/identifier'; -import type { ArgType, BlockInterpretCtx } from '../types'; +import type { ArgType, AttributeCtx } from '../types'; import { leafDiagnostic } from './diagnostic'; -export function identifier(name: N): ArgType { +export function identifier(name: N): ArgType { return { kind: 'identifier', label: name, diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/int.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/int.ts index 8f0a6927ad25..6c1280fa21c6 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/int.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/int.ts @@ -1,12 +1,12 @@ import type { PslDiagnostic } from '@internal/framework-components/psl-ast'; import { notOk, ok, type Result } from '@internal/utils/result'; import { NumberLiteralExprAst } from '../../syntax/ast/expressions'; -import type { ArgType, BlockInterpretCtx } from '../types'; +import type { ArgType, AttributeCtx } from '../types'; import { leafDiagnostic } from './diagnostic'; // An integer literal reduced to its numeric value. Passing `min`/`max` additionally rejects // out-of-range integers with a distinct range message, leaving the integer-only check intact. -export function int(opts?: { min?: number; max?: number }): ArgType { +export function int(opts?: { min?: number; max?: number }): ArgType { const min = opts?.min; const max = opts?.max; return { diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/json.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/json.ts index 06873104bf89..b1c904b60a33 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/json.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/json.ts @@ -2,7 +2,7 @@ import type { PslDiagnostic } from '@internal/framework-components/psl-ast'; import { blindCast } from '@internal/utils/casts'; import { notOk, ok, type Result } from '@internal/utils/result'; import { StringLiteralExprAst } from '../../syntax/ast/expressions'; -import type { ArgType, BlockInterpretCtx } from '../types'; +import type { ArgType, AttributeCtx } from '../types'; import { leafDiagnostic } from './diagnostic'; /** @@ -10,7 +10,7 @@ import { leafDiagnostic } from './diagnostic'; * exception (e.g. an index `filter` / `weights` argument). The string is decoded by the parser, * then JSON-parsed; a non-object (array/scalar) or invalid JSON is a diagnostic. */ -export function json(): ArgType, BlockInterpretCtx> { +export function json(): ArgType, AttributeCtx> { return { kind: 'json', label: 'JSON object', 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 014dd84d2601..0d127be6cd6e 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/list.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/list.ts @@ -1,7 +1,7 @@ import type { PslDiagnostic } from '@internal/framework-components/psl-ast'; import { notOk, ok, type Result } from '@internal/utils/result'; import { ArrayLiteralAst, type ExpressionAst } from '../../syntax/ast/expressions'; -import type { ArgType, BlockInterpretCtx } from '../types'; +import type { ArgType, AttributeCtx } from '../types'; import { leafDiagnostic } from './diagnostic'; export interface ListOptions { @@ -9,7 +9,7 @@ export interface ListOptions { readonly unique?: boolean; } -export function list( +export function list( of: ArgType, opts?: ListOptions, ): ArgType { diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/num.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/num.ts index 6625d3f4baeb..2fe3cf706e8a 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/num.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/num.ts @@ -1,13 +1,13 @@ import type { PslDiagnostic } from '@internal/framework-components/psl-ast'; import { notOk, ok, type Result } from '@internal/utils/result'; import { NumberLiteralExprAst } from '../../syntax/ast/expressions'; -import type { ArgType, BlockInterpretCtx } from '../types'; +import type { ArgType, AttributeCtx } from '../types'; import { leafDiagnostic } from './diagnostic'; /** The pinned form retains its value as the output literal type. */ -export function num(): ArgType; -export function num(value: T): ArgType; -export function num(value?: T): ArgType { +export function num(): ArgType; +export function num(value: T): ArgType; +export function num(value?: T): ArgType { return { kind: 'num', label: value === undefined ? 'number' : String(value), diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/one-of.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/one-of.ts index 4c4c417f3c49..15e2cd99398c 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/one-of.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/one-of.ts @@ -1,28 +1,31 @@ import type { PslDiagnostic } from '@internal/framework-components/psl-ast'; import { blindCast } from '@internal/utils/casts'; import { notOk, ok, type Result } from '@internal/utils/result'; -import type { ArgType, BlockInterpretCtx, OutOf } from '../types'; +import type { UnionToIntersection } from '@internal/utils/types'; +import type { ArgType, AttributeCtx, CtxOf, OutOf } from '../types'; import { leafDiagnostic } from './diagnostic'; +export type OneOfCtx[]> = UnionToIntersection< + CtxOf +> & + AttributeCtx; + export function oneOf< - Alts extends readonly [ - ArgType, - ...ArgType[], - ], ->(...alts: Alts): ArgType, BlockInterpretCtx>; -export function oneOf, ...ArgType[]]>( - ...alts: Alts -): ArgType>; -export function oneOf, ...ArgType[]]>( - ...alts: Alts -): ArgType> { + const Alts extends readonly [ArgType, ...ArgType[]], +>(...alts: [...Alts]): ArgType, OneOfCtx> { 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); + const result = alt.parse( + arg, + blindCast< + never, + 'Each alternative declares the ctx it reads and oneOf demands their intersection, so the received ctx satisfies every alternative; iterating the tuple widens each element to its `never`-ctx bound and erases that.' + >(ctx), + ); if (result.ok) { return ok( blindCast< diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/record.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/record.ts index dd1f7c9ee14b..0581b1d45c1e 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/record.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/record.ts @@ -1,10 +1,10 @@ import type { PslDiagnostic } from '@internal/framework-components/psl-ast'; import { notOk, ok, type Result } from '@internal/utils/result'; import { ObjectLiteralExprAst } from '../../syntax/ast/expressions'; -import type { ArgType, BlockInterpretCtx } from '../types'; +import type { ArgType, AttributeCtx } from '../types'; import { leafDiagnostic } from './diagnostic'; -export function record( +export function record( of: ArgType, ): ArgType, Ctx> { return { 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 7d48ff70b5c2..c30e3e0dc428 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 @@ -1,13 +1,13 @@ import type { PslDiagnostic } from '@internal/framework-components/psl-ast'; import { notOk, ok, type Result } from '@internal/utils/result'; import { StringLiteralExprAst } from '../../syntax/ast/expressions'; -import type { ArgType, BlockInterpretCtx } from '../types'; +import type { ArgType, AttributeCtx } from '../types'; import { leafDiagnostic } from './diagnostic'; /** The pinned form retains its value as the output literal type. */ -export function str(): ArgType; -export function str(value: T): ArgType; -export function str(value?: T): ArgType { +export function str(): ArgType; +export function str(value: T): ArgType; +export function str(value?: T): ArgType { return { kind: 'str', label: value === undefined ? 'string' : JSON.stringify(value), 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 f6f3fae9b607..2337d8c0c2dd 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 @@ -1,24 +1,33 @@ import type { PslDiagnostic } from '@internal/framework-components/psl-ast'; import type { AstNode } from '../syntax/ast-helpers'; -import type { AttributeOut, AttributeSpec, InterpretCtx, Param, PositionalParam } from './types'; +import type { + AttributeOut, + AttributeSpec, + FieldAttributeCtx, + Param, + PositionalParam, +} from './types'; interface FieldAttributeConfig< - Pos extends readonly PositionalParam[], - Named extends Record>, + Pos extends readonly PositionalParam[], + Named extends Record>, > { readonly positional?: Pos; readonly named?: Named; readonly refine?: ( parsed: AttributeOut, - ctx: InterpretCtx, + ctx: FieldAttributeCtx, attributeNode: AstNode, ) => readonly PslDiagnostic[]; } export function fieldAttribute< - const Pos extends readonly PositionalParam[] = readonly [], - const Named extends Record> = Record, ->(name: string, config: FieldAttributeConfig): AttributeSpec> { + const Pos extends readonly PositionalParam[] = readonly [], + const Named extends Record> = Record, +>( + name: string, + config: FieldAttributeConfig, +): AttributeSpec, FieldAttributeCtx> { return { level: 'field', name, 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 28526c1a4f61..7b8bd39408d8 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 @@ -7,8 +7,8 @@ import type { AttributeArgAst } from '../syntax/ast/expressions'; import { ATTRIBUTE_DIAGNOSTIC_CODE } from './combinators/diagnostic'; import type { ArgType, + AttributeCtx, AttributeSpec, - BlockInterpretCtx, OptionalArgType, Param, PositionalParam, @@ -17,13 +17,13 @@ import type { // The positional/named argument-binding for an attribute or a function call. `name` labels the // callee in binding diagnostics (`Attribute "" …`); `span` anchors the arity diagnostics // (too-many / missing) that have no per-argument node to point at. -export interface ArgBindingSpec { +export interface ArgBindingSpec { readonly name: string; readonly positional: readonly PositionalParam[]; readonly named: Readonly>>; } -export function interpretArgs( +export function interpretArgs( args: Iterable, spec: ArgBindingSpec, ctx: Ctx, @@ -123,7 +123,7 @@ export function interpretArgs( return ok(output); } -export function interpretAttribute( +export function interpretAttribute( attrNode: FieldAttributeAst | ModelAttributeAst, spec: AttributeSpec, ctx: Ctx, @@ -145,7 +145,7 @@ export function interpretAttribute( return ok(value); } -function parseArgValue( +function parseArgValue( arg: AttributeArgAst, argType: ArgType, ctx: Ctx, @@ -168,12 +168,12 @@ function parseArgValue( return result; } -function isOptionalArgType( +function isOptionalArgType( param: Param, ): param is OptionalArgType { return 'optional' in param && param.optional === true; } -function diagnostic(message: string, ctx: BlockInterpretCtx, span: PslSpan): PslDiagnostic { +function diagnostic(message: string, ctx: AttributeCtx, span: PslSpan): PslDiagnostic { return { code: ATTRIBUTE_DIAGNOSTIC_CODE, message, sourceId: ctx.sourceId, span }; } diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/model-attribute.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/model-attribute.ts index e369000a441e..fa706fae8c32 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/model-attribute.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/model-attribute.ts @@ -1,24 +1,33 @@ import type { PslDiagnostic } from '@internal/framework-components/psl-ast'; import type { AstNode } from '../syntax/ast-helpers'; -import type { AttributeOut, AttributeSpec, InterpretCtx, Param, PositionalParam } from './types'; +import type { + AttributeOut, + AttributeSpec, + ModelAttributeCtx, + Param, + PositionalParam, +} from './types'; interface ModelAttributeConfig< - Pos extends readonly PositionalParam[], - Named extends Record>, + Pos extends readonly PositionalParam[], + Named extends Record>, > { readonly positional?: Pos; readonly named?: Named; readonly refine?: ( parsed: AttributeOut, - ctx: InterpretCtx, + ctx: ModelAttributeCtx, attributeNode: AstNode, ) => readonly PslDiagnostic[]; } export function modelAttribute< - const Pos extends readonly PositionalParam[] = readonly [], - const Named extends Record> = Record, ->(name: string, config: ModelAttributeConfig): AttributeSpec> { + const Pos extends readonly PositionalParam[] = readonly [], + const Named extends Record> = Record, +>( + name: string, + config: ModelAttributeConfig, +): AttributeSpec, ModelAttributeCtx> { return { level: 'model', name, 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 1470d28895c3..b546e8c40953 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,6 +1,6 @@ -import type { ArgType, BlockInterpretCtx, OptionalArgType } from './types'; +import type { ArgType, AttributeCtx, OptionalArgType } from './types'; -export function optional( +export function optional( type: ArgType, ...rest: [defaultValue: T] | [] ): OptionalArgType { diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/spec-context.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/spec-context.ts index 6112fd02893c..27fac9a4f6cc 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/spec-context.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/spec-context.ts @@ -1,6 +1,6 @@ import type { ControlMutationDefaultRegistry } from '@internal/framework-components/control'; import type { FieldSymbol, ModelSymbol, SymbolTable } from '../symbol-table'; -import type { AttributeSpec, BlockInterpretCtx } from './types'; +import type { AttributeCtx, AttributeSpec, FieldAttributeCtx, ModelAttributeCtx } from './types'; export interface AttributeSpecContext { readonly symbols: SymbolTable; @@ -12,13 +12,17 @@ export interface FieldAttributeSpecContext extends AttributeSpecContext { readonly field: FieldSymbol; } -export type ModelAttributeSpecFactory = (ctx: AttributeSpecContext) => AttributeSpec; +export type ModelAttributeSpecFactory = ( + ctx: AttributeSpecContext, +) => AttributeSpec; -export type FieldAttributeSpecFactory = (ctx: FieldAttributeSpecContext) => AttributeSpec; +export type FieldAttributeSpecFactory = ( + ctx: FieldAttributeSpecContext, +) => AttributeSpec; export interface AttributeSpecNamespace { readonly model: Readonly>; readonly field: Readonly>; } -export type BlockAttributeSpecFactory = () => AttributeSpec; +export type BlockAttributeSpecFactory = () => AttributeSpec; 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 32588c8b8ada..d36b69144769 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,19 +8,21 @@ import type { AstNode } from '../syntax/ast-helpers'; export type AttributeLevel = 'field' | 'model' | 'block'; -export interface BlockInterpretCtx { - readonly level: AttributeLevel; +export interface AttributeCtx { readonly sourceId: string; readonly sourceFile: SourceFile; } -export interface InterpretCtx extends BlockInterpretCtx { +export interface ModelAttributeCtx extends AttributeCtx { readonly selfModel: ModelSymbol; +} + +export interface FieldAttributeCtx extends ModelAttributeCtx { + readonly field: FieldSymbol; resolveReferencedModel(): ModelSymbol | undefined; - readonly field?: FieldSymbol; } -export interface ArgType { +export interface ArgType { readonly kind: string; readonly label: string; // phantom carrier for `T`; never read at runtime. @@ -28,22 +30,21 @@ export interface ArgType { readonly parse: (arg: ExpressionAst, ctx: Ctx) => Result; } -export interface OptionalArgType - extends ArgType { +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; } -export type Param = ArgType; +export type Param = ArgType; -export interface PositionalParam { +export interface PositionalParam { readonly key: string; readonly type: Param; } -export interface AttributeSpec { +export interface AttributeSpec { readonly level: AttributeLevel; readonly name: string; readonly positional: readonly PositionalParam[]; @@ -58,6 +59,8 @@ export interface AttributeSpec = P extends ArgType ? T : never; +export type CtxOf

= P extends ArgType ? Ctx : never; + export type NamedOut>> = Simplify< { [K in keyof N as N[K] extends OptionalArgType ? never : K]: OutOf } & { [K in keyof N as N[K] extends OptionalArgType ? K : never]?: OutOf; diff --git a/packages/1-framework/2-authoring/psl-parser/src/block-reconstruction.ts b/packages/1-framework/2-authoring/psl-parser/src/block-reconstruction.ts index f6f46d9018c3..f4e82a77d956 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/block-reconstruction.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/block-reconstruction.ts @@ -145,7 +145,6 @@ function parseBlockAttribute( 'framework core cannot name AttributeSpec, so block-attribute factories transit the descriptor erased as unknown; this is the single point that restores the factory type the descriptor surface documents' >(declared[name]); const result = interpretAttribute(attribute, factory(), { - level: 'block', sourceId: BLOCK_ATTRIBUTE_SOURCE_ID, sourceFile, }); 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 3cc1e2badadb..d56fe64f9e86 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 @@ -42,8 +42,7 @@ export { blockAttribute } from '../attribute-spec/block-attribute'; export { bool } from '../attribute-spec/combinators/bool'; export { leafDiagnostic } from '../attribute-spec/combinators/diagnostic'; export { entityRef } from '../attribute-spec/combinators/entity-ref'; -export type { FieldRefArgType, FieldRefScope } from '../attribute-spec/combinators/field-ref'; -export { fieldRef } from '../attribute-spec/combinators/field-ref'; +export { fieldRef, referencedFieldRef } from '../attribute-spec/combinators/field-ref'; export type { FuncCallSig, TypedFuncCall } from '../attribute-spec/combinators/func-call'; export { funcCall } from '../attribute-spec/combinators/func-call'; export { identifier } from '../attribute-spec/combinators/identifier'; @@ -52,6 +51,7 @@ export { json } from '../attribute-spec/combinators/json'; export type { ListOptions } from '../attribute-spec/combinators/list'; export { list } from '../attribute-spec/combinators/list'; export { num } from '../attribute-spec/combinators/num'; +export type { OneOfCtx } from '../attribute-spec/combinators/one-of'; export { oneOf } from '../attribute-spec/combinators/one-of'; export { record } from '../attribute-spec/combinators/record'; export { str } from '../attribute-spec/combinators/str'; @@ -70,12 +70,14 @@ export type { } from '../attribute-spec/spec-context'; export type { ArgType, + AttributeCtx, AttributeLevel, AttributeOut, AttributeSpec, - BlockInterpretCtx, + CtxOf, + FieldAttributeCtx, InferAttr, - InterpretCtx, + ModelAttributeCtx, NamedOut, OptionalArgType, OutOf, diff --git a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-block.test-d.ts b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-block.test-d.ts index 2a69e3275774..7f5216289ec0 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-block.test-d.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-block.test-d.ts @@ -1,13 +1,26 @@ import { expectTypeOf, test } from 'vitest'; import type { ArgType, + AttributeCtx, AttributeSpec, BlockAttributeSpecFactory, - BlockInterpretCtx, + FieldAttributeCtx, InferAttr, - InterpretCtx, + ModelAttributeCtx, + TypedFuncCall, +} from '../src/exports'; +import { + blockAttribute, + fieldAttribute, + fieldRef, + funcCall, + list, + modelAttribute, + oneOf, + optional, + referencedFieldRef, + str, } from '../src/exports'; -import { blockAttribute, fieldRef, list, modelAttribute, optional, str } from '../src/exports'; test('blockAttribute infers its output like modelAttribute', () => { const blockSpec = blockAttribute('map', { @@ -25,34 +38,79 @@ test('blockAttribute infers its output like modelAttribute', () => { }>(); }); -test('a model-free combinator parses over the block ctx', () => { - expectTypeOf(str()).toEqualTypeOf>(); - expectTypeOf(list(str())).toEqualTypeOf>(); +test('a model-free combinator parses over the bare attribute ctx', () => { + expectTypeOf(str()).toEqualTypeOf>(); + expectTypeOf(list(str())).toEqualTypeOf>(); + expectTypeOf(funcCall('now', {})).toEqualTypeOf>(); +}); + +test('a bare-ctx combinator is usable at all three levels', () => { + blockAttribute('map', { positional: [{ key: 'name', type: str() }] }); + modelAttribute('map', { positional: [{ key: 'name', type: str() }] }); + fieldAttribute('map', { positional: [{ key: 'name', type: str() }] }); + blockAttribute('now', { named: { at: funcCall('now', {}) } }); }); test('a combinator that reads the model is rejected inside a block spec', () => { blockAttribute('bad', { // @ts-expect-error fieldRef needs selfModel, which a block never has - positional: [{ key: 'field', type: fieldRef('self') }], + positional: [{ key: 'field', type: fieldRef() }], + }); + blockAttribute('worse', { + // @ts-expect-error referencedFieldRef needs the field, which a block never has + positional: [{ key: 'field', type: referencedFieldRef() }], }); }); -test('a block spec is accepted where a model-level spec is expected', () => { +test('fieldRef is model-scoped and referencedFieldRef is field-scoped', () => { + modelAttribute('index', { positional: [{ key: 'fields', type: fieldRef() }] }); + fieldAttribute('id', { positional: [{ key: 'field', type: fieldRef() }] }); + fieldAttribute('relation', { named: { references: referencedFieldRef() } }); + modelAttribute('bad', { + // @ts-expect-error referencedFieldRef resolves through the field, which a model spec never has + named: { references: referencedFieldRef() }, + }); +}); + +test('a block spec is accepted where a bare-ctx spec is expected', () => { const blockSpec = blockAttribute('map', { positional: [{ key: 'name', type: str() }] }); - expectTypeOf(blockSpec).toMatchTypeOf>(); + expectTypeOf(blockSpec).toMatchTypeOf>(); }); -test('a model-level spec is rejected where a block spec is expected', () => { +test('a model-level spec is rejected where a bare-ctx spec is expected', () => { const modelSpec = modelAttribute('map', { - positional: [{ key: 'field', type: fieldRef('self') }], + positional: [{ key: 'field', type: fieldRef() }], + }); + expectTypeOf(modelSpec).not.toMatchTypeOf>(); +}); + +test('a field-level spec is rejected where a model-level spec is expected', () => { + const fieldSpec = fieldAttribute('map', { positional: [{ key: 'name', type: str() }] }); + expectTypeOf(fieldSpec).not.toMatchTypeOf>(); + expectTypeOf(fieldSpec).toMatchTypeOf>(); +}); + +test('oneOf over bare-ctx alternatives stays usable in a block spec', () => { + const spec = blockAttribute('kind', { + positional: [{ key: 'kind', type: oneOf(str('a'), str('b')) }], + }); + expectTypeOf>().toEqualTypeOf<{ kind: 'a' | 'b' }>(); +}); + +test('oneOf carrying a model-scoped alternative is rejected in a block spec', () => { + const arm = oneOf(str(), fieldRef()); + blockAttribute('bad', { + // @ts-expect-error one alternative reads selfModel, so the alternation demands a model ctx + positional: [{ key: 'value', type: arm }], }); - expectTypeOf(modelSpec).not.toMatchTypeOf>(); + const modelSpec = modelAttribute('ok', { positional: [{ key: 'value', type: arm }] }); + expectTypeOf>().toEqualTypeOf<{ value: string }>(); }); test('a nullary factory over a block spec satisfies BlockAttributeSpecFactory', () => { const factory = () => blockAttribute('map', { positional: [{ key: 'name', type: str() }] }); expectTypeOf(factory).toMatchTypeOf(); const modelFactory = () => - modelAttribute('map', { positional: [{ key: 'field', type: fieldRef('self') }] }); + modelAttribute('map', { positional: [{ key: 'field', type: fieldRef() }] }); expectTypeOf(modelFactory).not.toMatchTypeOf(); }); diff --git a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-block.test.ts b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-block.test.ts index 29cbe9ce812e..7255131ff3b3 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-block.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-block.test.ts @@ -1,17 +1,17 @@ import { describe, expect, it } from 'vitest'; -import type { BlockInterpretCtx } from '../src/exports'; +import type { AttributeCtx } from '../src/exports'; import { blockAttribute, interpretAttribute, leafDiagnostic, str } from '../src/exports'; import { Cursor, parseAttribute } from '../src/parse'; import { ModelAttributeAst } from '../src/syntax/ast/attributes'; import { createSyntaxTree } from '../src/syntax/red'; -function blockAttr(source: string): { node: ModelAttributeAst; ctx: BlockInterpretCtx } { +function blockAttr(source: string): { node: ModelAttributeAst; ctx: AttributeCtx } { const cursor = new Cursor(source); const node = ModelAttributeAst.cast(createSyntaxTree(parseAttribute(cursor))); if (!node) throw new Error('expected a block attribute'); return { node, - ctx: { level: 'block', sourceId: 'schema.prisma', sourceFile: cursor.sourceFile }, + ctx: { sourceId: 'schema.prisma', sourceFile: cursor.sourceFile }, }; } diff --git a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.foreign-copy.test.ts b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.foreign-copy.test.ts index cde85d9ab694..9c155dfda660 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.foreign-copy.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-combinators.foreign-copy.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import type { InterpretCtx } from '../src/exports'; +import type { ModelAttributeCtx } from '../src/exports'; import { bool, entityRef, @@ -27,7 +27,7 @@ class ForeignCopyOfAnAstNode { } } -function foreignArg(source: string): { arg: ExpressionAst; ctx: InterpretCtx } { +function foreignArg(source: string): { arg: ExpressionAst; ctx: ModelAttributeCtx } { const cursor = new Cursor(`@demo(${source})`); const node = FieldAttributeAst.cast(createSyntaxTree(parseAttribute(cursor))); const value = Array.from(node?.argList()?.args() ?? [])[0]?.value(); @@ -39,11 +39,9 @@ function foreignArg(source: string): { arg: ExpressionAst; ctx: InterpretCtx } { return { arg: new ForeignCopyOfAnAstNode(value.syntax) as unknown as ExpressionAst, ctx: { - level: 'field', sourceId: 'schema.prisma', sourceFile: cursor.sourceFile, selfModel, - resolveReferencedModel: () => undefined, }, }; } @@ -56,7 +54,7 @@ describe('combinators dispatch on syntax kind, not on AST class identity', () => ['bool', bool(), 'true', true], ['identifier', identifier('Cascade'), 'Cascade', 'Cascade'], ['entityRef', entityRef(), 'User', 'User'], - ['fieldRef', fieldRef('self'), 'id', 'id'], + ['fieldRef', fieldRef(), 'id', 'id'], ['json', json(), '"{\\"a\\":1}"', { a: 1 }], ['list', list(str()), '["a", "b"]', ['a', 'b']], ['record', record(int()), '{ a: 1 }', { a: 1 }], 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 7c9b622015c0..bbdb6dc9b4db 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,14 +1,14 @@ import { expectTypeOf, test } from 'vitest'; -import type { ArgType, BlockInterpretCtx, OutOf } from '../src/exports'; +import type { ArgType, AttributeCtx, OutOf } from '../src/exports'; import { identifier, list, num, oneOf, str } from '../src/exports'; test('identifier pins its name as the output literal type', () => { - expectTypeOf(identifier('NoAction')).toEqualTypeOf>(); + expectTypeOf(identifier('NoAction')).toEqualTypeOf>(); }); test('oneOf infers the union of its alternatives output types', () => { expectTypeOf(oneOf(identifier('NoAction'), identifier('Cascade'))).toEqualTypeOf< - ArgType<'NoAction' | 'Cascade', BlockInterpretCtx> + ArgType<'NoAction' | 'Cascade', AttributeCtx> >(); }); @@ -36,5 +36,5 @@ test('oneOf with no alternatives is a compile error', () => { }); test('list infers an array of its element type', () => { - expectTypeOf(list(str())).toEqualTypeOf>(); + 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 index 3c267d4ec76d..3a8f4ad15802 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,6 +1,6 @@ import { ok } from '@internal/utils/result'; import { describe, expect, it } from 'vitest'; -import type { ArgType, InterpretCtx } from '../src/exports'; +import type { ArgType, AttributeCtx, FieldAttributeCtx, ModelAttributeCtx } from '../src/exports'; import { bool, entityRef, @@ -18,6 +18,7 @@ import { oneOf, optional, record, + referencedFieldRef, str, } from '../src/exports'; import { Cursor, parse, parseAttribute } from '../src/parse'; @@ -27,7 +28,7 @@ import { FieldAttributeAst, ModelAttributeAst } from '../src/syntax/ast/attribut import type { ExpressionAst } from '../src/syntax/ast/expressions'; import { createSyntaxTree } from '../src/syntax/red'; -function makeCtx(sourceFile: SourceFile): InterpretCtx { +function makeCtx(sourceFile: SourceFile): FieldAttributeCtx { const { document, sourceFile: modelSource } = parse('model M {\n id Int @id\n}\n'); const { table } = buildSymbolTable({ document, @@ -36,16 +37,18 @@ function makeCtx(sourceFile: SourceFile): InterpretCtx { }); const selfModel = table.topLevel.models['M']; if (!selfModel) throw new Error('expected model M in the symbol table'); + const field = selfModel.fields['id']; + if (!field) throw new Error('expected field id on model M'); return { - level: 'field', sourceId: 'schema.prisma', sourceFile, selfModel, + field, resolveReferencedModel: () => undefined, }; } -function argOf(exprSource: string): { expr: ExpressionAst; ctx: InterpretCtx } { +function argOf(exprSource: string): { expr: ExpressionAst; ctx: FieldAttributeCtx } { const cursor = new Cursor(`@x(${exprSource})`); const node = FieldAttributeAst.cast(createSyntaxTree(parseAttribute(cursor))); if (!node) throw new Error('expected a field attribute'); @@ -55,11 +58,11 @@ function argOf(exprSource: string): { expr: ExpressionAst; ctx: InterpretCtx } { return { expr, ctx: makeCtx(cursor.sourceFile) }; } -function modelAttrOf(source: string): { node: ModelAttributeAst; ctx: InterpretCtx } { +function modelAttrOf(source: string): { node: ModelAttributeAst; ctx: ModelAttributeCtx } { const cursor = new Cursor(source); const node = ModelAttributeAst.cast(createSyntaxTree(parseAttribute(cursor))); if (!node) throw new Error('expected a model attribute'); - return { node, ctx: { ...makeCtx(cursor.sourceFile), level: 'model' } }; + return { node, ctx: makeCtx(cursor.sourceFile) }; } describe('str', () => { @@ -467,8 +470,16 @@ describe('modelAttribute', () => { 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 first: ArgType<'first', AttributeCtx> = { + kind: 'const', + label: 'first', + parse: () => ok('first'), + }; + const second: ArgType<'second', AttributeCtx> = { + kind: 'const', + label: 'second', + parse: () => ok('second'), + }; const result = oneOf(first, second).parse(expr, ctx); @@ -517,7 +528,7 @@ describe('fieldRef', () => { it('resolves a field that exists on the self model', () => { const { expr, ctx } = argOf('id'); - const result = fieldRef('self').parse(expr, ctx); + const result = fieldRef().parse(expr, ctx); expect(result.ok).toBe(true); if (result.ok) expect(result.value).toBe('id'); @@ -526,7 +537,7 @@ describe('fieldRef', () => { 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); + const result = fieldRef().parse(expr, ctx); expect(result.ok).toBe(false); if (!result.ok) { @@ -537,9 +548,12 @@ describe('fieldRef', () => { 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 referencedCtx: FieldAttributeCtx = { + ...ctx, + resolveReferencedModel: () => ctx.selfModel, + }; - const result = fieldRef('referenced').parse(expr, referencedCtx); + const result = referencedFieldRef().parse(expr, referencedCtx); expect(result.ok).toBe(true); if (result.ok) expect(result.value).toBe('id'); @@ -548,21 +562,21 @@ describe('fieldRef', () => { 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); + const result = referencedFieldRef().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('labels both scopes as a field name', () => { + expect(fieldRef().label).toBe('field name'); + expect(referencedFieldRef().label).toBe('field name'); }); it('rejects a non-identifier token', () => { const { expr, ctx } = argOf('"title"'); - const result = fieldRef('self').parse(expr, ctx); + const result = fieldRef().parse(expr, ctx); expect(result.ok).toBe(false); if (!result.ok) expect(result.failure[0]?.code).toBe('PSL_INVALID_ATTRIBUTE_SYNTAX'); 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 2d5f3c0c3d00..36672883bd10 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 @@ -1,10 +1,10 @@ import type { PslDiagnostic } from '@internal/framework-components/psl-ast'; import { ok, type Result } from '@internal/utils/result'; import { expectTypeOf, test } from 'vitest'; -import type { ArgType, InferAttr } from '../src/exports'; +import type { ArgType, AttributeCtx, InferAttr } from '../src/exports'; import { fieldAttribute, modelAttribute, optional } from '../src/exports'; -function leaf(kind: string, value: T): ArgType { +function leaf(kind: string, value: T): ArgType { return { kind, label: kind, @@ -12,8 +12,8 @@ function leaf(kind: string, value: T): ArgType { }; } -const str = (): ArgType => leaf('str', ''); -const int = (): ArgType => leaf('int', 0); +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() } }); 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 7c0c25be8ec6..c3b43a7f178e 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec.test.ts @@ -1,7 +1,7 @@ import type { PslDiagnostic } from '@internal/framework-components/psl-ast'; import { notOk, ok, type Result } from '@internal/utils/result'; import { describe, expect, it } from 'vitest'; -import type { ArgType, InterpretCtx } from '../src/exports'; +import type { ArgType, AttributeCtx, FieldAttributeCtx } from '../src/exports'; import { fieldAttribute, int, @@ -17,7 +17,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): InterpretCtx { +function makeCtx(sourceFile: SourceFile): FieldAttributeCtx { const { document, sourceFile: modelSource } = parse('model M {\n id Int @id\n}\n'); const { table } = buildSymbolTable({ document, @@ -26,23 +26,25 @@ function makeCtx(sourceFile: SourceFile): InterpretCtx { }); const selfModel = table.topLevel.models['M']; if (!selfModel) throw new Error('expected model M in the symbol table'); + const field = selfModel.fields['id']; + if (!field) throw new Error('expected field id on model M'); return { - level: 'field', sourceId: 'schema.prisma', sourceFile, selfModel, + field, resolveReferencedModel: () => undefined, }; } -function fieldAttr(source: string): { node: FieldAttributeAst; ctx: InterpretCtx } { +function fieldAttr(source: string): { node: FieldAttributeAst; ctx: FieldAttributeCtx } { 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) }; } -function str(): ArgType { +function str(): ArgType { return { kind: 'str', label: 'string', @@ -70,7 +72,7 @@ const FAILING_DIAGNOSTIC: PslDiagnostic = { span: { start: { offset: 0, line: 1, column: 1 }, end: { offset: 0, line: 1, column: 1 } }, }; -function failing(): ArgType { +function failing(): ArgType { return { kind: 'failing', label: 'failing', diff --git a/packages/2-mongo-family/2-authoring/contract-psl/src/mongo-attribute-specs.ts b/packages/2-mongo-family/2-authoring/contract-psl/src/mongo-attribute-specs.ts index ff3278fb450c..c207ce370f9d 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/src/mongo-attribute-specs.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/src/mongo-attribute-specs.ts @@ -5,10 +5,11 @@ import type { AttributeSpecContext, AttributeSpecNamespace, FieldAttributeSpecContext, + FieldAttributeCtx, FieldSymbol, FuncCallSig, InferAttr, - InterpretCtx, + ModelAttributeCtx, ModelSymbol, TypedFuncCall, } from '@internal/psl-parser'; @@ -28,6 +29,7 @@ import { oneOf, optional, record, + referencedFieldRef, str, } from '@internal/psl-parser'; import type { FieldAttributeAst, ModelAttributeAst, SourceFile } from '@internal/psl-parser/syntax'; @@ -52,29 +54,26 @@ export function findFieldAttributeNode( return undefined; } -function buildModelInterpretCtx(input: { +function buildModelAttributeCtx(input: { readonly selfModel: ModelSymbol; readonly sourceFile: SourceFile; readonly sourceId: string; -}): InterpretCtx { +}): ModelAttributeCtx { return { - level: 'model', sourceId: input.sourceId, sourceFile: input.sourceFile, selfModel: input.selfModel, - resolveReferencedModel: () => undefined, }; } -function buildFieldInterpretCtx(input: { +function buildFieldAttributeCtx(input: { readonly selfModel: ModelSymbol; readonly field: FieldSymbol; readonly sourceFile: SourceFile; readonly sourceId: string; readonly resolveReferencedModel?: (() => ModelSymbol | undefined) | undefined; -}): InterpretCtx { +}): FieldAttributeCtx { return { - level: 'field', sourceId: input.sourceId, sourceFile: input.sourceFile, selfModel: input.selfModel, @@ -88,7 +87,7 @@ function buildFieldInterpretCtx(input: { // failure so the caller can apply its own default/absence handling. export function interpretModelAttribute(input: { readonly node: ModelAttributeAst; - readonly spec: AttributeSpec; + readonly spec: AttributeSpec; readonly model: ModelSymbol; readonly sourceFile: SourceFile; readonly sourceId: string; @@ -97,7 +96,7 @@ export function interpretModelAttribute(input: { const result = interpretAttribute( input.node, input.spec, - buildModelInterpretCtx({ + buildModelAttributeCtx({ selfModel: input.model, sourceFile: input.sourceFile, sourceId: input.sourceId, @@ -115,7 +114,7 @@ export function interpretModelAttribute(input: { // failure so the caller can apply its own default/absence handling. export function interpretFieldAttribute(input: { readonly node: FieldAttributeAst; - readonly spec: AttributeSpec; + readonly spec: AttributeSpec; readonly model: ModelSymbol; readonly field: FieldSymbol; readonly sourceFile: SourceFile; @@ -126,7 +125,7 @@ export function interpretFieldAttribute(input: { const result = interpretAttribute( input.node, input.spec, - buildFieldInterpretCtx({ + buildFieldAttributeCtx({ selfModel: input.model, field: input.field, sourceFile: input.sourceFile, @@ -150,14 +149,14 @@ export const relationFieldSpec = fieldAttribute('relation', { positional: [{ key: 'name', type: optional(str()) }], named: { name: optional(str()), - fields: optional(list(fieldRef('self'), { nonEmpty: true, unique: true })), - references: optional(list(fieldRef('referenced'), { nonEmpty: true, unique: true })), + fields: optional(list(fieldRef(), { nonEmpty: true, unique: true })), + references: optional(list(referencedFieldRef(), { nonEmpty: true, unique: true })), }, }); export type RelationFieldOutput = InferAttr; export const discriminatorModelSpec = modelAttribute('discriminator', { - positional: [{ key: 'field', type: fieldRef('self') }], + positional: [{ key: 'field', type: fieldRef() }], }); export const baseModelSpec = modelAttribute('base', { positional: [ @@ -170,9 +169,14 @@ const sortSig = { named: { sort: oneOf(identifier('Asc'), identifier('Desc')) }, } satisfies FuncCallSig; -function indexFieldElement(fieldNames: readonly string[]): ArgType { - const arms: readonly [ArgType, ...ArgType[]] = [ - fieldRef('self'), +function indexFieldElement( + fieldNames: readonly string[], +): ArgType { + const arms: readonly [ + ArgType, + ...ArgType[], + ] = [ + fieldRef(), funcCall('wildcard', { positional: [{ key: 'scope', type: optional(entityRef()) }] }), ...fieldNames.map((name) => funcCall(name, sortSig)), ]; @@ -193,7 +197,7 @@ const collationNamedArgs = { function buildIndexModelSpec( name: 'index' | 'unique', - fieldElement: ArgType, + fieldElement: ArgType, ) { return modelAttribute(name, { positional: [{ key: 'fields', type: list(fieldElement, { nonEmpty: true }) }], @@ -213,7 +217,7 @@ function buildIndexModelSpec( }); } -function buildTextIndexModelSpec(fieldElement: ArgType) { +function buildTextIndexModelSpec(fieldElement: ArgType) { return modelAttribute('textIndex', { positional: [{ key: 'fields', type: list(fieldElement, { nonEmpty: true }) }], named: { diff --git a/packages/2-sql/2-authoring/contract-psl/src/sql-attribute-specs.ts b/packages/2-sql/2-authoring/contract-psl/src/sql-attribute-specs.ts index 50aea8a42343..da2141dedb44 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/sql-attribute-specs.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/sql-attribute-specs.ts @@ -6,14 +6,16 @@ import type { } from '@internal/framework-components/psl-ast'; import type { ArgType, + AttributeCtx, AttributeSpec, AttributeSpecContext, AttributeSpecNamespace, + FieldAttributeCtx, FieldAttributeSpecContext, FieldSymbol, FuncCallSig, InferAttr, - InterpretCtx, + ModelAttributeCtx, ModelSymbol, PslSpan, SymbolTable, @@ -35,6 +37,7 @@ import { oneOf, optional, record, + referencedFieldRef, str, } from '@internal/psl-parser'; import type { FieldAttributeAst, ModelAttributeAst, SourceFile } from '@internal/psl-parser/syntax'; @@ -61,29 +64,26 @@ export function findFieldAttributeNode( return undefined; } -function buildModelInterpretCtx(input: { +function buildModelAttributeCtx(input: { readonly selfModel: ModelSymbol; readonly sourceFile: SourceFile; readonly sourceId: string; -}): InterpretCtx { +}): ModelAttributeCtx { return { - level: 'model', sourceId: input.sourceId, sourceFile: input.sourceFile, selfModel: input.selfModel, - resolveReferencedModel: () => undefined, }; } -function buildFieldInterpretCtx(input: { +function buildFieldAttributeCtx(input: { readonly selfModel: ModelSymbol; readonly field: FieldSymbol; readonly sourceFile: SourceFile; readonly sourceId: string; readonly resolveReferencedModel?: (() => ModelSymbol | undefined) | undefined; -}): InterpretCtx { +}): FieldAttributeCtx { return { - level: 'field', sourceId: input.sourceId, sourceFile: input.sourceFile, selfModel: input.selfModel, @@ -97,7 +97,7 @@ function buildFieldInterpretCtx(input: { // failure so the caller can apply its own default/absence handling. export function interpretModelAttribute(input: { readonly node: ModelAttributeAst; - readonly spec: AttributeSpec; + readonly spec: AttributeSpec; readonly model: ModelSymbol; readonly sourceFile: SourceFile; readonly sourceId: string; @@ -106,7 +106,7 @@ export function interpretModelAttribute(input: { const result = interpretAttribute( input.node, input.spec, - buildModelInterpretCtx({ + buildModelAttributeCtx({ selfModel: input.model, sourceFile: input.sourceFile, sourceId: input.sourceId, @@ -124,7 +124,7 @@ export function interpretModelAttribute(input: { // failure so the caller can apply its own default/absence handling. export function interpretFieldAttribute(input: { readonly node: FieldAttributeAst; - readonly spec: AttributeSpec; + readonly spec: AttributeSpec; readonly model: ModelSymbol; readonly field: FieldSymbol; readonly sourceFile: SourceFile; @@ -135,7 +135,7 @@ export function interpretFieldAttribute(input: { const result = interpretAttribute( input.node, input.spec, - buildFieldInterpretCtx({ + buildFieldAttributeCtx({ selfModel: input.model, field: input.field, sourceFile: input.sourceFile, @@ -158,7 +158,7 @@ type DefaultArgValue = string | number | boolean | (string | number | boolean)[] function scalarDefaultArms( isList: boolean, registry: ControlMutationDefaultRegistry, -): readonly [ArgType, ...ArgType[]] { +): readonly [ArgType, ...ArgType[]] { const literal = () => oneOf(str(), num(), bool()); const funcArms = [...registry.entries()].map(([name, entry]) => funcCall( @@ -172,7 +172,7 @@ function scalarDefaultArms( return isList ? [list(literal()), ...funcArms] : [str(), num(), bool(), ...funcArms]; } -function noEnumMember(): ArgType { +function noEnumMember(): ArgType { return { kind: 'identifier', label: 'enum member', @@ -192,7 +192,7 @@ function enumMemberNames(ctx: FieldAttributeSpecContext): readonly string[] | un function enumDefaultArms( members: readonly string[], -): readonly [ArgType, ...ArgType[]] { +): readonly [ArgType, ...ArgType[]] { const [first, ...rest] = members; if (first === undefined) return [noEnumMember()]; return [identifier(first), ...rest.map((name) => identifier(name))]; @@ -232,11 +232,11 @@ const noCheckFieldSpec = fieldAttribute('noCheck', { }); const idModelSpec = modelAttribute('id', { - positional: [{ key: 'fields', type: list(fieldRef('self'), { nonEmpty: true, unique: true }) }], + positional: [{ key: 'fields', type: list(fieldRef(), { nonEmpty: true, unique: true }) }], named: { map: optional(str()) }, }); const uniqueModelSpec = modelAttribute('unique', { - positional: [{ key: 'fields', type: list(fieldRef('self'), { nonEmpty: true, unique: true }) }], + positional: [{ key: 'fields', type: list(fieldRef(), { nonEmpty: true, unique: true }) }], named: { map: optional(str()) }, }); @@ -251,7 +251,7 @@ export const PSL_INDEX_NAME_XOR_MAP: ContributedPslDiagnosticCode = 'PSL_INDEX_N const indexModelSpec = modelAttribute('index', { positional: [ - { key: 'fields', type: optional(list(fieldRef('self'), { nonEmpty: true, unique: true })) }, + { key: 'fields', type: optional(list(fieldRef(), { nonEmpty: true, unique: true })) }, ], named: { expression: optional(str()), @@ -377,7 +377,7 @@ const controlModelSpec = modelAttribute('control', { }); const discriminatorModelSpec = modelAttribute('discriminator', { - positional: [{ key: 'field', type: fieldRef('self') }], + positional: [{ key: 'field', type: fieldRef() }], }); const baseModelSpec = modelAttribute('base', { positional: [ @@ -386,21 +386,17 @@ const baseModelSpec = modelAttribute('base', { ], }); -function relationAttributeSpan(ctx: InterpretCtx): PslSpan { - const field = ctx.field; - if (field !== undefined) { - const node = findFieldAttributeNode(field, 'relation'); - if (node !== undefined) { - return nodePslSpan(node.syntax, ctx.sourceFile); - } - return field.span; +function relationAttributeSpan(ctx: FieldAttributeCtx): PslSpan { + const node = findFieldAttributeNode(ctx.field, 'relation'); + if (node !== undefined) { + return nodePslSpan(node.syntax, ctx.sourceFile); } - return ctx.selfModel.span; + return ctx.field.span; } function relationInvariants( parsed: { readonly fields?: readonly string[]; readonly references?: readonly string[] }, - ctx: InterpretCtx, + ctx: FieldAttributeCtx, ): readonly PslDiagnostic[] { const hasFields = parsed.fields !== undefined; const hasReferences = parsed.references !== undefined; @@ -408,7 +404,7 @@ function relationInvariants( return [ { code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', - message: `Relation field "${ctx.selfModel.name}.${ctx.field?.name ?? ''}" requires fields and references arguments`, + message: `Relation field "${ctx.selfModel.name}.${ctx.field.name}" requires fields and references arguments`, sourceId: ctx.sourceId, span: relationAttributeSpan(ctx), }, @@ -430,8 +426,8 @@ const relationFieldSpec = fieldAttribute('relation', { positional: [{ key: 'name', type: optional(str()) }], named: { name: optional(str()), - fields: optional(list(fieldRef('self'), { nonEmpty: true, unique: true })), - references: optional(list(fieldRef('referenced'), { nonEmpty: true, unique: true })), + fields: optional(list(fieldRef(), { nonEmpty: true, unique: true })), + references: optional(list(referencedFieldRef(), { nonEmpty: true, unique: true })), map: optional(str()), onDelete: optional(referentialActionArgument()), onUpdate: optional(referentialActionArgument()), diff --git a/skills/prisma-8/upgrading/extension/upgrades/8.0.0-rc.8-to-8.0.0-rc.9/instructions.md b/skills/prisma-8/upgrading/extension/upgrades/8.0.0-rc.8-to-8.0.0-rc.9/instructions.md index dad2b89acff3..c46cd7d64424 100644 --- a/skills/prisma-8/upgrading/extension/upgrades/8.0.0-rc.8-to-8.0.0-rc.9/instructions.md +++ b/skills/prisma-8/upgrading/extension/upgrades/8.0.0-rc.8-to-8.0.0-rc.9/instructions.md @@ -26,13 +26,27 @@ changes: anyMatch: true - id: arg-type-parse-is-a-property summary: | - `ArgType.parse` is now a property function type carrying a `Ctx` parameter (`ArgType`), so the ctx an argument type needs is checked contravariantly. A class that implements `ArgType` with a `parse(...)` method, or an object typed against `ArgType` and used inside `blockAttribute()`, must declare `parse` as a function-typed property over the ctx it actually reads (`BlockInterpretCtx` when it never touches `selfModel`). + `ArgType.parse` is now a property function type carrying a `Ctx` parameter, so the ctx an argument type needs is checked contravariantly. A class that implements `ArgType` with a `parse(...)` method, or an object typed against `ArgType` and used inside `blockAttribute()`, must declare `parse` as a function-typed property over the ctx it actually reads. detection: glob: "**/*.{ts,tsx}" contains: - "implements ArgType" - "ArgType<" anyMatch: true + - id: state-attribute-spec-contexts-explicitly + summary: | + The attribute-spec interpret contexts were reshaped. `BlockInterpretCtx` and `InterpretCtx` are gone, replaced by `AttributeCtx` (`sourceId` + `sourceFile`), `ModelAttributeCtx` (adds `selfModel`), and `FieldAttributeCtx` (adds a required `field` and `resolveReferencedModel()`). Contexts no longer carry `level`. `ArgType`, `OptionalArgType`, `Param`, `PositionalParam`, and `AttributeSpec` lost their default type arguments, so every use site must name its context. `fieldRef('self')` / `fieldRef('referenced')` became `fieldRef()` / `referencedFieldRef()`, and `FieldRefScope`, `FieldRefArgType`, and the `scope` property are removed. `oneOf` is one generic signature whose context is the intersection of its alternatives' contexts. + detection: + glob: "**/*.{ts,tsx}" + contains: + - "InterpretCtx" + - "fieldRef(" + - "FieldRefScope" + - "FieldRefArgType" + - "ArgType<" + - "AttributeSpec<" + - "PositionalParam" + anyMatch: true --- # 8.0.0-rc.8 → 8.0.0-rc.9 — Extension author upgrade instructions @@ -55,4 +69,18 @@ Delete every reference to `PSL_NATIVE_ENUM_INVALID_MAP`. Where a test asserted t ## `arg-type-parse-is-a-property` -Find classes declaring `implements ArgType<…>` with a `parse(arg, ctx)` method and object literals typed against `ArgType`. Declare `parse` as a property whose type is `(arg: ExpressionAst, ctx: Ctx) => Result`. Pick `Ctx = BlockInterpretCtx` when the implementation reads only `sourceId` / `sourceFile` (this makes the argument type usable inside `blockAttribute()` specs); keep the default `InterpretCtx` when it reads `selfModel` or `resolveReferencedModel()`. Dispatch on the syntax node with `XAst.cast(arg.syntax)` rather than `arg instanceof XAst` so the argument type keeps working when the spec and the parser come from different module copies. +Find classes declaring `implements ArgType<…>` with a `parse(arg, ctx)` method and object literals typed against `ArgType`. Declare `parse` as a property whose type is `(arg: ExpressionAst, ctx: Ctx) => Result`. Pick the narrowest `Ctx` the implementation actually reads, as the next entry describes. Dispatch on the syntax node with `XAst.cast(arg.syntax)` rather than `arg instanceof XAst` so the argument type keeps working when the spec and the parser come from different module copies. + +## `state-attribute-spec-contexts-explicitly` + +**Rename the context types.** `BlockInterpretCtx` becomes `AttributeCtx`. `InterpretCtx` splits: use `ModelAttributeCtx` where the code reads `selfModel` and nothing else, and `FieldAttributeCtx` where it reads `field` or `resolveReferencedModel()`. Both are exported from `@internal/psl-parser`. + +**Drop `level` from every context value.** A hand-built ctx object that set `level: 'field' | 'model' | 'block'` must delete that property; contexts no longer declare it. `AttributeSpec.level` is a different field and is unchanged — keep setting and reading it. + +**Move `resolveReferencedModel` down to the field level.** A model-level ctx must no longer supply it. The `resolveReferencedModel: () => undefined` stub that model-level ctx builders carried is now a type error; delete it. A field-level ctx must supply both `field` (previously optional, now required) and `resolveReferencedModel()`. + +**Name a context at every use site.** `ArgType`, `OptionalArgType`, `Param`, `PositionalParam`, and `AttributeSpec` no longer default their second type argument. Rewrite each as `ArgType` when the combinator reads only `sourceId` / `sourceFile`, `ArgType` when it reads `selfModel`, and `ArgType` when it reads `field` or `resolveReferencedModel()`; the same choice applies to the other four. `PositionalParam` also lost its `T = unknown` default, so a bare `PositionalParam` becomes `PositionalParam`. Prefer the widest context that still typechecks: a spec parameter over `AttributeCtx` is usable inside `blockAttribute()`, `modelAttribute()`, and `fieldAttribute()` alike. + +**Split the field reference combinator.** Replace `fieldRef('self')` with `fieldRef()` and `fieldRef('referenced')` with `referencedFieldRef()` (imported from `@internal/psl-parser`). `fieldRef()` is typed over `ModelAttributeCtx` and stays usable in model attributes such as `@@index`; `referencedFieldRef()` is typed over `FieldAttributeCtx` and is accepted only in field attributes. The `FieldRefScope` and `FieldRefArgType` types and the `scope` property on the returned combinator are removed — a test asserting `fieldRef('self').scope` has no replacement; assert on the parse behaviour or on `label` instead. + +**Let `oneOf` infer its own context.** `oneOf` is now a single generic signature: the output is the union of the alternatives' outputs and the context is their intersection. Delete any explicit type argument or context annotation forcing a particular alternation context. An alternation containing `fieldRef()` is model-scoped and is rejected inside `blockAttribute()`; one built only from `str()`, `num()`, `bool()`, `identifier()`, `json()`, `entityRef()`, and `funcCall()` is usable at every level. From 22044a6614b6973547d0dc2ceaadb23a61b83d56 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Wed, 9 Sep 2026 10:23:53 +0000 Subject: [PATCH 12/15] TML-3230: carry the reshaped attribute ctx through the merged mongo namespace Signed-off-by: Steven McClankerton --- .../2-authoring/contract-psl/src/mongo-attribute-specs.ts | 6 ++++-- packages/2-mongo-family/9-family/package.json | 1 - pnpm-lock.yaml | 3 --- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/2-mongo-family/2-authoring/contract-psl/src/mongo-attribute-specs.ts b/packages/2-mongo-family/2-authoring/contract-psl/src/mongo-attribute-specs.ts index c207ce370f9d..9bdba70ecfcd 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/src/mongo-attribute-specs.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/src/mongo-attribute-specs.ts @@ -4,8 +4,8 @@ import type { AttributeSpec, AttributeSpecContext, AttributeSpecNamespace, - FieldAttributeSpecContext, FieldAttributeCtx, + FieldAttributeSpecContext, FieldSymbol, FuncCallSig, InferAttr, @@ -230,7 +230,9 @@ function buildTextIndexModelSpec(fieldElement: ArgType { +function modelFieldElement( + ctx: AttributeSpecContext, +): ArgType { return indexFieldElement(Object.keys(ctx.model.fields)); } diff --git a/packages/2-mongo-family/9-family/package.json b/packages/2-mongo-family/9-family/package.json index 0302d0b163ff..6ac08fab59bc 100644 --- a/packages/2-mongo-family/9-family/package.json +++ b/packages/2-mongo-family/9-family/package.json @@ -33,7 +33,6 @@ }, "devDependencies": { "@internal/mongo-contract-ts": "workspace:8.0.0-rc.8", - "@internal/psl-parser": "workspace:8.0.0-rc.8", "@repo/test-utils": "workspace:8.0.0-rc.8", "@repo/tsconfig": "workspace:8.0.0-rc.8", "@repo/tsdown": "workspace:8.0.0-rc.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11963383256b..05532bb42fa4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2256,9 +2256,6 @@ importers: '@internal/mongo-contract-ts': specifier: workspace:8.0.0-rc.8 version: link:../2-authoring/contract-ts - '@internal/psl-parser': - specifier: workspace:8.0.0-rc.8 - version: link:../../1-framework/2-authoring/psl-parser '@repo/test-utils': specifier: workspace:8.0.0-rc.8 version: link:../../../test/utils From 8108c77f6171822a0b87c684c24669d75c48938b Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Wed, 9 Sep 2026 12:41:56 +0000 Subject: [PATCH 13/15] TML-3230: simplify oneOf to a single ctx shared by its alternatives The alternation no longer computes an intersection ctx from its alternatives. Constraining the tuple element to ArgType makes alt.parse(arg, ctx) typecheck directly, which removes the ctx blindCast, OneOfCtx, and the now-unused CtxOf. Signed-off-by: Steven McClankerton --- .../src/attribute-spec/combinators/one-of.ts | 21 +++++-------------- .../psl-parser/src/attribute-spec/types.ts | 2 -- .../psl-parser/src/exports/index.ts | 2 -- .../test/attribute-spec-block.test-d.ts | 5 +++-- .../8.0.0-rc.8-to-8.0.0-rc.9/instructions.md | 4 ++-- 5 files changed, 10 insertions(+), 24 deletions(-) diff --git a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/one-of.ts b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/one-of.ts index 15e2cd99398c..831e2e6a895e 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/one-of.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/attribute-spec/combinators/one-of.ts @@ -1,31 +1,20 @@ import type { PslDiagnostic } from '@internal/framework-components/psl-ast'; import { blindCast } from '@internal/utils/casts'; import { notOk, ok, type Result } from '@internal/utils/result'; -import type { UnionToIntersection } from '@internal/utils/types'; -import type { ArgType, AttributeCtx, CtxOf, OutOf } from '../types'; +import type { ArgType, AttributeCtx, OutOf } from '../types'; import { leafDiagnostic } from './diagnostic'; -export type OneOfCtx[]> = UnionToIntersection< - CtxOf -> & - AttributeCtx; - export function oneOf< - const Alts extends readonly [ArgType, ...ArgType[]], ->(...alts: [...Alts]): ArgType, OneOfCtx> { + Ctx extends AttributeCtx, + Alts extends readonly [ArgType, ...ArgType[]], +>(...alts: Alts): ArgType, Ctx> { 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, - blindCast< - never, - 'Each alternative declares the ctx it reads and oneOf demands their intersection, so the received ctx satisfies every alternative; iterating the tuple widens each element to its `never`-ctx bound and erases that.' - >(ctx), - ); + const result = alt.parse(arg, ctx); if (result.ok) { return ok( blindCast< 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 d36b69144769..69e674b871cb 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 @@ -59,8 +59,6 @@ export interface AttributeSpec { export type OutOf

= P extends ArgType ? T : never; -export type CtxOf

= P extends ArgType ? Ctx : never; - export type NamedOut>> = Simplify< { [K in keyof N as N[K] extends OptionalArgType ? never : K]: OutOf } & { [K in keyof N as N[K] extends OptionalArgType ? K : never]?: 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 d56fe64f9e86..aedab04ded4c 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 @@ -51,7 +51,6 @@ export { json } from '../attribute-spec/combinators/json'; export type { ListOptions } from '../attribute-spec/combinators/list'; export { list } from '../attribute-spec/combinators/list'; export { num } from '../attribute-spec/combinators/num'; -export type { OneOfCtx } from '../attribute-spec/combinators/one-of'; export { oneOf } from '../attribute-spec/combinators/one-of'; export { record } from '../attribute-spec/combinators/record'; export { str } from '../attribute-spec/combinators/str'; @@ -74,7 +73,6 @@ export type { AttributeLevel, AttributeOut, AttributeSpec, - CtxOf, FieldAttributeCtx, InferAttr, ModelAttributeCtx, diff --git a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-block.test-d.ts b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-block.test-d.ts index 7f5216289ec0..a0d879bf512a 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-block.test-d.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/attribute-spec-block.test-d.ts @@ -97,8 +97,9 @@ test('oneOf over bare-ctx alternatives stays usable in a block spec', () => { expectTypeOf>().toEqualTypeOf<{ kind: 'a' | 'b' }>(); }); -test('oneOf carrying a model-scoped alternative is rejected in a block spec', () => { - const arm = oneOf(str(), fieldRef()); +test('oneOf takes its ctx from the annotation a mixed alternation is assigned to', () => { + const arm: ArgType = oneOf(str(), fieldRef()); + expectTypeOf(arm).toEqualTypeOf>(); blockAttribute('bad', { // @ts-expect-error one alternative reads selfModel, so the alternation demands a model ctx positional: [{ key: 'value', type: arm }], diff --git a/skills/prisma-8/upgrading/extension/upgrades/8.0.0-rc.8-to-8.0.0-rc.9/instructions.md b/skills/prisma-8/upgrading/extension/upgrades/8.0.0-rc.8-to-8.0.0-rc.9/instructions.md index c46cd7d64424..49cb105e4ea2 100644 --- a/skills/prisma-8/upgrading/extension/upgrades/8.0.0-rc.8-to-8.0.0-rc.9/instructions.md +++ b/skills/prisma-8/upgrading/extension/upgrades/8.0.0-rc.8-to-8.0.0-rc.9/instructions.md @@ -35,7 +35,7 @@ changes: anyMatch: true - id: state-attribute-spec-contexts-explicitly summary: | - The attribute-spec interpret contexts were reshaped. `BlockInterpretCtx` and `InterpretCtx` are gone, replaced by `AttributeCtx` (`sourceId` + `sourceFile`), `ModelAttributeCtx` (adds `selfModel`), and `FieldAttributeCtx` (adds a required `field` and `resolveReferencedModel()`). Contexts no longer carry `level`. `ArgType`, `OptionalArgType`, `Param`, `PositionalParam`, and `AttributeSpec` lost their default type arguments, so every use site must name its context. `fieldRef('self')` / `fieldRef('referenced')` became `fieldRef()` / `referencedFieldRef()`, and `FieldRefScope`, `FieldRefArgType`, and the `scope` property are removed. `oneOf` is one generic signature whose context is the intersection of its alternatives' contexts. + The attribute-spec interpret contexts were reshaped. `BlockInterpretCtx` and `InterpretCtx` are gone, replaced by `AttributeCtx` (`sourceId` + `sourceFile`), `ModelAttributeCtx` (adds `selfModel`), and `FieldAttributeCtx` (adds a required `field` and `resolveReferencedModel()`). Contexts no longer carry `level`. `ArgType`, `OptionalArgType`, `Param`, `PositionalParam`, and `AttributeSpec` lost their default type arguments, so every use site must name its context. `fieldRef('self')` / `fieldRef('referenced')` became `fieldRef()` / `referencedFieldRef()`, and `FieldRefScope`, `FieldRefArgType`, and the `scope` property are removed. `oneOf` is one generic signature over a single context shared by every alternative, so a mixed alternation must be given that context by an annotation or a contextual type. detection: glob: "**/*.{ts,tsx}" contains: @@ -83,4 +83,4 @@ Find classes declaring `implements ArgType<…>` with a `parse(arg, ctx)` method **Split the field reference combinator.** Replace `fieldRef('self')` with `fieldRef()` and `fieldRef('referenced')` with `referencedFieldRef()` (imported from `@internal/psl-parser`). `fieldRef()` is typed over `ModelAttributeCtx` and stays usable in model attributes such as `@@index`; `referencedFieldRef()` is typed over `FieldAttributeCtx` and is accepted only in field attributes. The `FieldRefScope` and `FieldRefArgType` types and the `scope` property on the returned combinator are removed — a test asserting `fieldRef('self').scope` has no replacement; assert on the parse behaviour or on `label` instead. -**Let `oneOf` infer its own context.** `oneOf` is now a single generic signature: the output is the union of the alternatives' outputs and the context is their intersection. Delete any explicit type argument or context annotation forcing a particular alternation context. An alternation containing `fieldRef()` is model-scoped and is rejected inside `blockAttribute()`; one built only from `str()`, `num()`, `bool()`, `identifier()`, `json()`, `entityRef()`, and `funcCall()` is usable at every level. +**Give `oneOf` one context for all of its alternatives.** `oneOf` is now a single generic signature: the output is the union of the alternatives' outputs, and every alternative parses over the same context. An alternation whose alternatives all read only `sourceId` / `sourceFile` — `str()`, `num()`, `bool()`, `identifier()`, `json()`, `entityRef()`, `funcCall()` — needs nothing; its context is `AttributeCtx` and it stays usable at every level. An alternation that mixes those with a model-scoped or field-scoped alternative such as `fieldRef()` must be told which context it parses over, because `oneOf` no longer computes one from the alternatives. Supply it from the surrounding code: annotate the result (`const arm: ArgType = oneOf(str(), fieldRef())`), annotate the alternatives tuple before spreading it into `oneOf(...arms)`, or let the enclosing function's return type provide it. Such an alternation remains rejected inside `blockAttribute()`. From d19d849211e1dbd57ee579dec6630141e662b66e Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Wed, 9 Sep 2026 13:03:45 +0000 Subject: [PATCH 14/15] TML-3230: trim code-level detail from the attribute-spec ADRs Signed-off-by: Steven McClankerton --- .../adrs/ADR 126 - PSL top-level block SPI.md | 2 +- ...ADR 231 - Declarative attribute specifications.md | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/architecture docs/adrs/ADR 126 - PSL top-level block SPI.md b/docs/architecture docs/adrs/ADR 126 - PSL top-level block SPI.md index 38e917277583..eebd2473c7ab 100644 --- a/docs/architecture docs/adrs/ADR 126 - PSL top-level block SPI.md +++ b/docs/architecture docs/adrs/ADR 126 - PSL top-level block SPI.md @@ -66,7 +66,7 @@ A parameter is one of four kinds. The split is principled, not incidental: ## How the framework interprets a block -**Parse.** On an unknown top-level keyword, the framework looks it up in the `pslBlockDescriptors` registry. If a descriptor claims it, the generic parser reads the block into a `PslExtensionBlock` node — a name, a `parameters` map keyed by parameter name, and an `attributes` map holding the `@@` attributes interpreted through the specs the descriptor declares in `attributes`. No extension code runs. +**Parse.** On an unknown top-level keyword, the framework looks it up in the `pslBlockDescriptors` registry. If a descriptor claims it, the generic parser reads the block into a `PslExtensionBlock` node — a name plus a `parameters` map keyed by parameter name. No extension code runs. **Validate.** The validator checks, at parse time and with source spans: unknown parameters; missing required parameters; an `option` value outside the declared set; a `value` the codec's `decodeJson` rejects; and a `ref` that doesn't resolve within its declared scope. 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 e5787a843f98..dc46211e9e11 100644 --- a/docs/architecture docs/adrs/ADR 231 - Declarative attribute specifications.md +++ b/docs/architecture docs/adrs/ADR 231 - Declarative attribute specifications.md @@ -46,7 +46,7 @@ The SQL and Mongo family interpreters are the first consumers. They define their The kit consumes `ExpressionAst` directly. No intermediate argument representation is introduced, and no combinator reparses flattened source text except `json()`, the deliberate quoted-JSON-object exception. -Attributes are a PSL authoring concern, so the kit is in `psl-parser` rather than framework core. The constructors cover field, model, and block attributes: `blockAttribute()` builds a spec over the bare `AttributeCtx` (no model), a block descriptor declares its attributes on `AuthoringPslBlockDescriptor.attributes` as nullary factories, and the generic block reconstruction interprets them into `PslExtensionBlock.attributes`. +Attributes are a PSL authoring concern, so the kit is in `psl-parser` rather than framework core. Field, model, and block attributes are all constructed through it. A block descriptor declares which attributes its block accepts, and the generic block reconstruction interprets them at parse time. --- @@ -75,7 +75,7 @@ interface ArgType { } ``` -`Ctx` states what a combinator reads. The three contexts nest by what the site being parsed actually has, so a spec cannot demand facts its level never carries. There is no default type argument: every declaration names its context. +A combinator declares what it reads. The contexts nest by what the site being parsed actually has, so a spec cannot demand facts its level never carries. ```ts interface AttributeCtx { @@ -93,7 +93,7 @@ interface FieldAttributeCtx extends ModelAttributeCtx { } ``` -A block attribute is parsed with an `AttributeCtx` — a block has no model, so there is no separate block context type. `parse` is a property function type, so `Ctx` is checked contravariantly: a combinator over `AttributeCtx` is usable at every level, and one over `FieldAttributeCtx` is rejected in a model or block spec. +A block has no model, so a block attribute is parsed with only the source context. A combinator is usable at any level that carries the facts it declares, and rejected where those facts do not exist. A spec fixes the attribute level and name, declares its arguments, and may refine the parsed result: @@ -107,7 +107,7 @@ interface AttributeSpec { } ``` -`fieldAttribute`, `modelAttribute`, and `blockAttribute` fix `Ctx` to `FieldAttributeCtx`, `ModelAttributeCtx`, and `AttributeCtx` respectively, and infer `AttributeOut` when constructing a spec. `InferAttr` extracts that `Out` type. Optional parameters are `ArgType` values decorated by `optional(type)` or `optional(type, defaultValue)`; the engine detects the marker when finalizing absent arguments. +Each constructor fixes the context its level carries and infers `AttributeOut` when constructing a spec. `InferAttr` extracts that `Out` type. Optional parameters are `ArgType` values decorated by `optional(type)` or `optional(type, defaultValue)`; the engine detects the marker when finalizing absent arguments. Positionals are fixed slots with an output key. Variadic positionals are not supported. Positional and named parameters may intentionally share a key, which supports the relation-name alias while allowing the engine to diagnose conflicting duplicate values. @@ -140,7 +140,7 @@ These leaves perform direct AST checks. They do not wrap arktype schemas. ### References -`fieldRef()` parses a field-name identifier and validates it against the declaring model, so it needs only a `ModelAttributeCtx` and is available to model and field attributes alike. `referencedFieldRef()` validates against the relation target, which only a field can resolve, so it takes a `FieldAttributeCtx`; cross-space references may defer the existence check when no referenced model is locally available. Both return the authored field name as a string. +`fieldRef()` parses a field-name identifier and validates it against the declaring model, so it is available to model and field attributes alike. `referencedFieldRef()` validates against the relation target, which only a field can resolve; cross-space references may defer the existence check when no referenced model is locally available. Both return the authored field name as a string. `entityRef()` parses an unresolved model-name string. Existence and family semantics remain downstream concerns. @@ -166,7 +166,7 @@ This is intentionally narrower than an arbitrary JSON value. Its shipped use is ### Alternatives -`oneOf(first, ...rest)` tries its alternatives in order and returns the first success. If every alternative fails, it discards the branch diagnostics and emits one aggregate `Expected one of: …` diagnostic assembled from the alternatives' labels. Its output is the union of the alternatives' outputs and its context is their intersection, so an alternation containing `fieldRef()` is itself model-scoped. +`oneOf(first, ...rest)` tries its alternatives in order and returns the first success. If every alternative fails, it discards the branch diagnostics and emits one aggregate `Expected one of: …` diagnostic assembled from the alternatives' labels. This trade-off keeps the leaf contract small and allows backtracking, at the cost of less specific diagnostics for malformed input that resembles one particular branch. From b610de20b44d05c1a13ca23a29ffc89d1b5be4d2 Mon Sep 17 00:00:00 2001 From: Steven McClankerton Date: Wed, 9 Sep 2026 13:10:18 +0000 Subject: [PATCH 15/15] TML-3230: diagnose a duplicate block attribute whose first occurrence failed Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Steven McClankerton --- .../psl-parser/src/block-reconstruction.ts | 8 +++-- .../psl-parser/test/symbol-table.test.ts | 34 +++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/packages/1-framework/2-authoring/psl-parser/src/block-reconstruction.ts b/packages/1-framework/2-authoring/psl-parser/src/block-reconstruction.ts index f4e82a77d956..cb239fd519a6 100644 --- a/packages/1-framework/2-authoring/psl-parser/src/block-reconstruction.ts +++ b/packages/1-framework/2-authoring/psl-parser/src/block-reconstruction.ts @@ -36,6 +36,7 @@ export function reconstructExtensionBlock( const blockAttributes: PslExtensionBlockAttribute[] = []; const attributes: Record = {}; + const seenAttributeNames = new Set(); for (const attribute of node.attributes()) { const name = attribute.name()?.path().join('.') ?? ''; const args = Array.from(attribute.argList()?.args() ?? [], (arg) => { @@ -54,7 +55,7 @@ export function reconstructExtensionBlock( name, span, descriptor, - attributes, + seenAttributeNames, keyword, blockName, sourceFile, @@ -107,7 +108,7 @@ function parseBlockAttribute( name: string, span: PslSpan, descriptor: AuthoringPslBlockDescriptor, - parsedSoFar: Readonly>, + seenNames: Set, keyword: string, blockName: string, sourceFile: SourceFile, @@ -128,7 +129,7 @@ function parseBlockAttribute( ], }; } - if (Object.hasOwn(parsedSoFar, name)) { + if (seenNames.has(name)) { return { ok: false, diagnostics: [ @@ -140,6 +141,7 @@ function parseBlockAttribute( ], }; } + seenNames.add(name); const factory = blindCast< BlockAttributeSpecFactory, 'framework core cannot name AttributeSpec, so block-attribute factories transit the descriptor erased as unknown; this is the single point that restores the factory type the descriptor surface documents' diff --git a/packages/1-framework/2-authoring/psl-parser/test/symbol-table.test.ts b/packages/1-framework/2-authoring/psl-parser/test/symbol-table.test.ts index 76809b9e3d32..bb66216e1eaf 100644 --- a/packages/1-framework/2-authoring/psl-parser/test/symbol-table.test.ts +++ b/packages/1-framework/2-authoring/psl-parser/test/symbol-table.test.ts @@ -773,6 +773,40 @@ describe('buildSymbolTable() — block attributes parsed through the kit', () => expect(result.table.topLevel.blocks['Gear']?.block.attributes).toEqual({}); }); + it('diagnoses a duplicate whose first occurrence failed to bind', () => { + const result = build( + ['widget Gear {', ' @@map()', ' @@map("second")', '}'].join('\n'), + WIDGET_DESCRIPTORS, + ); + + expect(result.diagnostics).toEqual([ + { + code: 'PSL_INVALID_ATTRIBUTE_SYNTAX', + message: 'Attribute "map" is missing required argument "name"', + range: { start: { line: 1, character: 2 }, end: { line: 1, character: 9 } }, + }, + { + code: 'PSL_INVALID_EXTENSION_BLOCK_ATTRIBUTE', + message: 'Duplicate attribute "@@map" in "widget" block "Gear"; first occurrence wins', + range: { start: { line: 2, character: 2 }, end: { line: 2, character: 17 } }, + }, + ]); + expect(result.table.topLevel.blocks['Gear']?.block.attributes).toEqual({}); + }); + + it('reports an undeclared attribute on every occurrence', () => { + const result = build( + ['widget Gear {', ' @@schema("a")', ' @@schema("b")', '}'].join('\n'), + WIDGET_DESCRIPTORS, + ); + + expect(result.diagnostics.map((diagnostic) => diagnostic.code)).toEqual([ + 'PSL_EXTENSION_UNKNOWN_BLOCK_ATTRIBUTE', + 'PSL_EXTENSION_UNKNOWN_BLOCK_ATTRIBUTE', + ]); + expect(result.table.topLevel.blocks['Gear']?.block.attributes).toEqual({}); + }); + it('carries a refine diagnostic code contributed by the spec', () => { const result = build(['widget Gear {', ' @@map("")', '}'].join('\n'), WIDGET_DESCRIPTORS);