vigiles 28.0.0 → 29.1.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.
Files changed (51) hide show
  1. package/README.md +1 -1
  2. package/dist/adapter-registry.d.ts +39 -0
  3. package/dist/adapter-registry.js +45 -0
  4. package/dist/adapter.d.ts +8 -0
  5. package/dist/adapter.js +10 -1
  6. package/dist/adapters/claude-code/adapter.js +6 -0
  7. package/dist/adapters/claude-code/layout.d.ts +5 -0
  8. package/dist/adapters/claude-code/plugin-loader.d.ts +10 -1
  9. package/dist/adapters/claude-code/plugin-loader.js +10 -1
  10. package/dist/adapters/codex/adapter.js +6 -0
  11. package/dist/adapters/codex/layout.d.ts +51 -5
  12. package/dist/adapters/codex/layout.js +13 -4
  13. package/dist/adapters/opencode/adapter.js +6 -0
  14. package/dist/audit-report.template.html +1 -1
  15. package/dist/audit-score.d.ts +7 -0
  16. package/dist/audit-score.js +49 -2
  17. package/dist/cli-main.js +82 -20
  18. package/dist/core/adapter.d.ts +23 -0
  19. package/dist/core/compile.js +21 -3
  20. package/dist/core/config-schema.d.ts +244 -0
  21. package/dist/core/config-schema.js +460 -0
  22. package/dist/core/lethal-trifecta.js +5 -0
  23. package/dist/core/refs.js +10 -1
  24. package/dist/core/surface-discovery.d.ts +270 -0
  25. package/dist/core/surface-discovery.js +429 -0
  26. package/dist/core/surface-scopes.d.ts +38 -1
  27. package/dist/core/surface-scopes.js +73 -1
  28. package/dist/core/symbols.d.ts +24 -2
  29. package/dist/core/symbols.js +66 -18
  30. package/dist/core/types.d.ts +36 -107
  31. package/dist/core/validate.d.ts +36 -22
  32. package/dist/core/validate.js +88 -176
  33. package/dist/exclude.d.ts +20 -0
  34. package/dist/exclude.js +11 -1
  35. package/dist/layout-registry.d.ts +14 -0
  36. package/dist/layout-registry.js +40 -0
  37. package/dist/plugin-loader.d.ts +49 -1
  38. package/dist/plugin-loader.js +120 -14
  39. package/dist/scan-core.d.ts +19 -0
  40. package/dist/scan-core.js +30 -0
  41. package/dist/scan-files.js +15 -5
  42. package/dist/scan.d.ts +63 -0
  43. package/dist/scan.js +79 -12
  44. package/dist/score-core.js +8 -0
  45. package/dist/setup-plan.d.ts +2 -1
  46. package/dist/setup-plan.js +7 -2
  47. package/dist/surface-discovery-fs.d.ts +12 -0
  48. package/dist/surface-discovery-fs.js +108 -0
  49. package/dist/test-coverage.js +5 -0
  50. package/dist/vigilesrc.schema.json +1689 -0
  51. package/package.json +10 -6
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.loadPlugin = loadPlugin;
4
+ exports.loadPlugins = loadPlugins;
4
5
  exports.danglingRefs = danglingRefs;
5
6
  exports.resolveHarness = resolveHarness;
6
7
  /**
@@ -37,6 +38,7 @@ const node_fs_1 = require("node:fs");
37
38
  const node_path_1 = require("node:path");
38
39
  const toml_1 = require("@iarna/toml");
39
40
  const hash_js_1 = require("./core/hash.js");
41
+ const exclude_js_1 = require("./exclude.js");
40
42
  const fs_walk_js_1 = require("./fs-walk.js");
41
43
  const source_refs_js_1 = require("./core/source-refs.js");
42
44
  const surface_scopes_js_1 = require("./core/surface-scopes.js");
@@ -140,13 +142,20 @@ function readHooks(root, layout) {
140
142
  * `entryOf` has already cleared would cost a syscall per directory to answer a
141
143
  * question that cannot come out differently.
142
144
  */
143
- function readTree(dir, base) {
145
+ function readTree(dir, base, excluded = exclude_js_1.excludesNothing) {
144
146
  const out = {};
145
147
  for (const entry of (0, node_fs_1.readdirSync)(dir)) {
146
148
  const full = (0, node_path_1.join)(dir, entry);
149
+ // `.vigilesrc.json#exclude`, applied HERE — per entry, inside the recursion —
150
+ // and not merely at the surface-dir entry point. A vendored tree is usually
151
+ // excluded at its own root (`bench`, `vendor/corpus`), which is a descendant
152
+ // of a surface dir, so a check that only guarded the walk's start would let
153
+ // every one of its files into the file map the whole report is computed from.
154
+ if (excluded(full))
155
+ continue;
147
156
  const { kind, size } = (0, fs_walk_js_1.entryOf)(full);
148
157
  if (kind === "dir")
149
- Object.assign(out, readTree(full, base));
158
+ Object.assign(out, readTree(full, base, excluded));
150
159
  // The cap keeps a stray binary out of the in-memory file map, and the size
151
160
  // comes from the SAME stat that classified the entry. Asking a second time
152
161
  // needed its own `catch` — reachable only if the file vanished between the two
@@ -162,9 +171,21 @@ function readTree(dir, base) {
162
171
  * the files (CLAUDE.md + skills + agents + commands) to write into the test
163
172
  * sandbox, and `warnings` for surfaces the deterministic tier can't drive. Merge
164
173
  * `settings` with any inline settings and spread `files` into the fixture.
174
+ *
175
+ * `surfaceRoots` is this function's parameter name for what the repo owner
176
+ * writes as `.vigilesrc.json#harnesses["<name>"].roots` — extra repo-relative
177
+ * bases to read `<base>/<surfaceDir>/…` from, for a repo that keeps its skills
178
+ * somewhere no harness reads by default. The config key is nested UNDER a
179
+ * harness name precisely so a root cannot be declared without saying whose
180
+ * layout reads it; this parameter receives one harness's slice of that, which
181
+ * is why it is still a bare list here. `excludes` still wins over it:
182
+ * both the per-tree check in {@link materializeSurfaces} and `readTree` drop an
183
+ * excluded path whatever declared it, so a root that is declared AND excluded is
184
+ * read exactly as if it had never been declared.
165
185
  */
166
- function loadPlugin(pluginPath, layout) {
186
+ function loadPlugin(pluginPath, layout, excludes, surfaceRoots) {
167
187
  const root = (0, node_path_1.resolve)(pluginPath);
188
+ const excluded = (0, exclude_js_1.excludedBy)(excludes);
168
189
  const hooks = readHooks(root, layout);
169
190
  // Expand the plugin-root token to the real absolute path so the actual hook
170
191
  // scripts execute — we test the shipped wiring, not a reimplementation.
@@ -174,11 +195,11 @@ function loadPlugin(pluginPath, layout) {
174
195
  const files = {};
175
196
  const sources = {};
176
197
  const instructions = (0, node_path_1.join)(root, layout.instructionFile);
177
- if ((0, node_fs_1.existsSync)(instructions)) {
198
+ if ((0, node_fs_1.existsSync)(instructions) && !excluded(instructions)) {
178
199
  files[layout.instructionFile] = (0, node_fs_1.readFileSync)(instructions, "utf-8");
179
200
  sources[layout.instructionFile] = instructions;
180
201
  }
181
- const surfaces = materializeSurfaces(root, layout, files, sources);
202
+ const surfaces = materializeSurfaces(root, layout, files, sources, excluded, surfaceRoots);
182
203
  return {
183
204
  settings: resolvedHooks ? { hooks: resolvedHooks } : {},
184
205
  files,
@@ -186,6 +207,69 @@ function loadPlugin(pluginPath, layout) {
186
207
  warnings: pluginWarnings(root, surfaces, resolvedHooks, files, layout),
187
208
  };
188
209
  }
210
+ /**
211
+ * Load the repo once PER DECLARED HARNESS and merge the results into one
212
+ * `LoadedPlugin` (#240).
213
+ *
214
+ * 🔴 THE MERGE IS WHY THIS EXISTS, AND THE DEDUPLICATION IS ITS WHOLE CONTRACT.
215
+ * A repo that declares two harnesses is a repo whose grade must cover both — the
216
+ * flat `harness` array could not do that, because whichever name sat first
217
+ * decided the ONE layout everything was read under: measured on a repo with
218
+ * `AGENTS.md` + `.ai/skills/alpha/SKILL.md`, Claude-Code-first graded the skill
219
+ * and reported 0 chars of always-loaded instructions, Codex-first read
220
+ * `AGENTS.md` and reported no skill at all. Neither order produced both halves.
221
+ *
222
+ * ⚠️ AND THE OBVIOUS FIX HAS AN OBVIOUS SECOND BUG: a repo honest enough to say
223
+ * one tree serves both tools would then have that tree read twice and every
224
+ * skill in it counted twice, so declaring the truth would lower the grade. So the
225
+ * merge keys on the REAL ON-DISK PATH (`sources`), not on the materialized key:
226
+ * the first harness to claim a file keeps it, later ones skip it, and the counts
227
+ * are of files rather than of claims. Keying on the materialized key would not
228
+ * do — two layouts can reach one file under two different keys (`.ai/.agents`
229
+ * + `skills` and `.ai` + `.agents/skills` are the same directory), and that is
230
+ * exactly the case an honest dual declaration produces.
231
+ *
232
+ * Settings come from the FIRST harness that yields any, and the file-map merge
233
+ * is first-wins for the same reason: the primary harness is the one whose
234
+ * dialect the report is rendered in, so its reading of a shared path is the one
235
+ * the rest of the report is consistent with.
236
+ */
237
+ function loadPlugins(pluginPath, harnesses, excludes) {
238
+ const loads = harnesses.map((h) => loadPlugin(pluginPath, h.layout, excludes, h.roots));
239
+ /* v8 ignore next -- callers always pass >=1; the guard keeps the type honest */
240
+ if (loads.length <= 1)
241
+ return loads[0] ?? EMPTY_LOAD;
242
+ const files = {};
243
+ const sources = {};
244
+ const seenOnDisk = new Set();
245
+ const warnings = [];
246
+ let settings = {};
247
+ for (const load of loads) {
248
+ for (const [key, content] of Object.entries(load.files)) {
249
+ // A file with no recorded source is one the loader synthesized rather than
250
+ // read (there are none today); key it by its own key so it still dedupes.
251
+ const onDisk = load.sources[key] ?? key;
252
+ if (seenOnDisk.has(onDisk))
253
+ continue;
254
+ seenOnDisk.add(onDisk);
255
+ files[key] = content;
256
+ sources[key] = onDisk;
257
+ }
258
+ for (const w of load.warnings)
259
+ if (!warnings.includes(w))
260
+ warnings.push(w);
261
+ if (settings.hooks === undefined && load.settings.hooks !== undefined)
262
+ settings = load.settings;
263
+ }
264
+ return { settings, files, sources, warnings };
265
+ }
266
+ /** The shape `loadPlugins` returns for an empty harness list. */
267
+ const EMPTY_LOAD = {
268
+ settings: {},
269
+ files: {},
270
+ sources: {},
271
+ warnings: [],
272
+ };
189
273
  /**
190
274
  * Materialize every model surface (skills/agents/commands) into `files`, and
191
275
  * record each file's real on-disk path in `sources`. Best-effort (headless
@@ -214,8 +298,9 @@ function surfaceHasLoadable(layout, surface, tree) {
214
298
  ? keys.some((k) => (0, node_path_1.basename)(k) === "SKILL.md")
215
299
  : keys.some((k) => k.endsWith(".md"));
216
300
  }
217
- function materializeSurfaces(root, layout, files, sources) {
301
+ function materializeSurfaces(root, layout, files, sources, excluded = exclude_js_1.excludesNothing, surfaceRoots) {
218
302
  const counts = {};
303
+ const harnessCounts = {};
219
304
  const isDir = (p) => (0, node_fs_1.existsSync)(p) && (0, node_fs_1.statSync)(p).isDirectory();
220
305
  /**
221
306
  * One surface dir's tree, or `{}` when there is nothing to read.
@@ -226,7 +311,9 @@ function materializeSurfaces(root, layout, files, sources) {
226
311
  * per-entry one and that function says why; a shared skills dir linked in from
227
312
  * outside is still read.
228
313
  */
229
- const surfaceTree = (dir) => isDir(dir) && (0, fs_walk_js_1.walkableRoot)(dir, root) ? readTree(dir, dir) : {};
314
+ const surfaceTree = (dir) => isDir(dir) && (0, fs_walk_js_1.walkableRoot)(dir, root) && !excluded(dir)
315
+ ? readTree(dir, dir, excluded)
316
+ : {};
230
317
  /** Every surface tree of one scope, read once, keyed by surface dir. */
231
318
  const scopeTrees = (base) => {
232
319
  const trees = new Map();
@@ -245,13 +332,24 @@ function materializeSurfaces(root, layout, files, sources) {
245
332
  const userTrees = layout.userSurfaceRoot !== undefined
246
333
  ? scopeTrees(layout.userSurfaceRoot)
247
334
  : new Map();
335
+ // Declared roots are read HERE, up front and once, for the same reason the two
336
+ // above are: `surfaceSource` needs to know which of them hold anything before
337
+ // it can decide the scope list, and re-reading them afterwards would be a
338
+ // second walk that could disagree with the first.
339
+ const declaredRoots = (0, surface_scopes_js_1.normalizeSurfaceRoots)(surfaceRoots);
340
+ const declaredTrees = new Map();
341
+ for (const base of declaredRoots)
342
+ declaredTrees.set(base, scopeTrees(base));
248
343
  /** Copy one scope's already-read trees into `files`, keyed by that scope. */
249
344
  const materializeScope = (scope, trees) => {
250
345
  for (const surface of layout.surfaceDirs) {
251
346
  const tree = trees.get(surface) ?? {};
252
347
  for (const [rel, content] of Object.entries(tree))
253
348
  add((0, surface_scopes_js_1.scopeKey)(scope, surface, rel), content, (0, node_path_1.join)(root, scope.base, surface, rel));
254
- counts[surface] = (counts[surface] ?? 0) + Object.keys(tree).length;
349
+ const n = Object.keys(tree).length;
350
+ counts[surface] = (counts[surface] ?? 0) + n;
351
+ if (scope.declared !== true)
352
+ harnessCounts[surface] = (harnessCounts[surface] ?? 0) + n;
255
353
  }
256
354
  };
257
355
  /**
@@ -288,6 +386,12 @@ function materializeSurfaces(root, layout, files, sources) {
288
386
  add((0, node_path_1.join)(base, dir, rel), content, (0, node_path_1.join)(root, base, dir, rel));
289
387
  }
290
388
  };
389
+ /** The already-read trees a scope materializes from — never a second walk. */
390
+ const treesOf = (scope) => scope.declared === true
391
+ ? (declaredTrees.get(scope.base) ?? new Map())
392
+ : scope.base === ""
393
+ ? rootTrees
394
+ : userTrees;
291
395
  const source = (0, surface_scopes_js_1.surfaceSource)(layout, {
292
396
  hasRootSkillFile: (0, node_fs_1.existsSync)((0, node_path_1.join)(root, "SKILL.md")),
293
397
  skillName: (0, node_path_1.basename)(root),
@@ -295,6 +399,7 @@ function materializeSurfaces(root, layout, files, sources) {
295
399
  isPluginShaped: (0, node_fs_1.existsSync)((0, node_path_1.join)(root, layout.manifestPath)) ||
296
400
  (0, node_fs_1.existsSync)((0, node_path_1.join)(root, layout.hooksConventionPath)),
297
401
  userHasLoadable: hasLoadable(userTrees),
402
+ declaredRoots,
298
403
  });
299
404
  switch (source.kind) {
300
405
  case "single-skill": {
@@ -303,19 +408,20 @@ function materializeSurfaces(root, layout, files, sources) {
303
408
  // No `walkableRoot` here on purpose: `root` is the directory the CALLER
304
409
  // named (`vigiles audit <dir>`), not one this walk discovered, and refusing
305
410
  // to read the path someone explicitly pointed at is not a containment rule.
306
- const tree = readTree(root, root);
411
+ const tree = readTree(root, root, excluded);
307
412
  for (const [rel, content] of Object.entries(tree)) {
308
413
  add((0, node_path_1.join)(layout.materializeRoot, layout.skillDir, source.skillName, rel), content, (0, node_path_1.join)(root, rel));
309
414
  }
310
415
  counts[layout.skillDir] = Object.keys(tree).length;
311
- return { counts, scopes: [] };
416
+ harnessCounts[layout.skillDir] = counts[layout.skillDir];
417
+ return { counts, harnessCounts, scopes: [] };
312
418
  }
313
419
  case "scopes": {
314
420
  (0, surface_scopes_js_1.assertDistinctScopeKeys)(source.scopes, layout.name);
315
421
  for (const scope of source.scopes)
316
- materializeScope(scope, scope.base === "" ? rootTrees : userTrees);
422
+ materializeScope(scope, treesOf(scope));
317
423
  materializeRules();
318
- return { counts, scopes: source.scopes };
424
+ return { counts, harnessCounts, scopes: source.scopes };
319
425
  }
320
426
  /* v8 ignore next 2 -- exhaustiveness guard, unreachable given SurfaceSource */
321
427
  default:
@@ -329,9 +435,9 @@ function materializeSurfaces(root, layout, files, sources) {
329
435
  * the eval tier. MCP servers aren't wired by the loader at all. And a plugin
330
436
  * that yields neither hooks nor files would otherwise be a silent empty machine.
331
437
  */
332
- function pluginWarnings(root, { counts, scopes }, hooks, files, layout) {
438
+ function pluginWarnings(root, { counts, harnessCounts, scopes }, hooks, files, layout) {
333
439
  const warnings = [];
334
- const multiScope = (0, surface_scopes_js_1.multiScopeWarning)(scopes, counts);
440
+ const multiScope = (0, surface_scopes_js_1.multiScopeWarning)(scopes, harnessCounts);
335
441
  if (multiScope !== undefined)
336
442
  warnings.push(multiScope);
337
443
  if (counts.agents) {
@@ -37,6 +37,25 @@ export interface SurfaceClassifier {
37
37
  readonly isRule: (f: string) => boolean;
38
38
  }
39
39
  export declare function makeClassifier(layout: PluginLayout): SurfaceClassifier;
40
+ /**
41
+ * ONE classifier over SEVERAL layouts — a path is a skill if ANY declared
42
+ * harness would read it as one.
43
+ *
44
+ * 🔴 IT EXISTS BECAUSE A MERGED FILE MAP HAS NO SINGLE LAYOUT. A repo declaring
45
+ * `{"harnesses": {"claude-code": {…}, "codex": {}}}` is read under BOTH layouts
46
+ * and the two file maps merge into one, at which point classifying with one
47
+ * layout drops the other's surfaces on the floor — Codex's `prompts/x.md` is not
48
+ * a Claude Code command, and silently counting it as nothing is the same shape of
49
+ * bug as reading no surfaces and grading A (100).
50
+ *
51
+ * `agentName` answers from the FIRST layout that calls the path an agent, so the
52
+ * name is always the one the layout that claimed it would give — never a name
53
+ * derived under a layout that would not have read the file at all.
54
+ *
55
+ * A single-layout list behaves byte-identically to {@link makeClassifier}, which
56
+ * is what lets every existing caller keep passing one layout.
57
+ */
58
+ export declare function makeUnionClassifier(layouts: readonly PluginLayout[]): SurfaceClassifier;
40
59
  /** The plugin-root + materialize-root + dialect context skill scanning needs. */
41
60
  export interface SkillScanContext {
42
61
  readonly root: string;
package/dist/scan-core.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.makeClassifier = makeClassifier;
4
+ exports.makeUnionClassifier = makeUnionClassifier;
4
5
  exports.remapFindingPaths = remapFindingPaths;
5
6
  exports.detectOwnTestSignal = detectOwnTestSignal;
6
7
  exports.scanSkills = scanSkills;
@@ -199,6 +200,35 @@ function makeClassifier(layout) {
199
200
  agentName: (f) => isAgent(f) ? (0, layout_js_1.agentSurfaceName)(f, layout.agentDir) : null,
200
201
  };
201
202
  }
203
+ /**
204
+ * ONE classifier over SEVERAL layouts — a path is a skill if ANY declared
205
+ * harness would read it as one.
206
+ *
207
+ * 🔴 IT EXISTS BECAUSE A MERGED FILE MAP HAS NO SINGLE LAYOUT. A repo declaring
208
+ * `{"harnesses": {"claude-code": {…}, "codex": {}}}` is read under BOTH layouts
209
+ * and the two file maps merge into one, at which point classifying with one
210
+ * layout drops the other's surfaces on the floor — Codex's `prompts/x.md` is not
211
+ * a Claude Code command, and silently counting it as nothing is the same shape of
212
+ * bug as reading no surfaces and grading A (100).
213
+ *
214
+ * `agentName` answers from the FIRST layout that calls the path an agent, so the
215
+ * name is always the one the layout that claimed it would give — never a name
216
+ * derived under a layout that would not have read the file at all.
217
+ *
218
+ * A single-layout list behaves byte-identically to {@link makeClassifier}, which
219
+ * is what lets every existing caller keep passing one layout.
220
+ */
221
+ function makeUnionClassifier(layouts) {
222
+ const cs = layouts.map(makeClassifier);
223
+ const any = (pick) => (f) => cs.some((c) => pick(c)(f));
224
+ return {
225
+ isSkill: any((c) => c.isSkill),
226
+ isAgent: any((c) => c.isAgent),
227
+ isCommand: any((c) => c.isCommand),
228
+ isRule: any((c) => c.isRule),
229
+ agentName: (f) => cs.find((c) => c.isAgent(f))?.agentName(f) ?? null,
230
+ };
231
+ }
202
232
  function skillName(path) {
203
233
  return (path
204
234
  .replace(/\/SKILL\.md$/, "")
@@ -49,6 +49,8 @@ const mcp_config_js_1 = require("./core/mcp-config.js");
49
49
  const agent_plugins_js_1 = require("./core/agent-plugins.js");
50
50
  const mcp_hook_js_1 = require("./core/mcp-hook.js");
51
51
  const plugin_dir_layout_js_1 = require("./core/plugin-dir-layout.js");
52
+ const surface_discovery_js_1 = require("./core/surface-discovery.js");
53
+ const layout_registry_js_1 = require("./layout-registry.js");
52
54
  const surface_scopes_js_1 = require("./core/surface-scopes.js");
53
55
  const hook_block_ineffective_js_1 = require("./core/hook-block-ineffective.js");
54
56
  const hook_matcher_js_1 = require("./core/hook-matcher.js");
@@ -231,6 +233,7 @@ function surfaceHasLoadable(layout, surface, tree) {
231
233
  function materializeSurfaces(files, layout, acc, repoName) {
232
234
  const { out, sources } = acc;
233
235
  const counts = {};
236
+ const harnessCounts = {};
234
237
  const scopeTrees = (base) => {
235
238
  const trees = new Map();
236
239
  for (const surface of layout.surfaceDirs) {
@@ -255,7 +258,10 @@ function materializeSurfaces(files, layout, acc, repoName) {
255
258
  const dirRel = scope.base === "" ? surface : `${scope.base}/${surface}`;
256
259
  for (const [rel, content] of Object.entries(tree))
257
260
  add((0, surface_scopes_js_1.scopeKey)(scope, surface, rel), content, (0, posix_path_js_1.join)(exports.BROWSER_ROOT, dirRel, rel));
258
- counts[surface] = (counts[surface] ?? 0) + Object.keys(tree).length;
261
+ const n = Object.keys(tree).length;
262
+ counts[surface] = (counts[surface] ?? 0) + n;
263
+ if (scope.declared !== true)
264
+ harnessCounts[surface] = (harnessCounts[surface] ?? 0) + n;
259
265
  }
260
266
  };
261
267
  const source = (0, surface_scopes_js_1.surfaceSource)(layout, {
@@ -276,13 +282,14 @@ function materializeSurfaces(files, layout, acc, repoName) {
276
282
  add((0, posix_path_js_1.join)(layout.materializeRoot, layout.skillDir, source.skillName, rel), content, (0, posix_path_js_1.join)(exports.BROWSER_ROOT, rel));
277
283
  }
278
284
  counts[layout.skillDir] = Object.keys(tree).length;
279
- return { counts, scopes: [] };
285
+ harnessCounts[layout.skillDir] = counts[layout.skillDir];
286
+ return { counts, harnessCounts, scopes: [] };
280
287
  }
281
288
  case "scopes": {
282
289
  (0, surface_scopes_js_1.assertDistinctScopeKeys)(source.scopes, layout.name);
283
290
  for (const scope of source.scopes)
284
291
  materializeScope(scope, scope.base === "" ? rootTrees : userTrees);
285
- return { counts, scopes: source.scopes };
292
+ return { counts, harnessCounts, scopes: source.scopes };
286
293
  }
287
294
  }
288
295
  }
@@ -371,9 +378,9 @@ function danglingRefs(files, layout, rootName) {
371
378
  // ---------------------------------------------------------------------------
372
379
  // Warnings (mirrors plugin-loader.ts pluginWarnings)
373
380
  // ---------------------------------------------------------------------------
374
- function pluginWarnings(files, layout, { counts, scopes }, hooks, materialized, rootName) {
381
+ function pluginWarnings(files, layout, { counts, harnessCounts, scopes }, hooks, materialized, rootName) {
375
382
  const warnings = [];
376
- const multiScope = (0, surface_scopes_js_1.multiScopeWarning)(scopes, counts);
383
+ const multiScope = (0, surface_scopes_js_1.multiScopeWarning)(scopes, harnessCounts);
377
384
  if (multiScope !== undefined)
378
385
  warnings.push(multiScope);
379
386
  if (counts.agents) {
@@ -550,6 +557,9 @@ function scanFiles(files, layout = layout_js_1.claudeCodeLayout, dialect = diale
550
557
  trifectaFindings,
551
558
  skillResourceIssues: skillResourceFindings,
552
559
  skillFenceIssues: skillFenceFindings,
560
+ // The browser twin has the WHOLE fetched key set in hand, so discovery needs
561
+ // no walk here — the pure classifier re-applies the same bounded-root rule.
562
+ unclaimedSurfaces: (0, surface_discovery_js_1.unclaimedSurfaceFindings)(Object.keys(files), layout_registry_js_1.REGISTERED_LAYOUTS),
553
563
  pluginLayoutIssues: (0, plugin_dir_layout_js_1.pluginDirLayoutIssues)((0, posix_path_js_1.join)(exports.BROWSER_ROOT, (0, posix_path_js_1.dirname)(lay.manifestPath)), [...new Set([...lay.surfaceDirs, lay.hooksConventionPath.split("/")[0]])], { existsSync: exists, isDirectory: mapIsDirectory(files) }),
554
564
  delegationTrifecta: (0, scan_core_js_1.collectDelegationTrifecta)(agents, dialect),
555
565
  hookBlockFindings: (0, event_capability_js_1.blockIneffectiveEventsOf)(dialect).length > 0
package/dist/scan.d.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  * stack on top later; this core stays pure so it runs anywhere in CI for free.
13
13
  */
14
14
  import type { PluginLayout } from "./core/layout.js";
15
+ import type { ExcludeSet } from "./exclude.js";
15
16
  import type { HarnessDialect } from "./core/dialect.js";
16
17
  import type { ToolIssue } from "./core/tool-contract.js";
17
18
  import { type HookEventIssue } from "./core/hook-events.js";
@@ -25,6 +26,7 @@ import type { TrifectaFinding } from "./core/lethal-trifecta.js";
25
26
  import type { SkillResourceFinding } from "./core/skill-resources.js";
26
27
  import type { SkillFenceFinding } from "./core/skill-missing-fence.js";
27
28
  import { type PluginLayoutFinding } from "./core/plugin-dir-layout.js";
29
+ import { type UnclaimedSurfaceFinding } from "./core/surface-discovery.js";
28
30
  import type { DelegationTrifectaFinding } from "./core/delegation-trifecta.js";
29
31
  import { type HookBlockFinding } from "./core/hook-block-ineffective.js";
30
32
  import { type HookMatcherFinding } from "./core/hook-matcher.js";
@@ -283,6 +285,14 @@ export interface ScanReport {
283
285
  * and the `plugin-dir-layout` lint rule (one detector, no drift).
284
286
  */
285
287
  readonly pluginLayoutIssues: readonly PluginLayoutFinding[];
288
+ /**
289
+ * Surface directories found by SHAPE in the bounded root set that NO
290
+ * registered harness claims — a harness sitting somewhere vigiles does not
291
+ * read, reported instead of silently graded around (#240). Computed by
292
+ * `core/surface-discovery.ts` from paths alone; shared with the browser twin
293
+ * (one detector, no drift).
294
+ */
295
+ readonly unclaimedSurfaces: readonly UnclaimedSurfaceFinding[];
286
296
  /**
287
297
  * Lethal trifectas that EMERGE across a delegation edge — a subagent whose
288
298
  * effective (own ∪ delegated-to) capability holds all three legs though no
@@ -386,10 +396,63 @@ export interface ScanReport {
386
396
  unrestricted: number;
387
397
  };
388
398
  }
399
+ /**
400
+ * ONE harness a scan reads the repo under — the resolved form of a
401
+ * `.vigilesrc.json#harnesses` entry (#240).
402
+ *
403
+ * All three parts travel together on purpose. The LAYOUT says where this harness
404
+ * reads and what a surface is there; the DIALECT says what it understands (and
405
+ * carries its own instruction budget, in its own unit); the ROOTS are the extra
406
+ * places this repo's owner says that harness's surfaces also live. Splitting
407
+ * them is how a root came to be read under a layout nobody chose for it.
408
+ */
409
+ export interface ScanHarness {
410
+ readonly layout: PluginLayout;
411
+ readonly dialect: HarnessDialect;
412
+ readonly roots: readonly string[];
413
+ }
389
414
  /** Scan a plugin/repo directory and report its surfaces + structural issues. */
390
415
  export declare function scanPlugin(dir: string, layout?: PluginLayout, dialect?: HarnessDialect, opts?: {
391
416
  sharedDirs?: readonly string[];
392
417
  sharedDirsRoot?: string;
418
+ /**
419
+ * The repo's `.vigilesrc.json#exclude`, so surface DISCOVERY honours it.
420
+ *
421
+ * It rides in `opts` rather than as a fifth positional parameter because ~25
422
+ * call sites pass the first three and nothing else; a required parameter here
423
+ * would be twenty-odd mechanical edits for one behavioural change.
424
+ *
425
+ * ⚠️ Omitting it is NOT "the repo excludes nothing" — it is "this caller has
426
+ * no ExcludeSet to give", and the walk then reads everything. Today only
427
+ * `audit` supplies one; the `lint` rule checkers below still do not (they
428
+ * share a `(config, silent, adapter, root)` signature through `overBundles`).
429
+ */
430
+ excludes?: ExcludeSet;
431
+ /**
432
+ * The repo's `.vigilesrc.json#harnesses`, resolved — every declared harness
433
+ * with its own layout, dialect and roots, IN DECLARATION ORDER.
434
+ *
435
+ * 🔴 THE LIST IS READ WHOLE, AND THAT IS THE FIX (#240). The repo is loaded
436
+ * once per entry, each under its OWN layout, and the file maps merge
437
+ * (deduplicated by on-disk path, so one tree serving two harnesses is
438
+ * counted once). Under the flat `harness` array only the first entry's
439
+ * layout was ever used, so a repo declaring both got its skills OR its
440
+ * instruction file depending on which name it happened to list first, and no
441
+ * order gave it both.
442
+ *
443
+ * The first entry is the PRIMARY: it supplies the dialect every
444
+ * dialect-shaped check runs under and the `harness` the report is labelled
445
+ * with. The INSTRUCTION FILE is the deliberate exception — it comes from the
446
+ * first declared harness whose instruction file is actually present, because
447
+ * reporting "no instruction file" for a repo holding the other declared
448
+ * harness's one is the half of the bug that has nothing to do with skills.
449
+ *
450
+ * Rides in `opts` for the same reason `excludes` does: ~25 call sites pass
451
+ * the first three positionals and nothing else. Omitting it means "this
452
+ * caller has no declaration to pass" and the scan runs single-harness on the
453
+ * positional `layout`/`dialect`, exactly as before.
454
+ */
455
+ harnesses?: readonly ScanHarness[];
393
456
  }): ScanReport;
394
457
  /**
395
458
  * LIVE MCP tool resolution for a scanned plugin — the dynamic check no static
package/dist/scan.js CHANGED
@@ -35,10 +35,26 @@ exports.expandMarketplace = expandMarketplace;
35
35
  exports.formatScanReport = formatScanReport;
36
36
  const node_fs_1 = require("node:fs");
37
37
  const node_path_1 = require("node:path");
38
- const plugin_loader_js_1 = require("./adapters/claude-code/plugin-loader.js");
38
+ // The COMPOSITION-ROOT loader, not the `vigiles/claude-code` wrapper: this
39
+ // module always passes a layout explicitly (it never wanted the wrapper's
40
+ // Claude Code default), and only the generic one takes the `ExcludeSet` that
41
+ // makes `.vigilesrc.json#exclude` reach surface discovery.
42
+ const plugin_loader_js_1 = require("./plugin-loader.js");
43
+ // 🔴 A FINDING, NOT A FORMALITY, and one nothing else in the repo could see: this
44
+ // module is listed as a harness-agnostic detector, yet it imports the Claude Code
45
+ // adapter to use as a DEFAULT (`scanPlugin`'s `dialect`/`layout` parameters, and
46
+ // four more sites below). `boundaries/dependencies` does not catch it because it
47
+ // deliberately leaves this file unclassified; the CC-literal rule does not catch
48
+ // it because `\.claude` needs the dot and this path spells `/claude-code/`.
49
+ // Keeping the CC default is the stated backwards-compatibility guarantee, so this
50
+ // is real debt with a known shape — the default belongs to the CALLER (the CLI
51
+ // already resolves an adapter), not to the detector.
52
+ // eslint-disable-next-line local/no-harness-names -- see above
39
53
  const layout_js_1 = require("./adapters/claude-code/layout.js");
54
+ // eslint-disable-next-line local/no-harness-names -- see above
40
55
  const dialect_js_1 = require("./adapters/claude-code/dialect.js");
41
56
  const plugin_loader_js_2 = require("./plugin-loader.js");
57
+ const score_core_js_1 = require("./score-core.js");
42
58
  const skill_refs_js_1 = require("./skill-refs.js");
43
59
  const hook_events_js_1 = require("./core/hook-events.js");
44
60
  const mcp_config_js_1 = require("./core/mcp-config.js");
@@ -49,6 +65,9 @@ const mcp_contract_message_js_1 = require("./core/mcp-contract-message.js");
49
65
  const mcp_hook_js_1 = require("./core/mcp-hook.js");
50
66
  const merge_conflict_js_1 = require("./core/merge-conflict.js");
51
67
  const plugin_dir_layout_js_1 = require("./core/plugin-dir-layout.js");
68
+ const surface_discovery_js_1 = require("./core/surface-discovery.js");
69
+ const surface_discovery_fs_js_1 = require("./surface-discovery-fs.js");
70
+ const layout_registry_js_1 = require("./layout-registry.js");
52
71
  const hook_block_ineffective_js_1 = require("./core/hook-block-ineffective.js");
53
72
  const hook_matcher_js_1 = require("./core/hook-matcher.js");
54
73
  const test_coverage_js_1 = require("./test-coverage.js");
@@ -139,8 +158,23 @@ function ownTestSignalOnDisk(dir) {
139
158
  /** Scan a plugin/repo directory and report its surfaces + structural issues. */
140
159
  function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect, opts = {}) {
141
160
  const lay = layout ?? layout_js_1.claudeCodeLayout;
142
- const cls = (0, scan_core_js_1.makeClassifier)(lay);
143
- const loaded = (0, plugin_loader_js_1.loadPlugin)(dir, lay);
161
+ // The declared harnesses, or the single positional one — so every path below
162
+ // is the multi-harness path and a one-entry list is not a second code path.
163
+ const declared = opts.harnesses && opts.harnesses.length > 0
164
+ ? opts.harnesses
165
+ : [{ layout: lay, dialect, roots: [] }];
166
+ // A path is a surface if ANY declared harness reads it as one. Classifying a
167
+ // merged file map with one layout would drop the other harness's surfaces
168
+ // silently — the same shape of miss as grading a scan that opened nothing.
169
+ const cls = (0, scan_core_js_1.makeUnionClassifier)(declared.map((h) => h.layout));
170
+ const loaded = (0, plugin_loader_js_1.loadPlugins)(dir, declared.map((h) => ({ layout: h.layout, roots: h.roots })), opts.excludes);
171
+ // WHICH harness's instruction file this repo actually has. The first declared
172
+ // one that is present, not the first one declared: a repo listing
173
+ // `claude-code` before `codex` and shipping only `AGENTS.md` has an
174
+ // instruction file, and saying it does not was half of #240's wrong grade.
175
+ // Its dialect carries the budget the weight below is measured against, so the
176
+ // file named and the limit it is judged by come from the same harness.
177
+ const instructionHarness = declared.find((h) => loaded.files[h.layout.instructionFile] !== undefined) ?? declared[0];
144
178
  // Parse the raw `settings.hooks` ONCE at the boundary (parse-don't-validate):
145
179
  // tolerant of the Claude Code nested shape AND the Codex flat shape, so every
146
180
  // hook detector below consumes typed `HookRegistration[]` instead of re-walking
@@ -157,10 +191,11 @@ function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect, opts
157
191
  const eventNames = (0, hook_normalize_js_1.hookEventNames)(loaded.settings.hooks);
158
192
  const allHookEventIssues = (0, hook_events_js_1.verifyHookEvents)(eventNames, dialect);
159
193
  const hookEventIssues = (0, hook_events_js_1.scoredIssues)(allHookEventIssues);
160
- const instructions = loaded.files[lay.instructionFile] !== undefined
194
+ const instructionFile = instructionHarness.layout.instructionFile;
195
+ const instructions = loaded.files[instructionFile] !== undefined
161
196
  ? {
162
- file: lay.instructionFile,
163
- hasSpec: (0, node_fs_1.existsSync)((0, node_path_1.join)((0, node_path_1.resolve)(dir), `${lay.instructionFile}.spec.ts`)),
197
+ file: instructionFile,
198
+ hasSpec: (0, node_fs_1.existsSync)((0, node_path_1.join)((0, node_path_1.resolve)(dir), `${instructionFile}.spec.ts`)),
164
199
  }
165
200
  : null;
166
201
  const mcpServers = collectMcpServers((0, node_path_1.resolve)(dir), lay);
@@ -188,7 +223,17 @@ function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect, opts
188
223
  // deterministic tier (`Tested`), and the real-model tier (`Evaluated`). The
189
224
  // tiers differ in cost, cadence AND in the question they answer, so collapsing
190
225
  // them here would make the difference unrecoverable downstream.
191
- const coverage = (0, test_coverage_js_1.findUntestedSurfaces)({ basePath: dir, layout: lay });
226
+ const coverage = (0, test_coverage_js_1.findUntestedSurfaces)({
227
+ basePath: dir,
228
+ layout: lay,
229
+ // The SAME `.vigilesrc.json#exclude`, in this walk's string face. Untested-
230
+ // surface discovery is a second walk over the same trees, so leaving it out
231
+ // would have excluded a skill from the GRADE while still naming it in
232
+ // "Untested surfaces: 1" — a report contradicting itself about whether the
233
+ // file exists. `exclude` here NARROWS (it unions with DEFAULT_IGNORE), which
234
+ // is the documented relationship between the repo floor and a rule's own list.
235
+ exclude: opts.excludes ? [...opts.excludes.ignore] : undefined,
236
+ });
192
237
  const caveats = (0, test_coverage_js_1.coverageCaveats)(coverage);
193
238
  return {
194
239
  dir,
@@ -229,6 +274,11 @@ function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect, opts
229
274
  trifectaFindings,
230
275
  skillResourceIssues: skillResourceFindings,
231
276
  skillFenceIssues: skillFenceFindings,
277
+ unclaimedSurfaces: (0, surface_discovery_js_1.unclaimedSurfaceFindings)((0, surface_discovery_fs_js_1.boundedSurfacePaths)((0, node_path_1.resolve)(dir), opts.excludes), layout_registry_js_1.REGISTERED_LAYOUTS, declared.map((h) => ({
278
+ harness: h.layout.name,
279
+ layout: h.layout,
280
+ roots: h.roots,
281
+ }))),
232
282
  pluginLayoutIssues: (0, plugin_dir_layout_js_1.pluginDirLayoutIssues)((0, node_path_1.resolve)(dir, (0, node_path_1.dirname)(lay.manifestPath)),
233
283
  // The hooks dir is a misplaceable functional surface too, but it lives in
234
284
  // the layout as a convention PATH (`hooks/hooks.json`), not in surfaceDirs
@@ -257,8 +307,13 @@ function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect, opts
257
307
  return (0, node_fs_1.existsSync)(p) ? nodeReadFile(p) : undefined;
258
308
  }).map(merge_conflict_js_1.mergeConflictWarning),
259
309
  ],
260
- instructionWeight: dialect.instructionBudget
261
- ? (0, instruction_weight_js_1.weighInstructions)(readAlwaysLoaded(dir, dialect.instructionBudget), dialect.instructionBudget)
310
+ // Measured under the dialect whose instruction file this repo HAS — see
311
+ // `instructionHarness`. A budget is a harness's own number in a harness's own
312
+ // unit (Claude Code counts 40 000 chars, Codex 32 768 bytes), so there is no
313
+ // meaningful sum across two; reporting the one that owns the file that
314
+ // exists is the only reading that is true of something.
315
+ instructionWeight: instructionHarness.dialect.instructionBudget
316
+ ? (0, instruction_weight_js_1.weighInstructions)(readAlwaysLoaded(dir, instructionHarness.dialect.instructionBudget), instructionHarness.dialect.instructionBudget)
262
317
  : null,
263
318
  untested: coverage.untested.length,
264
319
  untestedHarness: coverage.harness.untested.length,
@@ -588,6 +643,11 @@ function formatScanReport(r) {
588
643
  out.push(...section("Skill bundled resources", r.skillResourceIssues.map((s) => ` ✗ ${s.name}: ${s.finding.ref} (line ${String(s.finding.line)}) — bundled resource not found`)));
589
644
  out.push(...section("Invisible skills (missing frontmatter fence)", r.skillFenceIssues.map((s) => ` ✗ ${s.name} (${s.path}): opens with \`${s.finding.key}:\` but no \`---\` fence — loads as body, never fires`)));
590
645
  out.push(...section("Misplaced plugin directories", r.pluginLayoutIssues.map((p) => ` ✗ ${p.message}`)));
646
+ // A harness sitting where no adapter reads (#240). Printed as a ✗ section like
647
+ // any other structural defect, because that is what it is: the grade above it
648
+ // was computed without this directory in it, and the old behaviour was to say
649
+ // nothing at all while returning A (100/100).
650
+ out.push(...section("Surfaces no harness reads", r.unclaimedSurfaces.map((u) => ` ✗ ${u.message}`)));
591
651
  out.push(...section("Lethal trifecta across delegation (blast radius)", r.delegationTrifecta.map((d) => ` ⚠ ${d.finding.name} (${d.path}): ${d.finding.message}`)));
592
652
  out.push(...section("Ineffective hook guards (false confidence)", r.hookBlockFindings.map((h) => ` ✗ [${h.event}] ${h.scriptPath ?? "(inline)"}: ${h.message}`)));
593
653
  out.push(...section("Hook matchers that don't fire as written", r.hookMatcherFindings.map((m) => ` ✗ ${m.message}`)));
@@ -658,6 +718,7 @@ function formatScanReport(r) {
658
718
  r.skillResourceIssues.length +
659
719
  r.skillFenceIssues.length +
660
720
  r.pluginLayoutIssues.length +
721
+ r.unclaimedSurfaces.length +
661
722
  r.hookBlockFindings.length +
662
723
  r.hookMatcherFindings.length +
663
724
  // Only HARD trifectas (✗) count as STRUCTURAL defects here. Both severities are
@@ -667,9 +728,15 @@ function formatScanReport(r) {
667
728
  // "broken" in the CLI summary is a separate, louder call. The delegation-trifecta
668
729
  // ⚠ risk is ungraded and does NOT count either.
669
730
  r.trifectaFindings.filter((t) => t.finding.severity === "hard").length;
670
- out.push(broken === 0
671
- ? "✓ no structural issues found"
672
- : `⚠ ${String(broken)} structural issue(s) — see ✗/⚠ above`);
731
+ out.push(broken > 0
732
+ ? `⚠ ${String(broken)} structural issue(s) — see ✗/⚠ above`
733
+ : // 🔴 "NOTHING WAS FOUND" IS NOT "NOTHING IS WRONG" (#240). With zero
734
+ // surfaces read, `broken` is zero for want of anything to count, and this
735
+ // line was the sentence the reporter quoted under an A (100): a repo whose
736
+ // 37 skills sat in a directory this tool does not know by name.
737
+ (0, score_core_js_1.isEmptyMachine)(r)
738
+ ? "⚠ nothing to check — 0 skills, 0 agents, 0 commands were read"
739
+ : "✓ no structural issues found");
673
740
  return out.join("\n");
674
741
  }
675
742
  //# sourceMappingURL=scan.js.map