vigiles 23.0.0 → 25.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/claude-code/run-scripts.js +26 -2
- package/dist/cli-flag-check.js +1 -1
- package/dist/cli.d.ts +121 -0
- package/dist/cli.js +352 -47
- package/dist/core/compile-generator.d.ts +6 -0
- package/dist/core/compile-generator.js +4 -1
- package/dist/core/compile.d.ts +53 -1
- package/dist/core/compile.js +20 -4
- package/dist/core/repo-path.d.ts +41 -0
- package/dist/core/repo-path.js +19 -0
- package/dist/core/rule-meta.d.ts +1 -1
- package/dist/core/rule-meta.js +16 -0
- package/dist/core/types.d.ts +44 -0
- package/dist/core/validate.js +18 -0
- package/dist/harness-resolve-hooks.d.mts +13 -0
- package/dist/harness-resolve-hooks.mjs +50 -0
- package/dist/run-hook.d.ts +27 -0
- package/dist/run-hook.js +31 -12
- package/dist/test.d.ts +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -12,8 +12,12 @@
|
|
|
12
12
|
*/
|
|
13
13
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
14
|
exports.specLoadFailureReason = specLoadFailureReason;
|
|
15
|
+
exports.lintTotals = lintTotals;
|
|
16
|
+
exports.discoverNestedBundles = discoverNestedBundles;
|
|
15
17
|
const node_fs_1 = require("node:fs");
|
|
16
18
|
const node_path_1 = require("node:path");
|
|
19
|
+
const minimatch_1 = require("minimatch");
|
|
20
|
+
const repo_path_js_1 = require("./core/repo-path.js");
|
|
17
21
|
const node_child_process_1 = require("node:child_process");
|
|
18
22
|
const glob_1 = require("glob");
|
|
19
23
|
const generate_types_js_1 = require("./core/generate-types.js");
|
|
@@ -285,11 +289,14 @@ function printWarnings(specFile, warnings) {
|
|
|
285
289
|
/** Compile a generator-skill spec from source → SKILL.md. Returns validity. */
|
|
286
290
|
function compileGeneratorSkillToFile(specPath, source) {
|
|
287
291
|
const outputPath = specPath.replace(/\.spec\.ts$/, "");
|
|
288
|
-
const {
|
|
292
|
+
const { artifact, errors } = (0, compile_generator_js_1.compileGeneratorSkill)(source, {
|
|
289
293
|
basePath: process.cwd(),
|
|
290
294
|
specFile: specPath,
|
|
291
295
|
});
|
|
292
|
-
|
|
296
|
+
// Written only when the compile is clean: `artifact` is null otherwise, and
|
|
297
|
+
// `writeArtifact` takes nothing else.
|
|
298
|
+
if (artifact)
|
|
299
|
+
writeArtifact(outputPath, artifact);
|
|
293
300
|
if (errors.length === 0) {
|
|
294
301
|
console.log(`\n✓ ${specPath} → ${outputPath} (generator skill)`);
|
|
295
302
|
return true;
|
|
@@ -390,14 +397,17 @@ function writeInstructionMirrors(primaryOutput, harnesses) {
|
|
|
390
397
|
/** Compile a declarative SkillSpec → SKILL.md. */
|
|
391
398
|
function compileSkillToFile(spec, specPath, dialect) {
|
|
392
399
|
const outputPath = specPath.replace(/\.spec\.ts$/, "");
|
|
393
|
-
const {
|
|
400
|
+
const { artifact, errors, warnings } = (0, compile_js_1.compileSkill)(spec, {
|
|
394
401
|
basePath: process.cwd(),
|
|
395
402
|
specFile: specPath,
|
|
396
403
|
// The SKILL.md frontmatter profile comes from the resolved harness — a Codex
|
|
397
404
|
// repo gets a minimal (name + description) SKILL.md; CC gets the full set.
|
|
398
405
|
dialect,
|
|
399
406
|
});
|
|
400
|
-
|
|
407
|
+
// Written only when the compile is clean: `artifact` is null otherwise, and
|
|
408
|
+
// `writeArtifact` takes nothing else.
|
|
409
|
+
if (artifact)
|
|
410
|
+
writeArtifact(outputPath, artifact);
|
|
401
411
|
if (errors.length === 0) {
|
|
402
412
|
console.log(`\n✓ ${specPath} → ${outputPath}`);
|
|
403
413
|
printWarnings(specPath, warnings);
|
|
@@ -411,12 +421,15 @@ function compileSkillToFile(spec, specPath, dialect) {
|
|
|
411
421
|
/** Compile a subagent spec → agents/<name>.md (with its result-contract section). */
|
|
412
422
|
function compileAgentToFile(spec, specPath, dialect) {
|
|
413
423
|
const outputPath = specPath.replace(/\.spec\.ts$/, "");
|
|
414
|
-
const {
|
|
424
|
+
const { artifact, errors, warnings } = (0, compile_js_1.compileAgent)(spec, {
|
|
415
425
|
basePath: process.cwd(),
|
|
416
426
|
specFile: specPath,
|
|
417
427
|
dialect,
|
|
418
428
|
});
|
|
419
|
-
|
|
429
|
+
// Written only when the compile is clean: `artifact` is null otherwise, and
|
|
430
|
+
// `writeArtifact` takes nothing else.
|
|
431
|
+
if (artifact)
|
|
432
|
+
writeArtifact(outputPath, artifact);
|
|
420
433
|
if (errors.length === 0) {
|
|
421
434
|
console.log(`\n✓ ${specPath} → ${outputPath}`);
|
|
422
435
|
printWarnings(specPath, warnings);
|
|
@@ -434,11 +447,14 @@ function compileAgentToFile(spec, specPath, dialect) {
|
|
|
434
447
|
*/
|
|
435
448
|
function compileRailwayToFile(spec, specPath, knownAgents) {
|
|
436
449
|
const outputPath = specPath.replace(/\.spec\.ts$/, "");
|
|
437
|
-
const {
|
|
450
|
+
const { artifact, errors } = (0, compile_js_1.compileRailway)(spec, {
|
|
438
451
|
specFile: specPath,
|
|
439
452
|
knownAgents,
|
|
440
453
|
});
|
|
441
|
-
|
|
454
|
+
// Written only when the compile is clean: `artifact` is null otherwise, and
|
|
455
|
+
// `writeArtifact` takes nothing else.
|
|
456
|
+
if (artifact)
|
|
457
|
+
writeArtifact(outputPath, artifact);
|
|
442
458
|
if (errors.length === 0) {
|
|
443
459
|
console.log(`\n✓ ${specPath} → ${outputPath}`);
|
|
444
460
|
return true;
|
|
@@ -801,6 +817,55 @@ async function verifyMarkdownMcpRefs(files, silent) {
|
|
|
801
817
|
return errors;
|
|
802
818
|
}
|
|
803
819
|
/** Exit codes: 0 clean, 1 warnings only, 2 hard errors. */
|
|
820
|
+
/**
|
|
821
|
+
* The run's totals, derived from the report itself.
|
|
822
|
+
*
|
|
823
|
+
* 🔴 ONE SOURCE, because the two numbers disagreeing IS the bug (#183). The
|
|
824
|
+
* human-readable log had no total, and counting its `⚠` lines gave a different
|
|
825
|
+
* number from the JSON — 21 against 88 on a real repo — because some checks print
|
|
826
|
+
* one line per finding and others one line carrying a count. Both numbers were
|
|
827
|
+
* right and nothing said why they differed, so "vigiles reports 21 warnings" and
|
|
828
|
+
* "88 warnings" were equally defensible readings of one run.
|
|
829
|
+
*
|
|
830
|
+
* Counted GENERICALLY off the `*Issues` / `*Errors` / count keys rather than a
|
|
831
|
+
* hand-maintained list, so a rule added later is included by existing, not by
|
|
832
|
+
* somebody remembering. `orphanCount` and `duplicatePairs` are named explicitly
|
|
833
|
+
* only because they predate the `*Issues` convention (#181).
|
|
834
|
+
*/
|
|
835
|
+
function lintTotals(report) {
|
|
836
|
+
let errors = 0;
|
|
837
|
+
let findings = 0;
|
|
838
|
+
for (const [key, value] of Object.entries(report)) {
|
|
839
|
+
if (typeof value !== "number" || value === 0)
|
|
840
|
+
continue;
|
|
841
|
+
if (key === "files")
|
|
842
|
+
continue;
|
|
843
|
+
// Informational counters: not findings, they describe the corpus.
|
|
844
|
+
if (key === "inlineRules" ||
|
|
845
|
+
key === "frontmatterRules" ||
|
|
846
|
+
key === "coverageEnabled" ||
|
|
847
|
+
key === "coverageDocumented" ||
|
|
848
|
+
key === "strengthenSuggestions")
|
|
849
|
+
continue;
|
|
850
|
+
if (key.endsWith("Errors")) {
|
|
851
|
+
errors += value;
|
|
852
|
+
findings += value;
|
|
853
|
+
continue;
|
|
854
|
+
}
|
|
855
|
+
// `*Issues` counts EVERY finding of that rule; when the rule is at "error"
|
|
856
|
+
// the same findings are also in `*Errors`, so they must not be counted twice.
|
|
857
|
+
if (key.endsWith("Issues")) {
|
|
858
|
+
const paired = report[`${key.slice(0, -"Issues".length)}Errors`];
|
|
859
|
+
findings += paired && paired > 0 ? 0 : value;
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
if (key === "orphanCount" ||
|
|
863
|
+
key === "duplicatePairs" ||
|
|
864
|
+
key === "untestedSurfaces")
|
|
865
|
+
findings += value;
|
|
866
|
+
}
|
|
867
|
+
return { findings, errors, warnings: findings - errors };
|
|
868
|
+
}
|
|
804
869
|
function lintExitCode(report) {
|
|
805
870
|
if (report.hashErrors > 0 ||
|
|
806
871
|
report.validationErrors > 0 ||
|
|
@@ -835,9 +900,15 @@ function lintExitCode(report) {
|
|
|
835
900
|
// it to "error", so it belongs in the hard tier with every other explicit
|
|
836
901
|
// error — it used to sit at exit 1 because it fired unasked and could not be
|
|
837
902
|
// turned off.
|
|
838
|
-
report.docRefErrors > 0
|
|
903
|
+
report.docRefErrors > 0 ||
|
|
904
|
+
report.specRefErrors > 0)
|
|
839
905
|
return 2;
|
|
840
|
-
|
|
906
|
+
// Tierable now: a `warn` orphan/duplicate finding is reported and does NOT
|
|
907
|
+
// change the exit code. Both used to feed the exit directly, which is what
|
|
908
|
+
// made them the only untierable findings in the tool.
|
|
909
|
+
if (report.orphanCount > 0 && report.orphanSeverity === "error")
|
|
910
|
+
return 1;
|
|
911
|
+
if (report.duplicatePairs > 0 && report.duplicateSeverity === "error")
|
|
841
912
|
return 1;
|
|
842
913
|
// Guidance counts are informational, not failures
|
|
843
914
|
return 0;
|
|
@@ -1085,6 +1156,169 @@ function sharedDirsRootFor(scanTarget) {
|
|
|
1085
1156
|
const underCwd = rel === "" || (!rel.startsWith("..") && !(0, node_path_1.isAbsolute)(rel));
|
|
1086
1157
|
return underCwd ? cwd : target;
|
|
1087
1158
|
}
|
|
1159
|
+
/**
|
|
1160
|
+
* Nested plugin bundles under a lint root — a directory that is itself a harness
|
|
1161
|
+
* (its own `.claude-plugin/plugin.json`, or its own skills dir) and is NOT the
|
|
1162
|
+
* root being linted.
|
|
1163
|
+
*
|
|
1164
|
+
* 🔴 WHY THIS EXISTS. Every per-surface check reads ONE root, so in a monorepo
|
|
1165
|
+
* holding `skills/` plus `plugins/ * /skills/` the nested skills were never scored
|
|
1166
|
+
* and nothing said so. Measured on a fixture: 4 skills over the description
|
|
1167
|
+
* budget, `lint .` reported 2, exit 0 — a repo reads that as green-with-2 while
|
|
1168
|
+
* the other 2 carry the same defect (#185). The failure is silent, which is the
|
|
1169
|
+
* shape this repo treats as worse than a loud one.
|
|
1170
|
+
*
|
|
1171
|
+
* Deliberately shallow (one level under a container dir): deep recursion would
|
|
1172
|
+
* sweep vendored corpora — this repo's own `test/dogfood/` holds real pinned
|
|
1173
|
+
* third-party plugins — and scoring someone else's vendored plugin as if it were
|
|
1174
|
+
* yours is the false-positive that gets a gate switched off.
|
|
1175
|
+
*/
|
|
1176
|
+
function discoverNestedBundles(root, exclude = []) {
|
|
1177
|
+
const out = [];
|
|
1178
|
+
const skip = new Set([
|
|
1179
|
+
"node_modules",
|
|
1180
|
+
".git",
|
|
1181
|
+
"dist",
|
|
1182
|
+
"coverage",
|
|
1183
|
+
".vigiles",
|
|
1184
|
+
]);
|
|
1185
|
+
const isBundle = (dir) => (0, node_fs_1.existsSync)((0, node_path_1.join)(dir, ".claude-plugin", "plugin.json")) ||
|
|
1186
|
+
(0, node_fs_1.existsSync)((0, node_path_1.join)(dir, "skills"));
|
|
1187
|
+
const excluded = (rel) => exclude.some((pattern) => rel === pattern ||
|
|
1188
|
+
rel.startsWith(`${pattern}/`) ||
|
|
1189
|
+
(0, minimatch_1.minimatch)(rel, pattern) ||
|
|
1190
|
+
(0, minimatch_1.minimatch)(rel, `${pattern}/**`));
|
|
1191
|
+
let entries;
|
|
1192
|
+
try {
|
|
1193
|
+
entries = (0, node_fs_1.readdirSync)(root, { withFileTypes: true })
|
|
1194
|
+
.filter((e) => e.isDirectory() && !skip.has(e.name) && !e.name.startsWith("."))
|
|
1195
|
+
.map((e) => e.name);
|
|
1196
|
+
}
|
|
1197
|
+
catch {
|
|
1198
|
+
return out;
|
|
1199
|
+
}
|
|
1200
|
+
for (const name of entries) {
|
|
1201
|
+
const dir = (0, node_path_1.join)(root, name);
|
|
1202
|
+
if (excluded(name))
|
|
1203
|
+
continue;
|
|
1204
|
+
// A container (`plugins/`) holds bundles; a bundle may also sit directly.
|
|
1205
|
+
if (isBundle(dir)) {
|
|
1206
|
+
out.push(dir);
|
|
1207
|
+
continue;
|
|
1208
|
+
}
|
|
1209
|
+
let inner;
|
|
1210
|
+
try {
|
|
1211
|
+
inner = (0, node_fs_1.readdirSync)(dir, { withFileTypes: true })
|
|
1212
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
|
|
1213
|
+
.map((e) => e.name);
|
|
1214
|
+
}
|
|
1215
|
+
catch {
|
|
1216
|
+
continue;
|
|
1217
|
+
}
|
|
1218
|
+
for (const child of inner) {
|
|
1219
|
+
const sub = (0, node_path_1.join)(dir, child);
|
|
1220
|
+
if (excluded(`${name}/${child}`))
|
|
1221
|
+
continue;
|
|
1222
|
+
if (isBundle(sub))
|
|
1223
|
+
out.push(sub);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
return out.sort();
|
|
1227
|
+
}
|
|
1228
|
+
/**
|
|
1229
|
+
* Run one per-surface check over EVERY root and sum its counters.
|
|
1230
|
+
*
|
|
1231
|
+
* The checks all share `(config, silent, adapter, root)` and return a small
|
|
1232
|
+
* record of numbers, so one wrapper covers all twenty rather than twenty edits —
|
|
1233
|
+
* and a check added later is swept in by using it, not by remembering to.
|
|
1234
|
+
*/
|
|
1235
|
+
function overBundles(fn, config, silent, adapter, roots) {
|
|
1236
|
+
const [first, ...rest] = roots;
|
|
1237
|
+
const total = { ...fn(config, silent, adapter, first) };
|
|
1238
|
+
for (const root of rest) {
|
|
1239
|
+
const next = fn(config, silent, adapter, root);
|
|
1240
|
+
for (const key of Object.keys(next))
|
|
1241
|
+
total[key] =
|
|
1242
|
+
(total[key] ?? 0) + (next[key] ?? 0);
|
|
1243
|
+
}
|
|
1244
|
+
return total;
|
|
1245
|
+
}
|
|
1246
|
+
/**
|
|
1247
|
+
* Re-derive a compiled artifact's references from its SPEC, and report the dead
|
|
1248
|
+
* ones — the half of #173 that deleting the write-on-error did not close.
|
|
1249
|
+
*
|
|
1250
|
+
* 🔴 THE HOLE. `lint` verifies the integrity HASH, which answers "is this file
|
|
1251
|
+
* still what the spec compiled to" and says nothing about whether the things it
|
|
1252
|
+
* NAMES still exist. So a `CLAUDE.md` committed while its refs were live stays
|
|
1253
|
+
* green forever after the referenced file is deleted: `compile` errors, `lint`
|
|
1254
|
+
* prints "hash valid — All compiled files intact" and exits 0. Reproduced:
|
|
1255
|
+
*
|
|
1256
|
+
* $ vigiles compile CLAUDE.md.spec.ts
|
|
1257
|
+
* ✗ [stale-file] File not found: "docs/guide.md"
|
|
1258
|
+
* $ vigiles lint .
|
|
1259
|
+
* ✓ CLAUDE.md — hash valid # exit 0
|
|
1260
|
+
*
|
|
1261
|
+
* The reporter named this residue himself when filing #173 and I deferred it;
|
|
1262
|
+
* it is the gap between what `README.md` promises of `lint` ("the CI gate …
|
|
1263
|
+
* broken refs") and what it checked. A hash is an integrity claim, not a
|
|
1264
|
+
* reference claim, and the two were being read as one.
|
|
1265
|
+
*
|
|
1266
|
+
* Cost is bounded: only specs whose compiled target actually EXISTS are loaded,
|
|
1267
|
+
* so a repo with no specs does no extra work at all.
|
|
1268
|
+
*/
|
|
1269
|
+
async function checkSpecRefs(config, silent, dialect) {
|
|
1270
|
+
const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["spec-refs"]) ?? "error";
|
|
1271
|
+
if (!sev)
|
|
1272
|
+
return { issues: 0, errors: 0 };
|
|
1273
|
+
const found = [];
|
|
1274
|
+
for (const specPath of findSpecs()) {
|
|
1275
|
+
const target = specPath.replace(/\.spec\.ts$/, "");
|
|
1276
|
+
if (!(0, node_fs_1.existsSync)(target))
|
|
1277
|
+
continue; // never compiled — `compile` reports it
|
|
1278
|
+
const spec = await loadSpec(specPath);
|
|
1279
|
+
if (!spec || spec._specType !== "claude")
|
|
1280
|
+
continue;
|
|
1281
|
+
try {
|
|
1282
|
+
const { errors } = (0, compile_js_1.compileClaude)(spec, {
|
|
1283
|
+
basePath: process.cwd(),
|
|
1284
|
+
specFile: specPath,
|
|
1285
|
+
dialect,
|
|
1286
|
+
maxRules: config?.maxRules,
|
|
1287
|
+
maxTokens: config?.maxTokens,
|
|
1288
|
+
maxSectionLines: config?.maxSectionLines,
|
|
1289
|
+
catalogOnly: config?.catalogOnly,
|
|
1290
|
+
linters: config?.linters,
|
|
1291
|
+
});
|
|
1292
|
+
for (const e of errors)
|
|
1293
|
+
found.push(`${target}: ${e.message} (from ${specPath})`);
|
|
1294
|
+
}
|
|
1295
|
+
catch {
|
|
1296
|
+
// A spec that will not load is `compile`'s finding, not this one's —
|
|
1297
|
+
// reporting it here would double-report and blame the wrong command.
|
|
1298
|
+
continue;
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
if (found.length > 0 && !silent) {
|
|
1302
|
+
console.log("\nSpec reference check:\n");
|
|
1303
|
+
for (const msg of found) {
|
|
1304
|
+
console.log(` ${sev === "error" ? "✗" : "⚠"} ${msg}`);
|
|
1305
|
+
ghAnnotate(sev === "error" ? "error" : "warning", msg);
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
return { issues: found.length, errors: sev === "error" ? found.length : 0 };
|
|
1309
|
+
}
|
|
1310
|
+
/**
|
|
1311
|
+
* Write a compiled artifact. Accepts ONLY a {@link StampedMarkdown}, so a body
|
|
1312
|
+
* that failed to compile cannot reach the disk — there is no stamp to pass.
|
|
1313
|
+
*
|
|
1314
|
+
* This replaces four copies of `writeFileSync(path, markdown)` that ran BEFORE
|
|
1315
|
+
* their error check (#173, reproduced in the skill/subagent/railway/generator
|
|
1316
|
+
* compilers after the CLAUDE.md one was fixed). Guarding four call sites would
|
|
1317
|
+
* have left the fifth writable; the type leaves nothing to remember.
|
|
1318
|
+
*/
|
|
1319
|
+
function writeArtifact(outputPath, artifact) {
|
|
1320
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.resolve)(process.cwd(), outputPath), artifact);
|
|
1321
|
+
}
|
|
1088
1322
|
async function runLint(restArgs, flags, config) {
|
|
1089
1323
|
const summary = flags.includes("--summary");
|
|
1090
1324
|
const json = flags.includes("--json");
|
|
@@ -1117,6 +1351,30 @@ async function runLint(restArgs, flags, config) {
|
|
|
1117
1351
|
configHarness: (0, adapter_registry_js_1.normalizeHarnessList)(config?.harness),
|
|
1118
1352
|
});
|
|
1119
1353
|
const adapter = lintSelection.adapter;
|
|
1354
|
+
// 🔴 WHICH ROOTS GET SCORED, and saying so either way (#185).
|
|
1355
|
+
//
|
|
1356
|
+
// Every per-surface check reads ONE root, so a monorepo with `skills/` plus
|
|
1357
|
+
// `plugins/*/skills/` scored only the first and said nothing — measured at 2
|
|
1358
|
+
// findings reported against 4 real ones, exit 0. A skipped surface that is not
|
|
1359
|
+
// announced reads as a clean surface.
|
|
1360
|
+
//
|
|
1361
|
+
// Default stays ROOT-ONLY on purpose: descending by default would start
|
|
1362
|
+
// scoring vendored third-party corpora (this repo's own `test/dogfood/` holds
|
|
1363
|
+
// pinned real plugins), and scoring someone else's plugin as if it were yours
|
|
1364
|
+
// is the false positive that gets a gate turned off. So the DEFAULT fixes the
|
|
1365
|
+
// SILENCE, and `bundles: "all"` fixes the COVERAGE — one exit code over the
|
|
1366
|
+
// whole repo, which is what a CI gate needs.
|
|
1367
|
+
const nestedBundles = discoverNestedBundles(scanRoot, config?.exclude ?? []);
|
|
1368
|
+
const scoreAll = flags.includes("--bundles=all") || config?.bundles === "all";
|
|
1369
|
+
const lintRoots = scoreAll ? [scanRoot, ...nestedBundles] : [scanRoot];
|
|
1370
|
+
if (!silent && nestedBundles.length > 0) {
|
|
1371
|
+
const rel = nestedBundles.map((b) => (0, node_path_1.relative)(scanRoot, b) || b);
|
|
1372
|
+
console.log(scoreAll
|
|
1373
|
+
? `\nScoring ${String(lintRoots.length)} bundles: the root + ${rel.join(", ")}`
|
|
1374
|
+
: `\n⚠ ${String(nestedBundles.length)} nested bundle(s) discovered but NOT scored: ${rel.join(", ")}\n` +
|
|
1375
|
+
` Their skills/agents/hooks are not in the counters below. Add \`"bundles": "all"\` to ` +
|
|
1376
|
+
`.vigilesrc.json (or pass --bundles=all) to score them in this run.`);
|
|
1377
|
+
}
|
|
1120
1378
|
// Discover the compiled files whose integrity is verified. Include the active
|
|
1121
1379
|
// harness's subagent dir (dogfood E2): a compiled `agents/<name>.md` carries a
|
|
1122
1380
|
// vigiles hash, but the default glob only matched CLAUDE/AGENTS/SKILL, so a
|
|
@@ -1170,7 +1428,14 @@ async function runLint(restArgs, flags, config) {
|
|
|
1170
1428
|
// declares the block; its `include` defaults to docs/ (research/ etc. are
|
|
1171
1429
|
// opted into explicitly). `enforce("vigiles/orphan-docs")` in a spec only
|
|
1172
1430
|
// validates the rule NAME — the block is what drives the scan.
|
|
1173
|
-
|
|
1431
|
+
// 🔴 SEVERITY IS READ, not just the block's presence (#181). `orphan-docs` had
|
|
1432
|
+
// a RULE_META entry and a documented severity, and nothing ever read it: both
|
|
1433
|
+
// `"warn"` and `"off"` still exited 1, so a repo could only choose between an
|
|
1434
|
+
// always-blocking check and deleting the `orphans` block. `warn` now reports
|
|
1435
|
+
// without touching the exit code and `false`/`"off"` skips the scan, exactly
|
|
1436
|
+
// like every other rule.
|
|
1437
|
+
const orphanSeverity = (0, types_js_1.ruleSeverity)(config?.rules?.["orphan-docs"]) ?? "warn";
|
|
1438
|
+
const orphansCfg = orphanSeverity ? config?.orphans : undefined;
|
|
1174
1439
|
if (!silent)
|
|
1175
1440
|
console.log("\nOrphan docs check:\n");
|
|
1176
1441
|
let orphanReport = {
|
|
@@ -1201,72 +1466,75 @@ async function runLint(restArgs, flags, config) {
|
|
|
1201
1466
|
// 7b. Untested-surface check — skills/agents/hooks shipping without a test or
|
|
1202
1467
|
// eval. Warning by default (a nudge, exit 0); set rules.untested-{skill,agent,
|
|
1203
1468
|
// hook} to "error" to gate CI. See src/test-coverage.ts and docs/rules/.
|
|
1204
|
-
|
|
1469
|
+
// A compiled artifact's refs, re-derived from its spec — the hash says the file
|
|
1470
|
+
// is unchanged, not that what it names still exists (#173).
|
|
1471
|
+
const specRefs = await checkSpecRefs(config, silent, adapter.dialect);
|
|
1472
|
+
const untested = overBundles(checkUntestedSurfaces, config, silent, adapter, lintRoots);
|
|
1205
1473
|
// 7c. Subagent tool-contract check — cross-reference each subagent's `tools:`
|
|
1206
1474
|
// rail against the harness catalog (the moat). n/a on a harness with no
|
|
1207
1475
|
// subagents. Off by default unless a severity is configured; warning surfaces
|
|
1208
1476
|
// a typo/never-available tool, error gates CI.
|
|
1209
|
-
const toolContract = checkSubagentToolContracts
|
|
1477
|
+
const toolContract = overBundles(checkSubagentToolContracts, config, silent, adapter, lintRoots);
|
|
1210
1478
|
// 7d. Hook-event check — a hook registered under an event the harness doesn't
|
|
1211
1479
|
// define never fires. High-precision (close typos only). Off unless configured.
|
|
1212
|
-
const hookEvents = checkHookEvents
|
|
1480
|
+
const hookEvents = overBundles(checkHookEvents, config, silent, adapter, lintRoots);
|
|
1213
1481
|
// 7e. Subagent-frontmatter check — a subagent missing required frontmatter
|
|
1214
1482
|
// (name + description) won't register. n/a on a harness with no subagents.
|
|
1215
|
-
const frontmatter = checkFrontmatterSchema
|
|
1483
|
+
const frontmatter = overBundles(checkFrontmatterSchema, config, silent, adapter, lintRoots);
|
|
1216
1484
|
// 7f. MCP-config check — a declared MCP server with no command/url can't start.
|
|
1217
|
-
const mcpConfig = checkMcpConfig
|
|
1485
|
+
const mcpConfig = overBundles(checkMcpConfig, config, silent, adapter, lintRoots);
|
|
1218
1486
|
// 7g. Skill-frontmatter — RECOMMEND explicit name/description on skills (a
|
|
1219
1487
|
// reliable trigger surface). Best-practice nudge; skills load without it.
|
|
1220
|
-
const skillFm = checkSkillFrontmatter
|
|
1488
|
+
const skillFm = overBundles(checkSkillFrontmatter, config, silent, adapter, lintRoots);
|
|
1221
1489
|
// 7h. MCP tool-resolution — an `mcp__server__tool` in a contract whose server
|
|
1222
1490
|
// the plugin doesn't declare can't resolve (the MCP half of the tool moat).
|
|
1223
|
-
const mcpToolResolves = checkMcpToolResolves
|
|
1491
|
+
const mcpToolResolves = overBundles(checkMcpToolResolves, config, silent, adapter, lintRoots);
|
|
1224
1492
|
// 7i. Hook-script existence — a hook command referencing a missing script file
|
|
1225
1493
|
// never runs (matches Anthropic's own `claude plugin validate`).
|
|
1226
|
-
const hookScripts = checkHookScriptExists
|
|
1494
|
+
const hookScripts = overBundles(checkHookScriptExists, config, silent, adapter, lintRoots);
|
|
1227
1495
|
// 7j. Disallowed-tools — a `disallowedTools:` block-list typo blocks nothing
|
|
1228
1496
|
// (the deny-side mirror of subagent-tool-contract; close-typo only).
|
|
1229
|
-
const disallowedTools = checkDisallowedTools
|
|
1497
|
+
const disallowedTools = overBundles(checkDisallowedTools, config, silent, adapter, lintRoots);
|
|
1230
1498
|
// 7k. Description-overlap — two model-invocable skills with near-identical
|
|
1231
1499
|
// descriptions collide in the selector (deterministic NCD precision proxy).
|
|
1232
|
-
const descriptionOverlap = checkDescriptionOverlap
|
|
1500
|
+
const descriptionOverlap = overBundles(checkDescriptionOverlap, config, silent, adapter, lintRoots);
|
|
1233
1501
|
// 7k². Skill-description-budget — a model-invocable skill whose description is
|
|
1234
1502
|
// so long the trigger signal is buried (heuristic proxy; degrades recall +
|
|
1235
1503
|
// precision). Generous 500-char budget; warn-tier, never gates.
|
|
1236
|
-
const descriptionBudget = checkDescriptionBudget
|
|
1504
|
+
const descriptionBudget = overBundles(checkDescriptionBudget, config, silent, adapter, lintRoots);
|
|
1237
1505
|
// 7l. Frontmatter-valid — a `---` block that isn't valid YAML (warn; js-yaml is
|
|
1238
1506
|
// stricter than some loaders, so verify before enforcing).
|
|
1239
|
-
const frontmatterValid = checkFrontmatterValid
|
|
1507
|
+
const frontmatterValid = overBundles(checkFrontmatterValid, config, silent, adapter, lintRoots);
|
|
1240
1508
|
// 7m. MCP hook-target — a `type: mcp_tool` hook action that's incomplete or
|
|
1241
1509
|
// targets an undeclared server (the moat applied to the hook surface).
|
|
1242
|
-
const mcpHookTargets = checkMcpHookTargets
|
|
1510
|
+
const mcpHookTargets = overBundles(checkMcpHookTargets, config, silent, adapter, lintRoots);
|
|
1243
1511
|
// 7n. Prefer-compiled-hooks — ONE discovery nudge (not per-hook) toward
|
|
1244
1512
|
// compiled `vigiles/hook` artifacts when hand-written hooks ship. Recommendation.
|
|
1245
|
-
const preferCompiledHooks = checkPreferCompiledHooks
|
|
1513
|
+
const preferCompiledHooks = overBundles(checkPreferCompiledHooks, config, silent, adapter, lintRoots);
|
|
1246
1514
|
// 7o. Lethal-trifecta — a unit (subagent / model-invocable skill) whose tools
|
|
1247
1515
|
// hold all three legs (read-private + ingest-untrusted + exfiltrate) is a
|
|
1248
1516
|
// prompt-injection exfil path (Rule of Two). Capability SET-intersection.
|
|
1249
|
-
const lethalTrifecta = checkLethalTrifecta
|
|
1517
|
+
const lethalTrifecta = overBundles(checkLethalTrifecta, config, silent, adapter, lintRoots);
|
|
1250
1518
|
// 7p. Skill-resource — a SKILL.md body referencing a bundled file that doesn't
|
|
1251
1519
|
// exist on disk under the skill dir (the agent gets nothing). FP-safe.
|
|
1252
|
-
const skillResources = checkSkillResourceResolves
|
|
1520
|
+
const skillResources = overBundles(checkSkillResourceResolves, config, silent, adapter, lintRoots);
|
|
1253
1521
|
// 7q. Skill-missing-fence — a SKILL.md opening with `name:`/`description:` but no
|
|
1254
1522
|
// `---` fence loads as plain body (invisible — no name/description/trigger).
|
|
1255
|
-
const skillFence = checkSkillMissingFence
|
|
1523
|
+
const skillFence = overBundles(checkSkillMissingFence, config, silent, adapter, lintRoots);
|
|
1256
1524
|
// 7r. Plugin-dir-layout — functional surface dirs (skills/agents/commands) nested
|
|
1257
1525
|
// inside the `.claude-plugin/` manifest dir where the harness can't see them.
|
|
1258
|
-
const pluginLayout = checkPluginDirLayout
|
|
1526
|
+
const pluginLayout = overBundles(checkPluginDirLayout, config, silent, adapter, lintRoots);
|
|
1259
1527
|
// 7s. Delegation-trifecta — a lethal trifecta that emerges across a delegation
|
|
1260
1528
|
// edge (a subagent's own ∪ delegated-to capability) though no single unit trips it.
|
|
1261
|
-
const delegationTrifecta = checkDelegationTrifecta
|
|
1529
|
+
const delegationTrifecta = overBundles(checkDelegationTrifecta, config, silent, adapter, lintRoots);
|
|
1262
1530
|
// 7t. Hook-block-ineffective — a hook that looks like it blocks but silently
|
|
1263
1531
|
// doesn't (block decision on a non-blocking event, or the legacy `decision`
|
|
1264
1532
|
// field on a permission-gated event). The #1 verified hook pain (#19009).
|
|
1265
|
-
const hookBlock = checkHookBlockIneffective
|
|
1533
|
+
const hookBlock = overBundles(checkHookBlockIneffective, config, silent, adapter, lintRoots);
|
|
1266
1534
|
// 7u. Hook-matcher — a hook `matcher` that doesn't fire as written (tool-name
|
|
1267
1535
|
// typo, an uncompilable or unreachable MCP pattern, one too narrow for real
|
|
1268
1536
|
// server naming, or an undeclared MCP server).
|
|
1269
|
-
const hookMatcher = checkHookMatcher
|
|
1537
|
+
const hookMatcher = overBundles(checkHookMatcher, config, silent, adapter, lintRoots);
|
|
1270
1538
|
// 8. Validate vigiles builder calls inside markdown code blocks — the
|
|
1271
1539
|
// `doc-refs` rule, DEFAULT OFF. Illustrative blocks opt out via
|
|
1272
1540
|
// `<!-- vigiles:ignore -->` (single block) or `<!-- vigiles:ignore-file -->`
|
|
@@ -1342,13 +1610,17 @@ async function runLint(restArgs, flags, config) {
|
|
|
1342
1610
|
inlineRules,
|
|
1343
1611
|
frontmatterErrors,
|
|
1344
1612
|
frontmatterRules,
|
|
1613
|
+
specRefIssues: specRefs.issues,
|
|
1614
|
+
specRefErrors: specRefs.errors,
|
|
1345
1615
|
duplicatePairs: dups.pairCount,
|
|
1616
|
+
duplicateSeverity: (0, types_js_1.ruleSeverity)(config?.rules?.["duplicate-rules"]) ?? "warn",
|
|
1346
1617
|
coverageEnabled: coverage.enabled,
|
|
1347
1618
|
coverageDocumented: coverage.documented,
|
|
1348
1619
|
strengthenSuggestions: guidanceCount,
|
|
1349
1620
|
integrityErrors,
|
|
1350
1621
|
coverageErrors,
|
|
1351
1622
|
orphanCount: orphanReport.orphans.length,
|
|
1623
|
+
orphanSeverity: orphanSeverity,
|
|
1352
1624
|
untestedSurfaces: untested.untested,
|
|
1353
1625
|
untestedErrors: untested.errors,
|
|
1354
1626
|
toolContractIssues: toolContract.issues,
|
|
@@ -1399,13 +1671,41 @@ async function runLint(restArgs, flags, config) {
|
|
|
1399
1671
|
mcpRefErrors,
|
|
1400
1672
|
files,
|
|
1401
1673
|
};
|
|
1674
|
+
// The totals both surfaces quote, computed ONCE (#183). Attaching them to the
|
|
1675
|
+
// report is what makes the log line and `--json` incapable of disagreeing —
|
|
1676
|
+
// the previous gap was not a wrong number, it was two right numbers with
|
|
1677
|
+
// nothing explaining the difference.
|
|
1678
|
+
const totals = lintTotals(report);
|
|
1679
|
+
const reported = { ...report, totals };
|
|
1680
|
+
// `--json-out=<file>` writes the JSON to disk while stdout keeps the
|
|
1681
|
+
// human-readable run — one scan, both artefacts (#182). A CI job needed both
|
|
1682
|
+
// (the log is what a human opens; the JSON is what the PR comment is built
|
|
1683
|
+
// from) and had to scan the repo TWICE to get them, which is the same work
|
|
1684
|
+
// done twice and grows with the corpus.
|
|
1685
|
+
const jsonOutFlag = flags.find((f) => f.startsWith("--json-out="));
|
|
1686
|
+
if (jsonOutFlag) {
|
|
1687
|
+
const dest = (0, node_path_1.resolve)(jsonOutFlag.slice("--json-out=".length));
|
|
1688
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(dest), { recursive: true });
|
|
1689
|
+
(0, node_fs_1.writeFileSync)(dest, `${JSON.stringify(reported, null, 2)}\n`);
|
|
1690
|
+
if (!silent)
|
|
1691
|
+
console.log(`\n✓ JSON report written to ${dest}`);
|
|
1692
|
+
}
|
|
1402
1693
|
if (summary) {
|
|
1403
|
-
printLintSummary(
|
|
1694
|
+
printLintSummary(reported);
|
|
1404
1695
|
}
|
|
1405
1696
|
else if (json) {
|
|
1406
|
-
console.log(JSON.stringify(
|
|
1697
|
+
console.log(JSON.stringify(reported, null, 2));
|
|
1407
1698
|
}
|
|
1408
|
-
|
|
1699
|
+
else {
|
|
1700
|
+
// The one number a reader can quote. Counting `⚠` lines gives a DIFFERENT
|
|
1701
|
+
// number, because some checks print one line per finding and others one line
|
|
1702
|
+
// carrying a count — so the log now states the finding total outright rather
|
|
1703
|
+
// than leaving the reader to infer it from line shapes.
|
|
1704
|
+
const code = lintExitCode(reported);
|
|
1705
|
+
console.log(`\n${String(totals.findings)} finding(s): ${String(totals.errors)} error, ` +
|
|
1706
|
+
`${String(totals.warnings)} warning — exit ${String(code)}`);
|
|
1707
|
+
}
|
|
1708
|
+
return reported;
|
|
1409
1709
|
}
|
|
1410
1710
|
/** Single-line lint summary for SessionStart hooks — minimal token cost. */
|
|
1411
1711
|
function printLintSummary(report) {
|
|
@@ -5947,14 +6247,12 @@ async function runHookProgramCommand(file) {
|
|
|
5947
6247
|
function ensureReportGitignored(cwd, entries) {
|
|
5948
6248
|
if (entries.length === 0)
|
|
5949
6249
|
return;
|
|
5950
|
-
//
|
|
5951
|
-
//
|
|
5952
|
-
//
|
|
5953
|
-
//
|
|
5954
|
-
//
|
|
5955
|
-
//
|
|
5956
|
-
if (entries.some((e) => e.startsWith("..") || (0, node_path_1.isAbsolute)(e)))
|
|
5957
|
-
return;
|
|
6250
|
+
// 🔴 THE GUARD THAT USED TO BE HERE IS GONE, and its absence is the point.
|
|
6251
|
+
// It checked at the write site that no entry escaped the repo (#176.8) — which
|
|
6252
|
+
// worked, and left the bug writable: the next caller to build an entry list
|
|
6253
|
+
// still got a bare `string[]`. `RepoRelativePath` moves the check into the
|
|
6254
|
+
// TYPE, so an escaping path cannot be handed to this function at all. One
|
|
6255
|
+
// place mints them (`repoRelative`), and it returns null instead.
|
|
5958
6256
|
const gi = (0, node_path_1.resolve)(cwd, ".gitignore");
|
|
5959
6257
|
try {
|
|
5960
6258
|
if (!(0, node_fs_1.existsSync)(gi)) {
|
|
@@ -6860,10 +7158,17 @@ async function main() {
|
|
|
6860
7158
|
// backslashes (`relative()` yields `reports\x` on Windows, which would
|
|
6861
7159
|
// never match `reports/x`).
|
|
6862
7160
|
if (wroteReports.length > 0) {
|
|
6863
|
-
|
|
6864
|
-
|
|
6865
|
-
|
|
6866
|
-
|
|
7161
|
+
// Only paths that PROVABLY sit inside the repo can be minted, so an
|
|
7162
|
+
// `--out` pointing elsewhere yields nothing to write rather than a
|
|
7163
|
+
// dead `../../..` entry.
|
|
7164
|
+
const rel = wroteReports
|
|
7165
|
+
.map((f) => (0, repo_path_js_1.repoRelative)(process.cwd(), (0, node_path_1.resolve)(outDir, f), {
|
|
7166
|
+
relative: node_path_1.relative,
|
|
7167
|
+
resolve: node_path_1.resolve,
|
|
7168
|
+
isAbsolute: node_path_1.isAbsolute,
|
|
7169
|
+
sep: node_path_1.sep,
|
|
7170
|
+
}))
|
|
7171
|
+
.filter((p) => p !== null);
|
|
6867
7172
|
ensureReportGitignored(process.cwd(), rel);
|
|
6868
7173
|
}
|
|
6869
7174
|
// A shareable deep-link for a public GitHub repo: the in-browser demo
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
* Gate references (`cmd`/`file`/`project`) are collected and verified, so the
|
|
26
26
|
* cross-referencing moat works on generators too (literal args only).
|
|
27
27
|
*/
|
|
28
|
+
import { type StampedMarkdown } from "./compile.js";
|
|
28
29
|
export interface GeneratorError {
|
|
29
30
|
type: "stale-file" | "stale-command";
|
|
30
31
|
message: string;
|
|
@@ -34,6 +35,11 @@ export interface CompileGeneratorResult {
|
|
|
34
35
|
errors: GeneratorError[];
|
|
35
36
|
}
|
|
36
37
|
export interface CompileGeneratorSkillResult {
|
|
38
|
+
/**
|
|
39
|
+
* The stamped artifact — present ONLY when `errors` is empty. `null` is what
|
|
40
|
+
* makes a failed compile unwritable: `writeArtifact` accepts nothing else.
|
|
41
|
+
*/
|
|
42
|
+
artifact: StampedMarkdown | null;
|
|
37
43
|
markdown: string;
|
|
38
44
|
errors: GeneratorError[];
|
|
39
45
|
}
|
|
@@ -278,6 +278,9 @@ function compileGeneratorSkill(source, options = {}) {
|
|
|
278
278
|
!genArg.body) {
|
|
279
279
|
return {
|
|
280
280
|
markdown: "",
|
|
281
|
+
// No stamp for a spec that did not compile — the error branch has nothing
|
|
282
|
+
// to write, which is the whole point of the field.
|
|
283
|
+
artifact: null,
|
|
281
284
|
errors: [
|
|
282
285
|
{
|
|
283
286
|
type: "stale-command",
|
|
@@ -300,7 +303,7 @@ function compileGeneratorSkill(source, options = {}) {
|
|
|
300
303
|
}
|
|
301
304
|
fm.push("", "---");
|
|
302
305
|
const content = `${fm.join("\n")}\n\n${body.trim()}\n`;
|
|
303
|
-
return {
|
|
306
|
+
return { ...(0, compile_js_1.seal)(content, errors, specFile), errors };
|
|
304
307
|
}
|
|
305
308
|
/**
|
|
306
309
|
* Compile a generator's SOURCE text to SKILL.md markdown + verified-ref
|