@vegastack/skills 0.13.0 → 0.16.0

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.
@@ -0,0 +1,774 @@
1
+ #!/usr/bin/env node
2
+ // dev-review guard: scans the project's agent skills with NVIDIA SkillSpector and
3
+ // blocks on any unsuppressed HIGH/CRITICAL finding. Facts block; heuristics warn.
4
+ // The scanner's own exit code (0 for score <= 50) is never the verdict — an
5
+ // aggregate score is distorted by meta-content, individual findings are not.
6
+ // Self-contained (ships with dev-review; no cross-skill imports, no dependencies).
7
+ //
8
+ // Exit codes: 0 pass (or skipped) · 1 pass-with-warnings · 2 blocked.
9
+ // Usage: node skill-scan.mjs [--root <path>] [--dev-md <path>] [--baseline <path>]
10
+ // [--llm] [--json]
11
+
12
+ import { execFileSync } from 'node:child_process';
13
+ import { createHash } from 'node:crypto';
14
+ import { existsSync, mkdtempSync, readFileSync, readdirSync } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { basename, join, resolve } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ // The clause every suppression must carry, mirroring the "Still flag if:"
20
+ // requirement on .vegastack/review-known-patterns.md entries: a suppression
21
+ // without a stated re-trigger condition is a blind spot, not a decision.
22
+ const CLAUSE = /still flag if:/i;
23
+
24
+ // SkillSpector's exact default when `skillspector baseline` writes a file
25
+ // without --reason. Committing one of those suppresses every current finding at
26
+ // once. Exact equality is a FACT and blocks; the looser phrase match below is a
27
+ // heuristic and only warns — conventions' guard doctrine is that regex judgement
28
+ // never blocks.
29
+ const PLACEHOLDER_EXACT = 'Accepted finding (auto-generated baseline)';
30
+ const PLACEHOLDER_LIKE = /auto-generated baseline/i;
31
+
32
+ function reasonErrors(entry, label, requireClause) {
33
+ const errors = [];
34
+ const warns = [];
35
+ const reason = typeof entry.reason === 'string' ? entry.reason.trim() : '';
36
+ if (!reason) {
37
+ errors.push(`${label}: missing reason — every suppression states why the pattern is structural here`);
38
+ return { errors, warns };
39
+ }
40
+ if (reason === PLACEHOLDER_EXACT) {
41
+ errors.push(`${label}: the scanner's default reason, unedited — write why this pattern is structural here`);
42
+ } else if (PLACEHOLDER_LIKE.test(reason)) {
43
+ warns.push(`${label}: reason mentions an auto-generated baseline ("${reason}") — check it was actually written, not adapted from the default`);
44
+ }
45
+ if (requireClause && !CLAUSE.test(reason)) {
46
+ errors.push(`${label}: reason has no "Still flag if:" clause — a suppression without a re-trigger condition is a blind spot`);
47
+ }
48
+ return { errors, warns };
49
+ }
50
+
51
+ // Returns { rules, fingerprints, errors }. Never throws: unreadable content comes
52
+ // back as an error so the caller can block on it like any other fact.
53
+ export function parseBaseline(text) {
54
+ let data;
55
+ try {
56
+ data = JSON.parse(text);
57
+ } catch (error) {
58
+ return { rules: [], fingerprints: [], errors: [`baseline is not valid JSON: ${error.message}`], warns: [] };
59
+ }
60
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
61
+ return { rules: [], fingerprints: [], errors: ['baseline must be a JSON object'], warns: [] };
62
+ }
63
+
64
+ const errors = [];
65
+ const warns = [];
66
+ const rawRules = Array.isArray(data.rules) ? data.rules : [];
67
+ const rawFingerprints = Array.isArray(data.fingerprints) ? data.fingerprints : [];
68
+ if (data.rules !== undefined && !Array.isArray(data.rules)) errors.push('baseline "rules" must be an array');
69
+ if (data.fingerprints !== undefined && !Array.isArray(data.fingerprints)) errors.push('baseline "fingerprints" must be an array');
70
+
71
+ const rules = [];
72
+ rawRules.forEach((raw, index) => {
73
+ const label = `rule ${index + 1}`;
74
+ if (!raw || typeof raw !== 'object') {
75
+ errors.push(`${label}: not an object`);
76
+ return;
77
+ }
78
+ // SkillSpector normalizes `id`/`rule_id` and `path`/`file` to one field each
79
+ // (`path=raw.get("path") or raw.get("file")`). Missing the `file` alias would
80
+ // both reject a valid baseline AND let `{"file": "*"}` past the wildcard
81
+ // check below into a scanner that honours it.
82
+ const id = raw.id ?? raw.rule_id;
83
+ const path = raw.path ?? raw.file;
84
+ const matchers = { id, path, message: raw.message };
85
+ const present = Object.entries(matchers).filter(([, value]) => value !== undefined);
86
+ if (present.length === 0) {
87
+ errors.push(`${label}: no matcher (id, path, or message) — a rule with no matcher suppresses every finding`);
88
+ }
89
+ // Matchers must be LITERAL. Chasing wildcard shapes is an arms race that
90
+ // was lost at the first attempt: `*` was rejected and `?*` silenced every
91
+ // finding just the same, as do `*.md`, `[a-z]*` and `*SKILL*`. The rule
92
+ // this project already states — "scope a rule as narrowly as its cause" —
93
+ // is mechanically checkable only as "name the thing". A project that wants
94
+ // two files writes two rules, which is the more reviewable artifact anyway.
95
+ for (const [field, value] of present) {
96
+ if (typeof value !== 'string' || !value.trim()) {
97
+ errors.push(`${label}: "${field}" must be a non-empty string, got ${JSON.stringify(value)}`);
98
+ continue;
99
+ }
100
+ const glob = value.match(/[*?[\]]/);
101
+ if (glob) {
102
+ errors.push(`${label}: "${field}" contains the glob character "${glob[0]}" ("${value}") — matchers must be literal so a rule cannot silence more than the cause it names; write one rule per file`);
103
+ }
104
+ }
105
+ const reasoned = reasonErrors(raw, label, true);
106
+ errors.push(...reasoned.errors);
107
+ warns.push(...reasoned.warns);
108
+ rules.push({ id, path, message: raw.message, reason: raw.reason });
109
+ });
110
+
111
+ // The scanner rejects a v2 baseline that carries fingerprints without pinning
112
+ // the version they were computed against, and it does so per invocation — so
113
+ // catching it here turns twelve confusing "no readable report" failures into
114
+ // one sentence naming the actual problem.
115
+ if (rawFingerprints.length > 0 && !data.scanner_version) {
116
+ errors.push('a v2 baseline with fingerprints must set "scanner_version" (the scanner rejects it otherwise)');
117
+ }
118
+
119
+ // Fingerprints get the same reason discipline minus the clause: they are
120
+ // content-hashed, so editing the surrounding file re-triggers the finding on
121
+ // its own — the re-trigger condition a rule has to state in prose. This check
122
+ // catches the common accident, committing `skillspector baseline` output
123
+ // verbatim, since that writes every finding as a fingerprint carrying the
124
+ // default reason. It does NOT stop someone passing `--reason` with a clause
125
+ // in it: a deliberate mass-suppression is caught by review of the diff and by
126
+ // the suppression counts in the report, not by this guard.
127
+ rawFingerprints.forEach((raw, index) => {
128
+ const label = `fingerprint ${index + 1}`;
129
+ if (!raw || typeof raw !== 'object') {
130
+ errors.push(`${label}: not an object`);
131
+ return;
132
+ }
133
+ const reasoned = reasonErrors(raw, label, false);
134
+ errors.push(...reasoned.errors);
135
+ warns.push(...reasoned.warns);
136
+ });
137
+
138
+ // `coverage:` accepts a COMPLETENESS signal, not a finding. The scanner's own
139
+ // baseline cannot express this: it suppresses findings only. Without it, a
140
+ // skill shipping ordinary JavaScript blocks forever — SkillSpector's shell
141
+ // parser reads a template literal in assignment position as backtick command
142
+ // substitution and degrades. Same discipline as a rule: name the skill AND the
143
+ // file, say why, and say what would make it a real signal again.
144
+ const rawCoverage = Array.isArray(data.coverage) ? data.coverage : [];
145
+ if (data.coverage !== undefined && !Array.isArray(data.coverage)) {
146
+ errors.push('baseline "coverage" must be an array');
147
+ }
148
+ const coverage = [];
149
+ rawCoverage.forEach((raw, index) => {
150
+ const label = `coverage ${index + 1}`;
151
+ if (!raw || typeof raw !== 'object') {
152
+ errors.push(`${label}: not an object`);
153
+ return;
154
+ }
155
+ // Content-bound, like a fingerprint. Without this an acceptance outlives the
156
+ // file it was written about: the reason stays on the page while the content
157
+ // it describes changes underneath, and every "Still flag if:" clause becomes
158
+ // decorative because nothing re-triggers the adjudication.
159
+ if (typeof raw.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(raw.sha256.trim())) {
160
+ errors.push(`${label}: "sha256" must be the 64-hex digest of the accepted file — an acceptance that is not content-bound never expires`);
161
+ }
162
+ for (const field of ['skill', 'file']) {
163
+ const value = raw[field];
164
+ if (typeof value !== 'string' || !value.trim()) {
165
+ errors.push(`${label}: "${field}" must be a non-empty string — a coverage acceptance names exactly one file in one skill`);
166
+ } else if (/[*?[\]]/.test(value)) {
167
+ errors.push(`${label}: "${field}" contains a glob character ("${value}") — coverage acceptances are literal, like rules`);
168
+ }
169
+ }
170
+ const reasoned = reasonErrors(raw, label, true);
171
+ errors.push(...reasoned.errors);
172
+ warns.push(...reasoned.warns);
173
+ coverage.push({ skill: raw.skill, file: raw.file, sha256: raw.sha256, reason: raw.reason });
174
+ });
175
+
176
+ return { rules, fingerprints: rawFingerprints, coverage, errors, warns };
177
+ }
178
+
179
+ // Absolute paths, sorted, of the skill directories under `root`. A directory is
180
+ // a skill iff it holds a SKILL.md. The root itself counts when it holds one;
181
+ // otherwise children AND grandchildren are examined — two levels, matching the
182
+ // authored layout's own cap (`skills/<name>/` and `skills/<group>/<name>/`), so
183
+ // pointing the knob at a grouped tree scans it instead of silently finding
184
+ // nothing. Dot-prefixed entries are skipped: a crashed scaffolder's
185
+ // `.name.scaffold-XXXX` leftover must never read as a skill. Symlinked
186
+ // directories are not followed — a scanner that traverses out of its root scans
187
+ // something other than what it reports on.
188
+ function childDirectories(dir) {
189
+ try {
190
+ return readdirSync(dir, { withFileTypes: true })
191
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
192
+ .map((entry) => join(dir, entry.name));
193
+ } catch {
194
+ return [];
195
+ }
196
+ }
197
+
198
+ export function discoverSkills(root) {
199
+ if (!root || !existsSync(root)) return [];
200
+ const absolute = resolve(root);
201
+ if (existsSync(join(absolute, 'SKILL.md'))) return [absolute];
202
+
203
+ const found = [];
204
+ for (const child of childDirectories(absolute)) {
205
+ if (existsSync(join(child, 'SKILL.md'))) {
206
+ found.push(child);
207
+ continue;
208
+ }
209
+ // One level deeper, for the grouped authored layout (`<root>/<group>/<skill>/`).
210
+ // Without this a grouped tree scans as ZERO skills while reporting success on
211
+ // whatever else it found — coverage silently lost, which is the whole defect
212
+ // class this guard exists to stop.
213
+ for (const grandchild of childDirectories(child)) {
214
+ if (existsSync(join(grandchild, 'SKILL.md'))) found.push(grandchild);
215
+ }
216
+ }
217
+ return found.sort();
218
+ }
219
+
220
+ // Anything that LOOKS like a skill but discovery did not scan. Defined as the
221
+ // difference between a full walk and `discoverSkills`, rather than as a list of
222
+ // known-bad shapes — so it stays correct by construction when discovery changes.
223
+ // It catches skills nested deeper than the layout allows, dot-prefixed
224
+ // directories, and symlinked directories that are or contain a skill.
225
+ //
226
+ // The walk is NOT depth-capped. A cap is a cliff: an earlier version stopped at
227
+ // depth 4, and a deliberately malicious skill at depth 5 was then scanned by
228
+ // nobody and flagged by nobody — the exact silent-coverage-loss this function
229
+ // exists to prevent, reintroduced one level down. Real directories cannot cycle
230
+ // and symlinks are never descended, so the walk terminates. TWO things can stop it early, and BOTH are reported rather than
231
+ // swallowed: the visit budget, and a directory it cannot read (EACCES, or a
232
+ // path past PATH_MAX). An earlier version caught the read failure and gave up
233
+ // silently, so a skill hidden behind a `chmod 000` directory was flagged by
234
+ // nobody while the run reported success — the same quiet give-up this whole
235
+ // function exists to prevent, one level down.
236
+ const WALK_BUDGET = 50_000;
237
+
238
+ function deepSkillDirs(dir, state) {
239
+ if (state.exhausted) return;
240
+ let entries;
241
+ try {
242
+ entries = readdirSync(dir, { withFileTypes: true });
243
+ } catch (error) {
244
+ // Not silence: an unreadable directory is unverified coverage.
245
+ state.unreadable.add(`${dir} (${error.code ?? error.message})`);
246
+ return;
247
+ }
248
+ for (const entry of entries) {
249
+ if (state.visited++ > WALK_BUDGET) {
250
+ state.exhausted = true;
251
+ return;
252
+ }
253
+ const child = join(dir, entry.name);
254
+ if (entry.isSymbolicLink()) {
255
+ // Never descended — a scanner that walks out of its root reports on
256
+ // something it was not pointed at. Flagged when the target is, or holds,
257
+ // a skill, so it is refused rather than dropped.
258
+ if (existsSync(join(child, 'SKILL.md'))) {
259
+ state.seen.add(child);
260
+ continue;
261
+ }
262
+ for (const nested of childDirectories(child)) {
263
+ if (existsSync(join(nested, 'SKILL.md'))) {
264
+ state.seen.add(child);
265
+ break;
266
+ }
267
+ }
268
+ continue;
269
+ }
270
+ if (!entry.isDirectory()) continue;
271
+ if (existsSync(join(child, 'SKILL.md'))) state.seen.add(child);
272
+ deepSkillDirs(child, state);
273
+ }
274
+ }
275
+
276
+ // Returns { unscannable, exhausted }. `exhausted` means the walk hit its budget
277
+ // and coverage could NOT be verified — the caller blocks on it.
278
+ export function findUnscannable(root) {
279
+ if (!root || !existsSync(root)) return { unscannable: [], exhausted: false };
280
+ const absolute = resolve(root);
281
+ const scanned = new Set(discoverSkills(absolute));
282
+ const state = { seen: new Set(), visited: 0, exhausted: false, unreadable: new Set() };
283
+ deepSkillDirs(absolute, state);
284
+ return {
285
+ unscannable: [...state.seen].filter((dir) => !scanned.has(dir)).sort(),
286
+ exhausted: state.exhausted,
287
+ unreadable: [...state.unreadable].sort(),
288
+ };
289
+ }
290
+
291
+ // The severities that stop a push. Deliberately NOT the aggregate risk score:
292
+ // a score is inflated by unresolvable-path artifacts in meta-content and
293
+ // deflated by suppressing unrelated findings, so it answers a question nobody
294
+ // asked. Individual findings are what a reviewer triages.
295
+ const BLOCKING = new Set(['HIGH', 'CRITICAL']);
296
+ // The one finding id that is a COMPLETENESS signal wearing a finding's clothes —
297
+ // the scanner's own text for it is "Referenced artifact was not completely
298
+ // inspected". A `coverage:` entry naming that file accepts it, because it is the
299
+ // same phenomenon the coverage section exists for; every other id must go
300
+ // through `rules` or `fingerprints`, which bind to content.
301
+ const COVERAGE_CLASS_RULE = 'AE1';
302
+ // Everything the scanner is known to emit. A severity outside this set is
303
+ // upstream drift, and drift must fail CLOSED: silently sorting an unrecognised
304
+ // severity under the blocking bar and then calling it "MEDIUM/LOW" would be a
305
+ // false success dressed as a summary line.
306
+ const KNOWN_SEVERITIES = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO']);
307
+
308
+ const INSTALL_HINT = 'install it with `uv tool install git+https://github.com/NVIDIA/skillspector.git`';
309
+
310
+ // Pure evaluation over gathered facts — unit tests drive this directly.
311
+ export function evaluateScan(facts) {
312
+ const blocks = [];
313
+ const warns = [];
314
+ const {
315
+ binaryMissing,
316
+ rootMissing,
317
+ baselineMissing,
318
+ baselineErrors = [],
319
+ skills = [],
320
+ scanErrors = [],
321
+ } = facts;
322
+
323
+ // Environment failures first: when the scanner never ran, a finding list is
324
+ // not evidence of anything, and the real cause must read before the noise.
325
+ if (binaryMissing) {
326
+ blocks.push(`the \`skillspector\` binary is not on PATH — ${INSTALL_HINT}, or set skill-scan: none if this project has no skills`);
327
+ }
328
+ if (rootMissing) {
329
+ blocks.push(`scan root "${rootMissing}" does not exist — build it first if it is a build output, or correct dev.md's skill-scan: knob`);
330
+ }
331
+ for (const error of baselineErrors) {
332
+ blocks.push(`baseline: ${error}`);
333
+ }
334
+ for (const { skill, message } of scanErrors) {
335
+ blocks.push(`${skill}: the scan did not produce a readable report (${message}) — an unscanned skill is not a clean skill`);
336
+ }
337
+ for (const dir of facts.unreadableDirs ?? []) {
338
+ blocks.push(`${dir} could not be read, so coverage under it is unverified — a skill hidden there would be reported by nobody`);
339
+ }
340
+ if (facts.coverageExhausted) {
341
+ blocks.push('the scan root is too large to verify coverage — the walk hit its budget, so an unscanned skill could be hiding in it; point --root at a narrower directory');
342
+ }
343
+ for (const path of facts.unscannable ?? []) {
344
+ blocks.push(`${path} holds a SKILL.md but was not scanned — nested deeper than the layout allows, dot-prefixed, or behind a symlink discovery will not follow out of the scan root. Move it into place, or scan it directly with --root`);
345
+ }
346
+
347
+ if (blocks.length > 0) return { blocks, warns };
348
+
349
+ if (skills.length === 0) {
350
+ blocks.push('no skills found under the scan root — a root with nothing in it is a misconfigured knob, not a clean result');
351
+ return { blocks, warns };
352
+ }
353
+
354
+ for (const entry of skills) {
355
+ // The ONLY short-circuit: a failed execution means no field of this report
356
+ // can be trusted. Every other coverage problem still yields real findings,
357
+ // and suppressing them behind the coverage block would tell the operator
358
+ // less than the guard actually knows.
359
+ if (!entry.executionSuccessful) {
360
+ blocks.push(`${entry.name}: the scan did not complete (execution_successful: false) — a partial score is not a verdict`);
361
+ continue;
362
+ }
363
+ // A degraded run reports a HIGHER score than a clean one (a failed analyzer
364
+ // leaves its findings unfiltered), so "no blocking finding" from a degraded
365
+ // scan proves nothing. But `status: "partial"` on its own is the NORMAL
366
+ // result for documentation-heavy skills — it is what unresolved path-like
367
+ // references produce — so blocking on it would block every scan forever,
368
+ // the same trap as gating on the aggregate score. Block only on the signals
369
+ // that mean work did not happen.
370
+ const { status, limitations, entirelyUninspected, partiallyInspected } = entry.completeness ?? {};
371
+
372
+ // A coverage acceptance clears the degraded/partly-read signals for a skill
373
+ // ONLY when it names every file the scanner said it could not finish. Accept
374
+ // one file and leave another unread, and the skill still blocks — otherwise
375
+ // an acceptance written for a known cause would silently cover an unknown one.
376
+ const forThisSkill = (facts.coverageAccepted ?? []).filter((c) => c.skill === entry.name);
377
+ for (const c of forThisSkill) {
378
+ if (c.actualSha256 && c.actualSha256 !== c.sha256) {
379
+ blocks.push(`${entry.name}: ${c.file} changed since its coverage acceptance was written (baseline ${c.sha256.slice(0, 12)}…, on disk ${c.actualSha256.slice(0, 12)}…) — re-adjudicate it rather than carrying the old reasoning forward`);
380
+ } else if (!c.actualSha256) {
381
+ blocks.push(`${entry.name}: ${c.file} has a coverage acceptance but could not be read to verify it — an acceptance for a file that is not there accepts nothing`);
382
+ }
383
+ }
384
+ const acceptedFiles = new Set(
385
+ forThisSkill.filter((c) => c.actualSha256 && c.actualSha256 === c.sha256).map((c) => c.file),
386
+ );
387
+ const unaccounted = (entry.partialPaths ?? []).filter((path) => !acceptedFiles.has(path));
388
+ const coverageClassAccepted = new Set();
389
+ const coverageAccepted =
390
+ acceptedFiles.size > 0 && (entry.partialPaths ?? []).length > 0 && unaccounted.length === 0;
391
+ if (status && status !== 'complete' && status !== 'partial') {
392
+ blocks.push(`${entry.name}: the scan reported completeness "${status}" — only "complete" or "partial" is a result you can act on`);
393
+ continue;
394
+ }
395
+ if (limitations?.length && !coverageAccepted) {
396
+ const detail = unaccounted.length ? ` in ${unaccounted.join(', ')}` : '';
397
+ blocks.push(`${entry.name}: an analyzer did not finish${detail} (${limitations.join('; ')}) — a degraded scan scores HIGHER than a clean one, so its silence proves nothing`);
398
+ }
399
+ if (entirelyUninspected > 0) {
400
+ blocks.push(`${entry.name}: ${entirelyUninspected} file(s) were never inspected — an unread file is not a clean file`);
401
+ }
402
+ // Distinct from `status: "partial"`, which every healthy scan here reports.
403
+ // Measured across all twelve skills, `partially_inspected_files` is 0 on a
404
+ // healthy run, so this blocks only genuinely truncated coverage.
405
+ if (partiallyInspected > 0 && !coverageAccepted) {
406
+ const where = unaccounted.length ? `: ${unaccounted.join(', ')}` : '';
407
+ blocks.push(`${entry.name}: ${partiallyInspected} file(s) were only partly inspected${where} — the unread remainder is exactly where something would hide`);
408
+ }
409
+ // A scan that read nothing reports "complete" with zero findings, which is
410
+ // indistinguishable from a clean skill. Reachable with a symlinked or
411
+ // unreadable SKILL.md: the scanner sees no bytes and says so by counting
412
+ // them, which is the only place this shows up.
413
+ if (entry.completeness?.fullyInspected === 0) {
414
+ blocks.push(`${entry.name}: the scanner inspected 0 files — an empty read is not a clean result (unreadable or symlinked content?)`);
415
+ }
416
+ for (const issue of entry.issues ?? []) {
417
+ const severity = String(issue.severity).toUpperCase();
418
+ const at = issue.line == null ? issue.file : `${issue.file}:${issue.line}`;
419
+ if (!KNOWN_SEVERITIES.has(severity)) {
420
+ blocks.push(`${entry.name}: unrecognised severity "${issue.severity}" for ${issue.id} at ${at} — refusing to rank an unknown severity below the bar`);
421
+ continue;
422
+ }
423
+ if (!BLOCKING.has(severity)) continue;
424
+ if (issue.id === COVERAGE_CLASS_RULE && acceptedFiles.has(issue.file)) {
425
+ coverageClassAccepted.add(issue.file);
426
+ continue;
427
+ }
428
+ blocks.push(`${entry.name}: ${issue.severity} ${issue.id} at ${at} — fix it, or add a justified baseline rule on the operator's word`);
429
+ }
430
+
431
+ // AFTER the issue loop: an accepted AE1 is only known here. Emitting this
432
+ // earlier meant an accepted HIGH finding vanished with no block and no
433
+ // warning — a suppression nobody could see is indistinguishable from a
434
+ // finding that never existed.
435
+ if (coverageAccepted || coverageClassAccepted.size > 0) {
436
+ const files = [...new Set([...(coverageAccepted ? acceptedFiles : []), ...coverageClassAccepted])].sort();
437
+ const suppressedHigh = coverageClassAccepted.size > 0 ? ` — including HIGH ${COVERAGE_CLASS_RULE} finding(s)` : '';
438
+ warns.push(`${entry.name}: reduced coverage accepted by the baseline for ${files.join(', ')}${suppressedHigh} — the scan of those files is incomplete by acknowledged cause`);
439
+ }
440
+ }
441
+
442
+ for (const warning of facts.baselineWarns ?? []) warns.push(`baseline: ${warning}`);
443
+ if (baselineMissing) {
444
+ warns.push('no baseline file — every finding counts, including ones previously adjudicated as structural');
445
+ }
446
+ const suppressed = skills.reduce((total, entry) => total + (entry.suppressedCount ?? 0), 0);
447
+ if (suppressed > 0) {
448
+ // A bare count hides what was silenced. Ten LOW suppressions and ten HIGH
449
+ // ones are very different facts about a baseline, and the second is the one
450
+ // worth reading before trusting a green run.
451
+ const bySeverity = {};
452
+ for (const entry of skills) {
453
+ for (const item of entry.suppressed ?? []) {
454
+ const key = String(item?.severity ?? 'UNKNOWN').toUpperCase();
455
+ bySeverity[key] = (bySeverity[key] ?? 0) + 1;
456
+ }
457
+ }
458
+ const breakdown = Object.entries(bySeverity)
459
+ .sort()
460
+ .map(([severity, count]) => `${count} ${severity}`)
461
+ .join(', ');
462
+ warns.push(
463
+ `${suppressed} finding(s) suppressed by the baseline${breakdown ? ` (${breakdown})` : ''} — read it when a result surprises you`,
464
+ );
465
+ }
466
+ const belowBar = skills.reduce(
467
+ (total, entry) =>
468
+ total +
469
+ (entry.issues ?? []).filter((i) => {
470
+ const severity = String(i.severity).toUpperCase();
471
+ return KNOWN_SEVERITIES.has(severity) && !BLOCKING.has(severity);
472
+ }).length,
473
+ 0,
474
+ );
475
+ if (belowBar > 0) {
476
+ warns.push(`${belowBar} MEDIUM/LOW finding(s) below the blocking bar — the security axis triages these`);
477
+ }
478
+
479
+ return { blocks, warns };
480
+ }
481
+
482
+ // The dev.md knob naming the directory to scan. `none` or absent means this
483
+ // project authors no skills — the guard skips rather than erroring, so callers
484
+ // run one unconditional command instead of honouring a rule written in prose.
485
+ // Conventional home for the project's suppressions, beside dev.md — one fewer
486
+ // knob, and it means the documented one-command invocation actually applies them.
487
+ export const DEFAULT_BASELINE = '.vegastack/skillspector-baseline.json';
488
+
489
+ // Tolerates the shapes a hand-edited profile actually takes — indented under a
490
+ // heading, or written as a list item. A knob the guard cannot see reads as
491
+ // absent, and absent silently disables the gate, so the match is deliberately
492
+ // forgiving about layout and strict about the value.
493
+ const KNOB_LINE = /^[ \t]*(?:[-*+][ \t]+)?skill-scan:[ \t]*(\S+)/gm;
494
+
495
+ // Every `skill-scan:` value the profile declares. Tolerating indentation and
496
+ // list bullets means a prose EXAMPLE can also match — and with first-match-wins
497
+ // an example of `skill-scan: none` sitting above the real knob silently
498
+ // disabled the gate. The caller blocks when these disagree rather than picking
499
+ // one; guessing which line the author meant is exactly the judgement a guard
500
+ // must not make.
501
+ export function scanRootDeclarations(devMdText) {
502
+ return [...String(devMdText ?? '').matchAll(KNOB_LINE)].map((match) => match[1]);
503
+ }
504
+
505
+ export function resolveScanRoot(devMdText) {
506
+ const value = scanRootDeclarations(devMdText)[0];
507
+ if (!value || value === 'none') return null;
508
+ return value;
509
+ }
510
+
511
+ // Findings carry file names and rule ids that originate in SCANNED content, and
512
+ // this guard's output is read in a terminal. Strip C0/C1 controls (ANSI escapes
513
+ // included) so a crafted path cannot repaint or forge lines of the report.
514
+ function safe(text) {
515
+ // eslint-disable-next-line no-control-regex
516
+ return String(text).replace(/[\u0000-\u001f\u007f-\u009f]/g, '?');
517
+ }
518
+
519
+ function normalizeIssue(raw) {
520
+ const location = raw.location ?? {};
521
+ return {
522
+ id: safe(raw.id ?? raw.rule_id ?? raw.finding_id ?? 'UNKNOWN'),
523
+ severity: safe(raw.severity ?? 'UNKNOWN'),
524
+ file: safe(location.file ?? raw.file ?? '(unknown file)'),
525
+ line: location.start_line ?? null,
526
+ };
527
+ }
528
+
529
+ // Impure: shells out to the scanner, once per skill. `--baseline` is rejected
530
+ // together with `--recursive` ("scan each sub-skill with its own baseline"), so
531
+ // the loop is the supported path, not an optimization we passed up.
532
+ export function gatherFacts({ root, baselinePath, llm }) {
533
+ // VSK_SKILLSPECTOR is a TEST SEAM (stubs the scanner in unit tests), mirroring
534
+ // ship-gate.mjs's VSK_GH. Normal runs resolve `skillspector` from PATH.
535
+ const binary = process.env.VSK_SKILLSPECTOR || 'skillspector';
536
+ const base = {
537
+ binaryMissing: false,
538
+ rootMissing: null,
539
+ baselineMissing: !baselinePath,
540
+ baselineErrors: [],
541
+ skills: [],
542
+ scanErrors: [],
543
+ };
544
+
545
+ if (!root || !existsSync(root)) return { ...base, rootMissing: root ?? '(unset)' };
546
+
547
+ const baselineUsable = Boolean(baselinePath) && existsSync(baselinePath);
548
+ if (baselinePath && !baselineUsable) base.baselineMissing = true;
549
+ if (baselineUsable) {
550
+ const parsed = parseBaseline(readFileSync(baselinePath, 'utf8'));
551
+ base.baselineErrors = parsed.errors;
552
+ base.baselineWarns = parsed.warns;
553
+ base.coverageAccepted = parsed.coverage;
554
+ }
555
+ // Short-circuit: with a bad baseline nothing the scan reports is trustworthy —
556
+ // suppressions may not apply — and the scanner would reject the file once per
557
+ // skill anyway. Block on the real reason instead of after N wasted invocations.
558
+ if (base.baselineErrors.length > 0) return base;
559
+
560
+ // Paths here are attacker-chosen directory names in a third-party tree, and
561
+ // they are printed verbatim in block lines.
562
+ // Hash each accepted file as it is on disk now, so a changed file drops its
563
+ // acceptance and blocks until it is re-adjudicated.
564
+ base.coverageAccepted = (base.coverageAccepted ?? []).map((entry) => {
565
+ const skillDir = discoverSkills(root).find((dir) => basename(dir) === entry.skill);
566
+ let actual = null;
567
+ if (skillDir) {
568
+ try {
569
+ actual = createHash('sha256').update(readFileSync(join(skillDir, entry.file))).digest('hex');
570
+ } catch {
571
+ actual = null;
572
+ }
573
+ }
574
+ return { ...entry, actualSha256: actual };
575
+ });
576
+
577
+ const coverage = findUnscannable(root);
578
+ base.unscannable = coverage.unscannable.map(safe);
579
+ base.coverageExhausted = coverage.exhausted;
580
+ base.unreadableDirs = coverage.unreadable.map(safe);
581
+
582
+ const outDir = mkdtempSync(join(tmpdir(), 'vsk-skill-scan-'));
583
+ const discovered = discoverSkills(root);
584
+ // Two skills can share a basename across groups; the report must say which is
585
+ // which, so an ambiguous name is qualified with its parent directory.
586
+ const basenameCounts = {};
587
+ for (const dir of discovered) basenameCounts[basename(dir)] = (basenameCounts[basename(dir)] ?? 0) + 1;
588
+
589
+ for (const [index, dir] of discovered.entries()) {
590
+ const bare = basename(dir);
591
+ // Sanitized: this comes from a DIRECTORY NAME on disk, which in a
592
+ // third-party skill tree is attacker-chosen, and it is printed to a terminal
593
+ // and embedded in every block line.
594
+ const name = safe(basenameCounts[bare] > 1 ? `${basename(resolve(dir, '..'))}/${bare}` : bare);
595
+ // Indexed, not named: two-level discovery makes duplicate basenames possible
596
+ // (`<root>/a/foo/` and `<root>/b/foo/`), and a shared report path would let
597
+ // one skill's result stand in for another's — a wrong verdict that looks
598
+ // exactly like a right one. `index` is unique per run by construction.
599
+ const reportPath = join(outDir, `${index}.json`);
600
+ const args = ['scan', dir, '--format', 'json', '--output', reportPath];
601
+ if (!llm) args.push('--no-llm');
602
+ if (baselineUsable) args.push('--baseline', baselinePath);
603
+
604
+ try {
605
+ // `env` is passed explicitly, as ship-gate.mjs does: under Bun a mutated
606
+ // process.env is NOT inherited by execFileSync children, so the seam and
607
+ // any scanner configuration (SKILLSPECTOR_PROVIDER, etc.) would be lost.
608
+ execFileSync(binary, args, {
609
+ stdio: ['ignore', 'pipe', 'pipe'],
610
+ env: { ...process.env },
611
+ // A hung or runaway scanner must fail the gate, not hold it open forever.
612
+ timeout: Number(process.env.VSK_SKILLSPECTOR_TIMEOUT_MS) || 300_000,
613
+ maxBuffer: 64 * 1024 * 1024,
614
+ });
615
+ } catch (error) {
616
+ // ENOENT means the binary itself is absent — a fact about the environment,
617
+ // not about any skill, and it stops the whole run.
618
+ if (error.code === 'ENOENT') return { ...base, binaryMissing: true };
619
+ // Any other non-zero exit is expected: the scanner exits 1 whenever the
620
+ // score exceeds 50, which says nothing about whether a finding blocks.
621
+ // The report is the evidence; only its absence is a failure.
622
+ }
623
+
624
+ let report;
625
+ try {
626
+ report = JSON.parse(readFileSync(reportPath, 'utf8'));
627
+ } catch (error) {
628
+ base.scanErrors.push({ skill: name, message: safe(error.message) });
629
+ continue;
630
+ }
631
+ // A report whose shape we do not recognise must fail loudly. Reading a
632
+ // missing `issues` key as "no findings" is the exact false-success this
633
+ // guard exists to prevent, and the scanner is upstream software on a fast
634
+ // cadence — a renamed key would otherwise turn every skill green.
635
+ if (!report || typeof report !== 'object' || Array.isArray(report) || !Array.isArray(report.issues)) {
636
+ base.scanErrors.push({
637
+ skill: name,
638
+ message: 'report has no "issues" array — unrecognised shape, refusing to read it as "no findings"',
639
+ });
640
+ continue;
641
+ }
642
+
643
+ const assessment = report.risk_assessment ?? {};
644
+ const completeness = report.analysis_completeness ?? {};
645
+ base.skills.push({
646
+ name,
647
+ score: assessment.score ?? null,
648
+ severity: assessment.severity ?? 'UNKNOWN',
649
+ executionSuccessful: report.execution_successful !== false,
650
+ suppressedCount: report.suppressed_count ?? 0,
651
+ // The scanner derives status from: "failed" when a ledger exception was
652
+ // fatal, else "partial" when anything was left uninspected or an analyzer
653
+ // reported a limitation, else "complete". `limitations` is the signal that
654
+ // an ANALYZER did not finish — distinct from the reference-resolution
655
+ // exceptions that make a healthy scan of documentation-heavy skills
656
+ // "partial". See the degradation rules in evaluateScan.
657
+ // The files the scanner itself says it could not finish reading, excluding
658
+ // `reference_unresolved` — that one is a path citation, not lost coverage,
659
+ // and it is already reported as an AE1 finding.
660
+ partialPaths: [
661
+ ...new Set(
662
+ (completeness.ledger_exceptions ?? [])
663
+ .filter((e) => e?.outcome === 'partial' && e?.reason_code !== 'reference_unresolved' && e?.path)
664
+ .map((e) => safe(e.path)),
665
+ ),
666
+ ].sort(),
667
+ completeness: {
668
+ status: completeness.status ?? 'unknown',
669
+ // Sanitized: analyzer messages are printed in block lines and can carry
670
+ // text derived from the scanned content.
671
+ limitations: (Array.isArray(completeness.limitations) ? completeness.limitations : []).map(safe),
672
+ entirelyUninspected: completeness.entirely_uninspected_files ?? 0,
673
+ partiallyInspected: completeness.partially_inspected_files ?? 0,
674
+ fullyInspected: completeness.fully_inspected_files ?? 0,
675
+ coveragePercent: completeness.coverage_percent ?? null,
676
+ },
677
+ // The scanner's own list of what the baseline silenced. The Security axis
678
+ // is told to judge whether each suppression was scoped to its cause, which
679
+ // it cannot do from a count — and this evidence is right here in the report.
680
+ suppressed: Array.isArray(report.suppressed) ? report.suppressed : [],
681
+ issues: (report.issues ?? []).map(normalizeIssue),
682
+ });
683
+ }
684
+
685
+ return base;
686
+ }
687
+
688
+ const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
689
+ if (invokedDirectly) {
690
+ const argv = process.argv.slice(2);
691
+ const get = (flag) => {
692
+ const index = argv.indexOf(flag);
693
+ return index === -1 ? undefined : argv[index + 1];
694
+ };
695
+ const json = argv.includes('--json');
696
+ const devMdPath = get('--dev-md') || '.vegastack/dev.md';
697
+
698
+ let root = get('--root');
699
+ const explicitRoot = Boolean(root);
700
+ let skipped = false;
701
+ let outcome = { blocks: [], warns: [] };
702
+ let facts = { skills: [] };
703
+ let baselinePath = get('--baseline') ?? null;
704
+
705
+ if (!explicitRoot) {
706
+ // "Could not read the profile" and "the profile says none" are different
707
+ // answers. Collapsing them let the guard report a clean skip from any
708
+ // directory that simply has no dev.md — a gate that silently disables
709
+ // itself when run from the wrong cwd.
710
+ let devMd = null;
711
+ try {
712
+ devMd = readFileSync(devMdPath, 'utf8');
713
+ } catch (error) {
714
+ outcome.blocks.push(`cannot read ${devMdPath} (${error.code ?? error.message}) — pass --dev-md <path>, or --root to scan a directory directly`);
715
+ }
716
+ if (devMd !== null) {
717
+ const declared = [...new Set(scanRootDeclarations(devMd))];
718
+ if (declared.length > 1) {
719
+ outcome.blocks.push(
720
+ `${devMdPath} gives skill-scan conflicting values (${declared.join(', ')}) — an example line above the real knob would otherwise silently decide the gate; leave exactly one`,
721
+ );
722
+ }
723
+ root = resolveScanRoot(devMd);
724
+ skipped = root === null && declared.length <= 1;
725
+ // The project's own suppressions apply to the project's own skills. They
726
+ // are NOT inherited by an ad-hoc `--root` scan of someone else's skill,
727
+ // where a rule written for our content could silence a real finding in
728
+ // theirs.
729
+ if (!skipped && !baselinePath && existsSync(DEFAULT_BASELINE)) baselinePath = DEFAULT_BASELINE;
730
+ }
731
+ }
732
+
733
+ if (!skipped && outcome.blocks.length === 0) {
734
+ // An uncaught throw would leave node exiting 1 — which in this guard's own
735
+ // scheme reads as "pass with warnings". A crash is not a pass.
736
+ try {
737
+ facts = gatherFacts({ root, baselinePath, llm: argv.includes('--llm') });
738
+ outcome = evaluateScan(facts);
739
+ } catch (error) {
740
+ facts = { skills: [] };
741
+ outcome = { blocks: [`the scan failed unexpectedly: ${error.message}`], warns: [] };
742
+ }
743
+ }
744
+
745
+ const ok = outcome.blocks.length === 0;
746
+ if (json) {
747
+ console.log(JSON.stringify({
748
+ guard: 'skill-scan',
749
+ ok,
750
+ skipped,
751
+ ...outcome,
752
+ // The full normalized issue list, not a count: dev-review's Security axis
753
+ // is told to read the source at each finding's file:line and to judge
754
+ // whether a suppression was scoped to its cause. A count makes both
755
+ // impossible, and this report is the axis's input.
756
+ skills: facts.skills.map(({ name, score, severity, suppressedCount, suppressed, completeness, issues }) => ({
757
+ name, score, severity, suppressedCount, suppressed, completeness, findings: issues.length, issues,
758
+ })),
759
+ }, null, 2));
760
+ } else if (skipped) {
761
+ console.log(`skill-scan: skipped — ${devMdPath} names no scan root (skill-scan: none or absent)`);
762
+ } else if (facts.skills.length === 0 && outcome.blocks.length > 0) {
763
+ console.log('skill-scan: BLOCKED');
764
+ for (const b of outcome.blocks) console.log(` block: ${b}`);
765
+ } else {
766
+ console.log(`skill-scan: ${ok ? (outcome.warns.length ? 'pass with warnings' : 'pass') : 'BLOCKED'}`);
767
+ for (const entry of facts.skills) {
768
+ console.log(` ${entry.name}: score ${entry.score} ${entry.severity} — ${entry.issues.length} finding(s)`);
769
+ }
770
+ for (const b of outcome.blocks) console.log(` block: ${b}`);
771
+ for (const w of outcome.warns) console.log(` warn: ${w}`);
772
+ }
773
+ process.exit(ok ? (outcome.warns.length > 0 ? 1 : 0) : 2);
774
+ }