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.
Files changed (51) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/LICENSE +21 -0
  3. package/README.md +515 -2
  4. package/THIRD_PARTY_NOTICES.md +33 -0
  5. package/assets/fonts/Inter-ExtraBold.subset.ttf +0 -0
  6. package/assets/fonts/Inter-Regular.subset.ttf +0 -0
  7. package/assets/fonts/Inter-SemiBold.subset.ttf +0 -0
  8. package/assets/fonts/OFL.txt +94 -0
  9. package/dist/adapters/claude-code.js +480 -0
  10. package/dist/adapters/codex.js +184 -0
  11. package/dist/adapters/common.js +157 -0
  12. package/dist/adapters/cursor.js +257 -0
  13. package/dist/adapters/skills.js +155 -0
  14. package/dist/adapters/types.js +2 -0
  15. package/dist/analyze/findings.js +82 -0
  16. package/dist/analyze/grade.js +55 -0
  17. package/dist/analyze/savings.js +111 -0
  18. package/dist/analyze/summary.js +57 -0
  19. package/dist/analyze/totals.js +21 -0
  20. package/dist/cli.js +454 -0
  21. package/dist/context.js +331 -0
  22. package/dist/fixtures.js +52 -0
  23. package/dist/fs-safe.js +222 -0
  24. package/dist/mcp/catalog.js +44 -0
  25. package/dist/mcp/configs.js +42 -0
  26. package/dist/model.js +11 -0
  27. package/dist/platform.js +115 -0
  28. package/dist/redact.js +39 -0
  29. package/dist/render/card.js +305 -0
  30. package/dist/render/fix.js +22 -0
  31. package/dist/render/json.js +97 -0
  32. package/dist/render/markdown.js +182 -0
  33. package/dist/render/png.js +162 -0
  34. package/dist/render/sarif.js +472 -0
  35. package/dist/render/terminal.js +329 -0
  36. package/dist/render/treemap.js +58 -0
  37. package/dist/safe-write.js +120 -0
  38. package/dist/scan.js +104 -0
  39. package/dist/spec.js +27 -0
  40. package/dist/text/comments.js +60 -0
  41. package/dist/text/frontmatter.js +90 -0
  42. package/dist/text/imports.js +81 -0
  43. package/dist/tokens/counter.js +41 -0
  44. package/dist/tokens/families.js +47 -0
  45. package/dist/util/fmt.js +102 -0
  46. package/dist/util/secrets.js +35 -0
  47. package/package.json +66 -4
  48. package/spec/claude-code.json +22 -0
  49. package/spec/codex.json +14 -0
  50. package/spec/cursor.json +15 -0
  51. package/spec/mcp-weights.json +62 -0
@@ -0,0 +1,472 @@
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.RULES = exports.WARN_WINDOW_PCT = exports.HEAVY_GROUP_TOKENS = exports.LARGE_ITEM_TOKENS = exports.SRCROOT = exports.SARIF_SCHEMA = void 0;
7
+ exports.sarifText = sarifText;
8
+ exports.toSarif = toSarif;
9
+ exports.renderSarif = renderSarif;
10
+ /**
11
+ * SARIF 2.1.0 report (--sarif): for GitHub code scanning, VS Code's SARIF viewer and other IDE / CI tools.
12
+ *
13
+ * Privacy by construction (same contract as the card and the Markdown report):
14
+ * - No file contents, no finding or warning TEXTS (those name user-level paths); every message is built here from numbers and generic words.
15
+ * - A physicalLocation is emitted ONLY for a file inside the scanned project folder, as a relative URI against %SRCROOT%. Never an absolute
16
+ * path, never a home or user-config path (~/.claude, ~/.cursor, ...), never a path that looks like it holds a secret or has control characters.
17
+ * User-level items (user, managed, plugin, built-in) get a logicalLocation with a generic label ("User CLAUDE.md") and no file location.
18
+ * - `strict` (--redact): no physical locations at all, generic labels only. `anonymize`: plugin / MCP server names become "plugin 1" / "MCP server 1".
19
+ * - Token numbers are ESTIMATES (`isEstimate: true`), an unmeasured item never carries a number (never 0), and a total that is a lower bound says "at least".
20
+ */
21
+ const node_crypto_1 = require("node:crypto");
22
+ const node_path_1 = __importDefault(require("node:path"));
23
+ const savings_1 = require("../analyze/savings");
24
+ const card_1 = require("./card");
25
+ const fmt_1 = require("../util/fmt");
26
+ const json_1 = require("./json");
27
+ const fs_safe_1 = require("../fs-safe");
28
+ const redact_1 = require("../redact");
29
+ exports.SARIF_SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json";
30
+ exports.SRCROOT = "%SRCROOT%";
31
+ /** An always-on file is reported (PH001) from this size on, or from 1% of the window. */
32
+ exports.LARGE_ITEM_TOKENS = 2000;
33
+ /** A plugin / skills folder / MCP server is reported (PH002) from this size on. */
34
+ exports.HEAVY_GROUP_TOKENS = 1000;
35
+ /** Warning instead of note from this share of the window (one item or group). */
36
+ exports.WARN_WINDOW_PCT = 5;
37
+ const MAX_GROUPS_PER_AGENT = 10;
38
+ exports.RULES = [
39
+ {
40
+ id: "PH001",
41
+ name: "LargeAlwaysOnFile",
42
+ level: "note",
43
+ short: "A file that loads before the first message is large",
44
+ full: "An instruction, rule or memory file that the coding agent loads into context before the first message costs a lot of tokens. The count is an estimate (o200k BPE with a per-agent scaling prior).",
45
+ help: "Trim the file, split rarely needed material into scoped rules or skills, or move reference text out of always-loaded files. Token counts are estimates with an error band, not exact for any model.",
46
+ precision: "medium",
47
+ },
48
+ {
49
+ id: "PH002",
50
+ name: "HeavyAlwaysOnGroup",
51
+ level: "note",
52
+ short: "A plugin, skills folder or MCP server costs many tokens before the first message",
53
+ full: "The listing of a plugin's skills, a skills folder or an MCP server's tool definitions is part of the always-on context. This is what `prehello --fix` calls the cut list. The count is an estimate.",
54
+ help: "If you do not use it, disable or remove the plugin, skills folder or MCP server in the agent's own settings. prehello never edits agent configuration. The saving is an estimate, not a promise.",
55
+ precision: "medium",
56
+ },
57
+ {
58
+ id: "PH003",
59
+ name: "OverTokenBudget",
60
+ level: "warning",
61
+ short: "Always-on context is above the --max-tokens budget",
62
+ full: "The estimated always-on token count of an agent in use is above the budget given with --max-tokens. When some always-on items could not be measured, the total is a lower bound (at least).",
63
+ help: "Cut the biggest contributors (see PH001 and PH002), or raise the budget if the size is intentional. This result only exists when --max-tokens is given; the same condition makes prehello exit with code 2.",
64
+ precision: "medium",
65
+ },
66
+ {
67
+ id: "PH004",
68
+ name: "UnmeasuredAlwaysOnItems",
69
+ level: "note",
70
+ short: "Some always-loaded items could not be measured",
71
+ full: "Some items that load before the first message (typically MCP servers whose tools are only known at runtime) have no token count. Their cost is unknown, not zero, so every always-on total for this agent is a lower bound.",
72
+ help: "No action is required. Treat the totals as 'at least'. You can supply measured weights with --mcp-catalog.",
73
+ precision: "low",
74
+ },
75
+ {
76
+ id: "PH005",
77
+ name: "DuplicateContextFiles",
78
+ level: "warning",
79
+ short: "Two context files are near-duplicates",
80
+ full: "Two files that agents load share most of their text (60% or more). If one agent loads both, the text is paid for twice.",
81
+ help: "Keep one copy. Claude Code @imports do not reduce cost. If different agents read the two files, do not delete either; make one the source of truth (for example a CLAUDE.md that only contains @AGENTS.md, or a symlink).",
82
+ precision: "medium",
83
+ },
84
+ {
85
+ id: "PH006",
86
+ name: "LongInstructionFile",
87
+ level: "note",
88
+ short: "An always-loaded file is longer than the documented guideline",
89
+ full: "The file is longer than the length the agent's documentation recommends (Claude Code: 200 lines for CLAUDE.md, Cursor: 500 lines per rule). Longer files can reduce how well the agent follows them.",
90
+ help: "Split the file into scoped rules or move reference material out of the always-loaded file.",
91
+ precision: "medium",
92
+ },
93
+ {
94
+ id: "PH007",
95
+ name: "SilentTruncation",
96
+ level: "warning",
97
+ short: "Part of a context file is silently dropped by the agent",
98
+ full: "The agent cuts this content off at a documented limit (Codex 32 KiB project docs, Claude Code MEMORY.md 200 lines / 25 KB, the skill description cap), so the tail never reaches the model.",
99
+ help: "Shorten the file or the description, and put the important parts first.",
100
+ precision: "medium",
101
+ },
102
+ {
103
+ id: "PH008",
104
+ name: "IgnoredContextFile",
105
+ level: "warning",
106
+ short: "A context file is ignored by the agent",
107
+ full: "The agent does not load this file (for example AGENTS.md next to a CLAUDE.md for Claude Code, or a .md file in .cursor/rules for Cursor). Other agents may still read it.",
108
+ help: "Rename or restructure the file if you intend the agent to use it, or delete it if it is stale.",
109
+ precision: "medium",
110
+ },
111
+ {
112
+ id: "PH009",
113
+ name: "PossibleSecretInContextFile",
114
+ level: "warning",
115
+ short: "A context file contains something that looks like a secret",
116
+ full: "A line of this file matches a secret pattern (cloud keys, tokens, private key headers, key=value credentials). The value is never printed or stored by prehello. Agents send context files to a model provider.",
117
+ help: "Remove the secret from the file and rotate it. This is a pattern match and can be a false positive.",
118
+ precision: "low",
119
+ },
120
+ {
121
+ id: "PH010",
122
+ name: "SkillsListingOverBudget",
123
+ level: "note",
124
+ short: "A skills listing is above the agent's listing budget",
125
+ full: "The skills listing is estimated to be larger than the share of the window the agent reserves for it, so the agent shortens or drops descriptions of the least used skills.",
126
+ help: "Remove skills you do not use, or switch rarely used skills to name-only where the agent supports it.",
127
+ precision: "low",
128
+ },
129
+ {
130
+ id: "PH011",
131
+ name: "IncompleteScan",
132
+ level: "note",
133
+ short: "Some sources could not be read or the scan hit its budget",
134
+ full: "Some files were unreadable, too large, binary or skipped, or the scan stopped at its byte or time budget. Every total is then a lower bound.",
135
+ help: "Run prehello locally to see which sources were skipped, or raise --scan-budget-mb / --scan-budget-ms.",
136
+ precision: "low",
137
+ },
138
+ ];
139
+ const RULE_INDEX = new Map(exports.RULES.map((r, i) => [r.id, i]));
140
+ /** Finding id -> rule. F5 (unmeasured) is reported per agent as PH004 from the coverage numbers instead. */
141
+ const FINDING_RULE = { F1: "PH006", F2: "PH005", F4: "PH007", F8: "PH007", F12: "PH007", F7: "PH008", F11: "PH008", F9: "PH009", F13: "PH010" };
142
+ const ZERO_WIDTH = /[\u200b-\u200f\u2028\u2029\u2060\ufeff]/g;
143
+ /** A user-controlled name inside SARIF message text: no control / bidi characters, no secrets, `[ ] \` escaped (SARIF reads `[text](n)` as a link). */
144
+ function sarifText(s, max = 80) {
145
+ const t = (0, fmt_1.sanitize)(s.replace(/[\r\n\t\v\f]+/g, " "), 400).replace(ZERO_WIDTH, "").replace(/\s+/g, " ").trim();
146
+ const cut = t.length > max ? t.slice(0, max - 3) + "..." : t;
147
+ return (cut || "(unnamed)").replace(/[\\[\]]/g, (c) => "\\" + c);
148
+ }
149
+ const plural = (n, one, many = one + "s") => `${n} ${n === 1 ? one : many}`;
150
+ function isHomeOrAncestor(cwd, home) {
151
+ const rel = node_path_1.default.relative(cwd, home);
152
+ return rel === "" || (!rel.startsWith("..") && !node_path_1.default.isAbsolute(rel));
153
+ }
154
+ function insideRel(base, p) {
155
+ const rel = node_path_1.default.relative(base, p);
156
+ if (rel === "" || rel === ".." || rel.startsWith(".." + node_path_1.default.sep) || node_path_1.default.isAbsolute(rel))
157
+ return null;
158
+ return rel;
159
+ }
160
+ /** Everything that turns SARIF input into output. Exported for tests. */
161
+ function toSarif(r, o) {
162
+ const cwd = (0, fs_safe_1.realpathOrSelf)(o.cwd);
163
+ const home = (0, fs_safe_1.realpathOrSelf)(o.home);
164
+ const shown = r.agents.filter((a) => !a.presentInRepoOnly);
165
+ const noFiles = o.strict || isHomeOrAncestor(cwd, home);
166
+ // user-level, managed, plugin and built-in items never get a file location, even if a path looks like it is inside the project
167
+ const userLevel = new Set();
168
+ for (const a of r.agents)
169
+ for (const it of a.items)
170
+ if (!["project", "local", "nested"].includes(it.scope))
171
+ userLevel.add(it.path);
172
+ function locate(p, line = 1) {
173
+ if (noFiles || userLevel.has(p))
174
+ return undefined;
175
+ const rel = insideRel(cwd, p);
176
+ if (rel === null)
177
+ return undefined;
178
+ const real = insideRel(cwd, (0, fs_safe_1.realpathOrSelf)(p));
179
+ if (real === null)
180
+ return undefined; // a symlink pointing out of the project
181
+ // eslint-disable-next-line no-control-regex
182
+ if (/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/.test(rel) || (0, redact_1.scrubSecrets)(rel) !== rel)
183
+ return undefined;
184
+ const segs = rel.split(node_path_1.default.sep);
185
+ return { uri: segs.map((s) => encodeURIComponent(s)).join("/"), shown: sarifText(segs.join("/"), 120), line: Math.max(1, Math.floor(line)) };
186
+ }
187
+ const recs = new Map();
188
+ const add = (x) => {
189
+ const cur = recs.get(x.key);
190
+ if (!cur) {
191
+ recs.set(x.key, x);
192
+ return;
193
+ }
194
+ for (const a of x.agents)
195
+ if (!cur.agents.includes(a))
196
+ cur.agents.push(a);
197
+ if (x.level === "warning")
198
+ cur.level = "warning";
199
+ // merged agents: keep the biggest numbers; per-agent numbers stay in `props.perAgent`
200
+ const pa = cur.props.perAgent ?? [];
201
+ cur.props.perAgent = [...pa, ...(x.props.perAgent ?? [])];
202
+ };
203
+ const levelFor = (tokens, window) => ((tokens / window) * 100 >= exports.WARN_WINDOW_PCT ? "warning" : "note");
204
+ const W = r.scan.window;
205
+ const est = (t) => ({ estimateTokens: t.est, lowTokens: t.low, highTokens: t.high, isEstimate: true });
206
+ for (const a of shown) {
207
+ const labels = (0, json_1.numberedLabels)(a);
208
+ // PH001: big always-on single items
209
+ for (const it of a.items) {
210
+ if (it.loadMode !== "always" || !it.tokens || it.kind === "skills-listing" || it.kind === "mcp-server")
211
+ continue;
212
+ if (it.tokens.est < exports.LARGE_ITEM_TOKENS && (it.tokens.est / W) * 100 < 1)
213
+ continue;
214
+ const loc = locate(it.path);
215
+ const label = sarifText(labels.get(it.id) ?? it.cardLabel);
216
+ add({
217
+ rule: "PH001",
218
+ key: loc ? `PH001|${loc.uri}` : `PH001|${a.id}|${label}`,
219
+ level: levelFor(it.tokens.est, W),
220
+ agents: [a.name],
221
+ loc,
222
+ logical: loc ? undefined : { name: label, fq: `${a.id}/${label}` },
223
+ d: { label },
224
+ props: { perAgent: [{ agent: a.id, kind: it.kind, scope: it.scope, loadMode: it.loadMode, ...est(it.tokens), percentOfWindow: round1((it.tokens.est / W) * 100), tokens: it.tokens.est }] },
225
+ fp: loc ? loc.uri : `${a.id}/${label}`,
226
+ });
227
+ }
228
+ // PH002: heavy removable groups (same groups, order and names as the card and the cut list)
229
+ const cands = (0, savings_1.cutCandidatesWithSource)(a.items);
230
+ const tiles = (0, savings_1.cardTiles)(a.items, o.anonymize);
231
+ const byCut = new Map(tiles.filter((t) => t.cut !== undefined).map((t) => [t.cut, t]));
232
+ let emitted = 0;
233
+ cands.forEach((c, i) => {
234
+ if (c.tokens < exports.HEAVY_GROUP_TOKENS || emitted >= MAX_GROUPS_PER_AGENT)
235
+ return;
236
+ emitted++;
237
+ const t = byCut.get(i);
238
+ const name = sarifText(t ? t.label : c.label);
239
+ const loc = c.kind === "mcp" ? locate(c.source.path) : undefined;
240
+ add({
241
+ rule: "PH002",
242
+ key: `PH002|${a.id}|${c.kind}|${name}|${loc?.uri ?? ""}`,
243
+ level: levelFor(c.tokens, W),
244
+ agents: [a.name],
245
+ loc,
246
+ logical: { name, fq: `${a.id}/${name}` },
247
+ d: { name, kind: c.kind, count: c.count },
248
+ props: { perAgent: [{ agent: a.id, kind: c.kind === "mcp" ? "mcp-server" : "skills", scope: c.source.scope, estimateTokens: c.tokens, isEstimate: true, skills: c.kind === "skills" ? c.count : undefined, percentOfWindow: round1((c.tokens / W) * 100), tokens: c.tokens }] },
249
+ fp: `${a.id}/${c.kind}/${name}`,
250
+ });
251
+ });
252
+ // PH003: --max-tokens
253
+ if (o.maxTokens !== undefined && a.totals.always.est > o.maxTokens) {
254
+ add({
255
+ rule: "PH003",
256
+ key: `PH003|${a.id}`,
257
+ level: "warning",
258
+ agents: [a.name],
259
+ logical: { name: a.name, fq: a.id },
260
+ d: { est: a.totals.always.est, max: o.maxTokens, lower: a.lowerBound },
261
+ props: { perAgent: [{ agent: a.id, ...est(a.totals.always), isLowerBound: a.lowerBound, maxTokens: o.maxTokens, tokens: a.totals.always.est }] },
262
+ fp: a.id,
263
+ });
264
+ }
265
+ // PH004: unmeasured always-on items (counts and kinds only)
266
+ const un = a.totals.always.unmeasuredItems;
267
+ if (un > 0) {
268
+ add({
269
+ rule: "PH004",
270
+ key: `PH004|${a.id}`,
271
+ level: "note",
272
+ agents: [a.name],
273
+ logical: { name: a.name, fq: a.id },
274
+ d: { n: un, kinds: a.coverage.unmeasuredKinds, est: a.totals.always.est, measured: a.totals.always.measuredItems },
275
+ props: { perAgent: [{ agent: a.id, unmeasuredItems: un, unmeasuredKinds: a.coverage.unmeasuredKinds, measuredItems: a.totals.always.measuredItems, alwaysTokensAtLeast: a.totals.always.measuredItems ? a.totals.always.est : null, isLowerBound: true }] },
276
+ fp: a.id,
277
+ });
278
+ }
279
+ }
280
+ // findings -> PH005..PH010 (messages rebuilt from structured data; the finding's own text names user-level paths)
281
+ const visibleAgents = new Set(shown.map((a) => a.id));
282
+ const nameOf = (f) => (f.agent === "all" ? shown.map((a) => a.name) : [shown.find((a) => a.id === f.agent)?.name ?? String(f.agent)]);
283
+ r.findings.forEach((f, fi) => {
284
+ const rule = FINDING_RULE[f.id];
285
+ if (!rule || (f.agent !== "all" && !visibleAgents.has(f.agent)))
286
+ return;
287
+ if (f.data?.omittedPairs)
288
+ return; // a "N more not listed" summary has no file; the count goes into the run properties
289
+ const lvl = exports.RULES[RULE_INDEX.get(rule)].level;
290
+ const agents = f.id === "F2" || f.id === "F9" ? [] : nameOf(f);
291
+ const first = f.paths[0] ? locate(f.paths[0], f.data?.lines?.[0] ?? 1) : undefined;
292
+ const second = f.id === "F2" && f.paths[1] ? locate(f.paths[1]) : undefined;
293
+ const primary = first ?? second;
294
+ const other = first && second ? second : undefined;
295
+ const outside = f.id === "F2" ? f.paths.length - [first, second].filter(Boolean).length : 0;
296
+ const gen = f.agent === "all" ? "agents" : agents[0];
297
+ add({
298
+ rule,
299
+ key: primary ? `${rule}|${f.id}|${primary.uri}${other ? "|" + other.uri : ""}` : `${rule}|${f.id}|${f.agent}|#${fi}`,
300
+ level: lvl,
301
+ agents,
302
+ loc: primary,
303
+ related: other,
304
+ logical: primary ? undefined : { name: f.agent === "all" ? "context files" : agents[0], fq: `${f.agent}/${f.id}` },
305
+ d: { fid: f.id, gen, outside, ...f.data, secretLines: f.data?.lines },
306
+ props: { findingId: f.id, confidence: f.confidence, ...(f.savingsTokens !== undefined ? { savingsTokensEstimate: f.savingsTokens, savingsIsEstimate: true } : {}), ...(f.data?.lines ? { lines: f.data.lines } : {}), ...(f.data?.similarityPct !== undefined ? { similarityPercent: f.data.similarityPct } : {}), ...(f.data?.fileLines !== undefined ? { fileLines: f.data.fileLines, guidelineLines: f.data.guidelineLines } : {}) },
307
+ fp: `${f.id}/${primary?.uri ?? `${f.agent}#${fi}`}/${other?.uri ?? ""}`,
308
+ });
309
+ });
310
+ // PH011: partial / unreadable sources
311
+ if (r.partial || r.warnings.length) {
312
+ add({
313
+ rule: "PH011",
314
+ key: "PH011",
315
+ level: "note",
316
+ agents: [],
317
+ logical: { name: "scan", fq: "scan" },
318
+ d: {},
319
+ props: { warnings: r.warnings.length, ...(r.partial ? { budget: r.partial.reason, filesNotRead: r.partial.filesNotRead } : {}) },
320
+ fp: "scan",
321
+ });
322
+ }
323
+ const results = [...recs.values()].map((x) => finalize(x, r, o)).sort((a, b) => (0, fmt_1.cmp)(a.ruleId, b.ruleId) || (0, fmt_1.cmp)(a.sortKey, b.sortKey)).map(({ sortKey: _s, ...res }) => res);
324
+ return {
325
+ $schema: exports.SARIF_SCHEMA,
326
+ version: "2.1.0",
327
+ runs: [
328
+ {
329
+ tool: {
330
+ driver: {
331
+ name: "prehello",
332
+ version: r.tool.version,
333
+ semanticVersion: r.tool.version,
334
+ shortDescription: { text: "Shows what a coding agent loads into context before the first message. Token counts are estimates." },
335
+ rules: exports.RULES.map((d) => ({
336
+ id: d.id,
337
+ name: d.name,
338
+ shortDescription: { text: d.short },
339
+ fullDescription: { text: d.full },
340
+ help: { text: `${d.help}\nSee the SARIF section of the prehello README for the rule list.`, markdown: `${d.help}\n\nSee the SARIF section of the prehello README for the rule list.` },
341
+ defaultConfiguration: { level: d.level },
342
+ properties: { tags: ["context", "tokens", "coding-agents"], precision: d.precision },
343
+ })),
344
+ },
345
+ },
346
+ columnKind: "utf16CodeUnits",
347
+ originalUriBaseIds: { [exports.SRCROOT]: { description: { text: "The scanned project folder. Run prehello from the repository root so these paths match the repository." } } },
348
+ automationDetails: { id: "prehello/" },
349
+ invocations: [{ executionSuccessful: true, endTimeUtc: r.generatedAt, properties: { partial: !!r.partial } }],
350
+ results,
351
+ properties: runProps(r, shown, o, noFiles),
352
+ },
353
+ ],
354
+ };
355
+ }
356
+ const round1 = (x) => Math.round(x * 10) / 10;
357
+ function runProps(r, shown, o, noFiles) {
358
+ return {
359
+ prehello: {
360
+ note: "All token numbers are estimates (o200k BPE with per-agent scaling priors), not exact for any model. An unmeasured item has no number, never 0. A total marked isLowerBound is 'at least'.",
361
+ tokenizer: r.scan.tokenizer.method,
362
+ claudeProfile: r.scan.claudeProfile,
363
+ windowTokens: r.scan.window,
364
+ redacted: o.strict,
365
+ namesAnonymized: o.anonymize,
366
+ fileLocations: !noFiles,
367
+ partial: r.partial ?? null,
368
+ ...(r.scan.fixturesSkipped ? { fixtureFoldersSkipped: r.scan.fixturesSkipped } : {}),
369
+ duplicatePairsNotListed: r.findings.reduce((n, f) => n + (f.data?.omittedPairs ?? 0), 0),
370
+ agents: shown.map((a) => ({
371
+ id: a.id,
372
+ profile: a.profile.id,
373
+ band: a.profile.band,
374
+ always: { ...{ estimateTokens: a.totals.always.measuredItems ? a.totals.always.est : null, lowTokens: a.totals.always.measuredItems ? a.totals.always.low : null, highTokens: a.totals.always.measuredItems ? a.totals.always.high : null }, isEstimate: true, isLowerBound: a.lowerBound, measuredItems: a.totals.always.measuredItems, unmeasuredItems: a.totals.always.unmeasuredItems },
375
+ percentOfWindow: a.percentOfWindowAlways === null ? null : round1(a.percentOfWindowAlways),
376
+ grade: a.grade ? { letter: a.grade.letter, provisional: a.grade.provisional, heuristic: true } : null,
377
+ savings: a.savings ? { topGroups: a.savings.n, estimateTokens: a.savings.topTokens, isEstimate: true } : null,
378
+ })),
379
+ },
380
+ };
381
+ }
382
+ function agentList(names) {
383
+ if (names.length <= 1)
384
+ return names[0] ?? "the agent";
385
+ return names.slice(0, -1).join(", ") + " and " + names[names.length - 1];
386
+ }
387
+ function finalize(x, r, o) {
388
+ const W = r.scan.window;
389
+ const per = x.props.perAgent ?? [];
390
+ const names = x.agents;
391
+ const d = x.d;
392
+ const fid = d.fid;
393
+ let text = "";
394
+ const sum = (k) => per.map((p) => p[k]).filter((v) => typeof v === "number");
395
+ switch (x.rule) {
396
+ case "PH001": {
397
+ const parts = per.map((p, i) => `${names[i] ?? "the agent"} ~${(0, fmt_1.fmtTok)(p.estimateTokens)}`);
398
+ const pct = Math.max(...sum("percentOfWindow"));
399
+ text = `This file is always loaded before the first message (${agentList(parts)} tokens, estimate; up to ${(0, fmt_1.fmtPct)(pct)} of a ${(0, fmt_1.fmtWindow)(W)} window).${x.loc ? "" : ` Item: ${d.label}.`}`;
400
+ break;
401
+ }
402
+ case "PH002": {
403
+ const p = per[0];
404
+ const cnt = d.kind === "skills" && d.count > 1 ? ` (${plural(d.count, "skill")})` : "";
405
+ text = `"${d.name}"${cnt} adds ~${(0, fmt_1.fmtTok)(p.estimateTokens)} tokens before the first message in ${names[0]} (estimate; ${(0, fmt_1.fmtPct)((p.estimateTokens / W) * 100)} of a ${(0, fmt_1.fmtWindow)(W)} window). Removing it would save about that much.`;
406
+ break;
407
+ }
408
+ case "PH003":
409
+ text = `${names[0]}: always-on context is ${d.lower ? card_1.AT_LEAST + " " : ""}~${(0, fmt_1.fmtTok)(d.est)} tokens (estimate), above --max-tokens ${(0, fmt_1.fmtTok)(d.max)}.${d.lower ? " Some items are unmeasured, so the real number is higher." : ""}`;
410
+ break;
411
+ case "PH004": {
412
+ const KIND_WORDS = { "mcp-server": ["MCP server", "MCP servers"], instructions: ["instruction file", "instruction files"], rule: ["rule", "rules"], memory: ["memory file", "memory files"] };
413
+ const kinds = Object.entries(d.kinds).map(([k, n]) => `${n} ${(KIND_WORDS[k] ?? [k, k])[n === 1 ? 0 : 1]}`).join(", ");
414
+ text = `${names[0]}: ${plural(d.n, "always-loaded item")} could not be measured (${kinds}). ${d.n === 1 ? "Its" : "Their"} token cost is unknown, not zero, so the always-on total${d.measured ? ` of ${card_1.AT_LEAST} ~${(0, fmt_1.fmtTok)(d.est)} tokens (estimate)` : ""} is a lower bound.`;
415
+ break;
416
+ }
417
+ case "PH005": {
418
+ const rel = x.related ? ` The other file: [${x.related.shown}](1).` : x.loc && d.outside > 0 ? " The other file is outside the project, so it is not shown." : "";
419
+ const where = x.loc ? "This file is" : "Two context files are";
420
+ text = `${where} about ${d.similarityPct}% the same text (estimated from shared 5-word sequences).${rel} Keep one copy, or make one the source of truth.`;
421
+ break;
422
+ }
423
+ case "PH006":
424
+ text = `This file is ${d.fileLines} lines, over the documented guideline of ${d.guidelineLines} for ${agentList(names)}; longer files can reduce adherence.`;
425
+ break;
426
+ case "PH007":
427
+ text = fid === "F8" ? `MEMORY.md is over the load cap (200 lines / 25 KB) for ${agentList(names)}; the rest is silently dropped.` : fid === "F12" ? `${agentList(names)} cuts this file off at project_doc_max_bytes; only the first part is loaded.` : `A skill description is over the length cap for ${agentList(names)}; its tail is dropped from the skills listing.`;
428
+ break;
429
+ case "PH008":
430
+ text = fid === "F11" ? `${agentList(names)} ignores this file: only .mdc files in .cursor/rules are loaded.` : `${agentList(names)} ignores this file: a CLAUDE.md exists in the directory tree and takes precedence. Other agents may still load it.`;
431
+ break;
432
+ case "PH009": {
433
+ const ls = d.secretLines ?? [];
434
+ text = `Possible secret on line${ls.length > 1 ? "s" : ""} ${ls.slice(0, 5).join(", ")}${ls.length > 5 ? ", ..." : ""} (value not shown; pattern match, may be a false positive). Agents send this file to a model provider: remove the secret and rotate it.`;
435
+ break;
436
+ }
437
+ case "PH010":
438
+ text = `The skills listing for ${agentList(names)} is estimated above its listing budget; descriptions of rarely used skills are shortened or dropped.`;
439
+ break;
440
+ default:
441
+ text = `Some sources could not be read or were skipped${r.partial ? `, or the scan hit its ${r.partial.reason} budget` : ""}. Every total is a lower bound.`;
442
+ }
443
+ const loc = x.loc;
444
+ const locations = [];
445
+ if (loc)
446
+ locations.push({ physicalLocation: { artifactLocation: { uri: loc.uri, uriBaseId: exports.SRCROOT }, region: { startLine: loc.line } } });
447
+ else if (x.logical)
448
+ locations.push({ logicalLocations: [{ name: x.logical.name, fullyQualifiedName: x.logical.fq, kind: "module" }] });
449
+ const props = { ...x.props };
450
+ if (per.length)
451
+ props.perAgent = per.map((p) => Object.fromEntries(Object.entries(p).filter(([k, v]) => v !== undefined && k !== "tokens")));
452
+ if (names.length)
453
+ props.agents = names;
454
+ props.anonymized = o.anonymize || o.strict;
455
+ const hash = (0, node_crypto_1.createHash)("sha256").update(`${x.rule}|${x.fp}`).digest("hex").slice(0, 32);
456
+ const ri = RULE_INDEX.get(x.rule);
457
+ const sortKey = `${loc?.uri ?? ""}|${x.logical?.fq ?? ""}|${String(1e9 - Math.max(0, ...per.map((p) => p.tokens ?? 0))).padStart(10, "0")}|${text}`;
458
+ return {
459
+ ruleId: x.rule,
460
+ ruleIndex: ri,
461
+ level: x.level,
462
+ message: { text },
463
+ locations,
464
+ ...(x.related ? { relatedLocations: [{ id: 1, physicalLocation: { artifactLocation: { uri: x.related.uri, uriBaseId: exports.SRCROOT }, region: { startLine: x.related.line } }, message: { text: "The other near-duplicate file" } }] } : {}),
465
+ partialFingerprints: { "prehello/v1": hash },
466
+ properties: props,
467
+ sortKey,
468
+ };
469
+ }
470
+ function renderSarif(r, o) {
471
+ return JSON.stringify(toSarif(r, o), null, 2) + "\n";
472
+ }