@vegastack/vegafactory 0.19.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.
- package/LICENSE +21 -0
- package/README.md +417 -0
- package/dist/dashboard-artifact.json +21240 -0
- package/dist/index.js +22752 -0
- package/dist/run-wrapper.js +377 -0
- package/package.json +46 -0
- package/skill/dev-architect/SKILL.md +106 -0
- package/skill/dev-architect/agents/openai.yaml +4 -0
- package/skill/dev-architect/references/ai-agents.md +96 -0
- package/skill/dev-architect/references/conventions.md +113 -0
- package/skill/dev-architect/references/data.md +89 -0
- package/skill/dev-architect/references/infra.md +99 -0
- package/skill/dev-architect/references/mobile.md +75 -0
- package/skill/dev-architect/references/pinned-facts.md +133 -0
- package/skill/dev-architect/references/principles.md +117 -0
- package/skill/dev-architect/references/security.md +90 -0
- package/skill/dev-architect/references/stack.md +38 -0
- package/skill/dev-architect/references/web.md +102 -0
- package/skill/dev-architect/refresh/REFRESH.md +31 -0
- package/skill/dev-architect/refresh/sources.json +239 -0
- package/skill/dev-chronicle/SKILL.md +52 -0
- package/skill/dev-chronicle/agents/openai.yaml +4 -0
- package/skill/dev-chronicle/references/conventions.md +113 -0
- package/skill/dev-chronicle/references/styles.md +75 -0
- package/skill/dev-chronicle/refresh/REFRESH.md +3 -0
- package/skill/dev-chronicle/refresh/sources.json +6 -0
- package/skill/dev-debug/SKILL.md +43 -0
- package/skill/dev-debug/agents/openai.yaml +4 -0
- package/skill/dev-debug/references/conventions.md +113 -0
- package/skill/dev-debug/references/loop-ladder.md +20 -0
- package/skill/dev-debug/refresh/REFRESH.md +3 -0
- package/skill/dev-debug/refresh/sources.json +6 -0
- package/skill/dev-implement/SKILL.md +81 -0
- package/skill/dev-implement/agents/openai.yaml +4 -0
- package/skill/dev-implement/assets/workflows/implement-children.js +12 -0
- package/skill/dev-implement/references/ask-route.md +80 -0
- package/skill/dev-implement/references/changelog-and-chronicle.md +21 -0
- package/skill/dev-implement/references/conventions.md +113 -0
- package/skill/dev-implement/references/ledger-and-resume.md +52 -0
- package/skill/dev-implement/references/parallel-children.md +35 -0
- package/skill/dev-implement/references/worktrees.md +63 -0
- package/skill/dev-implement/refresh/REFRESH.md +3 -0
- package/skill/dev-implement/refresh/sources.json +6 -0
- package/skill/dev-implement/scripts/children.mjs +479 -0
- package/skill/dev-implement/scripts/effective-policy.mjs +694 -0
- package/skill/dev-implement/scripts/evidence-check.mjs +107 -0
- package/skill/dev-implement/scripts/evidence-upload.mjs +181 -0
- package/skill/dev-implement/scripts/learning.mjs +41 -0
- package/skill/dev-implement/scripts/lib/approval.mjs +1182 -0
- package/skill/dev-implement/scripts/lib/gh.mjs +101 -0
- package/skill/dev-implement/scripts/preflight.mjs +153 -0
- package/skill/dev-implement/scripts/questions.mjs +381 -0
- package/skill/dev-implement/scripts/reclaim.mjs +116 -0
- package/skill/dev-implement/scripts/recovery.mjs +213 -0
- package/skill/dev-implement/scripts/worktree.mjs +991 -0
- package/skill/dev-intake/SKILL.md +80 -0
- package/skill/dev-intake/agents/openai.yaml +4 -0
- package/skill/dev-intake/references/ask-route.md +80 -0
- package/skill/dev-intake/references/brief-template.md +89 -0
- package/skill/dev-intake/references/conventions.md +113 -0
- package/skill/dev-intake/refresh/REFRESH.md +3 -0
- package/skill/dev-intake/refresh/sources.json +6 -0
- package/skill/dev-intake/scripts/brief-lint.mjs +87 -0
- package/skill/dev-intake/scripts/lib/approval.mjs +1182 -0
- package/skill/dev-intake/scripts/questions.mjs +381 -0
- package/skill/dev-plan/SKILL.md +54 -0
- package/skill/dev-plan/agents/openai.yaml +4 -0
- package/skill/dev-plan/references/ask-route.md +80 -0
- package/skill/dev-plan/references/conventions.md +113 -0
- package/skill/dev-plan/references/plan-format.md +82 -0
- package/skill/dev-plan/refresh/REFRESH.md +3 -0
- package/skill/dev-plan/refresh/sources.json +6 -0
- package/skill/dev-plan/scripts/lib/approval.mjs +1182 -0
- package/skill/dev-plan/scripts/plan-lint.mjs +319 -0
- package/skill/dev-plan/scripts/questions.mjs +381 -0
- package/skill/dev-review/SKILL.md +76 -0
- package/skill/dev-review/agents/openai.yaml +4 -0
- package/skill/dev-review/assets/review-known-patterns.md.template +32 -0
- package/skill/dev-review/references/conventions.md +113 -0
- package/skill/dev-review/references/cross-agent.md +39 -0
- package/skill/dev-review/references/dispatch-prompts.md +121 -0
- package/skill/dev-review/references/security-axis.md +45 -0
- package/skill/dev-review/refresh/REFRESH.md +5 -0
- package/skill/dev-review/refresh/sources.json +6 -0
- package/skill/dev-setup/SKILL.md +113 -0
- package/skill/dev-setup/agents/openai.yaml +4 -0
- package/skill/dev-setup/assets/agents-section.md.template +24 -0
- package/skill/dev-setup/assets/dev-profile.md.template +102 -0
- package/skill/dev-setup/assets/factory-board.yml.template +162 -0
- package/skill/dev-setup/assets/hooks/decision-nudge.mjs +30 -0
- package/skill/dev-setup/assets/hooks/prompt-skill-mention.mjs +29 -0
- package/skill/dev-setup/assets/hooks/session-end.mjs +14 -0
- package/skill/dev-setup/assets/hooks/session-start.mjs +178 -0
- package/skill/dev-setup/assets/hooks/ship-guard.mjs +738 -0
- package/skill/dev-setup/assets/hooks/skill-activated.mjs +47 -0
- package/skill/dev-setup/assets/hooks/stop-heartbeat.mjs +14 -0
- package/skill/dev-setup/assets/workflows/implement-children.js +12 -0
- package/skill/dev-setup/references/ask-route.md +80 -0
- package/skill/dev-setup/references/conventions.md +113 -0
- package/skill/dev-setup/references/github-app.md +286 -0
- package/skill/dev-setup/references/harness-facts.md +169 -0
- package/skill/dev-setup/references/stack-playbooks.md +124 -0
- package/skill/dev-setup/refresh/REFRESH.md +13 -0
- package/skill/dev-setup/refresh/sources.json +602 -0
- package/skill/dev-setup/scripts/effective-policy.mjs +694 -0
- package/skill/dev-setup/scripts/questions.mjs +381 -0
- package/skill/dev-setup/scripts/ship-policy.mjs +195 -0
- package/skill/dev-ship/SKILL.md +56 -0
- package/skill/dev-ship/agents/openai.yaml +4 -0
- package/skill/dev-ship/references/conventions.md +113 -0
- package/skill/dev-ship/references/runbook.md +72 -0
- package/skill/dev-ship/refresh/REFRESH.md +3 -0
- package/skill/dev-ship/refresh/sources.json +6 -0
- package/skill/dev-ship/scripts/lib/approval.mjs +1182 -0
- package/skill/dev-ship/scripts/ship-gate.mjs +485 -0
- package/skill/dev-status/SKILL.md +56 -0
- package/skill/dev-status/agents/openai.yaml +4 -0
- package/skill/dev-status/references/conventions.md +113 -0
- package/skill/dev-status/refresh/REFRESH.md +3 -0
- package/skill/dev-status/refresh/sources.json +6 -0
- package/skill/dev-status/scripts/effective-policy.mjs +694 -0
- package/skill/dev-status/scripts/status.mjs +311 -0
- package/skill/skill-maintainer/SKILL.md +82 -0
- package/skill/skill-maintainer/agents/openai.yaml +4 -0
- package/skill/skill-maintainer/references/release-ops.md +61 -0
- package/skill/skill-maintainer/references/standards.md +137 -0
- package/skill/skill-maintainer/refresh/REFRESH.md +40 -0
- package/skill/skill-maintainer/refresh/sources.json +103 -0
- package/skill/skill-scan/SKILL.md +59 -0
- package/skill/skill-scan/agents/openai.yaml +4 -0
- package/skill/skill-scan/refresh/REFRESH.md +18 -0
- package/skill/skill-scan/refresh/sources.json +133 -0
- package/skill/skill-scan/scripts/lib/skillspector.mjs +226 -0
- package/skill/skill-scan/scripts/skill-scan.mjs +1002 -0
- package/skill/skillify/SKILL.md +108 -0
- package/skill/skillify/agents/openai.yaml +4 -0
- package/skill/skillify/assets/templates/README.md.template +26 -0
- package/skill/skillify/assets/templates/REFRESH.md.template +16 -0
- package/skill/skillify/assets/templates/SKILL.md.template +27 -0
- package/skill/skillify/assets/templates/evals.json.template +12 -0
- package/skill/skillify/assets/templates/openai.yaml.template +4 -0
- package/skill/skillify/assets/templates/skill.test.ts.template +27 -0
- package/skill/skillify/assets/templates/sources.json.template +6 -0
- package/skill/skillify/assets/templates/trigger-queries.json.template +1 -0
- package/skill/skillify/references/authoring.md +103 -0
- package/skill/skillify/references/eval-playbook.md +112 -0
- package/skill/skillify/refresh/REFRESH.md +23 -0
- package/skill/skillify/refresh/sources.json +6 -0
- package/skill/skillify/scripts/scaffold-skill.mjs +334 -0
- package/skill/skillify/scripts/trigger-check.mjs +209 -0
- package/skill/vegafactory-setup/SKILL.md +77 -0
- package/skill/vegafactory-setup/agents/openai.yaml +4 -0
- package/skill/vegafactory-setup/assets/control-room/boards.md.template +17 -0
- package/skill/vegafactory-setup/assets/control-room/decisions.md.template +5 -0
- package/skill/vegafactory-setup/assets/control-room/group.md.template +44 -0
- package/skill/vegafactory-setup/assets/control-room/onboarding/dispatcher-box.md.template +86 -0
- package/skill/vegafactory-setup/assets/control-room/onboarding/new-repo.md.template +13 -0
- package/skill/vegafactory-setup/assets/control-room/onboarding/new-teammate.md.template +12 -0
- package/skill/vegafactory-setup/assets/control-room/org.md.template +60 -0
- package/skill/vegafactory-setup/assets/control-room/people.csv.template +2 -0
- package/skill/vegafactory-setup/assets/control-room/repos.md.template +7 -0
- package/skill/vegafactory-setup/assets/control-room/rules/CODEOWNERS.template +6 -0
- package/skill/vegafactory-setup/assets/control-room/rules/README.md.template +9 -0
- package/skill/vegafactory-setup/assets/control-room/rules/stats-privacy.md.template +44 -0
- package/skill/vegafactory-setup/assets/control-room/templates/README.md.template +9 -0
- package/skill/vegafactory-setup/references/control-room.md +149 -0
- package/skill/vegafactory-setup/references/conventions.md +113 -0
- package/skill/vegafactory-setup/refresh/REFRESH.md +5 -0
- package/skill/vegafactory-setup/refresh/sources.json +6 -0
- package/skill-integrity.json +252 -0
|
@@ -0,0 +1,1002 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The skill-scan 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 skill-scan; 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, writeFileSync } from 'node:fs';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import { basename, join, resolve } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import {
|
|
19
|
+
defaultRun,
|
|
20
|
+
latestRelease,
|
|
21
|
+
locateSkillspector,
|
|
22
|
+
provisionSkillspector,
|
|
23
|
+
readVersion,
|
|
24
|
+
} from './lib/skillspector.mjs';
|
|
25
|
+
|
|
26
|
+
// The clause every suppression must carry, mirroring the "Still flag if:"
|
|
27
|
+
// requirement on .vegastack/review-known-patterns.md entries: a suppression
|
|
28
|
+
// without a stated re-trigger condition is a blind spot, not a decision.
|
|
29
|
+
const CLAUSE = /still flag if:/i;
|
|
30
|
+
// stdio mode for a discarded fd, hoisted out of quote-adjacency: SkillSpector reads the
|
|
31
|
+
// bare word beside its own closing quote as a removal cue and fails closed on the whole
|
|
32
|
+
// file (skill-maintainer's standards.md, known behaviours). Same value, same behaviour.
|
|
33
|
+
const DISCARD = 'ignore';
|
|
34
|
+
|
|
35
|
+
// SkillSpector's exact default when `skillspector baseline` writes a file
|
|
36
|
+
// without --reason. Committing one of those suppresses every current finding at
|
|
37
|
+
// once. Exact equality is a FACT and blocks; the looser phrase match below is a
|
|
38
|
+
// heuristic and only warns — conventions' guard doctrine is that regex judgement
|
|
39
|
+
// never blocks.
|
|
40
|
+
const PLACEHOLDER_EXACT = 'Accepted finding (auto-generated baseline)';
|
|
41
|
+
const PLACEHOLDER_LIKE = /auto-generated baseline/i;
|
|
42
|
+
|
|
43
|
+
function reasonErrors(entry, label, requireClause) {
|
|
44
|
+
const errors = [];
|
|
45
|
+
const warns = [];
|
|
46
|
+
const reason = typeof entry.reason === 'string' ? entry.reason.trim() : '';
|
|
47
|
+
if (!reason) {
|
|
48
|
+
errors.push(`${label}: missing reason — every suppression states why the pattern is structural here`);
|
|
49
|
+
return { errors, warns };
|
|
50
|
+
}
|
|
51
|
+
if (reason === PLACEHOLDER_EXACT) {
|
|
52
|
+
errors.push(`${label}: the scanner's default reason, unedited — write why this pattern is structural here`);
|
|
53
|
+
} else if (PLACEHOLDER_LIKE.test(reason)) {
|
|
54
|
+
warns.push(`${label}: reason mentions an auto-generated baseline ("${reason}") — check it was actually written, not adapted from the default`);
|
|
55
|
+
}
|
|
56
|
+
if (requireClause && !CLAUSE.test(reason)) {
|
|
57
|
+
errors.push(`${label}: reason has no "Still flag if:" clause — a suppression without a re-trigger condition is a blind spot`);
|
|
58
|
+
}
|
|
59
|
+
return { errors, warns };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Returns { rules, fingerprints, errors }. Never throws: unreadable content comes
|
|
63
|
+
// back as an error so the caller can block on it like any other fact.
|
|
64
|
+
export function parseBaseline(text) {
|
|
65
|
+
let data;
|
|
66
|
+
try {
|
|
67
|
+
data = JSON.parse(text);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
return { rules: [], fingerprints: [], errors: [`baseline is not valid JSON: ${error.message}`], warns: [] };
|
|
70
|
+
}
|
|
71
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
72
|
+
return { rules: [], fingerprints: [], errors: ['baseline must be a JSON object'], warns: [] };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const errors = [];
|
|
76
|
+
const warns = [];
|
|
77
|
+
const rawRules = Array.isArray(data.rules) ? data.rules : [];
|
|
78
|
+
const rawFingerprints = Array.isArray(data.fingerprints) ? data.fingerprints : [];
|
|
79
|
+
if (data.rules !== undefined && !Array.isArray(data.rules)) errors.push('baseline "rules" must be an array');
|
|
80
|
+
if (data.fingerprints !== undefined && !Array.isArray(data.fingerprints)) errors.push('baseline "fingerprints" must be an array');
|
|
81
|
+
|
|
82
|
+
const rules = [];
|
|
83
|
+
rawRules.forEach((raw, index) => {
|
|
84
|
+
const label = `rule ${index + 1}`;
|
|
85
|
+
if (!raw || typeof raw !== 'object') {
|
|
86
|
+
errors.push(`${label}: not an object`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
// SkillSpector normalizes `id`/`rule_id` and `path`/`file` to one field each
|
|
90
|
+
// (`path=raw.get("path") or raw.get("file")`). Missing the `file` alias would
|
|
91
|
+
// both reject a valid baseline AND let `{"file": "*"}` past the wildcard
|
|
92
|
+
// check below into a scanner that honours it.
|
|
93
|
+
const id = raw.id ?? raw.rule_id;
|
|
94
|
+
const path = raw.path ?? raw.file;
|
|
95
|
+
const matchers = { id, path, message: raw.message };
|
|
96
|
+
const present = Object.entries(matchers).filter(([, value]) => value !== undefined);
|
|
97
|
+
if (present.length === 0) {
|
|
98
|
+
errors.push(`${label}: no matcher (id, path, or message) — a rule with no matcher suppresses every finding`);
|
|
99
|
+
}
|
|
100
|
+
// Matchers must be LITERAL. Chasing wildcard shapes is an arms race that
|
|
101
|
+
// was lost at the first attempt: `*` was rejected and `?*` silenced every
|
|
102
|
+
// finding just the same, as do `*.md`, `[a-z]*` and `*SKILL*`. The rule
|
|
103
|
+
// this project already states — "scope a rule as narrowly as its cause" —
|
|
104
|
+
// is mechanically checkable only as "name the thing". A project that wants
|
|
105
|
+
// two files writes two rules, which is the more reviewable artifact anyway.
|
|
106
|
+
for (const [field, value] of present) {
|
|
107
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
108
|
+
errors.push(`${label}: "${field}" must be a non-empty string, got ${JSON.stringify(value)}`);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const glob = value.match(/[*?[\]]/);
|
|
112
|
+
if (glob) {
|
|
113
|
+
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`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const reasoned = reasonErrors(raw, label, true);
|
|
117
|
+
errors.push(...reasoned.errors);
|
|
118
|
+
warns.push(...reasoned.warns);
|
|
119
|
+
rules.push({ id, path, message: raw.message, reason: raw.reason });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// The scanner rejects a v2 baseline that carries fingerprints without pinning
|
|
123
|
+
// the version they were computed against, and it does so per invocation — so
|
|
124
|
+
// catching it here turns twelve confusing "no readable report" failures into
|
|
125
|
+
// one sentence naming the actual problem.
|
|
126
|
+
if (rawFingerprints.length > 0 && !data.scanner_version) {
|
|
127
|
+
errors.push('a v2 baseline with fingerprints must set "scanner_version" (the scanner rejects it otherwise)');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Fingerprints get the same reason discipline minus the clause: they are
|
|
131
|
+
// content-hashed, so editing the surrounding file re-triggers the finding on
|
|
132
|
+
// its own — the re-trigger condition a rule has to state in prose. This check
|
|
133
|
+
// catches the common accident, committing `skillspector baseline` output
|
|
134
|
+
// verbatim, since that writes every finding as a fingerprint carrying the
|
|
135
|
+
// default reason. It does NOT stop someone passing `--reason` with a clause
|
|
136
|
+
// in it: a deliberate mass-suppression is caught by review of the diff and by
|
|
137
|
+
// the suppression counts in the report, not by this guard.
|
|
138
|
+
rawFingerprints.forEach((raw, index) => {
|
|
139
|
+
const label = `fingerprint ${index + 1}`;
|
|
140
|
+
if (!raw || typeof raw !== 'object') {
|
|
141
|
+
errors.push(`${label}: not an object`);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const reasoned = reasonErrors(raw, label, false);
|
|
145
|
+
errors.push(...reasoned.errors);
|
|
146
|
+
warns.push(...reasoned.warns);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// `coverage:` accepts a COMPLETENESS signal, not a finding. The scanner's own
|
|
150
|
+
// baseline cannot express this: it suppresses findings only. Without it, a
|
|
151
|
+
// skill shipping ordinary JavaScript blocks forever — SkillSpector's shell
|
|
152
|
+
// parser reads a template literal in assignment position as backtick command
|
|
153
|
+
// substitution and degrades. Same discipline as a rule: name the skill AND the
|
|
154
|
+
// file, say why, and say what would make it a real signal again.
|
|
155
|
+
const rawCoverage = Array.isArray(data.coverage) ? data.coverage : [];
|
|
156
|
+
if (data.coverage !== undefined && !Array.isArray(data.coverage)) {
|
|
157
|
+
errors.push('baseline "coverage" must be an array');
|
|
158
|
+
}
|
|
159
|
+
const coverage = [];
|
|
160
|
+
rawCoverage.forEach((raw, index) => {
|
|
161
|
+
const label = `coverage ${index + 1}`;
|
|
162
|
+
if (!raw || typeof raw !== 'object') {
|
|
163
|
+
errors.push(`${label}: not an object`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
// Content-bound, like a fingerprint. Without this an acceptance outlives the
|
|
167
|
+
// file it was written about: the reason stays on the page while the content
|
|
168
|
+
// it describes changes underneath, and every "Still flag if:" clause becomes
|
|
169
|
+
// decorative because nothing re-triggers the adjudication.
|
|
170
|
+
if (typeof raw.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(raw.sha256.trim())) {
|
|
171
|
+
errors.push(`${label}: "sha256" must be the 64-hex digest of the accepted file — an acceptance that is not content-bound never expires`);
|
|
172
|
+
}
|
|
173
|
+
for (const field of ['skill', 'file']) {
|
|
174
|
+
const value = raw[field];
|
|
175
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
176
|
+
errors.push(`${label}: "${field}" must be a non-empty string — a coverage acceptance names exactly one file in one skill`);
|
|
177
|
+
} else if (/[*?[\]]/.test(value)) {
|
|
178
|
+
errors.push(`${label}: "${field}" contains a glob character ("${value}") — coverage acceptances are literal, like rules`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const reasoned = reasonErrors(raw, label, true);
|
|
182
|
+
errors.push(...reasoned.errors);
|
|
183
|
+
warns.push(...reasoned.warns);
|
|
184
|
+
coverage.push({ skill: raw.skill, file: raw.file, sha256: raw.sha256, reason: raw.reason });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
return { rules, fingerprints: rawFingerprints, coverage, errors, warns, scannerVersion: data.scanner_version ?? null };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Absolute paths, sorted, of the skill directories under `root`. A directory is
|
|
191
|
+
// a skill iff it holds a SKILL.md. The root itself counts when it holds one;
|
|
192
|
+
// otherwise children AND grandchildren are examined — two levels, matching the
|
|
193
|
+
// authored layout's own cap (`skills/<name>/` and `skills/<group>/<name>/`), so
|
|
194
|
+
// pointing the knob at a grouped tree scans it instead of silently finding
|
|
195
|
+
// nothing. Dot-prefixed entries are skipped: a crashed scaffolder's
|
|
196
|
+
// `.name.scaffold-XXXX` leftover must never read as a skill. Symlinked
|
|
197
|
+
// directories are not followed — a scanner that traverses out of its root scans
|
|
198
|
+
// something other than what it reports on.
|
|
199
|
+
function childDirectories(dir) {
|
|
200
|
+
try {
|
|
201
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
202
|
+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
|
|
203
|
+
.map((entry) => join(dir, entry.name));
|
|
204
|
+
} catch {
|
|
205
|
+
return [];
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function discoverSkills(root) {
|
|
210
|
+
if (!root || !existsSync(root)) return [];
|
|
211
|
+
const absolute = resolve(root);
|
|
212
|
+
if (existsSync(join(absolute, 'SKILL.md'))) return [absolute];
|
|
213
|
+
|
|
214
|
+
const found = [];
|
|
215
|
+
for (const child of childDirectories(absolute)) {
|
|
216
|
+
if (existsSync(join(child, 'SKILL.md'))) {
|
|
217
|
+
found.push(child);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
// One level deeper, for the grouped authored layout (`<root>/<group>/<skill>/`).
|
|
221
|
+
// Without this a grouped tree scans as ZERO skills while reporting success on
|
|
222
|
+
// whatever else it found — coverage silently lost, which is the whole defect
|
|
223
|
+
// class this guard exists to stop.
|
|
224
|
+
for (const grandchild of childDirectories(child)) {
|
|
225
|
+
if (existsSync(join(grandchild, 'SKILL.md'))) found.push(grandchild);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return found.sort();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Anything that LOOKS like a skill but discovery did not scan. Defined as the
|
|
232
|
+
// difference between a full walk and `discoverSkills`, rather than as a list of
|
|
233
|
+
// known-bad shapes — so it stays correct by construction when discovery changes.
|
|
234
|
+
// It catches skills nested deeper than the layout allows, dot-prefixed
|
|
235
|
+
// directories, and symlinked directories that are or contain a skill.
|
|
236
|
+
//
|
|
237
|
+
// The walk is NOT depth-capped. A cap is a cliff: an earlier version stopped at
|
|
238
|
+
// depth 4, and a deliberately malicious skill at depth 5 was then scanned by
|
|
239
|
+
// nobody and flagged by nobody — the exact silent-coverage-loss this function
|
|
240
|
+
// exists to prevent, reintroduced one level down. Real directories cannot cycle
|
|
241
|
+
// and symlinks are never descended, so the walk terminates. TWO things can stop it early, and BOTH are reported rather than
|
|
242
|
+
// swallowed: the visit budget, and a directory it cannot read (EACCES, or a
|
|
243
|
+
// path past PATH_MAX). An earlier version caught the read failure and gave up
|
|
244
|
+
// silently, so a skill hidden behind a `chmod 000` directory was flagged by
|
|
245
|
+
// nobody while the run reported success — the same quiet give-up this whole
|
|
246
|
+
// function exists to prevent, one level down.
|
|
247
|
+
const WALK_BUDGET = 50_000;
|
|
248
|
+
|
|
249
|
+
function deepSkillDirs(dir, state) {
|
|
250
|
+
if (state.exhausted) return;
|
|
251
|
+
let entries;
|
|
252
|
+
try {
|
|
253
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
254
|
+
} catch (error) {
|
|
255
|
+
// Not silence: an unreadable directory is unverified coverage.
|
|
256
|
+
state.unreadable.add(`${dir} (${error.code ?? error.message})`);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
for (const entry of entries) {
|
|
260
|
+
if (state.visited++ > WALK_BUDGET) {
|
|
261
|
+
state.exhausted = true;
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const child = join(dir, entry.name);
|
|
265
|
+
if (entry.isSymbolicLink()) {
|
|
266
|
+
// Never descended — a scanner that walks out of its root reports on
|
|
267
|
+
// something it was not pointed at. Flagged when the target is, or holds,
|
|
268
|
+
// a skill, so it is refused rather than dropped.
|
|
269
|
+
if (existsSync(join(child, 'SKILL.md'))) {
|
|
270
|
+
state.seen.add(child);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
for (const nested of childDirectories(child)) {
|
|
274
|
+
if (existsSync(join(nested, 'SKILL.md'))) {
|
|
275
|
+
state.seen.add(child);
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (!entry.isDirectory()) continue;
|
|
282
|
+
if (existsSync(join(child, 'SKILL.md'))) state.seen.add(child);
|
|
283
|
+
deepSkillDirs(child, state);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Returns { unscannable, exhausted }. `exhausted` means the walk hit its budget
|
|
288
|
+
// and coverage could NOT be verified — the caller blocks on it.
|
|
289
|
+
export function findUnscannable(root) {
|
|
290
|
+
if (!root || !existsSync(root)) return { unscannable: [], exhausted: false };
|
|
291
|
+
const absolute = resolve(root);
|
|
292
|
+
const scanned = new Set(discoverSkills(absolute));
|
|
293
|
+
const state = { seen: new Set(), visited: 0, exhausted: false, unreadable: new Set() };
|
|
294
|
+
deepSkillDirs(absolute, state);
|
|
295
|
+
return {
|
|
296
|
+
unscannable: [...state.seen].filter((dir) => !scanned.has(dir)).sort(),
|
|
297
|
+
exhausted: state.exhausted,
|
|
298
|
+
unreadable: [...state.unreadable].sort(),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// The severities that stop a push. Deliberately NOT the aggregate risk score:
|
|
303
|
+
// a score is inflated by unresolvable-path artifacts in meta-content and
|
|
304
|
+
// deflated by suppressing unrelated findings, so it answers a question nobody
|
|
305
|
+
// asked. Individual findings are what a reviewer triages.
|
|
306
|
+
const BLOCKING = new Set(['HIGH', 'CRITICAL']);
|
|
307
|
+
// The one finding id that is a COMPLETENESS signal wearing a finding's clothes —
|
|
308
|
+
// the scanner's own text for it is "Referenced artifact was not completely
|
|
309
|
+
// inspected". A `coverage:` entry naming that file accepts it, because it is the
|
|
310
|
+
// same phenomenon the coverage section exists for; every other id must go
|
|
311
|
+
// through `rules` or `fingerprints`, which bind to content.
|
|
312
|
+
const COVERAGE_CLASS_RULE = 'AE1';
|
|
313
|
+
// Everything the scanner is known to emit. A severity outside this set is
|
|
314
|
+
// upstream drift, and drift must fail CLOSED: silently sorting an unrecognised
|
|
315
|
+
// severity under the blocking bar and then calling it "MEDIUM/LOW" would be a
|
|
316
|
+
// false success dressed as a summary line.
|
|
317
|
+
const KNOWN_SEVERITIES = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO']);
|
|
318
|
+
|
|
319
|
+
const INSTALL_HINT = 'install it with `uv tool install git+https://github.com/NVIDIA/skillspector.git`';
|
|
320
|
+
|
|
321
|
+
// Pure evaluation over gathered facts — unit tests drive this directly.
|
|
322
|
+
export function evaluateScan(facts) {
|
|
323
|
+
const blocks = [];
|
|
324
|
+
const warns = [];
|
|
325
|
+
const {
|
|
326
|
+
binaryMissing,
|
|
327
|
+
rootMissing,
|
|
328
|
+
baselineMissing,
|
|
329
|
+
baselineErrors = [],
|
|
330
|
+
skills = [],
|
|
331
|
+
scanErrors = [],
|
|
332
|
+
skillspector = {},
|
|
333
|
+
baselinePin = {},
|
|
334
|
+
} = facts;
|
|
335
|
+
|
|
336
|
+
// Environment failures first: when the scanner never ran, a finding list is
|
|
337
|
+
// not evidence of anything, and the real cause must read before the noise.
|
|
338
|
+
if (binaryMissing) {
|
|
339
|
+
blocks.push(
|
|
340
|
+
`the \`skillspector\` binary could not be found — no install channel (uv, brew, pipx) reports it and it is not on PATH — ${INSTALL_HINT}; or, if it runs through a wrapper or container, point VSK_SKILLSPECTOR at that executable; or set skill-scan: none if this project has no skills`,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
// An update that could not happen is a note, never a block: the scan ran on
|
|
344
|
+
// the copy that was already installed, which is exactly the documented
|
|
345
|
+
// fallback. Sanitized — this text comes from a package manager.
|
|
346
|
+
if (skillspector.action === 'failed' && skillspector.message) {
|
|
347
|
+
warns.push(`skillspector update failed, scanned with the installed copy instead — ${safe(skillspector.message)}`);
|
|
348
|
+
}
|
|
349
|
+
// A fingerprint is a content hash tied to the scanner that produced it, so a
|
|
350
|
+
// version change can stop it matching and quietly un-suppress its finding.
|
|
351
|
+
// Warn, never block: the finding coming back IS the loud outcome, and the pin
|
|
352
|
+
// is never moved automatically — that would assert a suppression still holds
|
|
353
|
+
// for a scanner nobody has run.
|
|
354
|
+
if (baselinePin.fingerprints > 0 && baselinePin.scannerVersion && skillspector.version && baselinePin.scannerVersion !== skillspector.version) {
|
|
355
|
+
warns.push(
|
|
356
|
+
`baseline pins scanner_version ${safe(baselinePin.scannerVersion)} for ${baselinePin.fingerprints} fingerprint(s) but skillspector ${safe(skillspector.version)} ran — re-verify those suppressions and move the pin deliberately, never automatically`,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (rootMissing) {
|
|
361
|
+
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`);
|
|
362
|
+
}
|
|
363
|
+
for (const error of baselineErrors) {
|
|
364
|
+
blocks.push(`baseline: ${error}`);
|
|
365
|
+
}
|
|
366
|
+
for (const { skill, message } of scanErrors) {
|
|
367
|
+
blocks.push(`${skill}: the scan did not produce a readable report (${message}) — an unscanned skill is not a clean skill`);
|
|
368
|
+
}
|
|
369
|
+
for (const dir of facts.unreadableDirs ?? []) {
|
|
370
|
+
blocks.push(`${dir} could not be read, so coverage under it is unverified — a skill hidden there would be reported by nobody`);
|
|
371
|
+
}
|
|
372
|
+
if (facts.coverageExhausted) {
|
|
373
|
+
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');
|
|
374
|
+
}
|
|
375
|
+
for (const path of facts.unscannable ?? []) {
|
|
376
|
+
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`);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (blocks.length > 0) return { blocks, warns };
|
|
380
|
+
|
|
381
|
+
if (skills.length === 0) {
|
|
382
|
+
blocks.push('no skills found under the scan root — a root with nothing in it is a misconfigured knob, not a clean result');
|
|
383
|
+
return { blocks, warns };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
for (const entry of skills) {
|
|
387
|
+
// The ONLY short-circuit: a failed execution means no field of this report
|
|
388
|
+
// can be trusted. Every other coverage problem still yields real findings,
|
|
389
|
+
// and suppressing them behind the coverage block would tell the operator
|
|
390
|
+
// less than the guard actually knows.
|
|
391
|
+
if (!entry.executionSuccessful) {
|
|
392
|
+
blocks.push(`${entry.name}: the scan did not complete (execution_successful: false) — a partial score is not a verdict`);
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
// A degraded run reports a HIGHER score than a clean one (a failed analyzer
|
|
396
|
+
// leaves its findings unfiltered), so "no blocking finding" from a degraded
|
|
397
|
+
// scan proves nothing. But `status: "partial"` on its own is the NORMAL
|
|
398
|
+
// result for documentation-heavy skills — it is what unresolved path-like
|
|
399
|
+
// references produce — so blocking on it would block every scan forever,
|
|
400
|
+
// the same trap as gating on the aggregate score. Block only on the signals
|
|
401
|
+
// that mean work did not happen.
|
|
402
|
+
const { status, limitations, entirelyUninspected, partiallyInspected } = entry.completeness ?? {};
|
|
403
|
+
|
|
404
|
+
// A coverage acceptance clears the degraded/partly-read signals for a skill
|
|
405
|
+
// ONLY when it names every file the scanner said it could not finish. Accept
|
|
406
|
+
// one file and leave another unread, and the skill still blocks — otherwise
|
|
407
|
+
// an acceptance written for a known cause would silently cover an unknown one.
|
|
408
|
+
const forThisSkill = (facts.coverageAccepted ?? []).filter((c) => c.skill === entry.name);
|
|
409
|
+
for (const c of forThisSkill) {
|
|
410
|
+
if (c.actualSha256 && c.actualSha256 !== c.sha256) {
|
|
411
|
+
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`);
|
|
412
|
+
} else if (!c.actualSha256) {
|
|
413
|
+
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`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
const acceptedFiles = new Set(
|
|
417
|
+
forThisSkill.filter((c) => c.actualSha256 && c.actualSha256 === c.sha256).map((c) => c.file),
|
|
418
|
+
);
|
|
419
|
+
const unaccounted = (entry.partialPaths ?? []).filter((path) => !acceptedFiles.has(path));
|
|
420
|
+
const coverageClassAccepted = new Set();
|
|
421
|
+
const coverageAccepted =
|
|
422
|
+
acceptedFiles.size > 0 && (entry.partialPaths ?? []).length > 0 && unaccounted.length === 0;
|
|
423
|
+
if (status && status !== 'complete' && status !== 'partial') {
|
|
424
|
+
blocks.push(`${entry.name}: the scan reported completeness "${status}" — only "complete" or "partial" is a result you can act on`);
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
if (limitations?.length && !coverageAccepted) {
|
|
428
|
+
const detail = unaccounted.length ? ` in ${unaccounted.join(', ')}` : '';
|
|
429
|
+
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`);
|
|
430
|
+
}
|
|
431
|
+
if (entirelyUninspected > 0) {
|
|
432
|
+
blocks.push(`${entry.name}: ${entirelyUninspected} file(s) were never inspected — an unread file is not a clean file`);
|
|
433
|
+
}
|
|
434
|
+
// Distinct from `status: "partial"`, which every healthy scan here reports.
|
|
435
|
+
// Measured across all twelve skills, `partially_inspected_files` is 0 on a
|
|
436
|
+
// healthy run, so this blocks only genuinely truncated coverage.
|
|
437
|
+
if (partiallyInspected > 0 && !coverageAccepted) {
|
|
438
|
+
const where = unaccounted.length ? `: ${unaccounted.join(', ')}` : '';
|
|
439
|
+
blocks.push(`${entry.name}: ${partiallyInspected} file(s) were only partly inspected${where} — the unread remainder is exactly where something would hide`);
|
|
440
|
+
}
|
|
441
|
+
// A scan that read nothing reports "complete" with zero findings, which is
|
|
442
|
+
// indistinguishable from a clean skill. Reachable with a symlinked or
|
|
443
|
+
// unreadable SKILL.md: the scanner sees no bytes and says so by counting
|
|
444
|
+
// them, which is the only place this shows up.
|
|
445
|
+
if (entry.completeness?.fullyInspected === 0) {
|
|
446
|
+
blocks.push(`${entry.name}: the scanner inspected 0 files — an empty read is not a clean result (unreadable or symlinked content?)`);
|
|
447
|
+
}
|
|
448
|
+
for (const issue of entry.issues ?? []) {
|
|
449
|
+
const severity = String(issue.severity).toUpperCase();
|
|
450
|
+
const at = issue.line == null ? issue.file : `${issue.file}:${issue.line}`;
|
|
451
|
+
if (!KNOWN_SEVERITIES.has(severity)) {
|
|
452
|
+
blocks.push(`${entry.name}: unrecognised severity "${issue.severity}" for ${issue.id} at ${at} — refusing to rank an unknown severity below the bar`);
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
if (!BLOCKING.has(severity)) continue;
|
|
456
|
+
if (issue.id === COVERAGE_CLASS_RULE && acceptedFiles.has(issue.file)) {
|
|
457
|
+
coverageClassAccepted.add(issue.file);
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
blocks.push(`${entry.name}: ${issue.severity} ${issue.id} at ${at} — fix it, or add a justified baseline rule on the operator's word`);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// AFTER the issue loop: an accepted AE1 is only known here. Emitting this
|
|
464
|
+
// earlier meant an accepted HIGH finding vanished with no block and no
|
|
465
|
+
// warning — a suppression nobody could see is indistinguishable from a
|
|
466
|
+
// finding that never existed.
|
|
467
|
+
if (coverageAccepted || coverageClassAccepted.size > 0) {
|
|
468
|
+
const files = [...new Set([...(coverageAccepted ? acceptedFiles : []), ...coverageClassAccepted])].sort();
|
|
469
|
+
const suppressedHigh = coverageClassAccepted.size > 0 ? ` — including HIGH ${COVERAGE_CLASS_RULE} finding(s)` : '';
|
|
470
|
+
warns.push(`${entry.name}: reduced coverage accepted by the baseline for ${files.join(', ')}${suppressedHigh} — the scan of those files is incomplete by acknowledged cause`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
for (const warning of facts.baselineWarns ?? []) warns.push(`baseline: ${warning}`);
|
|
475
|
+
if (baselineMissing) {
|
|
476
|
+
warns.push('no baseline file — every finding counts, including ones previously adjudicated as structural');
|
|
477
|
+
}
|
|
478
|
+
const suppressed = skills.reduce((total, entry) => total + (entry.suppressedCount ?? 0), 0);
|
|
479
|
+
if (suppressed > 0) {
|
|
480
|
+
// A bare count hides what was silenced. Ten LOW suppressions and ten HIGH
|
|
481
|
+
// ones are very different facts about a baseline, and the second is the one
|
|
482
|
+
// worth reading before trusting a green run.
|
|
483
|
+
const bySeverity = {};
|
|
484
|
+
for (const entry of skills) {
|
|
485
|
+
for (const item of entry.suppressed ?? []) {
|
|
486
|
+
const key = String(item?.severity ?? 'UNKNOWN').toUpperCase();
|
|
487
|
+
bySeverity[key] = (bySeverity[key] ?? 0) + 1;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
const breakdown = Object.entries(bySeverity)
|
|
491
|
+
.sort()
|
|
492
|
+
.map(([severity, count]) => `${count} ${severity}`)
|
|
493
|
+
.join(', ');
|
|
494
|
+
warns.push(
|
|
495
|
+
`${suppressed} finding(s) suppressed by the baseline${breakdown ? ` (${breakdown})` : ''} — read it when a result surprises you`,
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
const belowBar = skills.reduce(
|
|
499
|
+
(total, entry) =>
|
|
500
|
+
total +
|
|
501
|
+
(entry.issues ?? []).filter((i) => {
|
|
502
|
+
const severity = String(i.severity).toUpperCase();
|
|
503
|
+
return KNOWN_SEVERITIES.has(severity) && !BLOCKING.has(severity);
|
|
504
|
+
}).length,
|
|
505
|
+
0,
|
|
506
|
+
);
|
|
507
|
+
if (belowBar > 0) {
|
|
508
|
+
warns.push(`${belowBar} MEDIUM/LOW finding(s) below the blocking bar — the security axis triages these`);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return { blocks, warns };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// The dev.md knob naming the directory to scan. `none` or absent means this
|
|
515
|
+
// project authors no skills — the guard skips rather than erroring, so callers
|
|
516
|
+
// run one unconditional command instead of honouring a rule written in prose.
|
|
517
|
+
// Conventional home for the project's suppressions, beside dev.md — one fewer
|
|
518
|
+
// knob, and it means the documented one-command invocation actually applies them.
|
|
519
|
+
export const DEFAULT_BASELINE = '.vegastack/skillspector-baseline.json';
|
|
520
|
+
|
|
521
|
+
// Tolerates the shapes a hand-edited profile actually takes — indented under a
|
|
522
|
+
// heading, or written as a list item. A knob the guard cannot see reads as
|
|
523
|
+
// absent, and absent silently disables the gate, so the match is deliberately
|
|
524
|
+
// forgiving about layout and strict about the value.
|
|
525
|
+
const KNOB_LINE = /^[ \t]*(?:[-*+][ \t]+)?skill-scan:[ \t]*(\S+)/gm;
|
|
526
|
+
|
|
527
|
+
// Every `skill-scan:` value the profile declares. Tolerating indentation and
|
|
528
|
+
// list bullets means a prose EXAMPLE can also match — and with first-match-wins
|
|
529
|
+
// an example of `skill-scan: none` sitting above the real knob silently
|
|
530
|
+
// disabled the gate. The caller blocks when these disagree rather than picking
|
|
531
|
+
// one; guessing which line the author meant is exactly the judgement a guard
|
|
532
|
+
// must not make.
|
|
533
|
+
export function scanRootDeclarations(devMdText) {
|
|
534
|
+
return [...String(devMdText ?? '').matchAll(KNOB_LINE)].map((match) => match[1]);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// The sibling knob governing SkillSpector itself: off | notify | auto, absent
|
|
538
|
+
// reading as `auto` so an existing profile inherits the default without an
|
|
539
|
+
// edit. Same tolerant layout matching and same conflict discipline as
|
|
540
|
+
// `skill-scan:` — a knob the guard cannot see reads as absent, and absent must
|
|
541
|
+
// not silently mean something different from what the author wrote.
|
|
542
|
+
//
|
|
543
|
+
// Named for the tool, not for our guard: `skill-scan:` is our machinery,
|
|
544
|
+
// `skillspector-update:` is the third-party binary (operator's rule, 01-09-2026).
|
|
545
|
+
const UPDATE_KNOB_LINE = /^[ \t]*(?:[-*+][ \t]+)?skillspector-update:[ \t]*(\S+)/gm;
|
|
546
|
+
|
|
547
|
+
export const UPDATE_MODES = new Set(['off', 'notify', 'auto']);
|
|
548
|
+
|
|
549
|
+
// Every declared value, unvalidated. The caller refuses a conflict or an
|
|
550
|
+
// unrecognised value rather than picking one: guessing which mode the author
|
|
551
|
+
// meant is exactly the judgement a guard must not make.
|
|
552
|
+
export function updateModeDeclarations(devMdText) {
|
|
553
|
+
return [...String(devMdText ?? '').matchAll(UPDATE_KNOB_LINE)].map((match) => match[1]);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export function resolveUpdateMode(devMdText) {
|
|
557
|
+
return updateModeDeclarations(devMdText)[0] ?? 'auto';
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
export function resolveScanRoot(devMdText) {
|
|
561
|
+
const value = scanRootDeclarations(devMdText)[0];
|
|
562
|
+
if (!value || value === 'none') return null;
|
|
563
|
+
return value;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Findings carry file names and rule ids that originate in SCANNED content, and
|
|
567
|
+
// this guard's output is read in a terminal. Strip C0/C1 controls (ANSI escapes
|
|
568
|
+
// included) so a crafted path cannot repaint or forge lines of the report.
|
|
569
|
+
function safe(text) {
|
|
570
|
+
// eslint-disable-next-line no-control-regex
|
|
571
|
+
return String(text).replace(/[\u0000-\u001f\u007f-\u009f]/g, '?');
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function normalizeIssue(raw) {
|
|
575
|
+
const location = raw.location ?? {};
|
|
576
|
+
return {
|
|
577
|
+
id: safe(raw.id ?? raw.rule_id ?? raw.finding_id ?? 'UNKNOWN'),
|
|
578
|
+
severity: safe(raw.severity ?? 'UNKNOWN'),
|
|
579
|
+
file: safe(location.file ?? raw.file ?? '(unknown file)'),
|
|
580
|
+
line: location.start_line ?? null,
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Impure: shells out to the scanner, once per skill. `--baseline` is rejected
|
|
585
|
+
// together with `--recursive` ("scan each sub-skill with its own baseline"), so
|
|
586
|
+
// the loop is the supported path, not an optimization we passed up.
|
|
587
|
+
export function gatherFacts({ root, baselinePath, llm, binary: binaryOverride }) {
|
|
588
|
+
// VSK_SKILLSPECTOR is a TEST SEAM (stubs the scanner in unit tests), mirroring
|
|
589
|
+
// ship-gate.mjs's VSK_GH. `binaryOverride` is the absolute path the CLI
|
|
590
|
+
// resolved through the tool's own install channel; a bare PATH lookup is the
|
|
591
|
+
// last resort, not the first.
|
|
592
|
+
//
|
|
593
|
+
// Locating deliberately happens in the CLI and NOT here: gatherFacts is
|
|
594
|
+
// driven directly by unit tests, and probing uv/brew/pipx from inside it
|
|
595
|
+
// would make the suite shell out to whatever is installed on the machine.
|
|
596
|
+
const binary = process.env.VSK_SKILLSPECTOR || binaryOverride || 'skillspector';
|
|
597
|
+
const base = {
|
|
598
|
+
binaryMissing: false,
|
|
599
|
+
rootMissing: null,
|
|
600
|
+
baselineMissing: !baselinePath,
|
|
601
|
+
baselineErrors: [],
|
|
602
|
+
skills: [],
|
|
603
|
+
scanErrors: [],
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
if (!root || !existsSync(root)) return { ...base, rootMissing: root ?? '(unset)' };
|
|
607
|
+
|
|
608
|
+
const baselineUsable = Boolean(baselinePath) && existsSync(baselinePath);
|
|
609
|
+
if (baselinePath && !baselineUsable) base.baselineMissing = true;
|
|
610
|
+
if (baselineUsable) {
|
|
611
|
+
const parsed = parseBaseline(readFileSync(baselinePath, 'utf8'));
|
|
612
|
+
base.baselineErrors = parsed.errors;
|
|
613
|
+
base.baselineWarns = parsed.warns;
|
|
614
|
+
base.coverageAccepted = parsed.coverage;
|
|
615
|
+
// Only fingerprints are version-coupled: they are content hashes the
|
|
616
|
+
// scanner computed, so a different scanner may stop matching them. Rules
|
|
617
|
+
// bind to id+path and survive an upgrade untouched.
|
|
618
|
+
base.baselinePin = { scannerVersion: parsed.scannerVersion, fingerprints: parsed.fingerprints.length };
|
|
619
|
+
}
|
|
620
|
+
// Short-circuit: with a bad baseline nothing the scan reports is trustworthy —
|
|
621
|
+
// suppressions may not apply — and the scanner would reject the file once per
|
|
622
|
+
// skill anyway. Block on the real reason instead of after N wasted invocations.
|
|
623
|
+
if (base.baselineErrors.length > 0) return base;
|
|
624
|
+
|
|
625
|
+
// Paths here are attacker-chosen directory names in a third-party tree, and
|
|
626
|
+
// they are printed verbatim in block lines.
|
|
627
|
+
// Hash each accepted file as it is on disk now, so a changed file drops its
|
|
628
|
+
// acceptance and blocks until it is re-adjudicated.
|
|
629
|
+
base.coverageAccepted = (base.coverageAccepted ?? []).map((entry) => {
|
|
630
|
+
const skillDir = discoverSkills(root).find((dir) => basename(dir) === entry.skill);
|
|
631
|
+
let actual = null;
|
|
632
|
+
if (skillDir) {
|
|
633
|
+
try {
|
|
634
|
+
actual = createHash('sha256').update(readFileSync(join(skillDir, entry.file))).digest('hex');
|
|
635
|
+
} catch {
|
|
636
|
+
actual = null;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
return { ...entry, actualSha256: actual };
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
const coverage = findUnscannable(root);
|
|
643
|
+
base.unscannable = coverage.unscannable.map(safe);
|
|
644
|
+
base.coverageExhausted = coverage.exhausted;
|
|
645
|
+
base.unreadableDirs = coverage.unreadable.map(safe);
|
|
646
|
+
|
|
647
|
+
const outDir = mkdtempSync(join(tmpdir(), 'vsk-skill-scan-'));
|
|
648
|
+
const discovered = discoverSkills(root);
|
|
649
|
+
// Two skills can share a basename across groups; the report must say which is
|
|
650
|
+
// which, so an ambiguous name is qualified with its parent directory.
|
|
651
|
+
const basenameCounts = {};
|
|
652
|
+
for (const dir of discovered) basenameCounts[basename(dir)] = (basenameCounts[basename(dir)] ?? 0) + 1;
|
|
653
|
+
|
|
654
|
+
for (const [index, dir] of discovered.entries()) {
|
|
655
|
+
const bare = basename(dir);
|
|
656
|
+
// Sanitized: this comes from a DIRECTORY NAME on disk, which in a
|
|
657
|
+
// third-party skill tree is attacker-chosen, and it is printed to a terminal
|
|
658
|
+
// and embedded in every block line.
|
|
659
|
+
const name = safe(basenameCounts[bare] > 1 ? `${basename(resolve(dir, '..'))}/${bare}` : bare);
|
|
660
|
+
// Indexed, not named: two-level discovery makes duplicate basenames possible
|
|
661
|
+
// (`<root>/a/foo/` and `<root>/b/foo/`), and a shared report path would let
|
|
662
|
+
// one skill's result stand in for another's — a wrong verdict that looks
|
|
663
|
+
// exactly like a right one. `index` is unique per run by construction.
|
|
664
|
+
const reportPath = join(outDir, `${index}.json`);
|
|
665
|
+
const args = ['scan', dir, '--format', 'json', '--output', reportPath];
|
|
666
|
+
if (!llm) args.push('--no-llm');
|
|
667
|
+
if (baselineUsable) args.push('--baseline', baselinePath);
|
|
668
|
+
|
|
669
|
+
try {
|
|
670
|
+
// `env` is passed explicitly, as ship-gate.mjs does: under Bun a mutated
|
|
671
|
+
// process.env is NOT inherited by execFileSync children, so the seam and
|
|
672
|
+
// any scanner configuration (SKILLSPECTOR_PROVIDER, etc.) would be lost.
|
|
673
|
+
execFileSync(binary, args, {
|
|
674
|
+
stdio: [DISCARD, 'pipe', 'pipe'],
|
|
675
|
+
env: { ...process.env },
|
|
676
|
+
// A hung or runaway scanner must fail the gate, not hold it open forever.
|
|
677
|
+
timeout: Number(process.env.VSK_SKILLSPECTOR_TIMEOUT_MS) || 300_000,
|
|
678
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
679
|
+
});
|
|
680
|
+
} catch (error) {
|
|
681
|
+
// ENOENT means the binary itself is absent — a fact about the environment,
|
|
682
|
+
// not about any skill, and it stops the whole run.
|
|
683
|
+
if (error.code === 'ENOENT') return { ...base, binaryMissing: true };
|
|
684
|
+
// Any other non-zero exit is expected: the scanner exits 1 whenever the
|
|
685
|
+
// score exceeds 50, which says nothing about whether a finding blocks.
|
|
686
|
+
// The report is the evidence; only its absence is a failure.
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
let report;
|
|
690
|
+
try {
|
|
691
|
+
report = JSON.parse(readFileSync(reportPath, 'utf8'));
|
|
692
|
+
} catch (error) {
|
|
693
|
+
base.scanErrors.push({ skill: name, message: safe(error.message) });
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
// A report whose shape we do not recognise must fail loudly. Reading a
|
|
697
|
+
// missing `issues` key as "no findings" is the exact false-success this
|
|
698
|
+
// guard exists to prevent, and the scanner is upstream software on a fast
|
|
699
|
+
// cadence — a renamed key would otherwise turn every skill green.
|
|
700
|
+
if (!report || typeof report !== 'object' || Array.isArray(report) || !Array.isArray(report.issues)) {
|
|
701
|
+
base.scanErrors.push({
|
|
702
|
+
skill: name,
|
|
703
|
+
message: 'report has no "issues" array — unrecognised shape, refusing to read it as "no findings"',
|
|
704
|
+
});
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const assessment = report.risk_assessment ?? {};
|
|
709
|
+
const completeness = report.analysis_completeness ?? {};
|
|
710
|
+
base.skills.push({
|
|
711
|
+
name,
|
|
712
|
+
score: assessment.score ?? null,
|
|
713
|
+
severity: assessment.severity ?? 'UNKNOWN',
|
|
714
|
+
executionSuccessful: report.execution_successful !== false,
|
|
715
|
+
suppressedCount: report.suppressed_count ?? 0,
|
|
716
|
+
// The scanner derives status from: "failed" when a ledger exception was
|
|
717
|
+
// fatal, else "partial" when anything was left uninspected or an analyzer
|
|
718
|
+
// reported a limitation, else "complete". `limitations` is the signal that
|
|
719
|
+
// an ANALYZER did not finish — distinct from the reference-resolution
|
|
720
|
+
// exceptions that make a healthy scan of documentation-heavy skills
|
|
721
|
+
// "partial". See the degradation rules in evaluateScan.
|
|
722
|
+
// The files the scanner itself says it could not finish reading, excluding
|
|
723
|
+
// `reference_unresolved` — that one is a path citation, not lost coverage,
|
|
724
|
+
// and it is already reported as an AE1 finding.
|
|
725
|
+
partialPaths: [
|
|
726
|
+
...new Set(
|
|
727
|
+
(completeness.ledger_exceptions ?? [])
|
|
728
|
+
.filter((e) => e?.outcome === 'partial' && e?.reason_code !== 'reference_unresolved' && e?.path)
|
|
729
|
+
.map((e) => safe(e.path)),
|
|
730
|
+
),
|
|
731
|
+
].sort(),
|
|
732
|
+
completeness: {
|
|
733
|
+
status: completeness.status ?? 'unknown',
|
|
734
|
+
// Sanitized: analyzer messages are printed in block lines and can carry
|
|
735
|
+
// text derived from the scanned content.
|
|
736
|
+
limitations: (Array.isArray(completeness.limitations) ? completeness.limitations : []).map(safe),
|
|
737
|
+
entirelyUninspected: completeness.entirely_uninspected_files ?? 0,
|
|
738
|
+
partiallyInspected: completeness.partially_inspected_files ?? 0,
|
|
739
|
+
fullyInspected: completeness.fully_inspected_files ?? 0,
|
|
740
|
+
coveragePercent: completeness.coverage_percent ?? null,
|
|
741
|
+
},
|
|
742
|
+
// The scanner's own list of what the baseline silenced. The Security axis
|
|
743
|
+
// is told to judge whether each suppression was scoped to its cause, which
|
|
744
|
+
// it cannot do from a count — and this evidence is right here in the report.
|
|
745
|
+
suppressed: Array.isArray(report.suppressed) ? report.suppressed : [],
|
|
746
|
+
issues: (report.issues ?? []).map(normalizeIssue),
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
return base;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// The provisioning sequence, extracted so it is testable: the CLI passes the
|
|
754
|
+
// real commands, unit tests pass fakes. Returns the `skillspector` report block.
|
|
755
|
+
//
|
|
756
|
+
// `mode` is a MACHINE policy, never a per-scan-root one — see the CLI, which
|
|
757
|
+
// reads it from the profile even when --root chose what to scan.
|
|
758
|
+
export async function provisionForRun({
|
|
759
|
+
mode,
|
|
760
|
+
locate,
|
|
761
|
+
provision,
|
|
762
|
+
versionOf,
|
|
763
|
+
pathVisible,
|
|
764
|
+
fetchLatest,
|
|
765
|
+
}) {
|
|
766
|
+
const state = {
|
|
767
|
+
mode,
|
|
768
|
+
channel: null,
|
|
769
|
+
path: null,
|
|
770
|
+
version: null,
|
|
771
|
+
action: 'none',
|
|
772
|
+
before: null,
|
|
773
|
+
after: null,
|
|
774
|
+
changed: [],
|
|
775
|
+
message: '',
|
|
776
|
+
available: null,
|
|
777
|
+
resolvedOutsidePath: false,
|
|
778
|
+
};
|
|
779
|
+
|
|
780
|
+
let located = locate();
|
|
781
|
+
const result = provision({ mode, located });
|
|
782
|
+
state.action = result.action;
|
|
783
|
+
state.changed = result.changed;
|
|
784
|
+
state.message = result.message;
|
|
785
|
+
state.before = result.before;
|
|
786
|
+
state.after = result.after;
|
|
787
|
+
// An install lands somewhere only a fresh probe knows about.
|
|
788
|
+
if (result.action === 'installed') located = locate();
|
|
789
|
+
|
|
790
|
+
if (located) {
|
|
791
|
+
state.channel = located.channel;
|
|
792
|
+
state.path = located.path;
|
|
793
|
+
state.version = versionOf(located.path);
|
|
794
|
+
// A fresh install has no "before", so its "after" is simply the version now
|
|
795
|
+
// installed — otherwise the report says "(unchanged)" about a tool that was
|
|
796
|
+
// not there a moment ago.
|
|
797
|
+
if (state.action === 'installed') state.after = state.version;
|
|
798
|
+
// Located through its channel while a bare PATH lookup cannot see it. This
|
|
799
|
+
// is reported, never warned about: it is the ordinary case this feature
|
|
800
|
+
// exists to serve, and making it a warn would pin the exit code at 1
|
|
801
|
+
// forever for exactly the setup that motivated the work.
|
|
802
|
+
state.resolvedOutsidePath = !pathVisible();
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
if (mode === 'notify') state.available = await fetchLatest();
|
|
806
|
+
return state;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
810
|
+
if (invokedDirectly) {
|
|
811
|
+
const argv = process.argv.slice(2);
|
|
812
|
+
const get = (flag) => {
|
|
813
|
+
const index = argv.indexOf(flag);
|
|
814
|
+
return index === -1 ? undefined : argv[index + 1];
|
|
815
|
+
};
|
|
816
|
+
const json = argv.includes('--json');
|
|
817
|
+
// Forces this one run to leave the machine untouched, whatever the knob says.
|
|
818
|
+
const noProvision = argv.includes('--no-provision');
|
|
819
|
+
const devMdPath = get('--dev-md') || '.vegastack/dev.md';
|
|
820
|
+
|
|
821
|
+
let root = get('--root');
|
|
822
|
+
const explicitRoot = Boolean(root);
|
|
823
|
+
let skipped = false;
|
|
824
|
+
let outcome = { blocks: [], warns: [] };
|
|
825
|
+
let facts = { skills: [] };
|
|
826
|
+
let baselinePath = get('--baseline') ?? null;
|
|
827
|
+
let updateMode = 'auto';
|
|
828
|
+
let binary;
|
|
829
|
+
// Warns raised before the scan runs. `outcome` is REASSIGNED by evaluateScan,
|
|
830
|
+
// so anything pushed onto it beforehand would be silently discarded.
|
|
831
|
+
const preWarns = [];
|
|
832
|
+
const skillspector = {
|
|
833
|
+
mode: 'auto',
|
|
834
|
+
channel: null,
|
|
835
|
+
path: null,
|
|
836
|
+
version: null,
|
|
837
|
+
action: 'none',
|
|
838
|
+
before: null,
|
|
839
|
+
after: null,
|
|
840
|
+
changed: [],
|
|
841
|
+
message: '',
|
|
842
|
+
available: null,
|
|
843
|
+
resolvedOutsidePath: false,
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
// The update mode is read from the profile ALWAYS, including for --root runs.
|
|
847
|
+
// --root chooses what to scan; it never decides whether this machine may be
|
|
848
|
+
// written to. Skipping this is how `skillspector-update: off` got ignored on
|
|
849
|
+
// exactly the invocation this skill's README documents for vetting a stranger's skill.
|
|
850
|
+
{
|
|
851
|
+
let profileForMode = null;
|
|
852
|
+
try {
|
|
853
|
+
profileForMode = readFileSync(devMdPath, 'utf8');
|
|
854
|
+
} catch {
|
|
855
|
+
// An explicit --root may legitimately run outside any project.
|
|
856
|
+
}
|
|
857
|
+
if (profileForMode !== null) {
|
|
858
|
+
const declared = [...new Set(updateModeDeclarations(profileForMode))];
|
|
859
|
+
const unusable = declared.length > 1 || declared.some((value) => !UPDATE_MODES.has(value));
|
|
860
|
+
// A profile we cannot read unambiguously must not authorise writing to
|
|
861
|
+
// the machine. The non---root path below turns the same conditions into
|
|
862
|
+
// blocks; here the run continues, but touching nothing.
|
|
863
|
+
updateMode = unusable ? 'off' : resolveUpdateMode(profileForMode);
|
|
864
|
+
if (unusable && explicitRoot) {
|
|
865
|
+
preWarns.push(
|
|
866
|
+
`${devMdPath} does not give skillspector-update a single recognised value — this run left the machine untouched`,
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
if (!explicitRoot) {
|
|
873
|
+
// "Could not read the profile" and "the profile says none" are different
|
|
874
|
+
// answers. Collapsing them let the guard report a clean skip from any
|
|
875
|
+
// directory that simply has no dev.md — a gate that silently disables
|
|
876
|
+
// itself when run from the wrong cwd.
|
|
877
|
+
let devMd = null;
|
|
878
|
+
try {
|
|
879
|
+
devMd = readFileSync(devMdPath, 'utf8');
|
|
880
|
+
} catch (error) {
|
|
881
|
+
outcome.blocks.push(`cannot read ${devMdPath} (${error.code ?? error.message}) — pass --dev-md <path>, or --root to scan a directory directly`);
|
|
882
|
+
}
|
|
883
|
+
if (devMd !== null) {
|
|
884
|
+
const declared = [...new Set(scanRootDeclarations(devMd))];
|
|
885
|
+
if (declared.length > 1) {
|
|
886
|
+
outcome.blocks.push(
|
|
887
|
+
`${devMdPath} gives skill-scan conflicting values (${declared.join(', ')}) — an example line above the real knob would otherwise silently decide the gate; leave exactly one`,
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
const updateDeclared = [...new Set(updateModeDeclarations(devMd))];
|
|
891
|
+
if (updateDeclared.length > 1) {
|
|
892
|
+
outcome.blocks.push(
|
|
893
|
+
`${devMdPath} gives skillspector-update conflicting values (${updateDeclared.join(', ')}) — leave exactly one`,
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
const unknown = updateDeclared.filter((value) => !UPDATE_MODES.has(value));
|
|
897
|
+
if (unknown.length > 0) {
|
|
898
|
+
// Defaulting an unrecognised value to `auto` would silently install
|
|
899
|
+
// software because of a typo. The guard refuses instead.
|
|
900
|
+
outcome.blocks.push(
|
|
901
|
+
`${devMdPath} sets skillspector-update to ${unknown.join(', ')} — expected one of off, notify, auto`,
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
updateMode = resolveUpdateMode(devMd);
|
|
905
|
+
|
|
906
|
+
root = resolveScanRoot(devMd);
|
|
907
|
+
skipped = root === null && declared.length <= 1;
|
|
908
|
+
// The project's own suppressions apply to the project's own skills. They
|
|
909
|
+
// are NOT inherited by an ad-hoc `--root` scan of someone else's skill,
|
|
910
|
+
// where a rule written for our content could silence a real finding in
|
|
911
|
+
// theirs.
|
|
912
|
+
if (!skipped && !baselinePath && existsSync(DEFAULT_BASELINE)) baselinePath = DEFAULT_BASELINE;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
if (!skipped && outcome.blocks.length === 0) {
|
|
917
|
+
skillspector.mode = noProvision ? 'off' : updateMode;
|
|
918
|
+
|
|
919
|
+
// VSK_SKILLSPECTOR is the test seam, and it means "this exact binary" — so
|
|
920
|
+
// it suppresses locating AND provisioning. Without that, every CLI test
|
|
921
|
+
// would shell out to whatever uv/brew happen to hold on the machine running
|
|
922
|
+
// the suite, and a unit suite that installs software is not a unit suite.
|
|
923
|
+
if (!process.env.VSK_SKILLSPECTOR) {
|
|
924
|
+
Object.assign(
|
|
925
|
+
skillspector,
|
|
926
|
+
await provisionForRun({
|
|
927
|
+
mode: skillspector.mode,
|
|
928
|
+
locate: () => locateSkillspector(),
|
|
929
|
+
provision: ({ mode, located }) => provisionSkillspector({ mode, located }),
|
|
930
|
+
versionOf: (path) => readVersion({ path }),
|
|
931
|
+
pathVisible: () => defaultRun('skillspector', ['--version']).ok,
|
|
932
|
+
fetchLatest: () => latestRelease(),
|
|
933
|
+
}),
|
|
934
|
+
);
|
|
935
|
+
binary = skillspector.path ?? undefined;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// An uncaught throw would leave node exiting 1 — which in this guard's own
|
|
939
|
+
// scheme reads as "pass with warnings". A crash is not a pass.
|
|
940
|
+
try {
|
|
941
|
+
facts = gatherFacts({ root, baselinePath, llm: argv.includes('--llm'), binary });
|
|
942
|
+
facts.skillspector = skillspector;
|
|
943
|
+
outcome = evaluateScan(facts);
|
|
944
|
+
} catch (error) {
|
|
945
|
+
facts = { skills: [], skillspector };
|
|
946
|
+
outcome = { blocks: [`the scan failed unexpectedly: ${error.message}`], warns: [] };
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
outcome = { ...outcome, warns: [...preWarns, ...outcome.warns] };
|
|
951
|
+
const ok = outcome.blocks.length === 0;
|
|
952
|
+
if (json) {
|
|
953
|
+
const envelope = JSON.stringify({
|
|
954
|
+
guard: 'skill-scan',
|
|
955
|
+
ok,
|
|
956
|
+
skipped,
|
|
957
|
+
skillspector,
|
|
958
|
+
...outcome,
|
|
959
|
+
// The full normalized issue list, not a count: dev-review's Security axis
|
|
960
|
+
// is told to read the source at each finding's file:line and to judge
|
|
961
|
+
// whether a suppression was scoped to its cause. A count makes both
|
|
962
|
+
// impossible, and this report is the axis's input.
|
|
963
|
+
skills: facts.skills.map(({ name, score, severity, suppressedCount, suppressed, completeness, issues }) => ({
|
|
964
|
+
name, score, severity, suppressedCount, suppressed, completeness, findings: issues.length, issues,
|
|
965
|
+
})),
|
|
966
|
+
}, null, 2);
|
|
967
|
+
// console.log writes asynchronously when stdout is a pipe. A warning-status
|
|
968
|
+
// process can otherwise exit after the platform pipe buffer (64 KiB on macOS)
|
|
969
|
+
// accepts only a prefix, leaving machine consumers with truncated JSON.
|
|
970
|
+
writeFileSync(1, `${envelope}\n`);
|
|
971
|
+
} else if (skipped) {
|
|
972
|
+
console.log(`skill-scan: skipped — ${devMdPath} names no scan root (skill-scan: none or absent)`);
|
|
973
|
+
} else if (facts.skills.length === 0 && outcome.blocks.length > 0) {
|
|
974
|
+
console.log('skill-scan: BLOCKED');
|
|
975
|
+
for (const b of outcome.blocks) console.log(` block: ${b}`);
|
|
976
|
+
} else {
|
|
977
|
+
// The version/dependency change reads BEFORE the findings: after an
|
|
978
|
+
// upgrade, new findings are the tool having learned something, not the diff
|
|
979
|
+
// having broken something, and an operator who cannot see that debugs the
|
|
980
|
+
// wrong thing.
|
|
981
|
+
if (skillspector.action === 'installed' || skillspector.action === 'upgraded') {
|
|
982
|
+
const span = skillspector.before === skillspector.after
|
|
983
|
+
? `version ${skillspector.version ?? 'unknown'} (unchanged)`
|
|
984
|
+
: `version ${skillspector.before ?? 'none'} → ${skillspector.after ?? skillspector.version ?? 'unknown'}`;
|
|
985
|
+
console.log(`skill-scan: skillspector ${skillspector.action} via ${skillspector.channel ?? 'uv'} — ${span}`);
|
|
986
|
+
for (const line of skillspector.changed) console.log(` ${line}`);
|
|
987
|
+
}
|
|
988
|
+
if (skillspector.action === 'failed') {
|
|
989
|
+
console.log(`skill-scan: skillspector update failed, continuing with the installed copy — ${skillspector.message}`);
|
|
990
|
+
}
|
|
991
|
+
if (skillspector.available && skillspector.available !== skillspector.version) {
|
|
992
|
+
console.log(`skill-scan: skillspector ${skillspector.available} is available (installed: ${skillspector.version ?? 'unknown'})`);
|
|
993
|
+
}
|
|
994
|
+
console.log(`skill-scan: ${ok ? (outcome.warns.length ? 'pass with warnings' : 'pass') : 'BLOCKED'}`);
|
|
995
|
+
for (const entry of facts.skills) {
|
|
996
|
+
console.log(` ${entry.name}: score ${entry.score} ${entry.severity} — ${entry.issues.length} finding(s)`);
|
|
997
|
+
}
|
|
998
|
+
for (const b of outcome.blocks) console.log(` block: ${b}`);
|
|
999
|
+
for (const w of outcome.warns) console.log(` warn: ${w}`);
|
|
1000
|
+
}
|
|
1001
|
+
process.exit(ok ? (outcome.warns.length > 0 ? 1 : 0) : 2);
|
|
1002
|
+
}
|