@forgeax/engine-pack 0.1.20 → 0.1.23

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 (128) hide show
  1. package/README.md +159 -46
  2. package/dist/__tests__/inventory-schema.test.d.ts +2 -0
  3. package/dist/__tests__/inventory-schema.test.d.ts.map +1 -0
  4. package/dist/__tests__/parameterized-pack-authoring-gateway.unit.test.d.ts +2 -0
  5. package/dist/__tests__/parameterized-pack-authoring-gateway.unit.test.d.ts.map +1 -0
  6. package/dist/__tests__/parameterized-pack-scanner.unit.test.d.ts +2 -0
  7. package/dist/__tests__/parameterized-pack-scanner.unit.test.d.ts.map +1 -0
  8. package/dist/__tests__/parameterized-pack.unit.test.d.ts +2 -0
  9. package/dist/__tests__/parameterized-pack.unit.test.d.ts.map +1 -0
  10. package/dist/__tests__/scanner-blacklist.test.d.ts +2 -0
  11. package/dist/__tests__/scanner-blacklist.test.d.ts.map +1 -0
  12. package/dist/build.d.ts +9 -4
  13. package/dist/build.d.ts.map +1 -1
  14. package/dist/build.mjs +3317 -804
  15. package/dist/build.mjs.map +1 -1
  16. package/dist/builtin.mjs +37 -4
  17. package/dist/builtin.mjs.map +1 -1
  18. package/dist/catalog-builder.d.ts.map +1 -1
  19. package/dist/cli-asset.d.ts.map +1 -1
  20. package/dist/cli-asset.mjs +1368 -850
  21. package/dist/cli-asset.mjs.map +1 -1
  22. package/dist/deriveAssetName.d.ts +11 -7
  23. package/dist/deriveAssetName.d.ts.map +1 -1
  24. package/dist/evidence/material-cook.d.ts.map +1 -1
  25. package/dist/evidence/source-inventory.d.ts.map +1 -1
  26. package/dist/guid.d.ts +15 -0
  27. package/dist/guid.d.ts.map +1 -1
  28. package/dist/guid.mjs +53 -5
  29. package/dist/guid.mjs.map +1 -1
  30. package/dist/index.d.ts +3 -1
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.mjs +1255 -561
  33. package/dist/index.mjs.map +1 -1
  34. package/dist/inventory/binding.d.ts +6 -0
  35. package/dist/inventory/binding.d.ts.map +1 -0
  36. package/dist/inventory/declaration.d.ts +24 -0
  37. package/dist/inventory/declaration.d.ts.map +1 -0
  38. package/dist/inventory/index.d.ts +3 -0
  39. package/dist/inventory/index.d.ts.map +1 -0
  40. package/dist/inventory/sync.d.ts +5 -0
  41. package/dist/inventory/sync.d.ts.map +1 -0
  42. package/dist/material-cook.mjs +3 -1
  43. package/dist/material-cook.mjs.map +1 -1
  44. package/dist/name.mjs +4 -7
  45. package/dist/name.mjs.map +1 -1
  46. package/dist/package-finalizer.d.ts +2 -20
  47. package/dist/package-finalizer.d.ts.map +1 -1
  48. package/dist/parameterized-pack-node.d.ts +26 -0
  49. package/dist/parameterized-pack-node.d.ts.map +1 -0
  50. package/dist/parameterized-pack-node.mjs +11093 -0
  51. package/dist/parameterized-pack-node.mjs.map +1 -0
  52. package/dist/parameterized-pack.d.ts +233 -0
  53. package/dist/parameterized-pack.d.ts.map +1 -0
  54. package/dist/resolve-asset-source.d.ts +1 -3
  55. package/dist/resolve-asset-source.d.ts.map +1 -1
  56. package/dist/resolve-asset-source.mjs +6 -81
  57. package/dist/resolve-asset-source.mjs.map +1 -1
  58. package/dist/runtime.mjs +77 -7
  59. package/dist/runtime.mjs.map +1 -1
  60. package/dist/scanner.d.ts +19 -8
  61. package/dist/scanner.d.ts.map +1 -1
  62. package/dist/scanner.mjs +1169 -690
  63. package/dist/scanner.mjs.map +1 -1
  64. package/dist/schema-compiled.d.ts.map +1 -1
  65. package/dist/schema.mjs +79 -7
  66. package/dist/schema.mjs.map +1 -1
  67. package/dist/scriptable-pack-node.d.ts +8 -32
  68. package/dist/scriptable-pack-node.d.ts.map +1 -1
  69. package/dist/scriptable-pack-node.mjs +576 -1021
  70. package/dist/scriptable-pack-node.mjs.map +1 -1
  71. package/dist/scriptable-pack-worker.mjs +29 -11
  72. package/dist/scriptable-pack-worker.mjs.map +1 -1
  73. package/dist/scriptable-pack.d.ts +19 -532
  74. package/dist/scriptable-pack.d.ts.map +1 -1
  75. package/dist/scriptable-pack.mjs +1042 -527
  76. package/dist/scriptable-pack.mjs.map +1 -1
  77. package/package.json +7 -7
  78. package/schema/meta.schema.json +1 -1
  79. package/schema/pack.schema.json +76 -6
  80. package/src/__tests__/guid-collision.unit.test.ts +18 -9
  81. package/src/__tests__/inventory-schema.test.ts +109 -0
  82. package/src/__tests__/pack.unit.test.ts +1 -238
  83. package/src/__tests__/package-finalizer.contract.test.ts +55 -0
  84. package/src/__tests__/parameterized-pack-authoring-gateway.unit.test.ts +249 -0
  85. package/src/__tests__/parameterized-pack-scanner.unit.test.ts +123 -0
  86. package/src/__tests__/parameterized-pack.unit.test.ts +266 -0
  87. package/src/__tests__/resolve-asset-source.test.ts +15 -98
  88. package/src/__tests__/scanner-blacklist.test.ts +55 -0
  89. package/src/__tests__/scanner-inventory.contract.test.ts +14 -15
  90. package/src/__tests__/scanner-inventory.test.ts +24 -12
  91. package/src/__tests__/scriptable-pack-cli.integration.test.ts +4 -9
  92. package/src/__tests__/topology.unit.test.ts +23 -0
  93. package/src/build.ts +28 -18
  94. package/src/catalog-builder.ts +61 -34
  95. package/src/cli-asset.ts +70 -85
  96. package/src/deriveAssetName.ts +14 -13
  97. package/src/evidence/material-cook.ts +3 -1
  98. package/src/evidence/source-inventory.ts +34 -21
  99. package/src/guid.ts +65 -4
  100. package/src/index.ts +12 -11
  101. package/src/inventory/binding.ts +25 -0
  102. package/src/inventory/declaration.ts +168 -0
  103. package/src/inventory/index.ts +18 -0
  104. package/src/inventory/sync.ts +22 -0
  105. package/src/package-finalizer.ts +2 -42
  106. package/src/parameterized-pack-node.ts +1968 -0
  107. package/src/parameterized-pack.ts +1493 -0
  108. package/src/resolve-asset-source.ts +4 -76
  109. package/src/scanner.ts +241 -59
  110. package/src/schema-compiled.ts +2 -0
  111. package/src/scriptable-pack-node.ts +111 -722
  112. package/src/scriptable-pack-worker.ts +48 -16
  113. package/src/scriptable-pack.ts +27 -939
  114. package/dist/.tsbuildinfo +0 -1
  115. package/dist/__tests__/load-asset-config.test.d.ts +0 -2
  116. package/dist/__tests__/load-asset-config.test.d.ts.map +0 -1
  117. package/dist/__tests__/scriptable-pack.test-d.d.ts +0 -2
  118. package/dist/__tests__/scriptable-pack.test-d.d.ts.map +0 -1
  119. package/dist/__tests__/scriptable-pack.unit.test.d.ts +0 -2
  120. package/dist/__tests__/scriptable-pack.unit.test.d.ts.map +0 -1
  121. package/dist/config.d.ts +0 -6
  122. package/dist/config.d.ts.map +0 -1
  123. package/dist/config.mjs +0 -29
  124. package/dist/config.mjs.map +0 -1
  125. package/src/__tests__/load-asset-config.test.ts +0 -121
  126. package/src/__tests__/scriptable-pack.test-d.ts +0 -131
  127. package/src/__tests__/scriptable-pack.unit.test.ts +0 -789
  128. package/src/config.ts +0 -36
@@ -0,0 +1,123 @@
1
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { afterEach, describe, expect, it } from 'vitest';
5
+ import { scanEntries } from '../cli-asset.js';
6
+ import { scanInventory } from '../scanner.js';
7
+
8
+ const SOURCE_PACKAGE = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
9
+ const INSTANCE_PACKAGE = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
10
+ const DIRECT_PACKAGE = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
11
+
12
+ const roots: string[] = [];
13
+
14
+ afterEach(async () => {
15
+ const root = roots.pop();
16
+ if (root !== undefined) await rm(root, { recursive: true, force: true });
17
+ });
18
+
19
+ describe('parameterized Pack scanner', () => {
20
+ it('indexes direct v3 output keys as derived GUIDs without requiring entry GUIDs', async () => {
21
+ const root = await mkdtemp(join(tmpdir(), 'forgeax-parameterized-scan-'));
22
+ roots.push(root);
23
+ const path = join(root, 'direct.pack.json');
24
+ await writeFile(
25
+ path,
26
+ JSON.stringify({
27
+ schemaVersion: '3.0.0',
28
+ packageId: DIRECT_PACKAGE,
29
+ assets: {
30
+ 'scene/main': {
31
+ kind: 'scene',
32
+ payload: { kind: 'scene', nodes: [] },
33
+ refs: [],
34
+ },
35
+ },
36
+ }),
37
+ );
38
+
39
+ const result = await scanInventory([root]);
40
+
41
+ expect(result.ok).toBe(true);
42
+ if (!result.ok) return;
43
+ expect(result.value.inventory).toMatchObject([
44
+ {
45
+ kind: 'scene',
46
+ sourcePath: path,
47
+ sourceKey: 'scene/main',
48
+ sourceIndex: 0,
49
+ },
50
+ ]);
51
+ expect(result.value.inventory[0]?.guid).toMatch(
52
+ /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
53
+ );
54
+
55
+ const stdout: string[] = [];
56
+ const stderr: string[] = [];
57
+ const listed = await scanEntries([root], {
58
+ stdoutWrite: (line) => stdout.push(line),
59
+ stderrWrite: (line) => stderr.push(line),
60
+ });
61
+ expect(listed).toMatchObject({
62
+ ok: true,
63
+ value: [{ guid: result.value.inventory[0]?.guid, name: 'scene/main' }],
64
+ });
65
+ expect(stderr).toEqual([]);
66
+ });
67
+
68
+ it('captures a parameterized source as metadata and resolves an instance parent', async () => {
69
+ const root = await mkdtemp(join(tmpdir(), 'forgeax-parameterized-source-scan-'));
70
+ roots.push(root);
71
+ await writeFile(
72
+ join(root, 'source.pack.ts'),
73
+ [
74
+ 'const packageId = new Uint8Array(16);',
75
+ 'packageId.set([170, 170, 170, 170, 170, 170, 74, 170, 138, 170, 170, 170, 170, 170, 170, 170]);',
76
+ 'export default {',
77
+ " schemaVersion: '2.0.0',",
78
+ ' packageId,',
79
+ " parameters: [{ name: 'count', type: 'u32', default: 1 }],",
80
+ " build: ({ values }) => ({ ok: true, value: { ['mesh/' + values.count]: { kind: 'mesh', materialSlots: [] } } }),",
81
+ '};',
82
+ ].join('\n'),
83
+ );
84
+ await writeFile(
85
+ join(root, 'instance.pack.json'),
86
+ JSON.stringify({
87
+ schemaVersion: '3.0.0',
88
+ packageId: INSTANCE_PACKAGE,
89
+ parent: SOURCE_PACKAGE,
90
+ values: { count: 2 },
91
+ }),
92
+ );
93
+
94
+ const result = await scanInventory([root]);
95
+
96
+ expect(result.ok).toBe(true);
97
+ if (!result.ok) return;
98
+ const declaration = result.value.declarations.get(join(root, 'source.pack.ts'));
99
+ expect(declaration?.format).toBe('pack.ts-parameterized');
100
+ expect(result.value.inventory).toEqual([]);
101
+ });
102
+
103
+ it('fails closed when an instance parent is missing', async () => {
104
+ const root = await mkdtemp(join(tmpdir(), 'forgeax-parameterized-parent-scan-'));
105
+ roots.push(root);
106
+ await writeFile(
107
+ join(root, 'missing.pack.json'),
108
+ JSON.stringify({
109
+ schemaVersion: '3.0.0',
110
+ packageId: INSTANCE_PACKAGE,
111
+ parent: SOURCE_PACKAGE,
112
+ values: { count: 2 },
113
+ }),
114
+ );
115
+
116
+ const result = await scanInventory([root]);
117
+
118
+ expect(result.ok).toBe(false);
119
+ if (result.ok) return;
120
+ expect(result.error.code).toBe('pack-malformed-pack');
121
+ expect(result.error.detail).toMatchObject({ reason: 'pack-parent-not-found' });
122
+ });
123
+ });
@@ -0,0 +1,266 @@
1
+ import { ok } from '@forgeax/engine-types';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { AssetGuid, PackageId } from '../guid.js';
4
+ import {
5
+ definePack,
6
+ definePackageId,
7
+ type PackParameterInheritanceSubject,
8
+ parseParameterizedPackJson,
9
+ projectDirectPackJson,
10
+ resolvePackParameterInheritance,
11
+ resolvePackParameterValues,
12
+ validatePackDefinition,
13
+ } from '../parameterized-pack.js';
14
+
15
+ const NAMESPACE = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
16
+
17
+ function packageId(value: string) {
18
+ const result = PackageId.parse(value);
19
+ if (!result.ok) throw result.error;
20
+ return result.value;
21
+ }
22
+
23
+ describe('parameterized Pack authoring', () => {
24
+ it('derives the RFC 4122 UUIDv5 vector and keeps PackageId branded separately', () => {
25
+ const namespace = packageId(NAMESPACE);
26
+ expect(AssetGuid.format(AssetGuid.derive(namespace, 'www.widgets.com'))).toBe(
27
+ '21f7f8de-8051-5b89-8680-0195ef798b6a',
28
+ );
29
+ expect(PackageId.format(namespace)).toBe(NAMESPACE);
30
+ expect(AssetGuid.derive(namespace, 'mesh/a')).not.toBe(namespace);
31
+ });
32
+
33
+ it('derives an explicit context shape for zero-parameter and parameterized Packs', async () => {
34
+ const zero = definePack({
35
+ schemaVersion: '2.0.0',
36
+ packageId: definePackageId('01900000-0000-7000-8000-000000000001'),
37
+ build: ({ packageId: subjectId }) =>
38
+ ok({
39
+ 'scene/main': {
40
+ kind: 'scene',
41
+ entities: [],
42
+ mounts: [],
43
+ source: AssetGuid.format(AssetGuid.derive(subjectId, 'mesh/main')),
44
+ },
45
+ }),
46
+ });
47
+ expect('parameters' in zero).toBe(false);
48
+ const zeroResult = await zero.build({
49
+ packageId: zero.packageId,
50
+ readByGuid: async () => ok(null as never),
51
+ });
52
+ expect(zeroResult.ok).toBe(true);
53
+
54
+ const parameterized = definePack({
55
+ schemaVersion: '2.0.0',
56
+ packageId: definePackageId('01900000-0000-7000-8000-000000000002'),
57
+ parameters: [
58
+ { name: 'segments', type: 'u32', default: 12, minimum: 3, maximum: 128 },
59
+ { name: 'style', type: 'enum', values: ['oak', 'pine'], default: 'oak' },
60
+ ] as const,
61
+ build: ({ values }) =>
62
+ ok({
63
+ 'scene/main': {
64
+ kind: 'scene',
65
+ entities: values.segments >= 0 ? [] : [],
66
+ mounts: [],
67
+ },
68
+ }),
69
+ });
70
+ const values = resolvePackParameterValues(parameterized, { segments: 24 });
71
+ expect(values).toMatchObject({ ok: true, value: { segments: 24, style: 'oak' } });
72
+ expect(resolvePackParameterValues(parameterized, { unknown: true })).toMatchObject({
73
+ ok: false,
74
+ error: { code: 'pack-parameter-invalid' },
75
+ });
76
+
77
+ const bounded = definePack({
78
+ schemaVersion: '2.0.0',
79
+ packageId: definePackageId('01900000-0000-7000-8000-000000000003'),
80
+ parameters: [
81
+ { name: 'unsigned', type: 'u32', default: 0 },
82
+ { name: 'signed', type: 'i32', default: 0 },
83
+ { name: 'float', type: 'f32', default: 0 },
84
+ ] as const,
85
+ build: () => ok({}),
86
+ });
87
+ expect(resolvePackParameterValues(bounded, { unsigned: 0x1_0000_0000 })).toMatchObject({
88
+ ok: false,
89
+ error: { code: 'pack-parameter-invalid' },
90
+ });
91
+ expect(resolvePackParameterValues(bounded, { signed: 0x8000_0000 })).toMatchObject({
92
+ ok: false,
93
+ error: { code: 'pack-parameter-invalid' },
94
+ });
95
+ expect(resolvePackParameterValues(bounded, { float: Number.MAX_VALUE })).toMatchObject({
96
+ ok: false,
97
+ error: { code: 'pack-parameter-invalid' },
98
+ });
99
+ });
100
+
101
+ it('validates and preserves optional scene component schemas without exposing output identity', () => {
102
+ const definition = definePack({
103
+ schemaVersion: '2.0.0',
104
+ packageId: definePackageId('01900000-0000-7000-8000-000000000004'),
105
+ sceneComponents: [
106
+ { name: 'Transform', fields: { pos: { type: 'vec3' }, quat: 'quat' } },
107
+ { name: 'Name', fields: { value: 'string' } },
108
+ ],
109
+ build: () => ok({}),
110
+ });
111
+ expect(definition.sceneComponents).toEqual([
112
+ { name: 'Transform', fields: { pos: { type: 'vec3' }, quat: 'quat' } },
113
+ { name: 'Name', fields: { value: 'string' } },
114
+ ]);
115
+ expect(Object.isFrozen(definition.sceneComponents)).toBe(true);
116
+ expect(Object.isFrozen(definition.sceneComponents?.[0])).toBe(true);
117
+ expect(Object.isFrozen(definition.sceneComponents?.[0]?.fields)).toBe(true);
118
+
119
+ const duplicate = validatePackDefinition({
120
+ schemaVersion: '2.0.0',
121
+ packageId: definePackageId('01900000-0000-7000-8000-000000000005'),
122
+ sceneComponents: [
123
+ { name: 'Transform', fields: {} },
124
+ { name: 'Transform', fields: {} },
125
+ ],
126
+ build: () => ok({}),
127
+ });
128
+ expect(duplicate).toMatchObject({
129
+ ok: false,
130
+ error: {
131
+ code: 'pack-parameter-invalid',
132
+ detail: { reason: 'scene component names must be unique' },
133
+ },
134
+ });
135
+ const malformed = validatePackDefinition({
136
+ schemaVersion: '2.0.0',
137
+ packageId: definePackageId('01900000-0000-7000-8000-000000000006'),
138
+ sceneComponents: [{ name: 'Transform', fields: { pos: { type: '' } } }],
139
+ build: () => ok({}),
140
+ });
141
+ expect(malformed).toMatchObject({
142
+ ok: false,
143
+ error: {
144
+ code: 'pack-parameter-invalid',
145
+ detail: { reason: 'scene component field schema is malformed' },
146
+ },
147
+ });
148
+ });
149
+
150
+ it('parses direct and instance JSON without accepting GUID fields', () => {
151
+ const direct = parseParameterizedPackJson({
152
+ schemaVersion: '3.0.0',
153
+ packageId: '01900000-0000-7000-8000-000000000010',
154
+ assets: {
155
+ 'scene/main': { kind: 'scene', payload: {}, refs: [] },
156
+ },
157
+ });
158
+ expect(direct.ok).toBe(true);
159
+ if (direct.ok && direct.value.format === 'direct') {
160
+ const projected = projectDirectPackJson(direct.value);
161
+ expect(projected).toMatchObject({
162
+ ok: true,
163
+ value: {
164
+ packageId: '01900000-0000-7000-8000-000000000010',
165
+ assets: [{ sourceKey: 'scene/main', guid: expect.any(String) }],
166
+ },
167
+ });
168
+ }
169
+ expect(
170
+ parseParameterizedPackJson({
171
+ schemaVersion: '3.0.0',
172
+ packageId: '01900000-0000-7000-8000-000000000010',
173
+ assets: { 'scene/main': { guid: NAMESPACE, kind: 'scene', payload: {}, refs: [] } },
174
+ }),
175
+ ).toMatchObject({ ok: false, error: { code: 'pack-parameter-invalid' } });
176
+ expect(
177
+ parseParameterizedPackJson({
178
+ schemaVersion: '3.0.0',
179
+ packageId: '01900000-0000-7000-8000-000000000020',
180
+ parent: '01900000-0000-7000-8000-000000000002',
181
+ values: { segments: 24 },
182
+ }),
183
+ ).toMatchObject({ ok: true, value: { format: 'instance' } });
184
+ });
185
+
186
+ it('rejects legacy static output fields in the v2 source definition', () => {
187
+ expect(() =>
188
+ definePack({
189
+ schemaVersion: '2.0.0',
190
+ packageId: definePackageId('01900000-0000-7000-8000-000000000021'),
191
+ assets: [],
192
+ build: () => ok({}),
193
+ } as never),
194
+ ).toThrow(/repair the parameter declaration|authoring/);
195
+ expect(() =>
196
+ definePack({
197
+ schemaVersion: '2.0.0',
198
+ packageId: definePackageId('01900000-0000-7000-8000-000000000022'),
199
+ externalAssets: [],
200
+ build: () => ok({}),
201
+ } as never),
202
+ ).toThrow();
203
+ });
204
+
205
+ it('rejects unknown parameter descriptor fields instead of carrying a second schema', () => {
206
+ expect(() =>
207
+ definePack({
208
+ schemaVersion: '2.0.0',
209
+ packageId: definePackageId('01900000-0000-7000-8000-000000000023'),
210
+ parameters: [{ name: 'count', type: 'u32', default: 1, editor: 'slider' }] as never,
211
+ build: () => ok({}),
212
+ }),
213
+ ).toThrow();
214
+ });
215
+
216
+ it('resolves finite shallow inheritance and reports missing parent and cycle', async () => {
217
+ const rootId = packageId('01900000-0000-7000-8000-000000000030');
218
+ const middleId = packageId('01900000-0000-7000-8000-000000000031');
219
+ const leafId = packageId('01900000-0000-7000-8000-000000000032');
220
+ const root: PackParameterInheritanceSubject = {
221
+ format: 'source',
222
+ packageId: rootId,
223
+ parameters: [{ name: 'segments', type: 'u32', default: 12, minimum: 3 }],
224
+ };
225
+ const middle: PackParameterInheritanceSubject = {
226
+ format: 'instance',
227
+ packageId: middleId,
228
+ parent: rootId,
229
+ values: { segments: 24 },
230
+ };
231
+ const leaf: PackParameterInheritanceSubject = {
232
+ format: 'instance',
233
+ packageId: leafId,
234
+ parent: middleId,
235
+ values: {},
236
+ };
237
+ const subjects = new Map<string, PackParameterInheritanceSubject>([
238
+ [PackageId.format(rootId), root],
239
+ [PackageId.format(middleId), middle],
240
+ ]);
241
+ const resolved = await resolvePackParameterInheritance(leaf, (id) =>
242
+ subjects.get(PackageId.format(id)),
243
+ );
244
+ expect(resolved).toMatchObject({
245
+ ok: true,
246
+ value: {
247
+ values: { segments: 24 },
248
+ parentChain: [PackageId.format(middleId), PackageId.format(rootId)],
249
+ },
250
+ });
251
+
252
+ const missing = await resolvePackParameterInheritance(leaf, () => undefined);
253
+ expect(missing).toMatchObject({ ok: false, error: { code: 'pack-parent-not-found' } });
254
+
255
+ const cycleA: PackParameterInheritanceSubject = {
256
+ format: 'instance',
257
+ packageId: middleId,
258
+ parent: leafId,
259
+ values: {},
260
+ };
261
+ const cycle = await resolvePackParameterInheritance(cycleA, (id) =>
262
+ PackageId.format(id) === PackageId.format(leafId) ? leaf : cycleA,
263
+ );
264
+ expect(cycle).toMatchObject({ ok: false, error: { code: 'pack-parent-cycle' } });
265
+ });
266
+ });
@@ -1,31 +1,16 @@
1
- // @ts-nocheck — PackErrorDetail is a discriminated union narrowed by runtime .code
2
- // checks; TS cannot follow the narrowing through PackError.detail.
3
1
  import { resolve } from 'node:path';
4
2
  import { describe, expect, it } from 'vitest';
5
3
  import { resolveAssetSource } from '../resolve-asset-source.js';
6
4
 
7
- describe('resolveAssetSource — omit derivation (w4)', () => {
5
+ describe('resolveAssetSource — companion and sidecar-relative sources', () => {
8
6
  const metaDir = '/project/assets';
9
7
 
10
- it('omitted source derives companion file from meta filename (AC-2)', () => {
8
+ it('derives an omitted source from the companion filename', () => {
11
9
  const metaPath = resolve(metaDir, 'foo.png.meta.json');
12
- const result = resolveAssetSource(metaPath, undefined, {});
13
- expect(result.ok).toBe(true);
14
- if (result.ok) {
15
- expect(result.value).toBe(resolve(metaDir, 'foo.png'));
16
- }
17
- });
18
-
19
- it('renamed audio meta derives correct companion (AC-3)', () => {
20
- const metaPath = resolve(metaDir, 'bleep.wav.meta.json');
21
- const result = resolveAssetSource(metaPath, undefined, {});
22
- expect(result.ok).toBe(true);
23
- if (result.ok) {
24
- expect(result.value).toBe(resolve(metaDir, 'bleep.wav'));
25
- }
10
+ expect(resolveAssetSource(metaPath, undefined)).toBe(resolve(metaDir, 'foo.png'));
26
11
  });
27
12
 
28
- it('derivation is a single rule: strip .meta.json suffix only', () => {
13
+ it('supports every ordinary companion extension without a special table', () => {
29
14
  const cases = [
30
15
  { meta: 'model.glb.meta.json', expected: 'model.glb' },
31
16
  { meta: 'sprite.png.meta.json', expected: 'sprite.png' },
@@ -33,89 +18,21 @@ describe('resolveAssetSource — omit derivation (w4)', () => {
33
18
  { meta: 'bgm.mp3.meta.json', expected: 'bgm.mp3' },
34
19
  ];
35
20
  for (const { meta, expected } of cases) {
36
- const metaPath = resolve(metaDir, meta);
37
- const result = resolveAssetSource(metaPath, undefined, {});
38
- expect(result.ok).toBe(true);
39
- if (result.ok) {
40
- expect(result.value).toBe(resolve(metaDir, expected));
41
- }
42
- }
43
- });
44
- });
45
-
46
- describe('resolveAssetSource — @name/ path + boundary + legacy (w5)', () => {
47
- const metaDir = '/project/assets';
48
- const metaPath = resolve(metaDir, 'foo.png.meta.json');
49
-
50
- it('resolves @name/rest to absolute path via paths table (AC-5)', () => {
51
- const paths = { shared: '/lib/assets' };
52
- const result = resolveAssetSource(metaPath, '@shared/foo.png', paths);
53
- expect(result.ok).toBe(true);
54
- if (result.ok) {
55
- expect(result.value).toBe(resolve('/lib/assets', 'foo.png'));
56
- }
57
- });
58
-
59
- it('returns pack-unknown-path for undeclared @name (AC-8)', () => {
60
- const paths = { shared: '/lib/assets' };
61
- const result = resolveAssetSource(metaPath, '@nope/x.png', paths);
62
- expect(result.ok).toBe(false);
63
- if (!result.ok) {
64
- expect(result.error.code).toBe('pack-unknown-path');
65
- expect(result.error.detail.pathName).toBe('nope');
66
- expect(result.error.detail.knownNames).toEqual(['shared']);
21
+ expect(resolveAssetSource(resolve(metaDir, meta), undefined)).toBe(
22
+ resolve(metaDir, expected),
23
+ );
67
24
  }
68
25
  });
69
26
 
70
- it('returns pack-malformed-path-ref for bare @ with no name or rest (AC-9)', () => {
71
- const result = resolveAssetSource(metaPath, '@', { shared: '/lib/assets' });
72
- expect(result.ok).toBe(false);
73
- if (!result.ok) {
74
- expect(result.error.code).toBe('pack-malformed-path-ref');
75
- expect(result.error.detail.rawSource).toBe('@');
76
- if (result.error.detail.code === 'pack-malformed-path-ref') {
77
- expect(result.error.detail.reason).toBe('format');
78
- }
79
- }
80
- });
81
-
82
- it('returns pack-malformed-path-ref for @onlyname with no /rest (AC-9)', () => {
83
- const result = resolveAssetSource(metaPath, '@onlyname', { shared: '/lib/assets' });
84
- expect(result.ok).toBe(false);
85
- if (!result.ok) {
86
- expect(result.error.code).toBe('pack-malformed-path-ref');
87
- expect(result.error.detail.rawSource).toBe('@onlyname');
88
- }
89
- });
90
-
91
- it('returns pack-malformed-path-ref for ../ escape (AC-11)', () => {
92
- const paths = { shared: '/data/shared' };
93
- const result = resolveAssetSource(metaPath, '@shared/../escape.png', paths);
94
- expect(result.ok).toBe(false);
95
- if (!result.ok) {
96
- expect(result.error.code).toBe('pack-malformed-path-ref');
97
- expect(result.error.detail.rawSource).toBe('@shared/../escape.png');
98
- if (result.error.detail.code === 'pack-malformed-path-ref') {
99
- expect(result.error.detail.reason).toBe('escape');
100
- }
101
- }
102
- });
103
-
104
- it('resolves legacy relative path unchanged', () => {
105
- const result = resolveAssetSource(metaPath, 'sub/foo.png', {});
106
- expect(result.ok).toBe(true);
107
- if (result.ok) {
108
- expect(result.value).toBe(resolve(metaDir, 'sub/foo.png'));
109
- }
27
+ it('resolves an explicit source relative to its sidecar', () => {
28
+ const metaPath = resolve(metaDir, 'foo.png.meta.json');
29
+ expect(resolveAssetSource(metaPath, 'sub/foo.png')).toBe(resolve(metaDir, 'sub/foo.png'));
110
30
  });
111
31
 
112
- it('returns pack-unknown-path for @name/ when paths is empty', () => {
113
- const result = resolveAssetSource(metaPath, '@any/x.png', {});
114
- expect(result.ok).toBe(false);
115
- if (!result.ok) {
116
- expect(result.error.code).toBe('pack-unknown-path');
117
- expect(result.error.detail.pathName).toBe('any');
118
- expect(result.error.detail.knownNames).toEqual([]);
119
- }
32
+ it('keeps source resolution independent of package.json and path aliases', () => {
33
+ const metaPath = resolve(metaDir, 'foo.png.meta.json');
34
+ expect(resolveAssetSource(metaPath, '@shared/foo.png')).toBe(
35
+ resolve(metaDir, '@shared/foo.png'),
36
+ );
120
37
  });
121
38
  });
@@ -0,0 +1,55 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { afterEach, describe, expect, it } from 'vitest';
5
+ import { scan } from '../scanner.js';
6
+
7
+ const EMPTY_PACK = JSON.stringify({
8
+ schemaVersion: '1.0.0',
9
+ kind: 'internal-text-package',
10
+ assets: [],
11
+ });
12
+
13
+ describe('asset scanner test-directory boundary', () => {
14
+ const temporaryRoots: string[] = [];
15
+
16
+ afterEach(async () => {
17
+ await Promise.all(
18
+ temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
19
+ );
20
+ });
21
+
22
+ it('skips arbitrarily nested __tests__ declarations while keeping normal assets', async () => {
23
+ const root = await mkdtemp(join(tmpdir(), 'forgeax-pack-scanner-tests-'));
24
+ temporaryRoots.push(root);
25
+ const visible = join(root, 'world', 'scene.pack.json');
26
+ const nestedFixture = join(root, 'world', '__tests__', 'fixture.pack.json');
27
+ const rootFixture = join(root, '__tests__', 'nested', 'fixture.pack.json');
28
+
29
+ await mkdir(join(root, 'world', '__tests__'), { recursive: true });
30
+ await mkdir(join(root, '__tests__', 'nested'), { recursive: true });
31
+ await writeFile(visible, EMPTY_PACK);
32
+ // Deliberately malformed: a test fixture must not be able to degrade the
33
+ // production scan simply because it lives below the asset root.
34
+ await writeFile(nestedFixture, '{}');
35
+ await writeFile(rootFixture, '{}');
36
+
37
+ const result = await scan([root]);
38
+
39
+ expect(result).toMatchObject({ ok: true, value: [visible] });
40
+ });
41
+
42
+ it('allows an explicit __tests__ root when a test intentionally opts in', async () => {
43
+ const root = await mkdtemp(join(tmpdir(), 'forgeax-pack-scanner-tests-'));
44
+ temporaryRoots.push(root);
45
+ const testRoot = join(root, '__tests__');
46
+ const fixture = join(testRoot, 'fixture.pack.json');
47
+
48
+ await mkdir(testRoot, { recursive: true });
49
+ await writeFile(fixture, EMPTY_PACK);
50
+
51
+ const result = await scan([testRoot]);
52
+
53
+ expect(result).toMatchObject({ ok: true, value: [fixture] });
54
+ });
55
+ });
@@ -88,17 +88,14 @@ describe('engine-pack scanner inventory contract', () => {
88
88
  await writeFile(scriptablePath, '// scanner fixture');
89
89
 
90
90
  const scriptablePackageId = guid('019e3969-1d48-7c3b-ac24-6d68f4570662');
91
- const scriptableAssetGuid = guid('019e3969-1d48-7c3b-ac24-6d68f4570663');
92
91
  const result = await scanInventory([root], {
93
92
  scriptablePack: {
94
93
  executor: {
95
94
  load: async () => ({
96
95
  default: {
97
- schemaVersion: '1.0.0',
96
+ schemaVersion: '2.0.0',
98
97
  packageId: scriptablePackageId,
99
- assets: { effect: { guid: scriptableAssetGuid, kind: 'scene' } },
100
- externalAssets: {},
101
- build: () => ({ ok: true, value: {} }),
98
+ build: () => ({ ok: true, value: { effect: { kind: 'scene', entities: [] } } }),
102
99
  },
103
100
  }),
104
101
  },
@@ -120,17 +117,17 @@ describe('engine-pack scanner inventory contract', () => {
120
117
  });
121
118
  const scriptableDeclaration = result.value.declarations.get(scriptablePath);
122
119
  expect(scriptableDeclaration).toMatchObject({
123
- format: 'pack.ts',
120
+ format: 'pack.ts-parameterized',
124
121
  sourcePath: scriptablePath,
125
122
  value: {
126
- importer: 'pack-ts',
127
123
  packageId: '019e3969-1d48-7c3b-ac24-6d68f4570662',
128
- subAssets: [{ sourceKey: 'effect', kind: 'scene' }],
129
124
  },
130
125
  });
131
126
  expect(
132
- scriptableDeclaration?.format === 'pack.ts' ? scriptableDeclaration.meta : undefined,
133
- ).toBe(scriptableDeclaration?.format === 'pack.ts' ? scriptableDeclaration.value : undefined);
127
+ scriptableDeclaration?.format === 'pack.ts-parameterized'
128
+ ? scriptableDeclaration.value
129
+ : undefined,
130
+ ).not.toHaveProperty('subAssets');
134
131
  });
135
132
 
136
133
  it('releases the ScriptablePack loader for the path-only scan contract', async () => {
@@ -140,18 +137,15 @@ describe('engine-pack scanner inventory contract', () => {
140
137
  await writeFile(scriptablePath, '// scanner fixture');
141
138
 
142
139
  const packageId = guid('019e3969-1d48-7c3b-ac24-6d68f4570664');
143
- const assetGuid = guid('019e3969-1d48-7c3b-ac24-6d68f4570665');
144
140
  let disposals = 0;
145
141
  const result = await scan([root], {
146
142
  scriptablePack: {
147
143
  executor: {
148
144
  load: async () => ({
149
145
  default: {
150
- schemaVersion: '1.0.0',
146
+ schemaVersion: '2.0.0',
151
147
  packageId,
152
- assets: { effect: { guid: assetGuid, kind: 'scene' } },
153
- externalAssets: {},
154
- build: () => ({ ok: true, value: {} }),
148
+ build: () => ({ ok: true, value: { effect: { kind: 'scene', entities: [] } } }),
155
149
  },
156
150
  }),
157
151
  dispose: async () => {
@@ -164,4 +158,9 @@ describe('engine-pack scanner inventory contract', () => {
164
158
  expect(result.ok).toBe(true);
165
159
  expect(disposals).toBe(1);
166
160
  });
161
+
162
+ it('characterizes the public scanner inventory vocabulary', () => {
163
+ expect(typeof scan).toBe('function');
164
+ expect(typeof scanInventory).toBe('function');
165
+ });
167
166
  });