@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.
- package/README.md +12 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/build.d.ts +1 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.mjs +100 -51
- package/dist/build.mjs.map +1 -1
- package/dist/catalog-builder.d.ts +12 -0
- package/dist/catalog-builder.d.ts.map +1 -1
- package/dist/cli-asset.mjs +1 -1
- package/dist/cli-asset.mjs.map +1 -1
- package/dist/evidence/material-cook.d.ts +28 -6
- package/dist/evidence/material-cook.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +97 -42
- package/dist/index.mjs.map +1 -1
- package/dist/material-cook.d.ts +1 -1
- package/dist/material-cook.d.ts.map +1 -1
- package/dist/material-cook.mjs +96 -41
- package/dist/material-cook.mjs.map +1 -1
- package/dist/scanner.mjs +1 -1
- package/dist/scanner.mjs.map +1 -1
- package/dist/scriptable-pack-node.d.ts.map +1 -1
- package/dist/scriptable-pack-node.mjs +1 -1
- package/dist/scriptable-pack-node.mjs.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/material-cook-dependencies.unit.test.ts +111 -0
- package/src/__tests__/material-cook-receipt.unit.test.ts +77 -20
- package/src/__tests__/material-cook-schema.unit.test.ts +64 -10
- package/src/__tests__/pack-v2-fixture-migration.test.ts +24 -1
- package/src/build.ts +1 -0
- package/src/catalog-builder.ts +42 -5
- package/src/evidence/material-cook.ts +133 -54
- package/src/index.ts +4 -0
- package/src/material-cook.ts +2 -0
- package/src/schema/material-cook.schema.json +48 -8
- 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.
|
|
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.
|
|
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.
|
|
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/
|
|
13
|
+
schemaVersion: 'material-cook/3',
|
|
14
14
|
sourceClosure: ['b.wgsl', 'a.material.json'],
|
|
15
15
|
profile: 'webgpu/v1',
|
|
16
16
|
compilerVersion: 'compiler/1',
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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('
|
|
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/
|
|
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(
|
|
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({
|
|
61
|
-
receipt({
|
|
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
|
-
|
|
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
|
|
138
|
+
const generationReceipt = {
|
|
79
139
|
sourceClosure: ['materials/mat-root.material.json'],
|
|
80
140
|
profile: 'webgpu/v1',
|
|
81
141
|
compilerVersion: 'compiler/1',
|
|
82
|
-
|
|
83
|
-
outputDigest: 'sha256:artifact',
|
|
84
|
-
publicationGeneration: 11,
|
|
142
|
+
identity: { ...receipt().identity, cookGeneration: 11 },
|
|
85
143
|
};
|
|
86
|
-
expect(
|
|
87
|
-
|
|
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/
|
|
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'
|
|
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'
|
|
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/
|
|
34
|
+
schemaVersion: 'material-cook/3',
|
|
35
35
|
sourceClosure: ['materials/mat-root.material.json'],
|
|
36
36
|
profile: 'webgpu/v1',
|
|
37
37
|
compilerVersion: 'compiler/1',
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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/
|
|
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/
|
|
76
|
+
expect(schema.properties.schemaVersion?.const).toBe('material-cook/3');
|
|
60
77
|
expect(schema.properties.receipt?.required).toEqual(
|
|
61
|
-
expect.arrayContaining(['
|
|
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
package/src/catalog-builder.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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);
|