@forgeax/engine-pack 0.1.28 → 0.1.29

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 (41) hide show
  1. package/README.md +17 -5
  2. package/dist/build.mjs +34 -51
  3. package/dist/build.mjs.map +1 -1
  4. package/dist/cli-asset.mjs +21 -12
  5. package/dist/cli-asset.mjs.map +1 -1
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/inventory/declaration.d.ts +1 -1
  8. package/dist/inventory/declaration.d.ts.map +1 -1
  9. package/dist/inventory/sync.d.ts.map +1 -1
  10. package/dist/native-cooker-registry.d.ts +1 -0
  11. package/dist/native-cooker-registry.d.ts.map +1 -1
  12. package/dist/native-cooker.mjs.map +1 -1
  13. package/dist/pack-authoring-node.mjs +21 -12
  14. package/dist/pack-authoring-node.mjs.map +1 -1
  15. package/dist/runtime.mjs.map +1 -1
  16. package/dist/scanner.mjs +21 -12
  17. package/dist/scanner.mjs.map +1 -1
  18. package/dist/schema-compiled.d.ts.map +1 -1
  19. package/dist/schema.mjs +40 -62
  20. package/dist/schema.mjs.map +1 -1
  21. package/dist/scriptable-pack-node.d.ts.map +1 -1
  22. package/dist/scriptable-pack-node.mjs +11 -3
  23. package/dist/scriptable-pack-node.mjs.map +1 -1
  24. package/dist/scriptable-pack-worker.mjs +5 -1
  25. package/dist/scriptable-pack-worker.mjs.map +1 -1
  26. package/package.json +2 -2
  27. package/src/__tests__/inventory-schema.test.ts +10 -10
  28. package/src/__tests__/pack-authoring.unit.test.ts +2 -4
  29. package/src/__tests__/pack.unit.test.ts +86 -124
  30. package/src/__tests__/scanner-instance-cycle.test.ts +92 -0
  31. package/src/__tests__/scanner-inventory.contract.test.ts +2 -2
  32. package/src/__tests__/scriptable-pack-diagnostic.integration.test.ts +45 -0
  33. package/src/inventory/binding.ts +3 -3
  34. package/src/inventory/declaration.ts +10 -48
  35. package/src/inventory/sync.ts +3 -2
  36. package/src/native-cooker-registry.ts +1 -0
  37. package/src/scanner.ts +28 -28
  38. package/src/schema-compiled.ts +40 -79
  39. package/src/scriptable-pack-node.ts +11 -3
  40. package/src/scriptable-pack-worker.ts +8 -1
  41. package/src/__tests__/scanner-mount-cycle.test.ts +0 -232
@@ -0,0 +1,45 @@
1
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { expect, it } from 'vitest';
5
+ import { loadScriptablePack } from '../scriptable-pack-node.js';
6
+
7
+ it('keeps the source line and column for a malformed author module', async () => {
8
+ const root = await mkdtemp(join(tmpdir(), 'pack-source-diagnostic-'));
9
+ const source = join(root, 'broken.pack.ts');
10
+ try {
11
+ await writeFile(source, 'export default {\n build: () => { const = 1; }\n};\n');
12
+ const loaded = await loadScriptablePack(source);
13
+ expect(loaded.ok).toBe(false);
14
+ if (loaded.ok) return;
15
+ expect(JSON.stringify(loaded.error)).toContain(`${source}:2:`);
16
+ } finally {
17
+ await rm(root, { recursive: true, force: true });
18
+ }
19
+ });
20
+
21
+ it.each([
22
+ 0, 17,
23
+ ])('propagates worker exit %i during a build instead of waiting for timeout', async (code) => {
24
+ const root = await mkdtemp(join(tmpdir(), 'pack-worker-exit-'));
25
+ const source = join(root, 'exit.pack.ts');
26
+ try {
27
+ await writeFile(
28
+ source,
29
+ `export default { schemaVersion: '2.0.0', packageId: new Uint8Array([121,171,30,178,252,222,65,195,145,119,233,61,213,193,142,245]), build() { process.exit(${code}); } };`,
30
+ );
31
+ const loaded = await loadScriptablePack(source, { buildTimeoutMs: 1000 });
32
+ expect(loaded.ok).toBe(true);
33
+ if (!loaded.ok) return;
34
+ await expect(
35
+ loaded.value.build({
36
+ packageId: loaded.value.packageId,
37
+ readByGuid: async () => {
38
+ throw new Error('unexpected read');
39
+ },
40
+ }),
41
+ ).rejects.toThrow(`worker exited with code ${code}`);
42
+ } finally {
43
+ await rm(root, { recursive: true, force: true });
44
+ }
45
+ });
@@ -15,11 +15,11 @@ export function projectAssetRefs(inventory: AuthorInventory): readonly AssetRef[
15
15
  /** Derive instance-relative scene refs from the same validated author rows. */
16
16
  export function projectSceneEntityRefs(inventory: AuthorInventory): readonly SceneEntityRef[] {
17
17
  return inventory.declarations.flatMap((row) =>
18
- row.sceneBindings === undefined
18
+ row.sceneEntityKeys === undefined
19
19
  ? []
20
- : row.sceneBindings.map((bindingKey) => ({
20
+ : row.sceneEntityKeys.map((address) => ({
21
21
  sceneSourceKey: row.sourceKey,
22
- bindingKey,
22
+ address,
23
23
  })),
24
24
  );
25
25
  }
@@ -6,7 +6,7 @@ export interface AuthorInventoryRow {
6
6
  readonly kind: string;
7
7
  readonly payload: Readonly<Record<string, unknown>>;
8
8
  readonly refs: readonly string[];
9
- readonly sceneBindings?: readonly string[];
9
+ readonly sceneEntityKeys?: readonly string[];
10
10
  }
11
11
 
12
12
  export interface AuthorInventory {
@@ -102,54 +102,16 @@ export function validateAuthorInventory(value: unknown): Result<AuthorInventory,
102
102
  { guid, sourceKey },
103
103
  );
104
104
  }
105
- if (row.kind === 'scene') {
106
- const payload = row.payload;
107
- const payloadRecord =
108
- typeof payload === 'object' && payload !== null
109
- ? (payload as Record<string, unknown>)
110
- : undefined;
111
- const entities =
112
- payloadRecord !== undefined && Array.isArray(payloadRecord.entities)
113
- ? payloadRecord.entities
114
- : [];
115
- const bindings = new Set<string>();
116
- for (const entity of entities) {
117
- const bindingKey =
118
- typeof entity === 'object' && entity !== null && typeof entity.bindingKey === 'string'
119
- ? entity.bindingKey
120
- : undefined;
121
- if (bindingKey !== undefined && bindingKey.length === 0) {
122
- return failure(
123
- 'inventory-scene-binding-missing',
124
- 'each declared scene bindingKey is non-empty',
125
- 'remove the empty bindingKey or declare a stable key in the scene producer',
126
- { sourceKey },
127
- );
128
- }
129
- if (bindingKey === undefined) continue;
130
- if (bindings.has(bindingKey)) {
131
- return failure(
132
- 'inventory-scene-binding-duplicate',
133
- 'bindingKey values are unique within one scene',
134
- 'rename the duplicate bindingKey in the scene producer',
135
- { sourceKey },
136
- );
137
- }
138
- bindings.add(bindingKey);
139
- }
140
- }
141
105
  guids.add(normalizedGuid);
142
106
  sourceKeys.add(sourceKey);
143
- const sceneBindings =
144
- row.kind === 'scene' && typeof row.payload === 'object' && row.payload !== null
145
- ? (row.payload as { readonly entities?: readonly unknown[] }).entities?.flatMap(
146
- (entity) => {
147
- if (typeof entity !== 'object' || entity === null) return [];
148
- const key = (entity as { readonly bindingKey?: unknown }).bindingKey;
149
- return typeof key === 'string' && key.length > 0 ? [key] : [];
150
- },
151
- )
152
- : undefined;
107
+ const sceneEntityKeys = (() => {
108
+ if (row.kind !== 'scene' || typeof row.payload !== 'object' || row.payload === null)
109
+ return undefined;
110
+ const entities = (row.payload as { readonly entities?: unknown }).entities;
111
+ if (entities === null || typeof entities !== 'object' || Array.isArray(entities))
112
+ return undefined;
113
+ return Object.keys(entities);
114
+ })();
153
115
  declarations.push({
154
116
  guid,
155
117
  sourceKey,
@@ -161,7 +123,7 @@ export function validateAuthorInventory(value: unknown): Result<AuthorInventory,
161
123
  refs: Array.isArray(row.refs)
162
124
  ? row.refs.filter((ref): ref is string => typeof ref === 'string')
163
125
  : [],
164
- ...(sceneBindings === undefined ? {} : { sceneBindings }),
126
+ ...(sceneEntityKeys === undefined ? {} : { sceneEntityKeys }),
165
127
  });
166
128
  }
167
129
  return ok({ declarations });
@@ -5,7 +5,8 @@ export type InventoryDigest = string;
5
5
  export function inventoryDigest(inventory: AuthorInventory): InventoryDigest {
6
6
  return inventory.declarations
7
7
  .map(
8
- (row) => `${row.guid}\0${row.sourceKey}\0${row.kind}\0${row.sceneBindings?.join('\0') ?? ''}`,
8
+ (row) =>
9
+ `${row.guid}\0${row.sourceKey}\0${row.kind}\0${row.sceneEntityKeys?.join('\0') ?? ''}`,
9
10
  )
10
11
  .sort()
11
12
  .join('\n');
@@ -16,7 +17,7 @@ export function syncAuthorInventory(inventory: AuthorInventory): AuthorInventory
16
17
  declarations: inventory.declarations.map((row) => ({
17
18
  ...row,
18
19
  refs: [...row.refs],
19
- ...(row.sceneBindings === undefined ? {} : { sceneBindings: [...row.sceneBindings] }),
20
+ ...(row.sceneEntityKeys === undefined ? {} : { sceneEntityKeys: [...row.sceneEntityKeys] }),
20
21
  })),
21
22
  };
22
23
  }
@@ -20,6 +20,7 @@ export interface NativeCookDraft<P = unknown> {
20
20
  readonly refs: readonly string[];
21
21
  readonly artifacts: Readonly<Record<string, NativeCookArtifact>>;
22
22
  readonly inputFingerprint: string;
23
+ readonly sourceDependencies?: readonly string[];
23
24
  }
24
25
 
25
26
  export interface NativeCooker<P = unknown, I = unknown> {
package/src/scanner.ts CHANGED
@@ -254,27 +254,35 @@ function makePackError(
254
254
  }
255
255
 
256
256
  /**
257
- * For scene assets with `payload.mounts[]`, return the lowercased GUID
258
- * each `mount.source` integer resolves to via `asset.refs[]`. Returns an
259
- * empty iterable for non-scene assets, scene assets without mounts, or
260
- * mounts with malformed `source` (out-of-range integer / non-integer) —
261
- * those are caught by ajv schema validation upstream. The yielded GUIDs
262
- * feed scanner step-6's mount-asset cycle DFS (D-1, R10).
257
+ * For keyed scene assets, return the lowercased GUID each nested instance
258
+ * source resolves to via `asset.refs[]` (or directly carries). The yielded GUIDs feed
259
+ * scanner step-6's scene dependency cycle DFS.
263
260
  */
264
- function* extractMountSourceGuids(asset: {
261
+ function* extractInstanceSourceGuids(asset: {
265
262
  kind?: unknown;
266
263
  payload?: unknown;
267
264
  refs: readonly string[];
268
265
  }): Generator<string> {
269
266
  if (asset.kind !== 'scene') return;
270
- const payload = asset.payload as { mounts?: unknown } | undefined;
271
- if (!payload || !Array.isArray(payload.mounts)) return;
272
- for (const rawMount of payload.mounts) {
273
- const mount = rawMount as { source?: unknown };
274
- const idx = mount.source;
275
- if (typeof idx !== 'number' || !Number.isInteger(idx)) continue;
276
- if (idx < 0 || idx >= asset.refs.length) continue;
277
- const resolved = asset.refs[idx];
267
+ const payload = asset.payload as { entities?: unknown } | undefined;
268
+ if (
269
+ !payload ||
270
+ payload.entities === null ||
271
+ typeof payload.entities !== 'object' ||
272
+ Array.isArray(payload.entities)
273
+ )
274
+ return;
275
+ for (const rawEntity of Object.values(payload.entities as Record<string, unknown>)) {
276
+ if (rawEntity === null || typeof rawEntity !== 'object' || Array.isArray(rawEntity)) continue;
277
+ const instance = (rawEntity as { instance?: unknown }).instance;
278
+ if (instance === null || typeof instance !== 'object' || Array.isArray(instance)) continue;
279
+ const source = (instance as { source?: unknown }).source;
280
+ const resolved =
281
+ typeof source === 'number' && Number.isInteger(source)
282
+ ? asset.refs[source]
283
+ : typeof source === 'string'
284
+ ? source
285
+ : undefined;
278
286
  if (typeof resolved !== 'string') continue;
279
287
  yield resolved.toLowerCase();
280
288
  }
@@ -450,7 +458,7 @@ async function scanValidated(
450
458
  guidToPath.set(normalizedGuid, packPath);
451
459
  packRefs.set(normalizedGuid, [
452
460
  ...asset.refs.map((ref) => ref.toLowerCase()),
453
- ...extractMountSourceGuids(asset),
461
+ ...extractInstanceSourceGuids(asset),
454
462
  ]);
455
463
  }
456
464
  }
@@ -563,20 +571,12 @@ async function scanValidated(
563
571
  }
564
572
  guidToPath.set(normalizedGuid, packPath);
565
573
 
566
- // feat-20260608-scene-nesting-ecs-fication M1 / w14 (D-1):
567
- // mount-payload-extract for scene assets, redundantly inject the
568
- // mount.source -> resolved GUID edge into the cycle graph alongside
569
- // asset.refs[]. By the .pack.json convention mount.source is an
570
- // integer index into the same asset.refs[], so the resolved GUID is
571
- // already present in `existingRefs`; this defensive pass guarantees
572
- // that any author-supplied mounts[] references participate in the
573
- // cycle DFS even if the schema-emitter forgot to mirror them into
574
- // refs[]. The `kind: 'mount-asset'` tag on the resulting
575
- // pack-cyclic-reference detail is set by the cycle producer below
576
- // (R10).
574
+ // Keyed scene instances are explicit dependency edges. Keep the
575
+ // producer source in the cycle graph alongside ordinary asset refs so
576
+ // recursive SceneAsset declarations fail before publication.
577
577
  packRefs.set(normalizedGuid, [
578
578
  ...asset.refs.map((ref) => ref.toLowerCase()),
579
- ...extractMountSourceGuids(asset),
579
+ ...extractInstanceSourceGuids(asset),
580
580
  ]);
581
581
  }
582
582
  capture?.declarations.set(packPath, {
@@ -75,108 +75,69 @@ export function buildSceneAssetValidator(
75
75
  componentsProperties[name] = sub;
76
76
  }
77
77
 
78
- // MountOverride schema (feat-20260608-scene-nesting-ecs-fication M1 / w11;
79
- // feat-20260713-mount-override-component-add-and-shared-ref-round M1 / w3;
80
- // requirements §S-10 / AC-03): validates each entry in mounts[].overrides[].
81
- // `field` is optional — it is a component-granular add-or-patch discriminant
82
- // (present -> patch one field with `value`; absent -> add/upsert the whole
83
- // `comp` with `value` as its per-field map). `localId` / `comp` / `value`
84
- // stay required; `additionalProperties: false` rejects any `op`-tag
85
- // discriminant so downstream consumers never branch on it. Field-level type
86
- // validation (matching the schema vocab) is enforced at runtime via
87
- // setSceneOverride (D-9 / EcsErrorCode 'scene-override-type-mismatch').
88
- const mountOverrideSchema = {
78
+ const componentsSchema = {
89
79
  type: 'object',
90
80
  additionalProperties: false,
91
- required: ['localId', 'comp', 'value'],
92
- properties: {
93
- localId: { type: 'integer', minimum: 0 },
94
- comp: { type: 'string', minLength: 1 },
95
- field: { type: 'string', minLength: 1 },
96
- value: {},
97
- },
81
+ properties: componentsProperties,
98
82
  };
99
-
100
- // SceneInstanceMount schema (feat-20260608-scene-nesting-ecs-fication
101
- // M1 / w11; requirements §S-10): validates each mounts[] entry. parent /
102
- // components / overrides are optional; cross-mount window collision and
103
- // mount.source / mount.memberCount agreement with referenced child
104
- // SceneAsset are checked at the build-time scanner (D-1, w14), not here.
105
- const mountSchema = {
83
+ const instanceSchema = {
106
84
  type: 'object',
107
85
  additionalProperties: false,
108
- required: ['localId', 'source', 'memberFirst', 'memberCount'],
86
+ required: ['source'],
109
87
  properties: {
110
- localId: { type: 'integer', minimum: 0 },
111
- source: { type: 'integer', minimum: 0 },
112
- memberFirst: { type: 'integer', minimum: 0 },
113
- memberCount: { type: 'integer', minimum: 0 },
114
- parent: { type: 'integer', minimum: 0 },
115
- components: {
116
- type: 'object',
117
- additionalProperties: false,
118
- properties: componentsProperties,
88
+ // Authored scenes use a GUID string. Pack output may carry a refs[]
89
+ // index, which is resolved by the Scene decoder before instantiation.
90
+ source: {
91
+ oneOf: [
92
+ { type: 'string', minLength: 1 },
93
+ { type: 'integer', minimum: 0 },
94
+ ],
119
95
  },
120
96
  overrides: {
121
97
  type: 'array',
122
- items: mountOverrideSchema,
123
- },
124
- publicationFence: {
125
- type: 'object',
126
- additionalProperties: false,
127
- required: [
128
- 'schemaVersion',
129
- 'sourcePath',
130
- 'sourceRevision',
131
- 'publicationGeneration',
132
- 'outputDigest',
133
- 'outputSetDigest',
134
- 'receiptIdentity',
135
- ],
136
- properties: {
137
- schemaVersion: { type: 'string', const: 'scene-publication-fence/1' },
138
- sourcePath: { type: 'string', minLength: 1 },
139
- sourceRevision: { type: 'string', minLength: 1 },
140
- publicationGeneration: { type: 'integer', minimum: 1 },
141
- outputDigest: { type: 'string', minLength: 1 },
142
- outputSetDigest: { type: 'string', minLength: 1 },
143
- receiptIdentity: { type: 'string', minLength: 1 },
98
+ items: {
99
+ type: 'object',
100
+ additionalProperties: false,
101
+ required: ['target', 'components'],
102
+ properties: {
103
+ target: { type: 'array', minItems: 1, items: { type: 'string', minLength: 1 } },
104
+ components: componentsSchema,
105
+ },
144
106
  },
145
107
  },
146
108
  },
147
109
  };
148
-
110
+ const entitySchema = {
111
+ type: 'object',
112
+ additionalProperties: false,
113
+ required: ['components'],
114
+ properties: {
115
+ components: componentsSchema,
116
+ instance: instanceSchema,
117
+ },
118
+ };
149
119
  const sceneSchema = {
150
120
  type: 'object',
151
121
  additionalProperties: false,
152
122
  required: ['kind', 'entities'],
153
123
  properties: {
154
124
  kind: { type: 'string', const: 'scene' },
155
- sourceKey: { type: 'string', minLength: 1 },
156
- entities: {
125
+ // Skin assets are explicit scene dependencies so async catalog loading
126
+ // can finish the joint-wiring path before the Scene is instantiated.
127
+ // Authored producers use GUID strings; Pack output may use refs[] indices.
128
+ skinGuids: {
157
129
  type: 'array',
158
130
  items: {
159
- type: 'object',
160
- additionalProperties: false,
161
- required: ['localId', 'components'],
162
- properties: {
163
- localId: { type: 'integer', minimum: 0 },
164
- bindingKey: { type: 'string', minLength: 1 },
165
- components: {
166
- type: 'object',
167
- additionalProperties: false,
168
- properties: componentsProperties,
169
- },
170
- },
131
+ oneOf: [
132
+ { type: 'string', minLength: 1 },
133
+ { type: 'integer', minimum: 0 },
134
+ ],
171
135
  },
172
136
  },
173
- // Optional top-level mounts[] (feat-20260608-scene-nesting-ecs-fication
174
- // M1 / w11). Missing mounts is semantically equivalent to mounts: []
175
- // (plan-strategy §6.3 back-compat); ajv default produces the empty
176
- // array on absent input.
177
- mounts: {
178
- type: 'array',
179
- items: mountSchema,
137
+ entities: {
138
+ type: 'object',
139
+ propertyNames: { type: 'string', minLength: 1 },
140
+ additionalProperties: entitySchema,
180
141
  },
181
142
  },
182
143
  };
@@ -266,9 +266,17 @@ class WorkerScriptablePackExecutor
266
266
  }
267
267
  };
268
268
  worker.on('message', onMessage);
269
- worker.once('error', rejectLoad);
269
+ const fail = (error: Error): void => {
270
+ rejectLoad(error);
271
+ const active = this.build;
272
+ this.build = undefined;
273
+ active?.reject(error);
274
+ void this.dispose('failure');
275
+ };
276
+ worker.once('error', fail);
270
277
  worker.once('exit', (code) => {
271
- if (code !== 0) rejectLoad(new Error(`ScriptablePack worker exited with code ${code}`));
278
+ if (this.disposal !== undefined) return;
279
+ fail(new Error(`ScriptablePack worker exited with code ${code}`));
272
280
  });
273
281
  });
274
282
  }
@@ -393,7 +401,7 @@ class ReusableWorkerScriptablePackExecutor
393
401
  void this.dispose('failure');
394
402
  });
395
403
  this.worker.on('exit', (code) => {
396
- if (code === 0 || this.disposal !== undefined) return;
404
+ if (this.disposal !== undefined) return;
397
405
  this.failed = true;
398
406
  this.rejectActive(new Error(`ScriptablePack worker exited with code ${code}`));
399
407
  void this.dispose('failure');
@@ -215,7 +215,14 @@ function compileModuleClosure(entryPath: string, outputRoot: string, loadId?: nu
215
215
  if (errors.length > 0) {
216
216
  throw new Error(
217
217
  `ScriptablePack TypeScript transpile failed for ${path}: ${errors
218
- .map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'))
218
+ .map((diagnostic) => {
219
+ const position = diagnostic.file?.getLineAndCharacterOfPosition(diagnostic.start ?? 0);
220
+ const location =
221
+ position === undefined
222
+ ? path
223
+ : `${path}:${position.line + 1}:${position.character + 1}`;
224
+ return `${location}: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')}`;
225
+ })
219
226
  .join('; ')}`,
220
227
  );
221
228
  }