vigiles 23.0.0 → 24.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.
@@ -24,6 +24,7 @@ exports.formatScriptSummary = formatScriptSummary;
24
24
  const node_child_process_1 = require("node:child_process");
25
25
  const node_os_1 = require("node:os");
26
26
  const node_path_1 = require("node:path");
27
+ const node_url_1 = require("node:url");
27
28
  const node_fs_1 = require("node:fs");
28
29
  const node_os_2 = require("node:os");
29
30
  const glob_1 = require("glob");
@@ -250,10 +251,23 @@ async function runScripts(files, cwd, env = {}, opts = {}) {
250
251
  return;
251
252
  }
252
253
  const countFile = (0, node_path_1.join)(countDir, `${String(i)}.count`);
253
- const child = (0, node_child_process_1.spawn)("node", argv, {
254
+ // Resolve a harness's bare `vigiles` import from the CLI's OWN install, so
255
+ // running the gate does not require installing the package into the
256
+ // project — which, in a repo that already has a package.json, drags in the
257
+ // entire dependency tree (measured at 840 packages against vigiles' 42,
258
+ // #184). Only rescues that specifier, and only after normal resolution
259
+ // fails, so a locally installed copy still wins.
260
+ const selfRoot = (0, node_path_1.resolve)(__dirname, "..", "..", "..");
261
+ const hook = (0, node_url_1.pathToFileURL)((0, node_path_1.join)(selfRoot, "dist", "harness-resolve-hooks.mjs")).href;
262
+ const child = (0, node_child_process_1.spawn)("node", ["--import", hookImport(hook), ...argv], {
254
263
  cwd,
255
264
  stdio: ["ignore", "pipe", "pipe"],
256
- env: { ...process.env, ...env, [check_count_js_1.CHECK_COUNT_ENV]: countFile },
265
+ env: {
266
+ ...process.env,
267
+ ...env,
268
+ VIGILES_SELF_ROOT: selfRoot,
269
+ [check_count_js_1.CHECK_COUNT_ENV]: countFile,
270
+ },
257
271
  });
258
272
  const chunks = [];
259
273
  child.stdout.on("data", (c) => chunks.push(c));
@@ -376,4 +390,14 @@ function formatScriptSummary(results) {
376
390
  }
377
391
  return lines.join("\n");
378
392
  }
393
+ /**
394
+ * A `--import` argument that registers the resolver hook without a temp file:
395
+ * a data: URL calling `module.register`. Inline because writing a shim into the
396
+ * user's tree to run their tests would be a side effect the runner has no
397
+ * business having.
398
+ */
399
+ function hookImport(hookHref) {
400
+ const src = `import {register} from "node:module";register(${JSON.stringify(hookHref)});`;
401
+ return `data:text/javascript,${encodeURIComponent(src)}`;
402
+ }
379
403
  //# sourceMappingURL=run-scripts.js.map
@@ -78,7 +78,7 @@ exports.COMMAND_FLAGS = {
78
78
  ],
79
79
  compile: [],
80
80
  eject: ["--keep-spec"],
81
- lint: ["--summary", "--json"],
81
+ lint: ["--bundles=", "--summary", "--json", "--json-out="],
82
82
  // handleRunScripts (free tier — no lock flags).
83
83
  test: ["--min=", "--all", "--yes", "--no-interactive", "--no-skip"],
84
84
  // handleRunScripts + resolveEvalLockEnv + the trials knob.
package/dist/cli.d.ts CHANGED
@@ -9,6 +9,127 @@
9
9
  * `self-command-refs.test.ts` did not catch it because it guards against refs to
10
10
  * REMOVED commands, not against a list that merely stops growing.
11
11
  */
12
+ import type { RuleSeverity } from "./core/types.js";
12
13
  /** Reason the most recent `loadSpec()` returned null, or null if it succeeded. */
13
14
  export declare function specLoadFailureReason(): string | null;
15
+ /**
16
+ * Structured lint report used by --json, --summary, and exit-code logic.
17
+ */
18
+ interface LintReport {
19
+ hashErrors: number;
20
+ validationErrors: number;
21
+ inlineErrors: number;
22
+ inlineRules: number;
23
+ frontmatterErrors: number;
24
+ frontmatterRules: number;
25
+ specRefIssues: number;
26
+ specRefErrors: number;
27
+ duplicatePairs: number;
28
+ /** Severity of `duplicate-instructions` — carried so the exit code can tier it. */
29
+ duplicateSeverity: RuleSeverity;
30
+ coverageEnabled: number;
31
+ coverageDocumented: number;
32
+ strengthenSuggestions: number;
33
+ integrityErrors: number;
34
+ coverageErrors: number;
35
+ orphanCount: number;
36
+ /** Severity of `orphan-docs` — carried so the exit code can tier it (#181). */
37
+ orphanSeverity: RuleSeverity;
38
+ untestedSurfaces: number;
39
+ untestedErrors: number;
40
+ toolContractIssues: number;
41
+ toolContractErrors: number;
42
+ hookEventIssues: number;
43
+ hookEventErrors: number;
44
+ frontmatterSchemaIssues: number;
45
+ frontmatterSchemaErrors: number;
46
+ mcpConfigIssues: number;
47
+ mcpConfigErrors: number;
48
+ skillFrontmatterIssues: number;
49
+ skillFrontmatterErrors: number;
50
+ mcpToolIssues: number;
51
+ mcpToolErrors: number;
52
+ hookScriptIssues: number;
53
+ hookScriptErrors: number;
54
+ disallowedToolIssues: number;
55
+ disallowedToolErrors: number;
56
+ descriptionOverlapIssues: number;
57
+ descriptionOverlapErrors: number;
58
+ descriptionBudgetIssues: number;
59
+ descriptionBudgetErrors: number;
60
+ frontmatterValidIssues: number;
61
+ frontmatterValidErrors: number;
62
+ mcpHookIssues: number;
63
+ mcpHookErrors: number;
64
+ preferCompiledHookIssues: number;
65
+ preferCompiledHookErrors: number;
66
+ lethalTrifectaIssues: number;
67
+ lethalTrifectaErrors: number;
68
+ skillResourceIssues: number;
69
+ skillResourceErrors: number;
70
+ skillFenceIssues: number;
71
+ skillFenceErrors: number;
72
+ pluginLayoutIssues: number;
73
+ pluginLayoutErrors: number;
74
+ delegationTrifectaIssues: number;
75
+ delegationTrifectaErrors: number;
76
+ hookBlockIssues: number;
77
+ hookBlockErrors: number;
78
+ hookMatcherIssues: number;
79
+ hookMatcherErrors: number;
80
+ docRefErrors: number;
81
+ symbolRefErrors: number;
82
+ mcpRefErrors: number;
83
+ files: string[];
84
+ /**
85
+ * Findings / errors / warnings for the whole run — the same numbers the
86
+ * human-readable summary line prints, so a consumer never has to reconstruct
87
+ * them by counting output lines (#183, and the generic-consumer half of #181).
88
+ */
89
+ totals?: {
90
+ findings: number;
91
+ errors: number;
92
+ warnings: number;
93
+ };
94
+ }
95
+ /** Exit codes: 0 clean, 1 warnings only, 2 hard errors. */
96
+ /**
97
+ * The run's totals, derived from the report itself.
98
+ *
99
+ * 🔴 ONE SOURCE, because the two numbers disagreeing IS the bug (#183). The
100
+ * human-readable log had no total, and counting its `⚠` lines gave a different
101
+ * number from the JSON — 21 against 88 on a real repo — because some checks print
102
+ * one line per finding and others one line carrying a count. Both numbers were
103
+ * right and nothing said why they differed, so "vigiles reports 21 warnings" and
104
+ * "88 warnings" were equally defensible readings of one run.
105
+ *
106
+ * Counted GENERICALLY off the `*Issues` / `*Errors` / count keys rather than a
107
+ * hand-maintained list, so a rule added later is included by existing, not by
108
+ * somebody remembering. `orphanCount` and `duplicatePairs` are named explicitly
109
+ * only because they predate the `*Issues` convention (#181).
110
+ */
111
+ export declare function lintTotals(report: LintReport): {
112
+ findings: number;
113
+ errors: number;
114
+ warnings: number;
115
+ };
116
+ /**
117
+ * Nested plugin bundles under a lint root — a directory that is itself a harness
118
+ * (its own `.claude-plugin/plugin.json`, or its own skills dir) and is NOT the
119
+ * root being linted.
120
+ *
121
+ * 🔴 WHY THIS EXISTS. Every per-surface check reads ONE root, so in a monorepo
122
+ * holding `skills/` plus `plugins/ * /skills/` the nested skills were never scored
123
+ * and nothing said so. Measured on a fixture: 4 skills over the description
124
+ * budget, `lint .` reported 2, exit 0 — a repo reads that as green-with-2 while
125
+ * the other 2 carry the same defect (#185). The failure is silent, which is the
126
+ * shape this repo treats as worse than a loud one.
127
+ *
128
+ * Deliberately shallow (one level under a container dir): deep recursion would
129
+ * sweep vendored corpora — this repo's own `test/dogfood/` holds real pinned
130
+ * third-party plugins — and scoring someone else's vendored plugin as if it were
131
+ * yours is the false-positive that gets a gate switched off.
132
+ */
133
+ export declare function discoverNestedBundles(root: string, exclude?: readonly string[]): string[];
134
+ export {};
14
135
  //# sourceMappingURL=cli.d.ts.map
package/dist/cli.js CHANGED
@@ -12,8 +12,11 @@
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");
17
20
  const node_child_process_1 = require("node:child_process");
18
21
  const glob_1 = require("glob");
19
22
  const generate_types_js_1 = require("./core/generate-types.js");
@@ -801,6 +804,55 @@ async function verifyMarkdownMcpRefs(files, silent) {
801
804
  return errors;
802
805
  }
803
806
  /** Exit codes: 0 clean, 1 warnings only, 2 hard errors. */
807
+ /**
808
+ * The run's totals, derived from the report itself.
809
+ *
810
+ * 🔴 ONE SOURCE, because the two numbers disagreeing IS the bug (#183). The
811
+ * human-readable log had no total, and counting its `⚠` lines gave a different
812
+ * number from the JSON — 21 against 88 on a real repo — because some checks print
813
+ * one line per finding and others one line carrying a count. Both numbers were
814
+ * right and nothing said why they differed, so "vigiles reports 21 warnings" and
815
+ * "88 warnings" were equally defensible readings of one run.
816
+ *
817
+ * Counted GENERICALLY off the `*Issues` / `*Errors` / count keys rather than a
818
+ * hand-maintained list, so a rule added later is included by existing, not by
819
+ * somebody remembering. `orphanCount` and `duplicatePairs` are named explicitly
820
+ * only because they predate the `*Issues` convention (#181).
821
+ */
822
+ function lintTotals(report) {
823
+ let errors = 0;
824
+ let findings = 0;
825
+ for (const [key, value] of Object.entries(report)) {
826
+ if (typeof value !== "number" || value === 0)
827
+ continue;
828
+ if (key === "files")
829
+ continue;
830
+ // Informational counters: not findings, they describe the corpus.
831
+ if (key === "inlineRules" ||
832
+ key === "frontmatterRules" ||
833
+ key === "coverageEnabled" ||
834
+ key === "coverageDocumented" ||
835
+ key === "strengthenSuggestions")
836
+ continue;
837
+ if (key.endsWith("Errors")) {
838
+ errors += value;
839
+ findings += value;
840
+ continue;
841
+ }
842
+ // `*Issues` counts EVERY finding of that rule; when the rule is at "error"
843
+ // the same findings are also in `*Errors`, so they must not be counted twice.
844
+ if (key.endsWith("Issues")) {
845
+ const paired = report[`${key.slice(0, -"Issues".length)}Errors`];
846
+ findings += paired && paired > 0 ? 0 : value;
847
+ continue;
848
+ }
849
+ if (key === "orphanCount" ||
850
+ key === "duplicatePairs" ||
851
+ key === "untestedSurfaces")
852
+ findings += value;
853
+ }
854
+ return { findings, errors, warnings: findings - errors };
855
+ }
804
856
  function lintExitCode(report) {
805
857
  if (report.hashErrors > 0 ||
806
858
  report.validationErrors > 0 ||
@@ -835,9 +887,15 @@ function lintExitCode(report) {
835
887
  // it to "error", so it belongs in the hard tier with every other explicit
836
888
  // error — it used to sit at exit 1 because it fired unasked and could not be
837
889
  // turned off.
838
- report.docRefErrors > 0)
890
+ report.docRefErrors > 0 ||
891
+ report.specRefErrors > 0)
839
892
  return 2;
840
- if (report.duplicatePairs > 0 || report.orphanCount > 0)
893
+ // Tierable now: a `warn` orphan/duplicate finding is reported and does NOT
894
+ // change the exit code. Both used to feed the exit directly, which is what
895
+ // made them the only untierable findings in the tool.
896
+ if (report.orphanCount > 0 && report.orphanSeverity === "error")
897
+ return 1;
898
+ if (report.duplicatePairs > 0 && report.duplicateSeverity === "error")
841
899
  return 1;
842
900
  // Guidance counts are informational, not failures
843
901
  return 0;
@@ -1085,6 +1143,157 @@ function sharedDirsRootFor(scanTarget) {
1085
1143
  const underCwd = rel === "" || (!rel.startsWith("..") && !(0, node_path_1.isAbsolute)(rel));
1086
1144
  return underCwd ? cwd : target;
1087
1145
  }
1146
+ /**
1147
+ * Nested plugin bundles under a lint root — a directory that is itself a harness
1148
+ * (its own `.claude-plugin/plugin.json`, or its own skills dir) and is NOT the
1149
+ * root being linted.
1150
+ *
1151
+ * 🔴 WHY THIS EXISTS. Every per-surface check reads ONE root, so in a monorepo
1152
+ * holding `skills/` plus `plugins/ * /skills/` the nested skills were never scored
1153
+ * and nothing said so. Measured on a fixture: 4 skills over the description
1154
+ * budget, `lint .` reported 2, exit 0 — a repo reads that as green-with-2 while
1155
+ * the other 2 carry the same defect (#185). The failure is silent, which is the
1156
+ * shape this repo treats as worse than a loud one.
1157
+ *
1158
+ * Deliberately shallow (one level under a container dir): deep recursion would
1159
+ * sweep vendored corpora — this repo's own `test/dogfood/` holds real pinned
1160
+ * third-party plugins — and scoring someone else's vendored plugin as if it were
1161
+ * yours is the false-positive that gets a gate switched off.
1162
+ */
1163
+ function discoverNestedBundles(root, exclude = []) {
1164
+ const out = [];
1165
+ const skip = new Set([
1166
+ "node_modules",
1167
+ ".git",
1168
+ "dist",
1169
+ "coverage",
1170
+ ".vigiles",
1171
+ ]);
1172
+ const isBundle = (dir) => (0, node_fs_1.existsSync)((0, node_path_1.join)(dir, ".claude-plugin", "plugin.json")) ||
1173
+ (0, node_fs_1.existsSync)((0, node_path_1.join)(dir, "skills"));
1174
+ const excluded = (rel) => exclude.some((pattern) => rel === pattern ||
1175
+ rel.startsWith(`${pattern}/`) ||
1176
+ (0, minimatch_1.minimatch)(rel, pattern) ||
1177
+ (0, minimatch_1.minimatch)(rel, `${pattern}/**`));
1178
+ let entries;
1179
+ try {
1180
+ entries = (0, node_fs_1.readdirSync)(root, { withFileTypes: true })
1181
+ .filter((e) => e.isDirectory() && !skip.has(e.name) && !e.name.startsWith("."))
1182
+ .map((e) => e.name);
1183
+ }
1184
+ catch {
1185
+ return out;
1186
+ }
1187
+ for (const name of entries) {
1188
+ const dir = (0, node_path_1.join)(root, name);
1189
+ if (excluded(name))
1190
+ continue;
1191
+ // A container (`plugins/`) holds bundles; a bundle may also sit directly.
1192
+ if (isBundle(dir)) {
1193
+ out.push(dir);
1194
+ continue;
1195
+ }
1196
+ let inner;
1197
+ try {
1198
+ inner = (0, node_fs_1.readdirSync)(dir, { withFileTypes: true })
1199
+ .filter((e) => e.isDirectory() && !e.name.startsWith("."))
1200
+ .map((e) => e.name);
1201
+ }
1202
+ catch {
1203
+ continue;
1204
+ }
1205
+ for (const child of inner) {
1206
+ const sub = (0, node_path_1.join)(dir, child);
1207
+ if (excluded(`${name}/${child}`))
1208
+ continue;
1209
+ if (isBundle(sub))
1210
+ out.push(sub);
1211
+ }
1212
+ }
1213
+ return out.sort();
1214
+ }
1215
+ /**
1216
+ * Run one per-surface check over EVERY root and sum its counters.
1217
+ *
1218
+ * The checks all share `(config, silent, adapter, root)` and return a small
1219
+ * record of numbers, so one wrapper covers all twenty rather than twenty edits —
1220
+ * and a check added later is swept in by using it, not by remembering to.
1221
+ */
1222
+ function overBundles(fn, config, silent, adapter, roots) {
1223
+ const [first, ...rest] = roots;
1224
+ const total = { ...fn(config, silent, adapter, first) };
1225
+ for (const root of rest) {
1226
+ const next = fn(config, silent, adapter, root);
1227
+ for (const key of Object.keys(next))
1228
+ total[key] =
1229
+ (total[key] ?? 0) + (next[key] ?? 0);
1230
+ }
1231
+ return total;
1232
+ }
1233
+ /**
1234
+ * Re-derive a compiled artifact's references from its SPEC, and report the dead
1235
+ * ones — the half of #173 that deleting the write-on-error did not close.
1236
+ *
1237
+ * 🔴 THE HOLE. `lint` verifies the integrity HASH, which answers "is this file
1238
+ * still what the spec compiled to" and says nothing about whether the things it
1239
+ * NAMES still exist. So a `CLAUDE.md` committed while its refs were live stays
1240
+ * green forever after the referenced file is deleted: `compile` errors, `lint`
1241
+ * prints "hash valid — All compiled files intact" and exits 0. Reproduced:
1242
+ *
1243
+ * $ vigiles compile CLAUDE.md.spec.ts
1244
+ * ✗ [stale-file] File not found: "docs/guide.md"
1245
+ * $ vigiles lint .
1246
+ * ✓ CLAUDE.md — hash valid # exit 0
1247
+ *
1248
+ * The reporter named this residue himself when filing #173 and I deferred it;
1249
+ * it is the gap between what `README.md` promises of `lint` ("the CI gate …
1250
+ * broken refs") and what it checked. A hash is an integrity claim, not a
1251
+ * reference claim, and the two were being read as one.
1252
+ *
1253
+ * Cost is bounded: only specs whose compiled target actually EXISTS are loaded,
1254
+ * so a repo with no specs does no extra work at all.
1255
+ */
1256
+ async function checkSpecRefs(config, silent, dialect) {
1257
+ const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["spec-refs"]) ?? "error";
1258
+ if (!sev)
1259
+ return { issues: 0, errors: 0 };
1260
+ const found = [];
1261
+ for (const specPath of findSpecs()) {
1262
+ const target = specPath.replace(/\.spec\.ts$/, "");
1263
+ if (!(0, node_fs_1.existsSync)(target))
1264
+ continue; // never compiled — `compile` reports it
1265
+ const spec = await loadSpec(specPath);
1266
+ if (!spec || spec._specType !== "claude")
1267
+ continue;
1268
+ try {
1269
+ const { errors } = (0, compile_js_1.compileClaude)(spec, {
1270
+ basePath: process.cwd(),
1271
+ specFile: specPath,
1272
+ dialect,
1273
+ maxRules: config?.maxRules,
1274
+ maxTokens: config?.maxTokens,
1275
+ maxSectionLines: config?.maxSectionLines,
1276
+ catalogOnly: config?.catalogOnly,
1277
+ linters: config?.linters,
1278
+ });
1279
+ for (const e of errors)
1280
+ found.push(`${target}: ${e.message} (from ${specPath})`);
1281
+ }
1282
+ catch {
1283
+ // A spec that will not load is `compile`'s finding, not this one's —
1284
+ // reporting it here would double-report and blame the wrong command.
1285
+ continue;
1286
+ }
1287
+ }
1288
+ if (found.length > 0 && !silent) {
1289
+ console.log("\nSpec reference check:\n");
1290
+ for (const msg of found) {
1291
+ console.log(` ${sev === "error" ? "✗" : "⚠"} ${msg}`);
1292
+ ghAnnotate(sev === "error" ? "error" : "warning", msg);
1293
+ }
1294
+ }
1295
+ return { issues: found.length, errors: sev === "error" ? found.length : 0 };
1296
+ }
1088
1297
  async function runLint(restArgs, flags, config) {
1089
1298
  const summary = flags.includes("--summary");
1090
1299
  const json = flags.includes("--json");
@@ -1117,6 +1326,30 @@ async function runLint(restArgs, flags, config) {
1117
1326
  configHarness: (0, adapter_registry_js_1.normalizeHarnessList)(config?.harness),
1118
1327
  });
1119
1328
  const adapter = lintSelection.adapter;
1329
+ // 🔴 WHICH ROOTS GET SCORED, and saying so either way (#185).
1330
+ //
1331
+ // Every per-surface check reads ONE root, so a monorepo with `skills/` plus
1332
+ // `plugins/*/skills/` scored only the first and said nothing — measured at 2
1333
+ // findings reported against 4 real ones, exit 0. A skipped surface that is not
1334
+ // announced reads as a clean surface.
1335
+ //
1336
+ // Default stays ROOT-ONLY on purpose: descending by default would start
1337
+ // scoring vendored third-party corpora (this repo's own `test/dogfood/` holds
1338
+ // pinned real plugins), and scoring someone else's plugin as if it were yours
1339
+ // is the false positive that gets a gate turned off. So the DEFAULT fixes the
1340
+ // SILENCE, and `bundles: "all"` fixes the COVERAGE — one exit code over the
1341
+ // whole repo, which is what a CI gate needs.
1342
+ const nestedBundles = discoverNestedBundles(scanRoot, config?.exclude ?? []);
1343
+ const scoreAll = flags.includes("--bundles=all") || config?.bundles === "all";
1344
+ const lintRoots = scoreAll ? [scanRoot, ...nestedBundles] : [scanRoot];
1345
+ if (!silent && nestedBundles.length > 0) {
1346
+ const rel = nestedBundles.map((b) => (0, node_path_1.relative)(scanRoot, b) || b);
1347
+ console.log(scoreAll
1348
+ ? `\nScoring ${String(lintRoots.length)} bundles: the root + ${rel.join(", ")}`
1349
+ : `\n⚠ ${String(nestedBundles.length)} nested bundle(s) discovered but NOT scored: ${rel.join(", ")}\n` +
1350
+ ` Their skills/agents/hooks are not in the counters below. Add \`"bundles": "all"\` to ` +
1351
+ `.vigilesrc.json (or pass --bundles=all) to score them in this run.`);
1352
+ }
1120
1353
  // Discover the compiled files whose integrity is verified. Include the active
1121
1354
  // harness's subagent dir (dogfood E2): a compiled `agents/<name>.md` carries a
1122
1355
  // vigiles hash, but the default glob only matched CLAUDE/AGENTS/SKILL, so a
@@ -1170,7 +1403,14 @@ async function runLint(restArgs, flags, config) {
1170
1403
  // declares the block; its `include` defaults to docs/ (research/ etc. are
1171
1404
  // opted into explicitly). `enforce("vigiles/orphan-docs")` in a spec only
1172
1405
  // validates the rule NAME — the block is what drives the scan.
1173
- const orphansCfg = config?.orphans;
1406
+ // 🔴 SEVERITY IS READ, not just the block's presence (#181). `orphan-docs` had
1407
+ // a RULE_META entry and a documented severity, and nothing ever read it: both
1408
+ // `"warn"` and `"off"` still exited 1, so a repo could only choose between an
1409
+ // always-blocking check and deleting the `orphans` block. `warn` now reports
1410
+ // without touching the exit code and `false`/`"off"` skips the scan, exactly
1411
+ // like every other rule.
1412
+ const orphanSeverity = (0, types_js_1.ruleSeverity)(config?.rules?.["orphan-docs"]) ?? "warn";
1413
+ const orphansCfg = orphanSeverity ? config?.orphans : undefined;
1174
1414
  if (!silent)
1175
1415
  console.log("\nOrphan docs check:\n");
1176
1416
  let orphanReport = {
@@ -1201,72 +1441,75 @@ async function runLint(restArgs, flags, config) {
1201
1441
  // 7b. Untested-surface check — skills/agents/hooks shipping without a test or
1202
1442
  // eval. Warning by default (a nudge, exit 0); set rules.untested-{skill,agent,
1203
1443
  // hook} to "error" to gate CI. See src/test-coverage.ts and docs/rules/.
1204
- const untested = checkUntestedSurfaces(config, silent, adapter, scanRoot);
1444
+ // A compiled artifact's refs, re-derived from its spec — the hash says the file
1445
+ // is unchanged, not that what it names still exists (#173).
1446
+ const specRefs = await checkSpecRefs(config, silent, adapter.dialect);
1447
+ const untested = overBundles(checkUntestedSurfaces, config, silent, adapter, lintRoots);
1205
1448
  // 7c. Subagent tool-contract check — cross-reference each subagent's `tools:`
1206
1449
  // rail against the harness catalog (the moat). n/a on a harness with no
1207
1450
  // subagents. Off by default unless a severity is configured; warning surfaces
1208
1451
  // a typo/never-available tool, error gates CI.
1209
- const toolContract = checkSubagentToolContracts(config, silent, adapter, scanRoot);
1452
+ const toolContract = overBundles(checkSubagentToolContracts, config, silent, adapter, lintRoots);
1210
1453
  // 7d. Hook-event check — a hook registered under an event the harness doesn't
1211
1454
  // define never fires. High-precision (close typos only). Off unless configured.
1212
- const hookEvents = checkHookEvents(config, silent, adapter, scanRoot);
1455
+ const hookEvents = overBundles(checkHookEvents, config, silent, adapter, lintRoots);
1213
1456
  // 7e. Subagent-frontmatter check — a subagent missing required frontmatter
1214
1457
  // (name + description) won't register. n/a on a harness with no subagents.
1215
- const frontmatter = checkFrontmatterSchema(config, silent, adapter, scanRoot);
1458
+ const frontmatter = overBundles(checkFrontmatterSchema, config, silent, adapter, lintRoots);
1216
1459
  // 7f. MCP-config check — a declared MCP server with no command/url can't start.
1217
- const mcpConfig = checkMcpConfig(config, silent, adapter, scanRoot);
1460
+ const mcpConfig = overBundles(checkMcpConfig, config, silent, adapter, lintRoots);
1218
1461
  // 7g. Skill-frontmatter — RECOMMEND explicit name/description on skills (a
1219
1462
  // reliable trigger surface). Best-practice nudge; skills load without it.
1220
- const skillFm = checkSkillFrontmatter(config, silent, adapter, scanRoot);
1463
+ const skillFm = overBundles(checkSkillFrontmatter, config, silent, adapter, lintRoots);
1221
1464
  // 7h. MCP tool-resolution — an `mcp__server__tool` in a contract whose server
1222
1465
  // the plugin doesn't declare can't resolve (the MCP half of the tool moat).
1223
- const mcpToolResolves = checkMcpToolResolves(config, silent, adapter, scanRoot);
1466
+ const mcpToolResolves = overBundles(checkMcpToolResolves, config, silent, adapter, lintRoots);
1224
1467
  // 7i. Hook-script existence — a hook command referencing a missing script file
1225
1468
  // never runs (matches Anthropic's own `claude plugin validate`).
1226
- const hookScripts = checkHookScriptExists(config, silent, adapter, scanRoot);
1469
+ const hookScripts = overBundles(checkHookScriptExists, config, silent, adapter, lintRoots);
1227
1470
  // 7j. Disallowed-tools — a `disallowedTools:` block-list typo blocks nothing
1228
1471
  // (the deny-side mirror of subagent-tool-contract; close-typo only).
1229
- const disallowedTools = checkDisallowedTools(config, silent, adapter, scanRoot);
1472
+ const disallowedTools = overBundles(checkDisallowedTools, config, silent, adapter, lintRoots);
1230
1473
  // 7k. Description-overlap — two model-invocable skills with near-identical
1231
1474
  // descriptions collide in the selector (deterministic NCD precision proxy).
1232
- const descriptionOverlap = checkDescriptionOverlap(config, silent, adapter, scanRoot);
1475
+ const descriptionOverlap = overBundles(checkDescriptionOverlap, config, silent, adapter, lintRoots);
1233
1476
  // 7k². Skill-description-budget — a model-invocable skill whose description is
1234
1477
  // so long the trigger signal is buried (heuristic proxy; degrades recall +
1235
1478
  // precision). Generous 500-char budget; warn-tier, never gates.
1236
- const descriptionBudget = checkDescriptionBudget(config, silent, adapter, scanRoot);
1479
+ const descriptionBudget = overBundles(checkDescriptionBudget, config, silent, adapter, lintRoots);
1237
1480
  // 7l. Frontmatter-valid — a `---` block that isn't valid YAML (warn; js-yaml is
1238
1481
  // stricter than some loaders, so verify before enforcing).
1239
- const frontmatterValid = checkFrontmatterValid(config, silent, adapter, scanRoot);
1482
+ const frontmatterValid = overBundles(checkFrontmatterValid, config, silent, adapter, lintRoots);
1240
1483
  // 7m. MCP hook-target — a `type: mcp_tool` hook action that's incomplete or
1241
1484
  // targets an undeclared server (the moat applied to the hook surface).
1242
- const mcpHookTargets = checkMcpHookTargets(config, silent, adapter, scanRoot);
1485
+ const mcpHookTargets = overBundles(checkMcpHookTargets, config, silent, adapter, lintRoots);
1243
1486
  // 7n. Prefer-compiled-hooks — ONE discovery nudge (not per-hook) toward
1244
1487
  // compiled `vigiles/hook` artifacts when hand-written hooks ship. Recommendation.
1245
- const preferCompiledHooks = checkPreferCompiledHooks(config, silent, adapter, scanRoot);
1488
+ const preferCompiledHooks = overBundles(checkPreferCompiledHooks, config, silent, adapter, lintRoots);
1246
1489
  // 7o. Lethal-trifecta — a unit (subagent / model-invocable skill) whose tools
1247
1490
  // hold all three legs (read-private + ingest-untrusted + exfiltrate) is a
1248
1491
  // prompt-injection exfil path (Rule of Two). Capability SET-intersection.
1249
- const lethalTrifecta = checkLethalTrifecta(config, silent, adapter, scanRoot);
1492
+ const lethalTrifecta = overBundles(checkLethalTrifecta, config, silent, adapter, lintRoots);
1250
1493
  // 7p. Skill-resource — a SKILL.md body referencing a bundled file that doesn't
1251
1494
  // exist on disk under the skill dir (the agent gets nothing). FP-safe.
1252
- const skillResources = checkSkillResourceResolves(config, silent, adapter, scanRoot);
1495
+ const skillResources = overBundles(checkSkillResourceResolves, config, silent, adapter, lintRoots);
1253
1496
  // 7q. Skill-missing-fence — a SKILL.md opening with `name:`/`description:` but no
1254
1497
  // `---` fence loads as plain body (invisible — no name/description/trigger).
1255
- const skillFence = checkSkillMissingFence(config, silent, adapter, scanRoot);
1498
+ const skillFence = overBundles(checkSkillMissingFence, config, silent, adapter, lintRoots);
1256
1499
  // 7r. Plugin-dir-layout — functional surface dirs (skills/agents/commands) nested
1257
1500
  // inside the `.claude-plugin/` manifest dir where the harness can't see them.
1258
- const pluginLayout = checkPluginDirLayout(config, silent, adapter, scanRoot);
1501
+ const pluginLayout = overBundles(checkPluginDirLayout, config, silent, adapter, lintRoots);
1259
1502
  // 7s. Delegation-trifecta — a lethal trifecta that emerges across a delegation
1260
1503
  // edge (a subagent's own ∪ delegated-to capability) though no single unit trips it.
1261
- const delegationTrifecta = checkDelegationTrifecta(config, silent, adapter, scanRoot);
1504
+ const delegationTrifecta = overBundles(checkDelegationTrifecta, config, silent, adapter, lintRoots);
1262
1505
  // 7t. Hook-block-ineffective — a hook that looks like it blocks but silently
1263
1506
  // doesn't (block decision on a non-blocking event, or the legacy `decision`
1264
1507
  // field on a permission-gated event). The #1 verified hook pain (#19009).
1265
- const hookBlock = checkHookBlockIneffective(config, silent, adapter, scanRoot);
1508
+ const hookBlock = overBundles(checkHookBlockIneffective, config, silent, adapter, lintRoots);
1266
1509
  // 7u. Hook-matcher — a hook `matcher` that doesn't fire as written (tool-name
1267
1510
  // typo, an uncompilable or unreachable MCP pattern, one too narrow for real
1268
1511
  // server naming, or an undeclared MCP server).
1269
- const hookMatcher = checkHookMatcher(config, silent, adapter, scanRoot);
1512
+ const hookMatcher = overBundles(checkHookMatcher, config, silent, adapter, lintRoots);
1270
1513
  // 8. Validate vigiles builder calls inside markdown code blocks — the
1271
1514
  // `doc-refs` rule, DEFAULT OFF. Illustrative blocks opt out via
1272
1515
  // `<!-- vigiles:ignore -->` (single block) or `<!-- vigiles:ignore-file -->`
@@ -1342,13 +1585,17 @@ async function runLint(restArgs, flags, config) {
1342
1585
  inlineRules,
1343
1586
  frontmatterErrors,
1344
1587
  frontmatterRules,
1588
+ specRefIssues: specRefs.issues,
1589
+ specRefErrors: specRefs.errors,
1345
1590
  duplicatePairs: dups.pairCount,
1591
+ duplicateSeverity: (0, types_js_1.ruleSeverity)(config?.rules?.["duplicate-rules"]) ?? "warn",
1346
1592
  coverageEnabled: coverage.enabled,
1347
1593
  coverageDocumented: coverage.documented,
1348
1594
  strengthenSuggestions: guidanceCount,
1349
1595
  integrityErrors,
1350
1596
  coverageErrors,
1351
1597
  orphanCount: orphanReport.orphans.length,
1598
+ orphanSeverity: orphanSeverity,
1352
1599
  untestedSurfaces: untested.untested,
1353
1600
  untestedErrors: untested.errors,
1354
1601
  toolContractIssues: toolContract.issues,
@@ -1399,13 +1646,41 @@ async function runLint(restArgs, flags, config) {
1399
1646
  mcpRefErrors,
1400
1647
  files,
1401
1648
  };
1649
+ // The totals both surfaces quote, computed ONCE (#183). Attaching them to the
1650
+ // report is what makes the log line and `--json` incapable of disagreeing —
1651
+ // the previous gap was not a wrong number, it was two right numbers with
1652
+ // nothing explaining the difference.
1653
+ const totals = lintTotals(report);
1654
+ const reported = { ...report, totals };
1655
+ // `--json-out=<file>` writes the JSON to disk while stdout keeps the
1656
+ // human-readable run — one scan, both artefacts (#182). A CI job needed both
1657
+ // (the log is what a human opens; the JSON is what the PR comment is built
1658
+ // from) and had to scan the repo TWICE to get them, which is the same work
1659
+ // done twice and grows with the corpus.
1660
+ const jsonOutFlag = flags.find((f) => f.startsWith("--json-out="));
1661
+ if (jsonOutFlag) {
1662
+ const dest = (0, node_path_1.resolve)(jsonOutFlag.slice("--json-out=".length));
1663
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(dest), { recursive: true });
1664
+ (0, node_fs_1.writeFileSync)(dest, `${JSON.stringify(reported, null, 2)}\n`);
1665
+ if (!silent)
1666
+ console.log(`\n✓ JSON report written to ${dest}`);
1667
+ }
1402
1668
  if (summary) {
1403
- printLintSummary(report);
1669
+ printLintSummary(reported);
1404
1670
  }
1405
1671
  else if (json) {
1406
- console.log(JSON.stringify(report, null, 2));
1672
+ console.log(JSON.stringify(reported, null, 2));
1407
1673
  }
1408
- return report;
1674
+ else {
1675
+ // The one number a reader can quote. Counting `⚠` lines gives a DIFFERENT
1676
+ // number, because some checks print one line per finding and others one line
1677
+ // carrying a count — so the log now states the finding total outright rather
1678
+ // than leaving the reader to infer it from line shapes.
1679
+ const code = lintExitCode(reported);
1680
+ console.log(`\n${String(totals.findings)} finding(s): ${String(totals.errors)} error, ` +
1681
+ `${String(totals.warnings)} warning — exit ${String(code)}`);
1682
+ }
1683
+ return reported;
1409
1684
  }
1410
1685
  /** Single-line lint summary for SessionStart hooks — minimal token cost. */
1411
1686
  function printLintSummary(report) {
@@ -41,7 +41,7 @@ export type RuleSurface = "instruction" | "skill" | "subagent" | "hook" | "mcp"
41
41
  /** Where a rule sits by default — `"off"` is the normalized form of `false`. */
42
42
  export type RuleDefaultSeverity = "error" | "warn" | "off";
43
43
  /** Every named rule: the `RulesConfig` keys plus the built-in `orphan-docs`. */
44
- export type RuleName = keyof RulesConfig | "orphan-docs";
44
+ export type RuleName = keyof RulesConfig;
45
45
  /**
46
46
  * The declared shape of one rule — co-located metadata in the ESLint `meta`
47
47
  * sense, gathered into one registry because vigiles shares detectors.
@@ -262,6 +262,22 @@ exports.RULE_META = {
262
262
  detector: "delegationTrifectaIssues",
263
263
  },
264
264
  // --- Docs hygiene ---------------------------------------------------------
265
+ "duplicate-rules": {
266
+ id: "duplicate-rules",
267
+ bucket: "heuristic-behavioral",
268
+ surface: ["instruction"],
269
+ defaultSeverity: "warn",
270
+ summary: "Near-duplicate rules within one spec (NCD similarity) — two rules saying the same thing.",
271
+ detector: "findDuplicateRules",
272
+ },
273
+ "spec-refs": {
274
+ id: "spec-refs",
275
+ bucket: "external-decidable",
276
+ surface: ["instruction"],
277
+ defaultSeverity: "error",
278
+ summary: "A compiled instruction file whose spec references a file/script that no longer exists.",
279
+ detector: "compileClaude",
280
+ },
265
281
  "orphan-docs": {
266
282
  id: "orphan-docs",
267
283
  bucket: "heuristic-behavioral",
@@ -99,6 +99,36 @@ export interface TestCoverageConfig {
99
99
  testExtension?: string;
100
100
  }
101
101
  export interface RulesConfig {
102
+ /**
103
+ * Opt-in: a doc in a configured dir (default `docs/`) that no other `.md`
104
+ * references. The `orphans` block turns the SCAN on; this turns the FINDING
105
+ * into a warning or an error.
106
+ *
107
+ * It lived outside this interface until 2026-09 and therefore could not be
108
+ * set at all: `"warn"` and `"off"` were both ignored and the check always
109
+ * exited 1, so a repo's only choice was an always-blocking check or deleting
110
+ * the `orphans` block (#181). Default: "error", matching the old behaviour.
111
+ */
112
+ "orphan-docs"?: RuleSeverity;
113
+ /**
114
+ * Re-derive a compiled instruction file's references from its `.spec.ts` and
115
+ * report the dead ones.
116
+ *
117
+ * The integrity hash answers "is this file still what the spec compiled to";
118
+ * it says nothing about whether the paths and scripts it NAMES still exist. So
119
+ * an artifact committed while its refs were live stayed green forever after
120
+ * the target was deleted — `compile` errored, `lint` said "hash valid" and
121
+ * exited 0 (#173). Default: "error", matching `compile`.
122
+ */
123
+ "spec-refs"?: RuleSeverity;
124
+ /**
125
+ * Near-duplicate rules WITHIN one spec, by NCD similarity — spec bloat, two
126
+ * rules saying the same thing in different words.
127
+ *
128
+ * Also previously untierable, and worse: it had no rule id at all, so there
129
+ * was no name a config could even mention (#181). Default: "error".
130
+ */
131
+ "duplicate-rules"?: RuleSeverity;
102
132
  /**
103
133
  * Require a `.spec.ts` behind each instruction file (CLAUDE.md / AGENTS.md) —
104
134
  * the file must be compiled from a typed spec, not hand-written. NARROW: only a
@@ -363,6 +393,20 @@ export interface VigilesConfig {
363
393
  rulesDir?: string | string[];
364
394
  }>;
365
395
  /** Orphan-docs check configuration. Include/exclude globs, tsconfig-style. */
396
+ /**
397
+ * Which bundles `lint` scores: `"root"` (default) or `"all"`.
398
+ *
399
+ * A monorepo holding `skills/` plus `plugins/ * /skills/` had its nested skills
400
+ * silently uncounted — the counters looked complete while whole surfaces were
401
+ * never read (#185). `"all"` scores every discovered bundle in one pass, so a
402
+ * CI gate keeps ONE exit code over the whole repo.
403
+ *
404
+ * Root-only remains the default because descending unconditionally would score
405
+ * vendored third-party plugins (a repo may keep a pinned corpus on disk) as if
406
+ * they were the project's own. The default no longer hides the skip: `lint`
407
+ * names the bundles it did not score.
408
+ */
409
+ bundles?: "root" | "all";
366
410
  orphans?: OrphansConfig;
367
411
  /**
368
412
  * Glob patterns of instruction/skill files to EXCLUDE from `lint` discovery
@@ -32,6 +32,24 @@ const INSTRUCTION_FILES = ["CLAUDE.md", "AGENTS.md"];
32
32
  // The default instruction file to validate when no config names one.
33
33
  const DEFAULT_FILES = [INSTRUCTION_FILES[0]];
34
34
  exports.DEFAULT_RULES = {
35
+ // 🔴 BOTH DROP TO "warn", and that is a deliberate behaviour change.
36
+ //
37
+ // They used to feed the exit code directly and could not be tiered at all
38
+ // (#181), so a single unreferenced doc turned a PR red with no way to say
39
+ // "report it, do not block". Naming them as rules made the contradiction
40
+ // visible: both are HEURISTIC-BEHAVIORAL (an NCD similarity proxy, an
41
+ // "unreferenced" guess that an OSS sweep measured at ~100% false positives on
42
+ // nav-managed doc sites), and this repo's own calibration rule is that a
43
+ // heuristic never defaults to `error` because it cries wolf. `orphan-docs`
44
+ // already DECLARED `warn` in its meta while behaving as `error` — the gate
45
+ // caught that disagreement the moment the rule was registered properly.
46
+ //
47
+ // Set either to `"error"` to keep the old blocking behaviour.
48
+ // Hard error, like `compile` itself: a dead reference is decidable from the
49
+ // filesystem, not a proxy — the calibration rule's `external-decidable` tier.
50
+ "spec-refs": "error",
51
+ "orphan-docs": "warn",
52
+ "duplicate-rules": "warn",
35
53
  "require-instructions-spec": "warn",
36
54
  // Default OFF — the consistent `require-<surface>-spec` parallel. Skills are
37
55
  // legitimately hand-written, so requiring a .spec.ts per SKILL.md is the wrong
@@ -0,0 +1,13 @@
1
+ type ResolveContext = {
2
+ parentURL?: string;
3
+ conditions: string[];
4
+ };
5
+ type Resolved = {
6
+ url: string;
7
+ format?: string | null;
8
+ shortCircuit?: boolean;
9
+ };
10
+ type NextResolve = (specifier: string, context: ResolveContext) => Resolved | Promise<Resolved>;
11
+ export declare function resolve(specifier: string, context: ResolveContext, nextResolve: NextResolve): Promise<Resolved>;
12
+ export {};
13
+ //# sourceMappingURL=harness-resolve-hooks.d.mts.map
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Module-resolution hook for HARNESS scripts: make a bare `vigiles` import
3
+ * resolve to the CLI's OWN installation.
4
+ *
5
+ * 🔴 WHY. A harness file does `import { runHook } from "vigiles"`, so the
6
+ * package has to sit in a `node_modules` Node can reach from that file. In a
7
+ * repo that already has a `package.json`, the obvious way to put it there is
8
+ * `npm install` in the root — which installs the whole dependency tree. Measured
9
+ * by an adopter (#184): **840 packages in 2 minutes** where vigiles alone is 42
10
+ * and about 90 MB; the other 798 were a model-eval framework and an agent SDK
11
+ * that the gate — `lint` and `test`, both deterministic reads — never touches.
12
+ * One run sat 11 minutes in that step before being cancelled. Their workaround
13
+ * was installing into a directory outside the workspace and symlinking the tree
14
+ * back in, which works and is not something every adopter should reinvent.
15
+ *
16
+ * ⚠️ `NODE_PATH` does NOT solve this, and that was measured rather than assumed:
17
+ * Node ignores it for ESM resolution, and a harness is ESM. So the only ways to
18
+ * resolve a bare specifier from elsewhere are a real `node_modules` entry (the
19
+ * symlink) or a resolver hook. This is the hook.
20
+ *
21
+ * Scope is deliberately narrow: ONLY the `vigiles` specifier and its subpaths,
22
+ * and only when the normal resolution fails. A harness that has vigiles
23
+ * installed locally keeps resolving to the local copy, so nothing changes for a
24
+ * repo that already worked — this only fills the hole where resolution would
25
+ * otherwise throw.
26
+ */
27
+ import { createRequire } from "node:module";
28
+ import { pathToFileURL } from "node:url";
29
+ /** The CLI's own package root, handed in by the parent process. */
30
+ const SELF = process.env.VIGILES_SELF_ROOT ?? "";
31
+ export async function resolve(specifier, context, nextResolve) {
32
+ try {
33
+ return await nextResolve(specifier, context);
34
+ }
35
+ catch (err) {
36
+ // Only rescue OUR specifier, and only after normal resolution failed, so a
37
+ // locally installed vigiles always wins and no other package is affected.
38
+ if (!SELF)
39
+ throw err;
40
+ if (specifier !== "vigiles" && !specifier.startsWith("vigiles/"))
41
+ throw err;
42
+ const require = createRequire(pathToFileURL(`${SELF}/package.json`));
43
+ // Resolve through the package's own `exports` map rather than guessing a
44
+ // file path, so a subpath like `vigiles/eval` obeys the same contract it
45
+ // would from a normal install.
46
+ const target = require.resolve(specifier, { paths: [SELF] });
47
+ return { url: pathToFileURL(target).href, shortCircuit: true };
48
+ }
49
+ }
50
+ //# sourceMappingURL=harness-resolve-hooks.mjs.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "23.0.0",
3
+ "version": "24.0.0",
4
4
  "description": "Audit, test and measure the harness your AI agent runs on — grade your CLAUDE.md / AGENTS.md, skills, subagents and hooks, run them against a scripted model, and measure whether they actually fire.",
5
5
  "keywords": [
6
6
  "claude-code",