@forgeax/engine-pack 0.1.2

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 (228) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +370 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/ai-recovery.unit.test.d.ts +2 -0
  5. package/dist/__tests__/ai-recovery.unit.test.d.ts.map +1 -0
  6. package/dist/__tests__/artifact-path.test.d.ts +2 -0
  7. package/dist/__tests__/artifact-path.test.d.ts.map +1 -0
  8. package/dist/__tests__/asset-evidence-error-hints.test.d.ts +2 -0
  9. package/dist/__tests__/asset-evidence-error-hints.test.d.ts.map +1 -0
  10. package/dist/__tests__/asset-evidence-lifecycle.test.d.ts +2 -0
  11. package/dist/__tests__/asset-evidence-lifecycle.test.d.ts.map +1 -0
  12. package/dist/__tests__/asset-evidence-schema.test.d.ts +2 -0
  13. package/dist/__tests__/asset-evidence-schema.test.d.ts.map +1 -0
  14. package/dist/__tests__/atlas-cli-region-mismatch.test.d.ts +2 -0
  15. package/dist/__tests__/atlas-cli-region-mismatch.test.d.ts.map +1 -0
  16. package/dist/__tests__/atlas-error-code-owner.test-d.d.ts +2 -0
  17. package/dist/__tests__/atlas-error-code-owner.test-d.d.ts.map +1 -0
  18. package/dist/__tests__/cli-asset-evidence.integration.test.d.ts +2 -0
  19. package/dist/__tests__/cli-asset-evidence.integration.test.d.ts.map +1 -0
  20. package/dist/__tests__/cli.unit.test.d.ts +2 -0
  21. package/dist/__tests__/cli.unit.test.d.ts.map +1 -0
  22. package/dist/__tests__/evidence-source-inventory.unit.test.d.ts +2 -0
  23. package/dist/__tests__/evidence-source-inventory.unit.test.d.ts.map +1 -0
  24. package/dist/__tests__/guid-collision.unit.test.d.ts +2 -0
  25. package/dist/__tests__/guid-collision.unit.test.d.ts.map +1 -0
  26. package/dist/__tests__/guid-validator-surface.unit.test.d.ts +2 -0
  27. package/dist/__tests__/guid-validator-surface.unit.test.d.ts.map +1 -0
  28. package/dist/__tests__/load-asset-config.test.d.ts +2 -0
  29. package/dist/__tests__/load-asset-config.test.d.ts.map +1 -0
  30. package/dist/__tests__/material-cook-dependencies.unit.test.d.ts +2 -0
  31. package/dist/__tests__/material-cook-dependencies.unit.test.d.ts.map +1 -0
  32. package/dist/__tests__/material-cook-receipt.unit.test.d.ts +2 -0
  33. package/dist/__tests__/material-cook-receipt.unit.test.d.ts.map +1 -0
  34. package/dist/__tests__/material-cook-schema.unit.test.d.ts +2 -0
  35. package/dist/__tests__/material-cook-schema.unit.test.d.ts.map +1 -0
  36. package/dist/__tests__/mesh-bin-consumer-surface.unit.test.d.ts +2 -0
  37. package/dist/__tests__/mesh-bin-consumer-surface.unit.test.d.ts.map +1 -0
  38. package/dist/__tests__/mesh-bin-contract.unit.test.d.ts +2 -0
  39. package/dist/__tests__/mesh-bin-contract.unit.test.d.ts.map +1 -0
  40. package/dist/__tests__/meta-schema-optional.test.d.ts +2 -0
  41. package/dist/__tests__/meta-schema-optional.test.d.ts.map +1 -0
  42. package/dist/__tests__/meta-source-overrides.unit.test.d.ts +2 -0
  43. package/dist/__tests__/meta-source-overrides.unit.test.d.ts.map +1 -0
  44. package/dist/__tests__/native-cooker-registry.test.d.ts +2 -0
  45. package/dist/__tests__/native-cooker-registry.test.d.ts.map +1 -0
  46. package/dist/__tests__/offline-evidence-artifact-verification-owner.test-d.d.ts +2 -0
  47. package/dist/__tests__/offline-evidence-artifact-verification-owner.test-d.d.ts.map +1 -0
  48. package/dist/__tests__/offline-evidence.integration.test.d.ts +2 -0
  49. package/dist/__tests__/offline-evidence.integration.test.d.ts.map +1 -0
  50. package/dist/__tests__/pack-v2-fixture-migration.test.d.ts +2 -0
  51. package/dist/__tests__/pack-v2-fixture-migration.test.d.ts.map +1 -0
  52. package/dist/__tests__/pack-v2-schema.test.d.ts +2 -0
  53. package/dist/__tests__/pack-v2-schema.test.d.ts.map +1 -0
  54. package/dist/__tests__/pack.unit.test.d.ts +2 -0
  55. package/dist/__tests__/pack.unit.test.d.ts.map +1 -0
  56. package/dist/__tests__/producer-contract-schema.unit.test.d.ts +2 -0
  57. package/dist/__tests__/producer-contract-schema.unit.test.d.ts.map +1 -0
  58. package/dist/__tests__/resolve-asset-source.test.d.ts +2 -0
  59. package/dist/__tests__/resolve-asset-source.test.d.ts.map +1 -0
  60. package/dist/__tests__/runtime-projection.unit.test.d.ts +2 -0
  61. package/dist/__tests__/runtime-projection.unit.test.d.ts.map +1 -0
  62. package/dist/__tests__/scanner-inventory.test.d.ts +2 -0
  63. package/dist/__tests__/scanner-inventory.test.d.ts.map +1 -0
  64. package/dist/__tests__/scanner-mount-cycle.test.d.ts +2 -0
  65. package/dist/__tests__/scanner-mount-cycle.test.d.ts.map +1 -0
  66. package/dist/__tests__/scriptable-pack-cli.integration.test.d.ts +2 -0
  67. package/dist/__tests__/scriptable-pack-cli.integration.test.d.ts.map +1 -0
  68. package/dist/__tests__/scriptable-pack-ddc-key.unit.test.d.ts +2 -0
  69. package/dist/__tests__/scriptable-pack-ddc-key.unit.test.d.ts.map +1 -0
  70. package/dist/__tests__/scriptable-pack.test-d.d.ts +2 -0
  71. package/dist/__tests__/scriptable-pack.test-d.d.ts.map +1 -0
  72. package/dist/__tests__/scriptable-pack.unit.test.d.ts +2 -0
  73. package/dist/__tests__/scriptable-pack.unit.test.d.ts.map +1 -0
  74. package/dist/__tests__/topology-actions.unit.test.d.ts +2 -0
  75. package/dist/__tests__/topology-actions.unit.test.d.ts.map +1 -0
  76. package/dist/__tests__/topology.unit.test.d.ts +2 -0
  77. package/dist/__tests__/topology.unit.test.d.ts.map +1 -0
  78. package/dist/artifact-path.d.ts +9 -0
  79. package/dist/artifact-path.d.ts.map +1 -0
  80. package/dist/atlas/run-atlas.d.ts +8 -0
  81. package/dist/atlas/run-atlas.d.ts.map +1 -0
  82. package/dist/atlas/shelf-pack.d.ts +55 -0
  83. package/dist/atlas/shelf-pack.d.ts.map +1 -0
  84. package/dist/bridge.d.ts +2 -0
  85. package/dist/bridge.d.ts.map +1 -0
  86. package/dist/bridge.mjs +3 -0
  87. package/dist/bridge.mjs.map +1 -0
  88. package/dist/builtin.d.ts +24 -0
  89. package/dist/builtin.d.ts.map +1 -0
  90. package/dist/cli-asset.d.ts +22 -0
  91. package/dist/cli-asset.d.ts.map +1 -0
  92. package/dist/cli-asset.mjs +9950 -0
  93. package/dist/cli-asset.mjs.map +1 -0
  94. package/dist/config.d.ts +6 -0
  95. package/dist/config.d.ts.map +1 -0
  96. package/dist/config.mjs +29 -0
  97. package/dist/config.mjs.map +1 -0
  98. package/dist/deriveAssetName.d.ts +15 -0
  99. package/dist/deriveAssetName.d.ts.map +1 -0
  100. package/dist/errors.d.ts +30 -0
  101. package/dist/errors.d.ts.map +1 -0
  102. package/dist/errors.mjs +19 -0
  103. package/dist/errors.mjs.map +1 -0
  104. package/dist/evidence/material-cook.d.ts +70 -0
  105. package/dist/evidence/material-cook.d.ts.map +1 -0
  106. package/dist/evidence/offline-evidence.d.ts +25 -0
  107. package/dist/evidence/offline-evidence.d.ts.map +1 -0
  108. package/dist/evidence/source-inventory.d.ts +29 -0
  109. package/dist/evidence/source-inventory.d.ts.map +1 -0
  110. package/dist/guid.d.ts +45 -0
  111. package/dist/guid.d.ts.map +1 -0
  112. package/dist/guid.mjs +94 -0
  113. package/dist/guid.mjs.map +1 -0
  114. package/dist/index.d.ts +15 -0
  115. package/dist/index.d.ts.map +1 -0
  116. package/dist/index.mjs +8475 -0
  117. package/dist/index.mjs.map +1 -0
  118. package/dist/material/artifact-writer.d.ts +14 -0
  119. package/dist/material/artifact-writer.d.ts.map +1 -0
  120. package/dist/mesh-bin-contract.d.ts +36 -0
  121. package/dist/mesh-bin-contract.d.ts.map +1 -0
  122. package/dist/name.mjs +22 -0
  123. package/dist/name.mjs.map +1 -0
  124. package/dist/native-cooker-registry.d.ts +28 -0
  125. package/dist/native-cooker-registry.d.ts.map +1 -0
  126. package/dist/native-cooker.d.ts +3 -0
  127. package/dist/native-cooker.d.ts.map +1 -0
  128. package/dist/native-cooker.mjs +105 -0
  129. package/dist/native-cooker.mjs.map +1 -0
  130. package/dist/producer-contract.d.ts +19 -0
  131. package/dist/producer-contract.d.ts.map +1 -0
  132. package/dist/resolve-asset-source.d.ts +4 -0
  133. package/dist/resolve-asset-source.d.ts.map +1 -0
  134. package/dist/resolve-asset-source.mjs +99 -0
  135. package/dist/resolve-asset-source.mjs.map +1 -0
  136. package/dist/runtime-projection.d.ts +14 -0
  137. package/dist/runtime-projection.d.ts.map +1 -0
  138. package/dist/scanner.d.ts +80 -0
  139. package/dist/scanner.d.ts.map +1 -0
  140. package/dist/scanner.mjs +9015 -0
  141. package/dist/scanner.mjs.map +1 -0
  142. package/dist/schema-compiled.d.ts +12 -0
  143. package/dist/schema-compiled.d.ts.map +1 -0
  144. package/dist/schema.mjs +7369 -0
  145. package/dist/schema.mjs.map +1 -0
  146. package/dist/scriptable-pack-node.d.ts +67 -0
  147. package/dist/scriptable-pack-node.d.ts.map +1 -0
  148. package/dist/scriptable-pack-node.mjs +1590 -0
  149. package/dist/scriptable-pack-node.mjs.map +1 -0
  150. package/dist/scriptable-pack-worker.d.ts +2 -0
  151. package/dist/scriptable-pack-worker.d.ts.map +1 -0
  152. package/dist/scriptable-pack-worker.mjs +223 -0
  153. package/dist/scriptable-pack-worker.mjs.map +1 -0
  154. package/dist/scriptable-pack.d.ts +525 -0
  155. package/dist/scriptable-pack.d.ts.map +1 -0
  156. package/dist/scriptable-pack.mjs +583 -0
  157. package/dist/scriptable-pack.mjs.map +1 -0
  158. package/dist/topology.d.ts +10 -0
  159. package/dist/topology.d.ts.map +1 -0
  160. package/package.json +129 -0
  161. package/schema/meta.schema.json +173 -0
  162. package/schema/pack.schema.json +232 -0
  163. package/src/__tests__/ai-recovery.unit.test.ts +114 -0
  164. package/src/__tests__/artifact-path.test.ts +41 -0
  165. package/src/__tests__/asset-evidence-error-hints.test.ts +46 -0
  166. package/src/__tests__/asset-evidence-lifecycle.test.ts +104 -0
  167. package/src/__tests__/asset-evidence-schema.test.ts +86 -0
  168. package/src/__tests__/atlas-cli-region-mismatch.test.ts +134 -0
  169. package/src/__tests__/atlas-error-code-owner.test-d.ts +23 -0
  170. package/src/__tests__/cli-asset-evidence.integration.test.ts +172 -0
  171. package/src/__tests__/cli.unit.test.ts +448 -0
  172. package/src/__tests__/evidence-source-inventory.unit.test.ts +37 -0
  173. package/src/__tests__/guid-collision.unit.test.ts +112 -0
  174. package/src/__tests__/guid-validator-surface.unit.test.ts +20 -0
  175. package/src/__tests__/load-asset-config.test.ts +121 -0
  176. package/src/__tests__/material-cook-dependencies.unit.test.ts +45 -0
  177. package/src/__tests__/material-cook-receipt.unit.test.ts +91 -0
  178. package/src/__tests__/material-cook-schema.unit.test.ts +98 -0
  179. package/src/__tests__/mesh-bin-consumer-surface.unit.test.ts +21 -0
  180. package/src/__tests__/mesh-bin-contract.unit.test.ts +32 -0
  181. package/src/__tests__/meta-schema-optional.test.ts +116 -0
  182. package/src/__tests__/meta-source-overrides.unit.test.ts +71 -0
  183. package/src/__tests__/native-cooker-registry.test.ts +64 -0
  184. package/src/__tests__/offline-evidence-artifact-verification-owner.test-d.ts +64 -0
  185. package/src/__tests__/offline-evidence.integration.test.ts +106 -0
  186. package/src/__tests__/pack-v2-fixture-migration.test.ts +64 -0
  187. package/src/__tests__/pack-v2-schema.test.ts +142 -0
  188. package/src/__tests__/pack.unit.test.ts +2218 -0
  189. package/src/__tests__/producer-contract-schema.unit.test.ts +92 -0
  190. package/src/__tests__/resolve-asset-source.test.ts +121 -0
  191. package/src/__tests__/runtime-projection.unit.test.ts +87 -0
  192. package/src/__tests__/scanner-inventory.test.ts +72 -0
  193. package/src/__tests__/scanner-mount-cycle.test.ts +232 -0
  194. package/src/__tests__/scriptable-pack-cli.integration.test.ts +47 -0
  195. package/src/__tests__/scriptable-pack-ddc-key.unit.test.ts +63 -0
  196. package/src/__tests__/scriptable-pack.test-d.ts +131 -0
  197. package/src/__tests__/scriptable-pack.unit.test.ts +747 -0
  198. package/src/__tests__/topology-actions.unit.test.ts +94 -0
  199. package/src/__tests__/topology.unit.test.ts +85 -0
  200. package/src/artifact-path.ts +69 -0
  201. package/src/atlas/run-atlas.ts +358 -0
  202. package/src/atlas/shelf-pack.ts +176 -0
  203. package/src/atlas/upng-ambient.d.ts +13 -0
  204. package/src/bridge.ts +2 -0
  205. package/src/builtin.ts +79 -0
  206. package/src/cli-asset.ts +590 -0
  207. package/src/config.ts +36 -0
  208. package/src/deriveAssetName.ts +42 -0
  209. package/src/errors.ts +47 -0
  210. package/src/evidence/material-cook.ts +332 -0
  211. package/src/evidence/offline-evidence.ts +90 -0
  212. package/src/evidence/source-inventory.ts +193 -0
  213. package/src/guid.ts +99 -0
  214. package/src/index.ts +104 -0
  215. package/src/material/artifact-writer.ts +26 -0
  216. package/src/mesh-bin-contract.ts +150 -0
  217. package/src/native-cooker-registry.ts +163 -0
  218. package/src/native-cooker.ts +2 -0
  219. package/src/producer-contract.ts +277 -0
  220. package/src/resolve-asset-source.ts +93 -0
  221. package/src/runtime-projection.ts +100 -0
  222. package/src/scanner.ts +716 -0
  223. package/src/schema/material-cook.schema.json +79 -0
  224. package/src/schema-compiled.ts +238 -0
  225. package/src/scriptable-pack-node.ts +1390 -0
  226. package/src/scriptable-pack-worker.ts +306 -0
  227. package/src/scriptable-pack.ts +884 -0
  228. package/src/topology.ts +156 -0
@@ -0,0 +1,2218 @@
1
+ // @ts-nocheck — merged file: pre-existing typecheck patterns from packages/pack/src/__tests__/ used implicit detail-property accesses that were not validated under tsc rootDir; preserving original behavior post-merge
2
+ // Consolidated by feat-20260609-test-pool-startup-reduction-merge-tiny-test-files
3
+ // biome-ignore-all lint/complexity/noUselessLoneBlockStatements: scope isolation between merged source files
4
+ // biome-ignore-all lint/complexity/useLiteralKeys: literal-key accesses from original source files
5
+ // biome-ignore-all lint/style/noNonNullAssertion: non-null assertions from original source files
6
+ //
7
+ // Source files (N=12):
8
+ // - packages/pack/src/__tests__/builtin.test.ts
9
+ // - packages/pack/src/__tests__/gltf-meta-schema.test.ts
10
+ // - packages/pack/src/__tests__/material-asset-validator.test.ts
11
+ // - packages/pack/src/__tests__/scanner-material-step7.test.ts
12
+ // - packages/pack/src/__tests__/scene-schema.test.ts
13
+ // - packages/pack/src/atlas/__tests__/shelf-pack.test.ts
14
+ // - packages/pack/src/__tests__/builtin.test.ts
15
+ // - packages/pack/src/__tests__/errors.test.ts
16
+ // - packages/pack/src/__tests__/guid.test.ts
17
+ // - packages/pack/src/__tests__/scanner-fail-fast.test.ts
18
+ // - packages/pack/src/__tests__/scanner.test.ts
19
+ // - packages/pack/src/__tests__/schema.test.ts
20
+ //
21
+ // Paradigm: each block-scoped describe('<source-filename>.test.ts', ...) preserves
22
+ // source as ancestorTitles[0]. Top-level imports merged + deduped.
23
+
24
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
25
+ import { tmpdir } from 'node:os';
26
+ import { join } from 'node:path';
27
+ import type { PackErrorCode, PackErrorDetail } from '@forgeax/engine-types';
28
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
29
+ import type { AtlasImageInput, AtlasRegion } from '../atlas/shelf-pack.js';
30
+ import { shelfPack } from '../atlas/shelf-pack.js';
31
+ import {
32
+ BUILTIN_HANDLE_CUBE,
33
+ BUILTIN_HANDLE_TRIANGLE,
34
+ deriveBuiltin,
35
+ FORGEAX_NAMESPACE,
36
+ } from '../builtin.js';
37
+ import { runCliAsset } from '../cli-asset.js';
38
+ import { PackError } from '../errors.js';
39
+ import { AssetGuid } from '../guid.js';
40
+ import { validateProducerContract, validateProducerOutputs } from '../producer-contract.js';
41
+ import { scan } from '../scanner.js';
42
+ import {
43
+ buildMaterialAssetValidator,
44
+ buildSceneAssetValidator,
45
+ validateMeta,
46
+ validatePack,
47
+ } from '../schema-compiled.js';
48
+
49
+ const V1_WHITELIST = new Set([
50
+ 'f32',
51
+ 'i32',
52
+ 'u32',
53
+ 'vec2',
54
+ 'vec3',
55
+ 'vec4',
56
+ 'color',
57
+ 'texture2d',
58
+ 'sampler',
59
+ ]);
60
+
61
+ {
62
+ // ─── from builtin.test.ts (src/__tests__) ───
63
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
64
+
65
+ describe('builtin.test.ts (src/__tests__)', () => {
66
+ describe('BUILTIN_HANDLE_QUAD', () => {
67
+ it('is exported and is a valid UUIDv5 string', async () => {
68
+ const mod = (await import('../builtin.js')) as Record<string, unknown>;
69
+ const quad = mod['BUILTIN_HANDLE_QUAD'];
70
+ expect(typeof quad).toBe('string');
71
+ expect(UUID_RE.test(quad as string)).toBe(true);
72
+ });
73
+
74
+ it('equals deriveBuiltin(HANDLE_QUAD) result (UUIDv5 deterministic)', async () => {
75
+ const mod = (await import('../builtin.js')) as Record<string, unknown>;
76
+ const quad = mod['BUILTIN_HANDLE_QUAD'] as string;
77
+ const _deriveBuiltin = mod.deriveBuiltin as (name: string) => Promise<Uint8Array>;
78
+ const rawUuidBytes = await _deriveBuiltin('HANDLE_QUAD');
79
+ const AssetGuidMod = (await import('../guid.js')) as Record<string, unknown>;
80
+ const format = (AssetGuidMod.AssetGuid as Record<string, unknown>).format as (
81
+ guid: Uint8Array,
82
+ ) => string;
83
+ expect(quad).toBe(format(rawUuidBytes));
84
+ });
85
+
86
+ it('is distinct from BUILTIN_HANDLE_CUBE and BUILTIN_HANDLE_TRIANGLE', async () => {
87
+ const mod = (await import('../builtin.js')) as Record<string, unknown>;
88
+ const quad = mod['BUILTIN_HANDLE_QUAD'];
89
+ const cube = mod['BUILTIN_HANDLE_CUBE'];
90
+ const triangle = mod['BUILTIN_HANDLE_TRIANGLE'];
91
+ expect(quad).not.toBe(cube);
92
+ expect(quad).not.toBe(triangle);
93
+ expect(cube).not.toBe(triangle);
94
+ });
95
+ });
96
+
97
+ describe('BUILTIN_HANDLE_CUBE / BUILTIN_HANDLE_TRIANGLE (regression)', () => {
98
+ it('CUBE constant is deterministic UUIDv5', async () => {
99
+ const mod = (await import('../builtin.js')) as Record<string, unknown>;
100
+ const cube = mod['BUILTIN_HANDLE_CUBE'] as string;
101
+ const _deriveBuiltin = mod.deriveBuiltin as (name: string) => Promise<Uint8Array>;
102
+ const raw = await _deriveBuiltin('HANDLE_CUBE');
103
+ const AssetGuidMod = (await import('../guid.js')) as Record<string, unknown>;
104
+ const format = (AssetGuidMod.AssetGuid as Record<string, unknown>).format as (
105
+ guid: Uint8Array,
106
+ ) => string;
107
+ expect(cube).toBe(format(raw));
108
+ });
109
+
110
+ it('TRIANGLE constant is deterministic UUIDv5', async () => {
111
+ const mod = (await import('../builtin.js')) as Record<string, unknown>;
112
+ const triangle = mod['BUILTIN_HANDLE_TRIANGLE'] as string;
113
+ const _deriveBuiltin = mod.deriveBuiltin as (name: string) => Promise<Uint8Array>;
114
+ const raw = await _deriveBuiltin('HANDLE_TRIANGLE');
115
+ const AssetGuidMod = (await import('../guid.js')) as Record<string, unknown>;
116
+ const format = (AssetGuidMod.AssetGuid as Record<string, unknown>).format as (
117
+ guid: Uint8Array,
118
+ ) => string;
119
+ expect(triangle).toBe(format(raw));
120
+ });
121
+ });
122
+ });
123
+ }
124
+
125
+ {
126
+ describe('w4 producer conformance matrix', () => {
127
+ const providers = [
128
+ ['gltf', 'scene'],
129
+ ['image', 'texture'],
130
+ ['fbx', 'mesh'],
131
+ ['audio', 'audio'],
132
+ ['font', 'font'],
133
+ ['host-fixture', 'host/blob'],
134
+ ] as const;
135
+
136
+ it('accepts every built-in importer and one open host provider through one contract', () => {
137
+ for (const [provider, kind] of providers) {
138
+ const result = validateProducerContract({
139
+ packageId: `pkg/${provider}`,
140
+ provenance: { provider, version: '1.0.0' },
141
+ revision: { digest: `sha256:${provider}`, observedAt: 1, rootId: 'root-a' },
142
+ sourceKey: `${provider}:main`,
143
+ sourceIndex: 0,
144
+ kind,
145
+ });
146
+ expect(result.ok, provider).toBe(true);
147
+ }
148
+ });
149
+
150
+ it('returns structured ambiguity for a multi-output provider without keys', () => {
151
+ const result = validateProducerOutputs([
152
+ { guid: 'a', kind: 'host/blob', sourceIndex: 0 },
153
+ { guid: 'b', kind: 'host/blob', sourceIndex: 1 },
154
+ ]);
155
+ expect(result.ok).toBe(false);
156
+ if (!result.ok) expect(result.error.code).toBe('source-index-ambiguous');
157
+ });
158
+ });
159
+ }
160
+
161
+ {
162
+ // ─── from gltf-meta-schema.test.ts ───
163
+
164
+ const VALID_HELLO_GLTF_META = {
165
+ schemaVersion: 1,
166
+ kind: 'external-asset-package',
167
+ importer: 'gltf',
168
+ source: 'box.gltf',
169
+ importSettings: {
170
+ defaultSceneIndex: 0,
171
+ diagnostics: {
172
+ matrixTrsCoexistNodes: [],
173
+ nodeNames: ['Box', 'MainCamera'],
174
+ unsupportedExtensions: [],
175
+ },
176
+ },
177
+ subAssets: [
178
+ { guid: '019e2b88-aece-7b6e-bbfe-45d6453d21f3', kind: 'mesh', sourceIndex: 0 },
179
+ { guid: '019e2b88-aecf-7e78-9888-655f0ec62ebc', kind: 'material', sourceIndex: 0 },
180
+ { guid: '019e2b88-aecf-7e78-9888-6560222ee28a', kind: 'scene', sourceIndex: 0 },
181
+ ],
182
+ };
183
+
184
+ describe('gltf-meta-schema.test.ts', () => {
185
+ describe('w30 - gltf .meta.json ajv schema (AC-04)', () => {
186
+ it('(a) accepts the hello-gltf importer-emitted box.gltf.meta.json shape verbatim', () => {
187
+ const valid = validateMeta(VALID_HELLO_GLTF_META);
188
+ expect(valid).toBe(true);
189
+ expect(validateMeta.errors).toBeNull();
190
+ });
191
+
192
+ it('(a-string) accepts schemaVersion as the legacy semver string form (back-compat oneOf branch)', () => {
193
+ const semverMeta = { ...VALID_HELLO_GLTF_META, schemaVersion: '1.0.0' };
194
+ const valid = validateMeta(semverMeta);
195
+ expect(valid).toBe(true);
196
+ });
197
+
198
+ it('(b) rejects subAsset typo: sourceIndx instead of sourceIndex', () => {
199
+ const typo = {
200
+ ...VALID_HELLO_GLTF_META,
201
+ subAssets: [
202
+ { guid: '019e2b88-aece-7b6e-bbfe-45d6453d21f3', kind: 'mesh', sourceIndx: 0 },
203
+ ],
204
+ };
205
+ const valid = validateMeta(typo);
206
+ expect(valid).toBe(false);
207
+ const codes = (validateMeta.errors ?? []).map((e) => e.keyword);
208
+ expect(codes.includes('additionalProperties') || codes.includes('required')).toBe(true);
209
+ });
210
+
211
+ it('(c) accepts unknown subAssets[].kind since schema is now open-string (feat-20260629 D-1)', () => {
212
+ const unknownKind = {
213
+ ...VALID_HELLO_GLTF_META,
214
+ subAssets: [
215
+ { guid: '019e2b88-aece-7b6e-bbfe-45d6453d21f3', kind: 'video', sourceIndex: 0 },
216
+ ],
217
+ };
218
+ const valid = validateMeta(unknownKind);
219
+ expect(valid).toBe(true);
220
+ });
221
+
222
+ it('(d) accepts the formerly-closed-enum kind values: mesh, material, scene', () => {
223
+ for (const kind of ['mesh', 'material', 'scene'] as const) {
224
+ const meta = {
225
+ ...VALID_HELLO_GLTF_META,
226
+ subAssets: [{ guid: '019e2b88-aece-7b6e-bbfe-45d6453d21f3', kind, sourceIndex: 0 }],
227
+ };
228
+ expect(validateMeta(meta)).toBe(true);
229
+ }
230
+ });
231
+
232
+ it('(f) rejects meta missing top-level importer (feat-20260603 importer migration)', () => {
233
+ const { importer: _drop, ...withoutImporter } = VALID_HELLO_GLTF_META;
234
+ const valid = validateMeta(withoutImporter);
235
+ expect(valid).toBe(false);
236
+ const keywords = (validateMeta.errors ?? []).map((e) => e.keyword);
237
+ expect(keywords).toContain('required');
238
+ });
239
+
240
+ it('(g) rejects meta with an empty importer key (minLength, replaces the old closed enum)', () => {
241
+ const empty = { ...VALID_HELLO_GLTF_META, importer: '' };
242
+ const valid = validateMeta(empty);
243
+ expect(valid).toBe(false);
244
+ const keywords = (validateMeta.errors ?? []).map((e) => e.keyword);
245
+ expect(keywords).toContain('minLength');
246
+ });
247
+
248
+ it('(h) accepts any non-empty importer key (open string, was a closed enum)', () => {
249
+ for (const at of ['image', 'gltf', 'audio', 'font', 'video'] as const) {
250
+ const meta = { ...VALID_HELLO_GLTF_META, importer: at };
251
+ expect(validateMeta(meta)).toBe(true);
252
+ }
253
+ });
254
+
255
+ it('(e) free-form importSettings: deeply nested unknown fields stay accepted (path A)', () => {
256
+ const exotic = {
257
+ ...VALID_HELLO_GLTF_META,
258
+ importSettings: {
259
+ defaultSceneIndex: 0,
260
+ diagnostics: {
261
+ matrixTrsCoexistNodes: [],
262
+ nodeNames: ['Box'],
263
+ unsupportedExtensions: [],
264
+ },
265
+ compression: 'zstd',
266
+ mipmap: true,
267
+ animationClipFps: 30,
268
+ },
269
+ };
270
+ expect(validateMeta(exotic)).toBe(true);
271
+ });
272
+ });
273
+ });
274
+ }
275
+
276
+ {
277
+ // ─── from material-asset-validator.test.ts ───
278
+
279
+ function makePayload(overrides?: Record<string, unknown>): Record<string, unknown> {
280
+ return {
281
+ materialShader: 'forgeax::default-standard-pbr',
282
+ paramSchema: [
283
+ { name: 'baseColor', type: 'color', default: [1, 1, 1] },
284
+ { name: 'roughness', type: 'f32', default: 0.5 },
285
+ ],
286
+ values: { baseColor: [1, 0, 0], roughness: 0.8 },
287
+ ...overrides,
288
+ };
289
+ }
290
+
291
+ describe('material-asset-validator.test.ts', () => {
292
+ describe('buildMaterialAssetValidator - >= 8 test cases', () => {
293
+ const validate = buildMaterialAssetValidator(V1_WHITELIST);
294
+
295
+ it('valid schema-driven material (all 9 type members pass)', () => {
296
+ const v = buildMaterialAssetValidator(V1_WHITELIST);
297
+ const payload = {
298
+ materialShader: 'test',
299
+ paramSchema: [
300
+ { name: 'a', type: 'f32' },
301
+ { name: 'b', type: 'i32' },
302
+ { name: 'c', type: 'u32' },
303
+ { name: 'd', type: 'vec2' },
304
+ { name: 'e', type: 'vec3' },
305
+ { name: 'f', type: 'vec4' },
306
+ { name: 'g', type: 'color' },
307
+ { name: 'h', type: 'texture2d' },
308
+ { name: 'i', type: 'sampler' },
309
+ ],
310
+ values: {},
311
+ };
312
+ const ok = v(payload);
313
+ expect(ok).toBe(true);
314
+ });
315
+
316
+ it('invalid param type (boolean) is rejected', () => {
317
+ const payload = makePayload({ paramSchema: [{ name: 'x', type: 'boolean' }] });
318
+ const ok = validate(payload);
319
+ expect(ok).toBe(false);
320
+ expect(validate.errors).toBeDefined();
321
+ expect(validate.errors?.length).toBeGreaterThan(0);
322
+ });
323
+
324
+ it('missing materialShader field is rejected', () => {
325
+ const payload = { paramSchema: [{ name: 'a', type: 'f32' }], values: {} };
326
+ const ok = validate(payload);
327
+ expect(ok).toBe(false);
328
+ expect(validate.errors).toBeDefined();
329
+ });
330
+
331
+ it('empty paramSchema is accepted', () => {
332
+ const payload = { materialShader: 'test', paramSchema: [], values: {} };
333
+ const ok = validate(payload);
334
+ expect(ok).toBe(true);
335
+ });
336
+
337
+ it('values with extra keys absent from paramSchema is accepted (free-form payload)', () => {
338
+ const payload = makePayload({
339
+ values: { baseColor: [1, 0, 0], roughness: 0.8, extra: 42 },
340
+ });
341
+ const ok = validate(payload);
342
+ expect(ok).toBe(true);
343
+ });
344
+
345
+ it('unknown top-level key is rejected (additionalProperties: false)', () => {
346
+ const payload = { ...makePayload(), extraField: 'nope' };
347
+ const ok = validate(payload);
348
+ expect(ok).toBe(false);
349
+ expect(validate.errors?.length).toBeGreaterThan(0);
350
+ });
351
+
352
+ it('null payload is rejected', () => {
353
+ const ok = validate(null);
354
+ expect(ok).toBe(false);
355
+ });
356
+
357
+ it('paramSchema entry missing name field is rejected', () => {
358
+ const payload = makePayload({ paramSchema: [{ type: 'f32' }] });
359
+ const ok = validate(payload);
360
+ expect(ok).toBe(false);
361
+ expect(validate.errors?.length).toBeGreaterThan(0);
362
+ });
363
+
364
+ it('each validator instance is independent (no shared state mutation)', () => {
365
+ const v1 = buildMaterialAssetValidator(new Set(['f32']));
366
+ const v2 = buildMaterialAssetValidator(new Set(['f32', 'vec3']));
367
+
368
+ const ok1 = v1({
369
+ materialShader: 'x',
370
+ paramSchema: [{ name: 'a', type: 'f32' }],
371
+ values: {},
372
+ });
373
+ expect(ok1).toBe(true);
374
+
375
+ const ok2 = v2({
376
+ materialShader: 'x',
377
+ paramSchema: [{ name: 'a', type: 'vec3' }],
378
+ values: {},
379
+ });
380
+ expect(ok2).toBe(true);
381
+
382
+ const bad1 = v1({
383
+ materialShader: 'x',
384
+ paramSchema: [{ name: 'a', type: 'vec3' }],
385
+ values: {},
386
+ });
387
+ expect(bad1).toBe(false);
388
+ });
389
+
390
+ it('paramSchema entry missing type field is rejected', () => {
391
+ const payload = makePayload({ paramSchema: [{ name: 'a' }] });
392
+ const ok = validate(payload);
393
+ expect(ok).toBe(false);
394
+ });
395
+ });
396
+ });
397
+ }
398
+
399
+ {
400
+ // ─── from scanner-material-step7.test.ts ───
401
+
402
+ const VALID_MATERIAL_GUID = 'aa000000-0000-4000-8000-000000000001';
403
+ const INVALID_MATERIAL_GUID = 'aa000000-0000-4000-8000-000000000002';
404
+
405
+ function validMaterialPack(): unknown {
406
+ return {
407
+ schemaVersion: '1.0.0',
408
+ kind: 'internal-text-package',
409
+ assets: [
410
+ {
411
+ guid: VALID_MATERIAL_GUID,
412
+ kind: 'material',
413
+ refs: [],
414
+ payload: {
415
+ materialShader: 'forgeax::default-standard-pbr',
416
+ paramSchema: [
417
+ { name: 'baseColor', type: 'color', default: [1, 1, 1] },
418
+ { name: 'roughness', type: 'f32', default: 0.5 },
419
+ ],
420
+ values: { baseColor: [1, 0, 0] },
421
+ },
422
+ },
423
+ ],
424
+ };
425
+ }
426
+
427
+ function invalidMaterialPack(): unknown {
428
+ return {
429
+ schemaVersion: '1.0.0',
430
+ kind: 'internal-text-package',
431
+ assets: [
432
+ {
433
+ guid: INVALID_MATERIAL_GUID,
434
+ kind: 'material',
435
+ refs: [],
436
+ payload: {
437
+ materialShader: 'forgeax::default-standard-pbr',
438
+ paramSchema: [{ name: 'x', type: 'boolean' }],
439
+ values: {},
440
+ },
441
+ },
442
+ ],
443
+ };
444
+ }
445
+
446
+ function nonMaterialPack(guid: string): unknown {
447
+ return {
448
+ schemaVersion: '1.0.0',
449
+ kind: 'internal-text-package',
450
+ assets: [{ guid, kind: 'texture', refs: [], payload: {} }],
451
+ };
452
+ }
453
+
454
+ describe('scanner-material-step7.test.ts', () => {
455
+ describe('scanner step-7 — material-payload-schema-check', () => {
456
+ let tempDir: string;
457
+
458
+ beforeEach(async () => {
459
+ tempDir = await mkdtemp(join(tmpdir(), 'pack-scanner-step7-'));
460
+ });
461
+ afterEach(async () => {
462
+ await rm(tempDir, { recursive: true, force: true });
463
+ });
464
+
465
+ it('(a) valid schema-driven material passes step-7', async () => {
466
+ await writeFile(
467
+ join(tempDir, 'valid-material.pack.json'),
468
+ JSON.stringify(validMaterialPack()),
469
+ 'utf-8',
470
+ );
471
+ const result = await scan([tempDir]);
472
+ expect(result.ok).toBe(true);
473
+ if (result.ok) {
474
+ const packFiles = result.value.filter((p) => p.endsWith('.pack.json'));
475
+ expect(packFiles).toHaveLength(1);
476
+ }
477
+ });
478
+
479
+ it('(b) material parameter typing is deferred to the material cook gate', async () => {
480
+ await writeFile(
481
+ join(tempDir, 'invalid-material.pack.json'),
482
+ JSON.stringify(invalidMaterialPack()),
483
+ 'utf-8',
484
+ );
485
+ const result = await scan([tempDir]);
486
+ expect(result.ok).toBe(true);
487
+ });
488
+
489
+ it('(c) mixed material payloads remain scanable for the cook gate', async () => {
490
+ await writeFile(
491
+ join(tempDir, 'a-valid-material.pack.json'),
492
+ JSON.stringify(validMaterialPack()),
493
+ 'utf-8',
494
+ );
495
+ await writeFile(
496
+ join(tempDir, 'b-invalid-material.pack.json'),
497
+ JSON.stringify(invalidMaterialPack()),
498
+ 'utf-8',
499
+ );
500
+ const result = await scan([tempDir]);
501
+ expect(result.ok).toBe(true);
502
+ });
503
+
504
+ it('(d) dir with no material assets passes step-7 trivially', async () => {
505
+ await writeFile(
506
+ join(tempDir, 'texture.pack.json'),
507
+ JSON.stringify(nonMaterialPack('aa000000-0000-4000-8000-000000000003')),
508
+ 'utf-8',
509
+ );
510
+ const result = await scan([tempDir]);
511
+ expect(result.ok).toBe(true);
512
+ });
513
+ });
514
+
515
+ describe('CLI verify — material-validated count', () => {
516
+ let tempDir: string;
517
+ let io: { stdout: string[]; stderr: string[] };
518
+
519
+ beforeEach(async () => {
520
+ tempDir = await mkdtemp(join(tmpdir(), 'pack-cli-step7-'));
521
+ io = { stdout: [], stderr: [] };
522
+ });
523
+ afterEach(async () => {
524
+ await rm(tempDir, { recursive: true, force: true });
525
+ });
526
+
527
+ it('CLI verify prints material-validated: 1 for a valid material dir', async () => {
528
+ await writeFile(
529
+ join(tempDir, 'valid-material.pack.json'),
530
+ JSON.stringify(validMaterialPack()),
531
+ 'utf-8',
532
+ );
533
+ const code = await runCliAsset(['verify'], {
534
+ stdoutWrite: (l: string) => {
535
+ io.stdout.push(l);
536
+ },
537
+ stderrWrite: (l: string) => {
538
+ io.stderr.push(l);
539
+ },
540
+ cwd: tempDir,
541
+ });
542
+ expect(code).toBe(0);
543
+ expect(io.stdout).toContain('material-validated: 1');
544
+ });
545
+
546
+ it('CLI verify prints material-validated: 0 when no material assets', async () => {
547
+ await writeFile(
548
+ join(tempDir, 'texture.pack.json'),
549
+ JSON.stringify(nonMaterialPack('aa000000-0000-4000-8000-000000000003')),
550
+ 'utf-8',
551
+ );
552
+ const code = await runCliAsset(['verify'], {
553
+ stdoutWrite: (l: string) => {
554
+ io.stdout.push(l);
555
+ },
556
+ stderrWrite: (l: string) => {
557
+ io.stderr.push(l);
558
+ },
559
+ cwd: tempDir,
560
+ });
561
+ expect(code).toBe(0);
562
+ expect(io.stdout).toContain('material-validated: 0');
563
+ });
564
+
565
+ it('CLI verify reports material payloads for later cook validation', async () => {
566
+ await writeFile(
567
+ join(tempDir, 'invalid-material.pack.json'),
568
+ JSON.stringify(invalidMaterialPack()),
569
+ 'utf-8',
570
+ );
571
+ const code = await runCliAsset(['verify'], {
572
+ stdoutWrite: (l: string) => {
573
+ io.stdout.push(l);
574
+ },
575
+ stderrWrite: (l: string) => {
576
+ io.stderr.push(l);
577
+ },
578
+ cwd: tempDir,
579
+ });
580
+ expect(code).toBe(0);
581
+ expect(io.stderr).toEqual([]);
582
+ expect(io.stdout).toContain('material-validated: 1');
583
+ });
584
+
585
+ it('CLI verify counts only material assets among multiple kinds', async () => {
586
+ await writeFile(
587
+ join(tempDir, 'valid-material.pack.json'),
588
+ JSON.stringify(validMaterialPack()),
589
+ 'utf-8',
590
+ );
591
+ await writeFile(
592
+ join(tempDir, 'texture.pack.json'),
593
+ JSON.stringify(nonMaterialPack('aa000000-0000-4000-8000-000000000003')),
594
+ 'utf-8',
595
+ );
596
+ const code = await runCliAsset(['verify'], {
597
+ stdoutWrite: (l: string) => {
598
+ io.stdout.push(l);
599
+ },
600
+ stderrWrite: (l: string) => {
601
+ io.stderr.push(l);
602
+ },
603
+ cwd: tempDir,
604
+ });
605
+ expect(code).toBe(0);
606
+ expect(io.stdout).toContain('material-validated: 1');
607
+ });
608
+ });
609
+ });
610
+ }
611
+
612
+ {
613
+ // ─── from scene-schema.test.ts ───
614
+
615
+ const TRANSFORM_SCHEMA = {
616
+ type: 'object',
617
+ additionalProperties: false,
618
+ properties: {
619
+ pos: { type: 'array', items: { type: 'number' }, minItems: 3, maxItems: 3 },
620
+ },
621
+ } as const;
622
+ const MESH_FILTER_SCHEMA = {
623
+ type: 'object',
624
+ additionalProperties: false,
625
+ properties: { mesh: { type: 'string' } },
626
+ } as const;
627
+ const CHILD_OF_SCHEMA = {
628
+ type: 'object',
629
+ additionalProperties: false,
630
+ required: ['parent'],
631
+ properties: { parent: { type: 'integer', minimum: 0 } },
632
+ } as const;
633
+ const componentSchemas: Record<string, object> = {
634
+ Transform: TRANSFORM_SCHEMA,
635
+ MeshFilter: MESH_FILTER_SCHEMA,
636
+ ChildOf: CHILD_OF_SCHEMA,
637
+ };
638
+ const sceneValidate = buildSceneAssetValidator(componentSchemas);
639
+
640
+ describe('scene-schema.test.ts', () => {
641
+ describe('SceneAsset payload schema - positive cases (w4 / D-P4)', () => {
642
+ it('accepts a valid 3-node payload (Transform + MeshFilter + ChildOf)', () => {
643
+ const payload = {
644
+ kind: 'scene',
645
+ entities: [
646
+ {
647
+ localId: 0,
648
+ components: {
649
+ Transform: { pos: [1, 2, 3] },
650
+ MeshFilter: { mesh: 'cube' },
651
+ },
652
+ },
653
+ {
654
+ localId: 1,
655
+ components: { Transform: { pos: [0, 0, 0] }, ChildOf: { parent: 0 } },
656
+ },
657
+ {
658
+ localId: 2,
659
+ components: { Transform: { pos: [5, 5, 5] }, ChildOf: { parent: 1 } },
660
+ },
661
+ ],
662
+ };
663
+ const ok = sceneValidate(payload);
664
+ expect(ok).toBe(true);
665
+ expect(sceneValidate.errors ?? []).toEqual([]);
666
+ });
667
+ });
668
+
669
+ describe('SceneAsset payload schema - typo field name (AC-08(b))', () => {
670
+ it('rejects Transform with typo field `pozX`, ajv error mentions additional properties', () => {
671
+ const payload = {
672
+ kind: 'scene',
673
+ entities: [
674
+ { localId: 0, components: { Transform: { pos: [1, 2, 3] } } },
675
+ { localId: 1, components: { Transform: { pos: [0, 0, 0] } } },
676
+ { localId: 2, components: { Transform: { pozX: 7, pos: [0, 0, 0] } } },
677
+ ],
678
+ };
679
+ const ok = sceneValidate(payload);
680
+ expect(ok).toBe(false);
681
+ const errors = sceneValidate.errors ?? [];
682
+ expect(errors.length).toBeGreaterThan(0);
683
+ const e = errors[0];
684
+ expect(e?.instancePath ?? '').toContain('/entities/2/components/Transform');
685
+ expect(e?.message ?? '').toContain('additional properties');
686
+ });
687
+ });
688
+
689
+ describe('SceneAsset payload schema - ChildOf.parent type guard', () => {
690
+ it('rejects ChildOf.parent that is not a non-negative integer', () => {
691
+ const payload = {
692
+ kind: 'scene',
693
+ entities: [{ localId: 0, components: { ChildOf: { parent: -1 } } }],
694
+ };
695
+ const ok = sceneValidate(payload);
696
+ expect(ok).toBe(false);
697
+ const errors = sceneValidate.errors ?? [];
698
+ expect(errors.length).toBeGreaterThan(0);
699
+ });
700
+ });
701
+
702
+ describe('SceneAsset payload schema - unregistered component token', () => {
703
+ it('rejects a SceneEntity.components key not in the registered schema map', () => {
704
+ const payload = {
705
+ kind: 'scene',
706
+ entities: [{ localId: 0, components: { MysteryComponent: { foo: 1 } } }],
707
+ };
708
+ const ok = sceneValidate(payload);
709
+ expect(ok).toBe(false);
710
+ const errors = sceneValidate.errors ?? [];
711
+ expect(errors.length).toBeGreaterThan(0);
712
+ const e = errors[0];
713
+ expect(e?.message ?? '').toContain('additional properties');
714
+ });
715
+ });
716
+ });
717
+
718
+ describe('mount-override-schema.test.ts', () => {
719
+ // feat-20260713-mount-override-component-add-and-shared-ref-round M1 / w1
720
+ // (AC-03). `mountOverrideSchema.field` is now optional so the override shape
721
+ // carries an implicit component-granular discriminant: an override WITH
722
+ // `field` patches one field (legacy 4-key), an override WITHOUT `field`
723
+ // adds/upserts the whole component (new 3-key). The schema accepts both;
724
+ // `localId` / `comp` / `value` stay required; `additionalProperties: false`
725
+ // rejects any op-tag discriminant (OOS: no `switch (op)` fan-out downstream).
726
+ function makeMountPayload(override: Record<string, unknown>): Record<string, unknown> {
727
+ return {
728
+ kind: 'scene',
729
+ entities: [{ localId: 0, components: { Transform: { pos: [0, 0, 0] } } }],
730
+ mounts: [
731
+ {
732
+ localId: 1,
733
+ source: 0,
734
+ memberFirst: 2,
735
+ memberCount: 1,
736
+ overrides: [override],
737
+ },
738
+ ],
739
+ };
740
+ }
741
+
742
+ it('accepts the 4-key patch shape (with field)', () => {
743
+ const ok = sceneValidate(
744
+ makeMountPayload({ localId: 2, comp: 'Transform', field: 'pos', value: [1, 2, 3] }),
745
+ );
746
+ expect(ok).toBe(true);
747
+ expect(sceneValidate.errors ?? []).toEqual([]);
748
+ });
749
+
750
+ it('accepts the 3-key add shape (without field)', () => {
751
+ const ok = sceneValidate(
752
+ makeMountPayload({ localId: 2, comp: 'Transform', value: { pos: [1, 2, 3] } }),
753
+ );
754
+ expect(ok).toBe(true);
755
+ expect(sceneValidate.errors ?? []).toEqual([]);
756
+ });
757
+
758
+ it('rejects an override missing localId', () => {
759
+ const ok = sceneValidate(makeMountPayload({ comp: 'Transform', value: [1, 2, 3] }));
760
+ expect(ok).toBe(false);
761
+ expect((sceneValidate.errors ?? []).length).toBeGreaterThan(0);
762
+ });
763
+
764
+ it('rejects an override missing comp', () => {
765
+ const ok = sceneValidate(makeMountPayload({ localId: 2, value: [1, 2, 3] }));
766
+ expect(ok).toBe(false);
767
+ expect((sceneValidate.errors ?? []).length).toBeGreaterThan(0);
768
+ });
769
+
770
+ it('rejects an override missing value', () => {
771
+ const ok = sceneValidate(makeMountPayload({ localId: 2, comp: 'Transform', field: 'pos' }));
772
+ expect(ok).toBe(false);
773
+ expect((sceneValidate.errors ?? []).length).toBeGreaterThan(0);
774
+ });
775
+
776
+ it('rejects an override carrying an op discriminant field (additionalProperties:false)', () => {
777
+ const ok = sceneValidate(
778
+ makeMountPayload({ localId: 2, comp: 'Transform', value: [1, 2, 3], op: 'add' }),
779
+ );
780
+ expect(ok).toBe(false);
781
+ const errors = sceneValidate.errors ?? [];
782
+ expect(errors.length).toBeGreaterThan(0);
783
+ expect(errors.some((e) => (e?.message ?? '').includes('additional properties'))).toBe(true);
784
+ });
785
+
786
+ // ═════════════════════════════════════════════════════════════════════════
787
+ // w22 — AC-03 / D-9: serialize→validate round-trip on fold-produced overrides
788
+ //
789
+ // The M5 collect fold emits component-add overrides (no `field`) whose value
790
+ // is a per-field map that may carry shared<T> GUID strings (e.g. the added
791
+ // AnimationPlayer.clips = [GUID, 0, 0, 0]). ajv's mountOverrideSchema must
792
+ // accept that fold-produced shape, and a serialize→validate→deserialize
793
+ // round-trip must not drop the override data. The override value is a free-
794
+ // form object (`value: {}`) at the schema layer — runtime type checks live
795
+ // in the ecs apply path (setSceneOverride / _validateMountOverrides).
796
+ // ═════════════════════════════════════════════════════════════════════════
797
+
798
+ const W22_CLIP = 'f1e2d3c4-b5a6-4b7c-8d9e-0f1a2b3c4d5e';
799
+
800
+ it('(w22-a) accepts a fold-produced add-override with a shared-field GUID array value', () => {
801
+ const payload = makeMountPayload({
802
+ localId: 2,
803
+ comp: 'Transform',
804
+ // component-add form (no field); value carries a positional array with a
805
+ // GUID string at slot 0 and NULL-sentinel placeholders — the exact shape
806
+ // the fold + serialize wiring emits for AnimationPlayer.clips.
807
+ value: { clips: [W22_CLIP, 0, 0, 0] },
808
+ });
809
+ const ok = sceneValidate(payload);
810
+ expect(ok).toBe(true);
811
+ expect(sceneValidate.errors ?? []).toEqual([]);
812
+ });
813
+
814
+ it('(w22-b) accepts an add-override whose value has no shared field at all', () => {
815
+ // component-add with a plain scalar-only value map (the minimal add shape).
816
+ const ok = sceneValidate(
817
+ makeMountPayload({ localId: 2, comp: 'Transform', value: { pos: [9, 9, 9] } }),
818
+ );
819
+ expect(ok).toBe(true);
820
+ expect(sceneValidate.errors ?? []).toEqual([]);
821
+ });
822
+
823
+ it('(w22-c) round-trip: JSON serialize → validate → parse preserves override data', () => {
824
+ const override = {
825
+ localId: 2,
826
+ comp: 'Transform',
827
+ value: { clips: [W22_CLIP, 0, 0, 0], pos: [1, 2, 3] },
828
+ };
829
+ const payload = makeMountPayload(override);
830
+
831
+ // Simulate the on-disk pack path: JSON.stringify → fetch → JSON.parse.
832
+ const roundTripped = JSON.parse(JSON.stringify(payload)) as Record<string, unknown>;
833
+ const ok = sceneValidate(roundTripped);
834
+ expect(ok).toBe(true);
835
+ expect(sceneValidate.errors ?? []).toEqual([]);
836
+
837
+ // No data lost: the override survives the round-trip byte-for-byte.
838
+ const mounts = roundTripped.mounts as Array<Record<string, unknown>>;
839
+ const overrides = mounts[0]?.overrides as Array<Record<string, unknown>>;
840
+ expect(overrides).toHaveLength(1);
841
+ expect(overrides[0]).toEqual(override);
842
+ const value = overrides[0]?.value as Record<string, unknown>;
843
+ expect(value.clips).toEqual([W22_CLIP, 0, 0, 0]);
844
+ expect(value.pos).toEqual([1, 2, 3]);
845
+ });
846
+ });
847
+ }
848
+
849
+ {
850
+ // ─── from shelf-pack.test.ts (src/atlas/__tests__/) ───
851
+
852
+ function makeInput(name: string, width: number, height: number): AtlasImageInput {
853
+ return { name, width, height, pixels: new Uint8Array(width * height * 4) };
854
+ }
855
+
856
+ function rectsOverlap(a: AtlasRegion, b: AtlasRegion): boolean {
857
+ return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
858
+ }
859
+
860
+ describe('shelf-pack.test.ts', () => {
861
+ describe('shelfPack uniform input (T-25 a)', () => {
862
+ it('packs 100 32x32 sprites into a single atlas with no overlaps', () => {
863
+ const inputs: AtlasImageInput[] = [];
864
+ for (let i = 0; i < 100; i++)
865
+ inputs.push(makeInput(`s${String(i).padStart(3, '0')}`, 32, 32));
866
+ const outcome = shelfPack(inputs, { maxAtlasSize: 4096 });
867
+ expect(outcome.ok).toBe(true);
868
+ if (!outcome.ok) return;
869
+ const { atlasWidth, atlasHeight, regions } = outcome.value;
870
+ expect(regions).toHaveLength(100);
871
+ expect(atlasWidth).toBeLessThanOrEqual(4096);
872
+ expect(atlasHeight).toBeLessThanOrEqual(4096);
873
+ expect(atlasWidth * atlasHeight).toBeGreaterThanOrEqual(100 * 32 * 32);
874
+ for (const r of regions) {
875
+ expect(r.x).toBeGreaterThanOrEqual(0);
876
+ expect(r.y).toBeGreaterThanOrEqual(0);
877
+ expect(r.x + r.w).toBeLessThanOrEqual(atlasWidth);
878
+ expect(r.y + r.h).toBeLessThanOrEqual(atlasHeight);
879
+ expect(r.w).toBe(32);
880
+ expect(r.h).toBe(32);
881
+ }
882
+ for (let i = 0; i < regions.length; i++) {
883
+ for (let j = i + 1; j < regions.length; j++) {
884
+ const a = regions[i] as AtlasRegion;
885
+ const b = regions[j] as AtlasRegion;
886
+ expect(rectsOverlap(a, b)).toBe(false);
887
+ }
888
+ }
889
+ const names = new Set(regions.map((r) => r.name));
890
+ expect(names.size).toBe(100);
891
+ });
892
+ });
893
+
894
+ describe('shelfPack mixed input (T-25 b)', () => {
895
+ it('places mixed-size sprites without rectangle overlap and within atlas footprint', () => {
896
+ const inputs: AtlasImageInput[] = [
897
+ makeInput('big-128', 128, 128),
898
+ makeInput('mid-64', 64, 64),
899
+ makeInput('mid-64-b', 64, 64),
900
+ makeInput('small-32', 32, 32),
901
+ makeInput('small-32-b', 32, 32),
902
+ makeInput('tall-32x96', 32, 96),
903
+ ];
904
+ const outcome = shelfPack(inputs, { maxAtlasSize: 4096 });
905
+ expect(outcome.ok).toBe(true);
906
+ if (!outcome.ok) return;
907
+ const { atlasWidth, atlasHeight, regions } = outcome.value;
908
+ expect(regions).toHaveLength(inputs.length);
909
+ let totalArea = 0;
910
+ for (const r of regions) {
911
+ totalArea += r.w * r.h;
912
+ expect(r.x + r.w).toBeLessThanOrEqual(atlasWidth);
913
+ expect(r.y + r.h).toBeLessThanOrEqual(atlasHeight);
914
+ }
915
+ expect(totalArea).toBeLessThanOrEqual(atlasWidth * atlasHeight);
916
+ for (let i = 0; i < regions.length; i++) {
917
+ for (let j = i + 1; j < regions.length; j++) {
918
+ const a = regions[i] as AtlasRegion;
919
+ const b = regions[j] as AtlasRegion;
920
+ expect(rectsOverlap(a, b)).toBe(false);
921
+ }
922
+ }
923
+ });
924
+ });
925
+
926
+ describe('shelfPack empty input (T-25 c)', () => {
927
+ it('returns atlas-empty-input sentinel when images.length === 0', () => {
928
+ const outcome = shelfPack([], { maxAtlasSize: 4096 });
929
+ expect(outcome.ok).toBe(false);
930
+ if (outcome.ok) return;
931
+ expect(outcome.error.code).toBe('atlas-empty-input');
932
+ expect(outcome.error.detail.receivedCount).toBe(0);
933
+ });
934
+ });
935
+
936
+ describe('shelfPack oversize input (T-25 d)', () => {
937
+ it('returns atlas-size-exceeded sentinel when an image exceeds maxAtlasSize', () => {
938
+ const inputs = [makeInput('giant', 5000, 5000)];
939
+ const outcome = shelfPack(inputs, { maxAtlasSize: 4096 });
940
+ expect(outcome.ok).toBe(false);
941
+ if (outcome.ok) return;
942
+ expect(outcome.error.code).toBe('atlas-size-exceeded');
943
+ expect(outcome.error.detail.name).toBe('giant');
944
+ expect(outcome.error.detail.width).toBe(5000);
945
+ expect(outcome.error.detail.height).toBe(5000);
946
+ expect(outcome.error.detail.maxAtlasSize).toBe(4096);
947
+ });
948
+ });
949
+ });
950
+ }
951
+
952
+ {
953
+ // ─── from builtin.test.ts (__tests__/) ───
954
+
955
+ describe('builtin.test.ts (__tests__/)', () => {
956
+ describe('FORGEAX_NAMESPACE', () => {
957
+ it('is a valid AssetGuid', () => {
958
+ expect(FORGEAX_NAMESPACE).toBeInstanceOf(Uint8Array);
959
+ expect(FORGEAX_NAMESPACE.length).toBe(16);
960
+ });
961
+
962
+ it('is not the RFC 4122 X.500 namespace (6ba7b814-9dad-11d1-80b4-00c04fd430c8)', () => {
963
+ const x500Result = AssetGuid.parse('6ba7b814-9dad-11d1-80b4-00c04fd430c8');
964
+ if (!x500Result.ok) throw new Error('expected ok');
965
+ expect(AssetGuid.equals(FORGEAX_NAMESPACE, x500Result.value)).toBe(false);
966
+ });
967
+
968
+ it('matches the expected forgeax namespace UUID', () => {
969
+ const expected = AssetGuid.parse('9a09805a-7623-482e-b322-9fc3591f2a38');
970
+ if (!expected.ok) throw new Error('expected ok');
971
+ expect(AssetGuid.equals(FORGEAX_NAMESPACE, expected.value)).toBe(true);
972
+ });
973
+ });
974
+
975
+ describe('deriveBuiltin', () => {
976
+ it('HANDLE_CUBE derives fixed bytes (snapshot)', async () => {
977
+ const guid = await deriveBuiltin('HANDLE_CUBE');
978
+ expect(AssetGuid.format(guid)).toMatchInlineSnapshot(
979
+ '"cbe42beb-8975-5096-b3a1-3dda4cb4c077"',
980
+ );
981
+ });
982
+
983
+ it('HANDLE_TRIANGLE derives fixed bytes (snapshot)', async () => {
984
+ const guid = await deriveBuiltin('HANDLE_TRIANGLE');
985
+ expect(AssetGuid.format(guid)).toMatchInlineSnapshot(
986
+ '"22592f07-d967-5116-b29c-fa9781929ba8"',
987
+ );
988
+ });
989
+
990
+ it('same name always derives same bytes', async () => {
991
+ const a = await deriveBuiltin('HANDLE_CUBE');
992
+ const b = await deriveBuiltin('HANDLE_CUBE');
993
+ expect(AssetGuid.equals(a, b)).toBe(true);
994
+ });
995
+
996
+ it('different names derive different GUIDs', async () => {
997
+ const cube = await deriveBuiltin('HANDLE_CUBE');
998
+ const tri = await deriveBuiltin('HANDLE_TRIANGLE');
999
+ expect(AssetGuid.equals(cube, tri)).toBe(false);
1000
+ });
1001
+ });
1002
+
1003
+ describe('BUILTIN_HANDLE_CUBE constant', () => {
1004
+ it("matches deriveBuiltin('HANDLE_CUBE')", async () => {
1005
+ const derived = await deriveBuiltin('HANDLE_CUBE');
1006
+ const constant = AssetGuid.parse(BUILTIN_HANDLE_CUBE);
1007
+ if (!constant.ok) throw new Error('expected ok');
1008
+ expect(AssetGuid.equals(derived, constant.value)).toBe(true);
1009
+ });
1010
+ });
1011
+
1012
+ describe('BUILTIN_HANDLE_TRIANGLE constant', () => {
1013
+ it("matches deriveBuiltin('HANDLE_TRIANGLE')", async () => {
1014
+ const derived = await deriveBuiltin('HANDLE_TRIANGLE');
1015
+ const constant = AssetGuid.parse(BUILTIN_HANDLE_TRIANGLE);
1016
+ if (!constant.ok) throw new Error('expected ok');
1017
+ expect(AssetGuid.equals(derived, constant.value)).toBe(true);
1018
+ });
1019
+ });
1020
+ });
1021
+ }
1022
+
1023
+ {
1024
+ // ─── from errors.test.ts (__tests__/) ───
1025
+
1026
+ function exhaustiveSwitchCheck(err: PackError): string {
1027
+ switch (err.code) {
1028
+ case 'pack-malformed-meta':
1029
+ return (err.detail as { path: string }).path;
1030
+ case 'pack-malformed-pack':
1031
+ return (err.detail as { path: string }).path;
1032
+ case 'pack-guid-malformed':
1033
+ return (err.detail as { raw: string }).raw;
1034
+ case 'pack-orphan-meta':
1035
+ return (err.detail as { metaPath: string }).metaPath;
1036
+ case 'pack-meta-missing':
1037
+ return (err.detail as { filePath: string }).filePath;
1038
+ case 'pack-guid-collision':
1039
+ return (err.detail as { guid: string }).guid;
1040
+ case 'pack-cyclic-reference':
1041
+ return (err.detail as { cycle: readonly string[] }).cycle[0] ?? '';
1042
+ case 'pack-subasset-index-out-of-range':
1043
+ return String((err.detail as { sourceIndex: number }).sourceIndex);
1044
+ }
1045
+ }
1046
+ void exhaustiveSwitchCheck;
1047
+
1048
+ describe('errors.test.ts (__tests__/)', () => {
1049
+ describe('PackError — 8-member closed set', () => {
1050
+ it('pack-malformed-meta: .code + .expected + .hint + detail.ajvErrors', () => {
1051
+ const detail: PackErrorDetail = {
1052
+ path: 'assets/hero.png.meta.json',
1053
+ ajvErrors: [{ instancePath: '/guid', message: 'must match format "uuid"' }],
1054
+ };
1055
+ const err = new PackError({
1056
+ code: 'pack-malformed-meta',
1057
+ expected: 'meta.json must satisfy meta.schema.json',
1058
+ hint: 'check guid is a valid RFC 4122 UUID',
1059
+ detail,
1060
+ });
1061
+ expect(err.code).toBe('pack-malformed-meta' satisfies PackErrorCode);
1062
+ expect(err.expected).toBeTruthy();
1063
+ expect(err.hint).toBeTruthy();
1064
+ if (err.code === 'pack-malformed-meta') {
1065
+ const d = err.detail as { ajvErrors: unknown[]; path: string };
1066
+ expect(d.ajvErrors).toHaveLength(1);
1067
+ expect(d.path).toBe('assets/hero.png.meta.json');
1068
+ }
1069
+ });
1070
+
1071
+ it('pack-malformed-pack: .code + detail.ajvErrors', () => {
1072
+ const detail: PackErrorDetail = {
1073
+ path: 'assets/scene.pack.json',
1074
+ ajvErrors: [{ instancePath: '/assets/0/guid', message: 'must match format "uuid"' }],
1075
+ };
1076
+ const err = new PackError({
1077
+ code: 'pack-malformed-pack',
1078
+ expected: 'pack.json must satisfy pack.schema.json',
1079
+ hint: 'check all asset guid fields are valid 36-char dash-form UUIDs',
1080
+ detail,
1081
+ });
1082
+ expect(err.code).toBe('pack-malformed-pack' satisfies PackErrorCode);
1083
+ if (err.code === 'pack-malformed-pack') {
1084
+ const d = err.detail as { ajvErrors: unknown[] };
1085
+ expect(d.ajvErrors).toHaveLength(1);
1086
+ }
1087
+ });
1088
+
1089
+ it('pack-guid-malformed: detail.raw + detail.reason', () => {
1090
+ const detail: PackErrorDetail = {
1091
+ raw: 'not-a-uuid',
1092
+ reason: 'expected 36-char RFC 4122 dash-form UUID',
1093
+ };
1094
+ const err = new PackError({
1095
+ code: 'pack-guid-malformed',
1096
+ expected: 'all GUID fields must be 36-char RFC 4122 dash-form UUIDs',
1097
+ hint: 'use AssetGuid.random() or a UUID v7 generator to produce valid GUIDs',
1098
+ detail,
1099
+ });
1100
+ expect(err.code).toBe('pack-guid-malformed' satisfies PackErrorCode);
1101
+ if (err.code === 'pack-guid-malformed') {
1102
+ const d = err.detail as { raw: string; reason: string };
1103
+ expect(d.raw).toBe('not-a-uuid');
1104
+ expect(d.reason).toBeTruthy();
1105
+ }
1106
+ });
1107
+
1108
+ it('pack-orphan-meta: detail.metaPath + detail.expectedFile', () => {
1109
+ const detail: PackErrorDetail = {
1110
+ metaPath: 'assets/ghost.png.meta.json',
1111
+ expectedFile: 'assets/ghost.png',
1112
+ };
1113
+ const err = new PackError({
1114
+ code: 'pack-orphan-meta',
1115
+ expected: 'every .meta.json must have a corresponding source file',
1116
+ hint: 'remove orphan .meta.json or add the missing source file',
1117
+ detail,
1118
+ });
1119
+ expect(err.code).toBe('pack-orphan-meta' satisfies PackErrorCode);
1120
+ if (err.code === 'pack-orphan-meta') {
1121
+ const d = err.detail as { metaPath: string; expectedFile: string };
1122
+ expect(d.metaPath).toBe('assets/ghost.png.meta.json');
1123
+ expect(d.expectedFile).toBe('assets/ghost.png');
1124
+ }
1125
+ });
1126
+
1127
+ it('pack-meta-missing: detail.filePath', () => {
1128
+ const detail: PackErrorDetail = { filePath: 'assets/hero.png' };
1129
+ const err = new PackError({
1130
+ code: 'pack-meta-missing',
1131
+ expected: 'every source file must have a corresponding .meta.json in strict mode',
1132
+ hint: 'run forgeax-engine-remote-asset scan --roots to list files without .meta.json',
1133
+ detail,
1134
+ });
1135
+ expect(err.code).toBe('pack-meta-missing' satisfies PackErrorCode);
1136
+ if (err.code === 'pack-meta-missing') {
1137
+ const d = err.detail as { filePath: string };
1138
+ expect(d.filePath).toBe('assets/hero.png');
1139
+ }
1140
+ });
1141
+
1142
+ it('pack-guid-collision: detail.paths is [string, string] + detail.guid', () => {
1143
+ const detail: PackErrorDetail = {
1144
+ paths: ['assets/a.pack.json', 'assets/b.pack.json'],
1145
+ guid: '018e7a4d-1234-7abc-8def-000000000001',
1146
+ };
1147
+ const err = new PackError({
1148
+ code: 'pack-guid-collision',
1149
+ expected: 'every GUID must be unique across all .pack.json files in the scan roots',
1150
+ hint: 'run forgeax-engine-remote-asset verify to list all GUID collisions',
1151
+ detail,
1152
+ });
1153
+ expect(err.code).toBe('pack-guid-collision' satisfies PackErrorCode);
1154
+ if (err.code === 'pack-guid-collision') {
1155
+ const d = err.detail as { paths: [string, string]; guid: string };
1156
+ expect(d.paths).toHaveLength(2);
1157
+ expect(d.guid).toBe('018e7a4d-1234-7abc-8def-000000000001');
1158
+ }
1159
+ });
1160
+
1161
+ it('pack-cyclic-reference: detail.cycle first === last', () => {
1162
+ const cycle = [
1163
+ '018e7a4d-1234-7abc-8def-000000000001',
1164
+ '018e7a4d-1234-7abc-8def-000000000002',
1165
+ '018e7a4d-1234-7abc-8def-000000000001',
1166
+ ] as const;
1167
+ const detail: PackErrorDetail = { cycle };
1168
+ const err = new PackError({
1169
+ code: 'pack-cyclic-reference',
1170
+ expected: 'asset reference graph must be acyclic',
1171
+ hint: 'run forgeax-engine-remote-asset verify to list the cycle path',
1172
+ detail,
1173
+ });
1174
+ expect(err.code).toBe('pack-cyclic-reference' satisfies PackErrorCode);
1175
+ if (err.code === 'pack-cyclic-reference') {
1176
+ const d = err.detail as { cycle: readonly string[] };
1177
+ expect(d.cycle.length).toBeGreaterThanOrEqual(3);
1178
+ expect(d.cycle[0]).toBe(d.cycle[d.cycle.length - 1]);
1179
+ }
1180
+ });
1181
+
1182
+ it('pack-subasset-index-out-of-range: detail.metaPath + sourceIndex + max', () => {
1183
+ const detail: PackErrorDetail = {
1184
+ metaPath: 'assets/tileset.png.meta.json',
1185
+ sourceIndex: 5,
1186
+ max: 3,
1187
+ };
1188
+ const err = new PackError({
1189
+ code: 'pack-subasset-index-out-of-range',
1190
+ expected: 'subAsset.sourceIndex must be < number of sources in the source file',
1191
+ hint: 'check subAssets[].sourceIndex does not exceed the actual sub-image count',
1192
+ detail,
1193
+ });
1194
+ expect(err.code).toBe('pack-subasset-index-out-of-range' satisfies PackErrorCode);
1195
+ if (err.code === 'pack-subasset-index-out-of-range') {
1196
+ const d = err.detail as { metaPath: string; sourceIndex: number; max: number };
1197
+ expect(d.metaPath).toBe('assets/tileset.png.meta.json');
1198
+ expect(d.sourceIndex).toBe(5);
1199
+ expect(d.max).toBe(3);
1200
+ }
1201
+ });
1202
+ });
1203
+ });
1204
+ }
1205
+
1206
+ {
1207
+ // ─── from guid.test.ts (__tests__/) ───
1208
+
1209
+ describe('guid.test.ts (__tests__/)', () => {
1210
+ describe('AssetGuid.parse', () => {
1211
+ it('returns Ok(AssetGuid) for a valid RFC4122 dash-form UUID', () => {
1212
+ const result = AssetGuid.parse('01957b3a-1234-7abc-89de-123456789abc');
1213
+ expect(result.ok).toBe(true);
1214
+ if (!result.ok) throw new Error('expected ok');
1215
+ expect(result.value).toBeInstanceOf(Uint8Array);
1216
+ expect(result.value.length).toBe(16);
1217
+ });
1218
+
1219
+ it('returns Err(PackError) for a non-UUID string — no throw', () => {
1220
+ const result = AssetGuid.parse('not-a-uuid');
1221
+ expect(result.ok).toBe(false);
1222
+ if (result.ok) throw new Error('expected error');
1223
+ expect(result.error.code).toBe('pack-guid-malformed');
1224
+ expect(result.error.detail.raw).toBe('not-a-uuid');
1225
+ });
1226
+
1227
+ it('returns Err(PackError) for an empty string — no throw', () => {
1228
+ const result = AssetGuid.parse('');
1229
+ expect(result.ok).toBe(false);
1230
+ if (result.ok) throw new Error('expected error');
1231
+ expect(result.error.code).toBe('pack-guid-malformed');
1232
+ expect(result.error.detail.raw).toBe('');
1233
+ });
1234
+
1235
+ it('returns Err(PackError) with pack-guid-malformed code and reason', () => {
1236
+ const result = AssetGuid.parse('zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz');
1237
+ expect(result.ok).toBe(false);
1238
+ if (result.ok) throw new Error('expected error');
1239
+ expect(result.error.code).toBe('pack-guid-malformed');
1240
+ expect(typeof result.error.detail.reason).toBe('string');
1241
+ });
1242
+ });
1243
+
1244
+ describe('AssetGuid.format', () => {
1245
+ it('returns 36-char dash-form string', () => {
1246
+ const result = AssetGuid.parse('01957b3a-1234-7abc-89de-123456789abc');
1247
+ if (!result.ok) throw new Error('expected ok');
1248
+ const formatted = AssetGuid.format(result.value);
1249
+ expect(formatted).toHaveLength(36);
1250
+ expect(formatted).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
1251
+ });
1252
+
1253
+ it('round-trips through parse and format', () => {
1254
+ const original = '01957b3a-1234-7abc-89de-123456789abc';
1255
+ const result = AssetGuid.parse(original);
1256
+ if (!result.ok) throw new Error('expected ok');
1257
+ expect(AssetGuid.format(result.value)).toBe(original);
1258
+ });
1259
+ });
1260
+
1261
+ describe('AssetGuid.equals', () => {
1262
+ it('returns true for two GUIDs parsed from same string', () => {
1263
+ const a = AssetGuid.parse('01957b3a-1234-7abc-89de-123456789abc');
1264
+ const b = AssetGuid.parse('01957b3a-1234-7abc-89de-123456789abc');
1265
+ if (!a.ok || !b.ok) throw new Error('expected ok');
1266
+ expect(AssetGuid.equals(a.value, b.value)).toBe(true);
1267
+ });
1268
+
1269
+ it('returns false for two different GUIDs', () => {
1270
+ const a = AssetGuid.parse('01957b3a-1234-7abc-89de-123456789abc');
1271
+ const b = AssetGuid.parse('aaaaaaaa-bbbb-7ccc-8ddd-eeeeeeeeeeee');
1272
+ if (!a.ok || !b.ok) throw new Error('expected ok');
1273
+ expect(AssetGuid.equals(a.value, b.value)).toBe(false);
1274
+ });
1275
+ });
1276
+
1277
+ describe('AssetGuid.random', () => {
1278
+ it('generates a UUIDv7 (version bits 4 = 0x7)', () => {
1279
+ const guid = AssetGuid.random();
1280
+ expect(guid.length).toBe(16);
1281
+ const byte6 = guid[6];
1282
+ expect(byte6! >> 4).toBe(7);
1283
+ });
1284
+
1285
+ it('generates a UUIDv7 (variant bits = 0b10xx)', () => {
1286
+ const guid = AssetGuid.random();
1287
+ const byte8 = guid[8];
1288
+ expect((byte8! >> 6) & 0b11).toBe(0b10);
1289
+ });
1290
+
1291
+ it('generates distinct values', () => {
1292
+ const a = AssetGuid.random();
1293
+ const b = AssetGuid.random();
1294
+ expect(AssetGuid.equals(a, b)).toBe(false);
1295
+ });
1296
+ });
1297
+ });
1298
+ }
1299
+
1300
+ {
1301
+ // ─── from scanner-fail-fast.test.ts (__tests__/) ───
1302
+
1303
+ const GUID_A = '018e7a4d-1234-7abc-8def-000000000001';
1304
+ const GUID_B = '018e7a4d-1234-7abc-8def-000000000002';
1305
+
1306
+ function makePackJson(guid: string, refs: string[] = []) {
1307
+ return JSON.stringify({
1308
+ schemaVersion: '1.0.0',
1309
+ kind: 'internal-text-package',
1310
+ assets: [{ guid, kind: 'MeshAsset', payload: {}, refs }],
1311
+ });
1312
+ }
1313
+
1314
+ function makeMetaJson(source: string, subAssets: unknown[] = []) {
1315
+ return JSON.stringify({
1316
+ schemaVersion: '1.0.0',
1317
+ kind: 'external-asset-package',
1318
+ importer: 'image',
1319
+ source,
1320
+ importSettings: {},
1321
+ subAssets,
1322
+ });
1323
+ }
1324
+
1325
+ describe('scanner-fail-fast.test.ts', () => {
1326
+ let tmpDir: string;
1327
+
1328
+ beforeAll(async () => {
1329
+ tmpDir = join(tmpdir(), `forgeax-scanner-fail-fast-${Date.now()}`);
1330
+ await mkdir(tmpDir, { recursive: true });
1331
+ });
1332
+ afterAll(async () => {
1333
+ await rm(tmpDir, { recursive: true, force: true });
1334
+ });
1335
+
1336
+ describe('scanner fail-fast chain', () => {
1337
+ it('(1) pack-guid-collision: two .pack.json with same GUID -> detail.paths + detail.guid', async () => {
1338
+ const dir = join(tmpDir, 'collision');
1339
+ await mkdir(dir, { recursive: true });
1340
+ await writeFile(join(dir, 'a.pack.json'), makePackJson(GUID_A));
1341
+ await writeFile(join(dir, 'b.pack.json'), makePackJson(GUID_A));
1342
+
1343
+ const result = await scan([dir]);
1344
+ expect(result.ok).toBe(false);
1345
+ if (!result.ok) {
1346
+ expect(result.error.code).toBe('pack-guid-collision');
1347
+ if (result.error.code === 'pack-guid-collision') {
1348
+ expect(result.error.detail.paths).toHaveLength(2);
1349
+ expect(result.error.detail.guid).toBe(GUID_A);
1350
+ const pathSet = new Set(result.error.detail.paths);
1351
+ expect(pathSet.has(join(dir, 'a.pack.json'))).toBe(true);
1352
+ expect(pathSet.has(join(dir, 'b.pack.json'))).toBe(true);
1353
+ }
1354
+ }
1355
+ });
1356
+
1357
+ it('(2) pack-cyclic-reference: A refs B, B refs A -> detail.cycle first === last', async () => {
1358
+ const dir = join(tmpDir, 'cycle');
1359
+ await mkdir(dir, { recursive: true });
1360
+ await writeFile(join(dir, 'a.pack.json'), makePackJson(GUID_A, [GUID_B]));
1361
+ await writeFile(join(dir, 'b.pack.json'), makePackJson(GUID_B, [GUID_A]));
1362
+
1363
+ const result = await scan([dir]);
1364
+ expect(result.ok).toBe(false);
1365
+ if (!result.ok) {
1366
+ expect(result.error.code).toBe('pack-cyclic-reference');
1367
+ if (result.error.code === 'pack-cyclic-reference') {
1368
+ const cycle = result.error.detail.cycle;
1369
+ expect(cycle.length).toBeGreaterThanOrEqual(3);
1370
+ expect(cycle[0]).toBe(cycle[cycle.length - 1]);
1371
+ }
1372
+ }
1373
+ });
1374
+
1375
+ it('(3) orphan .meta.json (no source file) -> pack-orphan-meta; missing .meta.json for existing file is ignored (INFO only)', async () => {
1376
+ const dir = join(tmpDir, 'orphan');
1377
+ await mkdir(dir, { recursive: true });
1378
+ await writeFile(join(dir, 'hero.png'), Buffer.from('fake png'));
1379
+ await writeFile(join(dir, 'ghost.png.meta.json'), makeMetaJson('ghost.png'));
1380
+
1381
+ const result = await scan([dir]);
1382
+ expect(result.ok).toBe(false);
1383
+ if (!result.ok) {
1384
+ expect(result.error.code).toBe('pack-orphan-meta');
1385
+ if (result.error.code === 'pack-orphan-meta') {
1386
+ expect(result.error.detail.metaPath).toContain('ghost.png.meta.json');
1387
+ expect(result.error.detail.expectedFile).toContain('ghost.png');
1388
+ }
1389
+ }
1390
+ });
1391
+
1392
+ it('(4) pack-malformed-meta: .meta.json missing required guid field -> detail.ajvErrors array', async () => {
1393
+ const dir = join(tmpDir, 'malformed');
1394
+ await mkdir(dir, { recursive: true });
1395
+ const badMeta = JSON.stringify({
1396
+ schemaVersion: '1.0.0',
1397
+ kind: 'external-asset-package',
1398
+ importer: 'image',
1399
+ source: 'hero.png',
1400
+ importSettings: {},
1401
+ subAssets: [{ sourceIndex: 0, kind: 'TextureAsset' }],
1402
+ });
1403
+ await writeFile(join(dir, 'hero.png.meta.json'), badMeta);
1404
+
1405
+ const result = await scan([dir]);
1406
+ expect(result.ok).toBe(false);
1407
+ if (!result.ok) {
1408
+ expect(result.error.code).toBe('pack-malformed-meta');
1409
+ if (result.error.code === 'pack-malformed-meta') {
1410
+ expect(result.error.detail.ajvErrors.length).toBeGreaterThan(0);
1411
+ expect(result.error.detail.path).toContain('hero.png.meta.json');
1412
+ }
1413
+ }
1414
+ });
1415
+ });
1416
+ });
1417
+ }
1418
+
1419
+ {
1420
+ // ─── from scanner.test.ts (__tests__/) ───
1421
+
1422
+ async function scanPaths(roots: string[]): Promise<string[]> {
1423
+ const result = await scan(roots);
1424
+ if (!result.ok) throw new Error(`scan failed unexpectedly: ${result.error.code}`);
1425
+ return result.value;
1426
+ }
1427
+
1428
+ describe('scanner.test.ts', () => {
1429
+ let sTmpDir: string | undefined;
1430
+
1431
+ afterEach(async () => {
1432
+ if (sTmpDir !== undefined) {
1433
+ await rm(sTmpDir, { recursive: true, force: true });
1434
+ sTmpDir = undefined;
1435
+ }
1436
+ });
1437
+
1438
+ async function setupTmpDir(): Promise<string> {
1439
+ sTmpDir = await mkdtemp(join(tmpdir(), 'engine-pack-scanner-test-'));
1440
+ return sTmpDir;
1441
+ }
1442
+
1443
+ describe('scan - empty directory', () => {
1444
+ it('returns empty array for an empty directory', async () => {
1445
+ const root = await setupTmpDir();
1446
+ const results = await scanPaths([root]);
1447
+ expect(results).toEqual([]);
1448
+ });
1449
+ });
1450
+
1451
+ describe('scan - basic traversal', () => {
1452
+ it('discovers .meta.json files', async () => {
1453
+ const root = await setupTmpDir();
1454
+ await writeFile(
1455
+ join(root, 'hero.png.meta.json'),
1456
+ JSON.stringify({
1457
+ schemaVersion: '1.0.0',
1458
+ kind: 'external-asset-package',
1459
+ importer: 'image',
1460
+ source: 'hero.png',
1461
+ importSettings: {},
1462
+ subAssets: [],
1463
+ }),
1464
+ );
1465
+ await writeFile(join(root, 'hero.png'), Buffer.from('fake png'));
1466
+ const results = await scanPaths([root]);
1467
+ expect(results.some((p) => p.endsWith('.meta.json'))).toBe(true);
1468
+ });
1469
+
1470
+ it('discovers .pack.json files', async () => {
1471
+ const root = await setupTmpDir();
1472
+ await writeFile(
1473
+ join(root, 'assets.pack.json'),
1474
+ JSON.stringify({ schemaVersion: '1.0.0', kind: 'internal-text-package', assets: [] }),
1475
+ );
1476
+ const results = await scanPaths([root]);
1477
+ expect(results.some((p) => p.endsWith('.pack.json'))).toBe(true);
1478
+ });
1479
+
1480
+ it('discovers both meta and pack in subdirectories', async () => {
1481
+ const root = await setupTmpDir();
1482
+ const sub = join(root, 'subdir');
1483
+ await mkdir(sub);
1484
+ await writeFile(
1485
+ join(sub, 'foo.png.meta.json'),
1486
+ JSON.stringify({
1487
+ schemaVersion: '1.0.0',
1488
+ kind: 'external-asset-package',
1489
+ importer: 'image',
1490
+ source: 'foo.png',
1491
+ importSettings: {},
1492
+ subAssets: [],
1493
+ }),
1494
+ );
1495
+ await writeFile(join(sub, 'foo.png'), Buffer.from('fake png'));
1496
+ await writeFile(
1497
+ join(sub, 'bar.pack.json'),
1498
+ JSON.stringify({ schemaVersion: '1.0.0', kind: 'internal-text-package', assets: [] }),
1499
+ );
1500
+ const results = await scanPaths([root]);
1501
+ expect(results.filter((p) => p.endsWith('.meta.json'))).toHaveLength(1);
1502
+ expect(results.filter((p) => p.endsWith('.pack.json'))).toHaveLength(1);
1503
+ });
1504
+ });
1505
+
1506
+ describe('scan - blacklist directories', () => {
1507
+ it('skips node_modules directory', async () => {
1508
+ const root = await setupTmpDir();
1509
+ const nm = join(root, 'node_modules');
1510
+ await mkdir(nm);
1511
+ await writeFile(join(nm, 'lib.meta.json'), JSON.stringify({ test: true }));
1512
+ const results = await scanPaths([root]);
1513
+ expect(results).toEqual([]);
1514
+ });
1515
+
1516
+ it('skips .git directory', async () => {
1517
+ const root = await setupTmpDir();
1518
+ const git = join(root, '.git');
1519
+ await mkdir(git);
1520
+ await writeFile(join(git, 'hooks.meta.json'), JSON.stringify({ test: true }));
1521
+ const results = await scanPaths([root]);
1522
+ expect(results).toEqual([]);
1523
+ });
1524
+
1525
+ it('skips dist directory', async () => {
1526
+ const root = await setupTmpDir();
1527
+ const dist = join(root, 'dist');
1528
+ await mkdir(dist);
1529
+ await writeFile(join(dist, 'bundle.pack.json'), JSON.stringify({ test: true }));
1530
+ const results = await scanPaths([root]);
1531
+ expect(results).toEqual([]);
1532
+ });
1533
+
1534
+ it('skips .forgeax-harness directory', async () => {
1535
+ const root = await setupTmpDir();
1536
+ const kh = join(root, '.forgeax-harness');
1537
+ await mkdir(kh);
1538
+ await writeFile(join(kh, 'state.meta.json'), JSON.stringify({ test: true }));
1539
+ const results = await scanPaths([root]);
1540
+ expect(results).toEqual([]);
1541
+ });
1542
+
1543
+ it('skips .forgeax directory', async () => {
1544
+ const root = await setupTmpDir();
1545
+ const state = join(root, '.forgeax');
1546
+ await mkdir(state);
1547
+ await writeFile(join(state, 'game.meta.json'), JSON.stringify({ test: true }));
1548
+ const results = await scanPaths([root]);
1549
+ expect(results).toEqual([]);
1550
+ });
1551
+ });
1552
+
1553
+ describe('scan - whitelist override', () => {
1554
+ it('scans a blacklisted directory when given as explicit root', async () => {
1555
+ const root = await setupTmpDir();
1556
+ const nm = join(root, 'node_modules');
1557
+ await mkdir(nm);
1558
+ await writeFile(
1559
+ join(nm, 'lib.png.meta.json'),
1560
+ JSON.stringify({
1561
+ schemaVersion: '1.0.0',
1562
+ kind: 'external-asset-package',
1563
+ importer: 'image',
1564
+ source: 'lib.png',
1565
+ importSettings: {},
1566
+ subAssets: [],
1567
+ }),
1568
+ );
1569
+ await writeFile(join(nm, 'lib.png'), Buffer.from('fake png'));
1570
+ const results = await scanPaths([nm]);
1571
+ expect(results.some((p) => p.endsWith('.meta.json'))).toBe(true);
1572
+ });
1573
+ });
1574
+
1575
+ describe('verify - importer:shader identity sidecar validation', () => {
1576
+ it('accepts valid shader sidecar without parameter schema coupling', async () => {
1577
+ const root = await setupTmpDir();
1578
+ await writeFile(join(root, 'test.wgsl'), Buffer.from('// shader'));
1579
+ await writeFile(
1580
+ join(root, 'test.material.json'),
1581
+ JSON.stringify({
1582
+ schemaVersion: '1.0.0',
1583
+ kind: 'external-asset-package',
1584
+ importer: 'shader',
1585
+ source: 'test.wgsl',
1586
+ importSettings: { materialShaderIdentifier: 'test::shader' },
1587
+ subAssets: [
1588
+ {
1589
+ guid: '00000000-0000-4000-a000-000000000001',
1590
+ sourceIndex: 0,
1591
+ kind: 'material-shader',
1592
+ },
1593
+ ],
1594
+ paramSchema: [
1595
+ { name: 'color', type: 'color' },
1596
+ { name: 'intensity', type: 'f32' },
1597
+ ],
1598
+ }),
1599
+ );
1600
+ const stdoutParts: string[] = [];
1601
+ const stderrParts: string[] = [];
1602
+ const exitCode = await runCliAsset(['verify'], {
1603
+ stdoutWrite: (l: string) => {
1604
+ stdoutParts.push(l);
1605
+ },
1606
+ stderrWrite: (l: string) => {
1607
+ stderrParts.push(l);
1608
+ },
1609
+ cwd: root,
1610
+ });
1611
+ expect(exitCode).toBe(0);
1612
+ expect(stderrParts).toEqual([]);
1613
+ expect(stdoutParts).toContain('material-validated: 0');
1614
+ });
1615
+
1616
+ it('accepts shader sidecar with missing paramSchema', async () => {
1617
+ const root = await setupTmpDir();
1618
+ await writeFile(join(root, 'test.wgsl'), Buffer.from('// shader'));
1619
+ await writeFile(
1620
+ join(root, 'test.material.json'),
1621
+ JSON.stringify({
1622
+ schemaVersion: '1.0.0',
1623
+ kind: 'external-asset-package',
1624
+ importer: 'shader',
1625
+ source: 'test.wgsl',
1626
+ importSettings: { materialShaderIdentifier: 'test::shader' },
1627
+ subAssets: [
1628
+ {
1629
+ guid: '00000000-0000-4000-a000-000000000002',
1630
+ sourceIndex: 0,
1631
+ kind: 'material-shader',
1632
+ },
1633
+ ],
1634
+ }),
1635
+ );
1636
+ const stderrParts: string[] = [];
1637
+ const stdoutParts: string[] = [];
1638
+ const exitCode = await runCliAsset(['verify'], {
1639
+ stdoutWrite: (l: string) => stdoutParts.push(l),
1640
+ stderrWrite: (l: string) => {
1641
+ stderrParts.push(l);
1642
+ },
1643
+ cwd: root,
1644
+ });
1645
+ expect(exitCode).toBe(0);
1646
+ expect(stderrParts).toEqual([]);
1647
+ expect(stdoutParts).toContain('material-validated: 0');
1648
+ });
1649
+
1650
+ it('ignores legacy shader paramSchema types', async () => {
1651
+ const root = await setupTmpDir();
1652
+ await writeFile(join(root, 'test.wgsl'), Buffer.from('// shader'));
1653
+ await writeFile(
1654
+ join(root, 'test.material.json'),
1655
+ JSON.stringify({
1656
+ schemaVersion: '1.0.0',
1657
+ kind: 'external-asset-package',
1658
+ importer: 'shader',
1659
+ source: 'test.wgsl',
1660
+ importSettings: { materialShaderIdentifier: 'test::shader' },
1661
+ subAssets: [
1662
+ {
1663
+ guid: '00000000-0000-4000-a000-000000000003',
1664
+ sourceIndex: 0,
1665
+ kind: 'material-shader',
1666
+ },
1667
+ ],
1668
+ paramSchema: [{ name: 'color', type: 'float4' }],
1669
+ }),
1670
+ );
1671
+ const stderrParts: string[] = [];
1672
+ const exitCode = await runCliAsset(['verify'], {
1673
+ stdoutWrite: () => {},
1674
+ stderrWrite: (l: string) => {
1675
+ stderrParts.push(l);
1676
+ },
1677
+ cwd: root,
1678
+ });
1679
+ expect(exitCode).toBe(0);
1680
+ expect(stderrParts).toEqual([]);
1681
+ });
1682
+
1683
+ it('ignores legacy shader paramSchema shape', async () => {
1684
+ const root = await setupTmpDir();
1685
+ await writeFile(join(root, 'test.wgsl'), Buffer.from('// shader'));
1686
+ await writeFile(
1687
+ join(root, 'test.material.json'),
1688
+ JSON.stringify({
1689
+ schemaVersion: '1.0.0',
1690
+ kind: 'external-asset-package',
1691
+ importer: 'shader',
1692
+ source: 'test.wgsl',
1693
+ importSettings: { materialShaderIdentifier: 'test::shader' },
1694
+ subAssets: [
1695
+ {
1696
+ guid: '00000000-0000-4000-a000-000000000004',
1697
+ sourceIndex: 0,
1698
+ kind: 'material-shader',
1699
+ },
1700
+ ],
1701
+ paramSchema: [{ type: 'color' }],
1702
+ }),
1703
+ );
1704
+ const stderrParts: string[] = [];
1705
+ const exitCode = await runCliAsset(['verify'], {
1706
+ stdoutWrite: () => {},
1707
+ stderrWrite: (l: string) => {
1708
+ stderrParts.push(l);
1709
+ },
1710
+ cwd: root,
1711
+ });
1712
+ expect(exitCode).toBe(0);
1713
+ expect(stderrParts).toEqual([]);
1714
+ });
1715
+
1716
+ it('accepts an empty legacy shader paramSchema array', async () => {
1717
+ const root = await setupTmpDir();
1718
+ await writeFile(join(root, 'test.wgsl'), Buffer.from('// shader'));
1719
+ await writeFile(
1720
+ join(root, 'test.material.json'),
1721
+ JSON.stringify({
1722
+ schemaVersion: '1.0.0',
1723
+ kind: 'external-asset-package',
1724
+ importer: 'shader',
1725
+ source: 'test.wgsl',
1726
+ importSettings: { materialShaderIdentifier: 'test::shader' },
1727
+ subAssets: [
1728
+ {
1729
+ guid: '00000000-0000-4000-a000-000000000005',
1730
+ sourceIndex: 0,
1731
+ kind: 'material-shader',
1732
+ },
1733
+ ],
1734
+ paramSchema: [],
1735
+ }),
1736
+ );
1737
+ const stderrParts: string[] = [];
1738
+ const exitCode = await runCliAsset(['verify'], {
1739
+ stdoutWrite: () => {},
1740
+ stderrWrite: (l: string) => {
1741
+ stderrParts.push(l);
1742
+ },
1743
+ cwd: root,
1744
+ });
1745
+ expect(exitCode).toBe(0);
1746
+ expect(stderrParts).toEqual([]);
1747
+ });
1748
+ });
1749
+ });
1750
+ }
1751
+
1752
+ {
1753
+ // ─── from schema.test.ts (__tests__/) ───
1754
+
1755
+ const validMeta1 = {
1756
+ schemaVersion: '1.0.0',
1757
+ kind: 'external-asset-package',
1758
+ importer: 'image',
1759
+ source: 'hero.png',
1760
+ importSettings: { compression: 'zstd', mipmap: true },
1761
+ subAssets: [{ guid: '01957b3a-1234-7abc-89de-123456789abc', sourceIndex: 0, kind: 'mesh' }],
1762
+ };
1763
+ const validMeta2 = {
1764
+ schemaVersion: '1.0.0',
1765
+ kind: 'external-asset-package',
1766
+ importer: 'image',
1767
+ source: 'mesh.glb',
1768
+ importSettings: {},
1769
+ subAssets: [],
1770
+ };
1771
+ const validMeta3 = {
1772
+ schemaVersion: '1.0.0',
1773
+ kind: 'external-asset-package',
1774
+ importer: 'image',
1775
+ source: 'audio/bg.ogg',
1776
+ importSettings: { format: 'opus' },
1777
+ subAssets: [
1778
+ { guid: 'a1b2c3d4-e5f6-7a8b-9c0d-e1f2a3b4c5d6', sourceIndex: 0, kind: 'material' },
1779
+ { guid: 'b2c3d4e5-f6a7-7b8c-9d0e-f1a2b3c4d5e6', sourceIndex: 0, kind: 'scene' },
1780
+ ],
1781
+ };
1782
+ const validPack1 = {
1783
+ schemaVersion: '1.0.0',
1784
+ kind: 'internal-text-package',
1785
+ assets: [
1786
+ {
1787
+ guid: '01957b3a-1234-7abc-89de-123456789abc',
1788
+ kind: 'MaterialAsset',
1789
+ payload: { albedo: [1, 0, 0, 1] },
1790
+ refs: ['a1b2c3d4-e5f6-7a8b-9c0d-e1f2a3b4c5d6'],
1791
+ },
1792
+ ],
1793
+ };
1794
+ const validPack2 = { schemaVersion: '1.0.0', kind: 'internal-text-package', assets: [] };
1795
+ const validPack3 = {
1796
+ schemaVersion: '1.0.0',
1797
+ kind: 'internal-text-package',
1798
+ assets: [
1799
+ {
1800
+ guid: 'aaaaaaaa-bbbb-7ccc-8ddd-eeeeeeeeeeee',
1801
+ kind: 'MeshAsset',
1802
+ payload: { vertexCount: 24 },
1803
+ refs: [],
1804
+ },
1805
+ {
1806
+ guid: '11111111-2222-7333-8444-555555555555',
1807
+ kind: 'ScriptAsset',
1808
+ payload: {},
1809
+ refs: ['aaaaaaaa-bbbb-7ccc-8ddd-eeeeeeeeeeee', '01957b3a-1234-7abc-89de-123456789abc'],
1810
+ },
1811
+ ],
1812
+ };
1813
+
1814
+ const invalidMeta_missingGuid = {
1815
+ schemaVersion: '1.0.0',
1816
+ kind: 'external-asset-package',
1817
+ importer: 'image',
1818
+ source: 'hero.png',
1819
+ importSettings: {},
1820
+ subAssets: [{ sourceIndex: 0, kind: 'mesh' }],
1821
+ };
1822
+ const invalidMeta_badGuid = {
1823
+ schemaVersion: '1.0.0',
1824
+ kind: 'external-asset-package',
1825
+ importer: 'image',
1826
+ source: 'hero.png',
1827
+ importSettings: {},
1828
+ subAssets: [{ guid: 'not-a-valid-uuid', sourceIndex: 0, kind: 'mesh' }],
1829
+ };
1830
+ const invalidMeta_badSourceIndex = {
1831
+ schemaVersion: '1.0.0',
1832
+ kind: 'external-asset-package',
1833
+ importer: 'image',
1834
+ source: 'hero.png',
1835
+ importSettings: {},
1836
+ subAssets: [
1837
+ { guid: '01957b3a-1234-7abc-89de-123456789abc', sourceIndex: 'zero', kind: 'mesh' },
1838
+ ],
1839
+ };
1840
+ const invalidPack_builtinSentinelInRefs = {
1841
+ schemaVersion: '1.0.0',
1842
+ kind: 'internal-text-package',
1843
+ assets: [
1844
+ {
1845
+ guid: '01957b3a-1234-7abc-89de-123456789abc',
1846
+ kind: 'MaterialAsset',
1847
+ payload: {},
1848
+ refs: ['@builtin/HANDLE_CUBE'],
1849
+ },
1850
+ ],
1851
+ };
1852
+ const invalidPack_badGuid = {
1853
+ schemaVersion: '1.0.0',
1854
+ kind: 'internal-text-package',
1855
+ assets: [
1856
+ {
1857
+ guid: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxxxxxxx',
1858
+ kind: 'MeshAsset',
1859
+ payload: {},
1860
+ refs: [],
1861
+ },
1862
+ ],
1863
+ };
1864
+ const invalidPack_missingAssets = { schemaVersion: '1.0.0', kind: 'internal-text-package' };
1865
+
1866
+ describe('schema.test.ts', () => {
1867
+ describe('schema validation - meta.json', () => {
1868
+ it('accepts valid meta (hero.png with subAssets)', () => {
1869
+ expect(validateMeta(validMeta1)).toBe(true);
1870
+ });
1871
+ it('accepts valid meta with empty subAssets', () => {
1872
+ expect(validateMeta(validMeta2)).toBe(true);
1873
+ });
1874
+ it('accepts valid meta with multiple subAssets', () => {
1875
+ expect(validateMeta(validMeta3)).toBe(true);
1876
+ });
1877
+ it('rejects meta with missing guid in subAssets', () => {
1878
+ expect(validateMeta(invalidMeta_missingGuid)).toBe(false);
1879
+ });
1880
+ it('rejects meta with non-RFC4122 guid format', () => {
1881
+ expect(validateMeta(invalidMeta_badGuid)).toBe(false);
1882
+ });
1883
+ it('rejects meta with sourceIndex as string', () => {
1884
+ expect(validateMeta(invalidMeta_badSourceIndex)).toBe(false);
1885
+ });
1886
+ });
1887
+
1888
+ describe('schema validation - pack.json', () => {
1889
+ it('accepts valid pack with one asset and refs', () => {
1890
+ expect(validatePack(validPack1)).toBe(true);
1891
+ });
1892
+ it('accepts valid pack with empty assets', () => {
1893
+ expect(validatePack(validPack2)).toBe(true);
1894
+ });
1895
+ it('accepts valid pack with multiple assets', () => {
1896
+ expect(validatePack(validPack3)).toBe(true);
1897
+ });
1898
+ it('rejects pack with @builtin/ sentinel in refs (AC-11)', () => {
1899
+ expect(validatePack(invalidPack_builtinSentinelInRefs)).toBe(false);
1900
+ });
1901
+ it('rejects pack with invalid guid format in assets', () => {
1902
+ expect(validatePack(invalidPack_badGuid)).toBe(false);
1903
+ });
1904
+ it('rejects pack missing required assets field', () => {
1905
+ expect(validatePack(invalidPack_missingAssets)).toBe(false);
1906
+ });
1907
+ });
1908
+ });
1909
+ }
1910
+
1911
+ {
1912
+ // ─── AC-17:+AC-5+AC-10 scanner orphan with resolveAssetSource (w14+w15) ───
1913
+
1914
+ interface TestMeta {
1915
+ schemaVersion: string;
1916
+ kind: string;
1917
+ importer: string;
1918
+ source?: string;
1919
+ importSettings: Record<string, unknown>;
1920
+ subAssets: { guid: string; sourceIndex: number; kind: string }[];
1921
+ }
1922
+
1923
+ function makeMeta(overrides: Partial<TestMeta> = {}): string {
1924
+ return JSON.stringify({
1925
+ schemaVersion: '1.0.0',
1926
+ kind: 'external-asset-package',
1927
+ importer: 'image',
1928
+ source: 'test.png',
1929
+ importSettings: {},
1930
+ subAssets: [
1931
+ { guid: 'aaaaaaaa-bbbb-4000-8000-000000000001', sourceIndex: 0, kind: 'texture' },
1932
+ ],
1933
+ ...overrides,
1934
+ });
1935
+ }
1936
+
1937
+ describe('w14-scanner-orphan-optional.test.ts — AC-17 falsification', () => {
1938
+ let tmpDir: string;
1939
+
1940
+ beforeEach(async () => {
1941
+ tmpDir = await mkdtemp(join(tmpdir(), 'forgeax-ac17-'));
1942
+ });
1943
+ afterEach(async () => {
1944
+ await rm(tmpDir, { recursive: true, force: true });
1945
+ });
1946
+
1947
+ it('AC-17: omitted source + no companion file → pack-orphan-meta with derived expectedFile', async () => {
1948
+ const metaContent = makeMeta({ source: undefined });
1949
+ await writeFile(join(tmpDir, 'ghost.png.meta.json'), metaContent);
1950
+ // Do NOT create ghost.png — omitted source derives to ghost.png, which is missing
1951
+
1952
+ const result = await scan([tmpDir]);
1953
+ expect(result.ok).toBe(false);
1954
+ if (!result.ok) {
1955
+ expect(result.error.code).toBe('pack-orphan-meta');
1956
+ if (result.error.code === 'pack-orphan-meta') {
1957
+ expect(result.error.detail.metaPath).toContain('ghost.png.meta.json');
1958
+ expect(result.error.detail.expectedFile).toContain('ghost.png');
1959
+ expect(result.error.detail.expectedFile.endsWith('ghost.png')).toBe(true);
1960
+ }
1961
+ }
1962
+ });
1963
+
1964
+ it('AC-17: omitted source + companion file exists → scan passes (derivation succeeds)', async () => {
1965
+ const metaContent = makeMeta({ source: undefined });
1966
+ await writeFile(join(tmpDir, 'test.png.meta.json'), metaContent);
1967
+ await writeFile(join(tmpDir, 'test.png'), Buffer.from('fake png'));
1968
+
1969
+ const result = await scan([tmpDir]);
1970
+ expect(result.ok).toBe(true);
1971
+ });
1972
+
1973
+ it('AC-17: explicit source=hero.png + companion file → scan passes (backward compat)', async () => {
1974
+ await writeFile(join(tmpDir, 'hero.png.meta.json'), makeMeta({ source: 'hero.png' }));
1975
+ await writeFile(join(tmpDir, 'hero.png'), Buffer.from('fake png'));
1976
+
1977
+ const result = await scan([tmpDir]);
1978
+ expect(result.ok).toBe(true);
1979
+ });
1980
+ });
1981
+
1982
+ describe('w15-scanner-path-integration.test.ts — AC-5+AC-10', () => {
1983
+ let tmpDir: string;
1984
+
1985
+ beforeEach(async () => {
1986
+ tmpDir = await mkdtemp(join(tmpdir(), 'forgeax-ac5-'));
1987
+ });
1988
+ afterEach(async () => {
1989
+ await rm(tmpDir, { recursive: true, force: true });
1990
+ });
1991
+
1992
+ it('AC-5: @name/ path resolves correctly when file exists', async () => {
1993
+ const libDir = join(tmpDir, 'lib');
1994
+ await mkdir(libDir);
1995
+ await writeFile(join(libDir, 'shared.png'), Buffer.from('fake png'));
1996
+ const metaContent = JSON.stringify({
1997
+ schemaVersion: '1.0.0',
1998
+ kind: 'external-asset-package',
1999
+ importer: 'image',
2000
+ source: '@shared/shared.png',
2001
+ importSettings: {},
2002
+ subAssets: [
2003
+ { guid: 'aaaaaaaa-bbbb-4000-8000-000000000001', sourceIndex: 0, kind: 'texture' },
2004
+ ],
2005
+ });
2006
+ await writeFile(join(tmpDir, 'ref.meta.json'), metaContent);
2007
+
2008
+ // The scan runs inside its own process.cwd() — paths table resolution uses cwd
2009
+ // The tmpDir is not the cwd, so @shared/ will report pack-unknown-path.
2010
+ // AC-5: we need the path table to be configured at the scan's cwd.
2011
+ // For a unit test, we verify instead via resolveAssetSource directly:
2012
+ const { resolveAssetSource: resolveAS } = await import('../resolve-asset-source.js');
2013
+ const paths = { shared: libDir };
2014
+ const result = resolveAS(join(tmpDir, 'ref.meta.json'), '@shared/shared.png', paths);
2015
+ expect(result.ok).toBe(true);
2016
+ if (result.ok) {
2017
+ expect(result.value).toBe(join(libDir, 'shared.png'));
2018
+ }
2019
+ });
2020
+
2021
+ it('AC-10: path configured but file missing → pack-orphan-meta', async () => {
2022
+ const libDir = join(tmpDir, 'lib');
2023
+ await mkdir(libDir);
2024
+ // Do NOT create missing.png in libDir
2025
+ const metaContent = JSON.stringify({
2026
+ schemaVersion: '1.0.0',
2027
+ kind: 'external-asset-package',
2028
+ importer: 'image',
2029
+ source: '@shared/missing.png',
2030
+ importSettings: {},
2031
+ subAssets: [
2032
+ { guid: 'aaaaaaaa-bbbb-4000-8000-000000000001', sourceIndex: 0, kind: 'texture' },
2033
+ ],
2034
+ });
2035
+ await writeFile(join(tmpDir, 'ref.meta.json'), metaContent);
2036
+
2037
+ // resolveAssetSource resolves the path but does not stat — the resolved path will be returned ok
2038
+ const { resolveAssetSource: resolveAS } = await import('../resolve-asset-source.js');
2039
+ const paths = { shared: libDir };
2040
+ const result = resolveAS(join(tmpDir, 'ref.meta.json'), '@shared/missing.png', paths);
2041
+ expect(result.ok).toBe(true);
2042
+ if (result.ok) {
2043
+ expect(result.value).toBe(join(libDir, 'missing.png'));
2044
+ // The actual orphan check (stat) is done by the scanner caller
2045
+ }
2046
+ });
2047
+
2048
+ it('AC-10: scanner reports pack-orphan-meta when resolved path file does not exist', async () => {
2049
+ const metaContent = makeMeta({ source: 'nowhere.png' });
2050
+ await writeFile(join(tmpDir, 'orphan.meta.json'), metaContent);
2051
+ // Do NOT create nowhere.png
2052
+
2053
+ const result = await scan([tmpDir]);
2054
+ expect(result.ok).toBe(false);
2055
+ if (!result.ok) {
2056
+ expect(result.error.code).toBe('pack-orphan-meta');
2057
+ if (result.error.code === 'pack-orphan-meta') {
2058
+ expect(result.error.detail.expectedFile).toContain('nowhere.png');
2059
+ }
2060
+ }
2061
+ });
2062
+ });
2063
+
2064
+ describe('scanner @name/ end-to-end via cwd package.json — AC-5/AC-8', () => {
2065
+ let tmpDir: string;
2066
+ let originalCwd: string;
2067
+
2068
+ beforeEach(async () => {
2069
+ originalCwd = process.cwd();
2070
+ tmpDir = await mkdtemp(join(tmpdir(), 'forgeax-ac5e2e-'));
2071
+ process.chdir(tmpDir);
2072
+ });
2073
+ afterEach(async () => {
2074
+ process.chdir(originalCwd);
2075
+ await rm(tmpDir, { recursive: true, force: true });
2076
+ });
2077
+
2078
+ it('AC-5: scan() passes orphan check for @name/ meta when cwd package.json path table resolves to an existing file', async () => {
2079
+ const sharedDir = join(tmpDir, 'shared-lib', 'assets');
2080
+ await mkdir(sharedDir, { recursive: true });
2081
+ await writeFile(join(sharedDir, 'cross.png'), Buffer.from('fake png'));
2082
+ await writeFile(
2083
+ join(tmpDir, 'package.json'),
2084
+ JSON.stringify({
2085
+ name: 'ac5-e2e-tmp',
2086
+ forgeax: { assets: { paths: { shared: 'shared-lib/assets' } } },
2087
+ }),
2088
+ );
2089
+ const scanRoot = join(tmpDir, 'metas');
2090
+ await mkdir(scanRoot, { recursive: true });
2091
+ await writeFile(
2092
+ join(scanRoot, 'cross.png.meta.json'),
2093
+ JSON.stringify({
2094
+ schemaVersion: '1.0.0',
2095
+ kind: 'external-asset-package',
2096
+ importer: 'image',
2097
+ source: '@shared/cross.png',
2098
+ importSettings: {},
2099
+ subAssets: [
2100
+ { guid: 'aaaaaaaa-bbbb-4000-8000-000000000001', sourceIndex: 0, kind: 'texture' },
2101
+ ],
2102
+ }),
2103
+ );
2104
+
2105
+ const result = await scan([scanRoot]);
2106
+ expect(result.ok).toBe(true);
2107
+ });
2108
+
2109
+ it('AC-8: scan() reports pack-unknown-path when @name/ references an undeclared path name', async () => {
2110
+ await writeFile(
2111
+ join(tmpDir, 'package.json'),
2112
+ JSON.stringify({ name: 'ac8-e2e-tmp', forgeax: { assets: { paths: {} } } }),
2113
+ );
2114
+ const scanRoot = join(tmpDir, 'metas');
2115
+ await mkdir(scanRoot, { recursive: true });
2116
+ await writeFile(
2117
+ join(scanRoot, 'x.png.meta.json'),
2118
+ JSON.stringify({
2119
+ schemaVersion: '1.0.0',
2120
+ kind: 'external-asset-package',
2121
+ importer: 'image',
2122
+ source: '@nope/x.png',
2123
+ importSettings: {},
2124
+ subAssets: [
2125
+ { guid: 'aaaaaaaa-bbbb-4000-8000-000000000001', sourceIndex: 0, kind: 'texture' },
2126
+ ],
2127
+ }),
2128
+ );
2129
+
2130
+ const result = await scan([scanRoot]);
2131
+ expect(result.ok).toBe(false);
2132
+ if (!result.ok) {
2133
+ expect(result.error.code).toBe('pack-unknown-path');
2134
+ }
2135
+ });
2136
+ });
2137
+ }
2138
+
2139
+ {
2140
+ // ─── AC-1 regression w15 — resolveAssetSource explicit source backwards compat ───
2141
+
2142
+ describe('w15-AC-1-regression.test.ts', () => {
2143
+ it('AC-1: explicit source=foo.png with resolveAssetSource == resolve(metaDir, foo.png)', async () => {
2144
+ const { resolveAssetSource: resolveAS } = await import('../resolve-asset-source.js');
2145
+ const { resolve } = await import('node:path');
2146
+ const metaPath = '/home/user/assets/foo.png.meta.json';
2147
+ const result = resolveAS(metaPath, 'foo.png', {});
2148
+ expect(result.ok).toBe(true);
2149
+ if (result.ok) {
2150
+ expect(result.value).toBe(resolve('/home/user/assets', 'foo.png'));
2151
+ }
2152
+ });
2153
+
2154
+ it('AC-1: explicit source=sub/deep.png with resolveAssetSource == resolve(metaDir, sub/deep.png)', async () => {
2155
+ const { resolveAssetSource: resolveAS } = await import('../resolve-asset-source.js');
2156
+ const { resolve } = await import('node:path');
2157
+ const metaPath = '/home/user/assets/main.meta.json';
2158
+ const result = resolveAS(metaPath, 'sub/deep.png', {});
2159
+ expect(result.ok).toBe(true);
2160
+ if (result.ok) {
2161
+ expect(result.value).toBe(resolve('/home/user/assets', 'sub/deep.png'));
2162
+ }
2163
+ });
2164
+
2165
+ it('AC-1: omitted source derives from meta filename', async () => {
2166
+ const { resolveAssetSource: resolveAS } = await import('../resolve-asset-source.js');
2167
+ const { resolve } = await import('node:path');
2168
+ const metaPath = '/home/user/assets/foo.png.meta.json';
2169
+ const result = resolveAS(metaPath, undefined, {});
2170
+ expect(result.ok).toBe(true);
2171
+ if (result.ok) {
2172
+ expect(result.value).toBe(resolve('/home/user/assets', 'foo.png'));
2173
+ }
2174
+ });
2175
+ });
2176
+
2177
+ // ─── AC-5/AC-10 source path table scanner integration (w15) ───
2178
+
2179
+ describe('w15-AC-5-AC-10-path-table.test.ts', () => {
2180
+ it('AC-5: @shared/ resource resolves correctly when path table configured', async () => {
2181
+ const { resolveAssetSource: resolveAS } = await import('../resolve-asset-source.js');
2182
+ const { resolve } = await import('node:path');
2183
+ const metaPath = '/project/assets/ref.meta.json';
2184
+ const paths = { shared: '/project/lib' };
2185
+ const result = resolveAS(metaPath, '@shared/tex.png', paths);
2186
+ expect(result.ok).toBe(true);
2187
+ if (result.ok) {
2188
+ expect(result.value).toBe(resolve('/project/lib', 'tex.png'));
2189
+ }
2190
+ });
2191
+
2192
+ it('AC-5: @shared/ with nested rest path', async () => {
2193
+ const { resolveAssetSource: resolveAS } = await import('../resolve-asset-source.js');
2194
+ const { resolve } = await import('node:path');
2195
+ const metaPath = '/project/assets/ref.meta.json';
2196
+ const paths = { shared: '/project/lib' };
2197
+ const result = resolveAS(metaPath, '@shared/sub/dir/tex.png', paths);
2198
+ expect(result.ok).toBe(true);
2199
+ if (result.ok) {
2200
+ expect(result.value).toBe(resolve('/project/lib', 'sub/dir/tex.png'));
2201
+ }
2202
+ });
2203
+
2204
+ it('AC-10: @unknown/ path name → pack-unknown-path error', async () => {
2205
+ const { resolveAssetSource: resolveAS } = await import('../resolve-asset-source.js');
2206
+ const metaPath = '/project/assets/ref.meta.json';
2207
+ const result = resolveAS(metaPath, '@nope/tex.png', {});
2208
+ expect(result.ok).toBe(false);
2209
+ if (!result.ok) {
2210
+ expect(result.error.code).toBe('pack-unknown-path');
2211
+ if (result.error.code === 'pack-unknown-path') {
2212
+ expect(result.error.detail.pathName).toBe('nope');
2213
+ expect(result.error.detail.knownNames).toEqual([]);
2214
+ }
2215
+ }
2216
+ });
2217
+ });
2218
+ }