prehello 0.0.0-stage → 0.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.
- package/CHANGELOG.md +40 -0
- package/LICENSE +21 -0
- package/README.md +515 -2
- package/THIRD_PARTY_NOTICES.md +33 -0
- package/assets/fonts/Inter-ExtraBold.subset.ttf +0 -0
- package/assets/fonts/Inter-Regular.subset.ttf +0 -0
- package/assets/fonts/Inter-SemiBold.subset.ttf +0 -0
- package/assets/fonts/OFL.txt +94 -0
- package/dist/adapters/claude-code.js +480 -0
- package/dist/adapters/codex.js +184 -0
- package/dist/adapters/common.js +157 -0
- package/dist/adapters/cursor.js +257 -0
- package/dist/adapters/skills.js +155 -0
- package/dist/adapters/types.js +2 -0
- package/dist/analyze/findings.js +82 -0
- package/dist/analyze/grade.js +55 -0
- package/dist/analyze/savings.js +111 -0
- package/dist/analyze/summary.js +57 -0
- package/dist/analyze/totals.js +21 -0
- package/dist/cli.js +454 -0
- package/dist/context.js +331 -0
- package/dist/fixtures.js +52 -0
- package/dist/fs-safe.js +222 -0
- package/dist/mcp/catalog.js +44 -0
- package/dist/mcp/configs.js +42 -0
- package/dist/model.js +11 -0
- package/dist/platform.js +115 -0
- package/dist/redact.js +39 -0
- package/dist/render/card.js +305 -0
- package/dist/render/fix.js +22 -0
- package/dist/render/json.js +97 -0
- package/dist/render/markdown.js +182 -0
- package/dist/render/png.js +162 -0
- package/dist/render/sarif.js +472 -0
- package/dist/render/terminal.js +329 -0
- package/dist/render/treemap.js +58 -0
- package/dist/safe-write.js +120 -0
- package/dist/scan.js +104 -0
- package/dist/spec.js +27 -0
- package/dist/text/comments.js +60 -0
- package/dist/text/frontmatter.js +90 -0
- package/dist/text/imports.js +81 -0
- package/dist/tokens/counter.js +41 -0
- package/dist/tokens/families.js +47 -0
- package/dist/util/fmt.js +102 -0
- package/dist/util/secrets.js +35 -0
- package/package.json +66 -4
- package/spec/claude-code.json +22 -0
- package/spec/codex.json +14 -0
- package/spec/cursor.json +15 -0
- package/spec/mcp-weights.json +62 -0
package/dist/context.js
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.Ctx = exports.WALK_CANDIDATES = void 0;
|
|
7
|
+
exports.readFailWhy = readFailWhy;
|
|
8
|
+
exports.lineCount = lineCount;
|
|
9
|
+
exports.shingles = shingles;
|
|
10
|
+
exports.jaccard = jaccard;
|
|
11
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
12
|
+
const families_1 = require("./tokens/families");
|
|
13
|
+
const fs_safe_1 = require("./fs-safe");
|
|
14
|
+
const redact_1 = require("./redact");
|
|
15
|
+
const fmt_1 = require("./util/fmt");
|
|
16
|
+
const fixtures_1 = require("./fixtures");
|
|
17
|
+
/** Entry budget for looking inside skipped fixture folders (only to count how many held agent files; nothing from them is reported). */
|
|
18
|
+
const FIXTURE_PROBE_ENTRIES = 20_000;
|
|
19
|
+
const FIXTURE_PROBE_TOTAL = 50_000;
|
|
20
|
+
/** Human wording for a failed read; `verb` is "skipped" (rules, skills) or "not read" (instruction files). */
|
|
21
|
+
function readFailWhy(reason, verb) {
|
|
22
|
+
switch (reason) {
|
|
23
|
+
case "too-large": return `larger than 4 MiB (Claude Code's own limit for startup files), ${verb}`;
|
|
24
|
+
case "binary": return `binary content, ${verb}`;
|
|
25
|
+
case "utf16": return `saved as UTF-16 (Windows PowerShell "> file" does this); coding agents read UTF-8, so it is ${verb}. Re-save it as UTF-8`;
|
|
26
|
+
case "symlink-escape": return `symlink points outside the project, ${verb === "skipped" ? "skipped" : "not followed"}`;
|
|
27
|
+
case "not-file": return `not a regular file, ${verb}`;
|
|
28
|
+
case "budget": return `${verb}: scan budget reached`;
|
|
29
|
+
default: return reason === "unreadable" ? `unreadable, ${verb}` : `${reason}, ${verb}`;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Names (files) and "dir/" entries looked for in every directory of the bounded project walk. */
|
|
33
|
+
exports.WALK_CANDIDATES = [
|
|
34
|
+
"CLAUDE.md", "CLAUDE.local.md", ".claude/CLAUDE.md", "AGENTS.md", "AGENTS.override.md",
|
|
35
|
+
".cursor/rules/", ".cursor/skills/", ".agents/skills/", ".claude/skills/",
|
|
36
|
+
];
|
|
37
|
+
class Ctx {
|
|
38
|
+
opts;
|
|
39
|
+
counter;
|
|
40
|
+
warnings = [];
|
|
41
|
+
excluded = [];
|
|
42
|
+
findings = [];
|
|
43
|
+
docs = new Map(); // realpath -> info (for F2)
|
|
44
|
+
gitRoot;
|
|
45
|
+
seq = 0;
|
|
46
|
+
readCache = new Map();
|
|
47
|
+
findingKeys = new Set();
|
|
48
|
+
countCache = new Map();
|
|
49
|
+
walkCache;
|
|
50
|
+
/** Fixture folders skipped by the project walk that held agent files (counted, never read). */
|
|
51
|
+
fixturesSkipped = 0;
|
|
52
|
+
t0;
|
|
53
|
+
bytesRead = 0;
|
|
54
|
+
/** Set when a whole-scan budget (bytes or time) stopped reading; totals are then lower bounds. */
|
|
55
|
+
budgetHit;
|
|
56
|
+
budgetSkipped = 0;
|
|
57
|
+
constructor(opts, counter) {
|
|
58
|
+
this.opts = opts;
|
|
59
|
+
this.counter = counter;
|
|
60
|
+
this.gitRoot = (0, fs_safe_1.findGitRoot)(opts.cwd, opts.ceiling);
|
|
61
|
+
this.t0 = (opts.clock ?? Date.now)();
|
|
62
|
+
}
|
|
63
|
+
get maxScanBytes() {
|
|
64
|
+
return this.opts.maxScanBytes ?? fs_safe_1.DEFAULT_MAX_SCAN_BYTES;
|
|
65
|
+
}
|
|
66
|
+
get maxScanMs() {
|
|
67
|
+
return this.opts.maxScanMs ?? fs_safe_1.DEFAULT_MAX_SCAN_MS;
|
|
68
|
+
}
|
|
69
|
+
timeUp() {
|
|
70
|
+
if (this.budgetHit === "time")
|
|
71
|
+
return true;
|
|
72
|
+
if ((this.opts.clock ?? Date.now)() - this.t0 > this.maxScanMs) {
|
|
73
|
+
this.budgetHit = this.budgetHit ?? "time";
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
/** Is cwd a project folder (as opposed to a home directory, a drive root or a downloads folder)? .git, or an agent file at its top level. Never true for the home folder. */
|
|
79
|
+
isProjectDir() {
|
|
80
|
+
if (this.cwd === this.home)
|
|
81
|
+
return false;
|
|
82
|
+
if (this.gitRoot && this.gitRoot !== this.home)
|
|
83
|
+
return true;
|
|
84
|
+
const top = ["CLAUDE.md", "AGENTS.md", "CLAUDE.local.md", ".cursorrules", ".mcp.json"];
|
|
85
|
+
if (top.some((f) => (0, fs_safe_1.isFile)(node_path_1.default.join(this.cwd, f))))
|
|
86
|
+
return true;
|
|
87
|
+
return [".claude", ".cursor", ".codex", ".agents"].some((d) => (0, fs_safe_1.isDir)(node_path_1.default.join(this.cwd, d)));
|
|
88
|
+
}
|
|
89
|
+
get cwd() {
|
|
90
|
+
return this.opts.cwd;
|
|
91
|
+
}
|
|
92
|
+
get home() {
|
|
93
|
+
return this.opts.home;
|
|
94
|
+
}
|
|
95
|
+
profile(agent) {
|
|
96
|
+
return (0, families_1.profileFor)(agent, this.counter.method, this.opts.claudeProfile);
|
|
97
|
+
}
|
|
98
|
+
display(p) {
|
|
99
|
+
return (0, redact_1.displayPath)(p, { cwd: this.cwd, home: this.home });
|
|
100
|
+
}
|
|
101
|
+
warn(msg) {
|
|
102
|
+
if (!this.warnings.includes(msg))
|
|
103
|
+
this.warnings.push(msg);
|
|
104
|
+
}
|
|
105
|
+
exclude(agent, p, reason) {
|
|
106
|
+
this.excluded.push({ agent, path: p, reason });
|
|
107
|
+
}
|
|
108
|
+
/** A machine-wide absolute location, re-rooted under --system-root when given. */
|
|
109
|
+
sys(abs) {
|
|
110
|
+
const root = this.opts.systemRoot;
|
|
111
|
+
if (!root)
|
|
112
|
+
return abs;
|
|
113
|
+
const rel = abs.replace(/^[A-Za-z]:/, "").replace(/\\/g, "/").replace(/^\/+/, "");
|
|
114
|
+
return node_path_1.default.join(root, ...rel.split("/"));
|
|
115
|
+
}
|
|
116
|
+
/** Directories from filesystem root (or ceiling) down to cwd, root-first. */
|
|
117
|
+
ancestorDirs() {
|
|
118
|
+
const dirs = [];
|
|
119
|
+
let dir = this.cwd;
|
|
120
|
+
for (;;) {
|
|
121
|
+
dirs.unshift(dir);
|
|
122
|
+
if (this.opts.ceiling && dir === this.opts.ceiling)
|
|
123
|
+
break;
|
|
124
|
+
const parent = node_path_1.default.dirname(dir);
|
|
125
|
+
if (parent === dir)
|
|
126
|
+
break;
|
|
127
|
+
dir = parent;
|
|
128
|
+
}
|
|
129
|
+
return dirs;
|
|
130
|
+
}
|
|
131
|
+
/** jail for project-tree files: the git root if the file is inside it, else its own directory. */
|
|
132
|
+
jailFor(dir) {
|
|
133
|
+
return this.gitRoot && (0, fs_safe_1.isInside)(dir, this.gitRoot) ? this.gitRoot : dir;
|
|
134
|
+
}
|
|
135
|
+
/** Skip fixture folders? Not when asked not to, and not when the scan root is itself inside one (scanning a fixture on purpose). */
|
|
136
|
+
skipFixtures() {
|
|
137
|
+
return !this.opts.includeFixtures && !(0, fixtures_1.rootIsInsideFixtures)(this.cwd);
|
|
138
|
+
}
|
|
139
|
+
walk() {
|
|
140
|
+
if (!this.walkCache) {
|
|
141
|
+
const skip = this.skipFixtures();
|
|
142
|
+
const w = (0, fs_safe_1.walkForFiles)(this.cwd, exports.WALK_CANDIDATES, { timeUp: () => this.timeUp(), skipDir: skip ? (0, fixtures_1.makeFixtureSkipper)(this.cwd) : undefined });
|
|
143
|
+
const notAProject = !this.isProjectDir();
|
|
144
|
+
if ((w.truncated || w.timedOut) && notAProject) {
|
|
145
|
+
// Walking a home folder, a drive root or ~/Downloads: whatever CLAUDE.md/AGENTS.md files happen to sit in the first
|
|
146
|
+
// 5,000 entries are random, not "your project". Reporting them would print junk token totals, so drop them.
|
|
147
|
+
this.walkCache = { ...w, hits: [], notAProject, skipped: [] };
|
|
148
|
+
this.warn(`${this.display(this.cwd) === "." ? "this folder" : this.display(this.cwd)} does not look like a project (no .git, no agent files at its top level) and holds more than ${fs_safe_1.MAX_WALK_ENTRIES} entries, so nested agent files were NOT scanned (they would be arbitrary). Run prehello on a project folder: prehello ~/code/my-project`);
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
this.walkCache = { ...w, notAProject };
|
|
152
|
+
if (w.truncated)
|
|
153
|
+
this.warn(`project walk stopped at ${fs_safe_1.MAX_WALK_ENTRIES} entries; nested files deeper in the tree may be missed`);
|
|
154
|
+
if (w.timedOut)
|
|
155
|
+
this.warn("project walk stopped at the time budget; nested files may be missed");
|
|
156
|
+
this.fixturesSkipped = this.probeFixtures(w.skipped);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return this.walkCache;
|
|
160
|
+
}
|
|
161
|
+
/** How many skipped fixture folders hold agent files? A bounded, read-only look (names only); folders it cannot settle are not counted. */
|
|
162
|
+
probeFixtures(dirs) {
|
|
163
|
+
let left = FIXTURE_PROBE_TOTAL;
|
|
164
|
+
let n = 0;
|
|
165
|
+
for (const d of dirs) {
|
|
166
|
+
if (left <= 0 || this.timeUp())
|
|
167
|
+
break;
|
|
168
|
+
const p = (0, fs_safe_1.walkForFiles)(d, exports.WALK_CANDIDATES, { timeUp: () => this.timeUp(), includeRoot: true, stopAtFirstHit: true, maxEntries: Math.min(left, FIXTURE_PROBE_ENTRIES) });
|
|
169
|
+
left -= p.entries;
|
|
170
|
+
if (p.hits.length)
|
|
171
|
+
n++;
|
|
172
|
+
}
|
|
173
|
+
return n;
|
|
174
|
+
}
|
|
175
|
+
/** Cached, defensive read. Missing = silent; other failures become warnings (-> exit code 3). */
|
|
176
|
+
read(p, opts = {}) {
|
|
177
|
+
const key = `${p}|${opts.jail ?? ""}|${opts.maxBytes ?? ""}`;
|
|
178
|
+
let r = this.readCache.get(key);
|
|
179
|
+
if (!r) {
|
|
180
|
+
if (this.timeUp() || this.bytesRead >= this.maxScanBytes) {
|
|
181
|
+
// Budget spent: stop touching the disk. Missing files stay silent, everything else counts as skipped.
|
|
182
|
+
if (this.bytesRead >= this.maxScanBytes)
|
|
183
|
+
this.budgetHit = this.budgetHit ?? "bytes";
|
|
184
|
+
r = (0, fs_safe_1.readTextSafe)(p, { ...opts, budgetBytes: 0 });
|
|
185
|
+
if (!r.ok && r.reason === "budget")
|
|
186
|
+
this.budgetSkipped++;
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
r = (0, fs_safe_1.readTextSafe)(p, { ...opts, budgetBytes: this.maxScanBytes - this.bytesRead });
|
|
190
|
+
if (r.ok)
|
|
191
|
+
this.bytesRead += r.bytes;
|
|
192
|
+
else if (r.reason === "budget") {
|
|
193
|
+
this.budgetHit = this.budgetHit ?? "bytes";
|
|
194
|
+
this.budgetSkipped++;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
this.readCache.set(key, r);
|
|
198
|
+
if (!r.ok && !opts.quiet && r.reason !== "missing" && r.reason !== "budget")
|
|
199
|
+
this.warn(`${this.display(p)}: ${readFailWhy(r.reason, "skipped")}`);
|
|
200
|
+
}
|
|
201
|
+
return r;
|
|
202
|
+
}
|
|
203
|
+
/** Called once at the end of a scan: one summary warning when a whole-scan budget cut the scan short. */
|
|
204
|
+
budgetSummary() {
|
|
205
|
+
if (!this.budgetHit)
|
|
206
|
+
return undefined;
|
|
207
|
+
const what = this.budgetHit === "time" ? `${Math.round(this.maxScanMs / 1000)} s time budget` : `${Math.round(this.maxScanBytes / 1048576)} MiB read budget`;
|
|
208
|
+
return `scan stopped at its ${what}${this.budgetSkipped ? `; ${this.budgetSkipped} file${this.budgetSkipped > 1 ? "s were" : " was"} not read` : ""}. Results are PARTIAL: totals are lower bounds. Scan a smaller folder, or raise the budget with --scan-budget-mb / --scan-budget-ms.`;
|
|
209
|
+
}
|
|
210
|
+
readJson(p) {
|
|
211
|
+
const r = this.read(p, { maxBytes: 64 * 1024 * 1024 });
|
|
212
|
+
if (!r.ok)
|
|
213
|
+
return undefined;
|
|
214
|
+
try {
|
|
215
|
+
return JSON.parse(r.text);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
this.warn(`${this.display(p)}: could not parse as JSON, skipped`);
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
count(key, text) {
|
|
223
|
+
let n = this.countCache.get(key);
|
|
224
|
+
if (n === undefined) {
|
|
225
|
+
n = this.counter.count(text);
|
|
226
|
+
this.countCache.set(key, n);
|
|
227
|
+
}
|
|
228
|
+
return n;
|
|
229
|
+
}
|
|
230
|
+
addFinding(f, dedupeKey) {
|
|
231
|
+
const k = dedupeKey ?? `${f.id}|${f.agent}|${f.paths.join(",")}|${f.message}`;
|
|
232
|
+
if (this.findingKeys.has(k))
|
|
233
|
+
return;
|
|
234
|
+
this.findingKeys.add(k);
|
|
235
|
+
this.findings.push(f);
|
|
236
|
+
}
|
|
237
|
+
makeItem(a) {
|
|
238
|
+
const prof = this.profile(a.agent);
|
|
239
|
+
return {
|
|
240
|
+
id: `${a.agent}:${++this.seq}`,
|
|
241
|
+
agent: a.agent,
|
|
242
|
+
path: a.path,
|
|
243
|
+
kind: a.kind,
|
|
244
|
+
scope: a.scope,
|
|
245
|
+
loadMode: a.loadMode,
|
|
246
|
+
tokens: a.raw === null ? null : prof.apply(a.raw),
|
|
247
|
+
...(a.raw === null ? { unmeasuredReason: a.unmeasuredReason ?? "not measurable" } : {}),
|
|
248
|
+
chars: a.chars ?? 0,
|
|
249
|
+
lines: a.lines ?? 0,
|
|
250
|
+
confidence: a.confidence,
|
|
251
|
+
evidence: a.evidence,
|
|
252
|
+
notes: a.notes ?? [],
|
|
253
|
+
cardLabel: a.cardLabel,
|
|
254
|
+
label: (0, fmt_1.sanitize)(a.label, 100),
|
|
255
|
+
...(a.detail ? { detail: a.detail } : {}),
|
|
256
|
+
...(a.group ? { group: a.group, groupLabel: a.groupLabel ?? a.group } : {}),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
/** Per-document analysis shared by all agents: secrets (F9), guideline length (F1), shingles for duplicates (F2). */
|
|
260
|
+
analyzeDoc(agent, filePath, rawText, processed, rawTokens, guidelineLines) {
|
|
261
|
+
const real = (0, fs_safe_1.realpathOrSelf)(filePath);
|
|
262
|
+
const lines = lineCount(rawText);
|
|
263
|
+
const secretLines = (0, redact_1.findSecretLines)(rawText);
|
|
264
|
+
if (secretLines.length) {
|
|
265
|
+
this.addFinding({
|
|
266
|
+
id: "F9",
|
|
267
|
+
severity: "warn",
|
|
268
|
+
agent: "all",
|
|
269
|
+
message: `Possible secret on line${secretLines.length > 1 ? "s" : ""} ${secretLines.slice(0, 5).join(", ")}${secretLines.length > 5 ? ", …" : ""} of ${this.display(filePath)} (value not shown)`,
|
|
270
|
+
paths: [filePath],
|
|
271
|
+
confidence: "medium",
|
|
272
|
+
data: { lines: secretLines.slice(0, 50) },
|
|
273
|
+
fix: "Remove the secret from the instruction file and rotate it; agents send this file to a model provider.",
|
|
274
|
+
}, `F9|${real}`);
|
|
275
|
+
}
|
|
276
|
+
if (guidelineLines && lines > guidelineLines) {
|
|
277
|
+
this.addFinding({
|
|
278
|
+
id: "F1",
|
|
279
|
+
severity: "info",
|
|
280
|
+
agent,
|
|
281
|
+
message: `${this.display(filePath)} is ${lines} lines (documented guideline: under ${guidelineLines}); longer files can reduce adherence`,
|
|
282
|
+
paths: [filePath],
|
|
283
|
+
confidence: "medium",
|
|
284
|
+
data: { fileLines: lines, guidelineLines },
|
|
285
|
+
fix: "Split into scoped rules or move reference material out of the always-loaded file.",
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
let info = this.docs.get(real);
|
|
289
|
+
if (!info) {
|
|
290
|
+
info = { path: filePath, tokens: rawTokens, shingles: shingles(processed), agents: new Set() };
|
|
291
|
+
this.docs.set(real, info);
|
|
292
|
+
}
|
|
293
|
+
info.agents.add(agent);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
exports.Ctx = Ctx;
|
|
297
|
+
function lineCount(text) {
|
|
298
|
+
if (!text)
|
|
299
|
+
return 0;
|
|
300
|
+
return text.replace(/\n$/, "").split("\n").length;
|
|
301
|
+
}
|
|
302
|
+
function fnv(s) {
|
|
303
|
+
let h = 0x811c9dc5;
|
|
304
|
+
for (let i = 0; i < s.length; i++) {
|
|
305
|
+
h ^= s.charCodeAt(i);
|
|
306
|
+
h = Math.imul(h, 0x01000193);
|
|
307
|
+
}
|
|
308
|
+
return h >>> 0;
|
|
309
|
+
}
|
|
310
|
+
function shingles(text, k = 5) {
|
|
311
|
+
const words = text.toLowerCase().split(/\s+/).filter(Boolean);
|
|
312
|
+
const out = new Set();
|
|
313
|
+
if (words.length < k) {
|
|
314
|
+
if (words.length)
|
|
315
|
+
out.add(fnv(words.join(" ")));
|
|
316
|
+
return out;
|
|
317
|
+
}
|
|
318
|
+
for (let i = 0; i + k <= words.length; i++)
|
|
319
|
+
out.add(fnv(words.slice(i, i + k).join(" ")));
|
|
320
|
+
return out;
|
|
321
|
+
}
|
|
322
|
+
function jaccard(a, b) {
|
|
323
|
+
if (!a.size || !b.size)
|
|
324
|
+
return 0;
|
|
325
|
+
let inter = 0;
|
|
326
|
+
const [s, l] = a.size < b.size ? [a, b] : [b, a];
|
|
327
|
+
for (const x of s)
|
|
328
|
+
if (l.has(x))
|
|
329
|
+
inter++;
|
|
330
|
+
return inter / (a.size + b.size - inter);
|
|
331
|
+
}
|
package/dist/fixtures.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Test-fixture folders. A repository that tests an agent tool (prehello itself, linters for AGENTS.md, skill validators, ...) keeps
|
|
4
|
+
* sample AGENTS.md / CLAUDE.md / .cursor/rules / skills under test/fixtures/**. Those files are not loaded by any agent, so the
|
|
5
|
+
* project walk skips such folders by default (`--include-fixtures` brings them back).
|
|
6
|
+
*
|
|
7
|
+
* Pure functions over path segments; callers split paths on BOTH separators so Windows paths work.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.splitSegments = splitSegments;
|
|
11
|
+
exports.isFixtureSegment = isFixtureSegment;
|
|
12
|
+
exports.rootIsInsideFixtures = rootIsInsideFixtures;
|
|
13
|
+
exports.makeFixtureSkipper = makeFixtureSkipper;
|
|
14
|
+
/** Names that mean "test data" wherever they appear below the scan root. */
|
|
15
|
+
const FIXTURE_NAMES = new Set(["fixtures", "__fixtures__", "fixture", "testdata", "test-data", "__mocks__", "__snapshots__"]);
|
|
16
|
+
/** Names that are too generic on their own: only fixtures when they sit under one of TEST_PARENTS. */
|
|
17
|
+
const GENERIC_NAMES = new Set(["snapshots", "golden", "goldens"]);
|
|
18
|
+
const TEST_PARENTS = new Set(["test", "tests", "__tests__", "spec", "specs", "e2e", "cypress", "testdata", "test-data"]);
|
|
19
|
+
function splitSegments(p) {
|
|
20
|
+
return p.split(/[\\/]+/).filter(Boolean);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Is segment `i` of `segs` a fixture folder? `segs` = the segments from some root down to the folder, folder last at `i`.
|
|
24
|
+
* Comparison is case-insensitive (macOS and Windows filesystems are).
|
|
25
|
+
*/
|
|
26
|
+
function isFixtureSegment(segs, i) {
|
|
27
|
+
const name = segs[i].toLowerCase();
|
|
28
|
+
if (FIXTURE_NAMES.has(name))
|
|
29
|
+
return true;
|
|
30
|
+
if (!GENERIC_NAMES.has(name))
|
|
31
|
+
return false;
|
|
32
|
+
for (let j = 0; j < i; j++)
|
|
33
|
+
if (TEST_PARENTS.has(segs[j].toLowerCase()))
|
|
34
|
+
return true;
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
/** Is the scan root itself inside a fixture folder (so the user is deliberately scanning a fixture)? Then nothing is skipped. */
|
|
38
|
+
function rootIsInsideFixtures(cwd) {
|
|
39
|
+
const segs = splitSegments(cwd);
|
|
40
|
+
for (let i = 0; i < segs.length; i++)
|
|
41
|
+
if (isFixtureSegment(segs, i))
|
|
42
|
+
return true;
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Directory filter for the walk below `cwd`. `rel` = segments below the root (folder last). The root's own last segment counts as a
|
|
47
|
+
* possible test parent for generic names (scanning `repo/test` should still skip `test/golden`).
|
|
48
|
+
*/
|
|
49
|
+
function makeFixtureSkipper(cwd) {
|
|
50
|
+
const base = splitSegments(cwd).slice(-1);
|
|
51
|
+
return (_name, rel) => isFixtureSegment([...base, ...rel], base.length + rel.length - 1);
|
|
52
|
+
}
|
package/dist/fs-safe.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.MAX_WALK_DEPTH = exports.MAX_WALK_ENTRIES = exports.DEFAULT_MAX_SCAN_MS = exports.DEFAULT_MAX_SCAN_BYTES = exports.MAX_FILE_BYTES = void 0;
|
|
7
|
+
exports.realpathOrSelf = realpathOrSelf;
|
|
8
|
+
exports.isInside = isInside;
|
|
9
|
+
exports.exists = exists;
|
|
10
|
+
exports.isDir = isDir;
|
|
11
|
+
exports.isFile = isFile;
|
|
12
|
+
exports.listDir = listDir;
|
|
13
|
+
exports.readTextSafe = readTextSafe;
|
|
14
|
+
exports.walkForFiles = walkForFiles;
|
|
15
|
+
exports.findGitRoot = findGitRoot;
|
|
16
|
+
exports.onPath = onPath;
|
|
17
|
+
/**
|
|
18
|
+
* The ONLY module that touches the filesystem for scanning. It exposes read-only operations;
|
|
19
|
+
* nothing in the scan path may call a write API (enforced by test/safety.test.ts).
|
|
20
|
+
*/
|
|
21
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
22
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
23
|
+
const platform_1 = require("./platform");
|
|
24
|
+
/**
|
|
25
|
+
* Per-file read cap. 4 MiB is Claude Code's documented limit for startup CLAUDE.md files ("Claude Code skips a file over
|
|
26
|
+
* 4 MiB and loads none of it"), so for Claude Code a skipped file here is a file the agent skips too. For other agents it is
|
|
27
|
+
* a generic safety cap (Codex has its own, much smaller, project_doc_max_bytes that the Codex adapter models separately).
|
|
28
|
+
*/
|
|
29
|
+
exports.MAX_FILE_BYTES = 4 * 1024 * 1024;
|
|
30
|
+
/** Whole-scan budgets (a safety net against pathological trees, not a feature knob). Bytes are deterministic; the clock is not. */
|
|
31
|
+
exports.DEFAULT_MAX_SCAN_BYTES = 64 * 1024 * 1024;
|
|
32
|
+
exports.DEFAULT_MAX_SCAN_MS = 20_000;
|
|
33
|
+
exports.MAX_WALK_ENTRIES = 5000;
|
|
34
|
+
exports.MAX_WALK_DEPTH = 8;
|
|
35
|
+
const IGNORE_DIRS = new Set([
|
|
36
|
+
"node_modules", ".git", "dist", "build", "out", "target", ".next", ".nuxt", ".venv", "venv", "__pycache__",
|
|
37
|
+
"vendor", ".cache", "coverage", ".gradle", ".idea", ".tox", ".turbo",
|
|
38
|
+
]);
|
|
39
|
+
/**
|
|
40
|
+
* Canonical path. Uses the OS-native realpath where available so that on case-insensitive filesystems
|
|
41
|
+
* (macOS APFS default, Windows NTFS) `claude.md` and `CLAUDE.md`, or a `/var` -> `/private/var` alias,
|
|
42
|
+
* resolve to ONE path and are never counted twice.
|
|
43
|
+
*/
|
|
44
|
+
function realpathOrSelf(p) {
|
|
45
|
+
try {
|
|
46
|
+
return (node_fs_1.default.realpathSync.native ?? node_fs_1.default.realpathSync)(p);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
try {
|
|
50
|
+
return node_fs_1.default.realpathSync(p);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return node_path_1.default.resolve(p);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function isInside(child, parent) {
|
|
58
|
+
return (0, platform_1.isInsideWith)(node_path_1.default, child, parent);
|
|
59
|
+
}
|
|
60
|
+
function exists(p) {
|
|
61
|
+
try {
|
|
62
|
+
node_fs_1.default.statSync(p);
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function isDir(p) {
|
|
70
|
+
try {
|
|
71
|
+
return node_fs_1.default.statSync(p).isDirectory();
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function isFile(p) {
|
|
78
|
+
try {
|
|
79
|
+
return node_fs_1.default.statSync(p).isFile();
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** Sorted directory entries, or [] if unreadable. */
|
|
86
|
+
function listDir(p) {
|
|
87
|
+
try {
|
|
88
|
+
return node_fs_1.default.readdirSync(p, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Read a text file defensively. `jail`: if given, the file's real path must be inside it
|
|
96
|
+
* (blocks symlinks in untrusted project trees that point at e.g. ~/.ssh).
|
|
97
|
+
*/
|
|
98
|
+
function readTextSafe(p, opts = {}) {
|
|
99
|
+
let st;
|
|
100
|
+
try {
|
|
101
|
+
st = node_fs_1.default.statSync(p);
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
const code = e.code;
|
|
105
|
+
return code === "ENOENT" || code === "ENOTDIR" ? { ok: false, reason: "missing" } : { ok: false, reason: "unreadable", detail: String(code) };
|
|
106
|
+
}
|
|
107
|
+
if (!st.isFile())
|
|
108
|
+
return { ok: false, reason: "not-file" };
|
|
109
|
+
const real = realpathOrSelf(p);
|
|
110
|
+
if (opts.jail && !isInside(real, realpathOrSelf(opts.jail)))
|
|
111
|
+
return { ok: false, reason: "symlink-escape" };
|
|
112
|
+
if (st.size > (opts.maxBytes ?? exports.MAX_FILE_BYTES))
|
|
113
|
+
return { ok: false, reason: "too-large", detail: `${st.size} bytes` };
|
|
114
|
+
if (opts.budgetBytes !== undefined && st.size > opts.budgetBytes)
|
|
115
|
+
return { ok: false, reason: "budget", detail: `${st.size} bytes` };
|
|
116
|
+
let buf;
|
|
117
|
+
try {
|
|
118
|
+
buf = node_fs_1.default.readFileSync(p);
|
|
119
|
+
}
|
|
120
|
+
catch (e) {
|
|
121
|
+
return { ok: false, reason: "unreadable", detail: String(e.code) };
|
|
122
|
+
}
|
|
123
|
+
// Windows PowerShell 5 writes UTF-16 with a BOM for `echo x > AGENTS.md`; agents read these files as UTF-8, so say so instead of "binary".
|
|
124
|
+
if (buf.length >= 2 && ((buf[0] === 0xff && buf[1] === 0xfe) || (buf[0] === 0xfe && buf[1] === 0xff)))
|
|
125
|
+
return { ok: false, reason: "utf16" };
|
|
126
|
+
if (buf.subarray(0, 8000).includes(0))
|
|
127
|
+
return { ok: false, reason: "binary" };
|
|
128
|
+
let text = buf.toString("utf8");
|
|
129
|
+
if (text.charCodeAt(0) === 0xfeff)
|
|
130
|
+
text = text.slice(1);
|
|
131
|
+
text = text.replace(/\r\n?/g, "\n");
|
|
132
|
+
return { ok: true, text, bytes: buf.length, realPath: real };
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Bounded project walk (no symlink following, ignores build dirs, dot-dirs, ≤5k entries, depth ≤ 8).
|
|
136
|
+
* Returns directories (other than root) that contain any of the candidate relative paths.
|
|
137
|
+
*/
|
|
138
|
+
function walkForFiles(root, candidates, opts = {}) {
|
|
139
|
+
const hits = [];
|
|
140
|
+
const skipped = [];
|
|
141
|
+
const maxEntries = opts.maxEntries ?? exports.MAX_WALK_ENTRIES;
|
|
142
|
+
let seen = 0;
|
|
143
|
+
let truncated = false;
|
|
144
|
+
let timedOut = false;
|
|
145
|
+
const visit = (dir, depth, rel) => {
|
|
146
|
+
if (truncated || timedOut || (opts.stopAtFirstHit && hits.length))
|
|
147
|
+
return;
|
|
148
|
+
if (opts.timeUp?.()) {
|
|
149
|
+
timedOut = true;
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const entries = listDir(dir);
|
|
153
|
+
seen += entries.length;
|
|
154
|
+
if (seen > maxEntries) {
|
|
155
|
+
truncated = true;
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (dir !== root || opts.includeRoot) {
|
|
159
|
+
// a trailing "/" in a candidate means "a directory with this relative path"
|
|
160
|
+
const present = candidates.filter((c) => (c.endsWith("/") ? isDir(node_path_1.default.join(dir, c)) : isFile(node_path_1.default.join(dir, c))) && !isSymlink(node_path_1.default.join(dir, c)));
|
|
161
|
+
if (present.length)
|
|
162
|
+
hits.push({ dir, names: present });
|
|
163
|
+
}
|
|
164
|
+
if (depth >= exports.MAX_WALK_DEPTH)
|
|
165
|
+
return;
|
|
166
|
+
for (const e of entries) {
|
|
167
|
+
if (!e.isDirectory() || e.isSymbolicLink())
|
|
168
|
+
continue;
|
|
169
|
+
if (IGNORE_DIRS.has(e.name) || e.name.startsWith("."))
|
|
170
|
+
continue;
|
|
171
|
+
const childRel = [...rel, e.name];
|
|
172
|
+
if (opts.skipDir?.(e.name, childRel)) {
|
|
173
|
+
skipped.push(node_path_1.default.join(dir, e.name));
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
visit(node_path_1.default.join(dir, e.name), depth + 1, childRel);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
visit(root, 0, []);
|
|
180
|
+
return { hits, truncated, timedOut, entries: Math.min(seen, maxEntries), skipped };
|
|
181
|
+
}
|
|
182
|
+
function isSymlink(p) {
|
|
183
|
+
try {
|
|
184
|
+
return node_fs_1.default.lstatSync(p).isSymbolicLink();
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/** Find the nearest ancestor (inclusive) containing `.git`. */
|
|
191
|
+
function findGitRoot(start, ceiling) {
|
|
192
|
+
let dir = start;
|
|
193
|
+
for (;;) {
|
|
194
|
+
if (exists(node_path_1.default.join(dir, ".git")))
|
|
195
|
+
return dir;
|
|
196
|
+
if (ceiling && dir === ceiling)
|
|
197
|
+
return undefined;
|
|
198
|
+
const parent = node_path_1.default.dirname(dir);
|
|
199
|
+
if (parent === dir)
|
|
200
|
+
return undefined;
|
|
201
|
+
dir = parent;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Is an executable named `name` on the given PATH string? (read-only stat calls)
|
|
206
|
+
* On Windows the binary is `claude.exe` / `claude.cmd` (npm shims), never bare `claude`, so PATHEXT is honoured.
|
|
207
|
+
*/
|
|
208
|
+
function onPath(name, pathVar, opts = {}) {
|
|
209
|
+
if (!pathVar)
|
|
210
|
+
return false;
|
|
211
|
+
const win = opts.platform === "win32";
|
|
212
|
+
const delim = opts.platform ? (win ? ";" : ":") : node_path_1.default.delimiter;
|
|
213
|
+
const exts = win ? ["", ...(opts.pathExt || ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean).map((e) => e.toLowerCase()), ".ps1"] : [""];
|
|
214
|
+
for (const d of pathVar.split(delim)) {
|
|
215
|
+
if (!d)
|
|
216
|
+
continue;
|
|
217
|
+
for (const e of exts)
|
|
218
|
+
if (isFile(node_path_1.default.join(d, name + e)))
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.loadCatalog = loadCatalog;
|
|
7
|
+
exports.lookup = lookup;
|
|
8
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
9
|
+
const platform_1 = require("../platform");
|
|
10
|
+
const spec_1 = require("../spec");
|
|
11
|
+
function valid(e) {
|
|
12
|
+
if (typeof e !== "object" || e === null)
|
|
13
|
+
return false;
|
|
14
|
+
const x = e;
|
|
15
|
+
return ((typeof x.name === "string" || typeof x.package === "string") &&
|
|
16
|
+
Number.isFinite(x.nameTokens) && x.nameTokens >= 0 &&
|
|
17
|
+
Number.isFinite(x.schemaTokens) && x.schemaTokens >= 0 &&
|
|
18
|
+
Number.isFinite(x.tools) &&
|
|
19
|
+
typeof x.measuredAt === "string");
|
|
20
|
+
}
|
|
21
|
+
function loadCatalog(extraPath, noBundled = false) {
|
|
22
|
+
const entries = [];
|
|
23
|
+
const warnings = [];
|
|
24
|
+
// user-supplied file first: lookup() returns the first match, so a user's own measurement beats the bundled one
|
|
25
|
+
for (const p of [extraPath, noBundled ? undefined : (0, spec_1.loadBundledCatalogPath)()].filter((x) => !!x)) {
|
|
26
|
+
try {
|
|
27
|
+
const j = JSON.parse((0, platform_1.decodeUserText)(node_fs_1.default.readFileSync(p)));
|
|
28
|
+
for (const e of j.servers ?? []) {
|
|
29
|
+
if (valid(e))
|
|
30
|
+
entries.push(e);
|
|
31
|
+
else
|
|
32
|
+
warnings.push("an MCP catalog entry was malformed and ignored");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
if (p === extraPath)
|
|
37
|
+
warnings.push("MCP catalog file could not be read or parsed; ignored");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { entries, warnings };
|
|
41
|
+
}
|
|
42
|
+
function lookup(entries, def) {
|
|
43
|
+
return entries.find((e) => (e.name && e.name === def.name) || (e.package && def.pkg && e.package === def.pkg));
|
|
44
|
+
}
|