vigiles 8.0.0 → 9.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/scan.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  /**
3
- * `vigiles scan <dir>` — point vigiles at any plugin/repo and see what it ships
3
+ * `vigiles audit <dir>` — point vigiles at any plugin/repo and see what it ships
4
4
  * and what's broken, with **no model and no API key**.
5
5
  *
6
6
  * This is the deterministic substrate under the plugin/skill leaderboard
@@ -168,6 +168,7 @@ function scanSkills(files, cls) {
168
168
  name: fm.name ?? skillName(path),
169
169
  path,
170
170
  hasDescription: Boolean(effectiveDesc && effectiveDesc.length >= 20),
171
+ description: effectiveDesc?.trim(),
171
172
  userInvoked: /^\s*disable-model-invocation:\s*true\s*$/m.test(md),
172
173
  descriptionScript: effectiveDesc ? unexpectedScript(effectiveDesc) : null,
173
174
  });
@@ -244,7 +245,7 @@ function scanAgents(files, dialect, declaredServers, cls) {
244
245
  * quotes. A token that still carries any `$VAR` after that is genuinely
245
246
  * uncheckable.
246
247
  */
247
- function resolveScript(token, root, pluginRootToken) {
248
+ function resolveScript(token, root, pluginRootToken, fullCommand) {
248
249
  // "${CLAUDE_PLUGIN_ROOT}" → unbraced "$CLAUDE_PLUGIN_ROOT".
249
250
  const unbraced = pluginRootToken.replace(/^\$\{(.+)\}$/, "$$$1");
250
251
  const cleaned = token
@@ -252,14 +253,23 @@ function resolveScript(token, root, pluginRootToken) {
252
253
  .replaceAll(pluginRootToken, root)
253
254
  .replaceAll(unbraced, root);
254
255
  if (cleaned.includes("$"))
255
- return { script: token, status: "unresolved" };
256
+ return { command: fullCommand, script: token, status: "unresolved" };
256
257
  // A relative hook path (`./hooks/x.sh`, `scripts/x.py`) is the plugin's own —
257
258
  // resolve it against the PLUGIN ROOT, not the scanner's cwd. Without this, a
258
259
  // plugin that references `./hooks/x.sh` (the file IS present) was reported
259
260
  // MISSING because existsSync() checked cwd-relative (a false positive caught on
260
261
  // ananddtyagi/cc-marketplace). The displayed `script` stays as the author wrote it.
261
262
  const abs = (0, node_path_1.isAbsolute)(cleaned) ? cleaned : (0, node_path_1.resolve)(root, cleaned);
262
- return { script: cleaned, status: (0, node_fs_1.existsSync)(abs) ? "ok" : "missing" };
263
+ // Resolve the full command the same way we resolve the script token (expand
264
+ // plugin-root, strip outer quotes) so the CLI can pass it to verifyGuardrail.
265
+ const resolvedCommand = fullCommand
266
+ .replaceAll(pluginRootToken, root)
267
+ .replaceAll(unbraced, root);
268
+ return {
269
+ command: resolvedCommand,
270
+ script: cleaned,
271
+ status: (0, node_fs_1.existsSync)(abs) ? "ok" : "missing",
272
+ };
263
273
  }
264
274
  // A shell existence guard around a command — `[ ! -f x ] || x`, `[ -f x ] && x`,
265
275
  // `test -f x && …`. Authors use it to make a hook OPTIONAL (run the script only
@@ -284,9 +294,42 @@ function preferCompiledHooksMessage(count) {
284
294
  `an existing one blocks. See docs/compiled-hooks.md.`);
285
295
  }
286
296
  /** Pull script-file hook commands out of the resolved settings; count inline ones. */
297
+ /**
298
+ * Best-effort map of each script token → the hook EVENT it's registered under,
299
+ * by walking the canonical object-keyed-by-event settings shape
300
+ * (`{ PreToolUse: [{ hooks: [{ command }] }], … }`). Lets the safety battery
301
+ * scope itself to `PreToolUse` (the only event that can block a tool call), so a
302
+ * `SessionStart`/`PostToolUse`/`Stop` hook isn't tested against the disaster
303
+ * catalog. Returns an empty map for a non-object/array config (event → unknown).
304
+ */
305
+ function eventsByScript(hooks) {
306
+ const map = new Map();
307
+ if (!hooks || typeof hooks !== "object" || Array.isArray(hooks))
308
+ return map;
309
+ for (const [event, arr] of Object.entries(hooks)) {
310
+ if (!Array.isArray(arr))
311
+ continue;
312
+ for (const entry of arr) {
313
+ const hookList = entry.hooks;
314
+ if (!Array.isArray(hookList))
315
+ continue;
316
+ for (const h of hookList) {
317
+ const cmd = h.command;
318
+ if (typeof cmd !== "string")
319
+ continue;
320
+ for (const tok of cmd.match(SCRIPT_RE) ?? []) {
321
+ if (!map.has(tok))
322
+ map.set(tok, event);
323
+ }
324
+ }
325
+ }
326
+ }
327
+ return map;
328
+ }
287
329
  function scanHooks(settings, root, pluginRootToken) {
288
330
  const text = JSON.stringify(settings.hooks ?? {});
289
331
  const commands = [...text.matchAll(/"command":\s*"((?:[^"\\]|\\.)*)"/g)].map((m) => m[1]);
332
+ const evMap = eventsByScript(settings.hooks);
290
333
  // A hand-written hook is any non-empty command that isn't a vigiles-managed
291
334
  // (compiled) hook-runtime invocation — the basis for the prefer-compiled-hooks nudge.
292
335
  const manual = commands.filter((c) => {
@@ -309,8 +352,9 @@ function scanHooks(settings, root, pluginRootToken) {
309
352
  continue;
310
353
  }
311
354
  for (const tok of found) {
312
- const hook = resolveScript(tok, root, pluginRootToken);
313
- byScript.set(hook.script, hook);
355
+ const hook = resolveScript(tok, root, pluginRootToken, unescaped);
356
+ const event = evMap.get(tok);
357
+ byScript.set(hook.script, event ? { ...hook, event } : hook);
314
358
  }
315
359
  }
316
360
  const hooks = [...byScript.values()].sort((a, b) => a.script.localeCompare(b.script));
@@ -564,14 +608,16 @@ function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect) {
564
608
  };
565
609
  }
566
610
  /**
567
- * LIVE MCP tool resolution for a scanned plugin — the opt-in (`scan --verify-mcp`)
568
- * dynamic check no static linter can do: it STARTS each declared MCP server and
569
- * checks every `mcp__server__tool` the plugin's agents reference actually exists on
570
- * it (catching rename/removal rot, e.g. `create_issue`→`issue_write`). Reuses the
611
+ * LIVE MCP tool resolution for a scanned plugin — the dynamic check no static
612
+ * linter can do: it STARTS each declared MCP server and checks every
613
+ * `mcp__server__tool` the plugin's agents reference actually exists on it
614
+ * (catching rename/removal rot, e.g. `create_issue`→`issue_write`). Reuses the
571
615
  * already-computed `report` (its agents' tool lists) + the declared server configs;
572
616
  * returns `[]` when the plugin declares no MCP servers (nothing to start). Async +
573
- * side-effecting (spawns servers) — which is exactly why it's opt-in, not a default
574
- * lint rule. See `verifyMcpContractTools` (core/mcp.ts).
617
+ * side-effecting (spawns servers) — so `audit` runs it by default only for the
618
+ * user's OWN repo (own-repo, like running your own tools); a FOREIGN plugin's
619
+ * servers are never spawned, and `--fast` opts out. See `verifyMcpContractTools`
620
+ * (core/mcp.ts).
575
621
  */
576
622
  async function verifyLiveMcpTools(report, layout, dialect, timeoutMs = 10000) {
577
623
  // collectMcpServers yields the raw JSON server entries; a malformed one (no
@@ -596,7 +642,7 @@ function formatMcpContractReport(errors) {
596
642
  * Read a `marketplace.json` beside the layout's plugin manifest and classify its
597
643
  * members into on-disk vs external. Returns `null` when `dir` is not a
598
644
  * marketplace. The source of truth behind {@link expandMarketplace} and the
599
- * curated-marketplace report in `vigiles scan`.
645
+ * curated-marketplace report in `vigiles audit`.
600
646
  */
601
647
  function inspectMarketplace(dir, layout = layout_js_1.claudeCodeLayout) {
602
648
  const mpPath = (0, node_path_1.join)(dir, (0, node_path_1.dirname)(layout.manifestPath), "marketplace.json");
@@ -648,7 +694,7 @@ function inspectMarketplace(dir, layout = layout_js_1.claudeCodeLayout) {
648
694
  * plugin manifest, e.g. `.claude-plugin/marketplace.json`), expand it into the
649
695
  * absolute dirs of its member plugins. Returns `null` when there's no
650
696
  * marketplace, `[]` when it's a marketplace whose members are all external (not
651
- * on disk). Used by `vigiles scan` to rank a whole marketplace — wshobson/agents
697
+ * on disk). Used by `vigiles audit` to rank a whole marketplace — wshobson/agents
652
698
  * alone ships 80+ plugins under one `marketplace.json`. See {@link inspectMarketplace}.
653
699
  */
654
700
  function expandMarketplace(dir, layout = layout_js_1.claudeCodeLayout) {
@@ -35,7 +35,7 @@ export type BehavioralSymptom = "wrong-skill-fires" | "skill-never-fires" | "age
35
35
  * never-available tool can't be called); the cause is near-certain.
36
36
  * - `"possible"` — a high-precision PROXY for a behavioral risk (a description
37
37
  * overlap / a foreign-script description); deterministic to detect, but whether
38
- * it actually moved behaviour is confirmed by `scan --trigger`.
38
+ * it actually moved behaviour is confirmed by the `audit` trigger tier.
39
39
  */
40
40
  export type ExplanationConfidence = "likely" | "possible";
41
41
  export interface ScoreExplanation {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "8.0.0",
3
+ "version": "9.0.0",
4
4
  "description": "Lint & test the harness your AI agent runs on — verify the references in your CLAUDE.md / AGENTS.md and test that your hooks and skills actually work.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -55,6 +55,7 @@
55
55
  "dist/**/*.mjs",
56
56
  "dist/**/*.d.ts",
57
57
  "dist/**/*.d.mts",
58
+ "dist/audit-report.template.html",
58
59
  "!dist/**/*.test.js",
59
60
  "!dist/**/*.test.d.ts",
60
61
  "action.yml",
@@ -66,7 +67,8 @@
66
67
  "LICENSE"
67
68
  ],
68
69
  "scripts": {
69
- "build": "tsc",
70
+ "build": "tsc && node scripts/build-report.mjs",
71
+ "build:core": "tsc",
70
72
  "test": "npm run build && vitest run",
71
73
  "coverage": "npm run build && vitest run --coverage",
72
74
  "lint": "eslint src/",
@@ -39,7 +39,7 @@ surface sorts into one of three buckets:
39
39
 
40
40
  - **A — Free & deterministic** (no model, runs in CI on every commit): a hook's
41
41
  block/allow decision (`runHook`), a tool-contract / "did NOT call the forbidden
42
- tool" check, structural facts (`vigiles scan`), and **record-replay** of any tool
42
+ tool" check, structural facts (`vigiles audit`), and **record-replay** of any tool
43
43
  a skill shells out to (record the real result once, replay it via a PATH stub).
44
44
  - **B — Model-gated, on your subscription** (real model, **no metered API**): does a
45
45
  skill's description **fire** (`measureTriggerRate`, recall + precision) **and**