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.
- 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 +302 -27
- 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/package.json +1 -1
|
@@ -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
|
-
|
|
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: {
|
|
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
|
package/dist/cli-flag-check.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
1669
|
+
printLintSummary(reported);
|
|
1404
1670
|
}
|
|
1405
1671
|
else if (json) {
|
|
1406
|
-
console.log(JSON.stringify(
|
|
1672
|
+
console.log(JSON.stringify(reported, null, 2));
|
|
1407
1673
|
}
|
|
1408
|
-
|
|
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) {
|
package/dist/core/rule-meta.d.ts
CHANGED
|
@@ -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
|
|
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.
|
package/dist/core/rule-meta.js
CHANGED
|
@@ -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",
|
package/dist/core/types.d.ts
CHANGED
|
@@ -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
|
package/dist/core/validate.js
CHANGED
|
@@ -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": "
|
|
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",
|