@dzhechkov/harness-core 0.3.129 → 0.3.131
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/.dz-manifest.json +38 -14
- package/README.md +1 -0
- package/dist/guard.d.ts +5 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +18 -0
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/skills-verify.d.ts +176 -0
- package/dist/skills-verify.d.ts.map +1 -0
- package/dist/skills-verify.js +645 -0
- package/dist/skills-verify.js.map +1 -0
- package/package.json +5 -5
- package/sbom.json +73 -13
- package/src/guard.ts +19 -0
- package/src/index.ts +4 -0
- package/src/skills-verify.ts +793 -0
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `dz skills-verify` — does a project's `.claude/skills/` actually REGISTER?
|
|
3
|
+
*
|
|
4
|
+
* Origin (feature skills-verify, ADR-001): `@dzhechkov/health-advisor` 1.2.0 shipped to npm with zero
|
|
5
|
+
* skills registering while its layout test was green — the test asserted a PROXY (files on disk), not
|
|
6
|
+
* the PROPERTY (registration). This module makes the property observable.
|
|
7
|
+
*
|
|
8
|
+
* Two layers:
|
|
9
|
+
* L1 `scanSkillsLayout` — instant, no Claude session: which names CAN register, plus the three
|
|
10
|
+
* layout shapes that produced the 1.2.0 defect.
|
|
11
|
+
* L2 `parseInitFacts` — the authoritative listing, parsed from the `system/init` event of
|
|
12
|
+
* + `classifyRegistration` `claude -p --output-format stream-json --verbose`. No model prose.
|
|
13
|
+
*
|
|
14
|
+
* FAIL-CLOSED: anything that prevents an honest observation yields `inconclusive`, never `pass`.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, lstatSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
17
|
+
import { basename, isAbsolute, join, resolve } from 'node:path';
|
|
18
|
+
const MAX_BURIED_DEPTH = 4; // enough to catch extended/<name>/SKILL.md without walking the world
|
|
19
|
+
function findBuriedSkillMd(dir, depth, acc, errors) {
|
|
20
|
+
if (depth > MAX_BURIED_DEPTH)
|
|
21
|
+
return;
|
|
22
|
+
let entries;
|
|
23
|
+
try {
|
|
24
|
+
entries = readdirSync(dir);
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
// A subtree we cannot read is unknown territory, not proven-clean territory (QE2 #1).
|
|
28
|
+
errors.push(`cannot read ${dir}: ${error instanceof Error ? error.message : String(error)}`);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
for (const name of entries) {
|
|
32
|
+
if (name === 'node_modules' || name === '__pycache__' || name.startsWith('.'))
|
|
33
|
+
continue;
|
|
34
|
+
const full = join(dir, name);
|
|
35
|
+
let isDir = false;
|
|
36
|
+
try {
|
|
37
|
+
isDir = statSync(full).isDirectory();
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
errors.push(`cannot stat ${full}: ${error instanceof Error ? error.message : String(error)}`);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (isDir) {
|
|
44
|
+
findBuriedSkillMd(full, depth + 1, acc, errors);
|
|
45
|
+
}
|
|
46
|
+
else if (name === 'SKILL.md' && depth >= 2) {
|
|
47
|
+
acc.push(full);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function hasPluginManifest(dir) {
|
|
52
|
+
return existsSync(join(dir, '.claude-plugin', 'plugin.json'));
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Does this directory look like it was INTENDED as a skill? A dir with no markdown at all is
|
|
56
|
+
* ordinary content (`scripts/`, `bin/`, `templates/`) — calling it a failed skill is a false FAIL
|
|
57
|
+
* (Codex QE5 #7). MEASURED: naively flagging every dir marked ~40 healthy directories across 9
|
|
58
|
+
* npx-toolkit packages (reproducer: the pack survey in features/skills-verify/08_qe_report.md).
|
|
59
|
+
*/
|
|
60
|
+
export function looksLikeSkillDir(dir) {
|
|
61
|
+
const walk = (d, depth) => {
|
|
62
|
+
if (depth > 2)
|
|
63
|
+
return false;
|
|
64
|
+
let entries;
|
|
65
|
+
try {
|
|
66
|
+
entries = readdirSync(d);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
for (const name of entries) {
|
|
72
|
+
if (name.startsWith('.') || name === 'node_modules')
|
|
73
|
+
continue;
|
|
74
|
+
const full = join(d, name);
|
|
75
|
+
let isDir = false;
|
|
76
|
+
try {
|
|
77
|
+
isDir = statSync(full).isDirectory();
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (isDir) {
|
|
83
|
+
// A DIRECTORY named SKILL.md is a botched skill file, not ordinary content — clear intent.
|
|
84
|
+
if (name === 'SKILL.md')
|
|
85
|
+
return true;
|
|
86
|
+
if (walk(full, depth + 1))
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
else if (name.toLowerCase().endsWith('.md')) {
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
};
|
|
95
|
+
return walk(dir, 1);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* A directory registers only if `SKILL.md` is a regular FILE. `existsSync` also answers true for a
|
|
99
|
+
* DIRECTORY named SKILL.md, which registers nothing yet suppressed every other check (QE2 #4).
|
|
100
|
+
*/
|
|
101
|
+
function hasSkillFile(dir) {
|
|
102
|
+
try {
|
|
103
|
+
return statSync(join(dir, 'SKILL.md')).isFile();
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return false; // ENOENT, or a dangling symlink: either way it cannot register
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Walk `<projectDir>/.claude/skills/` and report what can register and what cannot.
|
|
111
|
+
* Deterministic, needs no Claude session — safe for CI.
|
|
112
|
+
*/
|
|
113
|
+
export function scanSkillsLayout(projectDir) {
|
|
114
|
+
const skillsRoot = join(resolve(projectDir), '.claude', 'skills');
|
|
115
|
+
// `existsSync` also answers false for a DANGLING SYMLINK — that is a broken tree, not an absent
|
|
116
|
+
// one, and must not scan as "clean" (QE2 #1). lstat distinguishes the two.
|
|
117
|
+
let rootLink = null;
|
|
118
|
+
try {
|
|
119
|
+
rootLink = lstatSync(skillsRoot);
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
// Only ENOENT means "genuinely absent". ENOTDIR/EACCES mean the tree is BROKEN or unreadable —
|
|
123
|
+
// reporting that as an empty-and-clean project is a route to a false PASS (QE3 #3).
|
|
124
|
+
const code = error.code;
|
|
125
|
+
if (code !== 'ENOENT') {
|
|
126
|
+
return {
|
|
127
|
+
skillsRoot, projectDir: resolve(projectDir), exists: true, registrable: [], findings: [], advisories: [], containers: [],
|
|
128
|
+
scanError: `cannot stat ${skillsRoot}: ${error instanceof Error ? error.message : String(error)}`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
// ENOENT on .claude/skills can also mean .claude itself is a FILE or unreadable — check it.
|
|
132
|
+
const claudeDir = join(resolve(projectDir), '.claude');
|
|
133
|
+
try {
|
|
134
|
+
const st = lstatSync(claudeDir);
|
|
135
|
+
// A SYMLINK must be followed: a dangling `.claude` link is a broken tree, not an absent one.
|
|
136
|
+
if (st.isSymbolicLink() && !existsSync(claudeDir)) {
|
|
137
|
+
return {
|
|
138
|
+
skillsRoot, projectDir: resolve(projectDir), exists: true, registrable: [], findings: [], advisories: [], containers: [],
|
|
139
|
+
scanError: `${claudeDir} is a dangling symlink`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
if (!st.isDirectory() && !st.isSymbolicLink()) {
|
|
143
|
+
return {
|
|
144
|
+
skillsRoot, projectDir: resolve(projectDir), exists: true, registrable: [], findings: [], advisories: [], containers: [],
|
|
145
|
+
scanError: `${claudeDir} is not a directory`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch (claudeError) {
|
|
150
|
+
const cCode = claudeError.code;
|
|
151
|
+
if (cCode !== 'ENOENT') {
|
|
152
|
+
return {
|
|
153
|
+
skillsRoot, projectDir: resolve(projectDir), exists: true, registrable: [], findings: [], advisories: [], containers: [],
|
|
154
|
+
scanError: `cannot stat ${claudeDir}: ${claudeError instanceof Error ? claudeError.message : String(claudeError)}`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return { skillsRoot, projectDir: resolve(projectDir), exists: false, registrable: [], findings: [], advisories: [], containers: [] }; // genuinely absent
|
|
159
|
+
}
|
|
160
|
+
if (rootLink.isSymbolicLink() && !existsSync(skillsRoot)) {
|
|
161
|
+
return {
|
|
162
|
+
skillsRoot,
|
|
163
|
+
projectDir: resolve(projectDir),
|
|
164
|
+
exists: true,
|
|
165
|
+
registrable: [],
|
|
166
|
+
findings: [],
|
|
167
|
+
advisories: [],
|
|
168
|
+
containers: [],
|
|
169
|
+
scanError: `${skillsRoot} is a dangling symlink`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
// `.claude/skills` must BE a directory. A file there scans as "empty and clean" otherwise (QE #4).
|
|
173
|
+
try {
|
|
174
|
+
if (!statSync(skillsRoot).isDirectory()) {
|
|
175
|
+
return { skillsRoot, projectDir: resolve(projectDir), exists: true, registrable: [], findings: [], advisories: [], containers: [], scanError: `${skillsRoot} is not a directory` };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
return {
|
|
180
|
+
skillsRoot,
|
|
181
|
+
projectDir: resolve(projectDir),
|
|
182
|
+
exists: true,
|
|
183
|
+
registrable: [],
|
|
184
|
+
findings: [],
|
|
185
|
+
advisories: [],
|
|
186
|
+
containers: [],
|
|
187
|
+
scanError: `cannot stat ${skillsRoot}: ${error instanceof Error ? error.message : String(error)}`,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
const registrable = [];
|
|
191
|
+
const findings = [];
|
|
192
|
+
const advisories = [];
|
|
193
|
+
const containers = [];
|
|
194
|
+
const scanErrors = [];
|
|
195
|
+
let entries = [];
|
|
196
|
+
try {
|
|
197
|
+
entries = readdirSync(skillsRoot);
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
// An unreadable root is NOT an empty project — say so, so the verdict can be inconclusive.
|
|
201
|
+
return {
|
|
202
|
+
skillsRoot,
|
|
203
|
+
projectDir: resolve(projectDir),
|
|
204
|
+
exists: true,
|
|
205
|
+
registrable: [],
|
|
206
|
+
findings: [],
|
|
207
|
+
advisories: [],
|
|
208
|
+
containers: [],
|
|
209
|
+
scanError: `cannot read ${skillsRoot}: ${error instanceof Error ? error.message : String(error)}`,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
for (const name of entries) {
|
|
213
|
+
if (name.startsWith('.'))
|
|
214
|
+
continue; // hidden/backup dirs are not skills
|
|
215
|
+
const dir = join(skillsRoot, name);
|
|
216
|
+
let isDir = false;
|
|
217
|
+
try {
|
|
218
|
+
isDir = statSync(dir).isDirectory();
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
// An entry we cannot stat is unknown, not absent — record it so the verdict stays honest.
|
|
222
|
+
scanErrors.push(`cannot stat ${dir}: ${error instanceof Error ? error.message : String(error)}`);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (!isDir) {
|
|
226
|
+
// A SKILL.md dropped directly into .claude/skills/ registers nothing — it is a real defect,
|
|
227
|
+
// not something to skip silently (QE #4).
|
|
228
|
+
if (name === 'SKILL.md') {
|
|
229
|
+
findings.push({
|
|
230
|
+
dir: '.',
|
|
231
|
+
kind: 'no-skill-md',
|
|
232
|
+
detail: 'SKILL.md sits directly in .claude/skills/ — a skill must live in its own directory to register',
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const registers = hasSkillFile(dir);
|
|
238
|
+
// A manifest-backed CONTAINER (`<dir>/.claude-plugin/plugin.json`) is a plugin-shaped layout.
|
|
239
|
+
// It did not register in measured practice, but the class may be supported under workspace
|
|
240
|
+
// trust — so the WHOLE container is advisory. Emitting the generic `no-skill-md` /
|
|
241
|
+
// `buried-skill-md` findings for it killed the layout anyway, which was the over-claim (QE4 #1).
|
|
242
|
+
const isPluginContainer = !registers && hasPluginManifest(dir);
|
|
243
|
+
const bucket = isPluginContainer ? advisories : findings;
|
|
244
|
+
if (registers) {
|
|
245
|
+
registrable.push(name);
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
// A dir with no markdown was never meant to be a skill — advisory, not a failure (QE5 #7).
|
|
249
|
+
const intended = looksLikeSkillDir(dir);
|
|
250
|
+
(isPluginContainer || !intended ? advisories : findings).push({
|
|
251
|
+
dir: name,
|
|
252
|
+
kind: 'no-skill-md',
|
|
253
|
+
detail: isPluginContainer
|
|
254
|
+
? `${name}/ is a plugin-shaped container (advisory): it has no depth-1 SKILL.md, so it registers only if the client loads it as a plugin`
|
|
255
|
+
: intended
|
|
256
|
+
? `no SKILL.md at ${name}/SKILL.md — this directory cannot register`
|
|
257
|
+
: `${name}/ holds no markdown (advisory): it looks like ordinary content, not a skill that failed to register`,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
// A plugin manifest under .claude/skills does NOT auto-register (plugins load from the
|
|
261
|
+
// marketplace only). This is the exact false premise that shipped health-advisor 1.2.0.
|
|
262
|
+
// Only a report when the dir registers NOTHING: a dir with its own depth-1 SKILL.md registers
|
|
263
|
+
// fine and a stray manifest beside it is inert, not a blocker (Codex QE #8).
|
|
264
|
+
if (isPluginContainer) {
|
|
265
|
+
// What would this container provide? The dir name of every nested SKILL.md.
|
|
266
|
+
const inner = [];
|
|
267
|
+
findBuriedSkillMd(dir, 1, inner, scanErrors);
|
|
268
|
+
const candidates = [...new Set(inner.map((f) => basename(join(f, '..'))))];
|
|
269
|
+
containers.push({ dir: name, candidates });
|
|
270
|
+
advisories.push({
|
|
271
|
+
dir: name,
|
|
272
|
+
kind: 'plugin-manifest-trap',
|
|
273
|
+
detail: `${name}/.claude-plugin/plugin.json did not register in measured practice (advisory, not a failure) — a plugin normally loads from the marketplace; if you meant these to register, install it as a plugin or use bare skills`,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
// SKILL.md files buried too deep never register (health-advisor 1.2.0: extended/<name>/SKILL.md).
|
|
277
|
+
//
|
|
278
|
+
// FALSE-POSITIVE GUARD (found by dogfooding this gate on a healthy project): a dir that DOES
|
|
279
|
+
// register may legitimately bundle nested SKILL.md files as its own resources — health-advisor
|
|
280
|
+
// 1.2.1 co-locates its base deps under the master exactly so they do NOT register. Only a dir
|
|
281
|
+
// that registers NOTHING while hiding SKILL.md files inside is the 1.2.0 defect shape.
|
|
282
|
+
if (!registers) {
|
|
283
|
+
const buried = [];
|
|
284
|
+
findBuriedSkillMd(dir, 1, buried, scanErrors);
|
|
285
|
+
for (const path of buried) {
|
|
286
|
+
bucket.push({
|
|
287
|
+
dir: name,
|
|
288
|
+
kind: 'buried-skill-md',
|
|
289
|
+
detail: `${path.slice(skillsRoot.length + 1)} is 2+ levels deep in a directory that registers nothing — the loader scans one level, so it never registers`,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
skillsRoot,
|
|
296
|
+
projectDir: resolve(projectDir),
|
|
297
|
+
exists: true,
|
|
298
|
+
registrable: registrable.sort(),
|
|
299
|
+
findings,
|
|
300
|
+
advisories,
|
|
301
|
+
containers,
|
|
302
|
+
...(scanErrors.length ? { scanError: scanErrors.join('; ') } : {}),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Parse the `system/init` event out of a `--output-format stream-json` stream. PURE: takes the raw
|
|
307
|
+
* text, returns facts, does no IO. Returns `null` when no init event is present (→ inconclusive).
|
|
308
|
+
*/
|
|
309
|
+
/**
|
|
310
|
+
* The first init event's facts. Use ONLY for "has the event arrived yet?" during streaming —
|
|
311
|
+
* `classifyRegistration` deliberately does NOT accept bare facts, because the count must travel
|
|
312
|
+
* with them (a caller that forgot to pass `initEventCount` got a free PASS — QE2 #2).
|
|
313
|
+
*/
|
|
314
|
+
export function parseInitFacts(streamText) {
|
|
315
|
+
return parseAllInitFacts(streamText)[0] ?? null;
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Every `system/init` event in the stream. More than one means the observations may CONTRADICT each
|
|
319
|
+
* other (Codex QE #7) — the classifier refuses to pick a winner and returns `inconclusive`.
|
|
320
|
+
*/
|
|
321
|
+
export function parseAllInitFacts(streamText) {
|
|
322
|
+
return parseStream(streamText).events;
|
|
323
|
+
}
|
|
324
|
+
function parseStream(streamText) {
|
|
325
|
+
const found = [];
|
|
326
|
+
let malformed = 0;
|
|
327
|
+
for (const raw of streamText.split('\n')) {
|
|
328
|
+
const line = raw.trim();
|
|
329
|
+
if (!line || line[0] !== '{')
|
|
330
|
+
continue;
|
|
331
|
+
let obj;
|
|
332
|
+
try {
|
|
333
|
+
obj = JSON.parse(line);
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
// A truncated `{"type":"system","subtype":"init",…` silently disappeared here, so a
|
|
337
|
+
// contradictory second event could be dropped and the first one believed (QE3 #2).
|
|
338
|
+
malformed += 1;
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (obj.type !== 'system' || obj.subtype !== 'init')
|
|
342
|
+
continue;
|
|
343
|
+
// The listing is authoritative or it is nothing: a partially-unparseable `skills` array (a
|
|
344
|
+
// non-string element) must NOT be silently narrowed to the strings it happens to contain —
|
|
345
|
+
// that turned an unreadable listing into a PASS (Codex QE #1). ABSENT key ≠ empty list (ADR SP-3).
|
|
346
|
+
const rawSkills = obj.skills;
|
|
347
|
+
const skills = Array.isArray(rawSkills)
|
|
348
|
+
? rawSkills.every((s) => typeof s === 'string')
|
|
349
|
+
? rawSkills
|
|
350
|
+
: null
|
|
351
|
+
: null;
|
|
352
|
+
const rawPlugins = obj.plugins;
|
|
353
|
+
const plugins = Array.isArray(rawPlugins)
|
|
354
|
+
? rawPlugins
|
|
355
|
+
.filter((p) => !!p && typeof p === 'object')
|
|
356
|
+
.map((p) => {
|
|
357
|
+
const name = typeof p.name === 'string' ? p.name : '(unnamed)';
|
|
358
|
+
// exactOptionalPropertyTypes: omit `version` rather than set it to undefined.
|
|
359
|
+
return typeof p.version === 'string' ? { name, version: p.version } : { name };
|
|
360
|
+
})
|
|
361
|
+
: [];
|
|
362
|
+
found.push({
|
|
363
|
+
skills,
|
|
364
|
+
plugins,
|
|
365
|
+
// An empty or relative cwd testifies to nothing — `resolve("")` silently becomes the caller's
|
|
366
|
+
// own cwd and can forge a match (Codex QE #6). Only an absolute path counts as evidence.
|
|
367
|
+
cwd: typeof obj.cwd === 'string' && obj.cwd !== '' && isAbsolute(obj.cwd) ? obj.cwd : null,
|
|
368
|
+
clientVersion: typeof obj.claude_code_version === 'string' ? obj.claude_code_version : null,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
return { events: found, malformedObjectLines: malformed };
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* The single entry point. PURE apart from the injected resolver. Fail-closed by construction: `pass`
|
|
375
|
+
* is reachable only from a completed scan, a probe that produced EXACTLY ONE well-formed init event
|
|
376
|
+
* from THIS project, a fully-readable skills listing, a performed provenance check with no ambiguity,
|
|
377
|
+
* and zero load-blocking layout findings.
|
|
378
|
+
*/
|
|
379
|
+
export function verifyRegistration(evidence, options = {}) {
|
|
380
|
+
// A JavaScript caller can hand us an incomplete object; a crash is not a verdict (QE4 #7).
|
|
381
|
+
const bad = (reason) => ({
|
|
382
|
+
verdict: 'inconclusive',
|
|
383
|
+
reason,
|
|
384
|
+
expected: [],
|
|
385
|
+
missing: [],
|
|
386
|
+
registeredCount: null,
|
|
387
|
+
clientVersion: null,
|
|
388
|
+
plugins: [],
|
|
389
|
+
advisories: [],
|
|
390
|
+
controls: { cwdMatched: null, skillsListPresent: false },
|
|
391
|
+
layout: [],
|
|
392
|
+
});
|
|
393
|
+
if (!evidence || typeof evidence !== 'object')
|
|
394
|
+
return bad('no evidence supplied');
|
|
395
|
+
if (!evidence.scan || !evidence.probe || !evidence.provenance || typeof evidence.projectDir !== 'string') {
|
|
396
|
+
return bad('incomplete evidence (scan, probe, provenance and projectDir are all required)');
|
|
397
|
+
}
|
|
398
|
+
const { projectDir, scan, probe, provenance } = evidence;
|
|
399
|
+
// The contract is CANONICAL identity: a lexical `resolve` alone reports a symlinked project as a
|
|
400
|
+
// different one and turns a correct registration into `inconclusive` (QE4 #3).
|
|
401
|
+
const resolvePath = options.resolvePath ??
|
|
402
|
+
((p) => {
|
|
403
|
+
try {
|
|
404
|
+
return realpathSync(p);
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
return resolve(p);
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
const expected = evidence.expected ?? scan.registrable;
|
|
411
|
+
const layout = scan.findings;
|
|
412
|
+
const fail = (verdict, reason, extra = {}) => ({
|
|
413
|
+
expected,
|
|
414
|
+
layout,
|
|
415
|
+
advisories: scan.advisories,
|
|
416
|
+
plugins: [],
|
|
417
|
+
clientVersion: null,
|
|
418
|
+
missing: [],
|
|
419
|
+
registeredCount: null,
|
|
420
|
+
controls: { cwdMatched: null, skillsListPresent: false },
|
|
421
|
+
...extra,
|
|
422
|
+
verdict,
|
|
423
|
+
reason,
|
|
424
|
+
});
|
|
425
|
+
// 1. The scan must describe THIS project, and must have completed.
|
|
426
|
+
let scanIsOurs;
|
|
427
|
+
try {
|
|
428
|
+
scanIsOurs = resolvePath(scan.projectDir) === resolvePath(projectDir);
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
scanIsOurs = scan.projectDir === projectDir;
|
|
432
|
+
}
|
|
433
|
+
if (!scanIsOurs) {
|
|
434
|
+
return fail('inconclusive', `the layout scan describes ${scan.projectDir}, not ${projectDir} — evidence from another project`);
|
|
435
|
+
}
|
|
436
|
+
if (scan.scanError !== undefined)
|
|
437
|
+
return fail('inconclusive', `layout scan failed: ${scan.scanError}`);
|
|
438
|
+
// 2. The probe must have produced a stream.
|
|
439
|
+
if (!probe.ok)
|
|
440
|
+
return fail('inconclusive', `probe failed: ${probe.error}`);
|
|
441
|
+
// 3. Parsing must be intact and the evidence must be singular.
|
|
442
|
+
const parsed = parseStream(probe.stream);
|
|
443
|
+
if (parsed.malformedObjectLines > 0) {
|
|
444
|
+
return fail('inconclusive', `${parsed.malformedObjectLines} malformed JSON line(s) in the stream — a truncated event may be hiding contradictory evidence`);
|
|
445
|
+
}
|
|
446
|
+
if (parsed.events.length !== 1) {
|
|
447
|
+
return fail('inconclusive', parsed.events.length === 0
|
|
448
|
+
? 'no system/init event in the stream — could not observe registration'
|
|
449
|
+
: `${parsed.events.length} init events in one stream — contradictory observations, refusing to pick one`);
|
|
450
|
+
}
|
|
451
|
+
const facts = parsed.events[0];
|
|
452
|
+
const withFacts = { plugins: facts.plugins, clientVersion: facts.clientVersion };
|
|
453
|
+
// 4. The cwd control: the session must have read THIS project.
|
|
454
|
+
if (facts.cwd === null) {
|
|
455
|
+
return fail('inconclusive', 'init event carried no usable absolute cwd — cannot confirm the session read this project', withFacts);
|
|
456
|
+
}
|
|
457
|
+
let cwdMatched;
|
|
458
|
+
try {
|
|
459
|
+
cwdMatched = resolvePath(facts.cwd) === resolvePath(projectDir);
|
|
460
|
+
}
|
|
461
|
+
catch (error) {
|
|
462
|
+
return fail('inconclusive', `cannot resolve the paths to compare (${error instanceof Error ? error.message : String(error)}) — the cwd control could not run`, withFacts);
|
|
463
|
+
}
|
|
464
|
+
if (!cwdMatched) {
|
|
465
|
+
return fail('inconclusive', `session read ${facts.cwd}, not ${projectDir} — its listing does not describe this project`, {
|
|
466
|
+
...withFacts,
|
|
467
|
+
registeredCount: facts.skills?.length ?? null,
|
|
468
|
+
controls: { cwdMatched: false, skillsListPresent: facts.skills !== null },
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
// 5. The listing must be readable in full.
|
|
472
|
+
if (facts.skills === null) {
|
|
473
|
+
return fail('inconclusive', 'the init listing has no readable `skills` array (absent or not all strings) — cannot read it', {
|
|
474
|
+
...withFacts,
|
|
475
|
+
controls: { cwdMatched: true, skillsListPresent: false },
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
const controls = { cwdMatched: true, skillsListPresent: true };
|
|
479
|
+
const registered = new Set(facts.skills);
|
|
480
|
+
const missing = expected.filter((name) => !registered.has(name));
|
|
481
|
+
const common = { ...withFacts, registeredCount: facts.skills.length, controls };
|
|
482
|
+
// 6. Load-blocking layout problems fail on their own — one healthy skill must not mask them.
|
|
483
|
+
if (layout.length > 0) {
|
|
484
|
+
return {
|
|
485
|
+
expected,
|
|
486
|
+
layout,
|
|
487
|
+
advisories: scan.advisories,
|
|
488
|
+
...common,
|
|
489
|
+
missing,
|
|
490
|
+
verdict: 'fail',
|
|
491
|
+
reason: expected.length === 0
|
|
492
|
+
? `nothing can register: ${layout.length} layout problem(s) and no registrable skill directory`
|
|
493
|
+
: `${layout.length} layout problem(s) can never register (alongside ${expected.length} expected skill(s))`,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
// 6b. A plugin-shaped container is forgiven ONLY if the SESSION says that plugin loaded.
|
|
497
|
+
// Matching on skill NAMES was wrong: one incidental collision with an unrelated plugin's skill
|
|
498
|
+
// (e.g. `deep-research`) forgave the whole container, and the gate PASSED the broken
|
|
499
|
+
// health-advisor 1.2.0 (MEASURED — reproducer: `dz skills-verify` against that published
|
|
500
|
+
// install). `init.plugins` names what actually loaded, so ask that instead.
|
|
501
|
+
const loadedPlugins = new Set(facts.plugins.map((p) => p.name));
|
|
502
|
+
// Only the LOADED-PLUGIN list counts. A namespaced-name fallback (`<dir>:<skill>` present in the
|
|
503
|
+
// listing) was still name-based evidence — the very class that made the gate pass a broken install
|
|
504
|
+
// — and `init.plugins` already covers every legitimate case.
|
|
505
|
+
const deadContainers = scan.containers.filter((c) => c.candidates.length > 0 && !loadedPlugins.has(c.dir));
|
|
506
|
+
if (deadContainers.length > 0) {
|
|
507
|
+
return {
|
|
508
|
+
expected,
|
|
509
|
+
layout,
|
|
510
|
+
advisories: scan.advisories,
|
|
511
|
+
...common,
|
|
512
|
+
missing,
|
|
513
|
+
verdict: 'fail',
|
|
514
|
+
reason: `${deadContainers.length} plugin-shaped container(s) did not load — ` +
|
|
515
|
+
deadContainers
|
|
516
|
+
.map((c) => `${c.dir}/ (${c.candidates.length} skill(s) inside, but no such plugin in the session)`)
|
|
517
|
+
.join('; '),
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
// 7. Provenance must have been CHECKED, and must be unambiguous.
|
|
521
|
+
if (!provenance.checked) {
|
|
522
|
+
return fail('inconclusive', 'provenance was not checked — a same-named skill outside this project would forge a pass', {
|
|
523
|
+
...common,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
const ambiguousExpected = expected.filter((name) => provenance.ambiguous.includes(name) && registered.has(name));
|
|
527
|
+
if (ambiguousExpected.length > 0 && missing.length === 0) {
|
|
528
|
+
return fail('inconclusive', `${ambiguousExpected.length} expected name(s) also exist outside this project (${ambiguousExpected.join(', ')}) — ` +
|
|
529
|
+
'the listing carries names, not provenance, so registration cannot be attributed to this project', { ...common });
|
|
530
|
+
}
|
|
531
|
+
if (missing.length > 0) {
|
|
532
|
+
return {
|
|
533
|
+
expected,
|
|
534
|
+
layout,
|
|
535
|
+
advisories: scan.advisories,
|
|
536
|
+
...common,
|
|
537
|
+
missing,
|
|
538
|
+
verdict: 'fail',
|
|
539
|
+
reason: `${missing.length} of ${expected.length} expected skill(s) did NOT register`,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
return {
|
|
543
|
+
expected,
|
|
544
|
+
layout,
|
|
545
|
+
advisories: scan.advisories,
|
|
546
|
+
...common,
|
|
547
|
+
missing: [],
|
|
548
|
+
verdict: 'pass',
|
|
549
|
+
reason: expected.length === 0
|
|
550
|
+
? 'nothing was expected to register; the session listing was read successfully'
|
|
551
|
+
: `all ${expected.length} expected skill(s) are registered`,
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
/** Exit code contract: pass=0, fail=1, inconclusive=2 (distinct so CI can treat it separately). */
|
|
555
|
+
export function registrationExitCode(verdict, strict = false) {
|
|
556
|
+
if (verdict === 'pass')
|
|
557
|
+
return 0;
|
|
558
|
+
if (verdict === 'fail')
|
|
559
|
+
return 1;
|
|
560
|
+
return strict ? 1 : 2;
|
|
561
|
+
}
|
|
562
|
+
const VERDICT_LABEL = {
|
|
563
|
+
pass: 'PASS',
|
|
564
|
+
fail: 'FAIL',
|
|
565
|
+
inconclusive: 'INCONCLUSIVE',
|
|
566
|
+
};
|
|
567
|
+
export function renderRegistrationReport(result, scan) {
|
|
568
|
+
const out = [];
|
|
569
|
+
out.push(`dz skills-verify: ${VERDICT_LABEL[result.verdict]} — ${result.reason}`);
|
|
570
|
+
if (scan) {
|
|
571
|
+
out.push(` layout: ${scan.registrable.length} registrable skill dir(s) under ${scan.skillsRoot}` +
|
|
572
|
+
(scan.exists ? '' : ' (missing)'));
|
|
573
|
+
}
|
|
574
|
+
if (result.registeredCount !== null) {
|
|
575
|
+
out.push(` session: ${result.registeredCount} skill(s) registered` +
|
|
576
|
+
(result.clientVersion ? ` · client ${result.clientVersion}` : '') +
|
|
577
|
+
(result.plugins.length ? ` · ${result.plugins.length} plugin(s) loaded` : ''));
|
|
578
|
+
}
|
|
579
|
+
if (result.missing.length) {
|
|
580
|
+
out.push(' MISSING (expected but not registered):');
|
|
581
|
+
for (const name of result.missing)
|
|
582
|
+
out.push(` - ${name}`);
|
|
583
|
+
}
|
|
584
|
+
if (result.layout.length) {
|
|
585
|
+
out.push(' layout problems (these can never register):');
|
|
586
|
+
for (const f of result.layout)
|
|
587
|
+
out.push(` [${f.kind}] ${f.detail}`);
|
|
588
|
+
}
|
|
589
|
+
if (result.advisories.length) {
|
|
590
|
+
out.push(' advisories (reported, not failures):');
|
|
591
|
+
for (const f of result.advisories)
|
|
592
|
+
out.push(` [${f.kind}] ${f.detail}`);
|
|
593
|
+
}
|
|
594
|
+
if (result.verdict === 'inconclusive') {
|
|
595
|
+
out.push(' (inconclusive is never a pass — it means registration could not be observed honestly)');
|
|
596
|
+
}
|
|
597
|
+
return out.join('\n');
|
|
598
|
+
}
|
|
599
|
+
// ── The publish-time guard fact ─────────────────────────────────────
|
|
600
|
+
/**
|
|
601
|
+
* Scan a PACKAGE directory for skill dirs that could never register.
|
|
602
|
+
*
|
|
603
|
+
* Discriminator (MEASURED before it was chosen — a naive "every dir needs SKILL.md" rule flagged ~40
|
|
604
|
+
* healthy directories across 9 npx-toolkit packages, and a markdown-based one still flagged `docs/`):
|
|
605
|
+
* 1. a package counts as a SKILL PACK only if it already has at least one `<dir>/SKILL.md`;
|
|
606
|
+
* 2. inside it, a dir is broken only when a `SKILL.md` EXISTS somewhere inside but not at depth 1.
|
|
607
|
+
* That second rule is unambiguous — the skill file is there, just where nothing will load it (exactly
|
|
608
|
+
* health-advisor 1.2.0). A dir with no SKILL.md anywhere is ordinary content, not a failed skill.
|
|
609
|
+
*/
|
|
610
|
+
export function findNonRegistrableSkillDirs(packDir) {
|
|
611
|
+
let entries;
|
|
612
|
+
try {
|
|
613
|
+
entries = readdirSync(packDir);
|
|
614
|
+
}
|
|
615
|
+
catch {
|
|
616
|
+
return [];
|
|
617
|
+
}
|
|
618
|
+
const dirs = [];
|
|
619
|
+
for (const name of entries) {
|
|
620
|
+
if (name.startsWith('.') || name === 'node_modules')
|
|
621
|
+
continue;
|
|
622
|
+
const full = join(packDir, name);
|
|
623
|
+
try {
|
|
624
|
+
if (statSync(full).isDirectory())
|
|
625
|
+
dirs.push(name);
|
|
626
|
+
}
|
|
627
|
+
catch {
|
|
628
|
+
/* unreadable entries are not evidence of a defect */
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
const isSkillPack = dirs.some((d) => hasSkillFile(join(packDir, d)));
|
|
632
|
+
if (!isSkillPack)
|
|
633
|
+
return [];
|
|
634
|
+
return dirs
|
|
635
|
+
.filter((d) => {
|
|
636
|
+
const full = join(packDir, d);
|
|
637
|
+
if (hasSkillFile(full))
|
|
638
|
+
return false; // registers fine
|
|
639
|
+
const buried = [];
|
|
640
|
+
findBuriedSkillMd(full, 1, buried, []);
|
|
641
|
+
return buried.length > 0; // a SKILL.md exists, but nothing will load it from there
|
|
642
|
+
})
|
|
643
|
+
.sort();
|
|
644
|
+
}
|
|
645
|
+
//# sourceMappingURL=skills-verify.js.map
|