@prisma-next/framework-components 0.12.0-dev.7 → 0.12.0-dev.70

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 (63) hide show
  1. package/dist/authoring.d.mts +2 -2
  2. package/dist/authoring.mjs +2 -2
  3. package/dist/{codec-BFOsuHKK.d.mts → codec-DCQAerzB.d.mts} +1 -1
  4. package/dist/{codec-BFOsuHKK.d.mts.map → codec-DCQAerzB.d.mts.map} +1 -1
  5. package/dist/codec.d.mts +1 -1
  6. package/dist/codec.d.mts.map +1 -1
  7. package/dist/components.d.mts +1 -1
  8. package/dist/components.mjs +1 -1
  9. package/dist/components.mjs.map +1 -1
  10. package/dist/control.d.mts +85 -13
  11. package/dist/control.d.mts.map +1 -1
  12. package/dist/control.mjs +89 -7
  13. package/dist/control.mjs.map +1 -1
  14. package/dist/{emission-types-CMv_053d.d.mts → emission-types-vfpSTe63.d.mts} +2 -2
  15. package/dist/{emission-types-CMv_053d.d.mts.map → emission-types-vfpSTe63.d.mts.map} +1 -1
  16. package/dist/emission.d.mts +3 -3
  17. package/dist/execution.d.mts +1 -1
  18. package/dist/execution.d.mts.map +1 -1
  19. package/dist/execution.mjs +1 -1
  20. package/dist/{framework-authoring-DcEZ5Lin.mjs → framework-authoring-CnwPJCO4.mjs} +76 -5
  21. package/dist/framework-authoring-CnwPJCO4.mjs.map +1 -0
  22. package/dist/framework-authoring-Cyde8zSN.d.mts +380 -0
  23. package/dist/framework-authoring-Cyde8zSN.d.mts.map +1 -0
  24. package/dist/{framework-components-FdqmlGUj.mjs → framework-components-DbCS57go.mjs} +1 -1
  25. package/dist/{framework-components-FdqmlGUj.mjs.map → framework-components-DbCS57go.mjs.map} +1 -1
  26. package/dist/{framework-components-CuoUhyB5.d.mts → framework-components-DdqvMc8S.d.mts} +6 -5
  27. package/dist/{framework-components-CuoUhyB5.d.mts.map → framework-components-DdqvMc8S.d.mts.map} +1 -1
  28. package/dist/ir.d.mts +20 -18
  29. package/dist/ir.d.mts.map +1 -1
  30. package/dist/ir.mjs +17 -14
  31. package/dist/ir.mjs.map +1 -1
  32. package/dist/{psl-ast-BDXL7iCg.d.mts → psl-ast-DRzRF9rS.d.mts} +57 -14
  33. package/dist/psl-ast-DRzRF9rS.d.mts.map +1 -0
  34. package/dist/psl-ast.d.mts +37 -2
  35. package/dist/psl-ast.d.mts.map +1 -0
  36. package/dist/psl-ast.mjs +142 -1
  37. package/dist/psl-ast.mjs.map +1 -1
  38. package/dist/runtime.d.mts +1 -1
  39. package/dist/runtime.d.mts.map +1 -1
  40. package/dist/runtime.mjs.map +1 -1
  41. package/dist/{types-import-spec-BxI5cSQy.d.mts → types-import-spec-DRKzrJ20.d.mts} +1 -1
  42. package/dist/{types-import-spec-BxI5cSQy.d.mts.map → types-import-spec-DRKzrJ20.d.mts.map} +1 -1
  43. package/dist/utils.mjs.map +1 -1
  44. package/package.json +9 -9
  45. package/src/control/control-instances.ts +15 -5
  46. package/src/control/control-migration-types.ts +20 -2
  47. package/src/control/control-result-types.ts +4 -1
  48. package/src/control/control-stack.ts +123 -4
  49. package/src/control/psl-ast.ts +73 -27
  50. package/src/control/psl-extension-block-validator.ts +340 -0
  51. package/src/control/verifier-disposition.ts +62 -0
  52. package/src/exports/authoring.ts +16 -0
  53. package/src/exports/control.ts +7 -0
  54. package/src/exports/psl-ast.ts +2 -0
  55. package/src/ir/namespace.ts +15 -13
  56. package/src/ir/storage.ts +8 -7
  57. package/src/shared/framework-authoring.ts +215 -2
  58. package/src/shared/framework-components.ts +2 -0
  59. package/src/shared/psl-extension-block.ts +184 -0
  60. package/dist/framework-authoring-BPPe9C9D.d.mts +0 -183
  61. package/dist/framework-authoring-BPPe9C9D.d.mts.map +0 -1
  62. package/dist/framework-authoring-DcEZ5Lin.mjs.map +0 -1
  63. package/dist/psl-ast-BDXL7iCg.d.mts.map +0 -1
@@ -1,15 +1,18 @@
1
+ import { blindCast } from '@prisma-next/utils/casts';
1
2
  import type { Codec } from '../shared/codec';
2
3
  import type { CodecLookup, CodecMeta } from '../shared/codec-types';
3
4
  import type {
4
5
  AuthoringContributions,
5
6
  AuthoringEntityTypeNamespace,
6
7
  AuthoringFieldNamespace,
8
+ AuthoringPslBlockDescriptorNamespace,
7
9
  AuthoringTypeNamespace,
8
10
  } from '../shared/framework-authoring';
9
11
  import {
10
12
  assertNoCrossRegistryCollisions,
11
13
  isAuthoringEntityTypeDescriptor,
12
14
  isAuthoringFieldPresetDescriptor,
15
+ isAuthoringPslBlockDescriptor,
13
16
  isAuthoringTypeConstructorDescriptor,
14
17
  mergeAuthoringNamespaces,
15
18
  } from '../shared/framework-authoring';
@@ -32,6 +35,7 @@ export interface AssembledAuthoringContributions {
32
35
  readonly field: AuthoringFieldNamespace;
33
36
  readonly type: AuthoringTypeNamespace;
34
37
  readonly entityTypes: AuthoringEntityTypeNamespace;
38
+ readonly pslBlockDescriptors: AuthoringPslBlockDescriptorNamespace;
35
39
  }
36
40
 
37
41
  export interface ControlStack<
@@ -151,6 +155,7 @@ export function assembleAuthoringContributions(
151
155
  const field = {} as Record<string, unknown>;
152
156
  const type = {} as Record<string, unknown>;
153
157
  const entityTypes = {} as Record<string, unknown>;
158
+ const pslBlockDescriptors: Record<string, unknown> = {};
154
159
 
155
160
  for (const descriptor of descriptors) {
156
161
  if (descriptor.authoring?.field) {
@@ -180,17 +185,36 @@ export function assembleAuthoringContributions(
180
185
  'entity',
181
186
  );
182
187
  }
188
+ if (descriptor.authoring?.pslBlockDescriptors) {
189
+ mergeAuthoringNamespaces(
190
+ pslBlockDescriptors,
191
+ descriptor.authoring.pslBlockDescriptors,
192
+ [],
193
+ isAuthoringPslBlockDescriptor,
194
+ 'pslBlock',
195
+ );
196
+ }
183
197
  }
184
198
 
185
199
  const fieldNamespace = field as AuthoringFieldNamespace;
186
200
  const typeNamespace = type as AuthoringTypeNamespace;
187
201
  const entityTypeNamespace = entityTypes as AuthoringEntityTypeNamespace;
188
- assertNoCrossRegistryCollisions(typeNamespace, fieldNamespace, entityTypeNamespace);
202
+ const pslBlockDescriptorNamespace = blindCast<
203
+ AuthoringPslBlockDescriptorNamespace,
204
+ 'merge target accumulator narrows to typed namespace post-merge'
205
+ >(pslBlockDescriptors);
206
+ assertNoCrossRegistryCollisions(
207
+ typeNamespace,
208
+ fieldNamespace,
209
+ entityTypeNamespace,
210
+ pslBlockDescriptorNamespace,
211
+ );
189
212
 
190
213
  return {
191
214
  field: fieldNamespace,
192
215
  type: typeNamespace,
193
216
  entityTypes: entityTypeNamespace,
217
+ pslBlockDescriptors: pslBlockDescriptorNamespace,
194
218
  };
195
219
  }
196
220
 
@@ -345,12 +369,107 @@ export function validateScalarTypeCodecIds(
345
369
  return errors;
346
370
  }
347
371
 
372
+ interface DependencyDeclaringDescriptor {
373
+ readonly id: string;
374
+ readonly contractSpace?: {
375
+ readonly contractJson?: {
376
+ readonly extensionPacks?: Readonly<Record<string, unknown>>;
377
+ };
378
+ };
379
+ }
380
+
381
+ function readDeclaredDependencyIds(descriptor: DependencyDeclaringDescriptor): readonly string[] {
382
+ const packs = descriptor.contractSpace?.contractJson?.extensionPacks;
383
+ if (packs === null || typeof packs !== 'object') return [];
384
+ return Object.keys(packs);
385
+ }
386
+
387
+ /**
388
+ * Builds a dependency-respecting load order for the given extension descriptors
389
+ * using Kahn's topological sort algorithm. Dependencies (packs declared in
390
+ * `contractSpace.contractJson.extensionPacks`) are placed before the extensions
391
+ * that depend on them.
392
+ *
393
+ * Throws if the dependency graph contains a cycle, with an error message that
394
+ * names every extension involved in the cycle.
395
+ *
396
+ * Throws if any extension declares a dependency on a pack ID that is not present
397
+ * in the provided list — add the missing pack to the `extensionPacks` list to
398
+ * resolve the error.
399
+ */
400
+
401
+ export function buildExtensionLoadOrder(
402
+ extensions: ReadonlyArray<DependencyDeclaringDescriptor>,
403
+ ): readonly string[] {
404
+ if (extensions.length === 0) return [];
405
+
406
+ const idSet = new Set(extensions.map((e) => e.id));
407
+ const inDegree = new Map<string, number>();
408
+ const dependents = new Map<string, string[]>();
409
+
410
+ for (const ext of extensions) {
411
+ if (!inDegree.has(ext.id)) inDegree.set(ext.id, 0);
412
+ if (!dependents.has(ext.id)) dependents.set(ext.id, []);
413
+ }
414
+
415
+ for (const ext of extensions) {
416
+ for (const depId of readDeclaredDependencyIds(ext)) {
417
+ if (!idSet.has(depId)) {
418
+ throw new Error(
419
+ `Extension "${ext.id}" declares a dependency on "${depId}", but "${depId}" is not in the provided extension set. Add the missing space to extensionPacks.`,
420
+ );
421
+ }
422
+ inDegree.set(ext.id, (inDegree.get(ext.id) ?? 0) + 1);
423
+ const list = dependents.get(depId);
424
+ if (list !== undefined) list.push(ext.id);
425
+ }
426
+ }
427
+
428
+ const queue: string[] = [];
429
+ for (const [id, deg] of inDegree) {
430
+ if (deg === 0) queue.push(id);
431
+ }
432
+ queue.sort();
433
+
434
+ const result: string[] = [];
435
+ while (queue.length > 0) {
436
+ const id = queue.shift();
437
+ if (id === undefined) break;
438
+ result.push(id);
439
+ const children = dependents.get(id) ?? [];
440
+ children.sort();
441
+ for (const childId of children) {
442
+ const newDeg = (inDegree.get(childId) ?? 1) - 1;
443
+ inDegree.set(childId, newDeg);
444
+ if (newDeg === 0) queue.push(childId);
445
+ }
446
+ }
447
+
448
+ if (result.length < extensions.length) {
449
+ const cycleMembers = extensions
450
+ .map((e) => e.id)
451
+ .filter((id) => !result.includes(id))
452
+ .sort();
453
+ throw new Error(
454
+ `Extension dependency cycle detected. Cycle members: ${cycleMembers.map((id) => `"${id}"`).join(', ')}.`,
455
+ );
456
+ }
457
+
458
+ return result;
459
+ }
460
+
348
461
  export function createControlStack<TFamilyId extends string, TTargetId extends string>(
349
462
  input: CreateControlStackInput<TFamilyId, TTargetId>,
350
463
  ): ControlStack<TFamilyId, TTargetId> {
351
464
  const { family, target, adapter, driver, extensionPacks = [] } = input;
352
465
 
353
- const allDescriptors = [family, target, ...(adapter ? [adapter] : []), ...extensionPacks];
466
+ const orderedIds = buildExtensionLoadOrder(extensionPacks);
467
+ const extensionById = new Map(extensionPacks.map((ext) => [ext.id, ext]));
468
+ const orderedExtensionPacks = orderedIds
469
+ .map((id) => extensionById.get(id))
470
+ .filter((ext): ext is ControlExtensionDescriptor<TFamilyId, TTargetId> => ext !== undefined);
471
+
472
+ const allDescriptors = [family, target, ...(adapter ? [adapter] : []), ...orderedExtensionPacks];
354
473
 
355
474
  const codecLookup = extractCodecLookup(allDescriptors);
356
475
  const scalarTypeDescriptors = assembleScalarTypeDescriptors(allDescriptors);
@@ -360,11 +479,11 @@ export function createControlStack<TFamilyId extends string, TTargetId extends s
360
479
  target,
361
480
  adapter,
362
481
  driver,
363
- extensionPacks: extensionPacks as readonly ControlExtensionDescriptor<TFamilyId, TTargetId>[],
482
+ extensionPacks: orderedExtensionPacks,
364
483
 
365
484
  codecTypeImports: extractCodecTypeImports(allDescriptors),
366
485
  queryOperationTypeImports: extractQueryOperationTypeImports(allDescriptors),
367
- extensionIds: extractComponentIds(family, target, adapter, extensionPacks),
486
+ extensionIds: extractComponentIds(family, target, adapter, orderedExtensionPacks),
368
487
  codecLookup,
369
488
  authoringContributions: assembleAuthoringContributions(allDescriptors),
370
489
  scalarTypeDescriptors,
@@ -1,28 +1,24 @@
1
- export interface PslPosition {
2
- readonly offset: number;
3
- readonly line: number;
4
- readonly column: number;
5
- }
6
-
7
- export interface PslSpan {
8
- readonly start: PslPosition;
9
- readonly end: PslPosition;
10
- }
11
-
12
- export type PslDiagnosticCode =
13
- | 'PSL_UNTERMINATED_BLOCK'
14
- | 'PSL_UNSUPPORTED_TOP_LEVEL_BLOCK'
15
- | 'PSL_INVALID_NAMESPACE_BLOCK'
16
- | 'PSL_INVALID_ATTRIBUTE_SYNTAX'
17
- | 'PSL_INVALID_MODEL_MEMBER'
18
- | 'PSL_UNSUPPORTED_MODEL_ATTRIBUTE'
19
- | 'PSL_UNSUPPORTED_FIELD_ATTRIBUTE'
20
- | 'PSL_INVALID_RELATION_ATTRIBUTE'
21
- | 'PSL_INVALID_REFERENTIAL_ACTION'
22
- | 'PSL_INVALID_DEFAULT_VALUE'
23
- | 'PSL_INVALID_ENUM_MEMBER'
24
- | 'PSL_INVALID_TYPES_MEMBER'
25
- | 'PSL_INVALID_QUALIFIED_TYPE';
1
+ export type { AuthoringPslBlockDescriptorNamespace } from '../shared/framework-authoring';
2
+ export type {
3
+ PslBlockParam,
4
+ PslBlockParamList,
5
+ PslBlockParamOption,
6
+ PslBlockParamRef,
7
+ PslBlockParamValue,
8
+ PslDiagnosticCode,
9
+ PslExtensionBlock,
10
+ PslExtensionBlockParamList,
11
+ PslExtensionBlockParamOption,
12
+ PslExtensionBlockParamRef,
13
+ PslExtensionBlockParamScalarValue,
14
+ PslExtensionBlockParamValue,
15
+ PslPosition,
16
+ PslSpan,
17
+ } from '../shared/psl-extension-block';
18
+
19
+ import type { CodecLookup } from '../shared/codec-types';
20
+ import type { AuthoringPslBlockDescriptorNamespace } from '../shared/framework-authoring';
21
+ import type { PslDiagnosticCode, PslExtensionBlock, PslSpan } from '../shared/psl-extension-block';
26
22
 
27
23
  export interface PslDiagnostic {
28
24
  readonly code: PslDiagnosticCode;
@@ -82,10 +78,19 @@ export type PslFieldAttribute = PslAttribute;
82
78
  export interface PslField {
83
79
  readonly kind: 'field';
84
80
  readonly name: string;
85
- /** Unqualified type name, e.g. `"User"` for both `User` and `auth.User`. */
81
+ /** Unqualified type name, e.g. `"User"` for both `User`, `auth.User`, and `supabase:auth.User`. */
86
82
  readonly typeName: string;
87
- /** Namespace qualifier from a dot-qualified type reference, e.g. `"auth"` for `auth.User`. Absent for unqualified types. */
83
+ /** Namespace qualifier from a dot-qualified type reference, e.g. `"auth"` for `auth.User` or `supabase:auth.User`. Absent for unqualified types. */
88
84
  readonly typeNamespaceId?: string;
85
+ /**
86
+ * Contract-space qualifier from a colon-prefix type reference, e.g. `"supabase"` for
87
+ * `supabase:auth.User` or `supabase:User`. Absent for local (same-space) type references.
88
+ *
89
+ * When present, the field references a model from a different contract space. The namespace
90
+ * (`typeNamespaceId`) and model name (`typeName`) identify the target within that space.
91
+ * Physical table resolution against the extension contract is deferred to the aggregate stage (M3).
92
+ */
93
+ readonly typeContractSpaceId?: string;
89
94
  readonly typeConstructor?: PslTypeConstructorCall;
90
95
  readonly optional: boolean;
91
96
  readonly list: boolean;
@@ -203,6 +208,23 @@ export interface PslNamespace {
203
208
  readonly models: readonly PslModel[];
204
209
  readonly enums: readonly PslEnum[];
205
210
  readonly compositeTypes: readonly PslCompositeType[];
211
+ /**
212
+ * Extension-contributed top-level blocks parsed inside this namespace.
213
+ * These are the parsed AST nodes produced by the generic framework parser
214
+ * when it encounters a keyword claimed by a registered
215
+ * {@link AuthoringPslBlockDescriptorNamespace} entry.
216
+ *
217
+ * Absent when no extension blocks appear in this namespace. Order matches
218
+ * source order within the namespace; extension-contributed and built-in
219
+ * blocks live in their own slots, so a namespace mixing `model X { … }` and
220
+ * `policy_select Y { … }` keeps the model in `models` and the policy in
221
+ * `extensionBlocks`.
222
+ *
223
+ * Contrast with {@link ParsePslDocumentInput.pslBlockDescriptors}: that
224
+ * field holds the registry of declarative descriptors that teach the parser
225
+ * which keywords to accept; this field holds the resulting parsed nodes.
226
+ */
227
+ readonly extensionBlocks?: readonly PslExtensionBlock[];
206
228
  readonly span: PslSpan;
207
229
  }
208
230
 
@@ -239,6 +261,30 @@ export function flatPslCompositeTypes(ast: PslDocumentAst): readonly PslComposit
239
261
  export interface ParsePslDocumentInput {
240
262
  readonly schema: string;
241
263
  readonly sourceId: string;
264
+ /**
265
+ * Registry of declarative block descriptors, keyed by arbitrary path
266
+ * segments with {@link AuthoringPslBlockDescriptor} leaves. The registry
267
+ * teaches the parser which top-level keywords belong to extension
268
+ * contributions: when the parser encounters an unknown keyword, it looks
269
+ * it up here and, when found, reads the block generically into a
270
+ * {@link PslExtensionBlock} node. Absent or undefined means no extension
271
+ * blocks are registered and any unknown keyword yields
272
+ * `PSL_UNSUPPORTED_TOP_LEVEL_BLOCK`.
273
+ *
274
+ * Contrast with {@link PslNamespace.extensionBlocks}: that field holds the
275
+ * parsed block nodes in a namespace; this field holds the registry of
276
+ * descriptors that teach the parser how to read those blocks.
277
+ */
278
+ readonly pslBlockDescriptors?: AuthoringPslBlockDescriptorNamespace;
279
+ /**
280
+ * Codec lookup for validating `value`-kind extension block parameters.
281
+ * When provided alongside `pslBlockDescriptors`, the generic validator runs
282
+ * over every parsed extension block after the full AST is assembled,
283
+ * appending any diagnostics to the parse result. Absent or undefined means
284
+ * no codec validation runs; `ref` resolution still runs when namespace
285
+ * context is available (built from the assembled namespaces).
286
+ */
287
+ readonly codecLookup?: CodecLookup;
242
288
  }
243
289
 
244
290
  export interface ParsePslDocumentResult {
@@ -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
+ }