prehello 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/LICENSE +21 -0
- package/README.md +515 -2
- package/THIRD_PARTY_NOTICES.md +33 -0
- package/assets/fonts/Inter-ExtraBold.subset.ttf +0 -0
- package/assets/fonts/Inter-Regular.subset.ttf +0 -0
- package/assets/fonts/Inter-SemiBold.subset.ttf +0 -0
- package/assets/fonts/OFL.txt +94 -0
- package/dist/adapters/claude-code.js +480 -0
- package/dist/adapters/codex.js +184 -0
- package/dist/adapters/common.js +157 -0
- package/dist/adapters/cursor.js +257 -0
- package/dist/adapters/skills.js +155 -0
- package/dist/adapters/types.js +2 -0
- package/dist/analyze/findings.js +82 -0
- package/dist/analyze/grade.js +55 -0
- package/dist/analyze/savings.js +111 -0
- package/dist/analyze/summary.js +57 -0
- package/dist/analyze/totals.js +21 -0
- package/dist/cli.js +454 -0
- package/dist/context.js +331 -0
- package/dist/fixtures.js +52 -0
- package/dist/fs-safe.js +222 -0
- package/dist/mcp/catalog.js +44 -0
- package/dist/mcp/configs.js +42 -0
- package/dist/model.js +11 -0
- package/dist/platform.js +115 -0
- package/dist/redact.js +39 -0
- package/dist/render/card.js +305 -0
- package/dist/render/fix.js +22 -0
- package/dist/render/json.js +97 -0
- package/dist/render/markdown.js +182 -0
- package/dist/render/png.js +162 -0
- package/dist/render/sarif.js +472 -0
- package/dist/render/terminal.js +329 -0
- package/dist/render/treemap.js +58 -0
- package/dist/safe-write.js +120 -0
- package/dist/scan.js +104 -0
- package/dist/spec.js +27 -0
- package/dist/text/comments.js +60 -0
- package/dist/text/frontmatter.js +90 -0
- package/dist/text/imports.js +81 -0
- package/dist/tokens/counter.js +41 -0
- package/dist/tokens/families.js +47 -0
- package/dist/util/fmt.js +102 -0
- package/dist/util/secrets.js +35 -0
- package/package.json +66 -4
- package/spec/claude-code.json +22 -0
- package/spec/codex.json +14 -0
- package/spec/cursor.json +15 -0
- package/spec/mcp-weights.json +62 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MAX_MCP_SERVERS = void 0;
|
|
4
|
+
exports.parseMcpMap = parseMcpMap;
|
|
5
|
+
exports.parseCodexToml = parseCodexToml;
|
|
6
|
+
const smol_toml_1 = require("smol-toml");
|
|
7
|
+
const fmt_1 = require("../util/fmt");
|
|
8
|
+
function isObj(x) {
|
|
9
|
+
return typeof x === "object" && x !== null && !Array.isArray(x);
|
|
10
|
+
}
|
|
11
|
+
function guessPkg(entry) {
|
|
12
|
+
const args = Array.isArray(entry.args) ? entry.args.filter((a) => typeof a === "string") : [];
|
|
13
|
+
const cmd = typeof entry.command === "string" ? entry.command : "";
|
|
14
|
+
if (!/(^|\/)(npx|bunx|pnpx|uvx|pipx)$/.test(cmd) && !/^(npx|bunx|pnpx|uvx|pipx)$/.test(cmd))
|
|
15
|
+
return undefined;
|
|
16
|
+
const a = args.find((x) => !x.startsWith("-"));
|
|
17
|
+
return a ? a.replace(/@[\w.^~<>=*-]+$/, (m) => (a.startsWith("@") && a.indexOf("@", 1) === -1 ? m : "")) : undefined;
|
|
18
|
+
}
|
|
19
|
+
/** A real config has a handful of servers. A hostile one with 200,000 entries must not become a 180 MB report. */
|
|
20
|
+
exports.MAX_MCP_SERVERS = 500;
|
|
21
|
+
/** Extract server definitions from a `{ mcpServers: {...} }`-style map. Values (env, headers, args) are never retained. */
|
|
22
|
+
function parseMcpMap(map, warn) {
|
|
23
|
+
if (!isObj(map))
|
|
24
|
+
return [];
|
|
25
|
+
const out = [];
|
|
26
|
+
const names = Object.keys(map);
|
|
27
|
+
if (names.length > exports.MAX_MCP_SERVERS)
|
|
28
|
+
warn?.(`an MCP config lists ${names.length} servers; only the first ${exports.MAX_MCP_SERVERS} were read, so MCP counts are a lower bound`);
|
|
29
|
+
for (const name of names.slice(0, exports.MAX_MCP_SERVERS)) {
|
|
30
|
+
const v = map[name];
|
|
31
|
+
if (!isObj(v))
|
|
32
|
+
continue;
|
|
33
|
+
const t = typeof v.type === "string" ? v.type : typeof v.transport === "string" ? v.transport : undefined;
|
|
34
|
+
const transport = t === "http" || t === "streamable-http" ? "http" : t === "sse" ? "sse" : t === "stdio" || typeof v.command === "string" ? "stdio" : typeof v.url === "string" ? "http" : "unknown";
|
|
35
|
+
out.push({ name: (0, fmt_1.sanitize)(name, 60), transport, pkg: guessPkg(v), disabled: v.disabled === true || v.enabled === false });
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
function parseCodexToml(text, warn) {
|
|
40
|
+
const cfg = (0, smol_toml_1.parse)(text);
|
|
41
|
+
return { servers: parseMcpMap(cfg.mcp_servers, warn), config: cfg };
|
|
42
|
+
}
|
package/dist/model.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LOAD_MODES = exports.ALL_AGENTS = exports.AGENT_NAMES = exports.SCHEMA_VERSION = void 0;
|
|
4
|
+
exports.SCHEMA_VERSION = 1;
|
|
5
|
+
exports.AGENT_NAMES = {
|
|
6
|
+
"claude-code": "Claude Code",
|
|
7
|
+
cursor: "Cursor",
|
|
8
|
+
codex: "Codex",
|
|
9
|
+
};
|
|
10
|
+
exports.ALL_AGENTS = ["claude-code", "cursor", "codex"];
|
|
11
|
+
exports.LOAD_MODES = ["always", "conditional", "deferred", "on-demand", "unknown"];
|
package/dist/platform.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
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.isWinAbsolute = isWinAbsolute;
|
|
7
|
+
exports.resolveHomeDir = resolveHomeDir;
|
|
8
|
+
exports.isWindowsReservedName = isWindowsReservedName;
|
|
9
|
+
exports.hasWindowsStream = hasWindowsStream;
|
|
10
|
+
exports.unicodeSupported = unicodeSupported;
|
|
11
|
+
exports.toAscii = toAscii;
|
|
12
|
+
exports.toPosix = toPosix;
|
|
13
|
+
exports.makeGlobMatcher = makeGlobMatcher;
|
|
14
|
+
exports.isInsideWith = isInsideWith;
|
|
15
|
+
exports.decodeUserText = decodeUserText;
|
|
16
|
+
/**
|
|
17
|
+
* Platform helpers: everything that differs between Linux, macOS and Windows lives here, as pure functions that take the
|
|
18
|
+
* platform (and a `path` implementation) as arguments, so the Windows behaviour can be unit-tested on any machine.
|
|
19
|
+
*/
|
|
20
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
21
|
+
const picomatch_1 = __importDefault(require("picomatch"));
|
|
22
|
+
/** Is `p` an absolute path in the Windows sense (C:\x, C:/x, \\server\share)? Works on any host. */
|
|
23
|
+
function isWinAbsolute(p) {
|
|
24
|
+
return node_path_1.default.win32.isAbsolute(p) && /^(?:[a-zA-Z]:[\\/]|\\\\|\/\/)/.test(p);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The home directory the AGENTS use. Claude Code, Cursor (Electron) and Codex all take it from the OS, not from $HOME:
|
|
28
|
+
* on Windows that is %USERPROFILE%. Git Bash sets HOME to a POSIX-style "/c/Users/me" that Node cannot open, and a network HOME
|
|
29
|
+
* ("H:\") can differ from the profile folder the agents read, so on Windows HOME is only a last resort.
|
|
30
|
+
*/
|
|
31
|
+
function resolveHomeDir(env, platform, osHomedir) {
|
|
32
|
+
if (platform === "win32") {
|
|
33
|
+
if (env.USERPROFILE && isWinAbsolute(env.USERPROFILE))
|
|
34
|
+
return env.USERPROFILE;
|
|
35
|
+
if (env.HOMEDRIVE && env.HOMEPATH && isWinAbsolute(env.HOMEDRIVE + env.HOMEPATH))
|
|
36
|
+
return env.HOMEDRIVE + env.HOMEPATH;
|
|
37
|
+
if (env.HOME && isWinAbsolute(env.HOME))
|
|
38
|
+
return env.HOME;
|
|
39
|
+
return osHomedir();
|
|
40
|
+
}
|
|
41
|
+
if (env.HOME && node_path_1.default.posix.isAbsolute(env.HOME))
|
|
42
|
+
return env.HOME;
|
|
43
|
+
return osHomedir();
|
|
44
|
+
}
|
|
45
|
+
/** Windows device names: opening "nul.svg" or "COM1.md" writes to a device (silently discarding the report), whatever the extension. */
|
|
46
|
+
function isWindowsReservedName(file) {
|
|
47
|
+
const base = file.split(/[\\/]/).pop() ?? "";
|
|
48
|
+
const stem = base.split(".")[0].replace(/[ .]+$/, "");
|
|
49
|
+
return /^(con|prn|aux|nul|conin\$|conout\$|com[0-9\u00b9\u00b2\u00b3]|lpt[0-9\u00b9\u00b2\u00b3])$/i.test(stem);
|
|
50
|
+
}
|
|
51
|
+
/** On Windows a ":" after the drive letter is an NTFS alternate data stream ("report.md:hidden"): never write one. */
|
|
52
|
+
function hasWindowsStream(file) {
|
|
53
|
+
return file.replace(/^(?:\\\\\?\\)?[a-zA-Z]:/, "").includes(":");
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Can this terminal draw the box/block/arrow glyphs prehello uses? Mirrors the widely used "is-unicode-supported" rule:
|
|
57
|
+
* the Linux virtual console (TERM=linux) and the legacy Windows console host (cmd.exe / classic PowerShell window) cannot,
|
|
58
|
+
* Windows Terminal, VS Code, JetBrains, ConEmu, Alacritty and modern xterm-likes can.
|
|
59
|
+
*/
|
|
60
|
+
function unicodeSupported(env, platform) {
|
|
61
|
+
if (env.PREHELLO_ASCII === "1")
|
|
62
|
+
return false;
|
|
63
|
+
if (platform !== "win32")
|
|
64
|
+
return env.TERM !== "linux";
|
|
65
|
+
return (!!env.WT_SESSION ||
|
|
66
|
+
!!env.TERMINUS_SUBLIME ||
|
|
67
|
+
env.ConEmuTask === "{cmd::Cmder}" ||
|
|
68
|
+
env.TERM_PROGRAM === "vscode" ||
|
|
69
|
+
env.TERM_PROGRAM === "Terminus-Sublime" ||
|
|
70
|
+
env.TERM === "xterm-256color" ||
|
|
71
|
+
env.TERM === "alacritty" ||
|
|
72
|
+
env.TERMINAL_EMULATOR === "JetBrains-JediTerm");
|
|
73
|
+
}
|
|
74
|
+
const ASCII_MAP = {
|
|
75
|
+
"·": "-", "–": "-", "—": "-", "−": "-", "…": "...", "≥": ">=", "≤": "<=", "→": "->", "⚠": "!", "ℹ": "i", "•": "*",
|
|
76
|
+
"█": "#", "▌": "=", "‘": "'", "’": "'", "“": '"', "”": '"', "≈": "~", "×": "x",
|
|
77
|
+
};
|
|
78
|
+
/** Replace every non-ASCII glyph prehello prints with an ASCII stand-in (accents and other letters become "?"). */
|
|
79
|
+
function toAscii(s) {
|
|
80
|
+
// eslint-disable-next-line no-control-regex
|
|
81
|
+
return s.replace(/[^\x00-\x7f]/gu, (c) => ASCII_MAP[c] ?? "?");
|
|
82
|
+
}
|
|
83
|
+
function toPosix(p) {
|
|
84
|
+
return p.replace(/\\/g, "/");
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Glob matcher for settings such as claudeMdExcludes. Always matches forward-slash paths: a Windows path is converted first,
|
|
88
|
+
* and backslashes in a pattern are path separators there (not escapes). Windows paths are case-insensitive.
|
|
89
|
+
*/
|
|
90
|
+
function makeGlobMatcher(patterns, platform) {
|
|
91
|
+
const win = platform === "win32";
|
|
92
|
+
const pats = win ? patterns.map(toPosix) : patterns;
|
|
93
|
+
const m = (0, picomatch_1.default)(pats, { dot: true, windows: false, nocase: win });
|
|
94
|
+
return (file) => m(win ? toPosix(file) : file);
|
|
95
|
+
}
|
|
96
|
+
/** True if `child` is `parent` or inside it. A directory called "..foo" is inside; "../x" is not. */
|
|
97
|
+
function isInsideWith(pm, child, parent) {
|
|
98
|
+
const rel = pm.relative(parent, child);
|
|
99
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(".." + pm.sep) && !pm.isAbsolute(rel));
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Text a USER hands to prehello (--mcp-catalog file, --paste-user-rules file or stdin). Windows editors and PowerShell 5 write
|
|
103
|
+
* a UTF-8 BOM or UTF-16: both are decoded here instead of failing as "malformed JSON" or counting a BOM as a token.
|
|
104
|
+
* CRLF is normalised to LF for pasted rules so the same text counts the same on every platform.
|
|
105
|
+
*/
|
|
106
|
+
function decodeUserText(buf) {
|
|
107
|
+
let s;
|
|
108
|
+
if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe)
|
|
109
|
+
s = buf.subarray(2).toString("utf16le");
|
|
110
|
+
else if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff)
|
|
111
|
+
s = Buffer.from(buf.subarray(2)).swap16().toString("utf16le");
|
|
112
|
+
else
|
|
113
|
+
s = buf.toString("utf8");
|
|
114
|
+
return s.replace(/^\ufeff/, "");
|
|
115
|
+
}
|
package/dist/redact.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
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.scrubSecrets = exports.findSecretLines = void 0;
|
|
7
|
+
exports.displayPath = displayPath;
|
|
8
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
9
|
+
const fmt_1 = require("./util/fmt");
|
|
10
|
+
const secrets_1 = require("./util/secrets");
|
|
11
|
+
Object.defineProperty(exports, "findSecretLines", { enumerable: true, get: function () { return secrets_1.findSecretLines; } });
|
|
12
|
+
Object.defineProperty(exports, "scrubSecrets", { enumerable: true, get: function () { return secrets_1.scrubSecrets; } });
|
|
13
|
+
/** True when `rel` (from path.relative) leaves its base: "..", "../x", but not a child called "..foo". */
|
|
14
|
+
function escapes(pm, rel) {
|
|
15
|
+
return rel === ".." || rel.startsWith(".." + pm.sep) || pm.isAbsolute(rel);
|
|
16
|
+
}
|
|
17
|
+
/** Display form of a path: ./relative inside the project, ~/ inside HOME, else absolute. Always forward slashes. Sanitised + scrubbed. */
|
|
18
|
+
function displayPath(p, c) {
|
|
19
|
+
const pm = c.pathMod ?? node_path_1.default;
|
|
20
|
+
const slash = (x) => x.split(pm.sep).join("/");
|
|
21
|
+
let out;
|
|
22
|
+
const rel = pm.relative(c.cwd, p);
|
|
23
|
+
if (rel === "")
|
|
24
|
+
out = ".";
|
|
25
|
+
else if (!escapes(pm, rel))
|
|
26
|
+
out = "./" + slash(rel);
|
|
27
|
+
else {
|
|
28
|
+
const hr = pm.relative(c.home, p);
|
|
29
|
+
if (hr === "")
|
|
30
|
+
out = "~";
|
|
31
|
+
else if (!escapes(pm, hr))
|
|
32
|
+
out = "~/" + slash(hr);
|
|
33
|
+
else if (rel.split(pm.sep).filter((x) => x === "..").length <= 4 && !pm.isAbsolute(rel))
|
|
34
|
+
out = slash(rel); // ../../CLAUDE.md (ancestor directory)
|
|
35
|
+
else
|
|
36
|
+
out = slash(p); // absolute elsewhere; on Windows "C:/Users/..." rather than backslashes (JSON/markdown-safe)
|
|
37
|
+
}
|
|
38
|
+
return (0, secrets_1.scrubSecrets)((0, fmt_1.sanitize)(out, 200));
|
|
39
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CARD_FONT = exports.THEMES = exports.AT_LEAST = exports.MIN_CUT_HEADLINE_TOKENS = void 0;
|
|
4
|
+
exports.buildHeadline = buildHeadline;
|
|
5
|
+
exports.assertHeadline = assertHeadline;
|
|
6
|
+
exports.pickCardAgent = pickCardAgent;
|
|
7
|
+
exports.wrapLabel = wrapLabel;
|
|
8
|
+
exports.renderCard = renderCard;
|
|
9
|
+
const fmt_1 = require("../util/fmt");
|
|
10
|
+
const treemap_1 = require("./treemap");
|
|
11
|
+
const savings_1 = require("../analyze/savings");
|
|
12
|
+
const secrets_1 = require("../util/secrets");
|
|
13
|
+
/** A saving smaller than this (estimated tokens) is noise, not a headline. Same threshold as the cut list itself. */
|
|
14
|
+
exports.MIN_CUT_HEADLINE_TOKENS = 500;
|
|
15
|
+
/** Wording of the lower-bound marker. Words, not a glyph: "≥" is missing from several fallback fonts used by SVG renderers. */
|
|
16
|
+
exports.AT_LEAST = "at least";
|
|
17
|
+
/**
|
|
18
|
+
* Headline templates. Each one is selected by data and re-validated by assertHeadline():
|
|
19
|
+
* no claim is rendered unless it is true for the scanned data (plan §10.1 guard).
|
|
20
|
+
*/
|
|
21
|
+
function buildHeadline(a, window, variant = "cut") {
|
|
22
|
+
const t = a.totals.always;
|
|
23
|
+
const lower = a.lowerBound ? `${exports.AT_LEAST} ` : "";
|
|
24
|
+
// The cut headline needs measured always-on data AND a saving that is worth saying out loud; otherwise the total headline is used.
|
|
25
|
+
if (variant === "cut" && a.savings && t.measuredItems > 0 && a.savings.topTokens >= exports.MIN_CUT_HEADLINE_TOKENS) {
|
|
26
|
+
const sv = a.savings;
|
|
27
|
+
const what = sv.top.some((c) => c.kind === "mcp") ? "Skills, plugins & MCP" : "Skills & plugins";
|
|
28
|
+
return {
|
|
29
|
+
id: "cut-list",
|
|
30
|
+
hero: `${what} cost ${lower}~${(0, fmt_1.fmtTok)(sv.groupsTokens)} tokens before hello`,
|
|
31
|
+
sub: `${sv.n === sv.groupsCount ? `all ${sv.n} group${sv.n > 1 ? "s" : ""}` : `top ${sv.n} of ${sv.groupsCount} groups`}: cut ${sv.n > 1 ? "them" : "it"}, save ~${(0, fmt_1.fmtTok)(sv.topTokens)} (estimate)`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
if (t.measuredItems > 0 && t.est > 0) {
|
|
35
|
+
const pct = (t.est / window) * 100;
|
|
36
|
+
return {
|
|
37
|
+
id: "tokens-before-hello",
|
|
38
|
+
hero: `${lower}~${(0, fmt_1.fmtTok)(t.est)} tokens load before hello`,
|
|
39
|
+
sub: `${pct < 0.1 ? (a.lowerBound ? "under 0.1% so far" : "under 0.1%") : (a.lowerBound ? exports.AT_LEAST + " " : "about ") + (0, fmt_1.fmtPct)(pct)} of a ${(0, fmt_1.fmtWindow)(window)} window · ${a.name} · estimate`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
if (t.unmeasuredItems > 0) {
|
|
43
|
+
const n = t.unmeasuredItems;
|
|
44
|
+
// only claim "MCP servers" when every unmeasured always-on item really is one (an unreadable CLAUDE.md is not a server)
|
|
45
|
+
const un = a.items.filter((i) => i.loadMode === "always" && i.tokens === null);
|
|
46
|
+
const mcpOnly = un.length > 0 && un.every((i) => i.kind === "mcp-server");
|
|
47
|
+
return { id: "mcp-unmeasured", hero: mcpOnly ? `${n} MCP server${n > 1 ? "s" : ""} load before hello` : `${n} always-loaded item${n > 1 ? "s" : ""} could not be measured`, sub: `${a.name} · token cost unmeasured (not zero)` };
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
function assertHeadline(h, a, window) {
|
|
52
|
+
const t = a.totals.always;
|
|
53
|
+
if (h.id === "tokens-before-hello") {
|
|
54
|
+
if (!(t.measuredItems > 0 && t.est > 0))
|
|
55
|
+
throw new Error("headline guard: tokens claim without measured always-on data");
|
|
56
|
+
if (a.lowerBound && !h.hero.startsWith(exports.AT_LEAST))
|
|
57
|
+
throw new Error("headline guard: lower-bound marker missing");
|
|
58
|
+
if (!h.hero.includes("~"))
|
|
59
|
+
throw new Error("headline guard: estimate marker missing");
|
|
60
|
+
if (/%/.test(h.hero))
|
|
61
|
+
throw new Error("headline guard: unexpected percentage in hero");
|
|
62
|
+
}
|
|
63
|
+
else if (h.id === "cut-list") {
|
|
64
|
+
if (!a.savings || !(t.measuredItems > 0))
|
|
65
|
+
throw new Error("headline guard: cut-list claim without savings data");
|
|
66
|
+
if (a.savings.topTokens < exports.MIN_CUT_HEADLINE_TOKENS)
|
|
67
|
+
throw new Error("headline guard: cut-list claim below the minimum saving");
|
|
68
|
+
if (!h.hero.includes("~") || !/estimate/.test(h.sub))
|
|
69
|
+
throw new Error("headline guard: estimate marker missing");
|
|
70
|
+
if (a.lowerBound && !/ cost at least /.test(h.hero))
|
|
71
|
+
throw new Error("headline guard: lower-bound marker missing");
|
|
72
|
+
}
|
|
73
|
+
else if (h.id === "mcp-unmeasured") {
|
|
74
|
+
if (t.measuredItems > 0 || t.unmeasuredItems === 0)
|
|
75
|
+
throw new Error("headline guard: mcp claim without unmeasured-only data");
|
|
76
|
+
if (/\d+(\.\d+)?k? tokens/.test(h.hero + h.sub))
|
|
77
|
+
throw new Error("headline guard: token number for unmeasured data");
|
|
78
|
+
}
|
|
79
|
+
void window;
|
|
80
|
+
}
|
|
81
|
+
function pickCardAgent(r, want) {
|
|
82
|
+
const shown = r.agents.filter((a) => !a.presentInRepoOnly);
|
|
83
|
+
if (want)
|
|
84
|
+
return shown.find((a) => a.id === want) ?? null;
|
|
85
|
+
const withData = shown.filter((a) => buildHeadline(a, r.scan.window));
|
|
86
|
+
withData.sort((a, b) => b.totals.always.est - a.totals.always.est || b.totals.always.unmeasuredItems - a.totals.always.unmeasuredItems);
|
|
87
|
+
return withData[0] ?? null;
|
|
88
|
+
}
|
|
89
|
+
const FILLS = ["#1f6feb", "#8250df", "#1a7f37", "#9a6700", "#bf3989", "#0a7a8a", "#bc4c00"];
|
|
90
|
+
const OTHER_FILL = "#57606a";
|
|
91
|
+
exports.THEMES = {
|
|
92
|
+
dark: { bg: "#0d1117", text: "#e6edf3", muted: "#9da7b3", chip: "#21262d", border: "#30363d" },
|
|
93
|
+
light: { bg: "#ffffff", text: "#1f2328", muted: "#4b5563", chip: "#eef1f4", border: "#d0d7de" },
|
|
94
|
+
};
|
|
95
|
+
function fit(text, maxChars) {
|
|
96
|
+
if (text.length <= maxChars)
|
|
97
|
+
return text;
|
|
98
|
+
return maxChars <= 3 ? text.slice(0, Math.max(0, maxChars)) : text.slice(0, maxChars - 3) + "...";
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Font stack with sane fallbacks: SVG renderers (browsers, rsvg, Inkscape, link-preview bots) differ in which of these exist,
|
|
102
|
+
* and `system-ui` is unknown to some of them. It always ends in the generic `sans-serif`, and every width estimate below assumes
|
|
103
|
+
* a wide face (DejaVu Sans is ~0.62 em per character) so text still fits when the fallback is the widest one.
|
|
104
|
+
*/
|
|
105
|
+
exports.CARD_FONT = "system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Liberation Sans', 'DejaVu Sans', sans-serif";
|
|
106
|
+
const DEFAULT_CHAR_EM = 0.62;
|
|
107
|
+
/** Greedy wrap of a tile label at spaces and after hyphens; the last allowed line ends in "..." when text is left over. Never returns an empty list. */
|
|
108
|
+
function wrapLabel(label, maxChars, maxLines) {
|
|
109
|
+
const toks = label.split(/(?<=-)|\s+/).filter(Boolean);
|
|
110
|
+
const lines = [];
|
|
111
|
+
let cur = "";
|
|
112
|
+
const rest = [];
|
|
113
|
+
for (let i = 0; i < toks.length; i++) {
|
|
114
|
+
const t = toks[i];
|
|
115
|
+
const joined = cur ? (cur.endsWith("-") ? cur + t : `${cur} ${t}`) : t;
|
|
116
|
+
if (!cur || joined.length <= maxChars)
|
|
117
|
+
cur = joined;
|
|
118
|
+
else {
|
|
119
|
+
lines.push(cur);
|
|
120
|
+
cur = t;
|
|
121
|
+
}
|
|
122
|
+
if (lines.length === maxLines) {
|
|
123
|
+
rest.push(...toks.slice(i));
|
|
124
|
+
cur = "";
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (cur)
|
|
129
|
+
lines.push(cur);
|
|
130
|
+
if (lines.length > maxLines)
|
|
131
|
+
lines.length = maxLines;
|
|
132
|
+
const out = lines.map((l) => (l.length > maxChars ? fit(l, maxChars) : l));
|
|
133
|
+
if (rest.length > 0)
|
|
134
|
+
out[out.length - 1] = fit(out[out.length - 1] + "...", maxChars);
|
|
135
|
+
return out.length ? out : [fit(label, maxChars)];
|
|
136
|
+
}
|
|
137
|
+
/** Greedy wrap on " · " boundaries so a footer never runs off the card. */
|
|
138
|
+
function wrapSegments(segments, maxChars) {
|
|
139
|
+
const lines = [];
|
|
140
|
+
let cur = "";
|
|
141
|
+
for (const seg of segments) {
|
|
142
|
+
const next = cur ? `${cur} · ${seg}` : seg;
|
|
143
|
+
if (cur && next.length > maxChars) {
|
|
144
|
+
lines.push(cur);
|
|
145
|
+
cur = seg;
|
|
146
|
+
}
|
|
147
|
+
else
|
|
148
|
+
cur = next;
|
|
149
|
+
}
|
|
150
|
+
if (cur)
|
|
151
|
+
lines.push(cur);
|
|
152
|
+
return lines.map((l) => fit(l, maxChars));
|
|
153
|
+
}
|
|
154
|
+
/** No paths or file contents by construction: only cut-list group names (plugin / MCP server names; anonymisable), generic labels (cardLabel), counts, the version and a date reach the SVG. */
|
|
155
|
+
function renderCard(r, a, o) {
|
|
156
|
+
const CHAR_EM = o.charEm ?? DEFAULT_CHAR_EM;
|
|
157
|
+
const maxCharsFor = (px, fontSize) => Math.max(1, Math.floor(px / (fontSize * CHAR_EM)));
|
|
158
|
+
const head = buildHeadline(a, r.scan.window, o.headline ?? "cut") ?? buildHeadline(a, r.scan.window, "total");
|
|
159
|
+
if (!head)
|
|
160
|
+
throw new Error("nothing measurable to put on a card");
|
|
161
|
+
assertHeadline(head, a, r.scan.window);
|
|
162
|
+
const th = exports.THEMES[o.theme];
|
|
163
|
+
const og = o.size === "og";
|
|
164
|
+
const W = og ? 1200 : 1080;
|
|
165
|
+
const H = og ? 630 : 1080;
|
|
166
|
+
const pad = og ? 48 : 56;
|
|
167
|
+
const innerW = W - pad * 2;
|
|
168
|
+
const font = o.fontFamily ?? exports.CARD_FONT;
|
|
169
|
+
const heroMax = og ? 64 : 72;
|
|
170
|
+
let heroLines = [head.hero];
|
|
171
|
+
let heroSize = Math.min(heroMax, Math.floor(innerW / (head.hero.length * CHAR_EM)));
|
|
172
|
+
// Square cards have the room for a two-line hero: split it at the space nearest the middle when one line would be small.
|
|
173
|
+
if (!og && heroSize < 52 && head.hero.includes(" ")) {
|
|
174
|
+
const mid = head.hero.length / 2;
|
|
175
|
+
const cut = [...head.hero.matchAll(/ /g)].map((m) => m.index).sort((x, y) => Math.abs(x - mid) - Math.abs(y - mid))[0];
|
|
176
|
+
heroLines = [head.hero.slice(0, cut), head.hero.slice(cut + 1)];
|
|
177
|
+
heroSize = Math.min(heroMax, Math.floor(innerW / (Math.max(...heroLines.map((l) => l.length)) * CHAR_EM)));
|
|
178
|
+
}
|
|
179
|
+
const heroLineH = Math.round(heroSize * 1.12);
|
|
180
|
+
const heroY = og ? 168 : heroLines.length > 1 ? 196 : 214;
|
|
181
|
+
const subSize = og ? 26 : 30;
|
|
182
|
+
const subY = heroY + (heroLines.length - 1) * heroLineH + (og ? 46 : 52);
|
|
183
|
+
const showStrip = !!a.savings && head.id !== "cut-list";
|
|
184
|
+
const tmY = subY + (og ? 36 : 56);
|
|
185
|
+
// footer: wrap on segment boundaries, anchored to the bottom edge
|
|
186
|
+
const unvalidated = a.profile.id.startsWith("claude") || a.profile.id === "cursor-mixed" || a.profile.id === "heuristic";
|
|
187
|
+
const footSize = og ? 17 : 20;
|
|
188
|
+
const footLines = wrapSegments(["prehello", "local-only scan", `estimate band: ${a.profile.band}${unvalidated ? " (unvalidated)" : ""}`, `window ${(0, fmt_1.fmtWindow)(r.scan.window)}`, ...(r.partial ? ["PARTIAL scan"] : [])], maxCharsFor(innerW, footSize));
|
|
189
|
+
const footLineH = footSize + 6;
|
|
190
|
+
const footTop = H - (og ? 30 : 44) - (footLines.length - 1) * footLineH;
|
|
191
|
+
// Layout flows upward from the footer so a wrapped (2-3 line) footer can never collide with the chips or the treemap.
|
|
192
|
+
const chipH = 38;
|
|
193
|
+
const limit = footTop - footSize - (og ? 14 : 24);
|
|
194
|
+
const stripBlock = showStrip ? (og ? 40 : 52) : 0;
|
|
195
|
+
const tmH = Math.max(og ? 120 : 200, Math.min(og ? 400 : 640, limit - chipH - (og ? 22 : 30) - stripBlock - tmY));
|
|
196
|
+
const stripY = tmY + tmH + (og ? 30 : 42);
|
|
197
|
+
const chipsY = tmY + tmH + (og ? 22 : 30) + stripBlock;
|
|
198
|
+
// Treemap: the same groups as "what to cut" (plugin posthog, 18 Cursor-managed skills, ...) plus the always-on items that are not removable
|
|
199
|
+
// (rules, instruction files). Small tiles are merged into "other" so every visible tile is big enough to read.
|
|
200
|
+
const tiles = (0, savings_1.cardTiles)(a.items, !!o.anonymize);
|
|
201
|
+
const tilesTotal = tiles.reduce((s, t) => s + t.tokens, 0);
|
|
202
|
+
const MAX_TILES = og ? 7 : 8;
|
|
203
|
+
const MIN_SHARE = 0.04;
|
|
204
|
+
const shown = tiles.filter((t, k) => k < MAX_TILES && t.tokens / tilesTotal >= MIN_SHARE);
|
|
205
|
+
const restTiles = tiles.filter((t) => !shown.includes(t));
|
|
206
|
+
if (restTiles.length === 1 && restTiles[0].tokens / tilesTotal >= MIN_SHARE)
|
|
207
|
+
shown.push(restTiles.pop());
|
|
208
|
+
const rest = restTiles.reduce((s, t) => s + t.tokens, 0);
|
|
209
|
+
const cells = shown.map((t, k) => ({ label: t.named ? (0, secrets_1.scrubSecrets)(t.label) : t.label, v: t.tokens, fill: FILLS[k % FILLS.length] }));
|
|
210
|
+
if (rest > 0)
|
|
211
|
+
cells.push({ label: restTiles.length > 1 ? `other (${restTiles.length} smaller)` : "other", v: rest, fill: OTHER_FILL });
|
|
212
|
+
const rects = (0, treemap_1.squarify)(cells.map((c) => c.v), pad, tmY, innerW, tmH);
|
|
213
|
+
const totalCells = cells.reduce((s, c) => s + c.v, 0);
|
|
214
|
+
const parts = [];
|
|
215
|
+
parts.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" role="img" aria-labelledby="t d">`);
|
|
216
|
+
parts.push(`<title id="t">${(0, fmt_1.xmlEscape)(head.hero)}</title><desc id="d">${(0, fmt_1.xmlEscape)(head.sub)}. Generated by prehello ${(0, fmt_1.xmlEscape)(r.tool.version)}. Token counts are estimates.</desc>`);
|
|
217
|
+
parts.push(`<rect width="${W}" height="${H}" fill="${th.bg}"/>`);
|
|
218
|
+
parts.push(`<text x="${pad}" y="${pad + 20}" font-family="${font}" font-size="28" font-weight="700" fill="${th.text}">prehello <tspan font-weight="400" fill="${th.muted}" font-size="20">v${(0, fmt_1.xmlEscape)(r.tool.version)} · ${(0, fmt_1.xmlEscape)(r.generatedAt.slice(0, 10))}</tspan></text>`);
|
|
219
|
+
if (a.grade) {
|
|
220
|
+
const gx = W - pad - 36;
|
|
221
|
+
const gy = pad + 14;
|
|
222
|
+
parts.push(`<circle cx="${gx}" cy="${gy}" r="34" fill="none" stroke="${th.muted}" stroke-width="3"/>`);
|
|
223
|
+
parts.push(`<text x="${gx}" y="${gy + 16}" text-anchor="middle" font-family="${font}" font-size="46" font-weight="800" fill="${th.text}">${(0, fmt_1.xmlEscape)(a.grade.letter)}</text>`);
|
|
224
|
+
parts.push(`<text x="${W - pad}" y="${gy + 54}" text-anchor="end" font-family="${font}" font-size="14" fill="${th.muted}">Context Grade${a.grade.provisional ? " (provisional)" : ""}</text>`);
|
|
225
|
+
}
|
|
226
|
+
heroLines.forEach((line, k) => parts.push(`<text x="${pad}" y="${heroY + k * heroLineH}" font-family="${font}" font-size="${heroSize}" font-weight="800" fill="${th.text}">${(0, fmt_1.xmlEscape)(line)}</text>`));
|
|
227
|
+
parts.push(`<text x="${pad}" y="${subY}" font-family="${font}" font-size="${subSize}" fill="${th.muted}">${(0, fmt_1.xmlEscape)(fit(head.sub, maxCharsFor(innerW, subSize)))}</text>`);
|
|
228
|
+
rects.forEach((rc) => {
|
|
229
|
+
const c = cells[rc.i];
|
|
230
|
+
const g = 3;
|
|
231
|
+
const rx = rc.x + g / 2, ry = rc.y + g / 2, rw = Math.max(0, rc.w - g), rh = Math.max(0, rc.h - g);
|
|
232
|
+
parts.push(`<rect x="${rx.toFixed(1)}" y="${ry.toFixed(1)}" width="${rw.toFixed(1)}" height="${rh.toFixed(1)}" rx="6" fill="${c.fill}"/>`);
|
|
233
|
+
// label size follows cell size: big cells get a big label, token count and share; small cells get the number only
|
|
234
|
+
const big = rw >= 240 && rh >= 110;
|
|
235
|
+
const mid = rw >= 120 && rh >= 64;
|
|
236
|
+
const lf0 = big ? 26 : mid ? 20 : 15;
|
|
237
|
+
if (rw >= 64 && rh >= 34) {
|
|
238
|
+
const avail = rw - 18;
|
|
239
|
+
// shrink the label to fit before wrapping or truncating it (never below 11 px)
|
|
240
|
+
let lf = Math.max(11, Math.min(lf0, Math.floor(avail / (Math.max(1, c.label.length) * CHAR_EM))));
|
|
241
|
+
let lines = [c.label];
|
|
242
|
+
if (c.label.length * lf * CHAR_EM > avail) {
|
|
243
|
+
// still too wide at the smallest size: wrap onto up to 3 lines at spaces and hyphens, ellipsis only if it still does not fit
|
|
244
|
+
lf = Math.min(lf0, 14);
|
|
245
|
+
const withSub = Math.floor((rh - 14 - (13 + 8)) / (lf + 3));
|
|
246
|
+
const maxLines = Math.min(3, withSub >= 1 ? withSub : Math.max(1, Math.floor((rh - 8) / (lf + 3))));
|
|
247
|
+
lines = wrapLabel(c.label, maxCharsFor(avail, lf), maxLines);
|
|
248
|
+
}
|
|
249
|
+
const lineH = lf + 3;
|
|
250
|
+
const tf = Math.max(13, lf - 4);
|
|
251
|
+
lines.forEach((ln, k) => parts.push(`<text x="${(rx + 10).toFixed(1)}" y="${(ry + lf + 6 + k * lineH).toFixed(1)}" font-family="${font}" font-size="${lf}" font-weight="600" fill="#ffffff">${(0, fmt_1.xmlEscape)(ln)}</text>`));
|
|
252
|
+
const subY = ry + lf + 6 + (lines.length - 1) * lineH + tf + 6;
|
|
253
|
+
if (lines.length === 1 ? rh >= lf + tf + 22 : subY <= ry + rh - 6) {
|
|
254
|
+
const pct = totalCells ? ` · ${(0, fmt_1.fmtPct)((c.v / totalCells) * 100)}` : "";
|
|
255
|
+
const ty = lines.length === 1 ? ry + lf + tf + 12 : subY;
|
|
256
|
+
parts.push(`<text x="${(rx + 10).toFixed(1)}" y="${ty.toFixed(1)}" font-family="${font}" font-size="${tf}" fill="#ffffff">${(0, fmt_1.xmlEscape)(fit(`~${(0, fmt_1.fmtTok)(c.v)}${big ? pct : ""}`, maxCharsFor(avail, tf)))}</text>`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
else if (rw >= 24 && rh >= 90) {
|
|
260
|
+
// a tall, narrow tile: the name and size read bottom-to-top instead of being dropped
|
|
261
|
+
const tx = rx + rw / 2 + 4.5;
|
|
262
|
+
const ty = ry + rh - 10;
|
|
263
|
+
parts.push(`<text transform="rotate(-90 ${tx.toFixed(1)} ${ty.toFixed(1)})" x="${tx.toFixed(1)}" y="${ty.toFixed(1)}" font-family="${font}" font-size="13" font-weight="600" fill="#ffffff">${(0, fmt_1.xmlEscape)(fit(`${c.label} · ~${(0, fmt_1.fmtTok)(c.v)}`, maxCharsFor(rh - 20, 13)))}</text>`);
|
|
264
|
+
}
|
|
265
|
+
else if (rw >= 40 && rh >= 22) {
|
|
266
|
+
parts.push(`<text x="${(rx + 6).toFixed(1)}" y="${(ry + rh / 2 + 5).toFixed(1)}" font-family="${font}" font-size="13" fill="#ffffff">${(0, fmt_1.xmlEscape)(fit(`~${(0, fmt_1.fmtTok)(c.v)}`, maxCharsFor(rw - 8, 13)))}</text>`);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
if (showStrip && a.savings) {
|
|
270
|
+
const sv = a.savings;
|
|
271
|
+
const kindWord = sv.top.every((c) => c.kind === "mcp") ? "MCP server" : sv.top.some((c) => c.kind === "mcp") ? "skill/plugin/MCP group" : "skill/plugin group";
|
|
272
|
+
const line = `Cut the top ${sv.n} ${kindWord}${sv.n > 1 ? "s" : ""}: save ~${(0, fmt_1.fmtTok)(sv.topTokens)} tokens (estimate)`;
|
|
273
|
+
const ss = og ? 22 : 24;
|
|
274
|
+
parts.push(`<text x="${pad}" y="${stripY}" font-family="${font}" font-size="${ss}" font-weight="700" fill="${th.text}">${(0, fmt_1.xmlEscape)(fit(line, maxCharsFor(innerW, ss)))}</text>`);
|
|
275
|
+
}
|
|
276
|
+
// chips (only for data that exists)
|
|
277
|
+
const chips = [];
|
|
278
|
+
// the cut headline does not show the total itself, so the total always appears as the first chip
|
|
279
|
+
if (head.id === "cut-list")
|
|
280
|
+
chips.push(`always-on ${a.lowerBound ? exports.AT_LEAST + " " : ""}~${(0, fmt_1.fmtTok)(a.totals.always.est)} · ${(0, fmt_1.fmtPct)((a.totals.always.est / r.scan.window) * 100)} of ${(0, fmt_1.fmtWindow)(r.scan.window)}`);
|
|
281
|
+
const mode = (m) => a.totals[m];
|
|
282
|
+
for (const m of ["deferred", "conditional", "on-demand"])
|
|
283
|
+
if (mode(m).measuredItems)
|
|
284
|
+
chips.push(`${m} ~${(0, fmt_1.fmtTok)(mode(m).est)}`);
|
|
285
|
+
const unm = a.totals.always.unmeasuredItems + a.totals.deferred.unmeasuredItems;
|
|
286
|
+
if (unm)
|
|
287
|
+
chips.push(`unmeasured: ${unm} item${unm > 1 ? "s" : ""}`);
|
|
288
|
+
if (a.id === "cursor")
|
|
289
|
+
chips.push("unmeasured: User Rules");
|
|
290
|
+
if (r.partial)
|
|
291
|
+
chips.unshift("partial scan");
|
|
292
|
+
let cx = pad;
|
|
293
|
+
const chipSize = 18;
|
|
294
|
+
for (const chip of chips) {
|
|
295
|
+
const cw = chip.length * chipSize * CHAR_EM + 28;
|
|
296
|
+
if (cx + cw > W - pad)
|
|
297
|
+
break;
|
|
298
|
+
parts.push(`<rect x="${cx}" y="${chipsY}" width="${cw.toFixed(0)}" height="${chipH}" rx="19" fill="${th.chip}" stroke="${th.border}"/>`);
|
|
299
|
+
parts.push(`<text x="${(cx + 14).toFixed(0)}" y="${chipsY + 25}" font-family="${font}" font-size="${chipSize}" fill="${th.text}">${(0, fmt_1.xmlEscape)(chip)}</text>`);
|
|
300
|
+
cx += cw + 12;
|
|
301
|
+
}
|
|
302
|
+
footLines.forEach((l, k) => parts.push(`<text x="${pad}" y="${footTop + k * footLineH}" font-family="${font}" font-size="${footSize}" fill="${th.muted}">${(0, fmt_1.xmlEscape)(l)}</text>`));
|
|
303
|
+
parts.push(`</svg>`);
|
|
304
|
+
return parts.join("\n") + "\n";
|
|
305
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.renderFix = renderFix;
|
|
4
|
+
const fmt_1 = require("../util/fmt");
|
|
5
|
+
/** Dry-run plan. This function only formats text; there is no code path in 0.1 that modifies user files. */
|
|
6
|
+
function renderFix(r) {
|
|
7
|
+
const lines = [];
|
|
8
|
+
const items = r.findings.filter((f) => f.fix);
|
|
9
|
+
const total = items.reduce((s, f) => s + (f.savingsTokens ?? 0), 0);
|
|
10
|
+
lines.push(`Fix plan (dry run): ${items.length} suggestion${items.length === 1 ? "" : "s"}${total ? `, ~${(0, fmt_1.fmtInt)(total)} always-on tokens could be saved (estimate)` : ""} · 0 files changed`);
|
|
11
|
+
lines.push("");
|
|
12
|
+
items.forEach((f, i) => {
|
|
13
|
+
lines.push(` ${i + 1}. [${f.id}] ${(0, fmt_1.sanitize)(f.message, 200)}`);
|
|
14
|
+
lines.push(` suggestion: ${(0, fmt_1.sanitize)(f.fix ?? "", 240)}`);
|
|
15
|
+
lines.push(` savings: ${f.savingsTokens ? "~" + (0, fmt_1.fmtInt)(f.savingsTokens) + " tokens (estimate)" : "none measurable (clarity/correctness)"} · confidence: ${f.confidence}`);
|
|
16
|
+
});
|
|
17
|
+
if (!items.length)
|
|
18
|
+
lines.push(" Nothing to suggest from the rules implemented in 0.1 (F1, F2, F4, F5, F7, F8, F9).");
|
|
19
|
+
lines.push("");
|
|
20
|
+
lines.push("Nothing was modified. prehello 0.1 has no --apply or --patch: changes are yours to make. Content-level rewrites are never auto-applied.");
|
|
21
|
+
return lines.join("\n") + "\n";
|
|
22
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.toJson = toJson;
|
|
4
|
+
exports.numberedLabels = numberedLabels;
|
|
5
|
+
const redact_1 = require("../redact");
|
|
6
|
+
function numberedLabels(agent, only) {
|
|
7
|
+
const list = only ?? agent.items;
|
|
8
|
+
// privacy-safe labels: cardLabel, numbered when repeated ("MCP server 2")
|
|
9
|
+
const totals = new Map();
|
|
10
|
+
for (const it of list)
|
|
11
|
+
totals.set(it.cardLabel, (totals.get(it.cardLabel) ?? 0) + 1);
|
|
12
|
+
const seen = new Map();
|
|
13
|
+
const out = new Map();
|
|
14
|
+
for (const it of list) {
|
|
15
|
+
const n = (seen.get(it.cardLabel) ?? 0) + 1;
|
|
16
|
+
seen.set(it.cardLabel, n);
|
|
17
|
+
out.set(it.id, (totals.get(it.cardLabel) ?? 1) > 1 ? `${it.cardLabel} ${n}` : it.cardLabel);
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
/** Serialise the Report. NEVER includes file contents. `strict` drops paths/names entirely (use before sharing). */
|
|
22
|
+
/** Defence in depth: user-controlled names (skill `name:`, MCP server keys, …) must never carry a secret-shaped string into JSON. */
|
|
23
|
+
function scrubDeep(v) {
|
|
24
|
+
if (typeof v === "string")
|
|
25
|
+
return (0, redact_1.scrubSecrets)(v);
|
|
26
|
+
if (Array.isArray(v))
|
|
27
|
+
return v.map(scrubDeep);
|
|
28
|
+
if (v && typeof v === "object")
|
|
29
|
+
return Object.fromEntries(Object.entries(v).map(([k, x]) => [(0, redact_1.scrubSecrets)(k), scrubDeep(x)]));
|
|
30
|
+
return v;
|
|
31
|
+
}
|
|
32
|
+
function toJson(r, c, strict) {
|
|
33
|
+
return scrubDeep(toJsonRaw(r, c, strict));
|
|
34
|
+
}
|
|
35
|
+
function toJsonRaw(r, c, strict) {
|
|
36
|
+
const dp = (p) => (0, redact_1.displayPath)(p, c);
|
|
37
|
+
return {
|
|
38
|
+
schemaVersion: r.schemaVersion,
|
|
39
|
+
tool: r.tool,
|
|
40
|
+
generatedAt: r.generatedAt,
|
|
41
|
+
redacted: strict ? "strict" : "paths-shortened (~ and ./ prefixes); server and file names kept",
|
|
42
|
+
scan: { ...r.scan, cwd: strict ? "project" : dp(r.scan.cwd) },
|
|
43
|
+
agents: r.agents.map((a) => {
|
|
44
|
+
const labels = numberedLabels(a);
|
|
45
|
+
return {
|
|
46
|
+
id: a.id,
|
|
47
|
+
name: a.name,
|
|
48
|
+
detected: a.detected,
|
|
49
|
+
detectedBy: a.detectedBy,
|
|
50
|
+
presentInRepoOnly: a.presentInRepoOnly,
|
|
51
|
+
profile: a.profile,
|
|
52
|
+
totals: a.totals,
|
|
53
|
+
percentOfWindowAlways: a.percentOfWindowAlways,
|
|
54
|
+
alwaysIsLowerBound: a.lowerBound,
|
|
55
|
+
savings: a.savings
|
|
56
|
+
? {
|
|
57
|
+
...a.savings,
|
|
58
|
+
top: a.savings.top.map((c, i) => (strict ? { label: `group ${i + 1}`, kind: c.kind, tokens: c.tokens, count: c.count } : c)),
|
|
59
|
+
note: "estimate: what removing these groups would save; not a promise",
|
|
60
|
+
}
|
|
61
|
+
: null,
|
|
62
|
+
grade: a.grade,
|
|
63
|
+
...(a.gradeNote ? { gradeNote: a.gradeNote } : {}),
|
|
64
|
+
coverage: a.coverage,
|
|
65
|
+
installedButNothingFound: a.items.length === 0 && !a.presentInRepoOnly,
|
|
66
|
+
offenders: a.offenders.map((x) => (strict ? { kind: x.kind, loadMode: x.loadMode, tokens: x.tokens } : { label: x.label, path: dp(x.path), kind: x.kind, loadMode: x.loadMode, tokens: x.tokens, ...(x.note ? { note: x.note } : {}) })),
|
|
67
|
+
mcpServers: a.mcpServers.map((m, i) => (strict ? { n: i + 1, transport: m.transport, scope: m.scope, measured: m.measured } : { name: m.name, source: dp(m.source), scope: m.scope, transport: m.transport, measured: m.measured })),
|
|
68
|
+
unmeasuredBanner: a.unmeasuredBanner,
|
|
69
|
+
items: a.items.map((it) => ({
|
|
70
|
+
id: it.id,
|
|
71
|
+
kind: it.kind,
|
|
72
|
+
scope: it.scope,
|
|
73
|
+
loadMode: it.loadMode,
|
|
74
|
+
label: strict ? labels.get(it.id) : it.label,
|
|
75
|
+
...(strict ? {} : { path: dp(it.path) }),
|
|
76
|
+
tokens: it.tokens,
|
|
77
|
+
unmeasured: it.tokens === null,
|
|
78
|
+
...(it.unmeasuredReason ? { unmeasuredReason: it.unmeasuredReason } : {}),
|
|
79
|
+
chars: it.chars,
|
|
80
|
+
lines: it.lines,
|
|
81
|
+
...(it.breakdown && !strict ? { breakdown: it.breakdown } : {}),
|
|
82
|
+
...(it.parts && !strict ? { parts: it.parts } : {}),
|
|
83
|
+
confidence: it.confidence,
|
|
84
|
+
evidence: it.evidence,
|
|
85
|
+
notes: strict ? it.notes.filter((n) => !/["'`\/\\@]/.test(n) && !/\bplugin\b/i.test(n)) : it.notes,
|
|
86
|
+
})),
|
|
87
|
+
};
|
|
88
|
+
}),
|
|
89
|
+
excluded: strict ? r.excluded.map((e) => ({ agent: e.agent, reason: e.reason.replace(/"[^"]*"/g, '"…"') })) : r.excluded.map((e) => ({ agent: e.agent, path: dp(e.path), reason: e.reason })),
|
|
90
|
+
findings: r.findings.map((f) => strict
|
|
91
|
+
? { id: f.id, severity: f.severity, agent: f.agent, savingsTokens: f.savingsTokens, confidence: f.confidence, fileCount: f.paths.length }
|
|
92
|
+
: { id: f.id, severity: f.severity, agent: f.agent, message: f.message, paths: f.paths.map(dp), savingsTokens: f.savingsTokens, confidence: f.confidence, fix: f.fix }),
|
|
93
|
+
warnings: strict ? r.warnings.length : r.warnings,
|
|
94
|
+
notMeasurable: r.notMeasurable,
|
|
95
|
+
partial: r.partial ?? null,
|
|
96
|
+
};
|
|
97
|
+
}
|