vigiles 14.7.0 → 14.8.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 (47) hide show
  1. package/dist/adapters/claude-code/agent-runtime.d.ts +2 -19
  2. package/dist/adapters/claude-code/agent-runtime.js +5 -30
  3. package/dist/adapters/claude-code/agent-tools.d.ts +20 -0
  4. package/dist/adapters/claude-code/agent-tools.js +40 -0
  5. package/dist/audit-report.d.ts +1 -1
  6. package/dist/audit-report.template.html +15 -15
  7. package/dist/audit-score.d.ts +1 -1
  8. package/dist/audit-score.js +19 -19
  9. package/dist/audit-verdict.d.ts +1 -1
  10. package/dist/audit-verdict.js +3 -3
  11. package/dist/core/assert-never.d.ts +9 -0
  12. package/dist/core/assert-never.js +14 -0
  13. package/dist/core/description-overlap.js +2 -2
  14. package/dist/core/effects.js +3 -3
  15. package/dist/core/hash.d.ts +1 -2
  16. package/dist/core/hash.js +6 -4
  17. package/dist/core/hook-block-ineffective.d.ts +55 -6
  18. package/dist/core/hook-block-ineffective.js +9 -14
  19. package/dist/core/mcp-contract-message.d.ts +22 -0
  20. package/dist/core/mcp-contract-message.js +29 -0
  21. package/dist/core/mcp.d.ts +4 -12
  22. package/dist/core/mcp.js +3 -14
  23. package/dist/core/ncd.d.ts +12 -0
  24. package/dist/core/ncd.js +50 -0
  25. package/dist/core/plugin-dir-layout.d.ts +5 -5
  26. package/dist/core/plugin-dir-layout.js +10 -22
  27. package/dist/core/proofs.d.ts +2 -11
  28. package/dist/core/proofs.js +4 -39
  29. package/dist/core/skill-resources.d.ts +3 -3
  30. package/dist/core/skill-resources.js +9 -8
  31. package/dist/leaderboard.d.ts +2 -51
  32. package/dist/leaderboard.js +20 -225
  33. package/dist/optimize.d.ts +1 -1
  34. package/dist/optimize.js +3 -3
  35. package/dist/posix-path.d.ts +40 -0
  36. package/dist/posix-path.js +293 -0
  37. package/dist/scan-core.d.ts +154 -0
  38. package/dist/scan-core.js +690 -0
  39. package/dist/scan-files.d.ts +28 -0
  40. package/dist/scan-files.js +489 -0
  41. package/dist/scan.d.ts +11 -34
  42. package/dist/scan.js +55 -668
  43. package/dist/score-core.d.ts +73 -0
  44. package/dist/score-core.js +226 -0
  45. package/dist/test-coverage-files.d.ts +11 -0
  46. package/dist/test-coverage-files.js +208 -0
  47. package/package.json +1 -1
@@ -0,0 +1,28 @@
1
+ import type { PluginLayout } from "./core/layout.js";
2
+ import type { HarnessDialect } from "./core/dialect.js";
3
+ import type { LoadedPlugin } from "./plugin-loader.js";
4
+ import type { ScanReport } from "./scan.js";
5
+ /**
6
+ * The synthetic absolute root every path in a browser scan resolves against. A
7
+ * pure, deterministic string (never `process.cwd()`), so `join`/`relative` stay
8
+ * side-effect-free and identical on every machine. Exported so the parity test can
9
+ * normalize an on-disk report's real absolute root to it before comparing.
10
+ */
11
+ export declare const BROWSER_ROOT = "/__vigiles_repo__";
12
+ /**
13
+ * Reconstruct the `LoadedPlugin` shape from a repo-relative file map, exactly as
14
+ * `loadPlugin` builds it off disk — surface files materialized under the layout's
15
+ * `materializeRoot`, hook commands with the plugin-root token expanded to
16
+ * {@link BROWSER_ROOT}, and the same `warnings`.
17
+ */
18
+ export declare function loadPluginFromFiles(files: Record<string, string>, layout: PluginLayout, repoName?: string): LoadedPlugin;
19
+ /**
20
+ * Scan an in-memory repo-relative file map and report its surfaces + structural
21
+ * issues — the browser-safe twin of {@link scanPlugin}. `files` is
22
+ * `repoRelativePath → content` (as a GitHub fetch yields). Produces a
23
+ * {@link ScanReport} that {@link buildAuditReport} turns into the exact same
24
+ * `AuditReport` the CLI's `audit --json` produces over the same files (modulo the
25
+ * checkout path — see {@link BROWSER_ROOT}).
26
+ */
27
+ export declare function scanFiles(files: Record<string, string>, layout?: PluginLayout, dialect?: HarnessDialect, repoName?: string): ScanReport;
28
+ //# sourceMappingURL=scan-files.d.ts.map
@@ -0,0 +1,489 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BROWSER_ROOT = void 0;
4
+ exports.loadPluginFromFiles = loadPluginFromFiles;
5
+ exports.scanFiles = scanFiles;
6
+ /**
7
+ * `scanFiles` — the BROWSER-SAFE deterministic audit engine.
8
+ *
9
+ * `scanPlugin` (src/scan.ts) walks a directory on disk. `scanFiles` produces the
10
+ * SAME {@link ScanReport} over an in-memory `Record<repoRelativePath, content>`
11
+ * file map — exactly what an in-browser GitHub fetch yields — so the whole
12
+ * deterministic audit (and the {@link buildAuditReport} it feeds) runs client-side
13
+ * with NO filesystem, NO `child_process`, NO disk I/O at all.
14
+ *
15
+ * It mirrors `scanPlugin`'s body: it (a) reconstructs the `LoadedPlugin` shape
16
+ * (`{settings.hooks, files, warnings, sources}`) from the map instead of a disk
17
+ * walk, then (b) runs the SAME pure detectors `scanPlugin` runs (re-exported from
18
+ * scan.ts — one detector, no drift), then (c) callers hand the result to
19
+ * `buildAuditReport` unchanged. Every filesystem access in `scanPlugin` maps to a
20
+ * lookup in the map: `existsSync(p)` → `p in files`, `readFileSync(p)` →
21
+ * `files[p]`, "is a directory" → `keys.some(k => k.startsWith(p + "/"))`.
22
+ *
23
+ * Path model — a SYNTHETIC ROOT ({@link BROWSER_ROOT}): a browser has no real
24
+ * directory, so we resolve every path against a fixed absolute sentinel. All the
25
+ * report fields that embed the plugin root on disk (`meta.dir`, a resolved hook
26
+ * `script`/`command`, a hook-block `scriptPath`) come out rooted at
27
+ * {@link BROWSER_ROOT} instead of the checkout path. The parity test normalizes
28
+ * the on-disk report's real absolute root to {@link BROWSER_ROOT} and asserts a
29
+ * byte-identical {@link buildAuditReport} — the checkout path is the ONE thing that
30
+ * legitimately differs between the two environments (it names WHERE the repo lives,
31
+ * not WHAT the harness contains).
32
+ *
33
+ * The six filesystem touchpoints in `scanPlugin` are reimplemented here over the
34
+ * map (never on disk): (1) hook-script resolution — scanHooks/collectHookBlockEntries
35
+ * take an injected map-backed `exists`; (2) `collectMcpServers`; (3) the `hasSpec`
36
+ * check; (4) `danglingRefs`; (5) `pluginDirLayoutIssues` (map-backed
37
+ * existsSync/isDirectory); (6) `findUntestedSurfaces` (its globs → `Object.keys`
38
+ * filters). `verifyLiveMcpTools` (spawns servers) is NOT part of `scanPlugin` and
39
+ * is excluded. See research/report-view-and-browser-demo.md.
40
+ */
41
+ const posix_path_js_1 = require("./posix-path.js");
42
+ const toml_1 = require("@iarna/toml");
43
+ const layout_js_1 = require("./adapters/claude-code/layout.js");
44
+ const dialect_js_1 = require("./adapters/claude-code/dialect.js");
45
+ const hook_normalize_js_1 = require("./core/hook-normalize.js");
46
+ const hook_events_js_1 = require("./core/hook-events.js");
47
+ const mcp_config_js_1 = require("./core/mcp-config.js");
48
+ const mcp_hook_js_1 = require("./core/mcp-hook.js");
49
+ const plugin_dir_layout_js_1 = require("./core/plugin-dir-layout.js");
50
+ const hook_block_ineffective_js_1 = require("./core/hook-block-ineffective.js");
51
+ const hook_matcher_js_1 = require("./core/hook-matcher.js");
52
+ const test_coverage_files_js_1 = require("./test-coverage-files.js");
53
+ const scan_core_js_1 = require("./scan-core.js");
54
+ /**
55
+ * The synthetic absolute root every path in a browser scan resolves against. A
56
+ * pure, deterministic string (never `process.cwd()`), so `join`/`relative` stay
57
+ * side-effect-free and identical on every machine. Exported so the parity test can
58
+ * normalize an on-disk report's real absolute root to it before comparing.
59
+ */
60
+ exports.BROWSER_ROOT = "/__vigiles_repo__";
61
+ /** Mirror of loadPlugin's `MAX_SKILL_FILE_BYTES` — surface files over this are skipped. */
62
+ const MAX_SKILL_FILE_BYTES = 256 * 1024;
63
+ /** UTF-8 byte length (mirrors disk `statSync(f).size` for a text file). */
64
+ const byteLen = (s) => new TextEncoder().encode(s).length;
65
+ // ---------------------------------------------------------------------------
66
+ // File-map primitives (repo-relative keys, POSIX-style)
67
+ // ---------------------------------------------------------------------------
68
+ /** `p in files` — a repo-relative path is present as a file key. */
69
+ function hasFile(files, rel) {
70
+ return Object.prototype.hasOwnProperty.call(files, rel);
71
+ }
72
+ /** "is a directory" — some file key lives UNDER `rel/`. */
73
+ function isDirRel(files, rel) {
74
+ const prefix = rel === "" ? "" : `${rel}/`;
75
+ if (rel === "")
76
+ return Object.keys(files).length > 0;
77
+ return Object.keys(files).some((k) => k.startsWith(prefix));
78
+ }
79
+ /**
80
+ * The repo-relative form of an absolute BROWSER_ROOT-rooted path, or `null` when
81
+ * the path escapes the root (a `../` outside the plugin — never a plugin file).
82
+ */
83
+ function toRel(absPath) {
84
+ const rel = (0, posix_path_js_1.relative)(exports.BROWSER_ROOT, absPath);
85
+ if (rel === "" || rel.startsWith("..") || (0, posix_path_js_1.isAbsolute)(rel))
86
+ return null;
87
+ return rel;
88
+ }
89
+ /**
90
+ * A map-backed `existsSync` over absolute BROWSER_ROOT-rooted paths. Mirrors
91
+ * node's `fs.existsSync`, which is true for a FILE **or a DIRECTORY** — so a
92
+ * detector like `pluginDirLayoutIssues` (`exists(dir) && isDirectory(dir)`)
93
+ * resolves the same in the browser as on disk. File-only here would silently miss
94
+ * any directory-existence check the CLI reports.
95
+ */
96
+ function mapExists(files) {
97
+ return (p) => {
98
+ const rel = toRel(p);
99
+ return rel !== null && (hasFile(files, rel) || isDirRel(files, rel));
100
+ };
101
+ }
102
+ /** A map-backed `isDirectory` over absolute BROWSER_ROOT-rooted paths. */
103
+ function mapIsDirectory(files) {
104
+ return (p) => {
105
+ const rel = toRel(p);
106
+ return rel !== null && isDirRel(files, rel);
107
+ };
108
+ }
109
+ /** A map-backed `readFileSync` (returns "" on a miss, like the detector default). */
110
+ function mapReadFile(files) {
111
+ return (p) => {
112
+ const rel = toRel(p);
113
+ return rel !== null && hasFile(files, rel) ? files[rel] : "";
114
+ };
115
+ }
116
+ /**
117
+ * Read a subtree of the map as `relativeToBase → content`, mirroring
118
+ * loadPlugin's `readTree(dir, base)` — including its `MAX_SKILL_FILE_BYTES` cap.
119
+ * `baseRel === ""` reads the whole repo (the single-skill case's `readTree(root,
120
+ * root)`).
121
+ */
122
+ function readTreeUnder(files, dirRel, baseRel) {
123
+ const out = {};
124
+ const underPrefix = dirRel === "" ? "" : `${dirRel}/`;
125
+ const basePrefix = baseRel === "" ? "" : `${baseRel}/`;
126
+ for (const [k, content] of Object.entries(files)) {
127
+ if (dirRel !== "" && !k.startsWith(underPrefix))
128
+ continue;
129
+ if (byteLen(content) > MAX_SKILL_FILE_BYTES)
130
+ continue;
131
+ const rel = baseRel === "" ? k : k.slice(basePrefix.length);
132
+ out[rel] = content;
133
+ }
134
+ return out;
135
+ }
136
+ // ---------------------------------------------------------------------------
137
+ // Manifest / hooks reading (mirrors plugin-loader.ts readHooks/safeReadManifest)
138
+ // ---------------------------------------------------------------------------
139
+ /** Parse a JSON file's text, or null on any error. */
140
+ function safeParseJson(text) {
141
+ if (text === undefined)
142
+ return null;
143
+ try {
144
+ return JSON.parse(text);
145
+ }
146
+ catch {
147
+ return null;
148
+ }
149
+ }
150
+ /** Parse the layout's manifest (JSON or TOML) from the map, or null. */
151
+ function readManifest(files, layout) {
152
+ const text = files[layout.manifestPath];
153
+ if (text === undefined)
154
+ return null;
155
+ if (layout.settingsFormat === "toml") {
156
+ try {
157
+ return (0, toml_1.parse)(text);
158
+ }
159
+ catch {
160
+ return null;
161
+ }
162
+ }
163
+ return safeParseJson(text);
164
+ }
165
+ /** The `.hooks` field of a JSON file in the map, or undefined. */
166
+ function readHooksJsonFile(files, rel) {
167
+ return safeParseJson(files[rel])?.hooks;
168
+ }
169
+ /** The `.hooks` of a settings file in the map, in the layout's format. */
170
+ function readSettingsHooks(text, format) {
171
+ if (format === "json")
172
+ return safeParseJson(text)?.hooks;
173
+ try {
174
+ return (0, toml_1.parse)(text).hooks;
175
+ }
176
+ catch {
177
+ return undefined;
178
+ }
179
+ }
180
+ /** Mirror of plugin-loader.ts `readHooks`, over the file map. */
181
+ function readHooks(files, layout) {
182
+ const m = readManifest(files, layout);
183
+ if (m) {
184
+ if (typeof m.hooks === "string") {
185
+ return readHooksJsonFile(files, m.hooks.replace(/^\.\//, ""));
186
+ }
187
+ if (m.hooks !== undefined)
188
+ return m.hooks;
189
+ }
190
+ if (hasFile(files, layout.hooksConventionPath)) {
191
+ return readHooksJsonFile(files, layout.hooksConventionPath);
192
+ }
193
+ const settings = files[layout.settingsPath];
194
+ if (settings !== undefined) {
195
+ return readSettingsHooks(settings, layout.settingsFormat);
196
+ }
197
+ return undefined;
198
+ }
199
+ /** Whether the plugin declares any MCP servers (standalone file or manifest key). */
200
+ function hasMcp(files, layout) {
201
+ if (hasFile(files, layout.mcpConfigFile))
202
+ return true;
203
+ return readManifest(files, layout)?.[layout.mcpManifestKey] !== undefined;
204
+ }
205
+ /** Mirror of plugin-loader.ts `surfaceHasLoadable`. */
206
+ function surfaceHasLoadable(layout, surface, tree) {
207
+ const keys = Object.keys(tree);
208
+ return surface === layout.skillDir
209
+ ? keys.some((k) => (0, posix_path_js_1.basename)(k) === "SKILL.md")
210
+ : keys.some((k) => k.endsWith(".md"));
211
+ }
212
+ /** Mirror of plugin-loader.ts `classifySurfaceSource`, over the file map. */
213
+ function classifySurfaceSource(files, layout, rootTrees, repoName) {
214
+ if (layout.skillDir && hasFile(files, "SKILL.md")) {
215
+ // Disk mirrors the CLI: a nameless root SKILL.md takes the audited dir's
216
+ // basename. In-browser there's no real dir, so use the repo name when the
217
+ // caller (runAudit) supplies it, else the synthetic BROWSER_ROOT basename.
218
+ return {
219
+ kind: "single-skill",
220
+ skillName: repoName ?? (0, posix_path_js_1.basename)(exports.BROWSER_ROOT),
221
+ };
222
+ }
223
+ const rootHasLoadable = layout.surfaceDirs.some((s) => surfaceHasLoadable(layout, s, rootTrees.get(s) ?? {}));
224
+ const isPluginShaped = hasFile(files, layout.manifestPath) ||
225
+ hasFile(files, layout.hooksConventionPath);
226
+ if (rootHasLoadable || isPluginShaped)
227
+ return { kind: "root" };
228
+ if (layout.userSurfaceRoot !== undefined) {
229
+ return { kind: "user", sub: layout.userSurfaceRoot };
230
+ }
231
+ return { kind: "none" };
232
+ }
233
+ /** Mirror of plugin-loader.ts `materializeSurfaces`, over the file map. */
234
+ function materializeSurfaces(files, layout, acc, repoName) {
235
+ const { out, sources } = acc;
236
+ const counts = {};
237
+ const rootTrees = new Map();
238
+ for (const surface of layout.surfaceDirs) {
239
+ if (isDirRel(files, surface)) {
240
+ rootTrees.set(surface, readTreeUnder(files, surface, surface));
241
+ }
242
+ }
243
+ const add = (key, content, onDisk) => {
244
+ out[key] = content;
245
+ sources[key] = onDisk;
246
+ };
247
+ const source = classifySurfaceSource(files, layout, rootTrees, repoName);
248
+ switch (source.kind) {
249
+ case "single-skill": {
250
+ const tree = readTreeUnder(files, "", "");
251
+ for (const [rel, content] of Object.entries(tree)) {
252
+ 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));
253
+ }
254
+ counts[layout.skillDir] = Object.keys(tree).length;
255
+ break;
256
+ }
257
+ case "root":
258
+ case "user": {
259
+ const baseRel = source.kind === "user" ? source.sub : "";
260
+ for (const surface of layout.surfaceDirs) {
261
+ const dirRel = baseRel === "" ? surface : `${baseRel}/${surface}`;
262
+ const tree = source.kind === "root"
263
+ ? (rootTrees.get(surface) ?? {})
264
+ : isDirRel(files, dirRel)
265
+ ? readTreeUnder(files, dirRel, dirRel)
266
+ : {};
267
+ for (const [rel, content] of Object.entries(tree)) {
268
+ add((0, posix_path_js_1.join)(layout.materializeRoot, surface, rel), content, (0, posix_path_js_1.join)(exports.BROWSER_ROOT, dirRel, rel));
269
+ }
270
+ counts[surface] = Object.keys(tree).length;
271
+ }
272
+ break;
273
+ }
274
+ case "none":
275
+ break;
276
+ }
277
+ return counts;
278
+ }
279
+ // ---------------------------------------------------------------------------
280
+ // Dangling intra-plugin refs (mirrors plugin-loader.ts danglingRefs)
281
+ // ---------------------------------------------------------------------------
282
+ const INTRA_REF_EXTS = "md|sh|cmd|mjs|cjs|js|ts|py|rb|txt|json";
283
+ function intraRefRe(layout) {
284
+ return new RegExp(`(?:${layout.intraRefDirs.join("|")})/[A-Za-z0-9._/-]+\\.(?:${INTRA_REF_EXTS})`, "g");
285
+ }
286
+ const NON_PLUGIN_VARS = new Set([
287
+ "CLAUDE_PROJECT_DIR",
288
+ "CLAUDE_PROJECT",
289
+ "HOME",
290
+ "PWD",
291
+ "OLDPWD",
292
+ ]);
293
+ /** Mirror of plugin-loader.ts `isPluginRooted`. */
294
+ function isPluginRooted(content, idx) {
295
+ if (idx === 0 || content[idx - 1] !== "/")
296
+ return true;
297
+ const seg = /([^\s"'`(=:/]*)$/.exec(content.slice(0, idx - 1))?.[1] ?? "";
298
+ const varName = /^\$\{?(\w+)\}?$/.exec(seg)?.[1];
299
+ if (varName !== undefined)
300
+ return !NON_PLUGIN_VARS.has(varName);
301
+ return false;
302
+ }
303
+ const DOC_SOURCE_RE = /\.(?:md|markdown|mdx|txt|rst)$/i;
304
+ /** The plugin's executable (non-prose) source-file CONTENTS under the surface dirs. */
305
+ function executableContents(files, layout) {
306
+ const out = [];
307
+ for (const surface of layout.intraRefDirs) {
308
+ if (!isDirRel(files, surface))
309
+ continue;
310
+ for (const [k, content] of Object.entries(files)) {
311
+ if (!k.startsWith(`${surface}/`))
312
+ continue;
313
+ if (DOC_SOURCE_RE.test(k))
314
+ continue;
315
+ if (byteLen(content) > MAX_SKILL_FILE_BYTES)
316
+ continue;
317
+ out.push(content);
318
+ }
319
+ }
320
+ return out;
321
+ }
322
+ /** Mirror of plugin-loader.ts `danglingRefs`, over the file map. */
323
+ function danglingRefs(files, layout) {
324
+ const re = intraRefRe(layout);
325
+ const missing = new Set();
326
+ for (const content of executableContents(files, layout)) {
327
+ for (const m of content.matchAll(re)) {
328
+ if (m.index !== undefined && !isPluginRooted(content, m.index))
329
+ continue;
330
+ if (!hasFile(files, m[0]))
331
+ missing.add(m[0]);
332
+ }
333
+ }
334
+ return [...missing];
335
+ }
336
+ // ---------------------------------------------------------------------------
337
+ // Warnings (mirrors plugin-loader.ts pluginWarnings)
338
+ // ---------------------------------------------------------------------------
339
+ function pluginWarnings(files, layout, counts, hooks, materialized) {
340
+ const warnings = [];
341
+ if (counts.agents) {
342
+ warnings.push(`plugin defines ${String(counts.agents)} subagent file(s) under agents/ — these run only under a real model; test them at the eval tier (runEval), not the deterministic mock.`);
343
+ }
344
+ if (counts.commands) {
345
+ warnings.push(`plugin defines ${String(counts.commands)} slash-command file(s) under commands/ — slash-command invocation needs a real model; test at the eval tier.`);
346
+ }
347
+ if (hasMcp(files, layout)) {
348
+ warnings.push(`plugin declares MCP server(s) (${layout.mcpManifestKey} / ${layout.mcpConfigFile}) — the loader does not wire MCP; bring the server up yourself if your test needs it.`);
349
+ }
350
+ const dangling = danglingRefs(files, layout);
351
+ if (dangling.length) {
352
+ const shown = dangling.slice(0, 5).join(", ");
353
+ const more = dangling.length > 5 ? `, … (+${String(dangling.length - 5)})` : "";
354
+ warnings.push(`plugin references ${String(dangling.length)} intra-plugin file(s) that don't exist (broken path / partial vendor): ${shown}${more}`);
355
+ }
356
+ if (!hooks && Object.keys(materialized).length === 0) {
357
+ warnings.push(`nothing was loaded (no hooks, CLAUDE.md, skills, agents, or commands) — the deterministic harness would run an effectively empty machine.`);
358
+ }
359
+ return warnings;
360
+ }
361
+ // ---------------------------------------------------------------------------
362
+ // loadPluginFromFiles (mirrors plugin-loader.ts loadPlugin)
363
+ // ---------------------------------------------------------------------------
364
+ /**
365
+ * Reconstruct the `LoadedPlugin` shape from a repo-relative file map, exactly as
366
+ * `loadPlugin` builds it off disk — surface files materialized under the layout's
367
+ * `materializeRoot`, hook commands with the plugin-root token expanded to
368
+ * {@link BROWSER_ROOT}, and the same `warnings`.
369
+ */
370
+ function loadPluginFromFiles(files, layout, repoName) {
371
+ const hooks = readHooks(files, layout);
372
+ const resolvedHooks = hooks
373
+ ? JSON.parse(JSON.stringify(hooks).replaceAll(layout.pluginRootToken, exports.BROWSER_ROOT))
374
+ : undefined;
375
+ const out = {};
376
+ const sources = {};
377
+ const instructionText = files[layout.instructionFile];
378
+ if (instructionText !== undefined) {
379
+ out[layout.instructionFile] = instructionText;
380
+ sources[layout.instructionFile] = (0, posix_path_js_1.join)(exports.BROWSER_ROOT, layout.instructionFile);
381
+ }
382
+ const counts = materializeSurfaces(files, layout, { out, sources }, repoName);
383
+ return {
384
+ settings: resolvedHooks ? { hooks: resolvedHooks } : {},
385
+ files: out,
386
+ sources,
387
+ warnings: pluginWarnings(files, layout, counts, resolvedHooks, out),
388
+ };
389
+ }
390
+ // ---------------------------------------------------------------------------
391
+ // collectMcpServers (mirrors scan.ts collectMcpServers)
392
+ // ---------------------------------------------------------------------------
393
+ function collectMcpServers(files, layout) {
394
+ const servers = {};
395
+ const collect = (file) => {
396
+ const text = files[file];
397
+ if (text === undefined)
398
+ return;
399
+ try {
400
+ const parsed = JSON.parse(text);
401
+ if (parsed.mcpServers !== null && typeof parsed.mcpServers === "object") {
402
+ Object.assign(servers, parsed.mcpServers);
403
+ }
404
+ }
405
+ catch {
406
+ /* malformed JSON is the loader's concern, not this check's */
407
+ }
408
+ };
409
+ collect(".mcp.json");
410
+ collect(layout.manifestPath);
411
+ return servers;
412
+ }
413
+ // ---------------------------------------------------------------------------
414
+ // Public API
415
+ // ---------------------------------------------------------------------------
416
+ /**
417
+ * Scan an in-memory repo-relative file map and report its surfaces + structural
418
+ * issues — the browser-safe twin of {@link scanPlugin}. `files` is
419
+ * `repoRelativePath → content` (as a GitHub fetch yields). Produces a
420
+ * {@link ScanReport} that {@link buildAuditReport} turns into the exact same
421
+ * `AuditReport` the CLI's `audit --json` produces over the same files (modulo the
422
+ * checkout path — see {@link BROWSER_ROOT}).
423
+ */
424
+ function scanFiles(files, layout = layout_js_1.claudeCodeLayout, dialect = dialect_js_1.claudeCodeDialect, repoName) {
425
+ const lay = layout;
426
+ const cls = (0, scan_core_js_1.makeClassifier)(lay);
427
+ const loaded = loadPluginFromFiles(files, lay, repoName);
428
+ const exists = mapExists(files);
429
+ const hookRegs = (0, hook_normalize_js_1.normalizeHooks)(loaded.settings.hooks);
430
+ const { hooks, inline, manual } = (0, scan_core_js_1.scanHooks)(hookRegs, exports.BROWSER_ROOT, lay.pluginRootToken, exists);
431
+ const eventNames = (0, hook_normalize_js_1.hookEventNames)(loaded.settings.hooks);
432
+ const hookEventIssues = (0, hook_events_js_1.confidentHookEventIssues)((0, hook_events_js_1.verifyHookEvents)(eventNames, dialect));
433
+ const instructions = loaded.files[lay.instructionFile] !== undefined
434
+ ? {
435
+ file: lay.instructionFile,
436
+ hasSpec: hasFile(files, `${lay.instructionFile}.spec.ts`),
437
+ }
438
+ : null;
439
+ const mcpServers = collectMcpServers(files, lay);
440
+ const declaredServers = Object.keys(mcpServers);
441
+ const agents = (0, scan_core_js_1.scanAgents)(loaded.files, dialect, declaredServers, cls);
442
+ const skills = (0, scan_core_js_1.scanSkills)(loaded.files, cls, {
443
+ root: exports.BROWSER_ROOT,
444
+ materializeRoot: lay.materializeRoot,
445
+ dialect,
446
+ sources: loaded.sources,
447
+ existsSync: exists,
448
+ });
449
+ const puritySummary = (0, scan_core_js_1.summarizePurity)(agents);
450
+ const { trifectaFindings, skillResourceFindings, skillFenceFindings } = (0, scan_core_js_1.collectSurfaceFindings)(agents, skills);
451
+ return {
452
+ dir: exports.BROWSER_ROOT,
453
+ instructions,
454
+ skills,
455
+ agents,
456
+ hooks,
457
+ inlineHooks: inline,
458
+ manualHookCount: manual,
459
+ commands: Object.keys(loaded.files).filter(cls.isCommand).length,
460
+ mcp: loaded.warnings.some((w) => w.includes("MCP server")),
461
+ danglingRefs: danglingRefs(files, lay),
462
+ hookEventIssues,
463
+ frontmatterIssues: (0, scan_core_js_1.frontmatterIssuesFor)(loaded.files, cls),
464
+ frontmatterValueIssues: (0, scan_core_js_1.frontmatterValueIssuesFor)(loaded.files, cls),
465
+ skillMetaIssues: (0, scan_core_js_1.skillMetaIssuesFor)(loaded.files, cls),
466
+ mcpIssues: (0, mcp_config_js_1.verifyMcpServers)(mcpServers),
467
+ mcpHookIssues: (0, mcp_hook_js_1.verifyMcpHookTargets)(loaded.settings.hooks, declaredServers, dialect),
468
+ descriptionOverlaps: (0, scan_core_js_1.descriptionOverlapsFor)(loaded.files, cls),
469
+ descriptionBudgetIssues: (0, scan_core_js_1.descriptionBudgetFor)(loaded.files, cls),
470
+ trifectaFindings,
471
+ skillResourceIssues: skillResourceFindings,
472
+ skillFenceIssues: skillFenceFindings,
473
+ 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) }),
474
+ delegationTrifecta: (0, scan_core_js_1.collectDelegationTrifecta)(agents, dialect),
475
+ hookBlockFindings: dialect.noEffectHookEvents
476
+ ? (0, hook_block_ineffective_js_1.hookBlockIssues)((0, scan_core_js_1.collectHookBlockEntries)(hookRegs, exports.BROWSER_ROOT, lay.pluginRootToken, exists), {
477
+ noEffectEvents: new Set(dialect.noEffectHookEvents),
478
+ permissionDecisionEvents: new Set(dialect.permissionDecisionHookEvents ?? []),
479
+ readFileSync: mapReadFile(files),
480
+ })
481
+ : [],
482
+ hookMatcherFindings: (0, hook_matcher_js_1.hookMatcherIssues)((0, scan_core_js_1.collectHookMatchers)(hookRegs), declaredServers, dialect),
483
+ malformedFrontmatter: (0, scan_core_js_1.malformedFrontmatterFor)(loaded.files, cls),
484
+ warnings: loaded.warnings,
485
+ untested: (0, test_coverage_files_js_1.findUntestedSurfacesInFiles)(files, lay).untested.length,
486
+ puritySummary,
487
+ };
488
+ }
489
+ //# sourceMappingURL=scan-files.js.map
package/dist/scan.d.ts CHANGED
@@ -13,22 +13,23 @@
13
13
  */
14
14
  import type { PluginLayout } from "./core/layout.js";
15
15
  import type { HarnessDialect } from "./core/dialect.js";
16
- import { type ToolIssue } from "./core/tool-contract.js";
16
+ import type { ToolIssue } from "./core/tool-contract.js";
17
17
  import { type HookEventIssue } from "./core/hook-events.js";
18
18
  import { type McpIssue } from "./core/mcp-config.js";
19
- import { type DescriptionOverlap } from "./core/description-overlap.js";
20
- import { type DescriptionBudgetIssue } from "./core/skill-description-budget.js";
21
- import { type McpToolIssue } from "./core/mcp-tool.js";
22
- import { type McpContractToolError } from "./core/mcp.js";
19
+ import type { DescriptionOverlap } from "./core/description-overlap.js";
20
+ import type { DescriptionBudgetIssue } from "./core/skill-description-budget.js";
21
+ import type { McpToolIssue } from "./core/mcp-tool.js";
22
+ import { type McpContractToolError } from "./core/mcp-contract-message.js";
23
23
  import { type McpHookIssue } from "./core/mcp-hook.js";
24
- import { type TrifectaFinding } from "./core/lethal-trifecta.js";
25
- import { type SkillResourceFinding } from "./core/skill-resources.js";
26
- import { type SkillFenceFinding } from "./core/skill-missing-fence.js";
24
+ import type { TrifectaFinding } from "./core/lethal-trifecta.js";
25
+ import type { SkillResourceFinding } from "./core/skill-resources.js";
26
+ import type { SkillFenceFinding } from "./core/skill-missing-fence.js";
27
27
  import { type PluginLayoutFinding } from "./core/plugin-dir-layout.js";
28
- import { type DelegationTrifectaFinding } from "./core/delegation-trifecta.js";
28
+ import type { DelegationTrifectaFinding } from "./core/delegation-trifecta.js";
29
29
  import { type HookBlockFinding } from "./core/hook-block-ineffective.js";
30
30
  import { type HookMatcherFinding } from "./core/hook-matcher.js";
31
- import { type PurityLevel, type EffectSurface } from "./core/effects.js";
31
+ import type { PurityLevel, EffectSurface } from "./core/effects.js";
32
+ export * from "./scan-core.js";
32
33
  export interface ScanSkill {
33
34
  readonly name: string;
34
35
  readonly path: string;
@@ -276,30 +277,6 @@ export interface ScanReport {
276
277
  unrestricted: number;
277
278
  };
278
279
  }
279
- /**
280
- * Per-kind surface classifiers, built from the harness `PluginLayout`'s
281
- * `skillDir`/`agentDir`/`commandDir` — so adding a harness whose subagents live
282
- * somewhere other than `agents/` (OpenCode's `.opencode/agent`) needs no change
283
- * here. Each anchors on a real path boundary (start-of-path or a `/`), so a
284
- * directory whose NAME merely ends in the keyword isn't misclassified — e.g. the
285
- * skill `skills/dispatching-parallel-agents/SKILL.md` must NOT register as an
286
- * agent named "SKILL" (the `-agents/` substring), which real plugins like
287
- * obra/superpowers ship. See scan.test.ts for the regression cases.
288
- */
289
- export interface SurfaceClassifier {
290
- readonly isSkill: (f: string) => boolean;
291
- readonly isAgent: (f: string) => boolean;
292
- readonly isCommand: (f: string) => boolean;
293
- }
294
- /**
295
- * A compiled `vigiles/hook` artifact runs through the `hook-runtime run-program`
296
- * runtime entrypoint; any other hook command is hand-written (a shell script or
297
- * an inline one-liner) the author maintains directly. The basis for the
298
- * `prefer-compiled-hooks` nudge.
299
- */
300
- export declare function isManagedHookCommand(command: string): boolean;
301
- /** The `prefer-compiled-hooks` recommendation message (shared by `lint` + `scan`). */
302
- export declare function preferCompiledHooksMessage(count: number): string;
303
280
  /** Scan a plugin/repo directory and report its surfaces + structural issues. */
304
281
  export declare function scanPlugin(dir: string, layout?: PluginLayout, dialect?: HarnessDialect, opts?: {
305
282
  sharedDirs?: readonly string[];