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.
- package/dist/adapters/claude-code/agent-runtime.d.ts +2 -19
- package/dist/adapters/claude-code/agent-runtime.js +5 -30
- package/dist/adapters/claude-code/agent-tools.d.ts +20 -0
- package/dist/adapters/claude-code/agent-tools.js +40 -0
- package/dist/audit-report.d.ts +1 -1
- package/dist/audit-report.template.html +15 -15
- package/dist/audit-score.d.ts +1 -1
- package/dist/audit-score.js +19 -19
- package/dist/audit-verdict.d.ts +1 -1
- package/dist/audit-verdict.js +3 -3
- package/dist/core/assert-never.d.ts +9 -0
- package/dist/core/assert-never.js +14 -0
- package/dist/core/description-overlap.js +2 -2
- package/dist/core/effects.js +3 -3
- package/dist/core/hash.d.ts +1 -2
- package/dist/core/hash.js +6 -4
- package/dist/core/hook-block-ineffective.d.ts +55 -6
- package/dist/core/hook-block-ineffective.js +9 -14
- package/dist/core/mcp-contract-message.d.ts +22 -0
- package/dist/core/mcp-contract-message.js +29 -0
- package/dist/core/mcp.d.ts +4 -12
- package/dist/core/mcp.js +3 -14
- package/dist/core/ncd.d.ts +12 -0
- package/dist/core/ncd.js +50 -0
- package/dist/core/plugin-dir-layout.d.ts +5 -5
- package/dist/core/plugin-dir-layout.js +10 -22
- package/dist/core/proofs.d.ts +2 -11
- package/dist/core/proofs.js +4 -39
- package/dist/core/skill-resources.d.ts +3 -3
- package/dist/core/skill-resources.js +9 -8
- package/dist/leaderboard.d.ts +2 -51
- package/dist/leaderboard.js +20 -225
- package/dist/optimize.d.ts +1 -1
- package/dist/optimize.js +3 -3
- package/dist/posix-path.d.ts +40 -0
- package/dist/posix-path.js +293 -0
- package/dist/scan-core.d.ts +154 -0
- package/dist/scan-core.js +690 -0
- package/dist/scan-files.d.ts +28 -0
- package/dist/scan-files.js +489 -0
- package/dist/scan.d.ts +11 -34
- package/dist/scan.js +55 -668
- package/dist/score-core.d.ts +73 -0
- package/dist/score-core.js +226 -0
- package/dist/test-coverage-files.d.ts +11 -0
- package/dist/test-coverage-files.js +208 -0
- 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 {
|
|
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 {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
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 {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
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 {
|
|
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 {
|
|
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[];
|