release-skill 0.2.4 → 0.2.5
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codebuddy-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/.kimi-plugin/plugin.json +1 -1
- package/CHANGELOG.md +24 -0
- package/INSTALL.md +4 -4
- package/INSTALL.zh-CN.md +4 -4
- package/README.md +16 -14
- package/README.zh-CN.md +16 -14
- package/adapters/claude/.claude-plugin/marketplace.json +1 -1
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/bin/release-skill.bundle.mjs +1375 -607
- package/adapters/claude/schemas/release-plan.schema.json +91 -0
- package/adapters/claude/schemas/release-run.schema.json +85 -0
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/release-skill.bundle.mjs +1375 -607
- package/adapters/codex/schemas/release-plan.schema.json +91 -0
- package/adapters/codex/schemas/release-run.schema.json +85 -0
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/release-skill.bundle.mjs +1375 -607
- package/adapters/kimi/schemas/release-plan.schema.json +91 -0
- package/adapters/kimi/schemas/release-run.schema.json +85 -0
- package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
- package/adapters/workbuddy/bin/release-skill.bundle.mjs +1375 -607
- package/adapters/workbuddy/schemas/release-plan.schema.json +91 -0
- package/adapters/workbuddy/schemas/release-run.schema.json +85 -0
- package/bin/release-skill.bundle.mjs +1375 -607
- package/package.json +1 -1
- package/schemas/release-plan.schema.json +91 -0
- package/schemas/release-run.schema.json +85 -0
- package/scripts/sync-public-files.mjs +20 -9
- package/src/adapters/plugin-marketplace.mjs +82 -3
- package/src/commands/prepare.mjs +74 -0
- package/src/commands/publish.mjs +62 -0
- package/src/commands/verify.mjs +151 -1
- package/src/core/plan.mjs +29 -0
- package/src/core/skill-resource-closure.mjs +425 -0
- package/src/platforms/codebuddy.mjs +16 -3
- package/src/platforms/kimi.mjs +36 -9
package/src/commands/verify.mjs
CHANGED
|
@@ -51,6 +51,11 @@ import {
|
|
|
51
51
|
resolveNpmRegistryAuthToken,
|
|
52
52
|
} from '../adapters/npm.mjs';
|
|
53
53
|
import { runConsumerVerificationGates } from '../core/verification-gates.mjs';
|
|
54
|
+
import {
|
|
55
|
+
checkSkillResourceClosure,
|
|
56
|
+
createSkillResourceClosureReceipt,
|
|
57
|
+
evaluateConsumerSkillResourceClosureReceipts,
|
|
58
|
+
} from '../core/skill-resource-closure.mjs';
|
|
54
59
|
import { isRemoteWriteAction, isMarketplaceAction } from '../core/checkpoints.mjs';
|
|
55
60
|
import {
|
|
56
61
|
shouldSkipVerification,
|
|
@@ -216,11 +221,13 @@ export async function runSmokeTest(plan, root, options = {}) {
|
|
|
216
221
|
skipped: true,
|
|
217
222
|
details: { message: 'No npm distributions in plan; smoke test skipped' },
|
|
218
223
|
gateResults: [],
|
|
224
|
+
skillResourceClosureReceipts: [],
|
|
219
225
|
};
|
|
220
226
|
}
|
|
221
227
|
|
|
222
228
|
const results = [];
|
|
223
229
|
const gateResults = [];
|
|
230
|
+
const skillResourceClosureReceipts = [];
|
|
224
231
|
|
|
225
232
|
for (const { package: pkgName, registry, targetVersion, unitId, smokeBin, smokeArgs, smokeExpectedJson } of npmDistributions) {
|
|
226
233
|
const packageAtVersion = `${pkgName}@${targetVersion}`;
|
|
@@ -285,6 +292,47 @@ export async function runSmokeTest(plan, root, options = {}) {
|
|
|
285
292
|
}
|
|
286
293
|
|
|
287
294
|
const pkgRoot = join(installDir, 'node_modules', pkgName);
|
|
295
|
+
if (plan.skillResourceClosure) {
|
|
296
|
+
const expectedUnitReceipt = plan.skillResourceClosure.unitReceipts
|
|
297
|
+
.find((item) => item.unitId === unitId);
|
|
298
|
+
if (!expectedUnitReceipt) {
|
|
299
|
+
return {
|
|
300
|
+
passed: false,
|
|
301
|
+
details: { error: `Skill resource closure receipt missing for npm unit "${unitId}"` },
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
const closureResult = await checkSkillResourceClosure({
|
|
305
|
+
snapshotDir: pkgRoot,
|
|
306
|
+
host: 'npm',
|
|
307
|
+
});
|
|
308
|
+
if (closureResult.findings.length > 0) {
|
|
309
|
+
return {
|
|
310
|
+
passed: false,
|
|
311
|
+
details: {
|
|
312
|
+
error: `Skill resource closure failed for ${packageAtVersion}`,
|
|
313
|
+
findings: closureResult.findings,
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
if (expectedUnitReceipt.skillCount > 0 && closureResult.skillCount === 0) {
|
|
318
|
+
return {
|
|
319
|
+
passed: false,
|
|
320
|
+
details: {
|
|
321
|
+
error: `Installed npm package ${packageAtVersion} contains no skills`,
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
skillResourceClosureReceipts.push(createSkillResourceClosureReceipt(
|
|
326
|
+
closureResult,
|
|
327
|
+
{
|
|
328
|
+
unitId,
|
|
329
|
+
distribution: 'npm',
|
|
330
|
+
host: 'npm',
|
|
331
|
+
checkedAt: new Date().toISOString(),
|
|
332
|
+
exitCode: 0,
|
|
333
|
+
},
|
|
334
|
+
));
|
|
335
|
+
}
|
|
288
336
|
gateResults.push(...await runConsumerVerificationGates({
|
|
289
337
|
plan,
|
|
290
338
|
unitId,
|
|
@@ -471,6 +519,7 @@ export async function runSmokeTest(plan, root, options = {}) {
|
|
|
471
519
|
count: results.length,
|
|
472
520
|
},
|
|
473
521
|
gateResults,
|
|
522
|
+
skillResourceClosureReceipts,
|
|
474
523
|
};
|
|
475
524
|
} finally {
|
|
476
525
|
await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
@@ -1002,7 +1051,10 @@ export async function verifyRelease(options) {
|
|
|
1002
1051
|
algorithmVersion: INSTALLATION_CONTRACT_ALGORITHM_VERSION,
|
|
1003
1052
|
});
|
|
1004
1053
|
|
|
1005
|
-
|
|
1054
|
+
// A current-run resource-closure receipt requires a fresh isolated
|
|
1055
|
+
// install. Installation-contract reuse alone cannot prove the
|
|
1056
|
+
// installed Skill resources are reachable.
|
|
1057
|
+
if (skipDecision === 'NOT_REQUIRED_UNCHANGED' && !plan.skillResourceClosure) {
|
|
1006
1058
|
adapterChecks.push({
|
|
1007
1059
|
actionId: action.id,
|
|
1008
1060
|
actionType: action.type,
|
|
@@ -1241,6 +1293,103 @@ export async function verifyRelease(options) {
|
|
|
1241
1293
|
);
|
|
1242
1294
|
}
|
|
1243
1295
|
|
|
1296
|
+
// =======================================================================
|
|
1297
|
+
// Step 4b: Skill resource closure check on consumer install surfaces
|
|
1298
|
+
// Re-run the closure check on each declared consumer install surface.
|
|
1299
|
+
// If the plan declares skillResourceClosure, every declared host surface
|
|
1300
|
+
// must be checked and pass; undeclared or failed surfaces block VERIFIED.
|
|
1301
|
+
// =======================================================================
|
|
1302
|
+
const skillResourceClosureReceipts = [
|
|
1303
|
+
...(smokeTest.skillResourceClosureReceipts ?? []),
|
|
1304
|
+
];
|
|
1305
|
+
if (plan.skillResourceClosure) {
|
|
1306
|
+
await evidence.append({ phase: 'verify', step: 'skill-resource-closure', status: 'started' });
|
|
1307
|
+
|
|
1308
|
+
// Collect install paths from marketplace adapter checks
|
|
1309
|
+
const installSurfaces = [];
|
|
1310
|
+
const actionDistribution = {
|
|
1311
|
+
'claude-marketplace-install': 'claude-plugin',
|
|
1312
|
+
'codex-marketplace-install': 'codex-plugin',
|
|
1313
|
+
'kimi-marketplace-install': 'kimi-plugin',
|
|
1314
|
+
'codebuddy-marketplace-install': 'codebuddy-plugin',
|
|
1315
|
+
};
|
|
1316
|
+
for (const check of adapterChecks) {
|
|
1317
|
+
const distribution = actionDistribution[check.actionType];
|
|
1318
|
+
if (distribution && check.observation?.installPath) {
|
|
1319
|
+
installSurfaces.push({
|
|
1320
|
+
host: distribution.replace('-plugin', ''),
|
|
1321
|
+
distribution,
|
|
1322
|
+
installPath: check.observation.installPath,
|
|
1323
|
+
actionId: check.actionId,
|
|
1324
|
+
unitId: actions.find((a) => a.id === check.actionId)?.unitId,
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
for (const surface of installSurfaces) {
|
|
1330
|
+
const closureResult = await checkSkillResourceClosure({
|
|
1331
|
+
snapshotDir: surface.installPath,
|
|
1332
|
+
host: surface.host,
|
|
1333
|
+
});
|
|
1334
|
+
|
|
1335
|
+
if (closureResult.findings.length > 0) {
|
|
1336
|
+
await evidence.append({
|
|
1337
|
+
phase: 'verify',
|
|
1338
|
+
step: 'skill-resource-closure',
|
|
1339
|
+
status: 'failed',
|
|
1340
|
+
host: surface.host,
|
|
1341
|
+
findingCount: closureResult.findings.length,
|
|
1342
|
+
findings: closureResult.findings,
|
|
1343
|
+
});
|
|
1344
|
+
throw new ReleaseError(
|
|
1345
|
+
POST_PUBLISH_VERIFY_FAILED,
|
|
1346
|
+
`skill resource closure check failed for ${surface.host} consumer install: ${closureResult.findings.length} finding(s)`,
|
|
1347
|
+
{ host: surface.host, findings: closureResult.findings },
|
|
1348
|
+
);
|
|
1349
|
+
}
|
|
1350
|
+
const expectedUnitReceipt = plan.skillResourceClosure.unitReceipts
|
|
1351
|
+
.find((item) => item.unitId === surface.unitId);
|
|
1352
|
+
if (!expectedUnitReceipt || closureResult.skillCount === 0) {
|
|
1353
|
+
throw new ReleaseError(
|
|
1354
|
+
POST_PUBLISH_VERIFY_FAILED,
|
|
1355
|
+
`skill resource closure installed surface is empty or unbound for ${surface.distribution}`,
|
|
1356
|
+
{ unitId: surface.unitId, distribution: surface.distribution },
|
|
1357
|
+
);
|
|
1358
|
+
}
|
|
1359
|
+
skillResourceClosureReceipts.push(createSkillResourceClosureReceipt(
|
|
1360
|
+
closureResult,
|
|
1361
|
+
{
|
|
1362
|
+
host: surface.host,
|
|
1363
|
+
distribution: surface.distribution,
|
|
1364
|
+
actionId: surface.actionId,
|
|
1365
|
+
unitId: surface.unitId,
|
|
1366
|
+
checkedAt: clockFn(),
|
|
1367
|
+
exitCode: 0,
|
|
1368
|
+
},
|
|
1369
|
+
));
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
const receiptCoverage = evaluateConsumerSkillResourceClosureReceipts(
|
|
1373
|
+
plan,
|
|
1374
|
+
skillResourceClosureReceipts,
|
|
1375
|
+
);
|
|
1376
|
+
if (!receiptCoverage.passed) {
|
|
1377
|
+
throw new ReleaseError(
|
|
1378
|
+
POST_PUBLISH_VERIFY_FAILED,
|
|
1379
|
+
'skill resource closure receipt set does not match declared consumer distributions',
|
|
1380
|
+
receiptCoverage,
|
|
1381
|
+
);
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
await evidence.append({
|
|
1385
|
+
phase: 'verify',
|
|
1386
|
+
step: 'skill-resource-closure',
|
|
1387
|
+
status: 'completed',
|
|
1388
|
+
surfaceCount: installSurfaces.length,
|
|
1389
|
+
receipts: skillResourceClosureReceipts,
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1244
1393
|
// =======================================================================
|
|
1245
1394
|
// All verifications passed — write verify run
|
|
1246
1395
|
// =======================================================================
|
|
@@ -1285,6 +1434,7 @@ export async function verifyRelease(options) {
|
|
|
1285
1434
|
}),
|
|
1286
1435
|
gateResults: consumerGateResults,
|
|
1287
1436
|
consumerVerificationReceipts,
|
|
1437
|
+
skillResourceClosureReceipts,
|
|
1288
1438
|
startedAt: clockFn(),
|
|
1289
1439
|
finishedAt: clockFn(),
|
|
1290
1440
|
};
|
package/src/core/plan.mjs
CHANGED
|
@@ -165,6 +165,35 @@ export function validatePlan(plan) {
|
|
|
165
165
|
);
|
|
166
166
|
}
|
|
167
167
|
}
|
|
168
|
+
|
|
169
|
+
if (plan.skillResourceClosure) {
|
|
170
|
+
const receipts = plan.skillResourceClosure.unitReceipts ?? [];
|
|
171
|
+
const receiptUnitIds = receipts.map((item) => item.unitId);
|
|
172
|
+
if (
|
|
173
|
+
new Set(receiptUnitIds).size !== receiptUnitIds.length
|
|
174
|
+
|| JSON.stringify([...receiptUnitIds].sort()) !== JSON.stringify([...unitsById.keys()].sort())
|
|
175
|
+
) {
|
|
176
|
+
throw new ReleaseError(
|
|
177
|
+
GATE_FAILED,
|
|
178
|
+
'skill resource closure receipts must cover every release unit exactly once',
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
const totals = {
|
|
182
|
+
totalSkillCount: receipts.reduce((sum, item) => sum + item.skillCount, 0),
|
|
183
|
+
totalReferenceCount: receipts.reduce((sum, item) => sum + item.referenceCount, 0),
|
|
184
|
+
totalSourceOnlyCount: receipts.reduce((sum, item) => sum + item.sourceOnlyCount, 0),
|
|
185
|
+
totalFindingCount: receipts.reduce((sum, item) => sum + item.findingCount, 0),
|
|
186
|
+
};
|
|
187
|
+
for (const [field, expected] of Object.entries(totals)) {
|
|
188
|
+
if (plan.skillResourceClosure[field] !== expected) {
|
|
189
|
+
throw new ReleaseError(
|
|
190
|
+
GATE_FAILED,
|
|
191
|
+
`skill resource closure ${field} does not match unit receipts`,
|
|
192
|
+
{ expected, observed: plan.skillResourceClosure[field] },
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
168
197
|
}
|
|
169
198
|
|
|
170
199
|
/**
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in, read-only Skill resource closure verification.
|
|
3
|
+
*
|
|
4
|
+
* Only path tokens in Markdown code spans, fenced code blocks, and local link
|
|
5
|
+
* targets are interpreted. Bare Skill resources resolve from the directory
|
|
6
|
+
* containing SKILL.md. Explicit plugin-root tokens resolve from the parent of
|
|
7
|
+
* the nearest `skills/` directory.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { lstat, readFile, readdir } from 'node:fs/promises';
|
|
11
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { canonicalJson, sha256Hex } from './digest.mjs';
|
|
14
|
+
import { GATE_FAILED, ReleaseError } from './errors.mjs';
|
|
15
|
+
import { computeFrozenSnapshot } from '../snapshot/frozen.mjs';
|
|
16
|
+
|
|
17
|
+
export const CHECKER_VERSION = 'skill-resource-closure-v1';
|
|
18
|
+
|
|
19
|
+
const BARE_PREFIXES = ['references/', 'assets/', 'schemas/', 'examples/', 'scripts/'];
|
|
20
|
+
const EXPLICIT_PREFIXES = [
|
|
21
|
+
'<plugin-root>/',
|
|
22
|
+
'<plugin_dir>/',
|
|
23
|
+
'$PLUGIN_ROOT/',
|
|
24
|
+
'${PLUGIN_ROOT}/',
|
|
25
|
+
'${CLAUDE_PLUGIN_ROOT}/',
|
|
26
|
+
'PLUGIN_ROOT/',
|
|
27
|
+
];
|
|
28
|
+
const SOURCE_TREE_PATTERN = /(?:^|\/)packages\/[A-Za-z0-9._-]+(?:\/|$)/u;
|
|
29
|
+
const MACHINE_ABSOLUTE_PATTERN = /^(?:\/(?:Users|home|root|tmp|var|etc|opt)(?:\/|$)|[A-Za-z]:[\\/])/u;
|
|
30
|
+
const GLOB_PATTERN = /[*?\[]/u;
|
|
31
|
+
const SOURCE_ONLY_MARKER = /(?:\bsource-only\b|仅源码包)/iu;
|
|
32
|
+
const TRANSPORT_EXCLUSIONS = Object.freeze([
|
|
33
|
+
'.git',
|
|
34
|
+
'node_modules',
|
|
35
|
+
'.in_use',
|
|
36
|
+
'.codex-plugin/migrated-command-skills',
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
export const CLASSIFICATION = Object.freeze({
|
|
40
|
+
SKILL_LOCAL: 'skill_local',
|
|
41
|
+
PLUGIN_ROOT: 'plugin_root',
|
|
42
|
+
SOURCE_BACKJUMP: 'source_backjump',
|
|
43
|
+
MACHINE_ABSOLUTE: 'machine_absolute',
|
|
44
|
+
OUT_OF_BOUNDS: 'out_of_bounds',
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export const FINDING_CODE = Object.freeze({
|
|
48
|
+
SNAPSHOT_UNSAFE: 'SNAPSHOT_UNSAFE',
|
|
49
|
+
RESOURCE_MISSING: 'RESOURCE_MISSING',
|
|
50
|
+
RESOURCE_NOT_REGULAR_FILE: 'RESOURCE_NOT_REGULAR_FILE',
|
|
51
|
+
SOURCE_BACKJUMP: 'SOURCE_BACKJUMP',
|
|
52
|
+
MACHINE_ABSOLUTE_PATH: 'MACHINE_ABSOLUTE_PATH',
|
|
53
|
+
OUT_OF_BOUNDS: 'OUT_OF_BOUNDS',
|
|
54
|
+
SYMLINK_NOT_ALLOWED: 'SYMLINK_NOT_ALLOWED',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
function toPosix(value) {
|
|
58
|
+
return value.replaceAll('\\', '/');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function relativeStable(root, target) {
|
|
62
|
+
const value = toPosix(relative(root, target));
|
|
63
|
+
return value === '' ? '.' : value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isInside(parent, candidate) {
|
|
67
|
+
const rel = relative(parent, candidate);
|
|
68
|
+
return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function discoverSkills(root, base = root) {
|
|
72
|
+
const results = [];
|
|
73
|
+
let entries;
|
|
74
|
+
try {
|
|
75
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (error.code === 'ENOENT') return results;
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
if (entry.name === '.git' || entry.name === 'node_modules') continue;
|
|
82
|
+
const absolute = join(root, entry.name);
|
|
83
|
+
const stableRelative = toPosix(relative(base, absolute));
|
|
84
|
+
if (stableRelative === '.codex-plugin/migrated-command-skills') continue;
|
|
85
|
+
if (entry.isDirectory()) {
|
|
86
|
+
results.push(...await discoverSkills(absolute, base));
|
|
87
|
+
} else if (entry.isFile() && entry.name === 'SKILL.md') {
|
|
88
|
+
results.push(stableRelative);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return results.sort();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function resolvePluginRoot(skillRelativePath, scanRoot) {
|
|
95
|
+
const parts = toPosix(skillRelativePath).split('/');
|
|
96
|
+
const skillsIndex = parts.lastIndexOf('skills');
|
|
97
|
+
if (skillsIndex < 0) return resolve(scanRoot);
|
|
98
|
+
return resolve(scanRoot, ...parts.slice(0, skillsIndex));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function resolveSkillRoot(skillRelativePath, scanRoot) {
|
|
102
|
+
return resolve(scanRoot, dirname(skillRelativePath));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function trimCandidate(value) {
|
|
106
|
+
return value
|
|
107
|
+
.trim()
|
|
108
|
+
.replace(/^["']/u, '')
|
|
109
|
+
.replace(/["']$/u, '')
|
|
110
|
+
.replace(/[.,;:]+$/u, '');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isPathToken(value) {
|
|
114
|
+
const token = trimCandidate(value);
|
|
115
|
+
if (!token || GLOB_PATTERN.test(token)) return false;
|
|
116
|
+
return BARE_PREFIXES.some((prefix) => token.startsWith(prefix))
|
|
117
|
+
|| EXPLICIT_PREFIXES.some((prefix) => token.startsWith(prefix))
|
|
118
|
+
|| SOURCE_TREE_PATTERN.test(token)
|
|
119
|
+
|| MACHINE_ABSOLUTE_PATTERN.test(token);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function scanSnippet(snippet, line, sourceOnly, results, seen) {
|
|
123
|
+
const candidates = snippet.match(/(?:"[^"\n]+"|'[^'\n]+'|[^\s"'`()\[\],;]+)/gu) ?? [];
|
|
124
|
+
for (const candidate of candidates) {
|
|
125
|
+
const token = trimCandidate(candidate);
|
|
126
|
+
if (!isPathToken(token)) continue;
|
|
127
|
+
const key = `${line}:${token}`;
|
|
128
|
+
if (seen.has(key)) continue;
|
|
129
|
+
seen.add(key);
|
|
130
|
+
results.push({ token, line, sourceOnly });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function extractPathTokens(content) {
|
|
135
|
+
const results = [];
|
|
136
|
+
const seen = new Set();
|
|
137
|
+
const lines = content.split(/\r?\n/u);
|
|
138
|
+
let inFence = false;
|
|
139
|
+
|
|
140
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
141
|
+
const line = lines[index];
|
|
142
|
+
const lineNumber = index + 1;
|
|
143
|
+
if (/^\s*```/u.test(line)) {
|
|
144
|
+
inFence = !inFence;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const sourceOnly = SOURCE_ONLY_MARKER.test(line);
|
|
148
|
+
if (inFence) scanSnippet(line, lineNumber, sourceOnly, results, seen);
|
|
149
|
+
|
|
150
|
+
for (const match of line.matchAll(/`([^`]+)`/gu)) {
|
|
151
|
+
scanSnippet(match[1], lineNumber, sourceOnly, results, seen);
|
|
152
|
+
}
|
|
153
|
+
for (const match of line.matchAll(/\]\(([^)]+)\)/gu)) {
|
|
154
|
+
const target = match[1].trim().split(/[?#]/u)[0];
|
|
155
|
+
if (!/^(?:https?:|#)/iu.test(target)) {
|
|
156
|
+
scanSnippet(target, lineNumber, sourceOnly, results, seen);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return results;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function stripExplicitPrefix(token) {
|
|
164
|
+
for (const prefix of EXPLICIT_PREFIXES) {
|
|
165
|
+
if (token.startsWith(prefix)) return token.slice(prefix.length);
|
|
166
|
+
}
|
|
167
|
+
return token;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function classifyReference(token, skillRoot, pluginRoot, scanRoot) {
|
|
171
|
+
if (MACHINE_ABSOLUTE_PATTERN.test(token)) {
|
|
172
|
+
return {
|
|
173
|
+
classification: CLASSIFICATION.MACHINE_ABSOLUTE,
|
|
174
|
+
resolutionRoot: '(machine)',
|
|
175
|
+
resolvedTarget: token,
|
|
176
|
+
absoluteTarget: token,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
if (SOURCE_TREE_PATTERN.test(token)) {
|
|
180
|
+
return {
|
|
181
|
+
classification: CLASSIFICATION.SOURCE_BACKJUMP,
|
|
182
|
+
resolutionRoot: '(source-tree)',
|
|
183
|
+
resolvedTarget: token,
|
|
184
|
+
absoluteTarget: resolve(scanRoot, token),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const explicit = EXPLICIT_PREFIXES.some((prefix) => token.startsWith(prefix));
|
|
189
|
+
const resolutionRootAbsolute = explicit ? pluginRoot : skillRoot;
|
|
190
|
+
const relativeTarget = explicit ? stripExplicitPrefix(token) : token;
|
|
191
|
+
const absoluteTarget = resolve(resolutionRootAbsolute, relativeTarget);
|
|
192
|
+
const inBounds = isInside(resolutionRootAbsolute, absoluteTarget);
|
|
193
|
+
return {
|
|
194
|
+
classification: inBounds
|
|
195
|
+
? (explicit ? CLASSIFICATION.PLUGIN_ROOT : CLASSIFICATION.SKILL_LOCAL)
|
|
196
|
+
: CLASSIFICATION.OUT_OF_BOUNDS,
|
|
197
|
+
resolutionRoot: relativeStable(scanRoot, resolutionRootAbsolute),
|
|
198
|
+
resolvedTarget: relativeStable(scanRoot, absoluteTarget),
|
|
199
|
+
absoluteTarget,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function inspectRegularFile(root, target) {
|
|
204
|
+
if (!isInside(root, target)) return { code: FINDING_CODE.OUT_OF_BOUNDS };
|
|
205
|
+
const parts = relative(root, target).split(sep).filter(Boolean);
|
|
206
|
+
let current = root;
|
|
207
|
+
const rootStat = await lstat(root);
|
|
208
|
+
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
|
|
209
|
+
return { code: FINDING_CODE.SYMLINK_NOT_ALLOWED };
|
|
210
|
+
}
|
|
211
|
+
for (const part of parts) {
|
|
212
|
+
current = join(current, part);
|
|
213
|
+
let stat;
|
|
214
|
+
try {
|
|
215
|
+
stat = await lstat(current);
|
|
216
|
+
} catch (error) {
|
|
217
|
+
if (error.code === 'ENOENT') return { code: FINDING_CODE.RESOURCE_MISSING };
|
|
218
|
+
throw error;
|
|
219
|
+
}
|
|
220
|
+
if (stat.isSymbolicLink()) return { code: FINDING_CODE.SYMLINK_NOT_ALLOWED };
|
|
221
|
+
}
|
|
222
|
+
const targetStat = await lstat(target);
|
|
223
|
+
if (!targetStat.isFile() || targetStat.nlink !== 1) {
|
|
224
|
+
return { code: FINDING_CODE.RESOURCE_NOT_REGULAR_FILE };
|
|
225
|
+
}
|
|
226
|
+
return { code: null };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function inferSurfaceHost(surfaceId, defaultHost) {
|
|
230
|
+
const match = /^adapters\/([^/]+)$/u.exec(surfaceId);
|
|
231
|
+
return match?.[1] ?? defaultHost;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function receiptProjection(result) {
|
|
235
|
+
return {
|
|
236
|
+
checkerVersion: result.checkerVersion,
|
|
237
|
+
inputDigest: result.inputDigest,
|
|
238
|
+
surfaces: result.surfaces,
|
|
239
|
+
skillCount: result.skillCount,
|
|
240
|
+
referenceCount: result.referenceCount,
|
|
241
|
+
sourceOnlyCount: result.sourceOnlyCount,
|
|
242
|
+
findingCount: result.findings.length,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function createSkillResourceClosureReceipt(result, identity = {}) {
|
|
247
|
+
return {
|
|
248
|
+
...identity,
|
|
249
|
+
checkerVersion: result.checkerVersion,
|
|
250
|
+
inputDigest: result.inputDigest,
|
|
251
|
+
surfaceCount: result.surfaces.length,
|
|
252
|
+
skillCount: result.skillCount,
|
|
253
|
+
referenceCount: result.referenceCount,
|
|
254
|
+
sourceOnlyCount: result.sourceOnlyCount,
|
|
255
|
+
findingCount: result.findings.length,
|
|
256
|
+
receiptDigest: result.receiptDigest,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function assertSkillResourceClosureReceipt(expected, observed, label = 'release unit') {
|
|
261
|
+
if (canonicalJson(expected) !== canonicalJson(observed)) {
|
|
262
|
+
throw new ReleaseError(
|
|
263
|
+
GATE_FAILED,
|
|
264
|
+
`skill resource closure receipt changed for ${label}`,
|
|
265
|
+
{ expected, observed },
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function evaluateConsumerSkillResourceClosureReceipts(plan, receipts) {
|
|
271
|
+
const supported = new Set([
|
|
272
|
+
'npm',
|
|
273
|
+
'claude-plugin',
|
|
274
|
+
'codex-plugin',
|
|
275
|
+
'kimi-plugin',
|
|
276
|
+
'codebuddy-plugin',
|
|
277
|
+
]);
|
|
278
|
+
const expectedKeys = [];
|
|
279
|
+
for (const unit of plan.units ?? []) {
|
|
280
|
+
for (const distribution of unit.distributions ?? []) {
|
|
281
|
+
if (supported.has(distribution.type)) {
|
|
282
|
+
expectedKeys.push(`${unit.id}:${distribution.type}`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const observedKeys = receipts.map((item) => `${item.unitId}:${item.distribution}`);
|
|
287
|
+
return {
|
|
288
|
+
passed: new Set(observedKeys).size === observedKeys.length
|
|
289
|
+
&& canonicalJson([...expectedKeys].sort()) === canonicalJson([...observedKeys].sort()),
|
|
290
|
+
expectedKeys: expectedKeys.sort(),
|
|
291
|
+
observedKeys: observedKeys.sort(),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export async function checkSkillResourceClosure({
|
|
296
|
+
snapshotDir,
|
|
297
|
+
host = 'root',
|
|
298
|
+
} = {}) {
|
|
299
|
+
if (typeof snapshotDir !== 'string' || snapshotDir.length === 0) {
|
|
300
|
+
throw new TypeError('snapshotDir must be a non-empty string');
|
|
301
|
+
}
|
|
302
|
+
const scanRoot = resolve(snapshotDir);
|
|
303
|
+
const scanStat = await lstat(scanRoot);
|
|
304
|
+
if (!scanStat.isDirectory() || scanStat.isSymbolicLink()) {
|
|
305
|
+
throw new Error('snapshotDir must be a real directory');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
let inputDigest;
|
|
309
|
+
let snapshotError = null;
|
|
310
|
+
try {
|
|
311
|
+
inputDigest = (await computeFrozenSnapshot(scanRoot, {
|
|
312
|
+
excludeRootEntries: TRANSPORT_EXCLUSIONS,
|
|
313
|
+
})).digest;
|
|
314
|
+
} catch (error) {
|
|
315
|
+
snapshotError = error;
|
|
316
|
+
inputDigest = sha256Hex(canonicalJson({
|
|
317
|
+
invalidTree: error.message,
|
|
318
|
+
}));
|
|
319
|
+
}
|
|
320
|
+
const skillPaths = await discoverSkills(scanRoot);
|
|
321
|
+
const findings = [];
|
|
322
|
+
const surfaceMap = new Map();
|
|
323
|
+
let referenceCount = 0;
|
|
324
|
+
let sourceOnlyCount = 0;
|
|
325
|
+
|
|
326
|
+
for (const skill of skillPaths) {
|
|
327
|
+
const skillRoot = resolveSkillRoot(skill, scanRoot);
|
|
328
|
+
const pluginRoot = resolvePluginRoot(skill, scanRoot);
|
|
329
|
+
const surfaceId = relativeStable(scanRoot, pluginRoot);
|
|
330
|
+
const surfaceHost = inferSurfaceHost(surfaceId, host);
|
|
331
|
+
const surface = surfaceMap.get(surfaceId) ?? {
|
|
332
|
+
id: surfaceId,
|
|
333
|
+
host: surfaceHost,
|
|
334
|
+
skillCount: 0,
|
|
335
|
+
referenceCount: 0,
|
|
336
|
+
sourceOnlyCount: 0,
|
|
337
|
+
};
|
|
338
|
+
surface.skillCount += 1;
|
|
339
|
+
surfaceMap.set(surfaceId, surface);
|
|
340
|
+
|
|
341
|
+
const content = await readFile(resolve(scanRoot, skill), 'utf8');
|
|
342
|
+
for (const pathToken of extractPathTokens(content)) {
|
|
343
|
+
referenceCount += 1;
|
|
344
|
+
surface.referenceCount += 1;
|
|
345
|
+
const classification = classifyReference(
|
|
346
|
+
pathToken.token,
|
|
347
|
+
skillRoot,
|
|
348
|
+
pluginRoot,
|
|
349
|
+
scanRoot,
|
|
350
|
+
);
|
|
351
|
+
const findingBase = {
|
|
352
|
+
host: surfaceHost,
|
|
353
|
+
surface: surfaceId,
|
|
354
|
+
skill,
|
|
355
|
+
line: pathToken.line,
|
|
356
|
+
reference: pathToken.token,
|
|
357
|
+
classification: classification.classification,
|
|
358
|
+
resolutionRoot: classification.resolutionRoot,
|
|
359
|
+
resolvedTarget: classification.resolvedTarget,
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
if (classification.classification === CLASSIFICATION.SOURCE_BACKJUMP) {
|
|
363
|
+
if (pathToken.sourceOnly) {
|
|
364
|
+
sourceOnlyCount += 1;
|
|
365
|
+
surface.sourceOnlyCount += 1;
|
|
366
|
+
} else {
|
|
367
|
+
findings.push({ ...findingBase, code: FINDING_CODE.SOURCE_BACKJUMP });
|
|
368
|
+
}
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (classification.classification === CLASSIFICATION.MACHINE_ABSOLUTE) {
|
|
372
|
+
findings.push({ ...findingBase, code: FINDING_CODE.MACHINE_ABSOLUTE_PATH });
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
if (classification.classification === CLASSIFICATION.OUT_OF_BOUNDS) {
|
|
376
|
+
findings.push({ ...findingBase, code: FINDING_CODE.OUT_OF_BOUNDS });
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const resolutionRootAbsolute = classification.classification === CLASSIFICATION.PLUGIN_ROOT
|
|
381
|
+
? pluginRoot
|
|
382
|
+
: skillRoot;
|
|
383
|
+
const inspection = await inspectRegularFile(
|
|
384
|
+
resolutionRootAbsolute,
|
|
385
|
+
classification.absoluteTarget,
|
|
386
|
+
);
|
|
387
|
+
if (inspection.code) {
|
|
388
|
+
if (pathToken.sourceOnly && inspection.code === FINDING_CODE.RESOURCE_MISSING) {
|
|
389
|
+
sourceOnlyCount += 1;
|
|
390
|
+
surface.sourceOnlyCount += 1;
|
|
391
|
+
} else {
|
|
392
|
+
findings.push({ ...findingBase, code: inspection.code });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (snapshotError) {
|
|
399
|
+
findings.push({
|
|
400
|
+
host,
|
|
401
|
+
surface: '.',
|
|
402
|
+
skill: null,
|
|
403
|
+
line: null,
|
|
404
|
+
reference: '(snapshot tree)',
|
|
405
|
+
classification: CLASSIFICATION.OUT_OF_BOUNDS,
|
|
406
|
+
resolutionRoot: '.',
|
|
407
|
+
resolvedTarget: '.',
|
|
408
|
+
code: FINDING_CODE.SNAPSHOT_UNSAFE,
|
|
409
|
+
reason: snapshotError.message,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const surfaces = [...surfaceMap.values()].sort((a, b) => a.id.localeCompare(b.id));
|
|
414
|
+
const result = {
|
|
415
|
+
checkerVersion: CHECKER_VERSION,
|
|
416
|
+
inputDigest,
|
|
417
|
+
surfaces,
|
|
418
|
+
skillCount: skillPaths.length,
|
|
419
|
+
referenceCount,
|
|
420
|
+
sourceOnlyCount,
|
|
421
|
+
findings,
|
|
422
|
+
};
|
|
423
|
+
result.receiptDigest = sha256Hex(canonicalJson(receiptProjection(result)));
|
|
424
|
+
return result;
|
|
425
|
+
}
|