@ikon85/agent-workflow-kit 0.34.4 → 0.34.6
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/.agents/skills/kit-release/SKILL.md +29 -21
- package/.agents/skills/kit-update/SKILL.md +6 -3
- package/.agents/skills/setup-workflow/SKILL.md +13 -5
- package/.agents/skills/setup-workflow/assets/agent-workflow-kit-update.yml +1 -3
- package/.claude/skills/kit-release/SKILL.md +29 -21
- package/.claude/skills/kit-update/SKILL.md +6 -3
- package/.claude/skills/setup-workflow/SKILL.md +13 -5
- package/.claude/skills/setup-workflow/assets/agent-workflow-kit-update.yml +1 -3
- package/README.md +15 -0
- package/agent-workflow-kit.package.json +10 -10
- package/docs/adr/0001-consumer-divergence-policy.md +4 -0
- package/docs/adr/0003-kit-core-and-project-extension-lifecycle.md +63 -0
- package/docs/adr/0004-release-intent-is-a-version-tag.md +18 -5
- package/docs/agents/board-sync.md +1 -1
- package/docs/agents/workflow-capabilities.json +17 -0
- package/docs/research/benchlm-routing-source.md +198 -0
- package/docs/research/consumer-owned-protocol-files.md +238 -0
- package/docs/research/frontend-agent-benchmarks.md +282 -0
- package/docs/research/model-effort-routing-benchmarks.md +261 -0
- package/docs/research/provider-neutral-agent-routing.md +207 -0
- package/package.json +1 -1
- package/scripts/kit-release.test.mjs +10 -4
- package/scripts/kit-update-pr.mjs +1 -1
- package/scripts/kit-update-pr.test.mjs +2 -0
- package/scripts/test_release_authorization_contract.py +101 -0
- package/scripts/test_skill_readiness_contract.py +6 -1
- package/scripts/test_skill_setup_workflow_seeds.py +18 -17
- package/src/commands/update.mjs +52 -20
- package/src/lib/bundle.mjs +1 -1
- package/src/lib/updateCandidate.mjs +278 -50
- package/src/lib/verifyUpdateCandidate.mjs +220 -0
- package/src/lib/verifyUpdateCandidateArtifacts.mjs +78 -0
- package/src/lib/verifyUpdateCandidateProtocol.mjs +221 -0
- package/src/lib/verifyUpdateCandidateTransaction.mjs +152 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { lstat, readdir } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import {
|
|
6
|
+
candidateInputPaths, declaredCandidateRunbookPaths, validateCandidateManifestPath,
|
|
7
|
+
} from './updateCandidate.mjs';
|
|
8
|
+
|
|
9
|
+
const run = promisify(execFile);
|
|
10
|
+
|
|
11
|
+
export async function verifyCandidateMembership(candidateRoot, context) {
|
|
12
|
+
const manifests = [
|
|
13
|
+
context.priorReadinessManifest, context.nextReadinessManifest,
|
|
14
|
+
];
|
|
15
|
+
const allowed = new Set(candidateInputPaths({ pkg: context.pkg, manifests }));
|
|
16
|
+
for (const path of await declaredCandidateRunbookPaths({ candidateRoot, manifests })) {
|
|
17
|
+
allowed.add(path);
|
|
18
|
+
}
|
|
19
|
+
for (const path of context.preview?.generated ?? []) allowed.add(path);
|
|
20
|
+
for (const { path } of context.preview?.migrations ?? []) allowed.add(path);
|
|
21
|
+
for (const path of await regularCandidateFiles(candidateRoot)) {
|
|
22
|
+
if (!allowed.has(path)) {
|
|
23
|
+
throw new Error(`candidate invariant artifact: extra ${path}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function verifyChangedSyntax(candidateRoot, preview) {
|
|
29
|
+
const changed = new Set([
|
|
30
|
+
...(preview?.added ?? []),
|
|
31
|
+
...(preview?.updated ?? []),
|
|
32
|
+
...(preview?.generated ?? []),
|
|
33
|
+
...(preview?.migrations ?? []).map(({ path }) => path),
|
|
34
|
+
]);
|
|
35
|
+
for (const path of [...changed].sort()) {
|
|
36
|
+
const absolute = join(candidateRoot, path);
|
|
37
|
+
let command;
|
|
38
|
+
if (/\.(?:mjs|cjs|js)$/.test(path)) {
|
|
39
|
+
command = [process.execPath, ['--check', absolute], 'Node'];
|
|
40
|
+
} else if (path.endsWith('.py')) {
|
|
41
|
+
command = [
|
|
42
|
+
'python3',
|
|
43
|
+
[
|
|
44
|
+
'-c',
|
|
45
|
+
'import ast,pathlib,sys; ast.parse(pathlib.Path(sys.argv[1]).read_bytes())',
|
|
46
|
+
absolute,
|
|
47
|
+
],
|
|
48
|
+
'Python',
|
|
49
|
+
];
|
|
50
|
+
} else if (path.endsWith('.sh')) {
|
|
51
|
+
command = ['bash', ['-n', absolute], 'shell'];
|
|
52
|
+
} else {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
await run(command[0], command[1]);
|
|
57
|
+
} catch {
|
|
58
|
+
throw new Error(`candidate invariant syntax: ${command[2]} parse failed ${path}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function regularCandidateFiles(root, relative = '') {
|
|
64
|
+
const files = [];
|
|
65
|
+
for (const entry of await readdir(join(root, relative), { withFileTypes: true })) {
|
|
66
|
+
const path = relative ? `${relative}/${entry.name}` : entry.name;
|
|
67
|
+
if (entry.isDirectory()) {
|
|
68
|
+
files.push(...await regularCandidateFiles(root, path));
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const state = await lstat(join(root, path));
|
|
72
|
+
if (!state.isFile()) {
|
|
73
|
+
throw new Error(`candidate invariant artifact: not a regular file ${path}`);
|
|
74
|
+
}
|
|
75
|
+
files.push(path);
|
|
76
|
+
}
|
|
77
|
+
return files;
|
|
78
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { CONSUMER_INSTALL_ROLE, CONSUMER_ORIGIN, filesForInstallRole } from './manifest.mjs';
|
|
4
|
+
import { validateCandidateManifestPath } from './updateCandidate.mjs';
|
|
5
|
+
|
|
6
|
+
const READINESS_MANIFEST_PATH = '.claude/skills/skill-manifest.json';
|
|
7
|
+
const SURFACE_ROOT = {
|
|
8
|
+
claude: '.claude/skills/',
|
|
9
|
+
codex: '.agents/skills/',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export async function verifyCandidateProtocol(candidateRoot, pkg, installed) {
|
|
13
|
+
if (!filesForInstallRole(pkg).some(({ path }) => path === READINESS_MANIFEST_PATH)) return;
|
|
14
|
+
let manifest;
|
|
15
|
+
try {
|
|
16
|
+
manifest = JSON.parse(await readFile(join(candidateRoot, READINESS_MANIFEST_PATH), 'utf8'));
|
|
17
|
+
} catch {
|
|
18
|
+
throw new Error('candidate invariant schema: readiness manifest is invalid JSON');
|
|
19
|
+
}
|
|
20
|
+
validateReadinessManifest(manifest);
|
|
21
|
+
validateSkillArtifactReferences(manifest, pkg);
|
|
22
|
+
for (const [name, declaration] of Object.entries(manifest.skills)) {
|
|
23
|
+
if (!declaration.publish
|
|
24
|
+
|| (declaration.installRole ?? CONSUMER_INSTALL_ROLE) !== CONSUMER_INSTALL_ROLE
|
|
25
|
+
|| !declaration.surfaces.includes('claude')
|
|
26
|
+
|| !declaration.surfaces.includes('codex')) continue;
|
|
27
|
+
await verifyMirrorGroup(candidateRoot, pkg, installed, name, declaration.class);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function validateReadinessManifest(manifest) {
|
|
32
|
+
if (!manifest || manifest.schema_version !== 1
|
|
33
|
+
|| !isRecord(manifest.skills)
|
|
34
|
+
|| !isRecord(manifest.readiness)
|
|
35
|
+
|| !Number.isInteger(manifest.readiness.contractVersion)
|
|
36
|
+
|| !isRecord(manifest.readiness.capabilities)) {
|
|
37
|
+
throw new Error('candidate invariant schema: readiness manifest identity is invalid');
|
|
38
|
+
}
|
|
39
|
+
const capabilities = manifest.readiness.capabilities;
|
|
40
|
+
for (const [name, capability] of Object.entries(capabilities)) {
|
|
41
|
+
const evidence = capability?.evidence;
|
|
42
|
+
if (!isRecord(evidence) || typeof evidence.type !== 'string'
|
|
43
|
+
|| !Array.isArray(evidence.paths) || !evidence.paths.length) {
|
|
44
|
+
throw new Error(`candidate invariant schema: invalid capability evidence ${name}`);
|
|
45
|
+
}
|
|
46
|
+
for (const path of evidence.paths) {
|
|
47
|
+
try {
|
|
48
|
+
validateCandidateManifestPath(path);
|
|
49
|
+
} catch {
|
|
50
|
+
throw new Error(`candidate invariant schema: unsafe capability path ${name}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
for (const [name, skill] of Object.entries(manifest.skills)) {
|
|
55
|
+
const readiness = skill?.readiness ?? {};
|
|
56
|
+
if (!isRecord(skill) || typeof skill.publish !== 'boolean'
|
|
57
|
+
|| !['generic', 'vendored', 'adapter'].includes(skill.class)
|
|
58
|
+
|| ![CONSUMER_INSTALL_ROLE, 'maintainer'].includes(
|
|
59
|
+
skill.installRole ?? CONSUMER_INSTALL_ROLE,
|
|
60
|
+
)
|
|
61
|
+
|| !Array.isArray(skill.surfaces)
|
|
62
|
+
|| (skill.publish && !skill.surfaces.length)
|
|
63
|
+
|| skill.surfaces.some((surface) => !SURFACE_ROOT[surface])
|
|
64
|
+
|| new Set(skill.surfaces).size !== skill.surfaces.length
|
|
65
|
+
|| !isRecord(readiness)
|
|
66
|
+
|| !Array.isArray(readiness.required ?? [])
|
|
67
|
+
|| !isRecord(readiness.optionalBlocks ?? {})
|
|
68
|
+
|| [...(readiness.required ?? []), ...Object.values(readiness.optionalBlocks ?? {})]
|
|
69
|
+
.some((capability) => typeof capability !== 'string' || !capability)) {
|
|
70
|
+
throw new Error(`candidate invariant schema: invalid skill declaration ${name}`);
|
|
71
|
+
}
|
|
72
|
+
for (const capability of [
|
|
73
|
+
...(readiness.required ?? []),
|
|
74
|
+
...Object.values(readiness.optionalBlocks ?? {}),
|
|
75
|
+
]) {
|
|
76
|
+
if (!capabilities[capability]) {
|
|
77
|
+
throw new Error(`candidate invariant schema: unknown readiness reference ${name}.${capability}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function validateSkillArtifactReferences(manifest, pkg) {
|
|
84
|
+
const installable = filesForInstallRole(pkg);
|
|
85
|
+
for (const entry of installable.filter(({ kind }) => kind === 'skill')) {
|
|
86
|
+
const name = entry.ownerSkill ?? skillNameFromPath(entry.path);
|
|
87
|
+
const surface = entry.surface ?? skillSurfaceFromPath(entry.path);
|
|
88
|
+
const declaration = manifest.skills[name];
|
|
89
|
+
if (!declaration?.publish || !declaration.surfaces.includes(surface)) {
|
|
90
|
+
throw new Error(`candidate invariant schema: undeclared skill artifact ${entry.path}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
for (const [name, declaration] of Object.entries(manifest.skills)) {
|
|
94
|
+
if (!declaration.publish
|
|
95
|
+
|| (declaration.installRole ?? CONSUMER_INSTALL_ROLE) !== CONSUMER_INSTALL_ROLE) continue;
|
|
96
|
+
for (const surface of declaration.surfaces) {
|
|
97
|
+
const path = `${SURFACE_ROOT[surface]}${name}/SKILL.md`;
|
|
98
|
+
if (!installable.some((entry) => entry.path === path)) {
|
|
99
|
+
throw new Error(`candidate invariant schema: missing skill entrypoint ${path}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function verifyMirrorGroup(candidateRoot, pkg, installed, name, skillClass) {
|
|
106
|
+
const relativeBySurface = {};
|
|
107
|
+
for (const [surface, root] of Object.entries(SURFACE_ROOT)) {
|
|
108
|
+
const prefix = `${root}${name}/`;
|
|
109
|
+
relativeBySurface[surface] = new Map(filesForInstallRole(pkg)
|
|
110
|
+
.filter(({ path }) => path.startsWith(prefix))
|
|
111
|
+
.map((entry) => [entry.path.slice(prefix.length), entry.path]));
|
|
112
|
+
}
|
|
113
|
+
const claude = relativeBySurface.claude;
|
|
114
|
+
const codex = relativeBySurface.codex;
|
|
115
|
+
const union = new Set([...claude.keys(), ...codex.keys()]);
|
|
116
|
+
for (const relative of union) {
|
|
117
|
+
const sourcePath = claude.get(relative);
|
|
118
|
+
const mirrorPath = codex.get(relative);
|
|
119
|
+
if (!sourcePath || !mirrorPath) {
|
|
120
|
+
throw new Error(`candidate invariant protocol: mirror file-set mismatch ${name}/${relative}`);
|
|
121
|
+
}
|
|
122
|
+
if (installed.get(sourcePath)?.origin === CONSUMER_ORIGIN
|
|
123
|
+
|| installed.get(mirrorPath)?.origin === CONSUMER_ORIGIN) continue;
|
|
124
|
+
const source = await readFile(join(candidateRoot, sourcePath));
|
|
125
|
+
const mirror = await readFile(join(candidateRoot, mirrorPath));
|
|
126
|
+
if (relative.endsWith('.md') && ['generic', 'vendored'].includes(skillClass)) {
|
|
127
|
+
const left = normalizedMirrorMarkdown(source.toString('utf8'));
|
|
128
|
+
const right = normalizedMirrorMarkdown(mirror.toString('utf8'));
|
|
129
|
+
if (left.sequence.join('\0') !== right.sequence.join('\0')
|
|
130
|
+
|| left.body !== right.body) {
|
|
131
|
+
throw new Error(`candidate invariant protocol: mirror content mismatch ${name}/${relative}`);
|
|
132
|
+
}
|
|
133
|
+
} else if (!source.equals(mirror)) {
|
|
134
|
+
throw new Error(`candidate invariant protocol: mirror content mismatch ${name}/${relative}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function normalizedMirrorMarkdown(text) {
|
|
140
|
+
const lines = text.split(/\r?\n/);
|
|
141
|
+
let body = lines;
|
|
142
|
+
let frontmatter = [];
|
|
143
|
+
if (lines[0] === '---') {
|
|
144
|
+
const end = lines.indexOf('---', 1);
|
|
145
|
+
if (end < 0) throw new Error('candidate invariant schema: unterminated skill frontmatter');
|
|
146
|
+
frontmatter = normalizedFrontmatter(lines.slice(1, end));
|
|
147
|
+
body = lines.slice(end + 1);
|
|
148
|
+
}
|
|
149
|
+
const kept = [];
|
|
150
|
+
const sequence = [];
|
|
151
|
+
let inTransform = false;
|
|
152
|
+
for (const line of body) {
|
|
153
|
+
const start = /^\s*<!--\s*mirror-xform:start(?:\s+([^>]*?))?\s*-->\s*$/.exec(line);
|
|
154
|
+
if (start) {
|
|
155
|
+
if (inTransform) throw new Error('candidate invariant protocol: nested mirror transform');
|
|
156
|
+
inTransform = true;
|
|
157
|
+
sequence.push((start[1] ?? '').trim());
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (/^\s*<!--\s*mirror-xform:end\s*-->\s*$/.test(line)) {
|
|
161
|
+
if (!inTransform) throw new Error('candidate invariant protocol: unmatched mirror transform');
|
|
162
|
+
inTransform = false;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (!inTransform) kept.push(line.trimEnd());
|
|
166
|
+
}
|
|
167
|
+
if (inTransform) throw new Error('candidate invariant protocol: unterminated mirror transform');
|
|
168
|
+
return { body: [...frontmatter, ...kept].join('\n').trim(), sequence };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function normalizedFrontmatter(lines) {
|
|
172
|
+
const fields = new Map();
|
|
173
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
174
|
+
const match = /^(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+)):\s*(.*)$/.exec(
|
|
175
|
+
lines[index].trimEnd(),
|
|
176
|
+
);
|
|
177
|
+
if (!match) throw new Error('candidate invariant schema: invalid skill frontmatter');
|
|
178
|
+
const key = match[1] ?? match[2] ?? match[3];
|
|
179
|
+
if (fields.has(key)) throw new Error(`candidate invariant schema: duplicate frontmatter key ${key}`);
|
|
180
|
+
let value = match[4];
|
|
181
|
+
if (/^[>|]-?$/.test(value)) {
|
|
182
|
+
const folded = value.startsWith('>');
|
|
183
|
+
const continuation = [];
|
|
184
|
+
while (index + 1 < lines.length && /^\s+/.test(lines[index + 1])) {
|
|
185
|
+
index += 1;
|
|
186
|
+
continuation.push(lines[index].trim());
|
|
187
|
+
}
|
|
188
|
+
value = continuation.join(folded ? ' ' : '\n');
|
|
189
|
+
} else if (value.startsWith('"') && value.endsWith('"')) {
|
|
190
|
+
try { value = JSON.parse(value); } catch {
|
|
191
|
+
throw new Error(`candidate invariant schema: invalid frontmatter value ${key}`);
|
|
192
|
+
}
|
|
193
|
+
} else if (value.startsWith("'") && value.endsWith("'")) {
|
|
194
|
+
value = value.slice(1, -1).replaceAll("''", "'");
|
|
195
|
+
} else if (/^(?:true|false|null|-?\d+(?:\.\d+)?)$/.test(value)) {
|
|
196
|
+
value = JSON.parse(value);
|
|
197
|
+
}
|
|
198
|
+
fields.set(key, value);
|
|
199
|
+
}
|
|
200
|
+
return [
|
|
201
|
+
'---',
|
|
202
|
+
...[...fields].filter(([key]) => key !== 'description')
|
|
203
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
204
|
+
.map(([key, value]) => `${key}: ${JSON.stringify(value)}`),
|
|
205
|
+
'---',
|
|
206
|
+
];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function skillNameFromPath(path) {
|
|
210
|
+
return path.split('/')[2];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function skillSurfaceFromPath(path) {
|
|
214
|
+
if (path.startsWith(SURFACE_ROOT.claude)) return 'claude';
|
|
215
|
+
if (path.startsWith(SURFACE_ROOT.codex)) return 'codex';
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function isRecord(value) {
|
|
220
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
221
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { sha256File } from './hash.mjs';
|
|
3
|
+
import { CONSUMER_INSTALL_ROLE, CONSUMER_ORIGIN, KIT_ORIGIN } from './manifest.mjs';
|
|
4
|
+
import { validateCandidateManifestPath } from './updateCandidate.mjs';
|
|
5
|
+
|
|
6
|
+
const HASH = /^[a-f0-9]{64}$/;
|
|
7
|
+
|
|
8
|
+
export function verifyDeletionState(installed, pkg, installable, preview) {
|
|
9
|
+
const installablePaths = new Set(installable.map(({ path }) => path));
|
|
10
|
+
const packageEntries = new Map(pkg.files.map((entry) => [entry.path, entry]));
|
|
11
|
+
const deleted = new Set(preview?.deleted ?? []);
|
|
12
|
+
const preserved = new Set(preview?.keptDeleted ?? []);
|
|
13
|
+
for (const [path, tracked] of installed) {
|
|
14
|
+
if (installablePaths.has(path)) continue;
|
|
15
|
+
if (deleted.has(path) && tracked.origin === KIT_ORIGIN) {
|
|
16
|
+
throw new Error(`candidate invariant deletion: stale Kit ledger path ${path}`);
|
|
17
|
+
}
|
|
18
|
+
if (tracked.origin === CONSUMER_ORIGIN) continue;
|
|
19
|
+
if (!preserved.has(path)) {
|
|
20
|
+
throw new Error(`candidate invariant deletion: undeclared legacy Kit path ${path}`);
|
|
21
|
+
}
|
|
22
|
+
const packaged = packageEntries.get(path);
|
|
23
|
+
const expectedRole = packaged?.installRole ?? tracked.installRole ?? CONSUMER_INSTALL_ROLE;
|
|
24
|
+
if ((tracked.installRole ?? CONSUMER_INSTALL_ROLE) !== expectedRole) {
|
|
25
|
+
throw new Error(`candidate invariant deletion: role mismatch ${path}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
for (const path of deleted) {
|
|
29
|
+
if (installed.has(path)) {
|
|
30
|
+
throw new Error(`candidate invariant deletion: stale Kit ledger path ${path}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
for (const path of preserved) {
|
|
34
|
+
if (!installed.has(path)) {
|
|
35
|
+
throw new Error(`candidate invariant deletion: missing preserved ledger path ${path}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function verifyTransactionPreview(preview, installable, installed) {
|
|
41
|
+
if (!preview || typeof preview !== 'object') {
|
|
42
|
+
throw new Error('candidate invariant transaction: preview is required');
|
|
43
|
+
}
|
|
44
|
+
const installablePaths = new Set(installable.map(({ path }) => path));
|
|
45
|
+
const owners = new Map();
|
|
46
|
+
const actionSets = new Map();
|
|
47
|
+
for (const key of ['added', 'updated', 'deleted', 'generated', 'keptDeleted']) {
|
|
48
|
+
if (!Array.isArray(preview[key] ?? [])) {
|
|
49
|
+
throw new Error(`candidate invariant transaction: ${key} must be an array`);
|
|
50
|
+
}
|
|
51
|
+
const local = new Set();
|
|
52
|
+
for (const path of preview[key] ?? []) {
|
|
53
|
+
claimTransactionPath(path, key, local, owners);
|
|
54
|
+
if (['added', 'updated'].includes(key) && !installablePaths.has(path)) {
|
|
55
|
+
throw new Error(`candidate invariant transaction: unmanaged ${key} path ${path}`);
|
|
56
|
+
}
|
|
57
|
+
if (key === 'deleted' && installablePaths.has(path)) {
|
|
58
|
+
throw new Error(`candidate invariant transaction: deletes current package path ${path}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
actionSets.set(key, local);
|
|
62
|
+
}
|
|
63
|
+
if (!Array.isArray(preview.migrations ?? [])) {
|
|
64
|
+
throw new Error('candidate invariant transaction: migrations must be an array');
|
|
65
|
+
}
|
|
66
|
+
const migrationPaths = new Set();
|
|
67
|
+
for (const migration of preview.migrations ?? []) {
|
|
68
|
+
claimTransactionPath(migration?.path, 'migrations', migrationPaths, owners);
|
|
69
|
+
}
|
|
70
|
+
verifyCollisionRecords(preview, installablePaths, installed, actionSets, owners);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function verifyDerivedArtifacts(candidateRoot, installed, preview) {
|
|
74
|
+
for (const path of preview.generated ?? []) {
|
|
75
|
+
const tracked = installed.get(path);
|
|
76
|
+
if (!tracked || tracked.origin !== CONSUMER_ORIGIN
|
|
77
|
+
|| !HASH.test(tracked.installedSha256 ?? '')) {
|
|
78
|
+
throw new Error(`candidate invariant transaction: invalid generated ledger ${path}`);
|
|
79
|
+
}
|
|
80
|
+
if (await transactionHash(candidateRoot, path) !== tracked.installedSha256) {
|
|
81
|
+
throw new Error(`candidate invariant transaction: generated hash mismatch ${path}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const migrations = new Set();
|
|
85
|
+
for (const migration of preview.migrations ?? []) {
|
|
86
|
+
const path = migration?.path;
|
|
87
|
+
claimTransactionPath(path, 'migration', migrations);
|
|
88
|
+
if (!(migration.beforeSha256 === null || HASH.test(migration.beforeSha256 ?? ''))
|
|
89
|
+
|| !HASH.test(migration.afterSha256 ?? '')
|
|
90
|
+
|| await transactionHash(candidateRoot, path) !== migration.afterSha256) {
|
|
91
|
+
throw new Error(`candidate invariant transaction: migration hash mismatch ${path}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function verifyCollisionRecords(preview, installablePaths, installed, actionSets, owners) {
|
|
97
|
+
if (!Array.isArray(preview.collisions ?? [])
|
|
98
|
+
|| !Array.isArray(preview.collisionResolutions ?? [])) {
|
|
99
|
+
throw new Error('candidate invariant transaction: collision records must be arrays');
|
|
100
|
+
}
|
|
101
|
+
const unresolved = new Set();
|
|
102
|
+
for (const path of preview.collisions ?? []) {
|
|
103
|
+
claimTransactionPath(path, 'collisions', unresolved);
|
|
104
|
+
}
|
|
105
|
+
if (unresolved.size) {
|
|
106
|
+
throw new Error('candidate invariant transaction: unresolved collision');
|
|
107
|
+
}
|
|
108
|
+
const resolutions = new Set();
|
|
109
|
+
for (const resolution of preview.collisionResolutions ?? []) {
|
|
110
|
+
const path = resolution?.path;
|
|
111
|
+
claimTransactionPath(path, 'collision resolution', resolutions);
|
|
112
|
+
if (!installablePaths.has(path)
|
|
113
|
+
|| !['keep-as-owned', 'replace'].includes(resolution?.outcome)
|
|
114
|
+
|| !HASH.test(resolution?.destinationSha256 ?? '')) {
|
|
115
|
+
throw new Error(`candidate invariant transaction: invalid collision resolution ${path}`);
|
|
116
|
+
}
|
|
117
|
+
const expectedOrigin = resolution.outcome === 'keep-as-owned' ? CONSUMER_ORIGIN : KIT_ORIGIN;
|
|
118
|
+
if (installed.get(path)?.origin !== expectedOrigin
|
|
119
|
+
|| (resolution.outcome === 'replace' && !actionSets.get('added').has(path))
|
|
120
|
+
|| (resolution.outcome === 'keep-as-owned' && owners.has(path))) {
|
|
121
|
+
throw new Error(`candidate invariant transaction: incoherent collision resolution ${path}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function claimTransactionPath(path, key, local, owners = null) {
|
|
127
|
+
try {
|
|
128
|
+
validateCandidateManifestPath(path);
|
|
129
|
+
} catch {
|
|
130
|
+
throw new Error(`candidate invariant transaction: unsafe ${key} path ${path}`);
|
|
131
|
+
}
|
|
132
|
+
if (local.has(path)) {
|
|
133
|
+
throw new Error(`candidate invariant transaction: duplicate ${key} path ${path}`);
|
|
134
|
+
}
|
|
135
|
+
local.add(path);
|
|
136
|
+
const owner = owners?.get(path);
|
|
137
|
+
if (owner && owner !== key) {
|
|
138
|
+
throw new Error(`candidate invariant transaction: overlapping action ${path}`);
|
|
139
|
+
}
|
|
140
|
+
owners?.set(path, key);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function transactionHash(candidateRoot, path) {
|
|
144
|
+
try {
|
|
145
|
+
return await sha256File(join(candidateRoot, path));
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (error.code === 'ENOENT') {
|
|
148
|
+
throw new Error(`candidate invariant transaction: missing derived artifact ${path}`);
|
|
149
|
+
}
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
}
|