@forgeax/engine-pack 0.1.27 → 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 (52) hide show
  1. package/README.md +30 -17
  2. package/dist/build.mjs +35 -52
  3. package/dist/build.mjs.map +1 -1
  4. package/dist/cli-asset.d.ts +1 -1
  5. package/dist/cli-asset.d.ts.map +1 -1
  6. package/dist/cli-asset.mjs +40 -33
  7. package/dist/cli-asset.mjs.map +1 -1
  8. package/dist/index.mjs +1 -1
  9. package/dist/index.mjs.map +1 -1
  10. package/dist/inventory/declaration.d.ts +1 -1
  11. package/dist/inventory/declaration.d.ts.map +1 -1
  12. package/dist/inventory/sync.d.ts.map +1 -1
  13. package/dist/native-cooker-registry.d.ts +1 -0
  14. package/dist/native-cooker-registry.d.ts.map +1 -1
  15. package/dist/native-cooker.mjs.map +1 -1
  16. package/dist/pack-authoring-node.mjs +22 -13
  17. package/dist/pack-authoring-node.mjs.map +1 -1
  18. package/dist/runtime.mjs +1 -1
  19. package/dist/runtime.mjs.map +1 -1
  20. package/dist/scanner.mjs +22 -13
  21. package/dist/scanner.mjs.map +1 -1
  22. package/dist/schema-compiled.d.ts.map +1 -1
  23. package/dist/schema.mjs +41 -63
  24. package/dist/schema.mjs.map +1 -1
  25. package/dist/scriptable-pack-node.d.ts.map +1 -1
  26. package/dist/scriptable-pack-node.mjs +11 -3
  27. package/dist/scriptable-pack-node.mjs.map +1 -1
  28. package/dist/scriptable-pack-worker.mjs +5 -1
  29. package/dist/scriptable-pack-worker.mjs.map +1 -1
  30. package/package.json +3 -6
  31. package/schema/meta.schema.json +1 -1
  32. package/src/__tests__/atlas-cli-region-mismatch.test.ts +1 -1
  33. package/src/__tests__/cli.unit.test.ts +1 -1
  34. package/src/__tests__/inventory-schema.test.ts +10 -10
  35. package/src/__tests__/pack-authoring.unit.test.ts +2 -4
  36. package/src/__tests__/pack.unit.test.ts +89 -127
  37. package/src/__tests__/scanner-instance-cycle.test.ts +92 -0
  38. package/src/__tests__/scanner-inventory.contract.test.ts +2 -2
  39. package/src/__tests__/scriptable-pack-diagnostic.integration.test.ts +45 -0
  40. package/src/atlas/run-atlas.ts +3 -3
  41. package/src/atlas/shelf-pack.ts +1 -1
  42. package/src/atlas/upng-ambient.d.ts +1 -1
  43. package/src/cli-asset.ts +22 -23
  44. package/src/inventory/binding.ts +3 -3
  45. package/src/inventory/declaration.ts +10 -48
  46. package/src/inventory/sync.ts +3 -2
  47. package/src/native-cooker-registry.ts +1 -0
  48. package/src/scanner.ts +29 -29
  49. package/src/schema-compiled.ts +40 -79
  50. package/src/scriptable-pack-node.ts +11 -3
  51. package/src/scriptable-pack-worker.ts +8 -1
  52. package/src/__tests__/scanner-mount-cycle.test.ts +0 -232
@@ -0,0 +1,92 @@
1
+ // Scanner dependency-cycle coverage for keyed SceneAsset instances.
2
+
3
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
7
+ import { scan } from '../scanner.js';
8
+
9
+ const GUID_A = 'aa000000-0000-4000-8000-000000000aaa';
10
+ const GUID_B = 'bb000000-0000-4000-8000-000000000bbb';
11
+ const GUID_C = 'cc000000-0000-4000-8000-000000000ccc';
12
+
13
+ function sceneAssetPack(guid: string, source?: string): unknown {
14
+ return {
15
+ schemaVersion: '1.0.0',
16
+ kind: 'internal-text-package',
17
+ assets: [
18
+ {
19
+ guid,
20
+ kind: 'scene',
21
+ refs: source === undefined ? [] : [source],
22
+ payload: {
23
+ kind: 'scene',
24
+ entities:
25
+ source === undefined
26
+ ? { root: { components: {} } }
27
+ : { child: { components: {}, instance: { source: 0 } } },
28
+ },
29
+ },
30
+ ],
31
+ };
32
+ }
33
+
34
+ describe('scanner keyed SceneAsset instance cycle detection', () => {
35
+ let dir: string;
36
+
37
+ beforeEach(async () => {
38
+ dir = await mkdtemp(join(tmpdir(), 'pack-instance-cycle-'));
39
+ });
40
+
41
+ afterEach(async () => {
42
+ await rm(dir, { recursive: true, force: true });
43
+ });
44
+
45
+ it('accepts an acyclic instance chain A -> B -> C', async () => {
46
+ await writeFile(
47
+ join(dir, 'a.pack.json'),
48
+ JSON.stringify(sceneAssetPack(GUID_A, GUID_B)),
49
+ 'utf8',
50
+ );
51
+ await writeFile(
52
+ join(dir, 'b.pack.json'),
53
+ JSON.stringify(sceneAssetPack(GUID_B, GUID_C)),
54
+ 'utf8',
55
+ );
56
+ await writeFile(join(dir, 'c.pack.json'), JSON.stringify(sceneAssetPack(GUID_C)), 'utf8');
57
+ expect((await scan([dir])).ok).toBe(true);
58
+ });
59
+
60
+ it.each([
61
+ ['two', [GUID_A, GUID_B]],
62
+ ['three', [GUID_A, GUID_B, GUID_C]],
63
+ ])('rejects a %s-asset instance cycle', async (_name, cycle) => {
64
+ const [a, b, c] = cycle;
65
+ await writeFile(join(dir, 'a.pack.json'), JSON.stringify(sceneAssetPack(a, b)), 'utf8');
66
+ await writeFile(join(dir, 'b.pack.json'), JSON.stringify(sceneAssetPack(b, c ?? a)), 'utf8');
67
+ if (c !== undefined)
68
+ await writeFile(join(dir, 'c.pack.json'), JSON.stringify(sceneAssetPack(c, a)), 'utf8');
69
+ const result = await scan([dir]);
70
+ expect(result.ok).toBe(false);
71
+ if (result.ok) return;
72
+ expect(result.error.code).toBe('pack-cyclic-reference');
73
+ const detail = result.error.detail as { kind?: string; cycle?: readonly string[] };
74
+ expect(detail.kind).toBe('mount-asset');
75
+ const found = detail.cycle ?? [];
76
+ expect(found[0]).toBe(found[found.length - 1]);
77
+ for (const guid of cycle) expect(found).toContain(guid);
78
+ });
79
+
80
+ it('rejects a self-instance', async () => {
81
+ await writeFile(
82
+ join(dir, 'a.pack.json'),
83
+ JSON.stringify(sceneAssetPack(GUID_A, GUID_A)),
84
+ 'utf8',
85
+ );
86
+ const result = await scan([dir]);
87
+ expect(result.ok).toBe(false);
88
+ if (result.ok) return;
89
+ expect(result.error.code).toBe('pack-cyclic-reference');
90
+ expect((result.error.detail as { kind?: string }).kind).toBe('mount-asset');
91
+ });
92
+ });
@@ -95,7 +95,7 @@ describe('engine-pack scanner inventory contract', () => {
95
95
  default: {
96
96
  schemaVersion: '2.0.0',
97
97
  packageId: scriptablePackageId,
98
- build: () => ({ ok: true, value: { effect: { kind: 'scene', entities: [] } } }),
98
+ build: () => ({ ok: true, value: { effect: { kind: 'scene', entities: {} } } }),
99
99
  },
100
100
  }),
101
101
  },
@@ -143,7 +143,7 @@ describe('engine-pack scanner inventory contract', () => {
143
143
  default: {
144
144
  schemaVersion: '2.0.0',
145
145
  packageId,
146
- build: () => ({ ok: true, value: { effect: { kind: 'scene', entities: [] } } }),
146
+ build: () => ({ ok: true, value: { effect: { kind: 'scene', entities: {} } } }),
147
147
  },
148
148
  }),
149
149
  dispose: async () => {
@@ -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
+ });
@@ -1,4 +1,4 @@
1
- // `forgeax-engine-remote-asset atlas` subcommand backend
1
+ // `forgeax asset atlas` subcommand backend
2
2
  // (feat-20260521-sprite-atlas-animation M5' T-32). Replaces the v1 vite-
3
3
  // plugin idiom (PR #190 deleted `@forgeax/engine-vite-plugin-image`) with
4
4
  // a one-shot Node CLI that reads a glob of PNGs, runs the pure shelf
@@ -7,7 +7,7 @@
7
7
  //
8
8
  // Why CLI not Vite plugin (PR #190 architectural pivot):
9
9
  // - asset importing is producer-side disk work; v2 routes the producer
10
- // through the kubectl-style `forgeax-engine-remote-` plugin bin so the
10
+ // through the unified `forgeax asset atlas` producer so the
11
11
  // same surface composes with `scan` / `lookup` / `verify` (charter P5
12
12
  // producer/consumer split).
13
13
  // - vite plugins reach for runtime-coupled hooks (buildStart / emitFile);
@@ -327,7 +327,7 @@ export async function runAtlas(rest: string[], ctx: AssetCtx): Promise<number> {
327
327
  return emitError(ctx, {
328
328
  code: 'atlas-empty-input',
329
329
  expected:
330
- 'forgeax-engine-remote-asset atlas --input <glob> --name <prefix> [--output <dir>] [--max-atlas-size <n>]',
330
+ 'forgeax asset atlas --input <glob> --name <prefix> [--output <dir>] [--max-atlas-size <n>]',
331
331
  hint: argsOutcome.message,
332
332
  detail: { receivedCount: 0 },
333
333
  });
@@ -1,4 +1,4 @@
1
- // Pure-function shelf packer for the `forgeax-engine-remote-asset atlas`
1
+ // Pure-function shelf packer for the `forgeax asset atlas`
2
2
  // subcommand (feat-20260521-sprite-atlas-animation M5' T-31). Maps a list
3
3
  // of decoded RGBA sprites to a non-overlapping atlas region map +
4
4
  // atlasWidth / atlasHeight envelope.
@@ -1,5 +1,5 @@
1
1
  // Ambient declaration for `upng-js` (PNG decoder/encoder) used by the
2
- // `forgeax-engine-remote-asset atlas` subcommand (run-atlas.ts) and by
2
+ // `forgeax asset atlas` subcommand (run-atlas.ts) and by
3
3
  // its hermetic CLI integration tests under `__tests__/`. The package
4
4
  // ships without bundled `.d.ts`; consumer sites cast through local
5
5
  // interfaces to match the subset they use.
package/src/cli-asset.ts CHANGED
@@ -1,10 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // @forgeax/engine-pack/src/cli-asset — `forgeax-engine-remote-asset`
4
- // plugin bin (feat-20260516-console-dependency-inversion plan-strategy
5
- // section 2.9). Discovered by the base bin via the kubectl 4th-path
6
- // `forgeax-engine-remote-` prefix scanner; subcommands scan / lookup /
7
- // verify operate offline against the pack scanner.
3
+ // @forgeax/engine-pack/src/cli-asset — Pack producer implementation used by
4
+ // DevKit's unified `forgeax asset` command. It is not a package bin.
8
5
  //
9
6
  // stderr contract (plan-strategy section 2.3 weak-contract): every error
10
7
  // path emits a single JSON Lines record carrying `code` / `expected` /
@@ -30,6 +27,10 @@ import { projectScriptablePackMeta } from './pack-authoring.js';
30
27
  import { type ScanSourceDeclaration, scanInventory } from './scanner.js';
31
28
  import { loadScriptablePack } from './scriptable-pack-node.js';
32
29
 
30
+ // The unified DevKit command tree reuses the producer implementation without
31
+ // reviving the historical standalone executable as a second public surface.
32
+ export { runAtlas } from './atlas/run-atlas.js';
33
+
33
34
  export interface PackEntry {
34
35
  readonly guid: string;
35
36
  readonly kind: string;
@@ -153,16 +154,14 @@ export async function scanEntries(
153
154
 
154
155
  function helpBody(): string {
155
156
  return [
156
- 'forgeax-engine-remote-asset — offline pack scanner / lookup / verifier / atlas builder',
157
+ 'forgeax asset — offline pack scanner / lookup / verifier / atlas builder',
157
158
  '',
158
159
  'Usage:',
159
- ' forgeax-engine-remote-asset scan [--roots <dir>]',
160
- ' forgeax-engine-remote-asset lookup <guid>',
161
- ' forgeax-engine-remote-asset verify',
162
- ' forgeax-engine-remote-asset meta <source.pack.ts> --json',
163
- ' forgeax-engine-remote-asset lookup --guid <guid> --project <dir> --catalog <path> --json',
164
- ' forgeax-engine-remote-asset verify --guid <guid> --project <dir> --catalog <path> --json',
165
- ' forgeax-engine-remote-asset atlas --input <glob> --name <prefix> [--output <dir>] [--max-atlas-size <n>]',
160
+ ' forgeax asset list --root <project> --json',
161
+ ' forgeax asset inspect --subject <guid> --root <project> --json',
162
+ ' forgeax asset verify --root <project> --json',
163
+ ' forgeax asset inspect --subject <source.pack.ts> --root <project> --json',
164
+ ' forgeax asset atlas --input <glob> --name <prefix> --root <project> [--output <dir>]',
166
165
  '',
167
166
  'AI recovery commands (host execution remains explicit):',
168
167
  ' inspect [--guid <guid>] read subject, execution, lifecycle, authority, and sourceKey evidence',
@@ -205,7 +204,7 @@ export async function runCliAsset(rest: string[], ctx: AssetCtx): Promise<number
205
204
  return emitError(ctx, {
206
205
  code: 'unknown-subcommand',
207
206
  expected: 'subcommand in {scan, lookup, verify, atlas, meta}',
208
- hint: "run 'forgeax-engine-remote-asset --help' for usage",
207
+ hint: "run 'forgeax help asset' for usage",
209
208
  detail: { subcommand: sub },
210
209
  });
211
210
  }
@@ -227,7 +226,7 @@ async function runMeta(rest: string[], ctx: AssetCtx): Promise<number> {
227
226
  } catch (error) {
228
227
  return emitError(ctx, {
229
228
  code: 'cli-parse-error',
230
- expected: 'forgeax-engine-remote-asset meta <source.pack.ts> --json',
229
+ expected: 'forgeax asset inspect --subject <source.pack.ts> --root <project> --json',
231
230
  hint: 'pass one trusted ScriptablePack module path',
232
231
  detail: { message: error instanceof Error ? error.message : String(error) },
233
232
  });
@@ -277,7 +276,7 @@ async function runRecoveryCommand(
277
276
  return emitError(ctx, {
278
277
  code: 'cli-parse-error',
279
278
  expected: `${operation} [--guid <guid>]`,
280
- hint: "run 'forgeax-engine-remote-asset --help' for usage",
279
+ hint: "run 'forgeax help asset' for usage",
281
280
  detail: { message: error instanceof Error ? error.message : String(error) },
282
281
  });
283
282
  }
@@ -297,8 +296,8 @@ async function runScan(rest: string[], ctx: AssetCtx): Promise<number> {
297
296
  const message = e instanceof Error ? e.message : String(e);
298
297
  return emitError(ctx, {
299
298
  code: 'cli-parse-error',
300
- expected: 'forgeax-engine-remote-asset scan [--roots <dir>]',
301
- hint: "run 'forgeax-engine-remote-asset --help' for usage",
299
+ expected: 'forgeax asset list --root <project> --json',
300
+ hint: "run 'forgeax help asset' for usage",
302
301
  detail: { message },
303
302
  });
304
303
  }
@@ -314,7 +313,7 @@ async function runLookup(rest: string[], ctx: AssetCtx): Promise<number> {
314
313
  if (typeof guid !== 'string') {
315
314
  return emitError(ctx, {
316
315
  code: 'cli-parse-error',
317
- expected: 'forgeax-engine-remote-asset lookup <36-char-uuid>',
316
+ expected: 'forgeax asset inspect --subject <guid> --root <project> --json',
318
317
  hint: 'pass a 36-char dash-form UUID positional argument',
319
318
  });
320
319
  }
@@ -356,8 +355,8 @@ async function runVerify(rest: string[], ctx: AssetCtx): Promise<number> {
356
355
  const message = e instanceof Error ? e.message : String(e);
357
356
  return emitError(ctx, {
358
357
  code: 'cli-parse-error',
359
- expected: 'forgeax-engine-remote-asset verify',
360
- hint: "run 'forgeax-engine-remote-asset --help' for usage",
358
+ expected: 'forgeax asset verify --root <project> --json',
359
+ hint: "run 'forgeax help asset' for usage",
361
360
  detail: { message },
362
361
  });
363
362
  }
@@ -397,7 +396,7 @@ function parseEvidenceOptions(rest: string[], ctx: AssetCtx): EvidenceCliOptions
397
396
  emitError(ctx, {
398
397
  code: 'cli-parse-error',
399
398
  expected: '--guid <guid> --project <dir> --catalog <path> --json',
400
- hint: "run 'forgeax-engine-remote-asset --help' for usage",
399
+ hint: "run 'forgeax help asset' for usage",
401
400
  });
402
401
  return undefined;
403
402
  }
@@ -407,7 +406,7 @@ function parseEvidenceOptions(rest: string[], ctx: AssetCtx): EvidenceCliOptions
407
406
  emitError(ctx, {
408
407
  code: 'cli-parse-error',
409
408
  expected: '--guid <guid> --project <dir> --catalog <path> --json',
410
- hint: "run 'forgeax-engine-remote-asset --help' for usage",
409
+ hint: "run 'forgeax help asset' for usage",
411
410
  detail: { message },
412
411
  });
413
412
  return undefined;
@@ -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
@@ -184,7 +184,7 @@ interface ScanCapture {
184
184
  * Requirements §3.4 + §5 blacklist.
185
185
  *
186
186
  * Re-exported as `SCANNER_BLACKLIST` for cross-package reuse: the
187
- * `forgeax-engine-remote-asset import --check` traversal (M4 / w21 +
187
+ * `forgeax asset import --check` traversal (M4 / w21 +
188
188
  * plan-strategy section 2.8 path b) walks the same set of source-orphan
189
189
  * candidates as the scanner, so we share the single SSOT here.
190
190
  */
@@ -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, {