@prisma-next/framework-components 0.12.0-dev.61 → 0.12.0-dev.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/authoring.d.mts +2 -2
  2. package/dist/authoring.mjs +2 -2
  3. package/dist/components.d.mts +1 -1
  4. package/dist/control.d.mts +5 -4
  5. package/dist/control.d.mts.map +1 -1
  6. package/dist/control.mjs +8 -3
  7. package/dist/control.mjs.map +1 -1
  8. package/dist/execution.d.mts +1 -1
  9. package/dist/{framework-authoring-Szvddbl3.mjs → framework-authoring-CnwPJCO4.mjs} +76 -5
  10. package/dist/framework-authoring-CnwPJCO4.mjs.map +1 -0
  11. package/dist/framework-authoring-Cyde8zSN.d.mts +380 -0
  12. package/dist/framework-authoring-Cyde8zSN.d.mts.map +1 -0
  13. package/dist/{framework-components-Ce_Cdw76.d.mts → framework-components-DdqvMc8S.d.mts} +2 -2
  14. package/dist/{framework-components-Ce_Cdw76.d.mts.map → framework-components-DdqvMc8S.d.mts.map} +1 -1
  15. package/dist/{psl-ast-CTuBYLYj.d.mts → psl-ast-DRzRF9rS.d.mts} +46 -12
  16. package/dist/psl-ast-DRzRF9rS.d.mts.map +1 -0
  17. package/dist/psl-ast.d.mts +37 -2
  18. package/dist/psl-ast.d.mts.map +1 -0
  19. package/dist/psl-ast.mjs +142 -1
  20. package/dist/psl-ast.mjs.map +1 -1
  21. package/package.json +7 -7
  22. package/src/control/control-migration-types.ts +8 -2
  23. package/src/control/control-stack.ts +25 -1
  24. package/src/control/psl-ast.ts +62 -25
  25. package/src/control/psl-extension-block-validator.ts +340 -0
  26. package/src/exports/authoring.ts +16 -0
  27. package/src/exports/psl-ast.ts +2 -0
  28. package/src/shared/framework-authoring.ts +215 -2
  29. package/src/shared/psl-extension-block.ts +184 -0
  30. package/dist/framework-authoring-Cv04iZjB.d.mts +0 -183
  31. package/dist/framework-authoring-Cv04iZjB.d.mts.map +0 -1
  32. package/dist/framework-authoring-Szvddbl3.mjs.map +0 -1
  33. package/dist/psl-ast-CTuBYLYj.d.mts.map +0 -1
@@ -0,0 +1,340 @@
1
+ /**
2
+ * Generic validator for extension-contributed top-level PSL blocks.
3
+ *
4
+ * One function — {@link validateExtensionBlock} — takes a parsed
5
+ * {@link PslExtensionBlock}, its {@link AuthoringPslBlockDescriptor}, a
6
+ * {@link CodecLookup} (for `value` parameters), and the set of
7
+ * {@link PslNamespace} objects from the document (for `ref` resolution), and
8
+ * returns the full list of {@link PslDiagnostic} objects for the block.
9
+ *
10
+ * Detection logic per failure mode:
11
+ *
12
+ * 1. **Unknown parameter** — keys present in `node.parameters` that are absent
13
+ * from `descriptor.parameters` (key-set difference). The parser stores
14
+ * unknown parameters as `kind:'value'` stubs; the validator discovers them
15
+ * by comparing the key sets, not by inspecting the captured kind.
16
+ *
17
+ * 2. **Missing required parameter** — `descriptor.parameters` entries with
18
+ * `required: true` whose key is absent from `node.parameters`.
19
+ *
20
+ * 3. **`option` value outside its set** — the captured `token` is not in
21
+ * `descriptor.values`.
22
+ *
23
+ * 4. **`value` rejected by its codec** — the raw string is first parsed as
24
+ * JSON (`JSON.parse(raw)`). If `JSON.parse` throws, the literal is not valid
25
+ * JSON and a `PSL_EXTENSION_INVALID_VALUE` diagnostic is emitted. If parsing
26
+ * succeeds but `codec.decodeJson(jsonValue)` throws, the JSON value is not
27
+ * acceptable to the codec and a `PSL_EXTENSION_INVALID_VALUE` diagnostic is
28
+ * emitted. If `codecLookup.get(codecId)` returns `undefined` (unknown codec
29
+ * id), a `PSL_EXTENSION_INVALID_VALUE` diagnostic is also emitted.
30
+ *
31
+ * 5. **`ref` that does not resolve within its scope** — the captured
32
+ * `identifier` is looked up in the PSL document's `PslNamespace` objects
33
+ * according to `param.scope`:
34
+ * - `same-namespace`: the referent must be in the same namespace as the
35
+ * block (the namespace containing the block).
36
+ * - `same-space`: the referent may be in any namespace in the document.
37
+ * - `cross-space`: pass-through — enforcement is scoped to first-consumer
38
+ * need (RLS roles). This case is documented and clearly flagged; the
39
+ * caller is responsible for wiring cross-space resolution when needed.
40
+ *
41
+ * 6. **`list`** — each element is validated against `param.of` recursively.
42
+ *
43
+ * ### `char`/`varchar` length
44
+ * Not enforced. RLS `using`/`check` strings are unbounded text and the codec
45
+ * already rejects structurally invalid literals; length constraints are a
46
+ * database-side concern, not a PSL authoring constraint.
47
+ *
48
+ * ### `cross-space` scope
49
+ * Implemented as a documented pass-through. The spec permits scoping
50
+ * cross-space enforcement to first-consumer need (RLS roles). When RLS roles
51
+ * arrive, wire `cross-space` resolution through the cross-contract-space
52
+ * coordinate model `(spaceId, namespaceId, entityKind, entityName)`.
53
+ */
54
+
55
+ import type { JsonValue } from '@prisma-next/contract/types';
56
+ import { blindCast } from '@prisma-next/utils/casts';
57
+ import type { CodecLookup } from '../shared/codec-types';
58
+ import type { AuthoringPslBlockDescriptor } from '../shared/framework-authoring';
59
+ import type {
60
+ PslBlockParam,
61
+ PslBlockParamRef,
62
+ PslExtensionBlock,
63
+ PslExtensionBlockParamValue,
64
+ PslSpan,
65
+ } from '../shared/psl-extension-block';
66
+ import type { PslDiagnostic, PslNamespace } from './psl-ast';
67
+
68
+ /**
69
+ * Context for ref resolution during extension-block validation.
70
+ *
71
+ * - `ownerNamespace` is the `PslNamespace` that contains the block being
72
+ * validated. Used for `same-namespace` scope checks.
73
+ * - `allNamespaces` is every namespace in the document. Used for `same-space`
74
+ * scope checks.
75
+ */
76
+ export interface ExtensionBlockRefResolutionContext {
77
+ readonly ownerNamespace: PslNamespace;
78
+ readonly allNamespaces: readonly PslNamespace[];
79
+ }
80
+
81
+ /**
82
+ * Validate a single parsed extension block against its descriptor.
83
+ *
84
+ * Returns an array of {@link PslDiagnostic} objects (possibly empty). The
85
+ * caller is responsible for threading `sourceId` into each returned diagnostic
86
+ * — the returned objects already have `sourceId` set from the `sourceId`
87
+ * parameter.
88
+ *
89
+ * @param node - The parsed block node produced by the generic framework parser.
90
+ * @param descriptor - The descriptor that claims this block's keyword.
91
+ * @param sourceId - The PSL source file identifier (threaded into diagnostics).
92
+ * @param codecLookup - Used to validate `value`-kind parameter literals via
93
+ * `codecLookup.get(codecId)?.decodeJson(JSON.parse(raw))`.
94
+ * @param refCtx - Namespace context for `ref`-kind scope resolution. Required
95
+ * when any descriptor parameter is `kind: 'ref'`; may be omitted if none are.
96
+ */
97
+ export function validateExtensionBlock(
98
+ node: PslExtensionBlock,
99
+ descriptor: AuthoringPslBlockDescriptor,
100
+ sourceId: string,
101
+ codecLookup: CodecLookup,
102
+ refCtx?: ExtensionBlockRefResolutionContext,
103
+ ): readonly PslDiagnostic[] {
104
+ const diagnostics: PslDiagnostic[] = [];
105
+
106
+ const descriptorKeys = new Set(Object.keys(descriptor.parameters));
107
+ const nodeKeys = new Set(Object.keys(node.parameters));
108
+
109
+ // 1. Unknown parameters — keys in the node not in the descriptor.
110
+ for (const key of nodeKeys) {
111
+ if (!descriptorKeys.has(key)) {
112
+ const captured = node.parameters[key];
113
+ diagnostics.push({
114
+ code: 'PSL_EXTENSION_UNKNOWN_PARAMETER',
115
+ message: `Unknown parameter "${key}" in "${descriptor.keyword}" block "${node.name}". The descriptor does not declare this parameter.`,
116
+ sourceId,
117
+ span: captured?.span ?? node.span,
118
+ });
119
+ }
120
+ }
121
+
122
+ // 2. Missing required parameters — required descriptor keys absent from the node.
123
+ for (const [key, param] of Object.entries(descriptor.parameters)) {
124
+ if (param.required === true && !nodeKeys.has(key)) {
125
+ diagnostics.push({
126
+ code: 'PSL_EXTENSION_MISSING_REQUIRED_PARAMETER',
127
+ message: `Required parameter "${key}" is missing from "${descriptor.keyword}" block "${node.name}".`,
128
+ sourceId,
129
+ span: node.span,
130
+ });
131
+ }
132
+ }
133
+
134
+ // 3–5. Per-parameter validation for parameters that are present.
135
+ for (const [key, param] of Object.entries(descriptor.parameters)) {
136
+ const captured = node.parameters[key];
137
+ if (captured === undefined) {
138
+ continue;
139
+ }
140
+ validateParam(
141
+ node,
142
+ descriptor,
143
+ key,
144
+ param,
145
+ captured,
146
+ sourceId,
147
+ codecLookup,
148
+ refCtx,
149
+ diagnostics,
150
+ );
151
+ }
152
+
153
+ return diagnostics;
154
+ }
155
+
156
+ function validateParam(
157
+ node: PslExtensionBlock,
158
+ descriptor: AuthoringPslBlockDescriptor,
159
+ key: string,
160
+ param: PslBlockParam,
161
+ captured: PslExtensionBlockParamValue,
162
+ sourceId: string,
163
+ codecLookup: CodecLookup,
164
+ refCtx: ExtensionBlockRefResolutionContext | undefined,
165
+ diagnostics: PslDiagnostic[],
166
+ ): void {
167
+ switch (param.kind) {
168
+ case 'option': {
169
+ if (captured.kind !== 'option') {
170
+ return;
171
+ }
172
+ if (!param.values.includes(captured.token)) {
173
+ diagnostics.push({
174
+ code: 'PSL_EXTENSION_OPTION_OUT_OF_SET',
175
+ message: `Parameter "${key}" in "${descriptor.keyword}" block "${node.name}" has value "${captured.token}" which is not one of the allowed values: ${param.values.map((v) => `"${v}"`).join(', ')}.`,
176
+ sourceId,
177
+ span: captured.span,
178
+ });
179
+ }
180
+ return;
181
+ }
182
+
183
+ case 'value': {
184
+ if (captured.kind !== 'value') {
185
+ return;
186
+ }
187
+ const codec = codecLookup.get(param.codecId);
188
+ if (codec === undefined) {
189
+ diagnostics.push({
190
+ code: 'PSL_EXTENSION_INVALID_VALUE',
191
+ message: `Parameter "${key}" in "${descriptor.keyword}" block "${node.name}" references unknown codec "${param.codecId}".`,
192
+ sourceId,
193
+ span: captured.span,
194
+ });
195
+ return;
196
+ }
197
+ let jsonValue: unknown;
198
+ try {
199
+ jsonValue = JSON.parse(captured.raw);
200
+ } catch {
201
+ diagnostics.push({
202
+ code: 'PSL_EXTENSION_INVALID_VALUE',
203
+ message: `Parameter "${key}" in "${descriptor.keyword}" block "${node.name}" is not a valid JSON literal (expected a JSON string, number, boolean, or null): ${captured.raw}`,
204
+ sourceId,
205
+ span: captured.span,
206
+ });
207
+ return;
208
+ }
209
+ try {
210
+ codec.decodeJson(
211
+ blindCast<JsonValue, 'JSON.parse returns a JsonValue-compatible value'>(jsonValue),
212
+ );
213
+ } catch (err) {
214
+ const reason = err instanceof Error ? err.message : String(err);
215
+ diagnostics.push({
216
+ code: 'PSL_EXTENSION_INVALID_VALUE',
217
+ message: `Parameter "${key}" in "${descriptor.keyword}" block "${node.name}" was rejected by codec "${param.codecId}": ${reason}`,
218
+ sourceId,
219
+ span: captured.span,
220
+ });
221
+ }
222
+ return;
223
+ }
224
+
225
+ case 'ref': {
226
+ if (captured.kind !== 'ref') {
227
+ return;
228
+ }
229
+ validateRef(
230
+ node,
231
+ descriptor,
232
+ key,
233
+ param,
234
+ captured.identifier,
235
+ captured.span,
236
+ sourceId,
237
+ refCtx,
238
+ diagnostics,
239
+ );
240
+ return;
241
+ }
242
+
243
+ case 'list': {
244
+ if (captured.kind !== 'list') {
245
+ return;
246
+ }
247
+ for (const item of captured.items) {
248
+ validateParam(
249
+ node,
250
+ descriptor,
251
+ key,
252
+ param.of,
253
+ item,
254
+ sourceId,
255
+ codecLookup,
256
+ refCtx,
257
+ diagnostics,
258
+ );
259
+ }
260
+ return;
261
+ }
262
+ }
263
+ }
264
+
265
+ function validateRef(
266
+ node: PslExtensionBlock,
267
+ descriptor: AuthoringPslBlockDescriptor,
268
+ key: string,
269
+ param: PslBlockParamRef,
270
+ identifier: string,
271
+ span: PslSpan,
272
+ sourceId: string,
273
+ refCtx: ExtensionBlockRefResolutionContext | undefined,
274
+ diagnostics: PslDiagnostic[],
275
+ ): void {
276
+ if (param.scope === 'cross-space') {
277
+ // cross-space enforcement is a documented pass-through. The spec permits
278
+ // scoping cross-space resolution to first-consumer need (RLS roles). When
279
+ // that consumer arrives, wire resolution here through the
280
+ // cross-contract-space coordinate model
281
+ // (spaceId, namespaceId, entityKind, entityName).
282
+ // For now, cross-space refs pass validation unconditionally.
283
+ return;
284
+ }
285
+
286
+ if (refCtx === undefined) {
287
+ // If no resolution context was provided, skip ref resolution. This matches
288
+ // the closed-grammar invariant: callers that register ref parameters must
289
+ // provide resolution context; callers without namespaces (e.g. unit tests
290
+ // that only exercise other validation modes) can omit it.
291
+ return;
292
+ }
293
+
294
+ const namespacesToSearch: readonly PslNamespace[] =
295
+ param.scope === 'same-namespace' ? [refCtx.ownerNamespace] : refCtx.allNamespaces;
296
+
297
+ if (!resolveEntityInNamespaces(identifier, param.refKind, namespacesToSearch)) {
298
+ const scopeLabel =
299
+ param.scope === 'same-namespace' ? 'the same namespace' : 'any namespace in the schema';
300
+ diagnostics.push({
301
+ code: 'PSL_EXTENSION_UNRESOLVED_REF',
302
+ message: `Parameter "${key}" in "${descriptor.keyword}" block "${node.name}" refers to "${identifier}" (expected ${param.refKind}), but no entity with that name and kind was found in ${scopeLabel}.`,
303
+ sourceId,
304
+ span,
305
+ });
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Returns true when an entity named `name` of kind `refKind` exists in at
311
+ * least one of the given namespaces.
312
+ *
313
+ * Built-in PSL entity kinds are mapped to their `PslNamespace` collection:
314
+ * - `'model'` → `ns.models`
315
+ * - `'enum'` → `ns.enums`
316
+ * - `'compositeType'` → `ns.compositeTypes`
317
+ *
318
+ * Any other `refKind` is resolved against the namespace's `extensionBlocks`
319
+ * array (matching by `block.kind === refKind` and `block.name === name`).
320
+ * This covers extension-contributed entity kinds that reference other
321
+ * extension-contributed blocks (e.g. a policy referencing a role block).
322
+ */
323
+ function resolveEntityInNamespaces(
324
+ name: string,
325
+ refKind: string,
326
+ namespaces: readonly PslNamespace[],
327
+ ): boolean {
328
+ for (const ns of namespaces) {
329
+ if (refKind === 'model') {
330
+ if (ns.models.some((m) => m.name === name)) return true;
331
+ } else if (refKind === 'enum') {
332
+ if (ns.enums.some((e) => e.name === name)) return true;
333
+ } else if (refKind === 'compositeType') {
334
+ if (ns.compositeTypes.some((ct) => ct.name === name)) return true;
335
+ } else {
336
+ if (ns.extensionBlocks?.some((b) => b.kind === refKind && b.name === name)) return true;
337
+ }
338
+ }
339
+ return false;
340
+ }
@@ -11,6 +11,8 @@ export type {
11
11
  AuthoringFieldNamespace,
12
12
  AuthoringFieldPresetDescriptor,
13
13
  AuthoringFieldPresetOutput,
14
+ AuthoringPslBlockDescriptor,
15
+ AuthoringPslBlockDescriptorNamespace,
14
16
  AuthoringStorageTypeTemplate,
15
17
  AuthoringTemplateValue,
16
18
  AuthoringTypeConstructorDescriptor,
@@ -25,8 +27,22 @@ export {
25
27
  isAuthoringArgRef,
26
28
  isAuthoringEntityTypeDescriptor,
27
29
  isAuthoringFieldPresetDescriptor,
30
+ isAuthoringPslBlockDescriptor,
28
31
  isAuthoringTypeConstructorDescriptor,
29
32
  mergeAuthoringNamespaces,
30
33
  resolveAuthoringTemplateValue,
31
34
  validateAuthoringHelperArguments,
32
35
  } from '../shared/framework-authoring';
36
+ export type {
37
+ PslBlockParam,
38
+ PslBlockParamList,
39
+ PslBlockParamOption,
40
+ PslBlockParamRef,
41
+ PslBlockParamValue,
42
+ PslExtensionBlock,
43
+ PslExtensionBlockParamList,
44
+ PslExtensionBlockParamOption,
45
+ PslExtensionBlockParamRef,
46
+ PslExtensionBlockParamScalarValue,
47
+ PslExtensionBlockParamValue,
48
+ } from '../shared/psl-extension-block';
@@ -1 +1,3 @@
1
1
  export * from '../control/psl-ast';
2
+ export type { ExtensionBlockRefResolutionContext } from '../control/psl-extension-block-validator';
3
+ export { validateExtensionBlock } from '../control/psl-extension-block-validator';
@@ -7,8 +7,10 @@ import {
7
7
  isColumnDefaultLiteralInputValue,
8
8
  isExecutionMutationDefaultValue,
9
9
  } from '@prisma-next/contract/types';
10
+ import { blindCast } from '@prisma-next/utils/casts';
10
11
  import { ifDefined } from '@prisma-next/utils/defined';
11
12
  import type { Type } from 'arktype';
13
+ import type { PslBlockParam } from './psl-extension-block';
12
14
 
13
15
  export type AuthoringArgRef = {
14
16
  readonly kind: 'arg';
@@ -157,10 +159,55 @@ export type AuthoringEntityTypeNamespace = {
157
159
  readonly [name: string]: AuthoringEntityTypeDescriptor | AuthoringEntityTypeNamespace;
158
160
  };
159
161
 
162
+ /**
163
+ * Declarative descriptor for an extension-contributed top-level PSL block.
164
+ *
165
+ * An extension registers one of these per keyword it contributes. The
166
+ * framework owns the generic parser, validator, and printer — no
167
+ * parsing or printing code runs from the extension.
168
+ *
169
+ * - `keyword` is the PSL top-level identifier this descriptor claims
170
+ * (`policy_select`, `role`, …).
171
+ * - `discriminator` is the routing key used by the printer dispatch and
172
+ * the `entityTypes` lowering factory lookup. Convention:
173
+ * `<target-or-family>-<kind>` (`postgres-policy-select`).
174
+ * - `name.required` declares whether the block must have a name token
175
+ * after the keyword. Currently always `true` — anonymous blocks are
176
+ * not part of the closed-grammar premise — but the field is explicit
177
+ * so the type can evolve without a breaking change.
178
+ * - `parameters` maps parameter names to their value-kind descriptors
179
+ * (`ref` / `value` / `option` / `list`). The generic parser and
180
+ * validator interpret these; the extension supplies no parser or
181
+ * printer function.
182
+ */
183
+ export interface AuthoringPslBlockDescriptor {
184
+ readonly kind: 'pslBlock';
185
+ readonly keyword: string;
186
+ readonly discriminator: string;
187
+ readonly name: { readonly required: boolean };
188
+ readonly parameters: Record<string, PslBlockParam>;
189
+ }
190
+
191
+ export type AuthoringPslBlockDescriptorNamespace = {
192
+ readonly [name: string]: AuthoringPslBlockDescriptor | AuthoringPslBlockDescriptorNamespace;
193
+ };
194
+
160
195
  export interface AuthoringContributions {
161
196
  readonly type?: AuthoringTypeNamespace;
162
197
  readonly field?: AuthoringFieldNamespace;
163
198
  readonly entityTypes?: AuthoringEntityTypeNamespace;
199
+ /**
200
+ * Registry of declarative block descriptors this contribution registers,
201
+ * keyed by arbitrary path segments. Each leaf is an
202
+ * {@link AuthoringPslBlockDescriptor} that claims a PSL top-level keyword.
203
+ * The framework owns the generic parser, validator, and printer; the
204
+ * contribution supplies only these declarative descriptors.
205
+ *
206
+ * Contrast with {@link PslNamespace.extensionBlocks}: that field holds
207
+ * the parsed block nodes in a namespace; this field holds the registry
208
+ * of descriptors that teach the parser how to read those blocks.
209
+ */
210
+ readonly pslBlockDescriptors?: AuthoringPslBlockDescriptorNamespace;
164
211
  }
165
212
 
166
213
  export function isAuthoringArgRef(value: unknown): value is AuthoringArgRef {
@@ -228,6 +275,42 @@ export function isAuthoringEntityTypeDescriptor(
228
275
  return typeof factory === 'function' || template !== undefined;
229
276
  }
230
277
 
278
+ export function isAuthoringPslBlockDescriptor(
279
+ value: unknown,
280
+ ): value is AuthoringPslBlockDescriptor {
281
+ if (typeof value !== 'object' || value === null) {
282
+ return false;
283
+ }
284
+ const record = blindCast<
285
+ Record<string, unknown>,
286
+ 'type-guard probing an unknown candidate-descriptor object for known property names'
287
+ >(value);
288
+ if (record['kind'] !== 'pslBlock') {
289
+ return false;
290
+ }
291
+ const keyword = record['keyword'];
292
+ if (typeof keyword !== 'string' || keyword.length === 0) {
293
+ return false;
294
+ }
295
+ const discriminator = record['discriminator'];
296
+ if (typeof discriminator !== 'string' || discriminator.length === 0) {
297
+ return false;
298
+ }
299
+ const name = record['name'];
300
+ if (typeof name !== 'object' || name === null) {
301
+ return false;
302
+ }
303
+ const nameRecord = blindCast<
304
+ Record<string, unknown>,
305
+ 'type-guard probing the name property of a candidate pslBlock descriptor'
306
+ >(name);
307
+ if (typeof nameRecord['required'] !== 'boolean') {
308
+ return false;
309
+ }
310
+ const parameters = record['parameters'];
311
+ return typeof parameters === 'object' && parameters !== null && !Array.isArray(parameters);
312
+ }
313
+
231
314
  /**
232
315
  * Returns true when `namespace` is a non-leaf key in `contributions.field`.
233
316
  *
@@ -341,10 +424,127 @@ function collectAuthoringLeafPaths(
341
424
  return paths;
342
425
  }
343
426
 
427
+ interface AuthoringLeafEntry {
428
+ readonly path: string;
429
+ readonly discriminator: string;
430
+ }
431
+
432
+ function collectAuthoringLeafDiscriminators(
433
+ namespace: Readonly<Record<string, unknown>>,
434
+ isLeaf: (value: unknown) => boolean,
435
+ label: string,
436
+ path: readonly string[] = [],
437
+ ): AuthoringLeafEntry[] {
438
+ const entries: AuthoringLeafEntry[] = [];
439
+ for (const [key, value] of Object.entries(namespace)) {
440
+ const currentPath = [...path, key];
441
+ if (isLeaf(value)) {
442
+ const record = blindCast<
443
+ Record<string, unknown>,
444
+ 'discriminator extraction from a leaf already validated by isLeaf'
445
+ >(value);
446
+ const discriminator = record['discriminator'];
447
+ if (typeof discriminator === 'string' && discriminator.length > 0) {
448
+ entries.push({ path: currentPath.join('.'), discriminator });
449
+ }
450
+ continue;
451
+ }
452
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
453
+ const record = blindCast<
454
+ Readonly<Record<string, unknown>>,
455
+ 'walker inspects a non-leaf value for descriptor-shaped keys before recursing'
456
+ >(value);
457
+ // A value carrying descriptor-shaped keys (`kind`/`keyword`/`discriminator`)
458
+ // but failing `isAuthoringPslBlockDescriptor` (e.g. missing `parameters`) is
459
+ // a malformed declarative descriptor. Descending into it as a sub-namespace
460
+ // would silently skip it, so a half-built contribution would pass validation.
461
+ // Reject it at load time instead, naming the path and what's wrong.
462
+ //
463
+ // A valid sub-namespace whose key happens to be named `kind`, `keyword`, or
464
+ // `discriminator` (but which does not look like a descriptor overall) must
465
+ // still descend normally — the check requires descriptor-shaped keys present
466
+ // AND the leaf guard rejecting it.
467
+ if (
468
+ (record['kind'] !== undefined ||
469
+ record['keyword'] !== undefined ||
470
+ record['discriminator'] !== undefined) &&
471
+ !isLeaf(value)
472
+ ) {
473
+ const hasKind = record['kind'] === 'pslBlock';
474
+ const hasKeyword = typeof record['keyword'] === 'string';
475
+ const hasDiscriminator = typeof record['discriminator'] === 'string';
476
+ if (hasKind || (hasKeyword && hasDiscriminator)) {
477
+ throw new Error(
478
+ `Malformed authoring ${label} contribution at "${currentPath.join('.')}". The value carries descriptor keys (kind/keyword/discriminator) but does not satisfy the ${label} descriptor shape. Fix the contribution so it is a complete descriptor, or remove the stray keys if it was meant to be a sub-namespace.`,
479
+ );
480
+ }
481
+ }
482
+ entries.push(...collectAuthoringLeafDiscriminators(record, isLeaf, label, currentPath));
483
+ }
484
+ }
485
+ return entries;
486
+ }
487
+
488
+ /**
489
+ * Throws when two or more entries in the same namespace share a discriminator.
490
+ * Duplicate discriminators within a namespace make dispatch ambiguous — the
491
+ * lowering factory lookup dispatches by discriminator, so one would silently
492
+ * shadow the other. Catch duplicates before building any dispatch map.
493
+ */
494
+ function assertUniqueDiscriminators(entries: readonly AuthoringLeafEntry[], label: string): void {
495
+ const seen = new Map<string, string>();
496
+ for (const { path, discriminator } of entries) {
497
+ const existing = seen.get(discriminator);
498
+ if (existing !== undefined) {
499
+ throw new Error(
500
+ `Duplicate ${label} discriminator "${discriminator}" registered at both "${existing}" and "${path}". Each ${label} contribution must use a unique discriminator.`,
501
+ );
502
+ }
503
+ seen.set(discriminator, path);
504
+ }
505
+ }
506
+
507
+ /**
508
+ * Every `pslBlockDescriptors` entry needs a matching `entityTypes` factory
509
+ * (same discriminator): the parser would otherwise produce an AST node
510
+ * nothing can lower to an IR class instance. The link is one-directional
511
+ * — an `entityTypes` factory may stand alone (e.g. `enum`, reachable from
512
+ * the TypeScript builder without any PSL block).
513
+ */
514
+ function assertPslBlocksHaveFactories(
515
+ entityTypeNamespace: AuthoringEntityTypeNamespace,
516
+ pslBlockNamespace: AuthoringPslBlockDescriptorNamespace,
517
+ ): void {
518
+ const blockEntries = collectAuthoringLeafDiscriminators(
519
+ pslBlockNamespace,
520
+ isAuthoringPslBlockDescriptor,
521
+ 'pslBlock',
522
+ );
523
+ const entityEntries = collectAuthoringLeafDiscriminators(
524
+ entityTypeNamespace,
525
+ isAuthoringEntityTypeDescriptor,
526
+ 'entityType',
527
+ );
528
+
529
+ assertUniqueDiscriminators(blockEntries, 'pslBlock');
530
+ assertUniqueDiscriminators(entityEntries, 'entityType');
531
+
532
+ const entityDiscriminators = new Set(entityEntries.map((entry) => entry.discriminator));
533
+
534
+ for (const block of blockEntries) {
535
+ if (!entityDiscriminators.has(block.discriminator)) {
536
+ throw new Error(
537
+ `Incomplete extension contribution: pslBlock helper "${block.path}" registers discriminator "${block.discriminator}" but no entityType contribution shares that discriminator. An extension-contributed PSL block requires a matching entityType factory so the parsed AST node can lower to an IR class instance; add an entityType helper with discriminator "${block.discriminator}".`,
538
+ );
539
+ }
540
+ }
541
+ }
542
+
344
543
  export function assertNoCrossRegistryCollisions(
345
544
  typeNamespace: AuthoringTypeNamespace,
346
545
  fieldNamespace: AuthoringFieldNamespace,
347
546
  entityTypeNamespace: AuthoringEntityTypeNamespace = {},
547
+ pslBlockNamespace: AuthoringPslBlockDescriptorNamespace = {},
348
548
  ): void {
349
549
  const typePaths = new Set(
350
550
  collectAuthoringLeafPaths(typeNamespace, isAuthoringTypeConstructorDescriptor),
@@ -360,20 +560,33 @@ export function assertNoCrossRegistryCollisions(
360
560
  // `mergeHelperNamespaces` in composed-authoring-helpers.ts), which throws
361
561
  // on same-path registrations within any single registry before this check
362
562
  // runs. This function only handles the cross-registry case.
563
+ //
564
+ // Cross-registry collisions are checked among `type` / `field` /
565
+ // `entityTypes` only — these three are user-facing helper paths that PSL
566
+ // must resolve unambiguously. `pslBlockDescriptors` is an internal
567
+ // framework index consumed by parser and printer dispatch, not a
568
+ // user-facing helper path; the natural authoring pattern is the same
569
+ // path key in `entityTypes` and `pslBlockDescriptors` for a single
570
+ // contribution. The block→factory link is enforced by
571
+ // `assertPslBlocksHaveFactories` via the discriminator string, not by path.
572
+ const ambiguityHint =
573
+ 'Register each path in only one of authoringContributions.field / authoringContributions.type / authoringContributions.entityTypes.';
363
574
  for (const fieldPath of fieldPaths) {
364
575
  if (typePaths.has(fieldPath)) {
365
576
  throw new Error(
366
- `Ambiguous authoring registry path "${fieldPath}". The same path is registered as both a type constructor and a field preset; PSL resolution would be ambiguous. Register each path in only one of authoringContributions.field / authoringContributions.type / authoringContributions.entityTypes.`,
577
+ `Ambiguous authoring registry path "${fieldPath}". The same path is registered as both a type constructor and a field preset; PSL resolution would be ambiguous. ${ambiguityHint}`,
367
578
  );
368
579
  }
369
580
  }
370
581
  for (const entityPath of entityPaths) {
371
582
  if (typePaths.has(entityPath) || fieldPaths.has(entityPath)) {
372
583
  throw new Error(
373
- `Ambiguous authoring registry path "${entityPath}". The same path is registered as an entity contribution AND as a type constructor or field preset; PSL resolution would be ambiguous. Register each path in only one of authoringContributions.field / authoringContributions.type / authoringContributions.entityTypes.`,
584
+ `Ambiguous authoring registry path "${entityPath}". The same path is registered as an entity contribution AND as a type constructor or field preset; PSL resolution would be ambiguous. ${ambiguityHint}`,
374
585
  );
375
586
  }
376
587
  }
588
+
589
+ assertPslBlocksHaveFactories(entityTypeNamespace, pslBlockNamespace);
377
590
  }
378
591
 
379
592
  export function resolveAuthoringTemplateValue(