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