@prisma-next/framework-components 0.13.0-dev.3 → 0.13.0-dev.31

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 (58) hide show
  1. package/dist/authoring.d.mts +2 -2
  2. package/dist/authoring.mjs +1 -1
  3. package/dist/{codec-DCQAerzB.d.mts → codec-types-yY3eSmi0.d.mts} +90 -67
  4. package/dist/codec-types-yY3eSmi0.d.mts.map +1 -0
  5. package/dist/codec.d.mts +23 -2
  6. package/dist/codec.d.mts.map +1 -1
  7. package/dist/codec.mjs +2 -1
  8. package/dist/codec.mjs.map +1 -1
  9. package/dist/components.d.mts +1 -1
  10. package/dist/control.d.mts +12 -24
  11. package/dist/control.d.mts.map +1 -1
  12. package/dist/control.mjs +15 -2
  13. package/dist/control.mjs.map +1 -1
  14. package/dist/execution.d.mts +1 -1
  15. package/dist/{framework-authoring-R0TYCkvG.d.mts → framework-authoring-C0UK0DAr.d.mts} +116 -9
  16. package/dist/framework-authoring-C0UK0DAr.d.mts.map +1 -0
  17. package/dist/{framework-authoring-CnwPJCO4.mjs → framework-authoring-Dv5F3EFC.mjs} +51 -24
  18. package/dist/framework-authoring-Dv5F3EFC.mjs.map +1 -0
  19. package/dist/{framework-components-DDQXmW0b.d.mts → framework-components-qbWtCu6G.d.mts} +3 -3
  20. package/dist/{framework-components-DDQXmW0b.d.mts.map → framework-components-qbWtCu6G.d.mts.map} +1 -1
  21. package/dist/ir.d.mts +11 -4
  22. package/dist/ir.d.mts.map +1 -1
  23. package/dist/ir.mjs +26 -6
  24. package/dist/ir.mjs.map +1 -1
  25. package/dist/{psl-ast-Cn50B-UG.d.mts → psl-ast-D5WPsvPp.d.mts} +11 -37
  26. package/dist/psl-ast-D5WPsvPp.d.mts.map +1 -0
  27. package/dist/psl-ast.d.mts +4 -4
  28. package/dist/psl-ast.mjs +18 -32
  29. package/dist/psl-ast.mjs.map +1 -1
  30. package/dist/resolve-codec-DR7uyr_c.mjs +47 -0
  31. package/dist/resolve-codec-DR7uyr_c.mjs.map +1 -0
  32. package/dist/runtime-error-B2gWOtgH.mjs +37 -0
  33. package/dist/runtime-error-B2gWOtgH.mjs.map +1 -0
  34. package/dist/runtime.d.mts +12 -10
  35. package/dist/runtime.d.mts.map +1 -1
  36. package/dist/runtime.mjs +1 -33
  37. package/dist/runtime.mjs.map +1 -1
  38. package/package.json +7 -7
  39. package/src/control/control-migration-types.ts +5 -22
  40. package/src/control/control-stack.ts +28 -3
  41. package/src/control/psl-ast.ts +10 -61
  42. package/src/control/psl-extension-block-validator.ts +11 -9
  43. package/src/execution/runtime-error.ts +5 -55
  44. package/src/exports/authoring.ts +1 -0
  45. package/src/exports/codec.ts +2 -0
  46. package/src/exports/control.ts +0 -1
  47. package/src/exports/ir.ts +1 -1
  48. package/src/ir/storage.ts +33 -6
  49. package/src/shared/codec-descriptor.ts +5 -0
  50. package/src/shared/codec-types.ts +21 -1
  51. package/src/shared/framework-authoring.ts +118 -35
  52. package/src/shared/psl-extension-block.ts +80 -5
  53. package/src/shared/resolve-codec.ts +64 -0
  54. package/src/shared/runtime-error.ts +50 -0
  55. package/dist/codec-DCQAerzB.d.mts.map +0 -1
  56. package/dist/framework-authoring-CnwPJCO4.mjs.map +0 -1
  57. package/dist/framework-authoring-R0TYCkvG.d.mts.map +0 -1
  58. package/dist/psl-ast-Cn50B-UG.d.mts.map +0 -1
@@ -107,15 +107,17 @@ export function validateExtensionBlock(
107
107
  const nodeKeys = new Set(Object.keys(node.parameters));
108
108
 
109
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
- });
110
+ if (!descriptor.variadicParameters) {
111
+ for (const key of nodeKeys) {
112
+ if (!descriptorKeys.has(key)) {
113
+ const captured = node.parameters[key];
114
+ diagnostics.push({
115
+ code: 'PSL_EXTENSION_UNKNOWN_PARAMETER',
116
+ message: `Unknown parameter "${key}" in "${descriptor.keyword}" block "${node.name}". The descriptor does not declare this parameter.`,
117
+ sourceId,
118
+ span: captured?.span ?? node.span,
119
+ });
120
+ }
119
121
  }
120
122
  }
121
123
 
@@ -1,9 +1,8 @@
1
- export interface RuntimeErrorEnvelope extends Error {
2
- readonly code: string;
3
- readonly category: 'PLAN' | 'CONTRACT' | 'LINT' | 'BUDGET' | 'RUNTIME';
4
- readonly severity: 'error';
5
- readonly details?: Record<string, unknown>;
6
- }
1
+ import type { RuntimeErrorEnvelope } from '../shared/runtime-error';
2
+ import { runtimeError } from '../shared/runtime-error';
3
+
4
+ export type { RuntimeErrorEnvelope } from '../shared/runtime-error';
5
+ export { isRuntimeError, runtimeError } from '../shared/runtime-error';
7
6
 
8
7
  /**
9
8
  * Stable code emitted by the runtime when an in-flight `execute()`
@@ -30,55 +29,6 @@ export type RuntimeAbortedPhase =
30
29
  | 'afterExecute'
31
30
  | 'onRow';
32
31
 
33
- /**
34
- * Type guard for the runtime-error envelope produced by `runtimeError`.
35
- *
36
- * Prefer this over duck-typing on `error.code` directly so consumers stay
37
- * insulated from the envelope's internal shape.
38
- */
39
- export function isRuntimeError(error: unknown): error is RuntimeErrorEnvelope {
40
- return (
41
- error instanceof Error &&
42
- 'code' in error &&
43
- typeof (error as { code?: unknown }).code === 'string' &&
44
- 'category' in error &&
45
- 'severity' in error
46
- );
47
- }
48
-
49
- export function runtimeError(
50
- code: string,
51
- message: string,
52
- details?: Record<string, unknown>,
53
- ): RuntimeErrorEnvelope {
54
- const error = new Error(message) as RuntimeErrorEnvelope;
55
- Object.defineProperty(error, 'name', {
56
- value: 'RuntimeError',
57
- configurable: true,
58
- });
59
-
60
- return Object.assign(error, {
61
- code,
62
- category: resolveCategory(code),
63
- severity: 'error' as const,
64
- message,
65
- details,
66
- });
67
- }
68
-
69
- function resolveCategory(code: string): RuntimeErrorEnvelope['category'] {
70
- const prefix = code.split('.')[0] ?? 'RUNTIME';
71
- switch (prefix) {
72
- case 'PLAN':
73
- case 'CONTRACT':
74
- case 'LINT':
75
- case 'BUDGET':
76
- return prefix;
77
- default:
78
- return 'RUNTIME';
79
- }
80
- }
81
-
82
32
  /**
83
33
  * Construct a `RUNTIME.ABORTED` envelope. Phase distinguishes where the
84
34
  * abort was observed — codec call sites (`encode` / `decode` / `stream`)
@@ -3,6 +3,7 @@ export type {
3
3
  AuthoringArgumentDescriptor,
4
4
  AuthoringColumnDefaultTemplate,
5
5
  AuthoringContributions,
6
+ AuthoringDiagnosticSink,
6
7
  AuthoringEntityContext,
7
8
  AuthoringEntityTypeDescriptor,
8
9
  AuthoringEntityTypeFactoryOutput,
@@ -16,6 +16,7 @@ export type {
16
16
  CodecLookup,
17
17
  CodecMeta,
18
18
  CodecRef,
19
+ CodecRegistry,
19
20
  CodecTrait,
20
21
  } from '../shared/codec-types';
21
22
  export { emptyCodecLookup, voidParamsSchema } from '../shared/codec-types';
@@ -26,3 +27,4 @@ export type {
26
27
  ColumnTypeDescriptor,
27
28
  } from '../shared/column-spec';
28
29
  export { column } from '../shared/column-spec';
30
+ export { materializeCodec, validateCodecTypeParams } from '../shared/resolve-codec';
@@ -47,7 +47,6 @@ export type {
47
47
  MigrationRunnerSuccessValue,
48
48
  MigrationScaffoldContext,
49
49
  OpFactoryCall,
50
- SerializedQueryPlan,
51
50
  TargetMigrationsCapability,
52
51
  } from '../control/control-migration-types';
53
52
  export type {
package/src/exports/ir.ts CHANGED
@@ -4,5 +4,5 @@ export { freezeNode, IRNodeBase } from '../ir/ir-node';
4
4
  export type { Namespace } from '../ir/namespace';
5
5
  export { NamespaceBase, UNBOUND_NAMESPACE_ID } from '../ir/namespace';
6
6
  export type { EntityCoordinate, Storage } from '../ir/storage';
7
- export { elementCoordinates } from '../ir/storage';
7
+ export { elementCoordinates, entityAt } from '../ir/storage';
8
8
  export type { StorageType } from '../ir/storage-type';
package/src/ir/storage.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { StorageBase } from '@prisma-next/contract/types';
2
+ import { blindCast } from '@prisma-next/utils/casts';
2
3
  import type { IRNode } from './ir-node';
3
4
  import type { Namespace } from './namespace';
4
5
 
@@ -30,11 +31,11 @@ export interface EntityCoordinate {
30
31
  * value, yielded as {@link EntityCoordinate} tuples with
31
32
  * `plane: 'storage'` (the parameter type binds the plane).
32
33
  *
33
- * Iterates each namespace's `entries` slot maps structurally. Skips
34
+ * Iterates each namespace's `entries` kind maps structurally. Skips
34
35
  * non-object `entries`; `id` and `kind` are not walked (`kind` is
35
36
  * non-enumerable on concretions). For every entity-kind key under
36
37
  * `entries` whose value is a non-null object, yields one coordinate per
37
- * entity name in that map. No family-specific slot vocabulary is required.
38
+ * entity name in that map. No family-specific kind vocabulary is required.
38
39
  */
39
40
  export function* elementCoordinates(
40
41
  storage: Pick<StorageBase, 'namespaces'>,
@@ -42,15 +43,41 @@ export function* elementCoordinates(
42
43
  for (const [namespaceId, ns] of Object.entries(storage.namespaces)) {
43
44
  const entries = ns.entries;
44
45
  if (entries === null || typeof entries !== 'object') continue;
45
- for (const [entityKind, slot] of Object.entries(entries)) {
46
- if (slot === null || typeof slot !== 'object') continue;
47
- for (const entityName of Object.keys(slot)) {
46
+ for (const [entityKind, kindMap] of Object.entries(entries)) {
47
+ if (kindMap === null || typeof kindMap !== 'object') continue;
48
+ for (const entityName of Object.keys(kindMap)) {
48
49
  yield { plane: 'storage', namespaceId, entityKind, entityName };
49
50
  }
50
51
  }
51
52
  }
52
53
  }
53
54
 
55
+ function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
56
+ return typeof value === 'object' && value !== null;
57
+ }
58
+
59
+ /**
60
+ * Looks up a single entity in a `Storage`-shaped value by its full coordinate.
61
+ * Returns `undefined` if the namespace, entity kind, or entity name is absent.
62
+ * The type parameter is a caller assertion — the walk itself is structural
63
+ * and cannot verify the entity's shape.
64
+ */
65
+ export function entityAt<T = unknown>(
66
+ storage: Pick<StorageBase, 'namespaces'>,
67
+ coord: Pick<EntityCoordinate, 'namespaceId' | 'entityKind' | 'entityName'>,
68
+ ): T | undefined {
69
+ const ns = storage.namespaces[coord.namespaceId];
70
+ if (ns === undefined) return undefined;
71
+ const entries = ns.entries;
72
+ if (!isRecord(entries)) return undefined;
73
+ const kindMap = entries[coord.entityKind];
74
+ if (!isRecord(kindMap)) return undefined;
75
+ if (!Object.hasOwn(kindMap, coord.entityName)) return undefined;
76
+ return blindCast<T | undefined, 'caller asserts the entity type at this coordinate'>(
77
+ kindMap[coord.entityName],
78
+ );
79
+ }
80
+
54
81
  /**
55
82
  * Framework-level promise that every Contract IR / Schema IR carries a
56
83
  * collection of namespaces keyed by namespace id. Family storage
@@ -66,7 +93,7 @@ export function* elementCoordinates(
66
93
  * is honest at every layer.
67
94
  *
68
95
  * Extends `IRNode` so the framework's IR-walking surfaces (verifiers,
69
- * serializers) can dispatch on `Storage`-typed slots through the same
96
+ * serializers) can dispatch on `Storage`-typed fields through the same
70
97
  * IR-node alphabet as every other node — the structural dual already
71
98
  * holds in code (every concrete storage class extends an IR-node base);
72
99
  * the interface promotion makes the typing honest.
@@ -43,6 +43,8 @@ export interface CodecDescriptor<P = void> {
43
43
  readonly isParameterized: boolean;
44
44
  /** Emit-path string renderer for `contract.d.ts`. Returns the TypeScript output type expression for given params (e.g. `Vector<1536>`). Optional; absent renderers cause the emitter to fall back to the codec's base output type. Non-parameterized codecs typically omit it. */
45
45
  readonly renderOutputType?: (params: P) => string | undefined;
46
+ /** Emit-path string renderer for the `contract.d.ts` *input* position (create/update values). Returns the TypeScript input type expression for given params. Optional; absent renderers fall back to the codec's base input type. A codec supplies this when its write type is narrower than the generic codec input — e.g. an enum whose input should be the literal member union, not `string`. */
47
+ readonly renderInputType?: (params: P) => string | undefined;
46
48
  /** The curried higher-order codec. For non-parameterized codecs, the factory is constant — every call returns the same shared codec instance. For parameterized codecs, the factory is called once per `storage.types` instance (or once per inline-`typeParams` column), with `ctx` carrying the column set the resulting codec serves. */
47
49
  readonly factory: (params: P) => (ctx: CodecInstanceContext) => Codec;
48
50
  }
@@ -78,6 +80,9 @@ export abstract class CodecDescriptorImpl<TParams = void> implements CodecDescri
78
80
  /** Optional emit-path string renderer for `contract.d.ts`. Returns the TypeScript output type expression for the given params (e.g. `Vector<1536>`). Non-parameterized codecs typically omit it. */
79
81
  renderOutputType?(params: TParams): string | undefined;
80
82
 
83
+ /** Optional emit-path string renderer for the `contract.d.ts` input position. Returns the TypeScript input type expression for the given params; supplied when the write type is narrower than the generic codec input (e.g. an enum's literal member union). */
84
+ renderInputType?(params: TParams): string | undefined;
85
+
81
86
  /**
82
87
  * Materialize a curried codec factory for the given params. Concrete subclasses override with a typed return type (e.g. `factory<N>(params: { length: N }): (ctx) => VectorCodec<N>`); per-codec helpers read the typed return at the *direct* call site, which is what preserves method-level generics. Type extraction (e.g. `ReturnType<D['factory']>`) widens method generics to their constraint — that's why the column-helper surface is per-codec, not polymorphic.
83
88
  */
@@ -36,7 +36,7 @@ export interface CodecCallContext {
36
36
  /**
37
37
  * Codec-id-keyed read surface threaded into emit and authoring paths.
38
38
  *
39
- * - `get(id)` returns the runtime {@link Codec} instance for the codec id (used by `family.deserializeContract` for `decodeJson` of literal column defaults).
39
+ * - `get(id)` returns a representative {@link Codec} instance for the codec id (used by `family.deserializeContract` for `decodeJson` of literal column defaults). For parameterized codecs whose factory requires concrete params, this may return `undefined` — use `CodecRegistry.forCodecRef` instead.
40
40
  * - `targetTypesFor(id)` exposes the codec-id-keyed `targetTypes` metadata the runtime instance no longer carries (TML-2357). Returns the same array `CodecDescriptor.targetTypes` would; for Mongo (whose registration doesn't yet resolve through the unified descriptor map — TML-2324) the family-side assembly populates this directly from the contributor's codec metadata.
41
41
  * - `metaFor(id)` exposes the codec-id-keyed `meta` (e.g. SQL-side `db.sql.postgres.nativeType`) the runtime instance no longer carries.
42
42
  * - `renderOutputTypeFor(id, params)` exposes the codec-id-keyed `renderOutputType` renderer the runtime instance no longer carries. Returns `undefined` when the codec doesn't render a custom type or when the codec id is unknown.
@@ -46,6 +46,26 @@ export interface CodecLookup {
46
46
  targetTypesFor(id: string): readonly string[] | undefined;
47
47
  metaFor(id: string): CodecMeta | undefined;
48
48
  renderOutputTypeFor(id: string, params: Record<string, unknown>): string | undefined;
49
+ /** Codec-id-keyed `renderInputType` renderer for the `contract.d.ts` input position. Optional so existing lookups need not provide it; returns `undefined` when the codec renders no custom input type or the id is unknown. */
50
+ renderInputTypeFor?(id: string, params: Record<string, unknown>): string | undefined;
51
+ }
52
+
53
+ /**
54
+ * Full codec registry — the read surface of {@link CodecLookup} plus codec resolution by ref or
55
+ * column coordinate. Built once by `extractCodecLookup` and passed by reference to adapters and
56
+ * other consumers that need to materialise codecs at runtime.
57
+ *
58
+ * - `forCodecRef(ref)` materialises a codec from a {@link CodecRef}. Throws
59
+ * `RUNTIME.CODEC_DESCRIPTOR_MISSING` for unknown ids and `RUNTIME.TYPE_PARAMS_INVALID` on param
60
+ * schema rejection.
61
+ * - `forColumn(namespaceId, table, column)` returns the codec for a specific column coordinate, or
62
+ * `undefined` when no column-to-codec mapping is present. This registry is contract-free so it
63
+ * always returns `undefined` — the method exists so the object structurally satisfies the SQL
64
+ * `ContractCodecRegistry` interface.
65
+ */
66
+ export interface CodecRegistry extends CodecLookup {
67
+ forCodecRef(ref: CodecRef): Codec;
68
+ forColumn(namespaceId: string, table: string, column: string): Codec | undefined;
49
69
  }
50
70
 
51
71
  export const emptyCodecLookup: CodecLookup = {
@@ -10,6 +10,7 @@ import {
10
10
  import { blindCast } from '@prisma-next/utils/casts';
11
11
  import { ifDefined } from '@prisma-next/utils/defined';
12
12
  import type { Type } from 'arktype';
13
+ import type { CodecLookup } from './codec-types';
13
14
  import type { PslBlockParam } from './psl-extension-block';
14
15
 
15
16
  export type AuthoringArgRef = {
@@ -109,9 +110,30 @@ export type AuthoringFieldNamespace = {
109
110
  * discover what the factory actually needs to read (codec lookup,
110
111
  * namespace registry, …).
111
112
  */
113
+ /**
114
+ * A write-only sink that a factory may push authoring-time diagnostics into.
115
+ * The concrete type pushed must be structurally compatible with whatever the
116
+ * consumer accumulates (typically `ContractSourceDiagnostic[]`); the framework
117
+ * layer deliberately does not depend on that concrete type.
118
+ */
119
+ export interface AuthoringDiagnosticSink {
120
+ push(d: {
121
+ readonly code: string;
122
+ readonly message: string;
123
+ readonly sourceId: string;
124
+ readonly span?: unknown;
125
+ }): void;
126
+ }
127
+
112
128
  export interface AuthoringEntityContext {
113
129
  readonly family: string;
114
130
  readonly target: string;
131
+ /** Codec registry available to factories that need to validate or decode values. */
132
+ readonly codecLookup?: CodecLookup;
133
+ /** Source file identifier threaded into diagnostics emitted by the factory. */
134
+ readonly sourceId?: string;
135
+ /** Push channel for authoring-time diagnostics emitted by the factory. */
136
+ readonly diagnostics?: AuthoringDiagnosticSink;
115
137
  }
116
138
 
117
139
  export interface AuthoringEntityTypeTemplateOutput {
@@ -186,6 +208,21 @@ export interface AuthoringPslBlockDescriptor {
186
208
  readonly discriminator: string;
187
209
  readonly name: { readonly required: boolean };
188
210
  readonly parameters: Record<string, PslBlockParam>;
211
+ /**
212
+ * When `true`, the block body accepts a variadic tail of parameters beyond
213
+ * the declared set. The block body may contain: fields (model-style),
214
+ * `key = value` parameters, and `@@` attributes. With `variadicParameters`,
215
+ * bare identifiers (keys without a `= value`) and undeclared `key = value`
216
+ * pairs flow into the variadic tail — their semantics belong to the
217
+ * lowering, not the parser.
218
+ *
219
+ * A key that IS declared in `parameters` must still be supplied as
220
+ * `key = value`; a bare occurrence of a declared key is a diagnostic.
221
+ *
222
+ * When `false` (default), the validator emits `PSL_EXTENSION_UNKNOWN_PARAMETER`
223
+ * for keys absent from `parameters`.
224
+ */
225
+ readonly variadicParameters?: boolean;
189
226
  }
190
227
 
191
228
  export type AuthoringPslBlockDescriptorNamespace = {
@@ -329,13 +366,29 @@ export function hasRegisteredFieldNamespace(
329
366
  return !isAuthoringFieldPresetDescriptor(contributions.field[namespace]);
330
367
  }
331
368
 
332
- function isPlainNamespaceObject(value: unknown): value is Record<string, unknown> {
333
- return typeof value === 'object' && value !== null && !Array.isArray(value);
369
+ function isCopyableNamespaceObject(value: unknown): value is Record<string, unknown> {
370
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
371
+ const proto: unknown = Object.getPrototypeOf(value);
372
+ return proto === Object.prototype || proto === null;
373
+ }
374
+
375
+ function deepCopyNamespace(
376
+ source: Record<string, unknown>,
377
+ isLeafDescriptor: (value: unknown) => boolean,
378
+ ): Record<string, unknown> {
379
+ const copy: Record<string, unknown> = {};
380
+ for (const [key, value] of Object.entries(source)) {
381
+ copy[key] =
382
+ isCopyableNamespaceObject(value) && !isLeafDescriptor(value)
383
+ ? deepCopyNamespace(value, isLeafDescriptor)
384
+ : value;
385
+ }
386
+ return copy;
334
387
  }
335
388
 
336
389
  /**
337
390
  * Merges `source` into `target` recursively at the descriptor-namespace
338
- * level. `leafGuard` decides which values are descriptors (terminal
391
+ * level. `isLeafDescriptor` decides which values are descriptors (terminal
339
392
  * merge points; same-path registrations across components are reported
340
393
  * as duplicates) versus sub-namespaces (recursion targets).
341
394
  *
@@ -355,7 +408,7 @@ export function mergeAuthoringNamespaces(
355
408
  target: Record<string, unknown>,
356
409
  source: Record<string, unknown>,
357
410
  path: readonly string[],
358
- leafGuard: (value: unknown) => boolean,
411
+ isLeafDescriptor: (value: unknown) => boolean,
359
412
  label: string,
360
413
  ): void {
361
414
  const assertSafePath = (currentPath: readonly string[]) => {
@@ -376,12 +429,19 @@ export function mergeAuthoringNamespaces(
376
429
  const existingValue = hasExistingValue ? target[key] : undefined;
377
430
 
378
431
  if (!hasExistingValue) {
379
- target[key] = sourceValue;
432
+ // Deep-copy plain-object sub-namespaces so subsequent merges don't mutate
433
+ // objects owned by source packs. Leaf descriptors and class instances are
434
+ // passed by reference — leaves are identity values; class instances carry
435
+ // prototype getters that spread would destroy.
436
+ target[key] =
437
+ isCopyableNamespaceObject(sourceValue) && !isLeafDescriptor(sourceValue)
438
+ ? deepCopyNamespace(sourceValue, isLeafDescriptor)
439
+ : sourceValue;
380
440
  continue;
381
441
  }
382
442
 
383
- const existingIsLeaf = leafGuard(existingValue);
384
- const sourceIsLeaf = leafGuard(sourceValue);
443
+ const existingIsLeaf = isLeafDescriptor(existingValue);
444
+ const sourceIsLeaf = isLeafDescriptor(sourceValue);
385
445
 
386
446
  if (existingIsLeaf || sourceIsLeaf) {
387
447
  throw new Error(
@@ -389,17 +449,17 @@ export function mergeAuthoringNamespaces(
389
449
  );
390
450
  }
391
451
 
392
- if (!isPlainNamespaceObject(existingValue) || !isPlainNamespaceObject(sourceValue)) {
452
+ if (!isCopyableNamespaceObject(existingValue) || !isCopyableNamespaceObject(sourceValue)) {
393
453
  throw new Error(
394
454
  `Invalid authoring ${label} helper "${currentPath.join('.')}". Expected a sub-namespace object or a recognized descriptor; received a malformed value.`,
395
455
  );
396
456
  }
397
457
 
398
- mergeAuthoringNamespaces(existingValue, sourceValue, currentPath, leafGuard, label);
458
+ mergeAuthoringNamespaces(existingValue, sourceValue, currentPath, isLeafDescriptor, label);
399
459
  }
400
460
  }
401
461
 
402
- function collectAuthoringLeafPaths(
462
+ function collectDescriptorPaths(
403
463
  namespace: Readonly<Record<string, unknown>>,
404
464
  isLeaf: (value: unknown) => boolean,
405
465
  path: readonly string[] = [],
@@ -413,29 +473,25 @@ function collectAuthoringLeafPaths(
413
473
  }
414
474
  if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
415
475
  paths.push(
416
- ...collectAuthoringLeafPaths(
417
- value as Readonly<Record<string, unknown>>,
418
- isLeaf,
419
- currentPath,
420
- ),
476
+ ...collectDescriptorPaths(value as Readonly<Record<string, unknown>>, isLeaf, currentPath),
421
477
  );
422
478
  }
423
479
  }
424
480
  return paths;
425
481
  }
426
482
 
427
- interface AuthoringLeafEntry {
483
+ interface DescriptorEntry {
428
484
  readonly path: string;
429
485
  readonly discriminator: string;
430
486
  }
431
487
 
432
- function collectAuthoringLeafDiscriminators(
488
+ function collectDescriptorEntries(
433
489
  namespace: Readonly<Record<string, unknown>>,
434
490
  isLeaf: (value: unknown) => boolean,
435
491
  label: string,
436
492
  path: readonly string[] = [],
437
- ): AuthoringLeafEntry[] {
438
- const entries: AuthoringLeafEntry[] = [];
493
+ ): DescriptorEntry[] {
494
+ const entries: DescriptorEntry[] = [];
439
495
  for (const [key, value] of Object.entries(namespace)) {
440
496
  const currentPath = [...path, key];
441
497
  if (isLeaf(value)) {
@@ -479,7 +535,7 @@ function collectAuthoringLeafDiscriminators(
479
535
  );
480
536
  }
481
537
  }
482
- entries.push(...collectAuthoringLeafDiscriminators(record, isLeaf, label, currentPath));
538
+ entries.push(...collectDescriptorEntries(record, isLeaf, label, currentPath));
483
539
  }
484
540
  }
485
541
  return entries;
@@ -491,7 +547,7 @@ function collectAuthoringLeafDiscriminators(
491
547
  * lowering factory lookup dispatches by discriminator, so one would silently
492
548
  * shadow the other. Catch duplicates before building any dispatch map.
493
549
  */
494
- function assertUniqueDiscriminators(entries: readonly AuthoringLeafEntry[], label: string): void {
550
+ function assertUniqueDiscriminators(entries: readonly DescriptorEntry[], label: string): void {
495
551
  const seen = new Map<string, string>();
496
552
  for (const { path, discriminator } of entries) {
497
553
  const existing = seen.get(discriminator);
@@ -504,23 +560,50 @@ function assertUniqueDiscriminators(entries: readonly AuthoringLeafEntry[], labe
504
560
  }
505
561
  }
506
562
 
563
+ function collectPslBlockDescriptorEntries(
564
+ namespace: Readonly<Record<string, unknown>>,
565
+ path: readonly string[] = [],
566
+ ): DescriptorEntry[] {
567
+ const entries: DescriptorEntry[] = [];
568
+ for (const [key, value] of Object.entries(namespace)) {
569
+ const currentPath = [...path, key];
570
+ if (isAuthoringPslBlockDescriptor(value)) {
571
+ entries.push({
572
+ path: currentPath.join('.'),
573
+ discriminator: value.discriminator,
574
+ });
575
+ continue;
576
+ }
577
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
578
+ const record = blindCast<
579
+ Readonly<Record<string, unknown>>,
580
+ 'walker descends into psl block namespace'
581
+ >(value);
582
+ const hasKind = record['kind'] === 'pslBlock';
583
+ const hasKeyword = typeof record['keyword'] === 'string';
584
+ const hasDiscriminator = typeof record['discriminator'] === 'string';
585
+ if (hasKind || (hasKeyword && hasDiscriminator)) {
586
+ throw new Error(
587
+ `Malformed authoring pslBlock contribution at "${currentPath.join('.')}". The value carries descriptor keys (kind/keyword/discriminator) but does not satisfy the pslBlock 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.`,
588
+ );
589
+ }
590
+ entries.push(...collectPslBlockDescriptorEntries(record, currentPath));
591
+ }
592
+ }
593
+ return entries;
594
+ }
595
+
507
596
  /**
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).
597
+ * Every `pslBlockDescriptors` entry requires a matching `entityTypes` factory
598
+ * with the same discriminator. An `entityTypes` factory may stand alone (e.g.
599
+ * `enum`, reachable from the TypeScript builder without any PSL block).
513
600
  */
514
601
  function assertPslBlocksHaveFactories(
515
602
  entityTypeNamespace: AuthoringEntityTypeNamespace,
516
603
  pslBlockNamespace: AuthoringPslBlockDescriptorNamespace,
517
604
  ): void {
518
- const blockEntries = collectAuthoringLeafDiscriminators(
519
- pslBlockNamespace,
520
- isAuthoringPslBlockDescriptor,
521
- 'pslBlock',
522
- );
523
- const entityEntries = collectAuthoringLeafDiscriminators(
605
+ const blockEntries = collectPslBlockDescriptorEntries(pslBlockNamespace);
606
+ const entityEntries = collectDescriptorEntries(
524
607
  entityTypeNamespace,
525
608
  isAuthoringEntityTypeDescriptor,
526
609
  'entityType',
@@ -547,13 +630,13 @@ export function assertNoCrossRegistryCollisions(
547
630
  pslBlockNamespace: AuthoringPslBlockDescriptorNamespace = {},
548
631
  ): void {
549
632
  const typePaths = new Set(
550
- collectAuthoringLeafPaths(typeNamespace, isAuthoringTypeConstructorDescriptor),
633
+ collectDescriptorPaths(typeNamespace, isAuthoringTypeConstructorDescriptor),
551
634
  );
552
635
  const fieldPaths = new Set(
553
- collectAuthoringLeafPaths(fieldNamespace, isAuthoringFieldPresetDescriptor),
636
+ collectDescriptorPaths(fieldNamespace, isAuthoringFieldPresetDescriptor),
554
637
  );
555
638
  const entityPaths = new Set(
556
- collectAuthoringLeafPaths(entityTypeNamespace, isAuthoringEntityTypeDescriptor),
639
+ collectDescriptorPaths(entityTypeNamespace, isAuthoringEntityTypeDescriptor),
557
640
  );
558
641
  // Within-registry duplicate detection is handled upstream by the merge
559
642
  // walker (`mergeAuthoringNamespaces` in control-stack.ts and