@forgeax/engine-pack 0.1.6 → 0.1.19

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 (37) hide show
  1. package/README.md +12 -1
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/build.d.ts +1 -1
  4. package/dist/build.d.ts.map +1 -1
  5. package/dist/build.mjs +100 -51
  6. package/dist/build.mjs.map +1 -1
  7. package/dist/catalog-builder.d.ts +12 -0
  8. package/dist/catalog-builder.d.ts.map +1 -1
  9. package/dist/cli-asset.mjs +1 -1
  10. package/dist/cli-asset.mjs.map +1 -1
  11. package/dist/evidence/material-cook.d.ts +28 -6
  12. package/dist/evidence/material-cook.d.ts.map +1 -1
  13. package/dist/index.d.ts +1 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.mjs +97 -42
  16. package/dist/index.mjs.map +1 -1
  17. package/dist/material-cook.d.ts +1 -1
  18. package/dist/material-cook.d.ts.map +1 -1
  19. package/dist/material-cook.mjs +96 -41
  20. package/dist/material-cook.mjs.map +1 -1
  21. package/dist/scanner.mjs +1 -1
  22. package/dist/scanner.mjs.map +1 -1
  23. package/dist/scriptable-pack-node.d.ts.map +1 -1
  24. package/dist/scriptable-pack-node.mjs +1 -1
  25. package/dist/scriptable-pack-node.mjs.map +1 -1
  26. package/package.json +3 -3
  27. package/src/__tests__/material-cook-dependencies.unit.test.ts +111 -0
  28. package/src/__tests__/material-cook-receipt.unit.test.ts +77 -20
  29. package/src/__tests__/material-cook-schema.unit.test.ts +64 -10
  30. package/src/__tests__/pack-v2-fixture-migration.test.ts +24 -1
  31. package/src/build.ts +1 -0
  32. package/src/catalog-builder.ts +42 -5
  33. package/src/evidence/material-cook.ts +133 -54
  34. package/src/index.ts +4 -0
  35. package/src/material-cook.ts +2 -0
  36. package/src/schema/material-cook.schema.json +48 -8
  37. package/src/scriptable-pack-node.ts +4 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgeax/engine-pack",
3
- "version": "0.1.6",
3
+ "version": "0.1.19",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -108,7 +108,7 @@
108
108
  "ajv": "^8.20.0",
109
109
  "ajv-formats": "^3.0.1",
110
110
  "tsup": "^8.3.5",
111
- "vitest": "4.1.5"
111
+ "vitest": "4.1.11"
112
112
  },
113
113
  "forgeax": {
114
114
  "metrics": {
@@ -136,7 +136,7 @@
136
136
  }
137
137
  },
138
138
  "dependencies": {
139
- "@forgeax/engine-types": "0.1.6",
139
+ "@forgeax/engine-types": "0.1.19",
140
140
  "@noble/hashes": "^2.2.0",
141
141
  "fast-glob": "^3.3.3",
142
142
  "typescript": "^5.8.3",
@@ -1,6 +1,8 @@
1
1
  import type { AssetGuid } from '@forgeax/engine-types';
2
2
  import { describe, expect, it } from 'vitest';
3
3
  import { collectMaterialCookRefs } from '../evidence/material-cook.js';
4
+ import { NativeCookerRegistry } from '../native-cooker-registry.js';
5
+ import { createRuntimePackPublication } from '../runtime-publication.js';
4
6
 
5
7
  describe('material cook dependency closure', () => {
6
8
  it('collects parent, texture, sampler, and module references', () => {
@@ -23,6 +25,50 @@ describe('material cook dependency closure', () => {
23
25
  });
24
26
  });
25
27
 
28
+ it('collects transmission and thickness texture dependencies', () => {
29
+ expect(
30
+ collectMaterialCookRefs({
31
+ parameters: [
32
+ { name: 'transmissionTexture', type: 'texture' },
33
+ { name: 'thicknessTexture', type: 'texture' },
34
+ ],
35
+ values: {
36
+ transmissionTexture: { texture: 'texture/transmission', sampler: 'sampler/linear' },
37
+ thicknessTexture: { texture: 'texture/thickness', sampler: 'sampler/linear' },
38
+ },
39
+ }),
40
+ ).toEqual({
41
+ parent: [],
42
+ textures: ['texture/thickness', 'texture/transmission'],
43
+ samplers: ['sampler/linear'],
44
+ modules: [],
45
+ });
46
+ });
47
+
48
+ it('keeps authored Standard values with both texture dependency edges', () => {
49
+ const refs = collectMaterialCookRefs({
50
+ passes: [{ name: 'Forward', program: { module: 'forgeax::default-standard-pbr' } }],
51
+ parameters: [
52
+ { name: 'transmissionTexture', type: 'texture' },
53
+ { name: 'thicknessTexture', type: 'texture' },
54
+ ],
55
+ values: {
56
+ transmission: 0.8,
57
+ ior: 1.45,
58
+ thickness: 0.25,
59
+ transmissionTexture: { texture: 'texture/transmission', sampler: 'sampler/linear' },
60
+ thicknessTexture: { texture: 'texture/thickness', sampler: 'sampler/linear' },
61
+ },
62
+ });
63
+
64
+ expect(refs).toEqual({
65
+ parent: [],
66
+ textures: ['texture/thickness', 'texture/transmission'],
67
+ samplers: ['sampler/linear'],
68
+ modules: ['forgeax::default-standard-pbr'],
69
+ });
70
+ });
71
+
26
72
  it('collects string shorthands only from declared texture parameters', () => {
27
73
  expect(
28
74
  collectMaterialCookRefs({
@@ -42,4 +88,69 @@ describe('material cook dependency closure', () => {
42
88
  }),
43
89
  ).toEqual({ parent: [], textures: ['texture/albedo'], samplers: [], modules: [] });
44
90
  });
91
+
92
+ it('keeps the last known good candidate when a generation fails before publication', async () => {
93
+ const registry = new NativeCookerRegistry();
94
+ let fail = false;
95
+ registry.register({
96
+ key: 'material',
97
+ cook: () => {
98
+ if (fail) throw new Error('candidate rejected');
99
+ return {
100
+ guid: 'material-child',
101
+ payload: { kind: 'material' },
102
+ refs: ['texture/albedo'],
103
+ artifacts: {
104
+ 'material-child/shader.wgsl': {
105
+ mediaType: 'text/wgsl',
106
+ bytes: new TextEncoder().encode('shader'),
107
+ },
108
+ },
109
+ inputFingerprint: 'sha256:material-candidate',
110
+ };
111
+ },
112
+ });
113
+
114
+ const committed = await registry.runTransaction({ key: 'material', input: {} });
115
+ expect(committed.ok).toBe(true);
116
+ if (!committed.ok) throw new Error(committed.error.hint);
117
+ fail = true;
118
+ const recovered = await registry.runTransaction({
119
+ key: 'material',
120
+ input: {},
121
+ previous: committed.value,
122
+ });
123
+
124
+ expect(recovered).toMatchObject({
125
+ ok: true,
126
+ value: {
127
+ status: 'recovered',
128
+ generation: 1,
129
+ candidateGeneration: 2,
130
+ lastKnownGoodGeneration: 1,
131
+ lastKnownGood: committed.value.draft,
132
+ },
133
+ });
134
+ });
135
+
136
+ it('keeps runtime publication identity independent from source-path diagnostics', () => {
137
+ const input = {
138
+ pack: {
139
+ assets: [{ guid: 'material-child', kind: 'material', payload: { roughness: 0.5 } }],
140
+ },
141
+ scopeId: 'project',
142
+ sourceRevision: 'rev-1',
143
+ packageUrl: '/packs/materials.json',
144
+ };
145
+ const first = createRuntimePackPublication({ ...input, sourcePath: '/src/materials.json' });
146
+ const moved = createRuntimePackPublication({ ...input, sourcePath: '/moved/materials.json' });
147
+
148
+ expect(moved.pack.digest).toBe(first.pack.digest);
149
+ expect(moved.publication.digest).toBe(first.publication.digest);
150
+ expect(moved.publication.outputSetDigest).toBe(first.publication.outputSetDigest);
151
+ expect(moved.publication.receipt.inputFingerprint).toBe(
152
+ first.publication.receipt.inputFingerprint,
153
+ );
154
+ expect(moved.publication.sourcePath).toBe('/moved/materials.json');
155
+ });
45
156
  });
@@ -10,28 +10,86 @@ const artifactDigest = 'sha256:output';
10
10
 
11
11
  function receipt(overrides: Record<string, unknown> = {}): MaterialCookReceipt {
12
12
  return {
13
- schemaVersion: 'material-cook/2',
13
+ schemaVersion: 'material-cook/3',
14
14
  sourceClosure: ['b.wgsl', 'a.material.json'],
15
15
  profile: 'webgpu/v1',
16
16
  compilerVersion: 'compiler/1',
17
- inputDigest: 'sha256:input',
18
- outputDigest: artifactDigest,
19
- layoutIdentity,
17
+ identity: {
18
+ materialContractDigest: 'sha256:material-contract',
19
+ sourceRevision: 'sha256:source-revision',
20
+ sourceClosureDigest: 'sha256:source-closure',
21
+ layoutIdentity,
22
+ programIdentity: 'sha256:program',
23
+ pipelineIdentity: 'sha256:pipeline',
24
+ materialPublicationIdentity: 'sha256:publication',
25
+ cookIdentity: 'sha256:input',
26
+ compilerFingerprint: 'sha256:compiler',
27
+ wasm: {
28
+ sourceContentKey: 'sha256:wasm-source',
29
+ artifactSha256: 'sha256:wasm-artifact',
30
+ glueSha256: 'sha256:wasm-glue',
31
+ },
32
+ artifactDigest,
33
+ valueGeneration: 1,
34
+ dependencyGeneration: 1,
35
+ cookGeneration: 1,
36
+ },
20
37
  derivedInterface: { layoutIdentity },
21
38
  ...overrides,
22
39
  } as unknown as MaterialCookReceipt;
23
40
  }
24
41
 
25
42
  describe('material cook receipt', () => {
26
- it('serializes a material-cook/2 receipt stably for equivalent cooks', () => {
43
+ it('requires the v3 identity and provenance tuple', () => {
44
+ const v3 = {
45
+ schemaVersion: 'material-cook/3',
46
+ sourceClosure: ['materials/mat.material.json', 'shaders/pbr.wgsl'],
47
+ profile: 'webgpu/v1',
48
+ compilerVersion: 'compiler/1',
49
+ identity: {
50
+ materialContractDigest: 'sha256:material-contract',
51
+ sourceRevision: 'sha256:source-revision',
52
+ sourceClosureDigest: 'sha256:source-closure',
53
+ layoutIdentity,
54
+ programIdentity: 'sha256:program',
55
+ pipelineIdentity: 'sha256:pipeline',
56
+ materialPublicationIdentity: 'sha256:publication',
57
+ cookIdentity: 'sha256:cook',
58
+ compilerFingerprint: 'sha256:compiler',
59
+ wasm: {
60
+ sourceContentKey: 'sha256:wasm-source',
61
+ artifactSha256: 'sha256:wasm-artifact',
62
+ glueSha256: 'sha256:wasm-glue',
63
+ },
64
+ artifactDigest,
65
+ valueGeneration: 3,
66
+ dependencyGeneration: 2,
67
+ cookGeneration: 7,
68
+ },
69
+ derivedInterface: { layoutIdentity },
70
+ };
71
+ expect(validateMaterialCookReceipt(v3)).toMatchObject({ ok: true, value: v3 });
72
+ expect(validateMaterialCookReceipt(receipt())).toMatchObject({ ok: true });
73
+ expect(
74
+ validateMaterialCookReceipt({
75
+ ...v3,
76
+ identity: { ...v3.identity, compilerFingerprint: undefined },
77
+ }),
78
+ ).toMatchObject({
79
+ ok: false,
80
+ error: { code: 'material-cook-record-invalid' },
81
+ });
82
+ });
83
+
84
+ it('serializes a material-cook/3 receipt stably for equivalent cooks', () => {
27
85
  const first = serializeMaterialCookReceipt(receipt());
28
86
  const second = serializeMaterialCookReceipt(
29
87
  receipt({ sourceClosure: ['a.material.json', 'b.wgsl'] }),
30
88
  );
31
89
  expect(first).toBe(second);
32
90
  expect(JSON.parse(first)).toMatchObject({
33
- schemaVersion: 'material-cook/2',
34
- layoutIdentity,
91
+ schemaVersion: 'material-cook/3',
92
+ identity: { layoutIdentity },
35
93
  derivedInterface: { layoutIdentity },
36
94
  });
37
95
  });
@@ -43,7 +101,10 @@ describe('material cook receipt', () => {
43
101
  value: receipt(),
44
102
  });
45
103
  expect(
46
- validateMaterialCookReceipt(receipt({ layoutIdentity: 'sha256:changed-layout' }), expected),
104
+ validateMaterialCookReceipt(
105
+ receipt({ identity: { ...receipt().identity, layoutIdentity: 'sha256:changed-layout' } }),
106
+ expected,
107
+ ),
47
108
  ).toMatchObject({ ok: false, error: { code: 'material-cook-record-invalid' } });
48
109
  expect(
49
110
  validateMaterialCookReceipt(
@@ -56,15 +117,14 @@ describe('material cook receipt', () => {
56
117
  it('rejects missing, stale, mismatched, and material-cook/1 receipts fail-fast', () => {
57
118
  const expected = { layoutIdentity, artifactDigest, inputDigest: 'sha256:input' };
58
119
  for (const value of [
59
- receipt({ layoutIdentity: undefined }),
60
- receipt({ outputDigest: 'sha256:stale-artifact' }),
61
- receipt({ inputDigest: 'sha256:stale-input' }),
120
+ receipt({ identity: { ...receipt().identity, layoutIdentity: undefined } }),
121
+ receipt({ identity: { ...receipt().identity, artifactDigest: 'sha256:stale-artifact' } }),
122
+ receipt({ identity: { ...receipt().identity, cookIdentity: 'sha256:stale-input' } }),
62
123
  {
63
124
  sourceClosure: [],
64
125
  profile: 'webgpu/v1',
65
126
  compilerVersion: 'compiler/1',
66
- inputDigest: 'sha256:input',
67
- outputDigest: artifactDigest,
127
+ identity: receipt().identity,
68
128
  },
69
129
  ]) {
70
130
  expect(validateMaterialCookReceipt(value as MaterialCookReceipt, expected)).toMatchObject({
@@ -75,17 +135,14 @@ describe('material cook receipt', () => {
75
135
  });
76
136
 
77
137
  it('records the coordinator-owned publication generation', () => {
78
- const receipt = {
138
+ const generationReceipt = {
79
139
  sourceClosure: ['materials/mat-root.material.json'],
80
140
  profile: 'webgpu/v1',
81
141
  compilerVersion: 'compiler/1',
82
- inputDigest: 'sha256:input',
83
- outputDigest: 'sha256:artifact',
84
- publicationGeneration: 11,
142
+ identity: { ...receipt().identity, cookGeneration: 11 },
85
143
  };
86
- expect(receipt).toMatchObject({
87
- publicationGeneration: 11,
88
- outputDigest: 'sha256:artifact',
144
+ expect(generationReceipt).toMatchObject({
145
+ identity: { cookGeneration: 11 },
89
146
  });
90
147
  });
91
148
  });
@@ -10,17 +10,17 @@ import {
10
10
  } from '../evidence/material-cook.js';
11
11
 
12
12
  const record: CookedMaterialRecord = {
13
- schemaVersion: 'material-cook/2',
13
+ schemaVersion: 'material-cook/3',
14
14
  guid: 'mat-root',
15
15
  authored: {
16
16
  kind: 'material',
17
17
  passes: [{ name: 'forward', program: { module: 'core/pbr' } }],
18
- parameters: [{ name: 'roughness', type: 'f32', static: true }],
18
+ parameters: [{ name: 'roughness', type: 'f32' }],
19
19
  values: { roughness: 0.5 },
20
20
  },
21
21
  resolved: {
22
22
  passes: [{ name: 'forward', program: { module: 'core/pbr' } }],
23
- parameters: [{ name: 'roughness', type: 'f32', static: true }],
23
+ parameters: [{ name: 'roughness', type: 'f32' }],
24
24
  values: { roughness: 0.5 },
25
25
  },
26
26
  refs: { parent: [], textures: [], samplers: [], modules: ['core/pbr'] },
@@ -31,19 +31,36 @@ const record: CookedMaterialRecord = {
31
31
  bytes: new TextEncoder().encode('shader'),
32
32
  },
33
33
  receipt: {
34
- schemaVersion: 'material-cook/2',
34
+ schemaVersion: 'material-cook/3',
35
35
  sourceClosure: ['materials/mat-root.material.json'],
36
36
  profile: 'webgpu/v1',
37
37
  compilerVersion: 'compiler/1',
38
- inputDigest: 'sha256:input',
39
- outputDigest: 'sha256:artifact',
40
- layoutIdentity: 'sha256:layout',
38
+ identity: {
39
+ materialContractDigest: 'sha256:material-contract',
40
+ sourceRevision: 'sha256:source-revision',
41
+ sourceClosureDigest: 'sha256:source-closure',
42
+ layoutIdentity: 'sha256:layout',
43
+ programIdentity: 'sha256:program',
44
+ pipelineIdentity: 'sha256:pipeline',
45
+ materialPublicationIdentity: 'sha256:publication',
46
+ cookIdentity: 'sha256:input',
47
+ compilerFingerprint: 'sha256:compiler',
48
+ wasm: {
49
+ sourceContentKey: 'sha256:wasm-source',
50
+ artifactSha256: 'sha256:wasm-artifact',
51
+ glueSha256: 'sha256:wasm-glue',
52
+ },
53
+ artifactDigest: 'sha256:artifact',
54
+ valueGeneration: 1,
55
+ dependencyGeneration: 1,
56
+ cookGeneration: 1,
57
+ },
41
58
  derivedInterface: { layoutIdentity: 'sha256:layout' },
42
59
  },
43
60
  };
44
61
 
45
62
  describe('cooked material schema', () => {
46
- it('requires material-cook/2 identity and derived interface association', () => {
63
+ it('requires material-cook/3 identity and derived interface association', () => {
47
64
  const schema = JSON.parse(
48
65
  readFileSync(
49
66
  fileURLToPath(new URL('../schema/material-cook.schema.json', import.meta.url)),
@@ -56,13 +73,50 @@ describe('cooked material schema', () => {
56
73
  >;
57
74
  required: string[];
58
75
  };
59
- expect(schema.properties.schemaVersion?.const).toBe('material-cook/2');
76
+ expect(schema.properties.schemaVersion?.const).toBe('material-cook/3');
60
77
  expect(schema.properties.receipt?.required).toEqual(
61
- expect.arrayContaining(['layoutIdentity', 'derivedInterface']),
78
+ expect.arrayContaining(['identity', 'derivedInterface']),
62
79
  );
63
80
  expect(schema.properties.receipt?.properties?.derivedInterface).toBeDefined();
64
81
  });
65
82
 
83
+ it('defines the v3 identity, provenance, and generation contract', () => {
84
+ const schema = JSON.parse(
85
+ readFileSync(
86
+ fileURLToPath(new URL('../schema/material-cook.schema.json', import.meta.url)),
87
+ 'utf8',
88
+ ),
89
+ ) as {
90
+ properties: Record<
91
+ string,
92
+ { const?: string; required?: string[]; properties?: Record<string, unknown> }
93
+ >;
94
+ };
95
+ expect(schema.properties.schemaVersion?.const).toBe('material-cook/3');
96
+ expect(schema.properties.receipt?.properties?.identity).toMatchObject({
97
+ type: 'object',
98
+ required: expect.arrayContaining([
99
+ 'materialContractDigest',
100
+ 'sourceRevision',
101
+ 'sourceClosureDigest',
102
+ 'layoutIdentity',
103
+ 'programIdentity',
104
+ 'pipelineIdentity',
105
+ 'materialPublicationIdentity',
106
+ 'cookIdentity',
107
+ 'compilerFingerprint',
108
+ 'wasm',
109
+ 'artifactDigest',
110
+ 'valueGeneration',
111
+ 'dependencyGeneration',
112
+ 'cookGeneration',
113
+ ]),
114
+ });
115
+ expect(schema.properties.receipt?.required).toEqual(
116
+ expect.arrayContaining(['identity', 'derivedInterface']),
117
+ );
118
+ });
119
+
66
120
  it('serializes a stable cooked DTO and derives an artifact digest', () => {
67
121
  const first = serializeCookedMaterialRecord(record);
68
122
  const second = serializeCookedMaterialRecord({ ...record });
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { resolve } from 'node:path';
3
3
  import { describe, expect, it } from 'vitest';
4
- import { parsePackV2 } from '../index.js';
4
+ import { parsePackV2, validateCookedMaterialRecord } from '../index.js';
5
5
 
6
6
  const root = resolve(import.meta.dirname, '../../../../');
7
7
 
@@ -61,4 +61,27 @@ describe('Pack v2 fixture migration', () => {
61
61
  }).ok,
62
62
  ).toBe(false);
63
63
  });
64
+
65
+ it('rejects retired material cook records instead of retaining a cross-generation reader', () => {
66
+ const retiredSchema = ['material-cook', '2'].join('/');
67
+ expect(
68
+ validateCookedMaterialRecord({
69
+ schemaVersion: retiredSchema,
70
+ guid: 'mat-v2',
71
+ resolved: { passes: [], parameters: [], values: {} },
72
+ refs: { parent: [], textures: [], samplers: [], modules: [] },
73
+ artifact: { mediaType: 'text/wgsl', path: 'shader.wgsl', digest: 'sha256:a', bytes: [] },
74
+ receipt: {
75
+ schemaVersion: retiredSchema,
76
+ sourceClosure: [],
77
+ profile: 'webgpu/v1',
78
+ compilerVersion: 'compiler/1',
79
+ inputDigest: 'sha256:i',
80
+ outputDigest: 'sha256:a',
81
+ layoutIdentity: 'sha256:l',
82
+ derivedInterface: { layoutIdentity: 'sha256:l' },
83
+ },
84
+ }),
85
+ ).toMatchObject({ ok: false, error: { code: 'material-cook-record-invalid' } });
86
+ });
64
87
  });
package/src/build.ts CHANGED
@@ -33,6 +33,7 @@ export {
33
33
  type CatalogBuildResult,
34
34
  type CatalogImporterPolicy,
35
35
  type CatalogProducerVisibility,
36
+ catalogSourcePathFor,
36
37
  metaPathForGuid,
37
38
  } from './catalog-builder.js';
38
39
  export { calculateCatalogDelta } from './catalog-delta.js';
@@ -1,4 +1,4 @@
1
- import { relative } from 'node:path';
1
+ import { isAbsolute, relative } from 'node:path';
2
2
  import type { PackIndexEntry, ProviderProvenance } from '@forgeax/engine-types';
3
3
  import {
4
4
  findReservedAssetKindConflict,
@@ -75,12 +75,38 @@ export interface CatalogBuildProjectionOptions {
75
75
  readonly scanOptions?: ScanOptions;
76
76
  readonly importerPolicy: (importer: string) => CatalogImporterPolicy;
77
77
  readonly visibility?: CatalogProducerVisibility;
78
+ /**
79
+ * Project physical source paths into a stable host-owned logical identity.
80
+ * Scanner/declaration maps retain physical paths for I/O; only Catalog rows
81
+ * use this projection so symlink farms cannot change publication identity.
82
+ */
83
+ readonly sourceIdentityFor?: (sourcePath: string) => string;
78
84
  }
79
85
 
80
86
  function sourcePathFor(cwd: string, path: string): string {
81
87
  return relative(cwd, path).replace(/\\/g, '/');
82
88
  }
83
89
 
90
+ /**
91
+ * Project one physical source path into the catalog's browser-safe logical
92
+ * locator. Physical paths remain the scanner/declaration keys; only catalog
93
+ * rows use this projection.
94
+ */
95
+ export function catalogSourcePathFor(
96
+ cwd: string,
97
+ path: string,
98
+ sourceIdentityFor: CatalogBuildProjectionOptions['sourceIdentityFor'],
99
+ ): string {
100
+ const projected = sourceIdentityFor?.(path);
101
+ // A host may intentionally leave paths outside its logical roots untouched.
102
+ // Keep the catalog's existing cwd-relative URL contract for that fallback;
103
+ // an absolute physical path is a valid dependency identity, but not a
104
+ // browser-served catalog locator.
105
+ const catalogPath =
106
+ projected === undefined || isAbsolute(projected) ? sourcePathFor(cwd, path) : projected;
107
+ return catalogPath.replaceAll('\\', '/').replace(/^\.\//, '');
108
+ }
109
+
84
110
  export function metaPathForGuid(
85
111
  declarations: ReadonlyMap<string, CatalogImportMeta>,
86
112
  guid: string,
@@ -115,6 +141,7 @@ function projectMeta(
115
141
  assetPaths: Record<string, string>,
116
142
  importerPolicy: (importer: string) => CatalogImporterPolicy,
117
143
  sourceDeclaration: Extract<ScanSourceDeclaration, { readonly format: 'meta.json' }>,
144
+ sourceIdentityFor: CatalogBuildProjectionOptions['sourceIdentityFor'],
118
145
  ): {
119
146
  readonly entries: PackIndexEntry[];
120
147
  readonly declaration?: CatalogImportMeta;
@@ -136,7 +163,7 @@ function projectMeta(
136
163
  },
137
164
  };
138
165
  }
139
- const sourcePath = sourcePathFor(cwd, resolved.value);
166
+ const sourcePath = catalogSourcePathFor(cwd, resolved.value, sourceIdentityFor);
140
167
  const packageUrl = `${base}/__forgeax-ddc/${meta.subAssets[0]?.guid?.toLowerCase() ?? 'pack'}.pack.json`;
141
168
  const declaration: CatalogImportMeta = {
142
169
  importer: meta.importer,
@@ -184,6 +211,7 @@ function projectSource(
184
211
  visibility: CatalogBuildProjectionOptions['visibility'],
185
212
  sourceDeclaration: ScanSourceDeclaration,
186
213
  declarations: Map<string, CatalogImportMeta>,
214
+ sourceIdentityFor: CatalogBuildProjectionOptions['sourceIdentityFor'],
187
215
  ): { readonly entries: PackIndexEntry[]; readonly error?: CatalogBuildError } {
188
216
  if (sourceDeclaration.format === 'pack.ts') {
189
217
  const meta = { ...sourceDeclaration.meta, source: sourcePathFor(cwd, path) };
@@ -193,7 +221,7 @@ function projectSource(
193
221
  : {
194
222
  entries: projectExternalCatalogEntries(
195
223
  meta,
196
- sourcePathFor(cwd, path),
224
+ catalogSourcePathFor(cwd, path, sourceIdentityFor),
197
225
  `${base}/__forgeax-ddc/${anchorGuid}.pack.json`,
198
226
  meta.subAssets,
199
227
  (output) => deriveAssetName(path, meta.subAssets.length, output.name),
@@ -212,12 +240,20 @@ function projectSource(
212
240
  ) {
213
241
  return { entries: [] };
214
242
  }
215
- const projected = projectMeta(path, cwd, base, assetPaths, importerPolicy, sourceDeclaration);
243
+ const projected = projectMeta(
244
+ path,
245
+ cwd,
246
+ base,
247
+ assetPaths,
248
+ importerPolicy,
249
+ sourceDeclaration,
250
+ sourceIdentityFor,
251
+ );
216
252
  if (projected.declaration !== undefined) declarations.set(path, projected.declaration);
217
253
  return projected;
218
254
  }
219
255
  const parsed = sourceDeclaration.value;
220
- const sourcePath = sourcePathFor(cwd, path);
256
+ const sourcePath = catalogSourcePathFor(cwd, path, sourceIdentityFor);
221
257
  const provenance =
222
258
  parsed.provenance ??
223
259
  ({ provider: 'pack', version: parsed.schemaVersion } satisfies ProviderProvenance);
@@ -297,6 +333,7 @@ export async function buildCatalogProjection(
297
333
  options.visibility,
298
334
  sourceDeclaration,
299
335
  declarations,
336
+ options.sourceIdentityFor,
300
337
  );
301
338
  entries.push(...projected.entries);
302
339
  if (projected.error !== undefined) errors.push(projected.error);