@lifeaitools/rdc-skills 0.21.0 → 0.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +18 -2
- package/.github/workflows/self-test.yml +34 -34
- package/commands/build.md +181 -181
- package/commands/collab.md +180 -180
- package/commands/deploy.md +148 -148
- package/commands/fixit.md +105 -105
- package/commands/handoff.md +173 -173
- package/commands/overnight.md +218 -218
- package/commands/plan.md +158 -158
- package/commands/preplan.md +131 -131
- package/commands/prototype.md +145 -145
- package/commands/report.md +99 -99
- package/commands/review.md +120 -120
- package/commands/status.md +86 -86
- package/commands/workitems.md +127 -127
- package/git-sha.json +1 -1
- package/guides/agent-bootstrap.md +195 -195
- package/guides/agents/backend.md +102 -102
- package/guides/agents/content.md +94 -94
- package/guides/agents/cs2.md +56 -56
- package/guides/agents/data.md +86 -86
- package/guides/agents/design.md +77 -77
- package/guides/agents/frontend.md +91 -91
- package/guides/agents/infrastructure.md +81 -81
- package/guides/agents/setup.md +272 -272
- package/guides/agents/verify.md +119 -119
- package/guides/agents/viz.md +106 -106
- package/package.json +1 -1
- package/scripts/self-test.mjs +1458 -1458
- package/skills/build/SKILL.md +478 -478
- package/skills/channel-formatter/SKILL.md +14 -1
- package/skills/collab/SKILL.md +239 -239
- package/skills/deploy/SKILL.md +522 -522
- package/skills/design/SKILL.md +205 -205
- package/skills/fixit/SKILL.md +165 -165
- package/skills/handoff/SKILL.md +200 -200
- package/skills/overnight/SKILL.md +230 -230
- package/skills/plan/SKILL.md +274 -274
- package/skills/preplan/SKILL.md +90 -90
- package/skills/prototype/SKILL.md +150 -150
- package/skills/release/SKILL.md +140 -140
- package/skills/report/SKILL.md +100 -100
- package/skills/review/SKILL.md +152 -152
- package/skills/self-test/SKILL.md +123 -123
- package/skills/status/SKILL.md +99 -99
- package/skills/watch/SKILL.md +90 -90
- package/skills/workitems/SKILL.md +151 -151
package/scripts/self-test.mjs
CHANGED
|
@@ -1,1458 +1,1458 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// self-test.mjs — Tier 1 static linter for rdc-skills
|
|
3
|
-
//
|
|
4
|
-
// Validates every skill file in skills/ against a checklist:
|
|
5
|
-
// 1. File parseable (readable, has frontmatter)
|
|
6
|
-
// 2. Frontmatter YAML valid (name + description required)
|
|
7
|
-
// 3. description does NOT start with backtick (breaks Claude Code parser)
|
|
8
|
-
// 4. description contains a `Usage `rdc:<name>` marker matching this skill
|
|
9
|
-
// 5. frontmatter name matches filename (rdc:foo ↔ rdc-foo.md)
|
|
10
|
-
// 6. Every referenced guide file exists (guides/*.md references)
|
|
11
|
-
// 7. Every referenced rule file exists (.claude/rules/*.md references)
|
|
12
|
-
// 8. Body contains the standard output-contract banner
|
|
13
|
-
// 9. Plugin manifest (.claude-plugin/plugin.json) exists, parses, has name+version,
|
|
14
|
-
// and version matches package.json
|
|
15
|
-
// 10. No duplicate skill names; no filename collisions with guides/agents/*.md
|
|
16
|
-
// 11. Hook files referenced by skills exist; orphan hook files get a warning
|
|
17
|
-
//
|
|
18
|
-
// Usage:
|
|
19
|
-
// node scripts/self-test.mjs # run all, human output, exit 1 on fail
|
|
20
|
-
// node scripts/self-test.mjs --json # machine-readable schema v2
|
|
21
|
-
// node scripts/self-test.mjs --skill rdc:foo # single skill
|
|
22
|
-
// node scripts/self-test.mjs --strict # warnings become failures
|
|
23
|
-
// node scripts/self-test.mjs --fix # auto-repair fixable findings
|
|
24
|
-
//
|
|
25
|
-
// Exit codes:
|
|
26
|
-
// 0 = all pass
|
|
27
|
-
// 1 = at least one skill/guide failure
|
|
28
|
-
// 2 = runner crashed (unreadable dirs, etc.)
|
|
29
|
-
// 3 = plugin manifest missing (distinct from skill failures)
|
|
30
|
-
|
|
31
|
-
import { readFileSync, readdirSync, existsSync, writeFileSync, appendFileSync, renameSync, mkdirSync, statSync } from "node:fs";
|
|
32
|
-
import { join, basename, dirname, resolve } from "node:path";
|
|
33
|
-
import { fileURLToPath } from "node:url";
|
|
34
|
-
import { spawnSync } from "node:child_process";
|
|
35
|
-
import { loadAllManifests } from "./lib/manifest-schema.mjs";
|
|
36
|
-
import { runManifest } from "./lib/runner.mjs";
|
|
37
|
-
import {
|
|
38
|
-
generateRunId,
|
|
39
|
-
resolveSandboxRef,
|
|
40
|
-
deleteSupabaseTestBranch,
|
|
41
|
-
removeWorktree,
|
|
42
|
-
} from "./lib/sandbox.mjs";
|
|
43
|
-
|
|
44
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
45
|
-
const REPO_ROOT = resolve(__dirname, "..");
|
|
46
|
-
const SKILLS_DIR = join(REPO_ROOT, "skills");
|
|
47
|
-
const GUIDES_DIR = join(REPO_ROOT, "guides");
|
|
48
|
-
const AGENT_GUIDES_DIR = join(GUIDES_DIR, "agents");
|
|
49
|
-
const HOOKS_DIR = join(REPO_ROOT, "hooks");
|
|
50
|
-
const PLUGIN_MANIFEST = join(REPO_ROOT, ".claude-plugin", "plugin.json");
|
|
51
|
-
const PACKAGE_JSON = join(REPO_ROOT, "package.json");
|
|
52
|
-
|
|
53
|
-
// ─── Guide-content validator constants ───────────────────────────────────────
|
|
54
|
-
// Path to the regen-root project (sibling of rdc-skills).
|
|
55
|
-
// Override with REGEN_ROOT env var when running from a non-standard location.
|
|
56
|
-
const REGEN_ROOT = process.env.REGEN_ROOT || "C:/Dev/regen-root";
|
|
57
|
-
|
|
58
|
-
// Terms that must NOT appear in guide/rule files as positive instructions.
|
|
59
|
-
// A line is flagged only when it does NOT contain a known negation pattern.
|
|
60
|
-
const GUIDE_BANNED_TERMS = [
|
|
61
|
-
"@masonator/coolify-mcp",
|
|
62
|
-
"@masonator",
|
|
63
|
-
"coolify-mcp",
|
|
64
|
-
"@regen/brand-studio",
|
|
65
|
-
"brand-studio",
|
|
66
|
-
];
|
|
67
|
-
|
|
68
|
-
// Negation patterns — if a line contains one of these, the banned-term
|
|
69
|
-
// occurrence is an explicit "don't use" warning and should NOT be flagged.
|
|
70
|
-
const GUIDE_NEGATION_PATTERNS = [
|
|
71
|
-
/\bdo not\b/i,
|
|
72
|
-
/\bnever\b/i,
|
|
73
|
-
/\bno such\b/i,
|
|
74
|
-
/\bdoes not exist\b/i,
|
|
75
|
-
/\bbanned\b/i,
|
|
76
|
-
/\bnot reference\b/i,
|
|
77
|
-
/\bnot use\b/i,
|
|
78
|
-
/\bavoid\b/i,
|
|
79
|
-
/\bremoved\b/i,
|
|
80
|
-
/\bdeprecated\b/i,
|
|
81
|
-
// Markdown table row showing a WRONG→CORRECT mapping (naming-corrections.md pattern)
|
|
82
|
-
/^\|[^|]*WRONG[^|]*\|/i,
|
|
83
|
-
// A table row where the term is in the WRONG column (first data column after the | WRONG | header)
|
|
84
|
-
// Heuristic: line starts with | and the term appears before the first CORRECT value
|
|
85
|
-
/^\|\s*(Brand Studio|brand-studio|@regen\/brand-studio|@masonator[^ |]*|coolify-mcp)[^|]*\|\s*\*\*/,
|
|
86
|
-
];
|
|
87
|
-
|
|
88
|
-
// Known valid clauth key names. Extend when new keys are added to the vault.
|
|
89
|
-
// Source: C:/Dev/regen-root/.rdc/guides/agent-bootstrap.md credential table
|
|
90
|
-
// + C:/Dev/regen-root/.claude/rules/clauth-endpoints.md
|
|
91
|
-
const KNOWN_CLAUTH_KEYS = new Set([
|
|
92
|
-
"coolify-api",
|
|
93
|
-
"cloudflare",
|
|
94
|
-
"npm",
|
|
95
|
-
"supabase",
|
|
96
|
-
"supabase-anon",
|
|
97
|
-
"supabase-db",
|
|
98
|
-
"r2-access-key-id",
|
|
99
|
-
"r2-secret-key",
|
|
100
|
-
"anthropic",
|
|
101
|
-
"openai",
|
|
102
|
-
"github",
|
|
103
|
-
"github-token",
|
|
104
|
-
"vultr-dev-ssh",
|
|
105
|
-
"mcp-web-research-secret",
|
|
106
|
-
"mcp-regen-media-secret",
|
|
107
|
-
"web-research",
|
|
108
|
-
"regen-media",
|
|
109
|
-
]);
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Audit a single guide or rule file for banned terms and clauth key validity.
|
|
113
|
-
*
|
|
114
|
-
* Returns an array of finding objects:
|
|
115
|
-
* { level: 'error'|'warn', code: string, file: string, line: number, message: string }
|
|
116
|
-
*/
|
|
117
|
-
function auditGuideFile(filepath, relPath) {
|
|
118
|
-
const findings = [];
|
|
119
|
-
let text;
|
|
120
|
-
try {
|
|
121
|
-
text = readFileSync(filepath, "utf8");
|
|
122
|
-
} catch (e) {
|
|
123
|
-
findings.push({ level: "error", code: "guide-unreadable", file: relPath, line: 0, message: `cannot read: ${e.message}` });
|
|
124
|
-
return findings;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const lines = text.split(/\r?\n/);
|
|
128
|
-
|
|
129
|
-
if (
|
|
130
|
-
relPath === "guides/agent-bootstrap.md" &&
|
|
131
|
-
!text.includes("guides/engineering-behavior.md")
|
|
132
|
-
) {
|
|
133
|
-
findings.push({
|
|
134
|
-
level: "error",
|
|
135
|
-
code: "guide-engineering-behavior-missing",
|
|
136
|
-
file: relPath,
|
|
137
|
-
line: 1,
|
|
138
|
-
message: "agent bootstrap must reference guides/engineering-behavior.md",
|
|
139
|
-
});
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
lines.forEach((line, i) => {
|
|
143
|
-
const lineNo = i + 1;
|
|
144
|
-
|
|
145
|
-
// Check banned terms (skip negation lines and comment lines)
|
|
146
|
-
for (const term of GUIDE_BANNED_TERMS) {
|
|
147
|
-
if (line.includes(term)) {
|
|
148
|
-
const isNegated = GUIDE_NEGATION_PATTERNS.some((re) => re.test(line));
|
|
149
|
-
if (!isNegated) {
|
|
150
|
-
findings.push({
|
|
151
|
-
level: "error",
|
|
152
|
-
code: "guide-banned-term",
|
|
153
|
-
file: relPath,
|
|
154
|
-
line: lineNo,
|
|
155
|
-
message: `banned term "${term}" found as positive instruction — replace with clauth+REST pattern`,
|
|
156
|
-
});
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Check clauth /v/<key> references — flag unknown key names
|
|
162
|
-
// Pattern: /v/some-key-name (standalone path, not inside a larger URL path)
|
|
163
|
-
const clauthKeyRe = /\bhttp:\/\/127\.0\.0\.1:52437\/v\/([\w-]+)/g;
|
|
164
|
-
let m;
|
|
165
|
-
while ((m = clauthKeyRe.exec(line)) !== null) {
|
|
166
|
-
const key = m[1];
|
|
167
|
-
if (!KNOWN_CLAUTH_KEYS.has(key)) {
|
|
168
|
-
findings.push({
|
|
169
|
-
level: "warn",
|
|
170
|
-
code: "guide-clauth-key-unknown",
|
|
171
|
-
file: relPath,
|
|
172
|
-
line: lineNo,
|
|
173
|
-
message: `clauth key "${key}" not in known key list — verify it exists in the vault`,
|
|
174
|
-
});
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
return findings;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Run the guide-content validator across:
|
|
184
|
-
* 1. C:/Dev/rdc-skills/guides/**\/*.md (base guides shipped with the plugin)
|
|
185
|
-
* 2. REGEN_ROOT/.rdc/guides/**\/*.md (project-level agent guides)
|
|
186
|
-
* 3. REGEN_ROOT/.claude/rules/**\/*.md (project auto-loaded rules)
|
|
187
|
-
*
|
|
188
|
-
* Also scans fixture guides under scripts/fixtures/guides/ when GUIDE_FIXTURE_DIR is set
|
|
189
|
-
* (used by the self-test's own test suite).
|
|
190
|
-
*
|
|
191
|
-
* Returns { findings[], ok, stats }
|
|
192
|
-
*/
|
|
193
|
-
function runGuideContentValidator({ fixtureDir } = {}) {
|
|
194
|
-
const allFindings = [];
|
|
195
|
-
let filesScanned = 0;
|
|
196
|
-
|
|
197
|
-
function scanDir(dir, prefix) {
|
|
198
|
-
if (!existsSync(dir)) return;
|
|
199
|
-
let entries;
|
|
200
|
-
try { entries = readdirSync(dir); } catch { return; }
|
|
201
|
-
for (const entry of entries) {
|
|
202
|
-
const fullPath = join(dir, entry);
|
|
203
|
-
let stat;
|
|
204
|
-
try { stat = statSync(fullPath); } catch { continue; }
|
|
205
|
-
if (stat.isDirectory()) {
|
|
206
|
-
scanDir(fullPath, `${prefix}/${entry}`);
|
|
207
|
-
} else if (entry.endsWith(".md")) {
|
|
208
|
-
const relPath = `${prefix}/${entry}`;
|
|
209
|
-
const findings = auditGuideFile(fullPath, relPath);
|
|
210
|
-
allFindings.push(...findings);
|
|
211
|
-
filesScanned++;
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// 1. Base guides in rdc-skills repo
|
|
217
|
-
scanDir(GUIDES_DIR, "guides");
|
|
218
|
-
|
|
219
|
-
// 2. Regen-root agent guides
|
|
220
|
-
scanDir(join(REGEN_ROOT, ".rdc", "guides"), "regen-root/.rdc/guides");
|
|
221
|
-
|
|
222
|
-
// 3. Regen-root auto-loaded rules
|
|
223
|
-
scanDir(join(REGEN_ROOT, ".claude", "rules"), "regen-root/.claude/rules");
|
|
224
|
-
|
|
225
|
-
// 4. Fixture directory (for self-test's own test assertions)
|
|
226
|
-
if (fixtureDir && existsSync(fixtureDir)) {
|
|
227
|
-
scanDir(fixtureDir, "fixtures");
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
const errors = allFindings.filter((f) => f.level === "error");
|
|
231
|
-
const warnings = allFindings.filter((f) => f.level === "warn");
|
|
232
|
-
|
|
233
|
-
return {
|
|
234
|
-
findings: allFindings,
|
|
235
|
-
errors,
|
|
236
|
-
warnings,
|
|
237
|
-
ok: errors.length === 0,
|
|
238
|
-
stats: { files_scanned: filesScanned, errors: errors.length, warnings: warnings.length },
|
|
239
|
-
};
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
const STANDARD_BANNER = [
|
|
243
|
-
"> **⚠️ OUTPUT CONTRACT (READ FIRST):** `guides/output-contract.md`",
|
|
244
|
-
"> Checklist-only output. No tool-call narration. No raw MCP/JSON/log dumps.",
|
|
245
|
-
"> One checklist upfront, updated in place, shown again at end with a 1-line verdict.",
|
|
246
|
-
"",
|
|
247
|
-
"",
|
|
248
|
-
].join("\n");
|
|
249
|
-
|
|
250
|
-
const argv = process.argv.slice(2);
|
|
251
|
-
const args = new Set(argv);
|
|
252
|
-
const STRICT = args.has("--strict");
|
|
253
|
-
const JSON_OUT = args.has("--json");
|
|
254
|
-
const FIX = args.has("--fix");
|
|
255
|
-
const TIER2 = args.has("--tier2");
|
|
256
|
-
const QUICK = args.has("--quick");
|
|
257
|
-
const ONLY_SKILLS = [];
|
|
258
|
-
for (let i = 0; i < argv.length; i++) {
|
|
259
|
-
if (argv[i] === "--skill" && argv[i + 1]) {
|
|
260
|
-
for (const s of argv[i + 1].split(",")) {
|
|
261
|
-
const t = s.trim();
|
|
262
|
-
if (t) ONLY_SKILLS.push(t);
|
|
263
|
-
}
|
|
264
|
-
i++;
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
const ONLY_SKILL = ONLY_SKILLS.length === 1 ? ONLY_SKILLS[0] : null; // backwards-compat for single-skill Tier 1 path
|
|
268
|
-
const parallelArgIdx = argv.indexOf("--parallel");
|
|
269
|
-
const PARALLEL = parallelArgIdx >= 0 ? Math.max(1, parseInt(argv[parallelArgIdx + 1], 10) || 3) : 3;
|
|
270
|
-
const logArgIdx = argv.indexOf("--log");
|
|
271
|
-
const LIVE_LOG = logArgIdx >= 0 ? argv[logArgIdx + 1] : null;
|
|
272
|
-
|
|
273
|
-
/** Append a timestamped line to the live log file (if --log was passed). */
|
|
274
|
-
function liveLog(kind, msg) {
|
|
275
|
-
if (!LIVE_LOG) return;
|
|
276
|
-
const ts = new Date().toISOString();
|
|
277
|
-
try { appendFileSync(LIVE_LOG, `[${ts}] [${kind}] ${msg}\n`); } catch {}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// Track files modified by --fix so caller can git diff
|
|
281
|
-
const FIXED_FILES = [];
|
|
282
|
-
|
|
283
|
-
function escapeRegExp(s) {
|
|
284
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
function parseFrontmatter(text) {
|
|
288
|
-
text = text.replace(/\r\n/g, "\n");
|
|
289
|
-
const m = text.match(/^---\n([\s\S]*?)\n---\n?/);
|
|
290
|
-
if (!m) return { error: "no frontmatter block" };
|
|
291
|
-
const raw = m[1];
|
|
292
|
-
const body = text.slice(m[0].length);
|
|
293
|
-
const fmEnd = m[0].length;
|
|
294
|
-
|
|
295
|
-
const nameMatch = raw.match(/^name:\s*(.+?)\s*$/m);
|
|
296
|
-
if (!nameMatch) return { error: "frontmatter missing `name:`" };
|
|
297
|
-
const name = nameMatch[1].trim();
|
|
298
|
-
|
|
299
|
-
let description = null;
|
|
300
|
-
let descStartsWithBacktick = false;
|
|
301
|
-
|
|
302
|
-
const plainDesc = raw.match(/^description:[ \t]+([^\n>| \t].*)$/m);
|
|
303
|
-
const foldedDesc = raw.match(/^description:[ \t]*>[-+]?[ \t]*\n((?:[ \t]+.*(?:\n|$))+)/m);
|
|
304
|
-
const literalDesc = raw.match(/^description:[ \t]*\|[-+]?[ \t]*\n((?:[ \t]+.*(?:\n|$))+)/m);
|
|
305
|
-
|
|
306
|
-
if (plainDesc) {
|
|
307
|
-
description = plainDesc[1].trim();
|
|
308
|
-
descStartsWithBacktick = description.startsWith("`");
|
|
309
|
-
} else if (foldedDesc || literalDesc) {
|
|
310
|
-
const block = (foldedDesc || literalDesc)[1];
|
|
311
|
-
const lines = block
|
|
312
|
-
.split(/\n/)
|
|
313
|
-
.map((l) => l.replace(/^[ \t]+/, ""))
|
|
314
|
-
.filter((l) => l.length > 0);
|
|
315
|
-
description = lines.join(foldedDesc ? " " : "\n").trim();
|
|
316
|
-
const firstLine = lines[0] || "";
|
|
317
|
-
descStartsWithBacktick = firstLine.startsWith("`");
|
|
318
|
-
} else {
|
|
319
|
-
return { error: "frontmatter missing `description:`" };
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
return { name, description, descStartsWithBacktick, body, raw, fmEnd };
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
function expectedSkillName(dirOrFile) {
|
|
326
|
-
// Accept current directory names (foo), legacy directory names
|
|
327
|
-
// (rdc-foo), or legacy filenames (rdc-foo.md).
|
|
328
|
-
const base = basename(dirOrFile, ".md");
|
|
329
|
-
if (base.startsWith("rdc-")) return "rdc:" + base.slice(4);
|
|
330
|
-
if (base === "tests" || base.startsWith(".")) return null;
|
|
331
|
-
return "rdc:" + base;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
function expectedFrontmatterName(dirOrFile, frontmatterName) {
|
|
335
|
-
const base = basename(dirOrFile, ".md");
|
|
336
|
-
if (frontmatterName && frontmatterName.startsWith("rdc:")) {
|
|
337
|
-
return expectedSkillName(dirOrFile);
|
|
338
|
-
}
|
|
339
|
-
return base;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
function findReferencedFiles(body) {
|
|
343
|
-
const refs = [];
|
|
344
|
-
const guideRe = /(?:^|[\s(`'"/])(?:\.rdc\/)?guides\/([\w/-]+\.md)/g;
|
|
345
|
-
let m;
|
|
346
|
-
while ((m = guideRe.exec(body)) !== null) {
|
|
347
|
-
refs.push({ kind: "guide", name: m[1] });
|
|
348
|
-
}
|
|
349
|
-
const ruleRe = /\.claude\/rules\/([\w-]+\.md)/g;
|
|
350
|
-
while ((m = ruleRe.exec(body)) !== null) {
|
|
351
|
-
refs.push({ kind: "rule", name: m[1] });
|
|
352
|
-
}
|
|
353
|
-
const hookRe = /(?:^|[\s(`'"/])hooks\/([\w.-]+\.(?:js|mjs|cjs))/g;
|
|
354
|
-
while ((m = hookRe.exec(body)) !== null) {
|
|
355
|
-
refs.push({ kind: "hook", name: m[1] });
|
|
356
|
-
}
|
|
357
|
-
return refs;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
function addFinding(result, level, code, message) {
|
|
361
|
-
result.findings.push({ level, code, message });
|
|
362
|
-
if (level === "error") result.errors.push(message);
|
|
363
|
-
else result.warnings.push(message);
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
function tryAutoFix(filepath, fm, text, result) {
|
|
367
|
-
if (!FIX) return false;
|
|
368
|
-
let changed = false;
|
|
369
|
-
let newText = text;
|
|
370
|
-
let newPath = filepath;
|
|
371
|
-
|
|
372
|
-
// Fix: insert OUTPUT CONTRACT banner after frontmatter if missing
|
|
373
|
-
if (!/OUTPUT CONTRACT/.test(fm.body)) {
|
|
374
|
-
const fmBlock = newText.slice(0, fm.fmEnd);
|
|
375
|
-
const rest = newText.slice(fm.fmEnd).replace(/^\n+/, "");
|
|
376
|
-
newText = fmBlock + "\n" + STANDARD_BANNER + rest;
|
|
377
|
-
writeFileSync(filepath, newText);
|
|
378
|
-
console.log(`FIXED: ${result.name || result.file} — inserted OUTPUT CONTRACT banner`);
|
|
379
|
-
FIXED_FILES.push(filepath);
|
|
380
|
-
changed = true;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
// Fix: filename/name mismatch — rename directory or file to match frontmatter name
|
|
384
|
-
const _filename = basename(filepath);
|
|
385
|
-
const _dirName = basename(dirname(filepath));
|
|
386
|
-
const _isSubdir = _filename === "SKILL.md";
|
|
387
|
-
const _skillDirOrFile = _isSubdir ? _dirName : _filename;
|
|
388
|
-
const expected = expectedSkillName(_skillDirOrFile);
|
|
389
|
-
if (expected && fm.name !== expected && fm.name.startsWith("rdc:")) {
|
|
390
|
-
if (_isSubdir) {
|
|
391
|
-
// Subdirectory layout: rename the parent directory
|
|
392
|
-
const targetDirName = "rdc-" + fm.name.slice(4);
|
|
393
|
-
const targetDirPath = join(dirname(dirname(filepath)), targetDirName);
|
|
394
|
-
if (!existsSync(targetDirPath)) {
|
|
395
|
-
renameSync(dirname(filepath), targetDirPath);
|
|
396
|
-
const targetPath = join(targetDirPath, "SKILL.md");
|
|
397
|
-
console.log(`FIXED: ${fm.name} — renamed dir ${_dirName} → ${targetDirName}`);
|
|
398
|
-
FIXED_FILES.push(targetPath);
|
|
399
|
-
newPath = targetPath;
|
|
400
|
-
changed = true;
|
|
401
|
-
}
|
|
402
|
-
} else {
|
|
403
|
-
const targetBase = "rdc-" + fm.name.slice(4) + ".md";
|
|
404
|
-
const targetPath = join(dirname(filepath), targetBase);
|
|
405
|
-
if (!existsSync(targetPath)) {
|
|
406
|
-
renameSync(filepath, targetPath);
|
|
407
|
-
console.log(`FIXED: ${fm.name} — renamed ${_filename} → ${targetBase}`);
|
|
408
|
-
FIXED_FILES.push(targetPath);
|
|
409
|
-
newPath = targetPath;
|
|
410
|
-
changed = true;
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
return changed ? newPath : false;
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
function auditSkill(filepath) {
|
|
419
|
-
const filename = basename(filepath);
|
|
420
|
-
// Support both flat (rdc-foo.md) and subdirectory (rdc-foo/SKILL.md) layouts
|
|
421
|
-
const dirName = basename(dirname(filepath)); // "rdc-foo" when filepath is .../rdc-foo/SKILL.md
|
|
422
|
-
const isSubdir = filename === "SKILL.md";
|
|
423
|
-
const skillDirOrFile = isSubdir ? dirName : filename;
|
|
424
|
-
const result = {
|
|
425
|
-
skill: null,
|
|
426
|
-
file: isSubdir ? `skills/${dirName}/SKILL.md` : `skills/${filename}`,
|
|
427
|
-
name: null,
|
|
428
|
-
pass: true,
|
|
429
|
-
errors: [],
|
|
430
|
-
warnings: [],
|
|
431
|
-
findings: [],
|
|
432
|
-
};
|
|
433
|
-
|
|
434
|
-
let text;
|
|
435
|
-
try {
|
|
436
|
-
text = readFileSync(filepath, "utf8");
|
|
437
|
-
} catch (e) {
|
|
438
|
-
addFinding(result, "error", "unreadable", `cannot read file: ${e.message}`);
|
|
439
|
-
result.pass = false;
|
|
440
|
-
return result;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
const fm = parseFrontmatter(text);
|
|
444
|
-
if (fm.error) {
|
|
445
|
-
addFinding(result, "error", "frontmatter-invalid", `frontmatter: ${fm.error}`);
|
|
446
|
-
result.pass = false;
|
|
447
|
-
return result;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
result.name = fm.name;
|
|
451
|
-
result.skill = fm.name;
|
|
452
|
-
|
|
453
|
-
if (fm.descStartsWithBacktick) {
|
|
454
|
-
addFinding(
|
|
455
|
-
result,
|
|
456
|
-
"error",
|
|
457
|
-
"description-backtick-leading",
|
|
458
|
-
"description starts with backtick — Claude Code parser will silently drop this skill from the menu",
|
|
459
|
-
);
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
// Usage marker — must reference this skill's own name
|
|
463
|
-
if (fm.name.startsWith("rdc:")) {
|
|
464
|
-
const escapedName = escapeRegExp(fm.name);
|
|
465
|
-
const usageRe = new RegExp("Usage\\s+`" + escapedName + "[ \\\\`]", "i");
|
|
466
|
-
const anyUsageRe = /\bUsage\s+`/i;
|
|
467
|
-
if (!anyUsageRe.test(fm.description)) {
|
|
468
|
-
addFinding(
|
|
469
|
-
result,
|
|
470
|
-
"warn",
|
|
471
|
-
"usage-marker-missing",
|
|
472
|
-
"description missing `Usage \\`rdc:name <args>\\`` marker — users can't see arg contract in menu",
|
|
473
|
-
);
|
|
474
|
-
} else if (!usageRe.test(fm.description)) {
|
|
475
|
-
addFinding(
|
|
476
|
-
result,
|
|
477
|
-
"warn",
|
|
478
|
-
"usage-marker-mismatch",
|
|
479
|
-
`Usage marker in description does not reference own name "${fm.name}" — copy-paste drift?`,
|
|
480
|
-
);
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
const expected = expectedFrontmatterName(skillDirOrFile, fm.name);
|
|
485
|
-
if (expected && fm.name !== expected) {
|
|
486
|
-
addFinding(
|
|
487
|
-
result,
|
|
488
|
-
"error",
|
|
489
|
-
"name-filename-mismatch",
|
|
490
|
-
`name mismatch: frontmatter says "${fm.name}" but filename implies "${expected}"`,
|
|
491
|
-
);
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
const refs = findReferencedFiles(fm.body);
|
|
495
|
-
const seen = new Set();
|
|
496
|
-
for (const ref of refs) {
|
|
497
|
-
const key = `${ref.kind}:${ref.name}`;
|
|
498
|
-
if (seen.has(key)) continue;
|
|
499
|
-
seen.add(key);
|
|
500
|
-
if (ref.kind === "guide") {
|
|
501
|
-
if (!existsSync(join(GUIDES_DIR, ref.name))) {
|
|
502
|
-
addFinding(
|
|
503
|
-
result,
|
|
504
|
-
"warn",
|
|
505
|
-
"guide-not-found",
|
|
506
|
-
`referenced guide not found in repo: guides/${ref.name}`,
|
|
507
|
-
);
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
if (ref.kind === "rule") {
|
|
511
|
-
const regenRoot = "C:/Dev/regen-root/.claude/rules";
|
|
512
|
-
if (existsSync(regenRoot) && !existsSync(join(regenRoot, ref.name))) {
|
|
513
|
-
addFinding(
|
|
514
|
-
result,
|
|
515
|
-
"warn",
|
|
516
|
-
"rule-not-found",
|
|
517
|
-
`referenced rule not found in regen-root: .claude/rules/${ref.name}`,
|
|
518
|
-
);
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
if (ref.kind === "hook") {
|
|
522
|
-
if (!existsSync(join(HOOKS_DIR, ref.name))) {
|
|
523
|
-
addFinding(
|
|
524
|
-
result,
|
|
525
|
-
"error",
|
|
526
|
-
"hook-not-found",
|
|
527
|
-
`referenced hook file not found: hooks/${ref.name}`,
|
|
528
|
-
);
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
if (!/OUTPUT CONTRACT/.test(fm.body)) {
|
|
534
|
-
addFinding(
|
|
535
|
-
result,
|
|
536
|
-
"warn",
|
|
537
|
-
"banner-missing",
|
|
538
|
-
"body missing OUTPUT CONTRACT banner (guides/output-contract.md reference)",
|
|
539
|
-
);
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
// Try auto-fix, then re-audit the same skill once to reflect new state
|
|
543
|
-
if (FIX && (result.errors.length > 0 || result.warnings.length > 0)) {
|
|
544
|
-
const newPath = tryAutoFix(filepath, fm, text, result);
|
|
545
|
-
if (newPath) return auditSkill(newPath);
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
result.pass = result.errors.length === 0;
|
|
549
|
-
return result;
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
function auditAgentGuide(filepath) {
|
|
553
|
-
const filename = basename(filepath);
|
|
554
|
-
const result = {
|
|
555
|
-
file: `guides/agents/${filename}`,
|
|
556
|
-
name: filename,
|
|
557
|
-
pass: true,
|
|
558
|
-
errors: [],
|
|
559
|
-
warnings: [],
|
|
560
|
-
findings: [],
|
|
561
|
-
};
|
|
562
|
-
let text;
|
|
563
|
-
try {
|
|
564
|
-
text = readFileSync(filepath, "utf8");
|
|
565
|
-
} catch (e) {
|
|
566
|
-
addFinding(result, "error", "unreadable", `cannot read file: ${e.message}`);
|
|
567
|
-
result.pass = false;
|
|
568
|
-
return result;
|
|
569
|
-
}
|
|
570
|
-
if (text.replace(/\r\n/g, "\n").startsWith("---\n")) {
|
|
571
|
-
addFinding(
|
|
572
|
-
result,
|
|
573
|
-
"error",
|
|
574
|
-
"agent-guide-has-frontmatter",
|
|
575
|
-
"agent guide still has frontmatter — should be plain markdown",
|
|
576
|
-
);
|
|
577
|
-
}
|
|
578
|
-
if (!/OUTPUT CONTRACT/.test(text)) {
|
|
579
|
-
addFinding(result, "warn", "banner-missing", "body missing OUTPUT CONTRACT banner");
|
|
580
|
-
}
|
|
581
|
-
if (text.trim().length === 0) {
|
|
582
|
-
addFinding(result, "error", "empty-file", "file is empty");
|
|
583
|
-
}
|
|
584
|
-
result.pass = result.errors.length === 0;
|
|
585
|
-
return result;
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
// Plugin manifest check. Returns { ok, exitCode, findings }
|
|
589
|
-
function auditPluginManifest() {
|
|
590
|
-
const findings = [];
|
|
591
|
-
if (!existsSync(PLUGIN_MANIFEST)) {
|
|
592
|
-
findings.push({
|
|
593
|
-
level: "error",
|
|
594
|
-
code: "manifest-missing",
|
|
595
|
-
message: `.claude-plugin/plugin.json not found`,
|
|
596
|
-
});
|
|
597
|
-
return { ok: false, exitCode: 3, findings, manifest: null };
|
|
598
|
-
}
|
|
599
|
-
let manifest;
|
|
600
|
-
try {
|
|
601
|
-
manifest = JSON.parse(readFileSync(PLUGIN_MANIFEST, "utf8"));
|
|
602
|
-
} catch (e) {
|
|
603
|
-
findings.push({
|
|
604
|
-
level: "error",
|
|
605
|
-
code: "manifest-invalid-json",
|
|
606
|
-
message: `plugin.json invalid JSON: ${e.message}`,
|
|
607
|
-
});
|
|
608
|
-
return { ok: false, exitCode: 1, findings, manifest: null };
|
|
609
|
-
}
|
|
610
|
-
if (!manifest.name) {
|
|
611
|
-
findings.push({ level: "error", code: "manifest-missing-name", message: "plugin.json missing `name`" });
|
|
612
|
-
}
|
|
613
|
-
if (!manifest.version) {
|
|
614
|
-
findings.push({
|
|
615
|
-
level: "error",
|
|
616
|
-
code: "manifest-missing-version",
|
|
617
|
-
message: "plugin.json missing `version`",
|
|
618
|
-
});
|
|
619
|
-
}
|
|
620
|
-
try {
|
|
621
|
-
const pkg = JSON.parse(readFileSync(PACKAGE_JSON, "utf8"));
|
|
622
|
-
if (manifest.version && pkg.version && manifest.version !== pkg.version) {
|
|
623
|
-
findings.push({
|
|
624
|
-
level: "error",
|
|
625
|
-
code: "manifest-version-mismatch",
|
|
626
|
-
message: `plugin.json version "${manifest.version}" ≠ package.json version "${pkg.version}"`,
|
|
627
|
-
});
|
|
628
|
-
}
|
|
629
|
-
} catch (e) {
|
|
630
|
-
findings.push({
|
|
631
|
-
level: "warn",
|
|
632
|
-
code: "package-json-unreadable",
|
|
633
|
-
message: `could not read package.json for version cross-check: ${e.message}`,
|
|
634
|
-
});
|
|
635
|
-
}
|
|
636
|
-
const ok = findings.every((f) => f.level !== "error");
|
|
637
|
-
return { ok, exitCode: ok ? 0 : 1, findings, manifest };
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
// Duplicate skill name + filename collision check
|
|
641
|
-
function auditDuplicates(results) {
|
|
642
|
-
const findings = [];
|
|
643
|
-
const nameMap = new Map();
|
|
644
|
-
for (const r of results) {
|
|
645
|
-
if (!r.name) continue;
|
|
646
|
-
if (nameMap.has(r.name)) {
|
|
647
|
-
findings.push({
|
|
648
|
-
level: "error",
|
|
649
|
-
code: "duplicate-skill-name",
|
|
650
|
-
message: `duplicate skill name "${r.name}" in ${nameMap.get(r.name)} and ${r.file}`,
|
|
651
|
-
});
|
|
652
|
-
} else {
|
|
653
|
-
nameMap.set(r.name, r.file);
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
if (existsSync(AGENT_GUIDES_DIR)) {
|
|
657
|
-
const agentBases = new Set(
|
|
658
|
-
readdirSync(AGENT_GUIDES_DIR)
|
|
659
|
-
.filter((f) => f.endsWith(".md"))
|
|
660
|
-
.map((f) => basename(f, ".md")),
|
|
661
|
-
);
|
|
662
|
-
for (const r of results) {
|
|
663
|
-
// r.file is either "skills/rdc-foo/SKILL.md" or legacy "skills/rdc-foo.md"
|
|
664
|
-
// Extract the skill directory/base name in both cases
|
|
665
|
-
const parts = r.file.replace(/\\/g, "/").split("/");
|
|
666
|
-
let skillBase;
|
|
667
|
-
if (parts.length >= 3 && parts[2] === "SKILL.md") {
|
|
668
|
-
skillBase = parts[1]; // "rdc-foo" from "skills/rdc-foo/SKILL.md"
|
|
669
|
-
} else {
|
|
670
|
-
skillBase = basename(r.file, ".md"); // legacy
|
|
671
|
-
}
|
|
672
|
-
const stem = skillBase.startsWith("rdc-") ? skillBase.slice(4) : skillBase;
|
|
673
|
-
if (agentBases.has(stem)) {
|
|
674
|
-
findings.push({
|
|
675
|
-
level: "info",
|
|
676
|
-
code: "skill-guide-name-overlap",
|
|
677
|
-
message: `skills/${skillBase}/SKILL.md overlaps guides/agents/${stem}.md; allowed when a user-facing skill delegates to an agent guide`,
|
|
678
|
-
});
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
return findings;
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
// Orphan hook scan — warn when hooks/ files aren't referenced anywhere
|
|
686
|
-
function auditOrphanHooks(results) {
|
|
687
|
-
const findings = [];
|
|
688
|
-
if (!existsSync(HOOKS_DIR)) return findings;
|
|
689
|
-
let hookFiles;
|
|
690
|
-
try {
|
|
691
|
-
hookFiles = readdirSync(HOOKS_DIR).filter((f) => /\.(?:js|mjs|cjs)$/.test(f));
|
|
692
|
-
} catch {
|
|
693
|
-
return findings;
|
|
694
|
-
}
|
|
695
|
-
const referenced = new Set();
|
|
696
|
-
for (const r of results) {
|
|
697
|
-
for (const f of r.findings) {
|
|
698
|
-
// no-op: findings don't carry refs. Re-scan body below.
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
|
-
// Re-scan skill bodies + known config files for hook refs
|
|
702
|
-
const sources = [
|
|
703
|
-
...readdirSync(SKILLS_DIR)
|
|
704
|
-
.filter((f) => {
|
|
705
|
-
try {
|
|
706
|
-
return statSync(join(SKILLS_DIR, f)).isDirectory() && existsSync(join(SKILLS_DIR, f, "SKILL.md"));
|
|
707
|
-
} catch { return false; }
|
|
708
|
-
})
|
|
709
|
-
.map((f) => join(SKILLS_DIR, f, "SKILL.md")),
|
|
710
|
-
join(REPO_ROOT, ".claude", "settings.json"),
|
|
711
|
-
join(REPO_ROOT, "scripts", "install-rdc-skills.js"),
|
|
712
|
-
PLUGIN_MANIFEST,
|
|
713
|
-
];
|
|
714
|
-
for (const src of sources) {
|
|
715
|
-
if (!existsSync(src)) continue;
|
|
716
|
-
try {
|
|
717
|
-
const text = readFileSync(src, "utf8");
|
|
718
|
-
// Match either "hooks/foo.js" paths or bare basenames (stem match)
|
|
719
|
-
const pathRe = /hooks\/([\w.-]+\.(?:js|mjs|cjs))/g;
|
|
720
|
-
let m;
|
|
721
|
-
while ((m = pathRe.exec(text)) !== null) referenced.add(m[1]);
|
|
722
|
-
for (const hf of hookFiles) {
|
|
723
|
-
const stem = hf.replace(/\.(?:js|mjs|cjs)$/, "");
|
|
724
|
-
if (text.includes(hf) || new RegExp("\\b" + escapeRegExp(stem) + "\\b").test(text)) {
|
|
725
|
-
referenced.add(hf);
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
|
-
} catch {}
|
|
729
|
-
}
|
|
730
|
-
for (const hf of hookFiles) {
|
|
731
|
-
if (!referenced.has(hf)) {
|
|
732
|
-
findings.push({
|
|
733
|
-
level: "info",
|
|
734
|
-
code: "orphan-hook",
|
|
735
|
-
message: `hooks/${hf} exists but is not referenced by any skill; plugin hooks may still be convention-discovered or wired by settings`,
|
|
736
|
-
});
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
return findings;
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
function auditRequiredHookWiring() {
|
|
743
|
-
const findings = [];
|
|
744
|
-
const required = [
|
|
745
|
-
{
|
|
746
|
-
file: join(REPO_ROOT, "scripts", "install-rdc-skills.js"),
|
|
747
|
-
label: "scripts/install-rdc-skills.js",
|
|
748
|
-
tokens: [
|
|
749
|
-
"UserPromptExpansion",
|
|
750
|
-
"UserPromptSubmit",
|
|
751
|
-
"Stop",
|
|
752
|
-
"rdc-invocation-marker.js",
|
|
753
|
-
"rdc-output-contract-gate.js",
|
|
754
|
-
],
|
|
755
|
-
},
|
|
756
|
-
{
|
|
757
|
-
file: join(REPO_ROOT, "scripts", "install.ps1"),
|
|
758
|
-
label: "scripts/install.ps1",
|
|
759
|
-
tokens: [
|
|
760
|
-
"UserPromptExpansion",
|
|
761
|
-
"UserPromptSubmit",
|
|
762
|
-
"Stop",
|
|
763
|
-
"rdc-invocation-marker.js",
|
|
764
|
-
"rdc-output-contract-gate.js",
|
|
765
|
-
],
|
|
766
|
-
},
|
|
767
|
-
];
|
|
768
|
-
|
|
769
|
-
for (const hookFile of [
|
|
770
|
-
"rdc-invocation-marker.js",
|
|
771
|
-
"rdc-output-contract-gate.js",
|
|
772
|
-
]) {
|
|
773
|
-
if (!existsSync(join(HOOKS_DIR, hookFile))) {
|
|
774
|
-
findings.push({
|
|
775
|
-
level: "error",
|
|
776
|
-
code: "required-hook-missing",
|
|
777
|
-
message: `hooks/${hookFile} is required for RDC output-contract enforcement`,
|
|
778
|
-
});
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
for (const target of required) {
|
|
783
|
-
if (!existsSync(target.file)) {
|
|
784
|
-
findings.push({
|
|
785
|
-
level: "error",
|
|
786
|
-
code: "hook-installer-missing",
|
|
787
|
-
message: `${target.label} not found for RDC hook wiring audit`,
|
|
788
|
-
});
|
|
789
|
-
continue;
|
|
790
|
-
}
|
|
791
|
-
let text = "";
|
|
792
|
-
try { text = readFileSync(target.file, "utf8"); } catch (e) {
|
|
793
|
-
findings.push({
|
|
794
|
-
level: "error",
|
|
795
|
-
code: "hook-installer-unreadable",
|
|
796
|
-
message: `${target.label} unreadable: ${e.message}`,
|
|
797
|
-
});
|
|
798
|
-
continue;
|
|
799
|
-
}
|
|
800
|
-
for (const token of target.tokens) {
|
|
801
|
-
if (!text.includes(token)) {
|
|
802
|
-
findings.push({
|
|
803
|
-
level: "error",
|
|
804
|
-
code: "required-hook-wiring-missing",
|
|
805
|
-
message: `${target.label} must reference ${token}`,
|
|
806
|
-
});
|
|
807
|
-
}
|
|
808
|
-
}
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
return findings;
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
function runHookBehaviorTests() {
|
|
815
|
-
const result = spawnSync(process.execPath, [join(REPO_ROOT, "scripts", "test-rdc-hooks.mjs")], {
|
|
816
|
-
cwd: REPO_ROOT,
|
|
817
|
-
encoding: "utf8",
|
|
818
|
-
});
|
|
819
|
-
return {
|
|
820
|
-
ok: result.status === 0,
|
|
821
|
-
status: result.status,
|
|
822
|
-
stdout: (result.stdout || "").trim(),
|
|
823
|
-
stderr: (result.stderr || "").trim(),
|
|
824
|
-
};
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
// ─── Tier 2 behavioral runner ──────────────────────────────────────────────
|
|
828
|
-
|
|
829
|
-
async function runPool(items, concurrency, worker) {
|
|
830
|
-
const results = new Array(items.length);
|
|
831
|
-
let next = 0;
|
|
832
|
-
async function lane() {
|
|
833
|
-
while (true) {
|
|
834
|
-
const i = next++;
|
|
835
|
-
if (i >= items.length) return;
|
|
836
|
-
try {
|
|
837
|
-
results[i] = await worker(items[i], i);
|
|
838
|
-
} catch (e) {
|
|
839
|
-
results[i] = { pass: false, error: e?.message || String(e) };
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
}
|
|
843
|
-
const lanes = Array.from({ length: Math.min(concurrency, items.length) }, () => lane());
|
|
844
|
-
await Promise.all(lanes);
|
|
845
|
-
return results;
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
async function runTier2() {
|
|
849
|
-
console.log("\nrdc-skills self-test — Tier 2 (behavioral)\n");
|
|
850
|
-
|
|
851
|
-
// Load manifests
|
|
852
|
-
let all = loadAllManifests();
|
|
853
|
-
if (ONLY_SKILLS.length > 0) {
|
|
854
|
-
all = all.filter((m) => m.manifest && ONLY_SKILLS.includes(m.manifest.skill));
|
|
855
|
-
}
|
|
856
|
-
if (QUICK) {
|
|
857
|
-
all = all.filter((m) => !(m.manifest?.fixture?.slow === true));
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
if (all.length === 0) {
|
|
861
|
-
console.log("No manifests found in skills/tests/.");
|
|
862
|
-
console.log("(Tier 2 will run once WP6/WP7 land baseline manifests.)");
|
|
863
|
-
process.exit(STRICT ? 1 : 0);
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
// Reject invalid manifests up front
|
|
867
|
-
const invalid = all.filter((m) => !m.ok);
|
|
868
|
-
const valid = all.filter((m) => m.ok);
|
|
869
|
-
for (const m of invalid) {
|
|
870
|
-
console.error(`SKIP ${m.file}: ${m.errors.map((e) => `${e.path}: ${e.msg}`).join("; ")}`);
|
|
871
|
-
}
|
|
872
|
-
if (valid.length === 0) {
|
|
873
|
-
console.error("No valid manifests to run.");
|
|
874
|
-
process.exit(1);
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
const runId = generateRunId();
|
|
878
|
-
console.log(`runId: ${runId}`);
|
|
879
|
-
console.log(`manifests: ${valid.length} valid, ${invalid.length} invalid`);
|
|
880
|
-
console.log(`parallel: ${PARALLEL}${QUICK ? " quick" : ""}`);
|
|
881
|
-
liveLog("start", `runId=${runId} skills=${valid.map((m) => m.manifest.skill).join(",")}`);
|
|
882
|
-
|
|
883
|
-
// WP-A3: use resolveSandboxRef — defaults to main-db mode (no branch, no cost)
|
|
884
|
-
const sandboxRef = await resolveSandboxRef({ runId });
|
|
885
|
-
console.log(`sandbox mode: ${sandboxRef.mode} apiUrl=${sandboxRef.apiUrl || "none"} anonKey=${sandboxRef.anonKey ? "ok" : "missing"}`);
|
|
886
|
-
liveLog("sandbox", `mode=${sandboxRef.mode} anonKey=${sandboxRef.anonKey ? "ok" : "missing"}`);
|
|
887
|
-
|
|
888
|
-
// Run
|
|
889
|
-
const toRun = valid.map((m) => m.manifest);
|
|
890
|
-
const total = toRun.length;
|
|
891
|
-
let launched = 0;
|
|
892
|
-
let finished = 0;
|
|
893
|
-
const started = Date.now();
|
|
894
|
-
let results;
|
|
895
|
-
try {
|
|
896
|
-
results = await runPool(toRun, PARALLEL, async (manifest) => {
|
|
897
|
-
const n = ++launched;
|
|
898
|
-
const timeoutSec = Math.round((manifest.timeout_ms || 240_000) / 1000);
|
|
899
|
-
console.log(`▶ [${n}/${total}] ${manifest.skill} (timeout ${timeoutSec}s)`);
|
|
900
|
-
liveLog("running", `skill=${manifest.skill}`);
|
|
901
|
-
const r = await runManifest(manifest, {
|
|
902
|
-
runId,
|
|
903
|
-
supabaseBranchRef: sandboxRef,
|
|
904
|
-
projectCwd: process.cwd(),
|
|
905
|
-
});
|
|
906
|
-
const done = ++finished;
|
|
907
|
-
const status = r.error ? "ERROR" : r.pass ? "PASS" : "FAIL";
|
|
908
|
-
const dur = r.duration_ms != null ? `${(r.duration_ms / 1000).toFixed(1)}s` : "?";
|
|
909
|
-
const icon = status === "PASS" ? "✓" : "✗";
|
|
910
|
-
const detail = r.error || (r.failures || []).map((f) => f.message).join("; ") || "";
|
|
911
|
-
console.log(
|
|
912
|
-
`${icon} [${done}/${total}] ${r.skill} ${dur} ${status}${detail ? " — " + detail : ""}`,
|
|
913
|
-
);
|
|
914
|
-
liveLog(status.toLowerCase(), `skill=${r.skill} duration=${r.duration_ms}ms${detail ? " | " + detail : ""}`);
|
|
915
|
-
return r;
|
|
916
|
-
});
|
|
917
|
-
} finally {
|
|
918
|
-
// Teardown — always. Pass process.cwd() so removeWorktree targets the
|
|
919
|
-
// same projectRoot (regen-root) that createWorktree used.
|
|
920
|
-
const projectCwd = process.cwd();
|
|
921
|
-
for (const r of results || []) {
|
|
922
|
-
if (r && r.skill) {
|
|
923
|
-
try { removeWorktree(runId, r.skill, projectCwd); } catch {}
|
|
924
|
-
}
|
|
925
|
-
}
|
|
926
|
-
try { await deleteSupabaseTestBranch(sandboxRef.branchId); } catch {}
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
const totalDuration = Date.now() - started;
|
|
930
|
-
|
|
931
|
-
// Report
|
|
932
|
-
const pad = (s, n) => String(s) + " ".repeat(Math.max(0, n - String(s).length));
|
|
933
|
-
console.log("\n" + pad("skill", 24) + pad("status", 10) + pad("duration", 12) + "failures");
|
|
934
|
-
console.log("─".repeat(90));
|
|
935
|
-
let failed = 0;
|
|
936
|
-
let errored = 0;
|
|
937
|
-
for (const r of results) {
|
|
938
|
-
if (r.error) {
|
|
939
|
-
errored++;
|
|
940
|
-
console.log(pad(r.skill || "?", 24) + pad("ERROR", 10) + pad("-", 12) + r.error);
|
|
941
|
-
continue;
|
|
942
|
-
}
|
|
943
|
-
const status = r.pass ? "pass" : "FAIL";
|
|
944
|
-
if (!r.pass) failed++;
|
|
945
|
-
const dur = `${r.duration_ms}ms`;
|
|
946
|
-
const fs = (r.failures || []).map((f) => `${f.predicate}: ${f.message}`).join("; ") || "";
|
|
947
|
-
console.log(pad(r.skill, 24) + pad(status, 10) + pad(dur, 12) + fs);
|
|
948
|
-
}
|
|
949
|
-
console.log("─".repeat(90));
|
|
950
|
-
console.log(
|
|
951
|
-
`total: ${results.length} | pass: ${results.length - failed - errored} | fail: ${failed} | error: ${errored} | wall: ${totalDuration}ms`,
|
|
952
|
-
);
|
|
953
|
-
liveLog("done", `total=${results.length} pass=${results.length - failed - errored} fail=${failed} error=${errored} wall=${totalDuration}ms`);
|
|
954
|
-
|
|
955
|
-
// JSON dump
|
|
956
|
-
try {
|
|
957
|
-
const REPORTS_DIR = resolve(REPO_ROOT, ".rdc", "reports");
|
|
958
|
-
if (!existsSync(REPORTS_DIR)) mkdirSync(REPORTS_DIR, { recursive: true });
|
|
959
|
-
const iso = new Date().toISOString().replace(/[:.]/g, "-");
|
|
960
|
-
const reportPath = join(REPORTS_DIR, `self-test-tier2-${iso}.json`);
|
|
961
|
-
writeFileSync(
|
|
962
|
-
reportPath,
|
|
963
|
-
JSON.stringify(
|
|
964
|
-
{
|
|
965
|
-
run_id: runId,
|
|
966
|
-
started_at: new Date(started).toISOString(),
|
|
967
|
-
duration_ms: totalDuration,
|
|
968
|
-
parallel: PARALLEL,
|
|
969
|
-
quick: QUICK,
|
|
970
|
-
supabase_branch: sandboxRef.branchId || null,
|
|
971
|
-
summary: {
|
|
972
|
-
total: results.length,
|
|
973
|
-
pass: results.length - failed - errored,
|
|
974
|
-
fail: failed,
|
|
975
|
-
error: errored,
|
|
976
|
-
},
|
|
977
|
-
results,
|
|
978
|
-
invalid_manifests: invalid.map((m) => ({ file: m.file, errors: m.errors })),
|
|
979
|
-
},
|
|
980
|
-
null,
|
|
981
|
-
2,
|
|
982
|
-
),
|
|
983
|
-
);
|
|
984
|
-
console.log(`\nreport: ${reportPath}`);
|
|
985
|
-
} catch (e) {
|
|
986
|
-
console.error(`WARN: failed to write JSON report: ${e.message}`);
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
writeLastRun({
|
|
990
|
-
tier: 2,
|
|
991
|
-
verdict: (failed > 0 || errored > 0) ? "FAIL" : "PASS",
|
|
992
|
-
exit_code: errored > 0 ? 2 : failed > 0 ? 1 : 0,
|
|
993
|
-
summary: {
|
|
994
|
-
total: results.length,
|
|
995
|
-
passed: results.length - failed - errored,
|
|
996
|
-
failed,
|
|
997
|
-
errored,
|
|
998
|
-
wall_ms: totalDuration,
|
|
999
|
-
},
|
|
1000
|
-
failures: results
|
|
1001
|
-
.filter((r) => !r.pass || r.error)
|
|
1002
|
-
.map((r) => ({
|
|
1003
|
-
skill: r.skill,
|
|
1004
|
-
error: r.error || null,
|
|
1005
|
-
timed_out: r.observed?.timed_out || false,
|
|
1006
|
-
assertions_failed: (r.failures || []).map((f) => `${f.predicate}: ${f.message}`),
|
|
1007
|
-
})),
|
|
1008
|
-
warnings: [],
|
|
1009
|
-
});
|
|
1010
|
-
|
|
1011
|
-
if (errored > 0) process.exit(2);
|
|
1012
|
-
process.exit(failed > 0 ? 1 : 0);
|
|
1013
|
-
}
|
|
1014
|
-
|
|
1015
|
-
function main() {
|
|
1016
|
-
// Plugin manifest pass first — can short-circuit with exit 3
|
|
1017
|
-
const manifestAudit = auditPluginManifest();
|
|
1018
|
-
const manifestMissing = manifestAudit.findings.some((f) => f.code === "manifest-missing");
|
|
1019
|
-
|
|
1020
|
-
let files;
|
|
1021
|
-
try {
|
|
1022
|
-
files = readdirSync(SKILLS_DIR).filter((f) => {
|
|
1023
|
-
try {
|
|
1024
|
-
return statSync(join(SKILLS_DIR, f)).isDirectory() && existsSync(join(SKILLS_DIR, f, "SKILL.md"));
|
|
1025
|
-
} catch { return false; }
|
|
1026
|
-
});
|
|
1027
|
-
} catch (e) {
|
|
1028
|
-
console.error(`FATAL: cannot read skills dir ${SKILLS_DIR}: ${e.message}`);
|
|
1029
|
-
process.exit(2);
|
|
1030
|
-
}
|
|
1031
|
-
|
|
1032
|
-
if (ONLY_SKILLS.length > 0) {
|
|
1033
|
-
files = files.filter((f) => {
|
|
1034
|
-
return ONLY_SKILLS.some((s) => {
|
|
1035
|
-
const bare = s.replace(/^rdc:/, "");
|
|
1036
|
-
return f === bare || f === s.replace(":", "-");
|
|
1037
|
-
});
|
|
1038
|
-
});
|
|
1039
|
-
if (files.length === 0) {
|
|
1040
|
-
console.error(`no skill file matches: ${ONLY_SKILLS.join(", ")}`);
|
|
1041
|
-
process.exit(2);
|
|
1042
|
-
}
|
|
1043
|
-
}
|
|
1044
|
-
|
|
1045
|
-
if (JSON_OUT) {
|
|
1046
|
-
// JSON path: buffer everything then dump
|
|
1047
|
-
const results = files.map((f) => auditSkill(join(SKILLS_DIR, f, "SKILL.md")));
|
|
1048
|
-
|
|
1049
|
-
let duplicateFindings = [];
|
|
1050
|
-
let orphanHookFindings = [];
|
|
1051
|
-
if (!ONLY_SKILL) {
|
|
1052
|
-
duplicateFindings = auditDuplicates(results);
|
|
1053
|
-
orphanHookFindings = auditOrphanHooks(results);
|
|
1054
|
-
}
|
|
1055
|
-
|
|
1056
|
-
let agentResults = [];
|
|
1057
|
-
if (!ONLY_SKILL && existsSync(AGENT_GUIDES_DIR)) {
|
|
1058
|
-
const agentFiles = readdirSync(AGENT_GUIDES_DIR).filter((f) => f.endsWith(".md"));
|
|
1059
|
-
agentResults = agentFiles.map((f) => auditAgentGuide(join(AGENT_GUIDES_DIR, f)));
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
const guideValidatorResultJson = !ONLY_SKILL ? runGuideContentValidator() : null;
|
|
1063
|
-
const hookBehaviorResultJson = !ONLY_SKILL ? runHookBehaviorTests() : null;
|
|
1064
|
-
|
|
1065
|
-
const failed = results.filter((r) => r.errors.length > 0);
|
|
1066
|
-
const warned = results.filter((r) => r.warnings.length > 0 && r.errors.length === 0);
|
|
1067
|
-
const clean = results.filter((r) => r.errors.length === 0 && r.warnings.length === 0);
|
|
1068
|
-
const agentFailed = agentResults.filter((r) => r.errors.length > 0);
|
|
1069
|
-
const agentWarned = agentResults.filter((r) => r.warnings.length > 0 && r.errors.length === 0);
|
|
1070
|
-
const agentClean = agentResults.filter((r) => r.errors.length === 0 && r.warnings.length === 0);
|
|
1071
|
-
const globalErrors = [
|
|
1072
|
-
...manifestAudit.findings.filter((f) => f.level === "error"),
|
|
1073
|
-
...duplicateFindings.filter((f) => f.level === "error"),
|
|
1074
|
-
...orphanHookFindings.filter((f) => f.level === "error"),
|
|
1075
|
-
...(guideValidatorResultJson ? guideValidatorResultJson.errors : []),
|
|
1076
|
-
...(hookBehaviorResultJson && !hookBehaviorResultJson.ok ? [{
|
|
1077
|
-
level: "error",
|
|
1078
|
-
code: "hook-behavior-test-failed",
|
|
1079
|
-
message: hookBehaviorResultJson.stderr || hookBehaviorResultJson.stdout || `exit ${hookBehaviorResultJson.status}`,
|
|
1080
|
-
}] : []),
|
|
1081
|
-
];
|
|
1082
|
-
const globalWarnings = [
|
|
1083
|
-
...manifestAudit.findings.filter((f) => f.level === "warn"),
|
|
1084
|
-
...duplicateFindings.filter((f) => f.level === "warn"),
|
|
1085
|
-
...orphanHookFindings.filter((f) => f.level === "warn"),
|
|
1086
|
-
...(guideValidatorResultJson ? guideValidatorResultJson.warnings : []),
|
|
1087
|
-
];
|
|
1088
|
-
const fail =
|
|
1089
|
-
failed.length > 0 ||
|
|
1090
|
-
agentFailed.length > 0 ||
|
|
1091
|
-
globalErrors.length > 0 ||
|
|
1092
|
-
(STRICT && (warned.length > 0 || agentWarned.length > 0 || globalWarnings.length > 0));
|
|
1093
|
-
let exitCode = fail ? 1 : 0;
|
|
1094
|
-
if (manifestMissing) exitCode = 3;
|
|
1095
|
-
|
|
1096
|
-
console.log(
|
|
1097
|
-
JSON.stringify(
|
|
1098
|
-
{
|
|
1099
|
-
summary: {
|
|
1100
|
-
total: results.length,
|
|
1101
|
-
failed: failed.length,
|
|
1102
|
-
warned: warned.length,
|
|
1103
|
-
clean: clean.length,
|
|
1104
|
-
strict: STRICT,
|
|
1105
|
-
fix: FIX,
|
|
1106
|
-
fixed_files: FIXED_FILES.map((p) => p.replace(REPO_ROOT + "\\", "").replace(REPO_ROOT + "/", "")),
|
|
1107
|
-
verdict: fail ? "FAIL" : "PASS",
|
|
1108
|
-
exit_code: exitCode,
|
|
1109
|
-
},
|
|
1110
|
-
plugin_manifest: {
|
|
1111
|
-
ok: manifestAudit.ok,
|
|
1112
|
-
version: manifestAudit.manifest?.version || null,
|
|
1113
|
-
findings: manifestAudit.findings,
|
|
1114
|
-
},
|
|
1115
|
-
global_findings: [...duplicateFindings, ...orphanHookFindings],
|
|
1116
|
-
guide_content_validator: guideValidatorResultJson
|
|
1117
|
-
? {
|
|
1118
|
-
ok: guideValidatorResultJson.ok,
|
|
1119
|
-
stats: guideValidatorResultJson.stats,
|
|
1120
|
-
findings: guideValidatorResultJson.findings,
|
|
1121
|
-
}
|
|
1122
|
-
: null,
|
|
1123
|
-
hook_behavior_tests: hookBehaviorResultJson,
|
|
1124
|
-
results: results.map((r) => ({
|
|
1125
|
-
skill: r.skill,
|
|
1126
|
-
file: r.file,
|
|
1127
|
-
pass: r.pass,
|
|
1128
|
-
findings: r.findings,
|
|
1129
|
-
})),
|
|
1130
|
-
agent_guides: {
|
|
1131
|
-
total: agentResults.length,
|
|
1132
|
-
failed: agentFailed.length,
|
|
1133
|
-
warned: agentWarned.length,
|
|
1134
|
-
clean: agentClean.length,
|
|
1135
|
-
results: agentResults.map((r) => ({
|
|
1136
|
-
file: r.file,
|
|
1137
|
-
pass: r.pass,
|
|
1138
|
-
findings: r.findings,
|
|
1139
|
-
})),
|
|
1140
|
-
},
|
|
1141
|
-
},
|
|
1142
|
-
null,
|
|
1143
|
-
2,
|
|
1144
|
-
),
|
|
1145
|
-
);
|
|
1146
|
-
process.exit(exitCode);
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
|
-
// Human-readable path: stream each result live as it's audited
|
|
1150
|
-
const pad = (s, n) => s + " ".repeat(Math.max(0, n - s.length));
|
|
1151
|
-
|
|
1152
|
-
// Count agent guides upfront for the startup banner
|
|
1153
|
-
let agentFileCount = 0;
|
|
1154
|
-
if (!ONLY_SKILL && existsSync(AGENT_GUIDES_DIR)) {
|
|
1155
|
-
try { agentFileCount = readdirSync(AGENT_GUIDES_DIR).filter((f) => f.endsWith(".md")).length; } catch {}
|
|
1156
|
-
}
|
|
1157
|
-
|
|
1158
|
-
// Startup banner — printed immediately so the user sees activity right away
|
|
1159
|
-
console.log(`\nrdc-skills self-test — Tier 1 (static lint)`);
|
|
1160
|
-
const scopeDesc = ONLY_SKILLS.length > 0
|
|
1161
|
-
? `skill: ${ONLY_SKILLS.join(", ")}`
|
|
1162
|
-
: `${files.length} skill${files.length !== 1 ? "s" : ""}${agentFileCount ? ` + ${agentFileCount} agent guide${agentFileCount !== 1 ? "s" : ""}` : ""}`;
|
|
1163
|
-
console.log(`Scanning ${scopeDesc}${STRICT ? " [strict]" : ""}${FIX ? " [fix]" : ""}\n`);
|
|
1164
|
-
|
|
1165
|
-
// Plugin manifest line
|
|
1166
|
-
const manifestStatus = manifestAudit.ok ? "pass" : "FAIL";
|
|
1167
|
-
const manifestNote = manifestAudit.ok
|
|
1168
|
-
? `v${manifestAudit.manifest?.version || "?"}`
|
|
1169
|
-
: manifestAudit.findings.map((f) => f.message).join("; ");
|
|
1170
|
-
console.log(pad("plugin manifest", 24) + pad(manifestStatus, 10) + manifestNote);
|
|
1171
|
-
console.log();
|
|
1172
|
-
|
|
1173
|
-
// Skills — print each row immediately after audit (no buffering)
|
|
1174
|
-
console.log(pad("skill", 24) + pad("status", 10) + "notes");
|
|
1175
|
-
console.log("─".repeat(80));
|
|
1176
|
-
const results = [];
|
|
1177
|
-
for (const f of files) {
|
|
1178
|
-
const r = auditSkill(join(SKILLS_DIR, f, "SKILL.md"));
|
|
1179
|
-
results.push(r);
|
|
1180
|
-
const status = r.errors.length > 0 ? "FAIL" : r.warnings.length > 0 ? "WARN" : "pass";
|
|
1181
|
-
const notes = r.errors.length > 0 ? r.errors[0] : r.warnings[0] || "";
|
|
1182
|
-
console.log(pad(r.name || r.file, 24) + pad(status, 10) + notes);
|
|
1183
|
-
const extras = [...r.errors.slice(1), ...r.warnings.slice(r.errors.length > 0 ? 0 : 1)];
|
|
1184
|
-
for (const extra of extras) {
|
|
1185
|
-
console.log(pad("", 24) + pad("", 10) + " " + extra);
|
|
1186
|
-
}
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
const failed = results.filter((r) => r.errors.length > 0);
|
|
1190
|
-
const warned = results.filter((r) => r.warnings.length > 0 && r.errors.length === 0);
|
|
1191
|
-
const clean = results.filter((r) => r.errors.length === 0 && r.warnings.length === 0);
|
|
1192
|
-
|
|
1193
|
-
console.log("─".repeat(80));
|
|
1194
|
-
console.log(`total: ${results.length} | fail: ${failed.length} | warn: ${warned.length} | pass: ${clean.length}`);
|
|
1195
|
-
|
|
1196
|
-
// Agent guides — stream live
|
|
1197
|
-
const agentResults = [];
|
|
1198
|
-
if (!ONLY_SKILL && existsSync(AGENT_GUIDES_DIR)) {
|
|
1199
|
-
const agentFiles = readdirSync(AGENT_GUIDES_DIR).filter((f) => f.endsWith(".md"));
|
|
1200
|
-
if (agentFiles.length > 0) {
|
|
1201
|
-
console.log("\nagent guides (guides/agents/*.md)\n");
|
|
1202
|
-
console.log(pad("guide", 24) + pad("status", 10) + "notes");
|
|
1203
|
-
console.log("─".repeat(80));
|
|
1204
|
-
for (const f of agentFiles) {
|
|
1205
|
-
const r = auditAgentGuide(join(AGENT_GUIDES_DIR, f));
|
|
1206
|
-
agentResults.push(r);
|
|
1207
|
-
const status = r.errors.length > 0 ? "FAIL" : r.warnings.length > 0 ? "WARN" : "pass";
|
|
1208
|
-
const notes = r.errors.length > 0 ? r.errors[0] : r.warnings[0] || "";
|
|
1209
|
-
console.log(pad(r.file, 24) + pad(status, 10) + notes);
|
|
1210
|
-
const extras = [...r.errors.slice(1), ...r.warnings.slice(r.errors.length > 0 ? 0 : 1)];
|
|
1211
|
-
for (const extra of extras) {
|
|
1212
|
-
console.log(pad("", 24) + pad("", 10) + " " + extra);
|
|
1213
|
-
}
|
|
1214
|
-
}
|
|
1215
|
-
const agentFailed = agentResults.filter((r) => r.errors.length > 0);
|
|
1216
|
-
const agentWarned = agentResults.filter((r) => r.warnings.length > 0 && r.errors.length === 0);
|
|
1217
|
-
const agentClean = agentResults.filter((r) => r.errors.length === 0 && r.warnings.length === 0);
|
|
1218
|
-
console.log("─".repeat(80));
|
|
1219
|
-
console.log(`total: ${agentResults.length} | fail: ${agentFailed.length} | warn: ${agentWarned.length} | pass: ${agentClean.length}`);
|
|
1220
|
-
}
|
|
1221
|
-
}
|
|
1222
|
-
|
|
1223
|
-
// Guide-content validation — check .rdc/guides + .claude/rules for banned terms and invalid clauth keys
|
|
1224
|
-
let guideValidatorResult = null;
|
|
1225
|
-
if (!ONLY_SKILL) {
|
|
1226
|
-
guideValidatorResult = runGuideContentValidator();
|
|
1227
|
-
if (guideValidatorResult.stats.files_scanned > 0) {
|
|
1228
|
-
console.log(`\nguide-content validator (${guideValidatorResult.stats.files_scanned} files)\n`);
|
|
1229
|
-
console.log("─".repeat(80));
|
|
1230
|
-
if (guideValidatorResult.findings.length === 0) {
|
|
1231
|
-
console.log(" ✓ no banned terms or invalid clauth keys found");
|
|
1232
|
-
} else {
|
|
1233
|
-
for (const f of guideValidatorResult.findings) {
|
|
1234
|
-
const tag = f.level === "error" ? "FAIL" : "WARN";
|
|
1235
|
-
console.log(` [${tag}] ${f.file}:${f.line} ${f.code} ${f.message}`);
|
|
1236
|
-
}
|
|
1237
|
-
}
|
|
1238
|
-
console.log("─".repeat(80));
|
|
1239
|
-
const gErrors = guideValidatorResult.errors.length;
|
|
1240
|
-
const gWarnings = guideValidatorResult.warnings.length;
|
|
1241
|
-
console.log(`total: ${guideValidatorResult.stats.files_scanned} | errors: ${gErrors} | warnings: ${gWarnings}`);
|
|
1242
|
-
}
|
|
1243
|
-
}
|
|
1244
|
-
|
|
1245
|
-
// Cross-skill checks — deferred until all audits complete
|
|
1246
|
-
let duplicateFindings = [];
|
|
1247
|
-
let orphanHookFindings = [];
|
|
1248
|
-
let requiredHookFindings = [];
|
|
1249
|
-
let hookBehaviorResult = null;
|
|
1250
|
-
if (!ONLY_SKILL) {
|
|
1251
|
-
duplicateFindings = auditDuplicates(results);
|
|
1252
|
-
orphanHookFindings = auditOrphanHooks(results);
|
|
1253
|
-
requiredHookFindings = auditRequiredHookWiring();
|
|
1254
|
-
hookBehaviorResult = runHookBehaviorTests();
|
|
1255
|
-
}
|
|
1256
|
-
|
|
1257
|
-
if (duplicateFindings.length + orphanHookFindings.length + requiredHookFindings.length > 0) {
|
|
1258
|
-
console.log("\nglobal findings\n");
|
|
1259
|
-
for (const f of [...duplicateFindings, ...orphanHookFindings, ...requiredHookFindings]) {
|
|
1260
|
-
console.log(` [${f.level}] ${f.code}: ${f.message}`);
|
|
1261
|
-
}
|
|
1262
|
-
}
|
|
1263
|
-
|
|
1264
|
-
if (hookBehaviorResult) {
|
|
1265
|
-
console.log("\nhook behavior tests\n");
|
|
1266
|
-
console.log("─".repeat(80));
|
|
1267
|
-
console.log(hookBehaviorResult.ok ? " pass rdc marker/gate behavioral smoke tests" : " FAIL rdc marker/gate behavioral smoke tests");
|
|
1268
|
-
if (hookBehaviorResult.stdout) console.log(` ${hookBehaviorResult.stdout}`);
|
|
1269
|
-
if (hookBehaviorResult.stderr) console.log(` ${hookBehaviorResult.stderr}`);
|
|
1270
|
-
}
|
|
1271
|
-
|
|
1272
|
-
if (FIXED_FILES.length > 0) {
|
|
1273
|
-
console.log("\nfixed files (review with git diff):");
|
|
1274
|
-
for (const p of FIXED_FILES) console.log(` ${p}`);
|
|
1275
|
-
}
|
|
1276
|
-
|
|
1277
|
-
const agentFailed = agentResults.filter((r) => r.errors.length > 0);
|
|
1278
|
-
const agentWarned = agentResults.filter((r) => r.warnings.length > 0 && r.errors.length === 0);
|
|
1279
|
-
const globalErrors = [
|
|
1280
|
-
...manifestAudit.findings.filter((f) => f.level === "error"),
|
|
1281
|
-
...duplicateFindings.filter((f) => f.level === "error"),
|
|
1282
|
-
...orphanHookFindings.filter((f) => f.level === "error"),
|
|
1283
|
-
...requiredHookFindings.filter((f) => f.level === "error"),
|
|
1284
|
-
...(guideValidatorResult ? guideValidatorResult.errors : []),
|
|
1285
|
-
...(hookBehaviorResult && !hookBehaviorResult.ok ? [{
|
|
1286
|
-
level: "error",
|
|
1287
|
-
code: "hook-behavior-test-failed",
|
|
1288
|
-
message: hookBehaviorResult.stderr || hookBehaviorResult.stdout || `exit ${hookBehaviorResult.status}`,
|
|
1289
|
-
}] : []),
|
|
1290
|
-
];
|
|
1291
|
-
const globalWarnings = [
|
|
1292
|
-
...manifestAudit.findings.filter((f) => f.level === "warn"),
|
|
1293
|
-
...duplicateFindings.filter((f) => f.level === "warn"),
|
|
1294
|
-
...orphanHookFindings.filter((f) => f.level === "warn"),
|
|
1295
|
-
...requiredHookFindings.filter((f) => f.level === "warn"),
|
|
1296
|
-
...(guideValidatorResult ? guideValidatorResult.warnings : []),
|
|
1297
|
-
];
|
|
1298
|
-
|
|
1299
|
-
const fail =
|
|
1300
|
-
failed.length > 0 ||
|
|
1301
|
-
agentFailed.length > 0 ||
|
|
1302
|
-
globalErrors.length > 0 ||
|
|
1303
|
-
(STRICT && (warned.length > 0 || agentWarned.length > 0 || globalWarnings.length > 0));
|
|
1304
|
-
|
|
1305
|
-
let exitCode = fail ? 1 : 0;
|
|
1306
|
-
if (manifestMissing) exitCode = 3;
|
|
1307
|
-
|
|
1308
|
-
console.log(`\nverdict: ${fail ? "❌ FAIL" : "✓ PASS"}${STRICT ? " (strict)" : ""} exit=${exitCode}\n`);
|
|
1309
|
-
|
|
1310
|
-
writeLastRun({
|
|
1311
|
-
tier: 1,
|
|
1312
|
-
verdict: fail ? "FAIL" : "PASS",
|
|
1313
|
-
exit_code: exitCode,
|
|
1314
|
-
strict: STRICT,
|
|
1315
|
-
summary: {
|
|
1316
|
-
total: results.length,
|
|
1317
|
-
failed: failed.length,
|
|
1318
|
-
warned: warned.length,
|
|
1319
|
-
passed: clean.length,
|
|
1320
|
-
},
|
|
1321
|
-
failures: [
|
|
1322
|
-
...manifestAudit.findings.filter((f) => f.level === "error").map((f) => ({
|
|
1323
|
-
scope: "plugin_manifest", code: f.code, message: f.message,
|
|
1324
|
-
})),
|
|
1325
|
-
...failed.map((r) => ({ skill: r.skill || r.file, errors: r.errors, findings: r.findings })),
|
|
1326
|
-
...agentFailed.map((r) => ({ scope: "agent_guide", file: r.file, errors: r.errors, findings: r.findings })),
|
|
1327
|
-
...duplicateFindings.filter((f) => f.level === "error").map((f) => ({ scope: "global", code: f.code, message: f.message })),
|
|
1328
|
-
...orphanHookFindings.filter((f) => f.level === "error").map((f) => ({ scope: "global", code: f.code, message: f.message })),
|
|
1329
|
-
],
|
|
1330
|
-
warnings: [
|
|
1331
|
-
...warned.map((r) => ({ skill: r.skill || r.file, warnings: r.warnings })),
|
|
1332
|
-
...agentWarned.map((r) => ({ scope: "agent_guide", file: r.file, warnings: r.warnings })),
|
|
1333
|
-
],
|
|
1334
|
-
});
|
|
1335
|
-
|
|
1336
|
-
process.exit(exitCode);
|
|
1337
|
-
}
|
|
1338
|
-
|
|
1339
|
-
// ── last-run.json ───────────────────────────────────────────────────────────
|
|
1340
|
-
// Always written after every run so Claude Code can read it without the user
|
|
1341
|
-
// having to copy/paste terminal output.
|
|
1342
|
-
// Path: C:/Dev/rdc-skills/.rdc/reports/last-run.json
|
|
1343
|
-
//
|
|
1344
|
-
// Shape:
|
|
1345
|
-
// { tier, ran_at, verdict, exit_code, summary, failures[], warnings[] }
|
|
1346
|
-
// "failures" contains only items that actually failed — ready to act on.
|
|
1347
|
-
|
|
1348
|
-
function writeLastRun(data) {
|
|
1349
|
-
const payload = { ...data, ran_at: new Date().toISOString() };
|
|
1350
|
-
try {
|
|
1351
|
-
const reportsDir = resolve(REPO_ROOT, ".rdc", "reports");
|
|
1352
|
-
if (!existsSync(reportsDir)) mkdirSync(reportsDir, { recursive: true });
|
|
1353
|
-
|
|
1354
|
-
const jsonPath = join(reportsDir, "last-run.json");
|
|
1355
|
-
writeFileSync(jsonPath, JSON.stringify(payload, null, 2));
|
|
1356
|
-
|
|
1357
|
-
const htmlPath = join(reportsDir, "last-run.html");
|
|
1358
|
-
writeFileSync(htmlPath, renderHtml(payload));
|
|
1359
|
-
|
|
1360
|
-
console.log(`\nresults: file:///${htmlPath.replace(/\\/g, "/")}`);
|
|
1361
|
-
} catch (e) {
|
|
1362
|
-
console.error(`WARN: could not write last-run reports: ${e.message}`);
|
|
1363
|
-
}
|
|
1364
|
-
}
|
|
1365
|
-
|
|
1366
|
-
function renderHtml(d) {
|
|
1367
|
-
const pass = d.verdict === "PASS";
|
|
1368
|
-
const ts = new Date(d.ran_at).toLocaleString();
|
|
1369
|
-
const badge = pass
|
|
1370
|
-
? `<span class="badge pass">✓ PASS</span>`
|
|
1371
|
-
: `<span class="badge fail">✗ FAIL</span>`;
|
|
1372
|
-
|
|
1373
|
-
const failRows = (d.failures || []).map((f) => {
|
|
1374
|
-
const name = f.skill || f.file || f.scope || "?";
|
|
1375
|
-
const detail = [
|
|
1376
|
-
...(f.errors || []),
|
|
1377
|
-
...(f.assertions_failed || []),
|
|
1378
|
-
f.error || "",
|
|
1379
|
-
f.message || "",
|
|
1380
|
-
].filter(Boolean).join("<br>");
|
|
1381
|
-
const extra = f.timed_out ? "<em>timed out</em>" : detail;
|
|
1382
|
-
return `<tr class="fail-row"><td>${name}</td><td>${extra || "—"}</td></tr>`;
|
|
1383
|
-
}).join("");
|
|
1384
|
-
|
|
1385
|
-
const warnRows = (d.warnings || []).map((w) => {
|
|
1386
|
-
const name = w.skill || w.file || "?";
|
|
1387
|
-
const detail = (w.warnings || []).join("<br>");
|
|
1388
|
-
return `<tr class="warn-row"><td>${name}</td><td>${detail}</td></tr>`;
|
|
1389
|
-
}).join("");
|
|
1390
|
-
|
|
1391
|
-
const s = d.summary || {};
|
|
1392
|
-
|
|
1393
|
-
return `<!DOCTYPE html>
|
|
1394
|
-
<html lang="en">
|
|
1395
|
-
<head>
|
|
1396
|
-
<meta charset="utf-8">
|
|
1397
|
-
<meta http-equiv="refresh" content="5">
|
|
1398
|
-
<title>rdc:self-test — ${d.verdict}</title>
|
|
1399
|
-
<style>
|
|
1400
|
-
body { font-family: monospace; background: #0d1117; color: #e6edf3; margin: 2rem; }
|
|
1401
|
-
h1 { font-size: 1.2rem; margin-bottom: 0.25rem; }
|
|
1402
|
-
.meta { color: #8b949e; font-size: 0.85rem; margin-bottom: 1.5rem; }
|
|
1403
|
-
.badge { padding: 0.2rem 0.6rem; border-radius: 4px; font-weight: bold; font-size: 1rem; }
|
|
1404
|
-
.badge.pass { background: #1a4731; color: #3fb950; }
|
|
1405
|
-
.badge.fail { background: #4a1515; color: #f85149; }
|
|
1406
|
-
table { border-collapse: collapse; width: 100%; margin-top: 1rem; }
|
|
1407
|
-
th { text-align: left; padding: 0.4rem 0.8rem; background: #161b22; color: #8b949e; font-size: 0.8rem; }
|
|
1408
|
-
td { padding: 0.4rem 0.8rem; border-top: 1px solid #21262d; vertical-align: top; }
|
|
1409
|
-
.fail-row td:first-child { color: #f85149; font-weight: bold; }
|
|
1410
|
-
.warn-row td:first-child { color: #d29922; }
|
|
1411
|
-
.summary { display: flex; gap: 2rem; margin-bottom: 1rem; }
|
|
1412
|
-
.stat { text-align: center; }
|
|
1413
|
-
.stat .n { font-size: 1.8rem; font-weight: bold; }
|
|
1414
|
-
.stat .n.red { color: #f85149; }
|
|
1415
|
-
.stat .n.green { color: #3fb950; }
|
|
1416
|
-
.stat .n.yellow { color: #d29922; }
|
|
1417
|
-
.stat .label { color: #8b949e; font-size: 0.8rem; }
|
|
1418
|
-
.section-title { color: #8b949e; font-size: 0.8rem; text-transform: uppercase;
|
|
1419
|
-
letter-spacing: 0.05em; margin: 1.5rem 0 0.5rem; }
|
|
1420
|
-
.refresh { color: #8b949e; font-size: 0.75rem; float: right; }
|
|
1421
|
-
</style>
|
|
1422
|
-
</head>
|
|
1423
|
-
<body>
|
|
1424
|
-
<h1>rdc:self-test ${badge} Tier ${d.tier}</h1>
|
|
1425
|
-
<div class="meta">${ts} · exit ${d.exit_code}${d.strict ? " · strict" : ""}<span class="refresh">auto-refresh 5s</span></div>
|
|
1426
|
-
|
|
1427
|
-
<div class="summary">
|
|
1428
|
-
<div class="stat"><div class="n">${s.total ?? "?"}</div><div class="label">total</div></div>
|
|
1429
|
-
<div class="stat"><div class="n ${(s.failed || 0) > 0 ? "red" : "green"}">${s.failed ?? s.fail ?? 0}</div><div class="label">failed</div></div>
|
|
1430
|
-
${s.warned != null ? `<div class="stat"><div class="n ${(s.warned) > 0 ? "yellow" : ""}">${s.warned}</div><div class="label">warned</div></div>` : ""}
|
|
1431
|
-
<div class="stat"><div class="n green">${s.passed ?? s.pass ?? 0}</div><div class="label">passed</div></div>
|
|
1432
|
-
${s.wall_ms != null ? `<div class="stat"><div class="n">${(s.wall_ms/1000).toFixed(1)}s</div><div class="label">wall time</div></div>` : ""}
|
|
1433
|
-
</div>
|
|
1434
|
-
|
|
1435
|
-
${failRows ? `<div class="section-title">Failures</div>
|
|
1436
|
-
<table><tr><th>Skill / Scope</th><th>Detail</th></tr>${failRows}</table>` : ""}
|
|
1437
|
-
|
|
1438
|
-
${warnRows ? `<div class="section-title">Warnings</div>
|
|
1439
|
-
<table><tr><th>Skill / Scope</th><th>Detail</th></tr>${warnRows}</table>` : ""}
|
|
1440
|
-
|
|
1441
|
-
${!failRows && !warnRows ? `<p style="color:#3fb950;margin-top:1rem">All checks passed.</p>` : ""}
|
|
1442
|
-
</body>
|
|
1443
|
-
</html>`;
|
|
1444
|
-
}
|
|
1445
|
-
|
|
1446
|
-
if (TIER2) {
|
|
1447
|
-
runTier2().catch((e) => {
|
|
1448
|
-
console.error(`FATAL: ${e.stack || e.message}`);
|
|
1449
|
-
process.exit(2);
|
|
1450
|
-
});
|
|
1451
|
-
} else {
|
|
1452
|
-
try {
|
|
1453
|
-
main();
|
|
1454
|
-
} catch (e) {
|
|
1455
|
-
console.error(`FATAL: ${e.stack || e.message}`);
|
|
1456
|
-
process.exit(2);
|
|
1457
|
-
}
|
|
1458
|
-
}
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// self-test.mjs — Tier 1 static linter for rdc-skills
|
|
3
|
+
//
|
|
4
|
+
// Validates every skill file in skills/ against a checklist:
|
|
5
|
+
// 1. File parseable (readable, has frontmatter)
|
|
6
|
+
// 2. Frontmatter YAML valid (name + description required)
|
|
7
|
+
// 3. description does NOT start with backtick (breaks Claude Code parser)
|
|
8
|
+
// 4. description contains a `Usage `rdc:<name>` marker matching this skill
|
|
9
|
+
// 5. frontmatter name matches filename (rdc:foo ↔ rdc-foo.md)
|
|
10
|
+
// 6. Every referenced guide file exists (guides/*.md references)
|
|
11
|
+
// 7. Every referenced rule file exists (.claude/rules/*.md references)
|
|
12
|
+
// 8. Body contains the standard output-contract banner
|
|
13
|
+
// 9. Plugin manifest (.claude-plugin/plugin.json) exists, parses, has name+version,
|
|
14
|
+
// and version matches package.json
|
|
15
|
+
// 10. No duplicate skill names; no filename collisions with guides/agents/*.md
|
|
16
|
+
// 11. Hook files referenced by skills exist; orphan hook files get a warning
|
|
17
|
+
//
|
|
18
|
+
// Usage:
|
|
19
|
+
// node scripts/self-test.mjs # run all, human output, exit 1 on fail
|
|
20
|
+
// node scripts/self-test.mjs --json # machine-readable schema v2
|
|
21
|
+
// node scripts/self-test.mjs --skill rdc:foo # single skill
|
|
22
|
+
// node scripts/self-test.mjs --strict # warnings become failures
|
|
23
|
+
// node scripts/self-test.mjs --fix # auto-repair fixable findings
|
|
24
|
+
//
|
|
25
|
+
// Exit codes:
|
|
26
|
+
// 0 = all pass
|
|
27
|
+
// 1 = at least one skill/guide failure
|
|
28
|
+
// 2 = runner crashed (unreadable dirs, etc.)
|
|
29
|
+
// 3 = plugin manifest missing (distinct from skill failures)
|
|
30
|
+
|
|
31
|
+
import { readFileSync, readdirSync, existsSync, writeFileSync, appendFileSync, renameSync, mkdirSync, statSync } from "node:fs";
|
|
32
|
+
import { join, basename, dirname, resolve } from "node:path";
|
|
33
|
+
import { fileURLToPath } from "node:url";
|
|
34
|
+
import { spawnSync } from "node:child_process";
|
|
35
|
+
import { loadAllManifests } from "./lib/manifest-schema.mjs";
|
|
36
|
+
import { runManifest } from "./lib/runner.mjs";
|
|
37
|
+
import {
|
|
38
|
+
generateRunId,
|
|
39
|
+
resolveSandboxRef,
|
|
40
|
+
deleteSupabaseTestBranch,
|
|
41
|
+
removeWorktree,
|
|
42
|
+
} from "./lib/sandbox.mjs";
|
|
43
|
+
|
|
44
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
45
|
+
const REPO_ROOT = resolve(__dirname, "..");
|
|
46
|
+
const SKILLS_DIR = join(REPO_ROOT, "skills");
|
|
47
|
+
const GUIDES_DIR = join(REPO_ROOT, "guides");
|
|
48
|
+
const AGENT_GUIDES_DIR = join(GUIDES_DIR, "agents");
|
|
49
|
+
const HOOKS_DIR = join(REPO_ROOT, "hooks");
|
|
50
|
+
const PLUGIN_MANIFEST = join(REPO_ROOT, ".claude-plugin", "plugin.json");
|
|
51
|
+
const PACKAGE_JSON = join(REPO_ROOT, "package.json");
|
|
52
|
+
|
|
53
|
+
// ─── Guide-content validator constants ───────────────────────────────────────
|
|
54
|
+
// Path to the regen-root project (sibling of rdc-skills).
|
|
55
|
+
// Override with REGEN_ROOT env var when running from a non-standard location.
|
|
56
|
+
const REGEN_ROOT = process.env.REGEN_ROOT || "C:/Dev/regen-root";
|
|
57
|
+
|
|
58
|
+
// Terms that must NOT appear in guide/rule files as positive instructions.
|
|
59
|
+
// A line is flagged only when it does NOT contain a known negation pattern.
|
|
60
|
+
const GUIDE_BANNED_TERMS = [
|
|
61
|
+
"@masonator/coolify-mcp",
|
|
62
|
+
"@masonator",
|
|
63
|
+
"coolify-mcp",
|
|
64
|
+
"@regen/brand-studio",
|
|
65
|
+
"brand-studio",
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
// Negation patterns — if a line contains one of these, the banned-term
|
|
69
|
+
// occurrence is an explicit "don't use" warning and should NOT be flagged.
|
|
70
|
+
const GUIDE_NEGATION_PATTERNS = [
|
|
71
|
+
/\bdo not\b/i,
|
|
72
|
+
/\bnever\b/i,
|
|
73
|
+
/\bno such\b/i,
|
|
74
|
+
/\bdoes not exist\b/i,
|
|
75
|
+
/\bbanned\b/i,
|
|
76
|
+
/\bnot reference\b/i,
|
|
77
|
+
/\bnot use\b/i,
|
|
78
|
+
/\bavoid\b/i,
|
|
79
|
+
/\bremoved\b/i,
|
|
80
|
+
/\bdeprecated\b/i,
|
|
81
|
+
// Markdown table row showing a WRONG→CORRECT mapping (naming-corrections.md pattern)
|
|
82
|
+
/^\|[^|]*WRONG[^|]*\|/i,
|
|
83
|
+
// A table row where the term is in the WRONG column (first data column after the | WRONG | header)
|
|
84
|
+
// Heuristic: line starts with | and the term appears before the first CORRECT value
|
|
85
|
+
/^\|\s*(Brand Studio|brand-studio|@regen\/brand-studio|@masonator[^ |]*|coolify-mcp)[^|]*\|\s*\*\*/,
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
// Known valid clauth key names. Extend when new keys are added to the vault.
|
|
89
|
+
// Source: C:/Dev/regen-root/.rdc/guides/agent-bootstrap.md credential table
|
|
90
|
+
// + C:/Dev/regen-root/.claude/rules/clauth-endpoints.md
|
|
91
|
+
const KNOWN_CLAUTH_KEYS = new Set([
|
|
92
|
+
"coolify-api",
|
|
93
|
+
"cloudflare",
|
|
94
|
+
"npm",
|
|
95
|
+
"supabase",
|
|
96
|
+
"supabase-anon",
|
|
97
|
+
"supabase-db",
|
|
98
|
+
"r2-access-key-id",
|
|
99
|
+
"r2-secret-key",
|
|
100
|
+
"anthropic",
|
|
101
|
+
"openai",
|
|
102
|
+
"github",
|
|
103
|
+
"github-token",
|
|
104
|
+
"vultr-dev-ssh",
|
|
105
|
+
"mcp-web-research-secret",
|
|
106
|
+
"mcp-regen-media-secret",
|
|
107
|
+
"web-research",
|
|
108
|
+
"regen-media",
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Audit a single guide or rule file for banned terms and clauth key validity.
|
|
113
|
+
*
|
|
114
|
+
* Returns an array of finding objects:
|
|
115
|
+
* { level: 'error'|'warn', code: string, file: string, line: number, message: string }
|
|
116
|
+
*/
|
|
117
|
+
function auditGuideFile(filepath, relPath) {
|
|
118
|
+
const findings = [];
|
|
119
|
+
let text;
|
|
120
|
+
try {
|
|
121
|
+
text = readFileSync(filepath, "utf8");
|
|
122
|
+
} catch (e) {
|
|
123
|
+
findings.push({ level: "error", code: "guide-unreadable", file: relPath, line: 0, message: `cannot read: ${e.message}` });
|
|
124
|
+
return findings;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const lines = text.split(/\r?\n/);
|
|
128
|
+
|
|
129
|
+
if (
|
|
130
|
+
relPath === "guides/agent-bootstrap.md" &&
|
|
131
|
+
!text.includes("guides/engineering-behavior.md")
|
|
132
|
+
) {
|
|
133
|
+
findings.push({
|
|
134
|
+
level: "error",
|
|
135
|
+
code: "guide-engineering-behavior-missing",
|
|
136
|
+
file: relPath,
|
|
137
|
+
line: 1,
|
|
138
|
+
message: "agent bootstrap must reference guides/engineering-behavior.md",
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
lines.forEach((line, i) => {
|
|
143
|
+
const lineNo = i + 1;
|
|
144
|
+
|
|
145
|
+
// Check banned terms (skip negation lines and comment lines)
|
|
146
|
+
for (const term of GUIDE_BANNED_TERMS) {
|
|
147
|
+
if (line.includes(term)) {
|
|
148
|
+
const isNegated = GUIDE_NEGATION_PATTERNS.some((re) => re.test(line));
|
|
149
|
+
if (!isNegated) {
|
|
150
|
+
findings.push({
|
|
151
|
+
level: "error",
|
|
152
|
+
code: "guide-banned-term",
|
|
153
|
+
file: relPath,
|
|
154
|
+
line: lineNo,
|
|
155
|
+
message: `banned term "${term}" found as positive instruction — replace with clauth+REST pattern`,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Check clauth /v/<key> references — flag unknown key names
|
|
162
|
+
// Pattern: /v/some-key-name (standalone path, not inside a larger URL path)
|
|
163
|
+
const clauthKeyRe = /\bhttp:\/\/127\.0\.0\.1:52437\/v\/([\w-]+)/g;
|
|
164
|
+
let m;
|
|
165
|
+
while ((m = clauthKeyRe.exec(line)) !== null) {
|
|
166
|
+
const key = m[1];
|
|
167
|
+
if (!KNOWN_CLAUTH_KEYS.has(key)) {
|
|
168
|
+
findings.push({
|
|
169
|
+
level: "warn",
|
|
170
|
+
code: "guide-clauth-key-unknown",
|
|
171
|
+
file: relPath,
|
|
172
|
+
line: lineNo,
|
|
173
|
+
message: `clauth key "${key}" not in known key list — verify it exists in the vault`,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
return findings;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Run the guide-content validator across:
|
|
184
|
+
* 1. C:/Dev/rdc-skills/guides/**\/*.md (base guides shipped with the plugin)
|
|
185
|
+
* 2. REGEN_ROOT/.rdc/guides/**\/*.md (project-level agent guides)
|
|
186
|
+
* 3. REGEN_ROOT/.claude/rules/**\/*.md (project auto-loaded rules)
|
|
187
|
+
*
|
|
188
|
+
* Also scans fixture guides under scripts/fixtures/guides/ when GUIDE_FIXTURE_DIR is set
|
|
189
|
+
* (used by the self-test's own test suite).
|
|
190
|
+
*
|
|
191
|
+
* Returns { findings[], ok, stats }
|
|
192
|
+
*/
|
|
193
|
+
function runGuideContentValidator({ fixtureDir } = {}) {
|
|
194
|
+
const allFindings = [];
|
|
195
|
+
let filesScanned = 0;
|
|
196
|
+
|
|
197
|
+
function scanDir(dir, prefix) {
|
|
198
|
+
if (!existsSync(dir)) return;
|
|
199
|
+
let entries;
|
|
200
|
+
try { entries = readdirSync(dir); } catch { return; }
|
|
201
|
+
for (const entry of entries) {
|
|
202
|
+
const fullPath = join(dir, entry);
|
|
203
|
+
let stat;
|
|
204
|
+
try { stat = statSync(fullPath); } catch { continue; }
|
|
205
|
+
if (stat.isDirectory()) {
|
|
206
|
+
scanDir(fullPath, `${prefix}/${entry}`);
|
|
207
|
+
} else if (entry.endsWith(".md")) {
|
|
208
|
+
const relPath = `${prefix}/${entry}`;
|
|
209
|
+
const findings = auditGuideFile(fullPath, relPath);
|
|
210
|
+
allFindings.push(...findings);
|
|
211
|
+
filesScanned++;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// 1. Base guides in rdc-skills repo
|
|
217
|
+
scanDir(GUIDES_DIR, "guides");
|
|
218
|
+
|
|
219
|
+
// 2. Regen-root agent guides
|
|
220
|
+
scanDir(join(REGEN_ROOT, ".rdc", "guides"), "regen-root/.rdc/guides");
|
|
221
|
+
|
|
222
|
+
// 3. Regen-root auto-loaded rules
|
|
223
|
+
scanDir(join(REGEN_ROOT, ".claude", "rules"), "regen-root/.claude/rules");
|
|
224
|
+
|
|
225
|
+
// 4. Fixture directory (for self-test's own test assertions)
|
|
226
|
+
if (fixtureDir && existsSync(fixtureDir)) {
|
|
227
|
+
scanDir(fixtureDir, "fixtures");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const errors = allFindings.filter((f) => f.level === "error");
|
|
231
|
+
const warnings = allFindings.filter((f) => f.level === "warn");
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
findings: allFindings,
|
|
235
|
+
errors,
|
|
236
|
+
warnings,
|
|
237
|
+
ok: errors.length === 0,
|
|
238
|
+
stats: { files_scanned: filesScanned, errors: errors.length, warnings: warnings.length },
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const STANDARD_BANNER = [
|
|
243
|
+
"> **⚠️ OUTPUT CONTRACT (READ FIRST):** `guides/output-contract.md`",
|
|
244
|
+
"> Checklist-only output. No tool-call narration. No raw MCP/JSON/log dumps.",
|
|
245
|
+
"> One checklist upfront, updated in place, shown again at end with a 1-line verdict.",
|
|
246
|
+
"",
|
|
247
|
+
"",
|
|
248
|
+
].join("\n");
|
|
249
|
+
|
|
250
|
+
const argv = process.argv.slice(2);
|
|
251
|
+
const args = new Set(argv);
|
|
252
|
+
const STRICT = args.has("--strict");
|
|
253
|
+
const JSON_OUT = args.has("--json");
|
|
254
|
+
const FIX = args.has("--fix");
|
|
255
|
+
const TIER2 = args.has("--tier2");
|
|
256
|
+
const QUICK = args.has("--quick");
|
|
257
|
+
const ONLY_SKILLS = [];
|
|
258
|
+
for (let i = 0; i < argv.length; i++) {
|
|
259
|
+
if (argv[i] === "--skill" && argv[i + 1]) {
|
|
260
|
+
for (const s of argv[i + 1].split(",")) {
|
|
261
|
+
const t = s.trim();
|
|
262
|
+
if (t) ONLY_SKILLS.push(t);
|
|
263
|
+
}
|
|
264
|
+
i++;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const ONLY_SKILL = ONLY_SKILLS.length === 1 ? ONLY_SKILLS[0] : null; // backwards-compat for single-skill Tier 1 path
|
|
268
|
+
const parallelArgIdx = argv.indexOf("--parallel");
|
|
269
|
+
const PARALLEL = parallelArgIdx >= 0 ? Math.max(1, parseInt(argv[parallelArgIdx + 1], 10) || 3) : 3;
|
|
270
|
+
const logArgIdx = argv.indexOf("--log");
|
|
271
|
+
const LIVE_LOG = logArgIdx >= 0 ? argv[logArgIdx + 1] : null;
|
|
272
|
+
|
|
273
|
+
/** Append a timestamped line to the live log file (if --log was passed). */
|
|
274
|
+
function liveLog(kind, msg) {
|
|
275
|
+
if (!LIVE_LOG) return;
|
|
276
|
+
const ts = new Date().toISOString();
|
|
277
|
+
try { appendFileSync(LIVE_LOG, `[${ts}] [${kind}] ${msg}\n`); } catch {}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Track files modified by --fix so caller can git diff
|
|
281
|
+
const FIXED_FILES = [];
|
|
282
|
+
|
|
283
|
+
function escapeRegExp(s) {
|
|
284
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function parseFrontmatter(text) {
|
|
288
|
+
text = text.replace(/\r\n/g, "\n");
|
|
289
|
+
const m = text.match(/^---\n([\s\S]*?)\n---\n?/);
|
|
290
|
+
if (!m) return { error: "no frontmatter block" };
|
|
291
|
+
const raw = m[1];
|
|
292
|
+
const body = text.slice(m[0].length);
|
|
293
|
+
const fmEnd = m[0].length;
|
|
294
|
+
|
|
295
|
+
const nameMatch = raw.match(/^name:\s*(.+?)\s*$/m);
|
|
296
|
+
if (!nameMatch) return { error: "frontmatter missing `name:`" };
|
|
297
|
+
const name = nameMatch[1].trim();
|
|
298
|
+
|
|
299
|
+
let description = null;
|
|
300
|
+
let descStartsWithBacktick = false;
|
|
301
|
+
|
|
302
|
+
const plainDesc = raw.match(/^description:[ \t]+([^\n>| \t].*)$/m);
|
|
303
|
+
const foldedDesc = raw.match(/^description:[ \t]*>[-+]?[ \t]*\n((?:[ \t]+.*(?:\n|$))+)/m);
|
|
304
|
+
const literalDesc = raw.match(/^description:[ \t]*\|[-+]?[ \t]*\n((?:[ \t]+.*(?:\n|$))+)/m);
|
|
305
|
+
|
|
306
|
+
if (plainDesc) {
|
|
307
|
+
description = plainDesc[1].trim();
|
|
308
|
+
descStartsWithBacktick = description.startsWith("`");
|
|
309
|
+
} else if (foldedDesc || literalDesc) {
|
|
310
|
+
const block = (foldedDesc || literalDesc)[1];
|
|
311
|
+
const lines = block
|
|
312
|
+
.split(/\n/)
|
|
313
|
+
.map((l) => l.replace(/^[ \t]+/, ""))
|
|
314
|
+
.filter((l) => l.length > 0);
|
|
315
|
+
description = lines.join(foldedDesc ? " " : "\n").trim();
|
|
316
|
+
const firstLine = lines[0] || "";
|
|
317
|
+
descStartsWithBacktick = firstLine.startsWith("`");
|
|
318
|
+
} else {
|
|
319
|
+
return { error: "frontmatter missing `description:`" };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return { name, description, descStartsWithBacktick, body, raw, fmEnd };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function expectedSkillName(dirOrFile) {
|
|
326
|
+
// Accept current directory names (foo), legacy directory names
|
|
327
|
+
// (rdc-foo), or legacy filenames (rdc-foo.md).
|
|
328
|
+
const base = basename(dirOrFile, ".md");
|
|
329
|
+
if (base.startsWith("rdc-")) return "rdc:" + base.slice(4);
|
|
330
|
+
if (base === "tests" || base.startsWith(".")) return null;
|
|
331
|
+
return "rdc:" + base;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function expectedFrontmatterName(dirOrFile, frontmatterName) {
|
|
335
|
+
const base = basename(dirOrFile, ".md");
|
|
336
|
+
if (frontmatterName && frontmatterName.startsWith("rdc:")) {
|
|
337
|
+
return expectedSkillName(dirOrFile);
|
|
338
|
+
}
|
|
339
|
+
return base;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function findReferencedFiles(body) {
|
|
343
|
+
const refs = [];
|
|
344
|
+
const guideRe = /(?:^|[\s(`'"/])(?:\.rdc\/)?guides\/([\w/-]+\.md)/g;
|
|
345
|
+
let m;
|
|
346
|
+
while ((m = guideRe.exec(body)) !== null) {
|
|
347
|
+
refs.push({ kind: "guide", name: m[1] });
|
|
348
|
+
}
|
|
349
|
+
const ruleRe = /\.claude\/rules\/([\w-]+\.md)/g;
|
|
350
|
+
while ((m = ruleRe.exec(body)) !== null) {
|
|
351
|
+
refs.push({ kind: "rule", name: m[1] });
|
|
352
|
+
}
|
|
353
|
+
const hookRe = /(?:^|[\s(`'"/])hooks\/([\w.-]+\.(?:js|mjs|cjs))/g;
|
|
354
|
+
while ((m = hookRe.exec(body)) !== null) {
|
|
355
|
+
refs.push({ kind: "hook", name: m[1] });
|
|
356
|
+
}
|
|
357
|
+
return refs;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function addFinding(result, level, code, message) {
|
|
361
|
+
result.findings.push({ level, code, message });
|
|
362
|
+
if (level === "error") result.errors.push(message);
|
|
363
|
+
else result.warnings.push(message);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function tryAutoFix(filepath, fm, text, result) {
|
|
367
|
+
if (!FIX) return false;
|
|
368
|
+
let changed = false;
|
|
369
|
+
let newText = text;
|
|
370
|
+
let newPath = filepath;
|
|
371
|
+
|
|
372
|
+
// Fix: insert OUTPUT CONTRACT banner after frontmatter if missing
|
|
373
|
+
if (!/OUTPUT CONTRACT/.test(fm.body)) {
|
|
374
|
+
const fmBlock = newText.slice(0, fm.fmEnd);
|
|
375
|
+
const rest = newText.slice(fm.fmEnd).replace(/^\n+/, "");
|
|
376
|
+
newText = fmBlock + "\n" + STANDARD_BANNER + rest;
|
|
377
|
+
writeFileSync(filepath, newText);
|
|
378
|
+
console.log(`FIXED: ${result.name || result.file} — inserted OUTPUT CONTRACT banner`);
|
|
379
|
+
FIXED_FILES.push(filepath);
|
|
380
|
+
changed = true;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Fix: filename/name mismatch — rename directory or file to match frontmatter name
|
|
384
|
+
const _filename = basename(filepath);
|
|
385
|
+
const _dirName = basename(dirname(filepath));
|
|
386
|
+
const _isSubdir = _filename === "SKILL.md";
|
|
387
|
+
const _skillDirOrFile = _isSubdir ? _dirName : _filename;
|
|
388
|
+
const expected = expectedSkillName(_skillDirOrFile);
|
|
389
|
+
if (expected && fm.name !== expected && fm.name.startsWith("rdc:")) {
|
|
390
|
+
if (_isSubdir) {
|
|
391
|
+
// Subdirectory layout: rename the parent directory
|
|
392
|
+
const targetDirName = "rdc-" + fm.name.slice(4);
|
|
393
|
+
const targetDirPath = join(dirname(dirname(filepath)), targetDirName);
|
|
394
|
+
if (!existsSync(targetDirPath)) {
|
|
395
|
+
renameSync(dirname(filepath), targetDirPath);
|
|
396
|
+
const targetPath = join(targetDirPath, "SKILL.md");
|
|
397
|
+
console.log(`FIXED: ${fm.name} — renamed dir ${_dirName} → ${targetDirName}`);
|
|
398
|
+
FIXED_FILES.push(targetPath);
|
|
399
|
+
newPath = targetPath;
|
|
400
|
+
changed = true;
|
|
401
|
+
}
|
|
402
|
+
} else {
|
|
403
|
+
const targetBase = "rdc-" + fm.name.slice(4) + ".md";
|
|
404
|
+
const targetPath = join(dirname(filepath), targetBase);
|
|
405
|
+
if (!existsSync(targetPath)) {
|
|
406
|
+
renameSync(filepath, targetPath);
|
|
407
|
+
console.log(`FIXED: ${fm.name} — renamed ${_filename} → ${targetBase}`);
|
|
408
|
+
FIXED_FILES.push(targetPath);
|
|
409
|
+
newPath = targetPath;
|
|
410
|
+
changed = true;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return changed ? newPath : false;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function auditSkill(filepath) {
|
|
419
|
+
const filename = basename(filepath);
|
|
420
|
+
// Support both flat (rdc-foo.md) and subdirectory (rdc-foo/SKILL.md) layouts
|
|
421
|
+
const dirName = basename(dirname(filepath)); // "rdc-foo" when filepath is .../rdc-foo/SKILL.md
|
|
422
|
+
const isSubdir = filename === "SKILL.md";
|
|
423
|
+
const skillDirOrFile = isSubdir ? dirName : filename;
|
|
424
|
+
const result = {
|
|
425
|
+
skill: null,
|
|
426
|
+
file: isSubdir ? `skills/${dirName}/SKILL.md` : `skills/${filename}`,
|
|
427
|
+
name: null,
|
|
428
|
+
pass: true,
|
|
429
|
+
errors: [],
|
|
430
|
+
warnings: [],
|
|
431
|
+
findings: [],
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
let text;
|
|
435
|
+
try {
|
|
436
|
+
text = readFileSync(filepath, "utf8");
|
|
437
|
+
} catch (e) {
|
|
438
|
+
addFinding(result, "error", "unreadable", `cannot read file: ${e.message}`);
|
|
439
|
+
result.pass = false;
|
|
440
|
+
return result;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const fm = parseFrontmatter(text);
|
|
444
|
+
if (fm.error) {
|
|
445
|
+
addFinding(result, "error", "frontmatter-invalid", `frontmatter: ${fm.error}`);
|
|
446
|
+
result.pass = false;
|
|
447
|
+
return result;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
result.name = fm.name;
|
|
451
|
+
result.skill = fm.name;
|
|
452
|
+
|
|
453
|
+
if (fm.descStartsWithBacktick) {
|
|
454
|
+
addFinding(
|
|
455
|
+
result,
|
|
456
|
+
"error",
|
|
457
|
+
"description-backtick-leading",
|
|
458
|
+
"description starts with backtick — Claude Code parser will silently drop this skill from the menu",
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Usage marker — must reference this skill's own name
|
|
463
|
+
if (fm.name.startsWith("rdc:")) {
|
|
464
|
+
const escapedName = escapeRegExp(fm.name);
|
|
465
|
+
const usageRe = new RegExp("Usage\\s+`" + escapedName + "[ \\\\`]", "i");
|
|
466
|
+
const anyUsageRe = /\bUsage\s+`/i;
|
|
467
|
+
if (!anyUsageRe.test(fm.description)) {
|
|
468
|
+
addFinding(
|
|
469
|
+
result,
|
|
470
|
+
"warn",
|
|
471
|
+
"usage-marker-missing",
|
|
472
|
+
"description missing `Usage \\`rdc:name <args>\\`` marker — users can't see arg contract in menu",
|
|
473
|
+
);
|
|
474
|
+
} else if (!usageRe.test(fm.description)) {
|
|
475
|
+
addFinding(
|
|
476
|
+
result,
|
|
477
|
+
"warn",
|
|
478
|
+
"usage-marker-mismatch",
|
|
479
|
+
`Usage marker in description does not reference own name "${fm.name}" — copy-paste drift?`,
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const expected = expectedFrontmatterName(skillDirOrFile, fm.name);
|
|
485
|
+
if (expected && fm.name !== expected) {
|
|
486
|
+
addFinding(
|
|
487
|
+
result,
|
|
488
|
+
"error",
|
|
489
|
+
"name-filename-mismatch",
|
|
490
|
+
`name mismatch: frontmatter says "${fm.name}" but filename implies "${expected}"`,
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const refs = findReferencedFiles(fm.body);
|
|
495
|
+
const seen = new Set();
|
|
496
|
+
for (const ref of refs) {
|
|
497
|
+
const key = `${ref.kind}:${ref.name}`;
|
|
498
|
+
if (seen.has(key)) continue;
|
|
499
|
+
seen.add(key);
|
|
500
|
+
if (ref.kind === "guide") {
|
|
501
|
+
if (!existsSync(join(GUIDES_DIR, ref.name))) {
|
|
502
|
+
addFinding(
|
|
503
|
+
result,
|
|
504
|
+
"warn",
|
|
505
|
+
"guide-not-found",
|
|
506
|
+
`referenced guide not found in repo: guides/${ref.name}`,
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
if (ref.kind === "rule") {
|
|
511
|
+
const regenRoot = "C:/Dev/regen-root/.claude/rules";
|
|
512
|
+
if (existsSync(regenRoot) && !existsSync(join(regenRoot, ref.name))) {
|
|
513
|
+
addFinding(
|
|
514
|
+
result,
|
|
515
|
+
"warn",
|
|
516
|
+
"rule-not-found",
|
|
517
|
+
`referenced rule not found in regen-root: .claude/rules/${ref.name}`,
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (ref.kind === "hook") {
|
|
522
|
+
if (!existsSync(join(HOOKS_DIR, ref.name))) {
|
|
523
|
+
addFinding(
|
|
524
|
+
result,
|
|
525
|
+
"error",
|
|
526
|
+
"hook-not-found",
|
|
527
|
+
`referenced hook file not found: hooks/${ref.name}`,
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (!/OUTPUT CONTRACT/.test(fm.body)) {
|
|
534
|
+
addFinding(
|
|
535
|
+
result,
|
|
536
|
+
"warn",
|
|
537
|
+
"banner-missing",
|
|
538
|
+
"body missing OUTPUT CONTRACT banner (guides/output-contract.md reference)",
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Try auto-fix, then re-audit the same skill once to reflect new state
|
|
543
|
+
if (FIX && (result.errors.length > 0 || result.warnings.length > 0)) {
|
|
544
|
+
const newPath = tryAutoFix(filepath, fm, text, result);
|
|
545
|
+
if (newPath) return auditSkill(newPath);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
result.pass = result.errors.length === 0;
|
|
549
|
+
return result;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function auditAgentGuide(filepath) {
|
|
553
|
+
const filename = basename(filepath);
|
|
554
|
+
const result = {
|
|
555
|
+
file: `guides/agents/${filename}`,
|
|
556
|
+
name: filename,
|
|
557
|
+
pass: true,
|
|
558
|
+
errors: [],
|
|
559
|
+
warnings: [],
|
|
560
|
+
findings: [],
|
|
561
|
+
};
|
|
562
|
+
let text;
|
|
563
|
+
try {
|
|
564
|
+
text = readFileSync(filepath, "utf8");
|
|
565
|
+
} catch (e) {
|
|
566
|
+
addFinding(result, "error", "unreadable", `cannot read file: ${e.message}`);
|
|
567
|
+
result.pass = false;
|
|
568
|
+
return result;
|
|
569
|
+
}
|
|
570
|
+
if (text.replace(/\r\n/g, "\n").startsWith("---\n")) {
|
|
571
|
+
addFinding(
|
|
572
|
+
result,
|
|
573
|
+
"error",
|
|
574
|
+
"agent-guide-has-frontmatter",
|
|
575
|
+
"agent guide still has frontmatter — should be plain markdown",
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
if (!/OUTPUT CONTRACT/.test(text)) {
|
|
579
|
+
addFinding(result, "warn", "banner-missing", "body missing OUTPUT CONTRACT banner");
|
|
580
|
+
}
|
|
581
|
+
if (text.trim().length === 0) {
|
|
582
|
+
addFinding(result, "error", "empty-file", "file is empty");
|
|
583
|
+
}
|
|
584
|
+
result.pass = result.errors.length === 0;
|
|
585
|
+
return result;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Plugin manifest check. Returns { ok, exitCode, findings }
|
|
589
|
+
function auditPluginManifest() {
|
|
590
|
+
const findings = [];
|
|
591
|
+
if (!existsSync(PLUGIN_MANIFEST)) {
|
|
592
|
+
findings.push({
|
|
593
|
+
level: "error",
|
|
594
|
+
code: "manifest-missing",
|
|
595
|
+
message: `.claude-plugin/plugin.json not found`,
|
|
596
|
+
});
|
|
597
|
+
return { ok: false, exitCode: 3, findings, manifest: null };
|
|
598
|
+
}
|
|
599
|
+
let manifest;
|
|
600
|
+
try {
|
|
601
|
+
manifest = JSON.parse(readFileSync(PLUGIN_MANIFEST, "utf8"));
|
|
602
|
+
} catch (e) {
|
|
603
|
+
findings.push({
|
|
604
|
+
level: "error",
|
|
605
|
+
code: "manifest-invalid-json",
|
|
606
|
+
message: `plugin.json invalid JSON: ${e.message}`,
|
|
607
|
+
});
|
|
608
|
+
return { ok: false, exitCode: 1, findings, manifest: null };
|
|
609
|
+
}
|
|
610
|
+
if (!manifest.name) {
|
|
611
|
+
findings.push({ level: "error", code: "manifest-missing-name", message: "plugin.json missing `name`" });
|
|
612
|
+
}
|
|
613
|
+
if (!manifest.version) {
|
|
614
|
+
findings.push({
|
|
615
|
+
level: "error",
|
|
616
|
+
code: "manifest-missing-version",
|
|
617
|
+
message: "plugin.json missing `version`",
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
try {
|
|
621
|
+
const pkg = JSON.parse(readFileSync(PACKAGE_JSON, "utf8"));
|
|
622
|
+
if (manifest.version && pkg.version && manifest.version !== pkg.version) {
|
|
623
|
+
findings.push({
|
|
624
|
+
level: "error",
|
|
625
|
+
code: "manifest-version-mismatch",
|
|
626
|
+
message: `plugin.json version "${manifest.version}" ≠ package.json version "${pkg.version}"`,
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
} catch (e) {
|
|
630
|
+
findings.push({
|
|
631
|
+
level: "warn",
|
|
632
|
+
code: "package-json-unreadable",
|
|
633
|
+
message: `could not read package.json for version cross-check: ${e.message}`,
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
const ok = findings.every((f) => f.level !== "error");
|
|
637
|
+
return { ok, exitCode: ok ? 0 : 1, findings, manifest };
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Duplicate skill name + filename collision check
|
|
641
|
+
function auditDuplicates(results) {
|
|
642
|
+
const findings = [];
|
|
643
|
+
const nameMap = new Map();
|
|
644
|
+
for (const r of results) {
|
|
645
|
+
if (!r.name) continue;
|
|
646
|
+
if (nameMap.has(r.name)) {
|
|
647
|
+
findings.push({
|
|
648
|
+
level: "error",
|
|
649
|
+
code: "duplicate-skill-name",
|
|
650
|
+
message: `duplicate skill name "${r.name}" in ${nameMap.get(r.name)} and ${r.file}`,
|
|
651
|
+
});
|
|
652
|
+
} else {
|
|
653
|
+
nameMap.set(r.name, r.file);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
if (existsSync(AGENT_GUIDES_DIR)) {
|
|
657
|
+
const agentBases = new Set(
|
|
658
|
+
readdirSync(AGENT_GUIDES_DIR)
|
|
659
|
+
.filter((f) => f.endsWith(".md"))
|
|
660
|
+
.map((f) => basename(f, ".md")),
|
|
661
|
+
);
|
|
662
|
+
for (const r of results) {
|
|
663
|
+
// r.file is either "skills/rdc-foo/SKILL.md" or legacy "skills/rdc-foo.md"
|
|
664
|
+
// Extract the skill directory/base name in both cases
|
|
665
|
+
const parts = r.file.replace(/\\/g, "/").split("/");
|
|
666
|
+
let skillBase;
|
|
667
|
+
if (parts.length >= 3 && parts[2] === "SKILL.md") {
|
|
668
|
+
skillBase = parts[1]; // "rdc-foo" from "skills/rdc-foo/SKILL.md"
|
|
669
|
+
} else {
|
|
670
|
+
skillBase = basename(r.file, ".md"); // legacy
|
|
671
|
+
}
|
|
672
|
+
const stem = skillBase.startsWith("rdc-") ? skillBase.slice(4) : skillBase;
|
|
673
|
+
if (agentBases.has(stem)) {
|
|
674
|
+
findings.push({
|
|
675
|
+
level: "info",
|
|
676
|
+
code: "skill-guide-name-overlap",
|
|
677
|
+
message: `skills/${skillBase}/SKILL.md overlaps guides/agents/${stem}.md; allowed when a user-facing skill delegates to an agent guide`,
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
return findings;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Orphan hook scan — warn when hooks/ files aren't referenced anywhere
|
|
686
|
+
function auditOrphanHooks(results) {
|
|
687
|
+
const findings = [];
|
|
688
|
+
if (!existsSync(HOOKS_DIR)) return findings;
|
|
689
|
+
let hookFiles;
|
|
690
|
+
try {
|
|
691
|
+
hookFiles = readdirSync(HOOKS_DIR).filter((f) => /\.(?:js|mjs|cjs)$/.test(f));
|
|
692
|
+
} catch {
|
|
693
|
+
return findings;
|
|
694
|
+
}
|
|
695
|
+
const referenced = new Set();
|
|
696
|
+
for (const r of results) {
|
|
697
|
+
for (const f of r.findings) {
|
|
698
|
+
// no-op: findings don't carry refs. Re-scan body below.
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
// Re-scan skill bodies + known config files for hook refs
|
|
702
|
+
const sources = [
|
|
703
|
+
...readdirSync(SKILLS_DIR)
|
|
704
|
+
.filter((f) => {
|
|
705
|
+
try {
|
|
706
|
+
return statSync(join(SKILLS_DIR, f)).isDirectory() && existsSync(join(SKILLS_DIR, f, "SKILL.md"));
|
|
707
|
+
} catch { return false; }
|
|
708
|
+
})
|
|
709
|
+
.map((f) => join(SKILLS_DIR, f, "SKILL.md")),
|
|
710
|
+
join(REPO_ROOT, ".claude", "settings.json"),
|
|
711
|
+
join(REPO_ROOT, "scripts", "install-rdc-skills.js"),
|
|
712
|
+
PLUGIN_MANIFEST,
|
|
713
|
+
];
|
|
714
|
+
for (const src of sources) {
|
|
715
|
+
if (!existsSync(src)) continue;
|
|
716
|
+
try {
|
|
717
|
+
const text = readFileSync(src, "utf8");
|
|
718
|
+
// Match either "hooks/foo.js" paths or bare basenames (stem match)
|
|
719
|
+
const pathRe = /hooks\/([\w.-]+\.(?:js|mjs|cjs))/g;
|
|
720
|
+
let m;
|
|
721
|
+
while ((m = pathRe.exec(text)) !== null) referenced.add(m[1]);
|
|
722
|
+
for (const hf of hookFiles) {
|
|
723
|
+
const stem = hf.replace(/\.(?:js|mjs|cjs)$/, "");
|
|
724
|
+
if (text.includes(hf) || new RegExp("\\b" + escapeRegExp(stem) + "\\b").test(text)) {
|
|
725
|
+
referenced.add(hf);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
} catch {}
|
|
729
|
+
}
|
|
730
|
+
for (const hf of hookFiles) {
|
|
731
|
+
if (!referenced.has(hf)) {
|
|
732
|
+
findings.push({
|
|
733
|
+
level: "info",
|
|
734
|
+
code: "orphan-hook",
|
|
735
|
+
message: `hooks/${hf} exists but is not referenced by any skill; plugin hooks may still be convention-discovered or wired by settings`,
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
return findings;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function auditRequiredHookWiring() {
|
|
743
|
+
const findings = [];
|
|
744
|
+
const required = [
|
|
745
|
+
{
|
|
746
|
+
file: join(REPO_ROOT, "scripts", "install-rdc-skills.js"),
|
|
747
|
+
label: "scripts/install-rdc-skills.js",
|
|
748
|
+
tokens: [
|
|
749
|
+
"UserPromptExpansion",
|
|
750
|
+
"UserPromptSubmit",
|
|
751
|
+
"Stop",
|
|
752
|
+
"rdc-invocation-marker.js",
|
|
753
|
+
"rdc-output-contract-gate.js",
|
|
754
|
+
],
|
|
755
|
+
},
|
|
756
|
+
{
|
|
757
|
+
file: join(REPO_ROOT, "scripts", "install.ps1"),
|
|
758
|
+
label: "scripts/install.ps1",
|
|
759
|
+
tokens: [
|
|
760
|
+
"UserPromptExpansion",
|
|
761
|
+
"UserPromptSubmit",
|
|
762
|
+
"Stop",
|
|
763
|
+
"rdc-invocation-marker.js",
|
|
764
|
+
"rdc-output-contract-gate.js",
|
|
765
|
+
],
|
|
766
|
+
},
|
|
767
|
+
];
|
|
768
|
+
|
|
769
|
+
for (const hookFile of [
|
|
770
|
+
"rdc-invocation-marker.js",
|
|
771
|
+
"rdc-output-contract-gate.js",
|
|
772
|
+
]) {
|
|
773
|
+
if (!existsSync(join(HOOKS_DIR, hookFile))) {
|
|
774
|
+
findings.push({
|
|
775
|
+
level: "error",
|
|
776
|
+
code: "required-hook-missing",
|
|
777
|
+
message: `hooks/${hookFile} is required for RDC output-contract enforcement`,
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
for (const target of required) {
|
|
783
|
+
if (!existsSync(target.file)) {
|
|
784
|
+
findings.push({
|
|
785
|
+
level: "error",
|
|
786
|
+
code: "hook-installer-missing",
|
|
787
|
+
message: `${target.label} not found for RDC hook wiring audit`,
|
|
788
|
+
});
|
|
789
|
+
continue;
|
|
790
|
+
}
|
|
791
|
+
let text = "";
|
|
792
|
+
try { text = readFileSync(target.file, "utf8"); } catch (e) {
|
|
793
|
+
findings.push({
|
|
794
|
+
level: "error",
|
|
795
|
+
code: "hook-installer-unreadable",
|
|
796
|
+
message: `${target.label} unreadable: ${e.message}`,
|
|
797
|
+
});
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
for (const token of target.tokens) {
|
|
801
|
+
if (!text.includes(token)) {
|
|
802
|
+
findings.push({
|
|
803
|
+
level: "error",
|
|
804
|
+
code: "required-hook-wiring-missing",
|
|
805
|
+
message: `${target.label} must reference ${token}`,
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
return findings;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function runHookBehaviorTests() {
|
|
815
|
+
const result = spawnSync(process.execPath, [join(REPO_ROOT, "scripts", "test-rdc-hooks.mjs")], {
|
|
816
|
+
cwd: REPO_ROOT,
|
|
817
|
+
encoding: "utf8",
|
|
818
|
+
});
|
|
819
|
+
return {
|
|
820
|
+
ok: result.status === 0,
|
|
821
|
+
status: result.status,
|
|
822
|
+
stdout: (result.stdout || "").trim(),
|
|
823
|
+
stderr: (result.stderr || "").trim(),
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// ─── Tier 2 behavioral runner ──────────────────────────────────────────────
|
|
828
|
+
|
|
829
|
+
async function runPool(items, concurrency, worker) {
|
|
830
|
+
const results = new Array(items.length);
|
|
831
|
+
let next = 0;
|
|
832
|
+
async function lane() {
|
|
833
|
+
while (true) {
|
|
834
|
+
const i = next++;
|
|
835
|
+
if (i >= items.length) return;
|
|
836
|
+
try {
|
|
837
|
+
results[i] = await worker(items[i], i);
|
|
838
|
+
} catch (e) {
|
|
839
|
+
results[i] = { pass: false, error: e?.message || String(e) };
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
const lanes = Array.from({ length: Math.min(concurrency, items.length) }, () => lane());
|
|
844
|
+
await Promise.all(lanes);
|
|
845
|
+
return results;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
async function runTier2() {
|
|
849
|
+
console.log("\nrdc-skills self-test — Tier 2 (behavioral)\n");
|
|
850
|
+
|
|
851
|
+
// Load manifests
|
|
852
|
+
let all = loadAllManifests();
|
|
853
|
+
if (ONLY_SKILLS.length > 0) {
|
|
854
|
+
all = all.filter((m) => m.manifest && ONLY_SKILLS.includes(m.manifest.skill));
|
|
855
|
+
}
|
|
856
|
+
if (QUICK) {
|
|
857
|
+
all = all.filter((m) => !(m.manifest?.fixture?.slow === true));
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
if (all.length === 0) {
|
|
861
|
+
console.log("No manifests found in skills/tests/.");
|
|
862
|
+
console.log("(Tier 2 will run once WP6/WP7 land baseline manifests.)");
|
|
863
|
+
process.exit(STRICT ? 1 : 0);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// Reject invalid manifests up front
|
|
867
|
+
const invalid = all.filter((m) => !m.ok);
|
|
868
|
+
const valid = all.filter((m) => m.ok);
|
|
869
|
+
for (const m of invalid) {
|
|
870
|
+
console.error(`SKIP ${m.file}: ${m.errors.map((e) => `${e.path}: ${e.msg}`).join("; ")}`);
|
|
871
|
+
}
|
|
872
|
+
if (valid.length === 0) {
|
|
873
|
+
console.error("No valid manifests to run.");
|
|
874
|
+
process.exit(1);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const runId = generateRunId();
|
|
878
|
+
console.log(`runId: ${runId}`);
|
|
879
|
+
console.log(`manifests: ${valid.length} valid, ${invalid.length} invalid`);
|
|
880
|
+
console.log(`parallel: ${PARALLEL}${QUICK ? " quick" : ""}`);
|
|
881
|
+
liveLog("start", `runId=${runId} skills=${valid.map((m) => m.manifest.skill).join(",")}`);
|
|
882
|
+
|
|
883
|
+
// WP-A3: use resolveSandboxRef — defaults to main-db mode (no branch, no cost)
|
|
884
|
+
const sandboxRef = await resolveSandboxRef({ runId });
|
|
885
|
+
console.log(`sandbox mode: ${sandboxRef.mode} apiUrl=${sandboxRef.apiUrl || "none"} anonKey=${sandboxRef.anonKey ? "ok" : "missing"}`);
|
|
886
|
+
liveLog("sandbox", `mode=${sandboxRef.mode} anonKey=${sandboxRef.anonKey ? "ok" : "missing"}`);
|
|
887
|
+
|
|
888
|
+
// Run
|
|
889
|
+
const toRun = valid.map((m) => m.manifest);
|
|
890
|
+
const total = toRun.length;
|
|
891
|
+
let launched = 0;
|
|
892
|
+
let finished = 0;
|
|
893
|
+
const started = Date.now();
|
|
894
|
+
let results;
|
|
895
|
+
try {
|
|
896
|
+
results = await runPool(toRun, PARALLEL, async (manifest) => {
|
|
897
|
+
const n = ++launched;
|
|
898
|
+
const timeoutSec = Math.round((manifest.timeout_ms || 240_000) / 1000);
|
|
899
|
+
console.log(`▶ [${n}/${total}] ${manifest.skill} (timeout ${timeoutSec}s)`);
|
|
900
|
+
liveLog("running", `skill=${manifest.skill}`);
|
|
901
|
+
const r = await runManifest(manifest, {
|
|
902
|
+
runId,
|
|
903
|
+
supabaseBranchRef: sandboxRef,
|
|
904
|
+
projectCwd: process.cwd(),
|
|
905
|
+
});
|
|
906
|
+
const done = ++finished;
|
|
907
|
+
const status = r.error ? "ERROR" : r.pass ? "PASS" : "FAIL";
|
|
908
|
+
const dur = r.duration_ms != null ? `${(r.duration_ms / 1000).toFixed(1)}s` : "?";
|
|
909
|
+
const icon = status === "PASS" ? "✓" : "✗";
|
|
910
|
+
const detail = r.error || (r.failures || []).map((f) => f.message).join("; ") || "";
|
|
911
|
+
console.log(
|
|
912
|
+
`${icon} [${done}/${total}] ${r.skill} ${dur} ${status}${detail ? " — " + detail : ""}`,
|
|
913
|
+
);
|
|
914
|
+
liveLog(status.toLowerCase(), `skill=${r.skill} duration=${r.duration_ms}ms${detail ? " | " + detail : ""}`);
|
|
915
|
+
return r;
|
|
916
|
+
});
|
|
917
|
+
} finally {
|
|
918
|
+
// Teardown — always. Pass process.cwd() so removeWorktree targets the
|
|
919
|
+
// same projectRoot (regen-root) that createWorktree used.
|
|
920
|
+
const projectCwd = process.cwd();
|
|
921
|
+
for (const r of results || []) {
|
|
922
|
+
if (r && r.skill) {
|
|
923
|
+
try { removeWorktree(runId, r.skill, projectCwd); } catch {}
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
try { await deleteSupabaseTestBranch(sandboxRef.branchId); } catch {}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const totalDuration = Date.now() - started;
|
|
930
|
+
|
|
931
|
+
// Report
|
|
932
|
+
const pad = (s, n) => String(s) + " ".repeat(Math.max(0, n - String(s).length));
|
|
933
|
+
console.log("\n" + pad("skill", 24) + pad("status", 10) + pad("duration", 12) + "failures");
|
|
934
|
+
console.log("─".repeat(90));
|
|
935
|
+
let failed = 0;
|
|
936
|
+
let errored = 0;
|
|
937
|
+
for (const r of results) {
|
|
938
|
+
if (r.error) {
|
|
939
|
+
errored++;
|
|
940
|
+
console.log(pad(r.skill || "?", 24) + pad("ERROR", 10) + pad("-", 12) + r.error);
|
|
941
|
+
continue;
|
|
942
|
+
}
|
|
943
|
+
const status = r.pass ? "pass" : "FAIL";
|
|
944
|
+
if (!r.pass) failed++;
|
|
945
|
+
const dur = `${r.duration_ms}ms`;
|
|
946
|
+
const fs = (r.failures || []).map((f) => `${f.predicate}: ${f.message}`).join("; ") || "";
|
|
947
|
+
console.log(pad(r.skill, 24) + pad(status, 10) + pad(dur, 12) + fs);
|
|
948
|
+
}
|
|
949
|
+
console.log("─".repeat(90));
|
|
950
|
+
console.log(
|
|
951
|
+
`total: ${results.length} | pass: ${results.length - failed - errored} | fail: ${failed} | error: ${errored} | wall: ${totalDuration}ms`,
|
|
952
|
+
);
|
|
953
|
+
liveLog("done", `total=${results.length} pass=${results.length - failed - errored} fail=${failed} error=${errored} wall=${totalDuration}ms`);
|
|
954
|
+
|
|
955
|
+
// JSON dump
|
|
956
|
+
try {
|
|
957
|
+
const REPORTS_DIR = resolve(REPO_ROOT, ".rdc", "reports");
|
|
958
|
+
if (!existsSync(REPORTS_DIR)) mkdirSync(REPORTS_DIR, { recursive: true });
|
|
959
|
+
const iso = new Date().toISOString().replace(/[:.]/g, "-");
|
|
960
|
+
const reportPath = join(REPORTS_DIR, `self-test-tier2-${iso}.json`);
|
|
961
|
+
writeFileSync(
|
|
962
|
+
reportPath,
|
|
963
|
+
JSON.stringify(
|
|
964
|
+
{
|
|
965
|
+
run_id: runId,
|
|
966
|
+
started_at: new Date(started).toISOString(),
|
|
967
|
+
duration_ms: totalDuration,
|
|
968
|
+
parallel: PARALLEL,
|
|
969
|
+
quick: QUICK,
|
|
970
|
+
supabase_branch: sandboxRef.branchId || null,
|
|
971
|
+
summary: {
|
|
972
|
+
total: results.length,
|
|
973
|
+
pass: results.length - failed - errored,
|
|
974
|
+
fail: failed,
|
|
975
|
+
error: errored,
|
|
976
|
+
},
|
|
977
|
+
results,
|
|
978
|
+
invalid_manifests: invalid.map((m) => ({ file: m.file, errors: m.errors })),
|
|
979
|
+
},
|
|
980
|
+
null,
|
|
981
|
+
2,
|
|
982
|
+
),
|
|
983
|
+
);
|
|
984
|
+
console.log(`\nreport: ${reportPath}`);
|
|
985
|
+
} catch (e) {
|
|
986
|
+
console.error(`WARN: failed to write JSON report: ${e.message}`);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
writeLastRun({
|
|
990
|
+
tier: 2,
|
|
991
|
+
verdict: (failed > 0 || errored > 0) ? "FAIL" : "PASS",
|
|
992
|
+
exit_code: errored > 0 ? 2 : failed > 0 ? 1 : 0,
|
|
993
|
+
summary: {
|
|
994
|
+
total: results.length,
|
|
995
|
+
passed: results.length - failed - errored,
|
|
996
|
+
failed,
|
|
997
|
+
errored,
|
|
998
|
+
wall_ms: totalDuration,
|
|
999
|
+
},
|
|
1000
|
+
failures: results
|
|
1001
|
+
.filter((r) => !r.pass || r.error)
|
|
1002
|
+
.map((r) => ({
|
|
1003
|
+
skill: r.skill,
|
|
1004
|
+
error: r.error || null,
|
|
1005
|
+
timed_out: r.observed?.timed_out || false,
|
|
1006
|
+
assertions_failed: (r.failures || []).map((f) => `${f.predicate}: ${f.message}`),
|
|
1007
|
+
})),
|
|
1008
|
+
warnings: [],
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
if (errored > 0) process.exit(2);
|
|
1012
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
function main() {
|
|
1016
|
+
// Plugin manifest pass first — can short-circuit with exit 3
|
|
1017
|
+
const manifestAudit = auditPluginManifest();
|
|
1018
|
+
const manifestMissing = manifestAudit.findings.some((f) => f.code === "manifest-missing");
|
|
1019
|
+
|
|
1020
|
+
let files;
|
|
1021
|
+
try {
|
|
1022
|
+
files = readdirSync(SKILLS_DIR).filter((f) => {
|
|
1023
|
+
try {
|
|
1024
|
+
return statSync(join(SKILLS_DIR, f)).isDirectory() && existsSync(join(SKILLS_DIR, f, "SKILL.md"));
|
|
1025
|
+
} catch { return false; }
|
|
1026
|
+
});
|
|
1027
|
+
} catch (e) {
|
|
1028
|
+
console.error(`FATAL: cannot read skills dir ${SKILLS_DIR}: ${e.message}`);
|
|
1029
|
+
process.exit(2);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
if (ONLY_SKILLS.length > 0) {
|
|
1033
|
+
files = files.filter((f) => {
|
|
1034
|
+
return ONLY_SKILLS.some((s) => {
|
|
1035
|
+
const bare = s.replace(/^rdc:/, "");
|
|
1036
|
+
return f === bare || f === s.replace(":", "-");
|
|
1037
|
+
});
|
|
1038
|
+
});
|
|
1039
|
+
if (files.length === 0) {
|
|
1040
|
+
console.error(`no skill file matches: ${ONLY_SKILLS.join(", ")}`);
|
|
1041
|
+
process.exit(2);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
if (JSON_OUT) {
|
|
1046
|
+
// JSON path: buffer everything then dump
|
|
1047
|
+
const results = files.map((f) => auditSkill(join(SKILLS_DIR, f, "SKILL.md")));
|
|
1048
|
+
|
|
1049
|
+
let duplicateFindings = [];
|
|
1050
|
+
let orphanHookFindings = [];
|
|
1051
|
+
if (!ONLY_SKILL) {
|
|
1052
|
+
duplicateFindings = auditDuplicates(results);
|
|
1053
|
+
orphanHookFindings = auditOrphanHooks(results);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
let agentResults = [];
|
|
1057
|
+
if (!ONLY_SKILL && existsSync(AGENT_GUIDES_DIR)) {
|
|
1058
|
+
const agentFiles = readdirSync(AGENT_GUIDES_DIR).filter((f) => f.endsWith(".md"));
|
|
1059
|
+
agentResults = agentFiles.map((f) => auditAgentGuide(join(AGENT_GUIDES_DIR, f)));
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
const guideValidatorResultJson = !ONLY_SKILL ? runGuideContentValidator() : null;
|
|
1063
|
+
const hookBehaviorResultJson = !ONLY_SKILL ? runHookBehaviorTests() : null;
|
|
1064
|
+
|
|
1065
|
+
const failed = results.filter((r) => r.errors.length > 0);
|
|
1066
|
+
const warned = results.filter((r) => r.warnings.length > 0 && r.errors.length === 0);
|
|
1067
|
+
const clean = results.filter((r) => r.errors.length === 0 && r.warnings.length === 0);
|
|
1068
|
+
const agentFailed = agentResults.filter((r) => r.errors.length > 0);
|
|
1069
|
+
const agentWarned = agentResults.filter((r) => r.warnings.length > 0 && r.errors.length === 0);
|
|
1070
|
+
const agentClean = agentResults.filter((r) => r.errors.length === 0 && r.warnings.length === 0);
|
|
1071
|
+
const globalErrors = [
|
|
1072
|
+
...manifestAudit.findings.filter((f) => f.level === "error"),
|
|
1073
|
+
...duplicateFindings.filter((f) => f.level === "error"),
|
|
1074
|
+
...orphanHookFindings.filter((f) => f.level === "error"),
|
|
1075
|
+
...(guideValidatorResultJson ? guideValidatorResultJson.errors : []),
|
|
1076
|
+
...(hookBehaviorResultJson && !hookBehaviorResultJson.ok ? [{
|
|
1077
|
+
level: "error",
|
|
1078
|
+
code: "hook-behavior-test-failed",
|
|
1079
|
+
message: hookBehaviorResultJson.stderr || hookBehaviorResultJson.stdout || `exit ${hookBehaviorResultJson.status}`,
|
|
1080
|
+
}] : []),
|
|
1081
|
+
];
|
|
1082
|
+
const globalWarnings = [
|
|
1083
|
+
...manifestAudit.findings.filter((f) => f.level === "warn"),
|
|
1084
|
+
...duplicateFindings.filter((f) => f.level === "warn"),
|
|
1085
|
+
...orphanHookFindings.filter((f) => f.level === "warn"),
|
|
1086
|
+
...(guideValidatorResultJson ? guideValidatorResultJson.warnings : []),
|
|
1087
|
+
];
|
|
1088
|
+
const fail =
|
|
1089
|
+
failed.length > 0 ||
|
|
1090
|
+
agentFailed.length > 0 ||
|
|
1091
|
+
globalErrors.length > 0 ||
|
|
1092
|
+
(STRICT && (warned.length > 0 || agentWarned.length > 0 || globalWarnings.length > 0));
|
|
1093
|
+
let exitCode = fail ? 1 : 0;
|
|
1094
|
+
if (manifestMissing) exitCode = 3;
|
|
1095
|
+
|
|
1096
|
+
console.log(
|
|
1097
|
+
JSON.stringify(
|
|
1098
|
+
{
|
|
1099
|
+
summary: {
|
|
1100
|
+
total: results.length,
|
|
1101
|
+
failed: failed.length,
|
|
1102
|
+
warned: warned.length,
|
|
1103
|
+
clean: clean.length,
|
|
1104
|
+
strict: STRICT,
|
|
1105
|
+
fix: FIX,
|
|
1106
|
+
fixed_files: FIXED_FILES.map((p) => p.replace(REPO_ROOT + "\\", "").replace(REPO_ROOT + "/", "")),
|
|
1107
|
+
verdict: fail ? "FAIL" : "PASS",
|
|
1108
|
+
exit_code: exitCode,
|
|
1109
|
+
},
|
|
1110
|
+
plugin_manifest: {
|
|
1111
|
+
ok: manifestAudit.ok,
|
|
1112
|
+
version: manifestAudit.manifest?.version || null,
|
|
1113
|
+
findings: manifestAudit.findings,
|
|
1114
|
+
},
|
|
1115
|
+
global_findings: [...duplicateFindings, ...orphanHookFindings],
|
|
1116
|
+
guide_content_validator: guideValidatorResultJson
|
|
1117
|
+
? {
|
|
1118
|
+
ok: guideValidatorResultJson.ok,
|
|
1119
|
+
stats: guideValidatorResultJson.stats,
|
|
1120
|
+
findings: guideValidatorResultJson.findings,
|
|
1121
|
+
}
|
|
1122
|
+
: null,
|
|
1123
|
+
hook_behavior_tests: hookBehaviorResultJson,
|
|
1124
|
+
results: results.map((r) => ({
|
|
1125
|
+
skill: r.skill,
|
|
1126
|
+
file: r.file,
|
|
1127
|
+
pass: r.pass,
|
|
1128
|
+
findings: r.findings,
|
|
1129
|
+
})),
|
|
1130
|
+
agent_guides: {
|
|
1131
|
+
total: agentResults.length,
|
|
1132
|
+
failed: agentFailed.length,
|
|
1133
|
+
warned: agentWarned.length,
|
|
1134
|
+
clean: agentClean.length,
|
|
1135
|
+
results: agentResults.map((r) => ({
|
|
1136
|
+
file: r.file,
|
|
1137
|
+
pass: r.pass,
|
|
1138
|
+
findings: r.findings,
|
|
1139
|
+
})),
|
|
1140
|
+
},
|
|
1141
|
+
},
|
|
1142
|
+
null,
|
|
1143
|
+
2,
|
|
1144
|
+
),
|
|
1145
|
+
);
|
|
1146
|
+
process.exit(exitCode);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
// Human-readable path: stream each result live as it's audited
|
|
1150
|
+
const pad = (s, n) => s + " ".repeat(Math.max(0, n - s.length));
|
|
1151
|
+
|
|
1152
|
+
// Count agent guides upfront for the startup banner
|
|
1153
|
+
let agentFileCount = 0;
|
|
1154
|
+
if (!ONLY_SKILL && existsSync(AGENT_GUIDES_DIR)) {
|
|
1155
|
+
try { agentFileCount = readdirSync(AGENT_GUIDES_DIR).filter((f) => f.endsWith(".md")).length; } catch {}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// Startup banner — printed immediately so the user sees activity right away
|
|
1159
|
+
console.log(`\nrdc-skills self-test — Tier 1 (static lint)`);
|
|
1160
|
+
const scopeDesc = ONLY_SKILLS.length > 0
|
|
1161
|
+
? `skill: ${ONLY_SKILLS.join(", ")}`
|
|
1162
|
+
: `${files.length} skill${files.length !== 1 ? "s" : ""}${agentFileCount ? ` + ${agentFileCount} agent guide${agentFileCount !== 1 ? "s" : ""}` : ""}`;
|
|
1163
|
+
console.log(`Scanning ${scopeDesc}${STRICT ? " [strict]" : ""}${FIX ? " [fix]" : ""}\n`);
|
|
1164
|
+
|
|
1165
|
+
// Plugin manifest line
|
|
1166
|
+
const manifestStatus = manifestAudit.ok ? "pass" : "FAIL";
|
|
1167
|
+
const manifestNote = manifestAudit.ok
|
|
1168
|
+
? `v${manifestAudit.manifest?.version || "?"}`
|
|
1169
|
+
: manifestAudit.findings.map((f) => f.message).join("; ");
|
|
1170
|
+
console.log(pad("plugin manifest", 24) + pad(manifestStatus, 10) + manifestNote);
|
|
1171
|
+
console.log();
|
|
1172
|
+
|
|
1173
|
+
// Skills — print each row immediately after audit (no buffering)
|
|
1174
|
+
console.log(pad("skill", 24) + pad("status", 10) + "notes");
|
|
1175
|
+
console.log("─".repeat(80));
|
|
1176
|
+
const results = [];
|
|
1177
|
+
for (const f of files) {
|
|
1178
|
+
const r = auditSkill(join(SKILLS_DIR, f, "SKILL.md"));
|
|
1179
|
+
results.push(r);
|
|
1180
|
+
const status = r.errors.length > 0 ? "FAIL" : r.warnings.length > 0 ? "WARN" : "pass";
|
|
1181
|
+
const notes = r.errors.length > 0 ? r.errors[0] : r.warnings[0] || "";
|
|
1182
|
+
console.log(pad(r.name || r.file, 24) + pad(status, 10) + notes);
|
|
1183
|
+
const extras = [...r.errors.slice(1), ...r.warnings.slice(r.errors.length > 0 ? 0 : 1)];
|
|
1184
|
+
for (const extra of extras) {
|
|
1185
|
+
console.log(pad("", 24) + pad("", 10) + " " + extra);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
const failed = results.filter((r) => r.errors.length > 0);
|
|
1190
|
+
const warned = results.filter((r) => r.warnings.length > 0 && r.errors.length === 0);
|
|
1191
|
+
const clean = results.filter((r) => r.errors.length === 0 && r.warnings.length === 0);
|
|
1192
|
+
|
|
1193
|
+
console.log("─".repeat(80));
|
|
1194
|
+
console.log(`total: ${results.length} | fail: ${failed.length} | warn: ${warned.length} | pass: ${clean.length}`);
|
|
1195
|
+
|
|
1196
|
+
// Agent guides — stream live
|
|
1197
|
+
const agentResults = [];
|
|
1198
|
+
if (!ONLY_SKILL && existsSync(AGENT_GUIDES_DIR)) {
|
|
1199
|
+
const agentFiles = readdirSync(AGENT_GUIDES_DIR).filter((f) => f.endsWith(".md"));
|
|
1200
|
+
if (agentFiles.length > 0) {
|
|
1201
|
+
console.log("\nagent guides (guides/agents/*.md)\n");
|
|
1202
|
+
console.log(pad("guide", 24) + pad("status", 10) + "notes");
|
|
1203
|
+
console.log("─".repeat(80));
|
|
1204
|
+
for (const f of agentFiles) {
|
|
1205
|
+
const r = auditAgentGuide(join(AGENT_GUIDES_DIR, f));
|
|
1206
|
+
agentResults.push(r);
|
|
1207
|
+
const status = r.errors.length > 0 ? "FAIL" : r.warnings.length > 0 ? "WARN" : "pass";
|
|
1208
|
+
const notes = r.errors.length > 0 ? r.errors[0] : r.warnings[0] || "";
|
|
1209
|
+
console.log(pad(r.file, 24) + pad(status, 10) + notes);
|
|
1210
|
+
const extras = [...r.errors.slice(1), ...r.warnings.slice(r.errors.length > 0 ? 0 : 1)];
|
|
1211
|
+
for (const extra of extras) {
|
|
1212
|
+
console.log(pad("", 24) + pad("", 10) + " " + extra);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
const agentFailed = agentResults.filter((r) => r.errors.length > 0);
|
|
1216
|
+
const agentWarned = agentResults.filter((r) => r.warnings.length > 0 && r.errors.length === 0);
|
|
1217
|
+
const agentClean = agentResults.filter((r) => r.errors.length === 0 && r.warnings.length === 0);
|
|
1218
|
+
console.log("─".repeat(80));
|
|
1219
|
+
console.log(`total: ${agentResults.length} | fail: ${agentFailed.length} | warn: ${agentWarned.length} | pass: ${agentClean.length}`);
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// Guide-content validation — check .rdc/guides + .claude/rules for banned terms and invalid clauth keys
|
|
1224
|
+
let guideValidatorResult = null;
|
|
1225
|
+
if (!ONLY_SKILL) {
|
|
1226
|
+
guideValidatorResult = runGuideContentValidator();
|
|
1227
|
+
if (guideValidatorResult.stats.files_scanned > 0) {
|
|
1228
|
+
console.log(`\nguide-content validator (${guideValidatorResult.stats.files_scanned} files)\n`);
|
|
1229
|
+
console.log("─".repeat(80));
|
|
1230
|
+
if (guideValidatorResult.findings.length === 0) {
|
|
1231
|
+
console.log(" ✓ no banned terms or invalid clauth keys found");
|
|
1232
|
+
} else {
|
|
1233
|
+
for (const f of guideValidatorResult.findings) {
|
|
1234
|
+
const tag = f.level === "error" ? "FAIL" : "WARN";
|
|
1235
|
+
console.log(` [${tag}] ${f.file}:${f.line} ${f.code} ${f.message}`);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
console.log("─".repeat(80));
|
|
1239
|
+
const gErrors = guideValidatorResult.errors.length;
|
|
1240
|
+
const gWarnings = guideValidatorResult.warnings.length;
|
|
1241
|
+
console.log(`total: ${guideValidatorResult.stats.files_scanned} | errors: ${gErrors} | warnings: ${gWarnings}`);
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
// Cross-skill checks — deferred until all audits complete
|
|
1246
|
+
let duplicateFindings = [];
|
|
1247
|
+
let orphanHookFindings = [];
|
|
1248
|
+
let requiredHookFindings = [];
|
|
1249
|
+
let hookBehaviorResult = null;
|
|
1250
|
+
if (!ONLY_SKILL) {
|
|
1251
|
+
duplicateFindings = auditDuplicates(results);
|
|
1252
|
+
orphanHookFindings = auditOrphanHooks(results);
|
|
1253
|
+
requiredHookFindings = auditRequiredHookWiring();
|
|
1254
|
+
hookBehaviorResult = runHookBehaviorTests();
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
if (duplicateFindings.length + orphanHookFindings.length + requiredHookFindings.length > 0) {
|
|
1258
|
+
console.log("\nglobal findings\n");
|
|
1259
|
+
for (const f of [...duplicateFindings, ...orphanHookFindings, ...requiredHookFindings]) {
|
|
1260
|
+
console.log(` [${f.level}] ${f.code}: ${f.message}`);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
if (hookBehaviorResult) {
|
|
1265
|
+
console.log("\nhook behavior tests\n");
|
|
1266
|
+
console.log("─".repeat(80));
|
|
1267
|
+
console.log(hookBehaviorResult.ok ? " pass rdc marker/gate behavioral smoke tests" : " FAIL rdc marker/gate behavioral smoke tests");
|
|
1268
|
+
if (hookBehaviorResult.stdout) console.log(` ${hookBehaviorResult.stdout}`);
|
|
1269
|
+
if (hookBehaviorResult.stderr) console.log(` ${hookBehaviorResult.stderr}`);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
if (FIXED_FILES.length > 0) {
|
|
1273
|
+
console.log("\nfixed files (review with git diff):");
|
|
1274
|
+
for (const p of FIXED_FILES) console.log(` ${p}`);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
const agentFailed = agentResults.filter((r) => r.errors.length > 0);
|
|
1278
|
+
const agentWarned = agentResults.filter((r) => r.warnings.length > 0 && r.errors.length === 0);
|
|
1279
|
+
const globalErrors = [
|
|
1280
|
+
...manifestAudit.findings.filter((f) => f.level === "error"),
|
|
1281
|
+
...duplicateFindings.filter((f) => f.level === "error"),
|
|
1282
|
+
...orphanHookFindings.filter((f) => f.level === "error"),
|
|
1283
|
+
...requiredHookFindings.filter((f) => f.level === "error"),
|
|
1284
|
+
...(guideValidatorResult ? guideValidatorResult.errors : []),
|
|
1285
|
+
...(hookBehaviorResult && !hookBehaviorResult.ok ? [{
|
|
1286
|
+
level: "error",
|
|
1287
|
+
code: "hook-behavior-test-failed",
|
|
1288
|
+
message: hookBehaviorResult.stderr || hookBehaviorResult.stdout || `exit ${hookBehaviorResult.status}`,
|
|
1289
|
+
}] : []),
|
|
1290
|
+
];
|
|
1291
|
+
const globalWarnings = [
|
|
1292
|
+
...manifestAudit.findings.filter((f) => f.level === "warn"),
|
|
1293
|
+
...duplicateFindings.filter((f) => f.level === "warn"),
|
|
1294
|
+
...orphanHookFindings.filter((f) => f.level === "warn"),
|
|
1295
|
+
...requiredHookFindings.filter((f) => f.level === "warn"),
|
|
1296
|
+
...(guideValidatorResult ? guideValidatorResult.warnings : []),
|
|
1297
|
+
];
|
|
1298
|
+
|
|
1299
|
+
const fail =
|
|
1300
|
+
failed.length > 0 ||
|
|
1301
|
+
agentFailed.length > 0 ||
|
|
1302
|
+
globalErrors.length > 0 ||
|
|
1303
|
+
(STRICT && (warned.length > 0 || agentWarned.length > 0 || globalWarnings.length > 0));
|
|
1304
|
+
|
|
1305
|
+
let exitCode = fail ? 1 : 0;
|
|
1306
|
+
if (manifestMissing) exitCode = 3;
|
|
1307
|
+
|
|
1308
|
+
console.log(`\nverdict: ${fail ? "❌ FAIL" : "✓ PASS"}${STRICT ? " (strict)" : ""} exit=${exitCode}\n`);
|
|
1309
|
+
|
|
1310
|
+
writeLastRun({
|
|
1311
|
+
tier: 1,
|
|
1312
|
+
verdict: fail ? "FAIL" : "PASS",
|
|
1313
|
+
exit_code: exitCode,
|
|
1314
|
+
strict: STRICT,
|
|
1315
|
+
summary: {
|
|
1316
|
+
total: results.length,
|
|
1317
|
+
failed: failed.length,
|
|
1318
|
+
warned: warned.length,
|
|
1319
|
+
passed: clean.length,
|
|
1320
|
+
},
|
|
1321
|
+
failures: [
|
|
1322
|
+
...manifestAudit.findings.filter((f) => f.level === "error").map((f) => ({
|
|
1323
|
+
scope: "plugin_manifest", code: f.code, message: f.message,
|
|
1324
|
+
})),
|
|
1325
|
+
...failed.map((r) => ({ skill: r.skill || r.file, errors: r.errors, findings: r.findings })),
|
|
1326
|
+
...agentFailed.map((r) => ({ scope: "agent_guide", file: r.file, errors: r.errors, findings: r.findings })),
|
|
1327
|
+
...duplicateFindings.filter((f) => f.level === "error").map((f) => ({ scope: "global", code: f.code, message: f.message })),
|
|
1328
|
+
...orphanHookFindings.filter((f) => f.level === "error").map((f) => ({ scope: "global", code: f.code, message: f.message })),
|
|
1329
|
+
],
|
|
1330
|
+
warnings: [
|
|
1331
|
+
...warned.map((r) => ({ skill: r.skill || r.file, warnings: r.warnings })),
|
|
1332
|
+
...agentWarned.map((r) => ({ scope: "agent_guide", file: r.file, warnings: r.warnings })),
|
|
1333
|
+
],
|
|
1334
|
+
});
|
|
1335
|
+
|
|
1336
|
+
process.exit(exitCode);
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
// ── last-run.json ───────────────────────────────────────────────────────────
|
|
1340
|
+
// Always written after every run so Claude Code can read it without the user
|
|
1341
|
+
// having to copy/paste terminal output.
|
|
1342
|
+
// Path: C:/Dev/rdc-skills/.rdc/reports/last-run.json
|
|
1343
|
+
//
|
|
1344
|
+
// Shape:
|
|
1345
|
+
// { tier, ran_at, verdict, exit_code, summary, failures[], warnings[] }
|
|
1346
|
+
// "failures" contains only items that actually failed — ready to act on.
|
|
1347
|
+
|
|
1348
|
+
function writeLastRun(data) {
|
|
1349
|
+
const payload = { ...data, ran_at: new Date().toISOString() };
|
|
1350
|
+
try {
|
|
1351
|
+
const reportsDir = resolve(REPO_ROOT, ".rdc", "reports");
|
|
1352
|
+
if (!existsSync(reportsDir)) mkdirSync(reportsDir, { recursive: true });
|
|
1353
|
+
|
|
1354
|
+
const jsonPath = join(reportsDir, "last-run.json");
|
|
1355
|
+
writeFileSync(jsonPath, JSON.stringify(payload, null, 2));
|
|
1356
|
+
|
|
1357
|
+
const htmlPath = join(reportsDir, "last-run.html");
|
|
1358
|
+
writeFileSync(htmlPath, renderHtml(payload));
|
|
1359
|
+
|
|
1360
|
+
console.log(`\nresults: file:///${htmlPath.replace(/\\/g, "/")}`);
|
|
1361
|
+
} catch (e) {
|
|
1362
|
+
console.error(`WARN: could not write last-run reports: ${e.message}`);
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
function renderHtml(d) {
|
|
1367
|
+
const pass = d.verdict === "PASS";
|
|
1368
|
+
const ts = new Date(d.ran_at).toLocaleString();
|
|
1369
|
+
const badge = pass
|
|
1370
|
+
? `<span class="badge pass">✓ PASS</span>`
|
|
1371
|
+
: `<span class="badge fail">✗ FAIL</span>`;
|
|
1372
|
+
|
|
1373
|
+
const failRows = (d.failures || []).map((f) => {
|
|
1374
|
+
const name = f.skill || f.file || f.scope || "?";
|
|
1375
|
+
const detail = [
|
|
1376
|
+
...(f.errors || []),
|
|
1377
|
+
...(f.assertions_failed || []),
|
|
1378
|
+
f.error || "",
|
|
1379
|
+
f.message || "",
|
|
1380
|
+
].filter(Boolean).join("<br>");
|
|
1381
|
+
const extra = f.timed_out ? "<em>timed out</em>" : detail;
|
|
1382
|
+
return `<tr class="fail-row"><td>${name}</td><td>${extra || "—"}</td></tr>`;
|
|
1383
|
+
}).join("");
|
|
1384
|
+
|
|
1385
|
+
const warnRows = (d.warnings || []).map((w) => {
|
|
1386
|
+
const name = w.skill || w.file || "?";
|
|
1387
|
+
const detail = (w.warnings || []).join("<br>");
|
|
1388
|
+
return `<tr class="warn-row"><td>${name}</td><td>${detail}</td></tr>`;
|
|
1389
|
+
}).join("");
|
|
1390
|
+
|
|
1391
|
+
const s = d.summary || {};
|
|
1392
|
+
|
|
1393
|
+
return `<!DOCTYPE html>
|
|
1394
|
+
<html lang="en">
|
|
1395
|
+
<head>
|
|
1396
|
+
<meta charset="utf-8">
|
|
1397
|
+
<meta http-equiv="refresh" content="5">
|
|
1398
|
+
<title>rdc:self-test — ${d.verdict}</title>
|
|
1399
|
+
<style>
|
|
1400
|
+
body { font-family: monospace; background: #0d1117; color: #e6edf3; margin: 2rem; }
|
|
1401
|
+
h1 { font-size: 1.2rem; margin-bottom: 0.25rem; }
|
|
1402
|
+
.meta { color: #8b949e; font-size: 0.85rem; margin-bottom: 1.5rem; }
|
|
1403
|
+
.badge { padding: 0.2rem 0.6rem; border-radius: 4px; font-weight: bold; font-size: 1rem; }
|
|
1404
|
+
.badge.pass { background: #1a4731; color: #3fb950; }
|
|
1405
|
+
.badge.fail { background: #4a1515; color: #f85149; }
|
|
1406
|
+
table { border-collapse: collapse; width: 100%; margin-top: 1rem; }
|
|
1407
|
+
th { text-align: left; padding: 0.4rem 0.8rem; background: #161b22; color: #8b949e; font-size: 0.8rem; }
|
|
1408
|
+
td { padding: 0.4rem 0.8rem; border-top: 1px solid #21262d; vertical-align: top; }
|
|
1409
|
+
.fail-row td:first-child { color: #f85149; font-weight: bold; }
|
|
1410
|
+
.warn-row td:first-child { color: #d29922; }
|
|
1411
|
+
.summary { display: flex; gap: 2rem; margin-bottom: 1rem; }
|
|
1412
|
+
.stat { text-align: center; }
|
|
1413
|
+
.stat .n { font-size: 1.8rem; font-weight: bold; }
|
|
1414
|
+
.stat .n.red { color: #f85149; }
|
|
1415
|
+
.stat .n.green { color: #3fb950; }
|
|
1416
|
+
.stat .n.yellow { color: #d29922; }
|
|
1417
|
+
.stat .label { color: #8b949e; font-size: 0.8rem; }
|
|
1418
|
+
.section-title { color: #8b949e; font-size: 0.8rem; text-transform: uppercase;
|
|
1419
|
+
letter-spacing: 0.05em; margin: 1.5rem 0 0.5rem; }
|
|
1420
|
+
.refresh { color: #8b949e; font-size: 0.75rem; float: right; }
|
|
1421
|
+
</style>
|
|
1422
|
+
</head>
|
|
1423
|
+
<body>
|
|
1424
|
+
<h1>rdc:self-test ${badge} Tier ${d.tier}</h1>
|
|
1425
|
+
<div class="meta">${ts} · exit ${d.exit_code}${d.strict ? " · strict" : ""}<span class="refresh">auto-refresh 5s</span></div>
|
|
1426
|
+
|
|
1427
|
+
<div class="summary">
|
|
1428
|
+
<div class="stat"><div class="n">${s.total ?? "?"}</div><div class="label">total</div></div>
|
|
1429
|
+
<div class="stat"><div class="n ${(s.failed || 0) > 0 ? "red" : "green"}">${s.failed ?? s.fail ?? 0}</div><div class="label">failed</div></div>
|
|
1430
|
+
${s.warned != null ? `<div class="stat"><div class="n ${(s.warned) > 0 ? "yellow" : ""}">${s.warned}</div><div class="label">warned</div></div>` : ""}
|
|
1431
|
+
<div class="stat"><div class="n green">${s.passed ?? s.pass ?? 0}</div><div class="label">passed</div></div>
|
|
1432
|
+
${s.wall_ms != null ? `<div class="stat"><div class="n">${(s.wall_ms/1000).toFixed(1)}s</div><div class="label">wall time</div></div>` : ""}
|
|
1433
|
+
</div>
|
|
1434
|
+
|
|
1435
|
+
${failRows ? `<div class="section-title">Failures</div>
|
|
1436
|
+
<table><tr><th>Skill / Scope</th><th>Detail</th></tr>${failRows}</table>` : ""}
|
|
1437
|
+
|
|
1438
|
+
${warnRows ? `<div class="section-title">Warnings</div>
|
|
1439
|
+
<table><tr><th>Skill / Scope</th><th>Detail</th></tr>${warnRows}</table>` : ""}
|
|
1440
|
+
|
|
1441
|
+
${!failRows && !warnRows ? `<p style="color:#3fb950;margin-top:1rem">All checks passed.</p>` : ""}
|
|
1442
|
+
</body>
|
|
1443
|
+
</html>`;
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
if (TIER2) {
|
|
1447
|
+
runTier2().catch((e) => {
|
|
1448
|
+
console.error(`FATAL: ${e.stack || e.message}`);
|
|
1449
|
+
process.exit(2);
|
|
1450
|
+
});
|
|
1451
|
+
} else {
|
|
1452
|
+
try {
|
|
1453
|
+
main();
|
|
1454
|
+
} catch (e) {
|
|
1455
|
+
console.error(`FATAL: ${e.stack || e.message}`);
|
|
1456
|
+
process.exit(2);
|
|
1457
|
+
}
|
|
1458
|
+
}
|