prehello 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +78 -0
- package/LICENSE +21 -0
- package/README.md +171 -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 +275 -0
- package/dist/adapters/gemini-cli.js +214 -0
- package/dist/adapters/github-copilot.js +249 -0
- package/dist/adapters/index.js +16 -0
- package/dist/adapters/skills.js +165 -0
- package/dist/adapters/types.js +2 -0
- package/dist/agents.js +54 -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/calibrate.js +265 -0
- package/dist/cli.js +591 -0
- package/dist/context.js +329 -0
- package/dist/diff.js +182 -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 +8 -0
- package/dist/platform.js +115 -0
- package/dist/redact.js +39 -0
- package/dist/render/card.js +306 -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 +318 -0
- package/dist/render/treemap.js +58 -0
- package/dist/safe-write.js +122 -0
- package/dist/scan.js +98 -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 +86 -0
- package/dist/tokens/counter.js +41 -0
- package/dist/tokens/families.js +79 -0
- package/dist/util/fmt.js +102 -0
- package/dist/util/secrets.js +35 -0
- package/dist/version.js +5 -0
- package/package.json +70 -4
- package/spec/calibration.json +33 -0
- package/spec/claude-code.json +22 -0
- package/spec/codex.json +14 -0
- package/spec/cursor.json +15 -0
- package/spec/gemini-cli.json +17 -0
- package/spec/github-copilot.json +14 -0
- package/spec/mcp-weights.json +62 -0
package/dist/context.js
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
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 agents_1 = require("./agents");
|
|
13
|
+
const families_1 = require("./tokens/families");
|
|
14
|
+
const fs_safe_1 = require("./fs-safe");
|
|
15
|
+
const redact_1 = require("./redact");
|
|
16
|
+
const fmt_1 = require("./util/fmt");
|
|
17
|
+
const fixtures_1 = require("./fixtures");
|
|
18
|
+
/** Entry budget for looking inside skipped fixture folders (only to count how many held agent files; nothing from them is reported). */
|
|
19
|
+
const FIXTURE_PROBE_ENTRIES = 20_000;
|
|
20
|
+
const FIXTURE_PROBE_TOTAL = 50_000;
|
|
21
|
+
/** Human wording for a failed read; `verb` is "skipped" (rules, skills) or "not read" (instruction files). */
|
|
22
|
+
function readFailWhy(reason, verb) {
|
|
23
|
+
switch (reason) {
|
|
24
|
+
case "too-large": return `larger than 4 MiB (Claude Code's own limit for startup files), ${verb}`;
|
|
25
|
+
case "binary": return `binary content, ${verb}`;
|
|
26
|
+
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`;
|
|
27
|
+
case "symlink-escape": return `symlink points outside the project, ${verb === "skipped" ? "skipped" : "not followed"}`;
|
|
28
|
+
case "not-file": return `not a regular file, ${verb}`;
|
|
29
|
+
case "budget": return `${verb}: scan budget reached`;
|
|
30
|
+
default: return reason === "unreadable" ? `unreadable, ${verb}` : `${reason}, ${verb}`;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Names (files) and "dir/" entries looked for in every directory of the bounded project walk. */
|
|
34
|
+
exports.WALK_CANDIDATES = [
|
|
35
|
+
"CLAUDE.md", "CLAUDE.local.md", ".claude/CLAUDE.md", "AGENTS.md", "AGENTS.override.md", "GEMINI.md",
|
|
36
|
+
".cursor/rules/", ".cursor/skills/", ".agents/skills/", ".claude/skills/",
|
|
37
|
+
];
|
|
38
|
+
class Ctx {
|
|
39
|
+
opts;
|
|
40
|
+
counter;
|
|
41
|
+
warnings = [];
|
|
42
|
+
excluded = [];
|
|
43
|
+
findings = [];
|
|
44
|
+
docs = new Map(); // realpath -> info (for F2)
|
|
45
|
+
gitRoot;
|
|
46
|
+
seq = 0;
|
|
47
|
+
readCache = new Map();
|
|
48
|
+
findingKeys = new Set();
|
|
49
|
+
countCache = new Map();
|
|
50
|
+
walkCache;
|
|
51
|
+
/** Fixture folders skipped by the project walk that held agent files (counted, never read). */
|
|
52
|
+
fixturesSkipped = 0;
|
|
53
|
+
t0;
|
|
54
|
+
bytesRead = 0;
|
|
55
|
+
/** Set when a whole-scan budget (bytes or time) stopped reading; totals are then lower bounds. */
|
|
56
|
+
budgetHit;
|
|
57
|
+
budgetSkipped = 0;
|
|
58
|
+
constructor(opts, counter) {
|
|
59
|
+
this.opts = opts;
|
|
60
|
+
this.counter = counter;
|
|
61
|
+
this.gitRoot = (0, fs_safe_1.findGitRoot)(opts.cwd, opts.ceiling);
|
|
62
|
+
this.t0 = (opts.clock ?? Date.now)();
|
|
63
|
+
}
|
|
64
|
+
get maxScanBytes() {
|
|
65
|
+
return this.opts.maxScanBytes ?? fs_safe_1.DEFAULT_MAX_SCAN_BYTES;
|
|
66
|
+
}
|
|
67
|
+
get maxScanMs() {
|
|
68
|
+
return this.opts.maxScanMs ?? fs_safe_1.DEFAULT_MAX_SCAN_MS;
|
|
69
|
+
}
|
|
70
|
+
timeUp() {
|
|
71
|
+
if (this.budgetHit === "time")
|
|
72
|
+
return true;
|
|
73
|
+
if ((this.opts.clock ?? Date.now)() - this.t0 > this.maxScanMs) {
|
|
74
|
+
this.budgetHit = this.budgetHit ?? "time";
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
/** 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. */
|
|
80
|
+
isProjectDir() {
|
|
81
|
+
if (this.cwd === this.home)
|
|
82
|
+
return false;
|
|
83
|
+
if (this.gitRoot && this.gitRoot !== this.home)
|
|
84
|
+
return true;
|
|
85
|
+
return agents_1.AGENTS.flatMap((a) => a.projectMarkers).some((m) => m.endsWith("/") ? (0, fs_safe_1.isDir)(node_path_1.default.join(this.cwd, m.slice(0, -1))) : (0, fs_safe_1.isFile)(node_path_1.default.join(this.cwd, m)));
|
|
86
|
+
}
|
|
87
|
+
get cwd() {
|
|
88
|
+
return this.opts.cwd;
|
|
89
|
+
}
|
|
90
|
+
get home() {
|
|
91
|
+
return this.opts.home;
|
|
92
|
+
}
|
|
93
|
+
profile(agent) {
|
|
94
|
+
return (0, families_1.profileFor)(agent, this.counter.method, this.opts.claudeProfile);
|
|
95
|
+
}
|
|
96
|
+
display(p) {
|
|
97
|
+
return (0, redact_1.displayPath)(p, { cwd: this.cwd, home: this.home });
|
|
98
|
+
}
|
|
99
|
+
warn(msg) {
|
|
100
|
+
if (!this.warnings.includes(msg))
|
|
101
|
+
this.warnings.push(msg);
|
|
102
|
+
}
|
|
103
|
+
exclude(agent, p, reason) {
|
|
104
|
+
this.excluded.push({ agent, path: p, reason });
|
|
105
|
+
}
|
|
106
|
+
/** A machine-wide absolute location, re-rooted under --system-root when given. */
|
|
107
|
+
sys(abs) {
|
|
108
|
+
const root = this.opts.systemRoot;
|
|
109
|
+
if (!root)
|
|
110
|
+
return abs;
|
|
111
|
+
const rel = abs.replace(/^[A-Za-z]:/, "").replace(/\\/g, "/").replace(/^\/+/, "");
|
|
112
|
+
return node_path_1.default.join(root, ...rel.split("/"));
|
|
113
|
+
}
|
|
114
|
+
/** Directories from filesystem root (or ceiling) down to cwd, root-first. */
|
|
115
|
+
ancestorDirs() {
|
|
116
|
+
const dirs = [];
|
|
117
|
+
let dir = this.cwd;
|
|
118
|
+
for (;;) {
|
|
119
|
+
dirs.unshift(dir);
|
|
120
|
+
if (this.opts.ceiling && dir === this.opts.ceiling)
|
|
121
|
+
break;
|
|
122
|
+
const parent = node_path_1.default.dirname(dir);
|
|
123
|
+
if (parent === dir)
|
|
124
|
+
break;
|
|
125
|
+
dir = parent;
|
|
126
|
+
}
|
|
127
|
+
return dirs;
|
|
128
|
+
}
|
|
129
|
+
/** jail for project-tree files: the git root if the file is inside it, else its own directory. */
|
|
130
|
+
jailFor(dir) {
|
|
131
|
+
return this.gitRoot && (0, fs_safe_1.isInside)(dir, this.gitRoot) ? this.gitRoot : dir;
|
|
132
|
+
}
|
|
133
|
+
/** Skip fixture folders? Not when asked not to, and not when the scan root is itself inside one (scanning a fixture on purpose). */
|
|
134
|
+
skipFixtures() {
|
|
135
|
+
return !this.opts.includeFixtures && !(0, fixtures_1.rootIsInsideFixtures)(this.cwd);
|
|
136
|
+
}
|
|
137
|
+
walk() {
|
|
138
|
+
if (!this.walkCache) {
|
|
139
|
+
const skip = this.skipFixtures();
|
|
140
|
+
const w = (0, fs_safe_1.walkForFiles)(this.cwd, exports.WALK_CANDIDATES, { timeUp: () => this.timeUp(), skipDir: skip ? (0, fixtures_1.makeFixtureSkipper)(this.cwd) : undefined });
|
|
141
|
+
const notAProject = !this.isProjectDir();
|
|
142
|
+
if ((w.truncated || w.timedOut) && notAProject) {
|
|
143
|
+
// Walking a home folder, a drive root or ~/Downloads: whatever CLAUDE.md/AGENTS.md files happen to sit in the first
|
|
144
|
+
// 5,000 entries are random, not "your project". Reporting them would print junk token totals, so drop them.
|
|
145
|
+
this.walkCache = { ...w, hits: [], notAProject, skipped: [] };
|
|
146
|
+
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`);
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
this.walkCache = { ...w, notAProject };
|
|
150
|
+
if (w.truncated)
|
|
151
|
+
this.warn(`project walk stopped at ${fs_safe_1.MAX_WALK_ENTRIES} entries; nested files deeper in the tree may be missed`);
|
|
152
|
+
if (w.timedOut)
|
|
153
|
+
this.warn("project walk stopped at the time budget; nested files may be missed");
|
|
154
|
+
this.fixturesSkipped = this.probeFixtures(w.skipped);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return this.walkCache;
|
|
158
|
+
}
|
|
159
|
+
/** How many skipped fixture folders hold agent files? A bounded, read-only look (names only); folders it cannot settle are not counted. */
|
|
160
|
+
probeFixtures(dirs) {
|
|
161
|
+
let left = FIXTURE_PROBE_TOTAL;
|
|
162
|
+
let n = 0;
|
|
163
|
+
for (const d of dirs) {
|
|
164
|
+
if (left <= 0 || this.timeUp())
|
|
165
|
+
break;
|
|
166
|
+
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) });
|
|
167
|
+
left -= p.entries;
|
|
168
|
+
if (p.hits.length)
|
|
169
|
+
n++;
|
|
170
|
+
}
|
|
171
|
+
return n;
|
|
172
|
+
}
|
|
173
|
+
/** Cached, defensive read. Missing = silent; other failures become warnings (-> exit code 3). */
|
|
174
|
+
read(p, opts = {}) {
|
|
175
|
+
const key = `${p}|${opts.jail ?? ""}|${opts.maxBytes ?? ""}`;
|
|
176
|
+
let r = this.readCache.get(key);
|
|
177
|
+
if (!r) {
|
|
178
|
+
if (this.timeUp() || this.bytesRead >= this.maxScanBytes) {
|
|
179
|
+
// Budget spent: stop touching the disk. Missing files stay silent, everything else counts as skipped.
|
|
180
|
+
if (this.bytesRead >= this.maxScanBytes)
|
|
181
|
+
this.budgetHit = this.budgetHit ?? "bytes";
|
|
182
|
+
r = (0, fs_safe_1.readTextSafe)(p, { ...opts, budgetBytes: 0 });
|
|
183
|
+
if (!r.ok && r.reason === "budget")
|
|
184
|
+
this.budgetSkipped++;
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
r = (0, fs_safe_1.readTextSafe)(p, { ...opts, budgetBytes: this.maxScanBytes - this.bytesRead });
|
|
188
|
+
if (r.ok)
|
|
189
|
+
this.bytesRead += r.bytes;
|
|
190
|
+
else if (r.reason === "budget") {
|
|
191
|
+
this.budgetHit = this.budgetHit ?? "bytes";
|
|
192
|
+
this.budgetSkipped++;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
this.readCache.set(key, r);
|
|
196
|
+
if (!r.ok && !opts.quiet && r.reason !== "missing" && r.reason !== "budget")
|
|
197
|
+
this.warn(`${this.display(p)}: ${readFailWhy(r.reason, "skipped")}`);
|
|
198
|
+
}
|
|
199
|
+
return r;
|
|
200
|
+
}
|
|
201
|
+
/** Called once at the end of a scan: one summary warning when a whole-scan budget cut the scan short. */
|
|
202
|
+
budgetSummary() {
|
|
203
|
+
if (!this.budgetHit)
|
|
204
|
+
return undefined;
|
|
205
|
+
const what = this.budgetHit === "time" ? `${Math.round(this.maxScanMs / 1000)} s time budget` : `${Math.round(this.maxScanBytes / 1048576)} MiB read budget`;
|
|
206
|
+
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.`;
|
|
207
|
+
}
|
|
208
|
+
readJson(p) {
|
|
209
|
+
const r = this.read(p, { maxBytes: 64 * 1024 * 1024 });
|
|
210
|
+
if (!r.ok)
|
|
211
|
+
return undefined;
|
|
212
|
+
try {
|
|
213
|
+
return JSON.parse(r.text);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
this.warn(`${this.display(p)}: could not parse as JSON, skipped`);
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
count(key, text) {
|
|
221
|
+
let n = this.countCache.get(key);
|
|
222
|
+
if (n === undefined) {
|
|
223
|
+
n = this.counter.count(text);
|
|
224
|
+
this.countCache.set(key, n);
|
|
225
|
+
}
|
|
226
|
+
return n;
|
|
227
|
+
}
|
|
228
|
+
addFinding(f, dedupeKey) {
|
|
229
|
+
const k = dedupeKey ?? `${f.id}|${f.agent}|${f.paths.join(",")}|${f.message}`;
|
|
230
|
+
if (this.findingKeys.has(k))
|
|
231
|
+
return;
|
|
232
|
+
this.findingKeys.add(k);
|
|
233
|
+
this.findings.push(f);
|
|
234
|
+
}
|
|
235
|
+
makeItem(a) {
|
|
236
|
+
const prof = this.profile(a.agent);
|
|
237
|
+
return {
|
|
238
|
+
id: `${a.agent}:${++this.seq}`,
|
|
239
|
+
agent: a.agent,
|
|
240
|
+
path: a.path,
|
|
241
|
+
kind: a.kind,
|
|
242
|
+
scope: a.scope,
|
|
243
|
+
loadMode: a.loadMode,
|
|
244
|
+
tokens: a.raw === null ? null : prof.apply(a.raw, a.kind === "mcp-server" ? "json" : "markdown"),
|
|
245
|
+
...(a.raw === null ? { unmeasuredReason: a.unmeasuredReason ?? "not measurable" } : {}),
|
|
246
|
+
chars: a.chars ?? 0,
|
|
247
|
+
lines: a.lines ?? 0,
|
|
248
|
+
confidence: a.confidence,
|
|
249
|
+
evidence: a.evidence,
|
|
250
|
+
notes: a.notes ?? [],
|
|
251
|
+
cardLabel: a.cardLabel,
|
|
252
|
+
label: (0, fmt_1.sanitize)(a.label, 100),
|
|
253
|
+
...(a.detail ? { detail: a.detail } : {}),
|
|
254
|
+
...(a.group ? { group: a.group, groupLabel: a.groupLabel ?? a.group } : {}),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
/** Per-document analysis shared by all agents: secrets (F9), guideline length (F1), shingles for duplicates (F2). */
|
|
258
|
+
analyzeDoc(agent, filePath, rawText, processed, rawTokens, guidelineLines) {
|
|
259
|
+
const real = (0, fs_safe_1.realpathOrSelf)(filePath);
|
|
260
|
+
const lines = lineCount(rawText);
|
|
261
|
+
const secretLines = (0, redact_1.findSecretLines)(rawText);
|
|
262
|
+
if (secretLines.length) {
|
|
263
|
+
this.addFinding({
|
|
264
|
+
id: "F9",
|
|
265
|
+
severity: "warn",
|
|
266
|
+
agent: "all",
|
|
267
|
+
message: `Possible secret on line${secretLines.length > 1 ? "s" : ""} ${secretLines.slice(0, 5).join(", ")}${secretLines.length > 5 ? ", …" : ""} of ${this.display(filePath)} (value not shown)`,
|
|
268
|
+
paths: [filePath],
|
|
269
|
+
confidence: "medium",
|
|
270
|
+
data: { lines: secretLines.slice(0, 50) },
|
|
271
|
+
fix: "Remove the secret from the instruction file and rotate it; agents send this file to a model provider.",
|
|
272
|
+
}, `F9|${real}`);
|
|
273
|
+
}
|
|
274
|
+
if (guidelineLines && lines > guidelineLines) {
|
|
275
|
+
this.addFinding({
|
|
276
|
+
id: "F1",
|
|
277
|
+
severity: "info",
|
|
278
|
+
agent,
|
|
279
|
+
message: `${this.display(filePath)} is ${lines} lines (documented guideline: under ${guidelineLines}); longer files can reduce adherence`,
|
|
280
|
+
paths: [filePath],
|
|
281
|
+
confidence: "medium",
|
|
282
|
+
data: { fileLines: lines, guidelineLines },
|
|
283
|
+
fix: "Split into scoped rules or move reference material out of the always-loaded file.",
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
let info = this.docs.get(real);
|
|
287
|
+
if (!info) {
|
|
288
|
+
info = { path: filePath, tokens: rawTokens, shingles: shingles(processed), agents: new Set() };
|
|
289
|
+
this.docs.set(real, info);
|
|
290
|
+
}
|
|
291
|
+
info.agents.add(agent);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
exports.Ctx = Ctx;
|
|
295
|
+
function lineCount(text) {
|
|
296
|
+
if (!text)
|
|
297
|
+
return 0;
|
|
298
|
+
return text.replace(/\n$/, "").split("\n").length;
|
|
299
|
+
}
|
|
300
|
+
function fnv(s) {
|
|
301
|
+
let h = 0x811c9dc5;
|
|
302
|
+
for (let i = 0; i < s.length; i++) {
|
|
303
|
+
h ^= s.charCodeAt(i);
|
|
304
|
+
h = Math.imul(h, 0x01000193);
|
|
305
|
+
}
|
|
306
|
+
return h >>> 0;
|
|
307
|
+
}
|
|
308
|
+
function shingles(text, k = 5) {
|
|
309
|
+
const words = text.toLowerCase().split(/\s+/).filter(Boolean);
|
|
310
|
+
const out = new Set();
|
|
311
|
+
if (words.length < k) {
|
|
312
|
+
if (words.length)
|
|
313
|
+
out.add(fnv(words.join(" ")));
|
|
314
|
+
return out;
|
|
315
|
+
}
|
|
316
|
+
for (let i = 0; i + k <= words.length; i++)
|
|
317
|
+
out.add(fnv(words.slice(i, i + k).join(" ")));
|
|
318
|
+
return out;
|
|
319
|
+
}
|
|
320
|
+
function jaccard(a, b) {
|
|
321
|
+
if (!a.size || !b.size)
|
|
322
|
+
return 0;
|
|
323
|
+
let inter = 0;
|
|
324
|
+
const [s, l] = a.size < b.size ? [a, b] : [b, a];
|
|
325
|
+
for (const x of s)
|
|
326
|
+
if (l.has(x))
|
|
327
|
+
inter++;
|
|
328
|
+
return inter / (a.size + b.size - inter);
|
|
329
|
+
}
|
package/dist/diff.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DiffInputError = exports.DIFF_MARKER = void 0;
|
|
4
|
+
exports.parseSnapshot = parseSnapshot;
|
|
5
|
+
exports.diffSnapshots = diffSnapshots;
|
|
6
|
+
exports.signedTok = signedTok;
|
|
7
|
+
exports.headline = headline;
|
|
8
|
+
exports.overThreshold = overThreshold;
|
|
9
|
+
exports.renderDiffTerminal = renderDiffTerminal;
|
|
10
|
+
exports.renderDiffMarkdown = renderDiffMarkdown;
|
|
11
|
+
exports.diffJson = diffJson;
|
|
12
|
+
exports.badgeJson = badgeJson;
|
|
13
|
+
/**
|
|
14
|
+
* `prehello diff base.json head.json`: compare two saved reports (from `prehello --save` / `--json=`). Offline, no git:
|
|
15
|
+
* the GitHub Action (action.yml) produces the two snapshots. Item names come from the scanned repository, so every one
|
|
16
|
+
* goes through mdCode / sanitize before it is printed.
|
|
17
|
+
*/
|
|
18
|
+
const fmt_1 = require("./util/fmt");
|
|
19
|
+
const markdown_1 = require("./render/markdown");
|
|
20
|
+
const card_1 = require("./render/card");
|
|
21
|
+
/** Starts with MD_MARKER_PREFIX (so --markdown=<file> can overwrite its own output) and is the sticky-comment marker the Action looks for. */
|
|
22
|
+
exports.DIFF_MARKER = "<!-- generated by prehello diff -->";
|
|
23
|
+
class DiffInputError extends Error {
|
|
24
|
+
}
|
|
25
|
+
exports.DiffInputError = DiffInputError;
|
|
26
|
+
function parseSnapshot(text, what) {
|
|
27
|
+
let j;
|
|
28
|
+
try {
|
|
29
|
+
j = JSON.parse(text.replace(/^\uFEFF/, ""));
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
throw new DiffInputError(`${what} is not JSON; create it with: prehello --save ${what}`);
|
|
33
|
+
}
|
|
34
|
+
const s = j;
|
|
35
|
+
if (!s || typeof s !== "object" || s.tool?.name !== "prehello" || !Array.isArray(s.agents) || s.schemaVersion === undefined) {
|
|
36
|
+
throw new DiffInputError(`${what} is not a prehello report; create it with: prehello --save ${what}`);
|
|
37
|
+
}
|
|
38
|
+
return s;
|
|
39
|
+
}
|
|
40
|
+
const MAX_ITEMS = 10;
|
|
41
|
+
const itemKey = (i) => `${i.kind}|${i.loadMode}|${i.path ?? i.label ?? i.id ?? ""}`;
|
|
42
|
+
/** Counted when installed or when the repository has files for it (a CI runner has no agent installed). */
|
|
43
|
+
const active = (a) => !!a && (!!a.detected || (a.items?.length ?? 0) > 0);
|
|
44
|
+
function diffSnapshots(base, head) {
|
|
45
|
+
const notes = [];
|
|
46
|
+
if (String(base.schemaVersion) !== String(head.schemaVersion))
|
|
47
|
+
notes.push(`report schema differs (base ${base.schemaVersion}, head ${head.schemaVersion}); numbers may not be comparable`);
|
|
48
|
+
if (base.scan?.tokenizer?.method !== head.scan?.tokenizer?.method)
|
|
49
|
+
notes.push("base and head were counted with different tokenizers");
|
|
50
|
+
if (base.tool.version !== head.tool.version)
|
|
51
|
+
notes.push(`made by different prehello versions (base ${(0, fmt_1.sanitize)(base.tool.version, 20)}, head ${(0, fmt_1.sanitize)(head.tool.version, 20)}); part of the change may be the estimator`);
|
|
52
|
+
const ids = [...new Set([...base.agents, ...head.agents].map((a) => a.id))];
|
|
53
|
+
const agents = [];
|
|
54
|
+
for (const id of ids) {
|
|
55
|
+
const b = base.agents.find((a) => a.id === id);
|
|
56
|
+
const h = head.agents.find((a) => a.id === id);
|
|
57
|
+
if (!active(b) && !active(h))
|
|
58
|
+
continue;
|
|
59
|
+
const bt = active(b) ? b.totals.always.est : null;
|
|
60
|
+
const ht = active(h) ? h.totals.always.est : null;
|
|
61
|
+
const delta = (ht ?? 0) - (bt ?? 0);
|
|
62
|
+
const deferredDelta = (active(h) ? h.totals.deferred?.est ?? 0 : 0) - (active(b) ? b.totals.deferred?.est ?? 0 : 0);
|
|
63
|
+
if (b?.profile?.id && h?.profile?.id && b.profile.id !== h.profile.id)
|
|
64
|
+
notes.push(`${(0, fmt_1.sanitize)(h.name, 40)}: estimate profile changed (${(0, fmt_1.sanitize)(b.profile.id, 30)} -> ${(0, fmt_1.sanitize)(h.profile.id, 30)})`);
|
|
65
|
+
const bi = new Map((active(b) ? b.items ?? [] : []).filter((i) => i.loadMode === "always").map((i) => [itemKey(i), i]));
|
|
66
|
+
const hi = new Map((active(h) ? h.items ?? [] : []).filter((i) => i.loadMode === "always").map((i) => [itemKey(i), i]));
|
|
67
|
+
const changes = [];
|
|
68
|
+
for (const k of new Set([...bi.keys(), ...hi.keys()])) {
|
|
69
|
+
const x = bi.get(k);
|
|
70
|
+
const y = hi.get(k);
|
|
71
|
+
const bv = x?.tokens?.est ?? null;
|
|
72
|
+
const hv = y?.tokens?.est ?? null;
|
|
73
|
+
const d = Math.round((hv ?? 0) - (bv ?? 0));
|
|
74
|
+
if (x && y && d === 0)
|
|
75
|
+
continue;
|
|
76
|
+
const it = (y ?? x);
|
|
77
|
+
changes.push({ key: k, label: it.path ?? it.label ?? it.kind, kind: it.kind, base: x ? bv : null, head: y ? hv : null, delta: d });
|
|
78
|
+
}
|
|
79
|
+
changes.sort((p, q) => Math.abs(q.delta) - Math.abs(p.delta) || p.label.localeCompare(q.label));
|
|
80
|
+
agents.push({
|
|
81
|
+
id,
|
|
82
|
+
name: (h ?? b).name,
|
|
83
|
+
base: bt,
|
|
84
|
+
head: ht,
|
|
85
|
+
delta: Math.round(delta),
|
|
86
|
+
deltaPct: bt && bt >= 100 ? Math.round((delta / bt) * 1000) / 10 : null,
|
|
87
|
+
deferredDelta: Math.round(deferredDelta),
|
|
88
|
+
lowerBound: !!(b?.alwaysIsLowerBound || h?.alwaysIsLowerBound),
|
|
89
|
+
changes: changes.slice(0, MAX_ITEMS),
|
|
90
|
+
moreChanges: Math.max(0, changes.length - MAX_ITEMS),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return { base, head, agents, notes };
|
|
94
|
+
}
|
|
95
|
+
function signedTok(n) {
|
|
96
|
+
if (Math.round(n) === 0)
|
|
97
|
+
return "±0";
|
|
98
|
+
return (n > 0 ? "+" : "−") + (0, fmt_1.fmtTok)(Math.abs(n));
|
|
99
|
+
}
|
|
100
|
+
function headline(a) {
|
|
101
|
+
if (a.base === null)
|
|
102
|
+
return `${a.name}: new, ~${(0, fmt_1.fmtTok)(a.head ?? 0)} tokens before hello`;
|
|
103
|
+
if (a.head === null)
|
|
104
|
+
return `${a.name}: no longer detected (was ~${(0, fmt_1.fmtTok)(a.base)} tokens before hello)`;
|
|
105
|
+
if (a.delta === 0)
|
|
106
|
+
return `${a.name}: no change, ~${(0, fmt_1.fmtTok)(a.head)} tokens before hello`;
|
|
107
|
+
return `${a.name}: ${signedTok(a.delta)} tokens before hello (~${(0, fmt_1.fmtTok)(a.base)} → ~${(0, fmt_1.fmtTok)(a.head)}${a.deltaPct !== null ? `, ${a.deltaPct > 0 ? "+" : ""}${a.deltaPct}%` : ""})`;
|
|
108
|
+
}
|
|
109
|
+
const cell = (n) => (n === null ? "-" : (0, fmt_1.fmtTok)(n));
|
|
110
|
+
const chg = (c) => (c.base === null ? "added" : c.head === null ? "removed" : "changed");
|
|
111
|
+
function overThreshold(d, max) {
|
|
112
|
+
return d.agents.filter((a) => a.delta > max);
|
|
113
|
+
}
|
|
114
|
+
function renderDiffTerminal(d) {
|
|
115
|
+
const out = ["prehello diff (estimates; always-on context before your first message)", ""];
|
|
116
|
+
if (!d.agents.length)
|
|
117
|
+
out.push("No agent context found in either report.");
|
|
118
|
+
for (const a of d.agents) {
|
|
119
|
+
out.push((0, fmt_1.sanitize)(headline(a), 200));
|
|
120
|
+
for (const c of a.changes)
|
|
121
|
+
out.push(` ${signedTok(c.delta).padStart(7)} ${chg(c).padEnd(7)} ${(0, fmt_1.sanitize)(c.label, 80)}`);
|
|
122
|
+
if (a.moreChanges)
|
|
123
|
+
out.push(` … ${a.moreChanges} more changed item(s)`);
|
|
124
|
+
if (a.deferredDelta)
|
|
125
|
+
out.push(` on demand (skill bodies, deferred tools): ${signedTok(a.deferredDelta)}`);
|
|
126
|
+
}
|
|
127
|
+
for (const n of d.notes)
|
|
128
|
+
out.push(`note: ${n}`);
|
|
129
|
+
return out.join("\n") + "\n";
|
|
130
|
+
}
|
|
131
|
+
function renderDiffMarkdown(d, opts = {}) {
|
|
132
|
+
const over = opts.threshold !== undefined ? overThreshold(d, opts.threshold) : [];
|
|
133
|
+
const out = [exports.DIFF_MARKER, "### prehello: context before hello", ""];
|
|
134
|
+
const moved = d.agents.filter((a) => a.delta !== 0 || a.base === null || a.head === null);
|
|
135
|
+
if (!d.agents.length)
|
|
136
|
+
out.push("No agent context (CLAUDE.md, AGENTS.md, rules, skills, MCP) found in this repository.");
|
|
137
|
+
else if (!moved.length)
|
|
138
|
+
out.push(`No change in always-on context. ${d.agents.map((a) => `${(0, markdown_1.mdCode)(a.name)} ~${(0, fmt_1.fmtTok)(a.head ?? 0)} tokens`).join(", ")}.`);
|
|
139
|
+
else {
|
|
140
|
+
out.push("| Agent | Base | Head | Change |", "|---|---:|---:|---:|");
|
|
141
|
+
for (const a of d.agents)
|
|
142
|
+
out.push(`| ${(0, markdown_1.mdCode)(a.name, true)} | ${cell(a.base)} | ${cell(a.head)}${a.lowerBound ? " (at least)" : ""} | **${signedTok(a.delta)}**${a.deltaPct !== null && a.delta ? ` (${a.deltaPct > 0 ? "+" : ""}${a.deltaPct}%)` : ""} |`);
|
|
143
|
+
out.push("");
|
|
144
|
+
for (const a of d.agents.filter((x) => x.changes.length)) {
|
|
145
|
+
out.push(`<details><summary>${summaryText(a.name)}: ${a.changes.length + a.moreChanges} changed item(s)</summary>`, "", "| Change | Item | Base | Head |", "|---:|---|---:|---:|");
|
|
146
|
+
for (const c of a.changes)
|
|
147
|
+
out.push(`| ${signedTok(c.delta)} | ${chg(c)} ${(0, markdown_1.mdCode)(c.label, true)} | ${cell(c.base)} | ${cell(c.head)} |`);
|
|
148
|
+
if (a.moreChanges)
|
|
149
|
+
out.push(`| | … ${a.moreChanges} more | | |`);
|
|
150
|
+
out.push("", "</details>", "");
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (over.length)
|
|
154
|
+
out.push("", `**Over the limit:** ${over.map((a) => `${(0, markdown_1.mdCode)(a.name)} grew ${signedTok(a.delta)} (limit +${(0, fmt_1.fmtTok)(opts.threshold)})`).join(", ")}.`);
|
|
155
|
+
for (const n of d.notes)
|
|
156
|
+
out.push("", `_Note: ${(0, fmt_1.sanitize)(n, 200).replace(/[_*`<>[\]]/g, " ")}_`);
|
|
157
|
+
out.push("", `<sub>Estimates (o200k-based, per-agent factors), not exact for any model. Only this repository's files are scanned. [prehello](https://${card_1.REPO_URL}) ${(0, fmt_1.sanitize)(d.head.tool.version, 20)}</sub>`);
|
|
158
|
+
return out.join("\n") + "\n";
|
|
159
|
+
}
|
|
160
|
+
function diffJson(d, threshold) {
|
|
161
|
+
return {
|
|
162
|
+
tool: { name: "prehello", version: d.head.tool.version },
|
|
163
|
+
kind: "diff",
|
|
164
|
+
agents: d.agents,
|
|
165
|
+
notes: d.notes,
|
|
166
|
+
...(threshold !== undefined ? { threshold, overThreshold: overThreshold(d, threshold).map((a) => a.id) } : {}),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/** shields.io endpoint JSON (https://shields.io/badges/endpoint-badge). */
|
|
170
|
+
function badgeJson(name, est, lowerBound) {
|
|
171
|
+
const color = est === null ? "lightgrey" : est < 5000 ? "brightgreen" : est < 15000 ? "green" : est < 30000 ? "yellow" : est < 60000 ? "orange" : "red";
|
|
172
|
+
return { schemaVersion: 1, label: "context before hello", message: est === null ? "unmeasured" : `${lowerBound ? "≥" : "~"}${(0, fmt_1.fmtTok)(est)} tokens (${name})`, color };
|
|
173
|
+
}
|
|
174
|
+
/** Plain text for inside <summary>: GitHub does not render Markdown there, so no backticks; HTML-escaped. */
|
|
175
|
+
function summaryText(name) {
|
|
176
|
+
let t = name.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff]+/g, " ").replace(/\s+/g, " ").trim();
|
|
177
|
+
if (t.length > 80)
|
|
178
|
+
t = t.slice(0, 77) + "...";
|
|
179
|
+
if (!t)
|
|
180
|
+
t = "(unnamed)";
|
|
181
|
+
return t.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/@/g, "@").replace(/#/g, "#");
|
|
182
|
+
}
|
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
|
+
}
|