@forgeax/engine-import 0.0.0-dev.8d955ade1c79

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 (118) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +227 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/decode-image-owner.test-d.d.ts +2 -0
  5. package/dist/__tests__/decode-image-owner.test-d.d.ts.map +1 -0
  6. package/dist/__tests__/import-contract-migration.test.d.ts +2 -0
  7. package/dist/__tests__/import-contract-migration.test.d.ts.map +1 -0
  8. package/dist/__tests__/import-dependencies.test.d.ts +2 -0
  9. package/dist/__tests__/import-dependencies.test.d.ts.map +1 -0
  10. package/dist/__tests__/import-finalizer-digest-retry.test.d.ts +2 -0
  11. package/dist/__tests__/import-finalizer-digest-retry.test.d.ts.map +1 -0
  12. package/dist/__tests__/import-local-artifacts.test.d.ts +2 -0
  13. package/dist/__tests__/import-local-artifacts.test.d.ts.map +1 -0
  14. package/dist/__tests__/import.unit.test.d.ts +2 -0
  15. package/dist/__tests__/import.unit.test.d.ts.map +1 -0
  16. package/dist/__tests__/mesh-bin-contract.test.d.ts +2 -0
  17. package/dist/__tests__/mesh-bin-contract.test.d.ts.map +1 -0
  18. package/dist/__tests__/mesh-bin-morph-v3.test.d.ts +2 -0
  19. package/dist/__tests__/mesh-bin-morph-v3.test.d.ts.map +1 -0
  20. package/dist/__tests__/mesh-bin.test.d.ts +2 -0
  21. package/dist/__tests__/mesh-bin.test.d.ts.map +1 -0
  22. package/dist/__tests__/owner-chain.integration.test.d.ts +2 -0
  23. package/dist/__tests__/owner-chain.integration.test.d.ts.map +1 -0
  24. package/dist/__tests__/scriptable-pack-host.unit.test.d.ts +2 -0
  25. package/dist/__tests__/scriptable-pack-host.unit.test.d.ts.map +1 -0
  26. package/dist/__tests__/scriptable-pack-output-producers.unit.test.d.ts +2 -0
  27. package/dist/__tests__/scriptable-pack-output-producers.unit.test.d.ts.map +1 -0
  28. package/dist/__tests__/scriptable-pack-product.contract.test.d.ts +2 -0
  29. package/dist/__tests__/scriptable-pack-product.contract.test.d.ts.map +1 -0
  30. package/dist/__tests__/scriptable-pack-production-producers.unit.test.d.ts +2 -0
  31. package/dist/__tests__/scriptable-pack-production-producers.unit.test.d.ts.map +1 -0
  32. package/dist/__tests__/scriptable-pack-staged-snapshot.unit.test.d.ts +2 -0
  33. package/dist/__tests__/scriptable-pack-staged-snapshot.unit.test.d.ts.map +1 -0
  34. package/dist/__tests__/scriptable-pack.integration.test.d.ts +2 -0
  35. package/dist/__tests__/scriptable-pack.integration.test.d.ts.map +1 -0
  36. package/dist/__tests__/source-overrides-import-context.unit.test.d.ts +2 -0
  37. package/dist/__tests__/source-overrides-import-context.unit.test.d.ts.map +1 -0
  38. package/dist/__tests__/source-package-publication.integration.test.d.ts +2 -0
  39. package/dist/__tests__/source-package-publication.integration.test.d.ts.map +1 -0
  40. package/dist/browser.d.ts +5 -0
  41. package/dist/browser.d.ts.map +1 -0
  42. package/dist/browser.mjs +782 -0
  43. package/dist/browser.mjs.map +1 -0
  44. package/dist/build-production.d.ts +48 -0
  45. package/dist/build-production.d.ts.map +1 -0
  46. package/dist/catalog-importer-policy.d.ts +13 -0
  47. package/dist/catalog-importer-policy.d.ts.map +1 -0
  48. package/dist/catalog-inventory.d.ts +4 -0
  49. package/dist/catalog-inventory.d.ts.map +1 -0
  50. package/dist/import-product.d.ts +27 -0
  51. package/dist/import-product.d.ts.map +1 -0
  52. package/dist/import-runner.d.ts +136 -0
  53. package/dist/import-runner.d.ts.map +1 -0
  54. package/dist/importer-registry.d.ts +48 -0
  55. package/dist/importer-registry.d.ts.map +1 -0
  56. package/dist/index.d.ts +19 -0
  57. package/dist/index.d.ts.map +1 -0
  58. package/dist/index.mjs +2849 -0
  59. package/dist/index.mjs.map +1 -0
  60. package/dist/mesh-bin.d.ts +19 -0
  61. package/dist/mesh-bin.d.ts.map +1 -0
  62. package/dist/mesh-bin.mjs +182 -0
  63. package/dist/mesh-bin.mjs.map +1 -0
  64. package/dist/pack-projection.d.ts +5 -0
  65. package/dist/pack-projection.d.ts.map +1 -0
  66. package/dist/scriptable-pack-host.d.ts +72 -0
  67. package/dist/scriptable-pack-host.d.ts.map +1 -0
  68. package/dist/scriptable-pack-output-producers.d.ts +7 -0
  69. package/dist/scriptable-pack-output-producers.d.ts.map +1 -0
  70. package/dist/scriptable-pack-staged-snapshot.d.ts +21 -0
  71. package/dist/scriptable-pack-staged-snapshot.d.ts.map +1 -0
  72. package/dist/scriptable-pack.d.ts +141 -0
  73. package/dist/scriptable-pack.d.ts.map +1 -0
  74. package/dist/scriptable-source-package.d.ts +14 -0
  75. package/dist/scriptable-source-package.d.ts.map +1 -0
  76. package/dist/source-package-errors.d.ts +52 -0
  77. package/dist/source-package-errors.d.ts.map +1 -0
  78. package/dist/source-package-publication.d.ts +63 -0
  79. package/dist/source-package-publication.d.ts.map +1 -0
  80. package/dist/source-package.d.ts +60 -0
  81. package/dist/source-package.d.ts.map +1 -0
  82. package/package.json +69 -0
  83. package/src/__tests__/decode-image-owner.test-d.ts +53 -0
  84. package/src/__tests__/import-contract-migration.test.ts +223 -0
  85. package/src/__tests__/import-dependencies.test.ts +65 -0
  86. package/src/__tests__/import-finalizer-digest-retry.test.ts +113 -0
  87. package/src/__tests__/import-local-artifacts.test.ts +102 -0
  88. package/src/__tests__/import.unit.test.ts +429 -0
  89. package/src/__tests__/mesh-bin-contract.test.ts +95 -0
  90. package/src/__tests__/mesh-bin-morph-v3.test.ts +44 -0
  91. package/src/__tests__/mesh-bin.test.ts +102 -0
  92. package/src/__tests__/owner-chain.integration.test.ts +37 -0
  93. package/src/__tests__/scriptable-pack-host.unit.test.ts +86 -0
  94. package/src/__tests__/scriptable-pack-output-producers.unit.test.ts +190 -0
  95. package/src/__tests__/scriptable-pack-product.contract.test.ts +47 -0
  96. package/src/__tests__/scriptable-pack-production-producers.unit.test.ts +488 -0
  97. package/src/__tests__/scriptable-pack-staged-snapshot.unit.test.ts +290 -0
  98. package/src/__tests__/scriptable-pack.integration.test.ts +435 -0
  99. package/src/__tests__/source-overrides-import-context.unit.test.ts +92 -0
  100. package/src/__tests__/source-package-publication.integration.test.ts +75 -0
  101. package/src/browser.ts +14 -0
  102. package/src/build-production.ts +493 -0
  103. package/src/catalog-importer-policy.ts +58 -0
  104. package/src/catalog-inventory.ts +24 -0
  105. package/src/import-product.ts +163 -0
  106. package/src/import-runner.ts +749 -0
  107. package/src/importer-registry.ts +103 -0
  108. package/src/index.ts +175 -0
  109. package/src/mesh-bin.ts +229 -0
  110. package/src/pack-projection.ts +21 -0
  111. package/src/scriptable-pack-host.ts +544 -0
  112. package/src/scriptable-pack-output-producers.ts +456 -0
  113. package/src/scriptable-pack-staged-snapshot.ts +172 -0
  114. package/src/scriptable-pack.ts +626 -0
  115. package/src/scriptable-source-package.ts +88 -0
  116. package/src/source-package-errors.ts +201 -0
  117. package/src/source-package-publication.ts +326 -0
  118. package/src/source-package.ts +187 -0
@@ -0,0 +1,749 @@
1
+ // @forgeax/engine-import - import runner (feat-20260603-asset-import-loader-injection M2 / w15).
2
+ //
3
+ // The build-time orchestration that turns one parsed `*.meta.json` sidecar
4
+ // into the logical Pack v2 package. It is the consumer side of
5
+ // the ImporterRegistry: it reads `meta.importer`, looks up the registered
6
+ // Importer, calls `importer.import(ctx)`, enforces the GUID import-stable iron
7
+ // law against the produced asset set, then folds the produced PODs into
8
+ // logical `assets[]` rows. Artifact bytes stay with their owning asset;
9
+ // final paths and integrity belong to M3.
10
+ //
11
+ // Error model (charter P3, ImportErrorCode 5 closed members):
12
+ // - importer-not-registered : registry.get(meta.importer) === undefined
13
+ // - source-read-failed : ctx.readSource() failed
14
+ // - import-internal-error : importer conversion or CookProduct finalization threw (never bare-throws out)
15
+ // - guid-mismatch : produced a GUID not declared in subAssets[]
16
+ // - import-produced-no-assets: produced [], or omitted a declared GUID
17
+ //
18
+ // The reserved key `importer: 'shader'` is skipped (plan-strategy D-4 /
19
+ // research Finding 10): shader sidecars are consumed by the orthogonal
20
+ // `@forgeax/engine-vite-plugin-shader` transform pipeline, never by asset
21
+ // import. `runImport` returns `{ ok: true, value: { skipped: 'shader' } }`
22
+ // for them so the caller can account for the sidecar without writing a DDC.
23
+
24
+ import type {
25
+ AssetRelation,
26
+ CatalogDiagnostic,
27
+ CookProduct,
28
+ ImportContext,
29
+ ImportDiagnostic,
30
+ ImportError as ImportErrorType,
31
+ ImportedArtifactBody,
32
+ ImportProduct,
33
+ ProviderProvenance,
34
+ ResourceRevision,
35
+ SourceOverrideMap,
36
+ } from '@forgeax/engine-types';
37
+ import {
38
+ canonicalizeSourceOverrides,
39
+ IMPORT_ERROR_HINTS,
40
+ ImportError,
41
+ validateSourceOverrideMap,
42
+ } from '@forgeax/engine-types';
43
+ import {
44
+ createImportProduct,
45
+ finalizeImportProducts,
46
+ type TerminalImportProduct,
47
+ } from './import-product.js';
48
+ import type { ImporterRegistry } from './importer-registry.js';
49
+
50
+ /** Reserved `meta.importer` key consumed by vite-plugin-shader, not the import runner. */
51
+ export const SHADER_RESERVED_IMPORTER_KEY = 'shader';
52
+
53
+ /**
54
+ * Classify an importer throw as a build-time module-LOAD failure (the importer
55
+ * module / native addon could not be imported) vs a conversion THROW (the
56
+ * loaded importer ran and threw). feat-20260629 D-5: both keep the
57
+ * `import-internal-error` code, but a load failure surfaces `.detail.loadError`
58
+ * so AI users distinguish "my importer is not loadable / not built" from "my
59
+ * importer ran and crashed" without parsing `.message`.
60
+ *
61
+ * Node signals a module-load failure via `err.code` (`MODULE_NOT_FOUND` for
62
+ * CJS `require`, `ERR_MODULE_NOT_FOUND` for ESM `import()`, `ERR_DLOPEN_FAILED`
63
+ * for a broken native `.node` addon) or a recognizable message. Native FBX-style
64
+ * bindings throw a plain Error whose message names the missing addon.
65
+ */
66
+ function isModuleLoadFailure(e: unknown): boolean {
67
+ if (!(e instanceof Error)) return false;
68
+ const code = (e as { code?: unknown }).code;
69
+ if (
70
+ code === 'MODULE_NOT_FOUND' ||
71
+ code === 'ERR_MODULE_NOT_FOUND' ||
72
+ code === 'ERR_DLOPEN_FAILED'
73
+ ) {
74
+ return true;
75
+ }
76
+ const msg = e.message;
77
+ return (
78
+ msg.includes('Cannot find module') || msg.includes('native addon') || msg.includes('.node')
79
+ );
80
+ }
81
+
82
+ /**
83
+ * bug-20260610-pack-typed-array-roundtrip: normalise a value tree so every
84
+ * typed-array becomes a plain `number[]`. The DDC pack is serialised via
85
+ * `JSON.stringify`; left as-is, a `Float32Array` round-trips to an indexed
86
+ * object (`{ "0": v0, ... }`) and the runtime mesh / animation-clip loaders
87
+ * reject it with `asset-parse-failed`. Walking the tree once at the importer
88
+ * boundary keeps every downstream consumer (build emitFile, dev startMetaImport,
89
+ * and any future pack-cache tool) aligned on the same on-disk shape.
90
+ */
91
+ export function normaliseForPack(value: unknown): unknown {
92
+ if (value === null || value === undefined) return value;
93
+ if (
94
+ value instanceof Float32Array ||
95
+ value instanceof Float64Array ||
96
+ value instanceof Uint8Array ||
97
+ value instanceof Uint16Array ||
98
+ value instanceof Uint32Array ||
99
+ value instanceof Int8Array ||
100
+ value instanceof Int16Array ||
101
+ value instanceof Int32Array
102
+ ) {
103
+ return Array.from(value as ArrayLike<number>);
104
+ }
105
+ if (Array.isArray(value)) {
106
+ return value.map(normaliseForPack);
107
+ }
108
+ if (typeof value === 'object') {
109
+ const out: Record<string, unknown> = {};
110
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
111
+ out[k] = normaliseForPack(v);
112
+ }
113
+ return out;
114
+ }
115
+ return value;
116
+ }
117
+
118
+ /** Result envelope returned by {@link runImport} (mirrors the engine `Result<T,E>` shape). */
119
+ export type RunImportResult =
120
+ | { readonly ok: true; readonly value: RunImportOk }
121
+ | { readonly ok: false; readonly error: ImportErrorType };
122
+
123
+ export type RunImportProductResult =
124
+ | {
125
+ readonly ok: true;
126
+ readonly value:
127
+ | { readonly skipped: 'shader' }
128
+ | {
129
+ readonly product: TerminalImportProduct;
130
+ readonly cookProducts: readonly CookProduct[];
131
+ };
132
+ }
133
+ | { readonly ok: false; readonly error: ImportErrorType };
134
+
135
+ /**
136
+ * Success payload. `skipped: 'shader'` marks a reserved shader sidecar the
137
+ * runner intentionally did not import (no DDC written); otherwise `pack` is the
138
+ * logical Pack v2 document. Artifact bytes remain owned by each asset.
139
+ */
140
+ export type RunImportOk =
141
+ | { readonly skipped: 'shader' }
142
+ | {
143
+ readonly product: TerminalImportProduct;
144
+ readonly cookProducts: readonly CookProduct[];
145
+ readonly pack: DdcPack;
146
+ };
147
+
148
+ /** The logical Pack v2 document consumed by the shared finalizer. */
149
+ export interface DdcPack {
150
+ readonly schemaVersion: '2.0.0';
151
+ readonly kind: 'internal-text-package';
152
+ readonly packageId?: string;
153
+ readonly provenance?: ProviderProvenance;
154
+ readonly revision?: ResourceRevision;
155
+ readonly diagnostics?: readonly CatalogDiagnostic[];
156
+ readonly assets: ReadonlyArray<{
157
+ readonly guid: string;
158
+ readonly kind: string;
159
+ readonly name?: string;
160
+ readonly sourceKey?: string;
161
+ readonly sourceIndex?: number;
162
+ readonly relations?: readonly AssetRelation[];
163
+ readonly payload: Record<string, unknown>;
164
+ readonly refs: readonly string[];
165
+ readonly artifacts: Readonly<Record<string, ImportedArtifactBody>>;
166
+ }>;
167
+ }
168
+
169
+ /** Minimal parsed-meta shape the runner reads (a superset of the sidecar). */
170
+ export interface RunImportMeta {
171
+ readonly importer: string;
172
+ readonly source: string;
173
+ /** Scanner-owned source declaration revision; avoids reopening validated Meta. */
174
+ readonly sourceRevision?: string;
175
+ readonly packageId?: string;
176
+ readonly provenance?: ProviderProvenance;
177
+ readonly revision?: ResourceRevision;
178
+ readonly diagnostics?: readonly CatalogDiagnostic[];
179
+ readonly importSettings?: Readonly<Record<string, unknown>>;
180
+ readonly sourceOverrides?: SourceOverrideMap;
181
+ /** Skip the normalized DDC pack when a downstream finalizer owns publication. */
182
+ readonly buildPack?: boolean;
183
+ readonly subAssets: ReadonlyArray<{
184
+ readonly guid: string;
185
+ readonly sourceIndex: number;
186
+ readonly kind: string;
187
+ readonly sourceKey?: string;
188
+ readonly relations?: readonly AssetRelation[];
189
+ }>;
190
+ }
191
+
192
+ function declarationFields(
193
+ declaration: RunImportMeta['subAssets'][number] | undefined,
194
+ ): Pick<DdcPack['assets'][number], 'sourceKey' | 'sourceIndex' | 'relations'> {
195
+ if (declaration === undefined) return {};
196
+ return {
197
+ sourceIndex: declaration.sourceIndex,
198
+ ...(declaration.sourceKey !== undefined ? { sourceKey: declaration.sourceKey } : {}),
199
+ ...(declaration.relations !== undefined ? { relations: declaration.relations } : {}),
200
+ };
201
+ }
202
+
203
+ /** Filesystem + decode capabilities the runner needs to drive an importer.
204
+ *
205
+ * `readSource` reads the primary source bytes addressed by `meta.source`.
206
+ * `readSibling` reads a co-located file (e.g. an external `.bin` / `.png`
207
+ * referenced from a `.gltf` via relative URI). Optional today so existing
208
+ * callers stay green; importers that depend on it (gltfImporter texture
209
+ * external-uri path) gate on its presence and surface a structured
210
+ * `'source-read-failed'` ImportError when the host did not wire it.
211
+ *
212
+ * `decodeImage` is the M3 D-1 seam: gltfImporter funnels all three image
213
+ * sources (bufferView / data-uri / external-uri) through one callback so
214
+ * `@forgeax/engine-gltf` carries no `from '@forgeax/engine-image'` edge
215
+ * (the grep gate `packages/gltf/scripts/check-no-image-import.mjs` enforces
216
+ * this). The concrete decode lives in `@forgeax/engine-image/image-importer`;
217
+ * the build-time orchestrator binds the callback when constructing this
218
+ * `ImportRunnerFs`.
219
+ */
220
+ export interface ImportRunnerFs {
221
+ readSource(
222
+ sourcePath: string,
223
+ ): Promise<
224
+ | { readonly ok: true; readonly value: Uint8Array }
225
+ | { readonly ok: false; readonly error: unknown }
226
+ >;
227
+ readSibling?(
228
+ sourcePath: string,
229
+ uri: string,
230
+ ): Promise<
231
+ | { readonly ok: true; readonly value: Uint8Array }
232
+ | { readonly ok: false; readonly error: unknown }
233
+ >;
234
+ decodeImage?: ImportContext['decodeImage'];
235
+ }
236
+
237
+ /**
238
+ * Resolve an `images[].uri` (or any sibling reference) against the directory
239
+ * of `meta.source`. Pure path arithmetic — no I/O. Used as the default
240
+ * `readSibling` fallback when the host did not wire one explicitly.
241
+ */
242
+ function joinSiblingPath(sourcePath: string, uri: string): string {
243
+ const slash = Math.max(sourcePath.lastIndexOf('/'), sourcePath.lastIndexOf('\\'));
244
+ const dir = slash >= 0 ? sourcePath.slice(0, slash + 1) : '';
245
+ return `${dir}${uri}`;
246
+ }
247
+
248
+ function normalizeDependencyPath(path: string): string {
249
+ const slash = path.replaceAll('\\', '/');
250
+ const parts: string[] = [];
251
+ for (const part of slash.split('/')) {
252
+ if (part === '' || part === '.') continue;
253
+ if (part === '..') {
254
+ parts.pop();
255
+ } else {
256
+ parts.push(part);
257
+ }
258
+ }
259
+ return parts.join('/');
260
+ }
261
+
262
+ function errResult(error: ImportErrorType): {
263
+ readonly ok: false;
264
+ readonly error: ImportErrorType;
265
+ } {
266
+ return { ok: false, error };
267
+ }
268
+
269
+ function sourceKeyActual(value: unknown): string {
270
+ if (typeof value === 'string') return JSON.stringify(value);
271
+ if (value === undefined) return 'missing';
272
+ return typeof value;
273
+ }
274
+
275
+ type SourceReaderErrorMetadata = {
276
+ readonly code?: unknown;
277
+ readonly name?: unknown;
278
+ readonly message?: unknown;
279
+ readonly detail?: unknown;
280
+ };
281
+
282
+ function sourceReadFailureReason(error: unknown): string {
283
+ if (typeof error !== 'object' || error === null) {
284
+ return typeof error === 'string' ? error : 'unknown';
285
+ }
286
+ const metadata = error as SourceReaderErrorMetadata;
287
+ const token =
288
+ typeof metadata.code === 'string'
289
+ ? metadata.code
290
+ : typeof metadata.name === 'string'
291
+ ? metadata.name
292
+ : undefined;
293
+ switch (token) {
294
+ case 'ENOENT':
295
+ case 'NotFoundError':
296
+ return 'not-found';
297
+ case 'EACCES':
298
+ case 'EPERM':
299
+ case 'PermissionDeniedError':
300
+ case 'NotAllowedError':
301
+ case 'SecurityError':
302
+ return 'permission-denied';
303
+ case 'EAGAIN':
304
+ case 'EBUSY':
305
+ case 'EINTR':
306
+ case 'ECONNABORTED':
307
+ case 'ECONNREFUSED':
308
+ case 'ECONNRESET':
309
+ case 'EHOSTUNREACH':
310
+ case 'ENETDOWN':
311
+ case 'ENETUNREACH':
312
+ case 'ETIMEDOUT':
313
+ case 'EPIPE':
314
+ case 'ABORT_ERR':
315
+ case 'AbortError':
316
+ case 'TimeoutError':
317
+ return 'transient';
318
+ default:
319
+ if (typeof metadata.detail === 'object' && metadata.detail !== null) {
320
+ const reason = (metadata.detail as { readonly reason?: unknown }).reason;
321
+ if (typeof reason === 'string' && reason.length > 0) return reason;
322
+ }
323
+ if (typeof metadata.message === 'string' && metadata.message.length > 0) {
324
+ return metadata.message;
325
+ }
326
+ return 'unknown';
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Validate producer identity before source work or DDC product finalization.
332
+ * Multi-output declarations cannot recover identity from sourceIndex, array
333
+ * order, or a path, so every row must carry one unique semantic sourceKey.
334
+ * Single-output declarations may omit the key for legacy source compatibility;
335
+ * producers that expose a semantic output should still declare it so Catalog
336
+ * evidence remains stable when the output later gains siblings.
337
+ */
338
+ function validateOutputSourceKeys(meta: RunImportMeta): ImportErrorType | undefined {
339
+ if (meta.subAssets.length <= 1) return undefined;
340
+
341
+ const diagnostics: ImportDiagnostic[] = [];
342
+ const seen = new Map<string, number>();
343
+ for (const [index, declaration] of meta.subAssets.entries()) {
344
+ const sourcePath = `${meta.source}#subAssets[${index}]`;
345
+ const sourceRange = { start: 0, end: 0, line: 1, column: 1 };
346
+ const sourceKey = declaration.sourceKey;
347
+ if (typeof sourceKey !== 'string' || sourceKey.trim().length === 0) {
348
+ diagnostics.push({
349
+ code: 'source-key-required',
350
+ severity: 'error',
351
+ sourcePath,
352
+ sourceRange,
353
+ rule: 'import-output-source-key',
354
+ expected: 'a non-empty producer-owned sourceKey',
355
+ actual: sourceKeyActual(sourceKey),
356
+ hint: 'publish a stable semantic sourceKey; sourceIndex is only a locator',
357
+ });
358
+ continue;
359
+ }
360
+ const prior = seen.get(sourceKey);
361
+ if (prior !== undefined) {
362
+ diagnostics.push({
363
+ code: 'duplicate-source-key',
364
+ severity: 'error',
365
+ sourcePath,
366
+ sourceRange,
367
+ rule: 'import-output-source-key-unique',
368
+ expected: 'sourceKey to be unique within one imported package',
369
+ actual: `${JSON.stringify(sourceKey)} duplicates subAssets[${prior}]`,
370
+ hint: 'rename the duplicate semantic output before writing Meta',
371
+ });
372
+ continue;
373
+ }
374
+ seen.set(sourceKey, index);
375
+ }
376
+ if (diagnostics.length === 0) return undefined;
377
+ return new ImportError({
378
+ code: 'source-validation-failed',
379
+ expected: 'every writable imported output to declare a unique non-empty sourceKey',
380
+ hint: IMPORT_ERROR_HINTS['source-validation-failed'],
381
+ detail: { diagnostics },
382
+ });
383
+ }
384
+
385
+ /**
386
+ * Run the importer for one parsed meta sidecar and produce its DDC.
387
+ *
388
+ * @param meta the parsed `*.meta.json` (importer + source + subAssets[]).
389
+ * @param registry the wired {@link ImporterRegistry}.
390
+ * @param fs the source-read capability (injected so the runner stays
391
+ * testable without touching real disk).
392
+ */
393
+ export function runImport(
394
+ meta: RunImportMeta & { readonly buildPack: false },
395
+ registry: ImporterRegistry,
396
+ fs: ImportRunnerFs,
397
+ ): Promise<RunImportProductResult>;
398
+ export function runImport(
399
+ meta: RunImportMeta,
400
+ registry: ImporterRegistry,
401
+ fs: ImportRunnerFs,
402
+ ): Promise<RunImportResult>;
403
+ export async function runImport(
404
+ meta: RunImportMeta,
405
+ registry: ImporterRegistry,
406
+ fs: ImportRunnerFs,
407
+ ): Promise<RunImportResult | RunImportProductResult> {
408
+ // Reserved shader key: orthogonal vite-plugin-shader pipeline owns these.
409
+ if (meta.importer === SHADER_RESERVED_IMPORTER_KEY) {
410
+ return { ok: true, value: { skipped: 'shader' } };
411
+ }
412
+
413
+ const sourceKeyError = validateOutputSourceKeys(meta);
414
+ if (sourceKeyError !== undefined) return errResult(sourceKeyError);
415
+
416
+ const declaredSourceKeys = meta.subAssets.flatMap((subAsset) =>
417
+ subAsset.sourceKey === undefined ? [] : [subAsset.sourceKey],
418
+ );
419
+ const sourceOverridesResult = validateSourceOverrideMap(meta.sourceOverrides, declaredSourceKeys);
420
+ if (!sourceOverridesResult.ok) {
421
+ const error = new ImportError({
422
+ code: sourceOverridesResult.error.code,
423
+ expected: sourceOverridesResult.error.expected,
424
+ hint: IMPORT_ERROR_HINTS[sourceOverridesResult.error.code],
425
+ detail: {
426
+ sourceKey: sourceOverridesResult.error.actual,
427
+ declaredSourceKeys,
428
+ reason: sourceOverridesResult.error.hint,
429
+ },
430
+ });
431
+ if (sourceOverridesResult.error.actual !== undefined) {
432
+ Object.assign(error, { actual: sourceOverridesResult.error.actual });
433
+ }
434
+ return errResult(error);
435
+ }
436
+
437
+ const importer = registry.get(meta.importer);
438
+ if (importer === undefined) {
439
+ return errResult(
440
+ new ImportError({
441
+ code: 'importer-not-registered',
442
+ expected: `an importer registered for meta.importer "${meta.importer}"`,
443
+ hint: IMPORT_ERROR_HINTS['importer-not-registered'],
444
+ detail: {
445
+ importer: meta.importer,
446
+ registeredImporters: registry.registeredImporters(),
447
+ },
448
+ }),
449
+ );
450
+ }
451
+
452
+ const dependencies = new Set<string>();
453
+ const readSource = async (sourcePath: string) => {
454
+ dependencies.add(normalizeDependencyPath(sourcePath));
455
+ try {
456
+ return await fs.readSource(sourcePath);
457
+ } catch (error) {
458
+ return { ok: false as const, error };
459
+ }
460
+ };
461
+
462
+ const readSibling = async (
463
+ uri: string,
464
+ ): Promise<
465
+ | { readonly ok: true; readonly value: Uint8Array }
466
+ | { readonly ok: false; readonly error: ImportErrorType }
467
+ > => {
468
+ let inner:
469
+ | { readonly ok: true; readonly value: Uint8Array }
470
+ | { readonly ok: false; readonly error: unknown }
471
+ | { readonly ok: false; readonly error: ImportErrorType };
472
+ try {
473
+ if (fs.readSibling) {
474
+ dependencies.add(normalizeDependencyPath(joinSiblingPath(meta.source, uri)));
475
+ inner = await fs.readSibling(meta.source, uri);
476
+ } else {
477
+ inner = await readSource(joinSiblingPath(meta.source, uri));
478
+ }
479
+ } catch (error) {
480
+ inner = { ok: false, error };
481
+ }
482
+ if (inner.ok) {
483
+ return { ok: true, value: inner.value };
484
+ }
485
+ return {
486
+ ok: false,
487
+ error: new ImportError({
488
+ code: 'source-read-failed',
489
+ expected: `readable sibling file "${uri}" co-located with meta.source "${meta.source}"`,
490
+ hint: IMPORT_ERROR_HINTS['source-read-failed'],
491
+ detail: {
492
+ source: uri,
493
+ reason: sourceReadFailureReason(inner.error),
494
+ },
495
+ }),
496
+ };
497
+ };
498
+
499
+ const decodeImage: ImportContext['decodeImage'] =
500
+ fs.decodeImage ??
501
+ (async () => {
502
+ throw new Error(
503
+ 'ImportRunnerFs.decodeImage was not provided; gltfImporter texture extraction requires the host (vite-plugin-pack / cli-gltf / test) to bind decodeImage when constructing the ImportRunnerFs',
504
+ );
505
+ });
506
+
507
+ const canonicalSourceOverrides = canonicalizeSourceOverrides(sourceOverridesResult.value);
508
+ const ctx: ImportContext = {
509
+ source: meta.source,
510
+ readSource: () => readSource(meta.source),
511
+ readSibling,
512
+ decodeImage,
513
+ subAssets: meta.subAssets.map(({ guid, sourceIndex, sourceKey, kind }) => ({
514
+ guid,
515
+ sourceIndex,
516
+ ...(sourceKey === undefined ? {} : { sourceKey }),
517
+ kind,
518
+ })),
519
+ importSettings: meta.importSettings ?? {},
520
+ ...(canonicalSourceOverrides === undefined
521
+ ? {}
522
+ : { sourceOverrides: canonicalSourceOverrides }),
523
+ };
524
+
525
+ // Probe the source once up-front so a missing/unreadable source surfaces as
526
+ // source-read-failed rather than as an opaque import-internal-error inside
527
+ // the importer (charter P3 precise attribution).
528
+ const sourceProbe = await readSource(meta.source);
529
+ if (!sourceProbe.ok) {
530
+ return errResult(
531
+ new ImportError({
532
+ code: 'source-read-failed',
533
+ expected: `readable source file at meta.source "${meta.source}"`,
534
+ hint: IMPORT_ERROR_HINTS['source-read-failed'],
535
+ detail: {
536
+ source: meta.source,
537
+ reason: sourceReadFailureReason(sourceProbe.error),
538
+ },
539
+ }),
540
+ );
541
+ }
542
+
543
+ let product: ImportProduct;
544
+ try {
545
+ const imported = (await importer.import(ctx)) as
546
+ | { readonly ok: boolean; readonly value?: ImportProduct; readonly error?: ImportErrorType }
547
+ | undefined;
548
+ if (
549
+ imported === undefined ||
550
+ imported === null ||
551
+ typeof imported !== 'object' ||
552
+ !('ok' in imported)
553
+ ) {
554
+ throw new Error(
555
+ 'importer returned a legacy or malformed result; expected ImportResult<ImportProduct>',
556
+ );
557
+ } else {
558
+ if (!imported.ok) {
559
+ if (imported.error === undefined)
560
+ throw new Error('importer returned an invalid failure result');
561
+ if (imported.error instanceof ImportError) return errResult(imported.error);
562
+ return errResult(
563
+ new ImportError({
564
+ code: 'import-internal-error',
565
+ expected: `importer "${meta.importer}" to return a structured ImportError`,
566
+ hint: IMPORT_ERROR_HINTS['import-internal-error'],
567
+ detail: { reason: String(imported.error) },
568
+ }),
569
+ );
570
+ }
571
+ if (imported.value === undefined)
572
+ throw new Error('importer returned an invalid success result');
573
+ const value = imported.value;
574
+ if (
575
+ value === undefined ||
576
+ !Array.isArray(value.assets) ||
577
+ !Array.isArray(value.sourceDependencies) ||
578
+ 'artifacts' in value
579
+ ) {
580
+ throw new Error('importer returned an invalid ImportProduct');
581
+ }
582
+ for (const asset of value.assets) {
583
+ if (
584
+ asset === null ||
585
+ typeof asset !== 'object' ||
586
+ !('artifacts' in asset) ||
587
+ asset.artifacts === null ||
588
+ typeof asset.artifacts !== 'object' ||
589
+ Array.isArray(asset.artifacts)
590
+ ) {
591
+ throw new Error('importer returned an asset without local artifacts');
592
+ }
593
+ }
594
+ product = value;
595
+ }
596
+ } catch (e) {
597
+ if (e instanceof ImportError) return errResult(e);
598
+ const message = e instanceof Error ? e.message : String(e);
599
+ // D-5: a module-LOAD failure rides `.detail.loadError`; a conversion THROW
600
+ // rides `.detail.reason`. Same `import-internal-error` code (no new closed
601
+ // union member); AI users branch on the `.detail` shape.
602
+ if (isModuleLoadFailure(e)) {
603
+ return errResult(
604
+ new ImportError({
605
+ code: 'import-internal-error',
606
+ expected: `importer module "${meta.importer}" to load (module + native addon present)`,
607
+ hint: IMPORT_ERROR_HINTS['import-internal-error'],
608
+ detail: { loadError: message },
609
+ }),
610
+ );
611
+ }
612
+ return errResult(
613
+ new ImportError({
614
+ code: 'import-internal-error',
615
+ expected: `importer "${meta.importer}" to convert the source without throwing`,
616
+ hint: IMPORT_ERROR_HINTS['import-internal-error'],
617
+ detail: { reason: message },
618
+ }),
619
+ );
620
+ }
621
+
622
+ const produced = product.assets;
623
+
624
+ // GUID import-stable iron law: the produced GUID set must be a superset of
625
+ // the declared set, and must not contain any GUID the meta never declared.
626
+ const declared = new Set(meta.subAssets.map((s) => s.guid));
627
+ const producedGuids = new Set(produced.map((a) => a.guid));
628
+
629
+ const unexpectedGuids = [...producedGuids].filter((g) => !declared.has(g));
630
+ if (unexpectedGuids.length > 0) {
631
+ return errResult(
632
+ new ImportError({
633
+ code: 'guid-mismatch',
634
+ expected: 'every produced GUID to be declared in meta.subAssets[]',
635
+ hint: IMPORT_ERROR_HINTS['guid-mismatch'],
636
+ detail: { unexpectedGuids },
637
+ }),
638
+ );
639
+ }
640
+
641
+ const missingGuids = [...declared].filter((g) => !producedGuids.has(g));
642
+ if (produced.length === 0 || missingGuids.length > 0) {
643
+ return errResult(
644
+ new ImportError({
645
+ code: 'import-produced-no-assets',
646
+ expected:
647
+ produced.length === 0
648
+ ? 'the importer to produce at least one ImportedAsset'
649
+ : 'the produced GUID set to be a superset of meta.subAssets[]',
650
+ hint: IMPORT_ERROR_HINTS['import-produced-no-assets'],
651
+ detail: { missingGuids },
652
+ }),
653
+ );
654
+ }
655
+
656
+ const declarations = new Map(
657
+ meta.subAssets.map((declaration) => [declaration.guid, declaration]),
658
+ );
659
+ const productWithDependencies = {
660
+ ...product,
661
+ sourceDependencies: [...dependencies],
662
+ };
663
+ const inputFingerprint = `source:${[...dependencies].sort().join('|')}`;
664
+ let cookProducts: readonly CookProduct[];
665
+ try {
666
+ cookProducts = await finalizeImportProducts(productWithDependencies, inputFingerprint);
667
+ } catch (e) {
668
+ const reason = e instanceof Error ? e.message : String(e);
669
+ return errResult(
670
+ new ImportError({
671
+ code: 'import-internal-error',
672
+ expected: `importer "${meta.importer}" finalization to produce complete CookProduct digests`,
673
+ hint: IMPORT_ERROR_HINTS['import-internal-error'],
674
+ detail: { reason: `finalization/digest: ${reason}` },
675
+ }),
676
+ );
677
+ }
678
+ const terminalProduct = createImportProduct({
679
+ ...productWithDependencies,
680
+ refs: productWithDependencies.assets.flatMap((asset) => asset.refs),
681
+ artifacts: Object.fromEntries(
682
+ productWithDependencies.assets.flatMap((asset) =>
683
+ Object.entries(asset.artifacts).map(([key, artifact]) => [
684
+ `${asset.guid}/${key}`,
685
+ artifact,
686
+ ]),
687
+ ),
688
+ ),
689
+ receipts: cookProducts.map((product) => product.receipt),
690
+ diagnostics: meta.diagnostics ?? [],
691
+ sourceRevision: inputFingerprint,
692
+ });
693
+ if (!terminalProduct.ok) {
694
+ return errResult(
695
+ new ImportError({
696
+ code: 'import-internal-error',
697
+ expected: 'the import product to retain complete terminal producer facts',
698
+ hint: 'preserve source identity and producer evidence when returning the import product',
699
+ detail: { reason: terminalProduct.error.detail.field },
700
+ }),
701
+ );
702
+ }
703
+ if (meta.buildPack === false) {
704
+ return {
705
+ ok: true,
706
+ value: { product: terminalProduct.value, cookProducts },
707
+ };
708
+ }
709
+
710
+ const assets = produced.map((a) => {
711
+ const outputFields = declarationFields(declarations.get(a.guid));
712
+ return {
713
+ guid: a.guid,
714
+ kind: a.kind,
715
+ ...outputFields,
716
+ ...(a.name !== undefined ? { name: a.name } : {}),
717
+ // bug-20260610: mesh / scene / animation-clip payloads carry Float32Array
718
+ // / Uint16Array / Uint32Array fields. JSON.stringify on a typed array
719
+ // serialises to `{ "0": v0, "1": v1, ... }` (a plain object), which the
720
+ // runtime mesh / animation loaders reject (`vertexData instanceof
721
+ // Float32Array` and `Array.isArray(vertexData)` both fail). Convert
722
+ // every typed-array field to a plain Array here so the pack is JSON-
723
+ // roundtrip safe end-to-end. This matches the convention every
724
+ // existing pack-fixture test uses (`vertices: Array.from(...)`).
725
+ payload: normaliseForPack(a.payload as unknown) as Record<string, unknown>,
726
+ refs: a.refs.map((r) => r.guid),
727
+ artifacts: a.artifacts,
728
+ };
729
+ });
730
+
731
+ const pack: DdcPack = {
732
+ schemaVersion: '2.0.0',
733
+ kind: 'internal-text-package',
734
+ ...(meta.packageId !== undefined ? { packageId: meta.packageId } : {}),
735
+ ...(meta.provenance !== undefined ? { provenance: meta.provenance } : {}),
736
+ ...(meta.revision !== undefined ? { revision: meta.revision } : {}),
737
+ ...(meta.diagnostics !== undefined ? { diagnostics: meta.diagnostics } : {}),
738
+ assets,
739
+ };
740
+
741
+ return {
742
+ ok: true,
743
+ value: {
744
+ product: terminalProduct.value,
745
+ cookProducts,
746
+ pack,
747
+ },
748
+ };
749
+ }