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
|
@@ -0,0 +1,162 @@
|
|
|
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.PNG_MAGIC = exports.PNG_UNAVAILABLE_MESSAGE = exports.FONT_DIR = exports.PNG_CHAR_EM = exports.PNG_FONT_FAMILY = exports.PngUnavailableError = void 0;
|
|
7
|
+
exports.detectInstallKind = detectInstallKind;
|
|
8
|
+
exports.pngUnavailableMessage = pngUnavailableMessage;
|
|
9
|
+
exports.loadEngine = loadEngine;
|
|
10
|
+
exports.toFontSafeText = toFontSafeText;
|
|
11
|
+
exports.tagPng = tagPng;
|
|
12
|
+
exports.svgToPng = svgToPng;
|
|
13
|
+
/**
|
|
14
|
+
* PNG export of the share card. Pure JS + WebAssembly: no native binaries, no network, no child processes.
|
|
15
|
+
*
|
|
16
|
+
* Engine: `@resvg/resvg-wasm` (MPL-2.0), an OPTIONAL dependency. If it is not installed, prehello says so and SVG keeps working.
|
|
17
|
+
* Fonts: three subsetted static weights of Inter (SIL OFL 1.1, see assets/fonts/OFL.txt) are shipped in the package and handed to the
|
|
18
|
+
* renderer as buffers. System fonts are never used, so the PNG looks the same on every machine and can never show "tofu" boxes
|
|
19
|
+
* for the glyphs a card uses (ASCII, Latin-1, and a handful of punctuation marks).
|
|
20
|
+
*/
|
|
21
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
22
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
23
|
+
class PngUnavailableError extends Error {
|
|
24
|
+
}
|
|
25
|
+
exports.PngUnavailableError = PngUnavailableError;
|
|
26
|
+
/** Family name the PNG variant of the SVG asks for; it matches the name inside the shipped font files. */
|
|
27
|
+
exports.PNG_FONT_FAMILY = "Inter";
|
|
28
|
+
const FONT_FILES = ["Inter-Regular.subset.ttf", "Inter-SemiBold.subset.ttf", "Inter-ExtraBold.subset.ttf"];
|
|
29
|
+
/** Average advance of the shipped Inter weights is 0.46-0.50 em on card text and 0.58-0.61 on caps and digits; layout uses this (the system-font stack uses a wider 0.62). */
|
|
30
|
+
exports.PNG_CHAR_EM = 0.58;
|
|
31
|
+
exports.FONT_DIR = node_path_1.default.join(__dirname, "..", "..", "assets", "fonts");
|
|
32
|
+
function detectInstallKind(dir = __dirname, hasPackageJson = (d) => node_fs_1.default.existsSync(node_path_1.default.join(d, "package.json"))) {
|
|
33
|
+
const hits = [...dir.matchAll(/[\\/]node_modules(?=[\\/]|$)/g)];
|
|
34
|
+
if (!hits.length)
|
|
35
|
+
return "source";
|
|
36
|
+
if (dir.split(/[\\/]+/).includes("_npx"))
|
|
37
|
+
return "npx";
|
|
38
|
+
// <something>/node_modules/prehello/dist/render -> <something> is a project (has package.json) for a local install,
|
|
39
|
+
// and a global prefix (<prefix>/lib or %APPDATA%\npm, no package.json) for `npm install -g`.
|
|
40
|
+
const holder = dir.slice(0, hits[hits.length - 1].index) || "/";
|
|
41
|
+
return hasPackageJson(holder) ? "local" : "global";
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Printed (stderr) when PNG is asked for and the optional engine is missing. Same wording on Linux, macOS and Windows: npm commands
|
|
45
|
+
* are identical there. The detected install kind is marked, the other ways follow, and SVG is always offered.
|
|
46
|
+
*/
|
|
47
|
+
function pngUnavailableMessage(kind = "unknown") {
|
|
48
|
+
const here = (k) => (kind === k ? " <- how this prehello was installed" : "");
|
|
49
|
+
return [
|
|
50
|
+
"PNG cards need the optional package @resvg/resvg-wasm, which is not installed here, so no card was written (everything else in this run completed normally).",
|
|
51
|
+
"Install it, then run the same command again:",
|
|
52
|
+
` global install of prehello: npm install -g @resvg/resvg-wasm${here("global")}`,
|
|
53
|
+
` project install: npm install @resvg/resvg-wasm (run it in the project that has prehello in node_modules)${here("local")}`,
|
|
54
|
+
` one-off with npx: npx --package prehello --package @resvg/resvg-wasm -- prehello --card=card.png${here("npx")}`,
|
|
55
|
+
...(kind === "source" ? [" from a source checkout: npm install (in the prehello folder) <- how this prehello is running"] : []),
|
|
56
|
+
" if you installed with --omit=optional (or npm config optional=false), reinstall without it.",
|
|
57
|
+
"Or skip PNG: an SVG card needs no extra install, e.g. --card=card.svg (browsers and most image tools open it).",
|
|
58
|
+
].join("\n");
|
|
59
|
+
}
|
|
60
|
+
/** Generic form of the message (install kind unknown). */
|
|
61
|
+
exports.PNG_UNAVAILABLE_MESSAGE = pngUnavailableMessage();
|
|
62
|
+
let engine;
|
|
63
|
+
/** Loads the optional engine once. `resolver` exists so tests can simulate a missing dependency. */
|
|
64
|
+
function loadEngine(resolver = require.resolve) {
|
|
65
|
+
if (engine)
|
|
66
|
+
return engine;
|
|
67
|
+
const p = (async () => {
|
|
68
|
+
let mod;
|
|
69
|
+
let wasmPath;
|
|
70
|
+
try {
|
|
71
|
+
wasmPath = resolver("@resvg/resvg-wasm/index_bg.wasm");
|
|
72
|
+
mod = require(resolver("@resvg/resvg-wasm"));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
throw new PngUnavailableError(pngUnavailableMessage(detectInstallKind()));
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
await mod.initWasm(node_fs_1.default.readFileSync(wasmPath));
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
const msg = e.message;
|
|
82
|
+
// initWasm throws "Already initialized" when something else in the process initialised it: harmless.
|
|
83
|
+
if (!/already initialized/i.test(msg))
|
|
84
|
+
throw new PngUnavailableError(`PNG export could not start its WebAssembly renderer (${msg}). Reinstalling it may help: npm install @resvg/resvg-wasm. Or write an SVG instead: --card=card.svg`);
|
|
85
|
+
}
|
|
86
|
+
return mod;
|
|
87
|
+
})();
|
|
88
|
+
// a failed load is not cached when the caller injected a resolver (tests); the real one is cached either way
|
|
89
|
+
if (resolver === require.resolve)
|
|
90
|
+
engine = p;
|
|
91
|
+
return p;
|
|
92
|
+
}
|
|
93
|
+
/** Every glyph the card can print is in the font subset; anything else (never expected) becomes "?" rather than a tofu box. */
|
|
94
|
+
function toFontSafeText(svg) {
|
|
95
|
+
return svg.replace(/[^\x09\x0a\x0d\x20-\x7e\u00a0-\u00ff\u2013\u2014\u2018\u2019\u201c\u201d\u2022\u2026\u2192\u2212\u2248\u2264\u2265]/gu, "?");
|
|
96
|
+
}
|
|
97
|
+
const CRC_TABLE = (() => {
|
|
98
|
+
const t = new Uint32Array(256);
|
|
99
|
+
for (let n = 0; n < 256; n++) {
|
|
100
|
+
let c = n;
|
|
101
|
+
for (let k = 0; k < 8; k++)
|
|
102
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
103
|
+
t[n] = c >>> 0;
|
|
104
|
+
}
|
|
105
|
+
return t;
|
|
106
|
+
})();
|
|
107
|
+
function crc32(buf) {
|
|
108
|
+
let c = 0xffffffff;
|
|
109
|
+
for (let i = 0; i < buf.length; i++)
|
|
110
|
+
c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
|
111
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
112
|
+
}
|
|
113
|
+
function textChunk(keyword, text) {
|
|
114
|
+
const data = Buffer.concat([Buffer.from(keyword, "latin1"), Buffer.from([0]), Buffer.from(text, "latin1")]);
|
|
115
|
+
const body = Buffer.concat([Buffer.from("tEXt", "latin1"), data]);
|
|
116
|
+
const len = Buffer.alloc(4);
|
|
117
|
+
len.writeUInt32BE(data.length);
|
|
118
|
+
const crc = Buffer.alloc(4);
|
|
119
|
+
crc.writeUInt32BE(crc32(body));
|
|
120
|
+
return Buffer.concat([len, body, crc]);
|
|
121
|
+
}
|
|
122
|
+
/** Adds `Software` and `Description` tEXt chunks right after IHDR. This is how prehello recognises its own PNG before replacing it. */
|
|
123
|
+
function tagPng(png, version) {
|
|
124
|
+
const ihdrEnd = 8 + 4 + 4 + 13 + 4; // signature + length + type + data + crc
|
|
125
|
+
return Buffer.concat([
|
|
126
|
+
png.subarray(0, ihdrEnd),
|
|
127
|
+
textChunk("Software", `prehello ${version}`),
|
|
128
|
+
textChunk("Description", "Share card generated locally by prehello. Token counts are estimates."),
|
|
129
|
+
png.subarray(ihdrEnd),
|
|
130
|
+
]);
|
|
131
|
+
}
|
|
132
|
+
exports.PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
133
|
+
/** Rasterises a card SVG (made with `fontFamily: PNG_FONT_FAMILY`) to a PNG buffer. Throws PngUnavailableError when the engine is missing. */
|
|
134
|
+
async function svgToPng(svg, o) {
|
|
135
|
+
const { Resvg } = await loadEngine(o.resolver);
|
|
136
|
+
let fontBuffers;
|
|
137
|
+
try {
|
|
138
|
+
fontBuffers = FONT_FILES.map((f) => new Uint8Array(node_fs_1.default.readFileSync(node_path_1.default.join(exports.FONT_DIR, f))));
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
throw new PngUnavailableError("PNG export could not read prehello's bundled fonts (assets/fonts is missing from this install). Reinstall prehello, or write an SVG instead: --card=card.svg");
|
|
142
|
+
}
|
|
143
|
+
const width = Number(/<svg[^>]*\swidth="(\d+)"/.exec(svg)?.[1] ?? 1200);
|
|
144
|
+
try {
|
|
145
|
+
const r = new Resvg(toFontSafeText(svg), {
|
|
146
|
+
font: { fontBuffers, defaultFontFamily: exports.PNG_FONT_FAMILY, sansSerifFamily: exports.PNG_FONT_FAMILY },
|
|
147
|
+
fitTo: { mode: "width", value: width * o.scale },
|
|
148
|
+
textRendering: 1,
|
|
149
|
+
shapeRendering: 2,
|
|
150
|
+
});
|
|
151
|
+
try {
|
|
152
|
+
return tagPng(Buffer.from(r.render().asPng()), o.version);
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
r.free();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
catch (e) {
|
|
159
|
+
// never a stack trace: a renderer failure is reported like a missing engine, and SVG keeps working
|
|
160
|
+
throw new PngUnavailableError(`PNG rendering failed (${String(e?.message ?? e).slice(0, 200)}). Write an SVG instead: --card=card.svg`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -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}\nThe rule list is in docs/REFERENCE.md (SARIF) in the prehello repository.`, markdown: `${d.help}\n\nThe rule list is in docs/REFERENCE.md (SARIF) in the prehello repository.` },
|
|
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 factors), 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
|
+
}
|