claude-code-runrate 0.5.0 → 0.6.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 CHANGED
@@ -7,6 +7,46 @@ version is worth upgrading to, and where to read the rest.
7
7
 
8
8
  Dates are npm publish dates, because that is when a version reached anyone.
9
9
 
10
+ ## 0.6.0 — 2026-09-01
11
+
12
+ The remote-session release. Over mosh, two things had been degrading in
13
+ silence — every clipboard copy was dropped in transit, and 24-bit colour was
14
+ quietly flattened to 256 — and the tmux version floor the sidecar had grown
15
+ was written down nowhere. All three were measured at the terminal, reading
16
+ the raw bytes that reach a mosh client, not inferred.
17
+
18
+ - **The clipboard survives mosh.** tmux's built-in OSC 52 emits an empty
19
+ selection field and mosh's parser accepts only an explicit `c`, so every
20
+ copy vanished between the two. The shipped tmux config now spells the
21
+ selection out — and names `c` explicitly, which is *more* compatible than
22
+ tmux's default everywhere, not a mosh-only workaround.
23
+ - **True colour survives mosh.** mosh forces `TERM=xterm-256color` and drops
24
+ `COLORTERM`, which tmux reads as "no RGB" and silently downgrades every
25
+ 24-bit colour to the nearest 256. The config now declares RGB for that
26
+ TERM, restoring what mosh was carrying fine all along.
27
+ - **The tmux floor is declared: 3.2.** Percentage splits and
28
+ `terminal-features` need 3.1; the pane-scoped hooks that keep the sidebar
29
+ out of copy-mode need 3.2. Below the floor each failure is silent and looks
30
+ like something else, so `ccr doctor` now reports the tmux version and names
31
+ one that is too old — and an unreadable version is reported as unreadable,
32
+ never manufactured into a failure.
33
+ - **The sidebar split speaks tmux 3.4's dialect.** `-p 34` was deprecated in
34
+ 3.1 and rejected outright in 3.4; the launcher sizes with `-l 34%`, which
35
+ every supported tmux accepts.
36
+ - **The Node floor rises to 22.17.** Node 18 and 20 are past their end of
37
+ life, and the vendored test runner's own floor is 22.17 — testing below it
38
+ produced a lane that hung rather than failed. `engines`, the CI matrix,
39
+ `ccr doctor`'s check, and the spec all state the same floor now, and the
40
+ matrix pins 22.17 exactly so the floor is tested, not just declared.
41
+ - **The acceptance suite hardened underneath.** The vendored Gherkin runner
42
+ moved to gherkin-node-test 0.11.0, whose step linter enforces a rule the
43
+ suite now lives by: a negative assertion over a literal needle passes
44
+ forever once the needle rots, so *absence must be earned* — by a control
45
+ proving the needle can still find, or a sanction naming what proves it.
46
+ Every structural refusal in the step layer (the renderer spawns nothing,
47
+ the launcher reads no blob, the sidecar has no input channel) now carries
48
+ one or the other, and both of 0.11's linters gate the suite.
49
+
10
50
  ## 0.5.0 — 2026-08-22
11
51
 
12
52
  The Windows release. The things that could not be reasoned about were measured
package/README.md CHANGED
@@ -69,8 +69,8 @@ On Linux/macOS the default host is `tmux`. No WSL required.
69
69
 
70
70
  ## Requirements
71
71
 
72
- - **Node ≥ 18.3** — that's it for the core. **Zero runtime dependencies.**
73
- - For the optional live `ccr sidecar`: `tmux` on Linux/macOS, or **Windows
72
+ - **Node ≥ 22.17** — that's it for the core. **Zero runtime dependencies.**
73
+ - For the optional live `ccr sidecar`: **`tmux` ≥ 3.2** on Linux/macOS, or **Windows
74
74
  Terminal** / **VS Code's integrated terminal** on Windows (no WSL).
75
75
 
76
76
  ## VS Code (split terminal)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-runrate",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Claude Code run-rate — subscription burn-rate & economy for your Claude Code sessions.",
5
5
  "license": "MIT",
6
6
  "author": "Bing Ho <reps-attic-riot@duck.com>",
@@ -17,7 +17,7 @@
17
17
  "ccr": "bin/ccr.js"
18
18
  },
19
19
  "engines": {
20
- "node": ">=18.3"
20
+ "node": ">=22.17"
21
21
  },
22
22
  "files": [
23
23
  "bin",
package/scripts/launch.sh CHANGED
@@ -135,7 +135,14 @@ if [ -n "${CCR_TITLE:-}" ]; then
135
135
  fi
136
136
 
137
137
  # Pane 1: the live economy sidebar. Capture its pane id so we can scope a hook to it.
138
- SIDEBAR_PANE="$(tmux -L "$SOCKET" split-window -t "$SESSION:0" -h -p "${CCR_SIDEBAR_PCT:-34}" -P -F '#{pane_id}' \
138
+ # The width is `-l <pct>%`, NOT `-p <pct>`. Both spell the same split, but `-p` is the
139
+ # deprecated form and tmux 3.4 — what Ubuntu 24.04 LTS ships, i.e. every stock Ubuntu
140
+ # Server — rejects it with `size missing`. That killed the launcher AFTER new-session
141
+ # had already succeeded, so the failure mode was silent and confusing: a live Claude
142
+ # pane, no sidebar, no error on screen (launch.sh's stderr goes to the terminal it was
143
+ # spawned from, which is gone by then). `-l N%` is accepted by 3.1 through 3.6+ alike
144
+ # and produces an identical split, so there is no version guard to maintain.
145
+ SIDEBAR_PANE="$(tmux -L "$SOCKET" split-window -t "$SESSION:0" -h -l "${CCR_SIDEBAR_PCT:-34}%" -P -F '#{pane_id}' \
139
146
  "$ENV_PREAMBLE; \"$NODE\" \"$REPO/bin/ccr.js\" sidecar; read -r -p 'sidebar exited — Enter to close '")"
140
147
 
141
148
  # The sidebar is a live dashboard — there is nothing to scroll. A stray mouse-wheel
@@ -22,3 +22,41 @@ set -g set-clipboard on
22
22
 
23
23
  # prefix + P — dump the whole pane scrollback to a file, regardless of length.
24
24
  bind-key P command-prompt -p 'save pane to:' 'capture-pane -S - ; save-buffer %1'
25
+
26
+ # --- Remote sessions: true colour and a clipboard that survives mosh ---------
27
+ #
28
+ # Both lines matter only over a remote transport, and mosh is the case that
29
+ # breaks without them. Measured over mosh -> tmux, reading the raw bytes that
30
+ # reach the client; neither is guesswork.
31
+ #
32
+ # 1. TRUE COLOUR. mosh forces TERM=xterm-256color and DROPS COLORTERM. tmux
33
+ # infers RGB support from COLORTERM, so with it gone tmux silently
34
+ # downgrades 24-bit colour to the nearest 256 (38;2;R;G;B -> 38;5;N). mosh
35
+ # itself carries 24-bit fine; tmux is the one degrading it. Declaring the
36
+ # feature restores it.
37
+ #
38
+ # 2. CLIPBOARD. tmux's built-in OSC 52 emits an EMPTY selection field
39
+ # (`\033]52;;<base64>`), and mosh's OSC 52 parser accepts ONLY an explicit
40
+ # `c`. So every copy is silently dropped in transit. This override spells
41
+ # the selection out. Three details are load-bearing:
42
+ # * `%p1%.0s` CONSUMES the selection parameter while printing nothing.
43
+ # Omitting it leaves p1 unconsumed and the expansion is invalid.
44
+ # * `\007`, never `\7`. tmux 3.x SILENTLY REJECTS a terminal-overrides
45
+ # value containing `\7` — the option is discarded whole with no error,
46
+ # and `tmux show -s terminal-overrides` simply will not list it. Older
47
+ # guides on the web all use `\7`; that spelling was fine on tmux 2.x.
48
+ # * terminal-overrides keys on the client's TERM, so this matches when
49
+ # TERM is xterm-256color — which is exactly what mosh sets.
50
+ # Emitting an explicit `c` is also strictly more compatible than tmux's
51
+ # default empty field, so this is not a mosh-only workaround.
52
+ #
53
+ # Both require tmux 3.1+; ccr asks for 3.2+ overall (see `ccr doctor`).
54
+ #
55
+ # RGB is declared unconditionally. 24-bit has been table stakes for years —
56
+ # iTerm2, Ghostty, WezTerm, kitty, Alacritty, Konsole, every libvte terminal
57
+ # (GNOME Terminal et al), Windows Terminal and PuTTY all handle it. The lone
58
+ # holdout that reports TERM=xterm-256color without it is macOS Terminal.app
59
+ # before macOS 26, which degrades rather than breaking. Optimising the default
60
+ # for that shrinking case would cost every other user their colours.
61
+ set -as terminal-features ',xterm-256color:RGB'
62
+ set -as terminal-overrides ",xterm-256color:Ms=\E]52;c%p1%.0s;%p2%s\007"
package/src/doctor.js CHANGED
@@ -35,8 +35,27 @@ function isExec(/** @type {string} */ f) {
35
35
  try { return (fs.statSync(f).mode & 0o111) !== 0; } catch { return false; }
36
36
  }
37
37
 
38
+ // ccr's tmux dialect has a floor. 3.1 is HARD: percentage splits
39
+ // (`split-window -l 34%`, since `-p` was deprecated there and 3.4+ rejects it)
40
+ // and `terminal-features`, which sidecar/ccr.tmux.conf needs for true colour
41
+ // over mosh. 3.2 adds pane-scoped hooks, which the launcher uses to keep the
42
+ // sidebar out of copy-mode; below that it degrades quietly rather than
43
+ // breaking, so 3.2 is what we ask for and 3.1 is what we tolerate.
44
+ const TMUX_MIN = { major: 3, minor: 2 };
45
+ /** @param {string} bin @returns {{major:number,minor:number}|null} */
46
+ function tmuxVersion(bin) {
47
+ try {
48
+ const r = spawnSync(bin, ['-V'], { encoding: 'utf8' });
49
+ if (r.status !== 0) return null;
50
+ // "tmux 3.7c", "tmux 3.2a", "tmux next-3.6" — only the numbers matter.
51
+ const m = /(\d+)\.(\d+)/.exec(r.stdout || '');
52
+ return m ? { major: Number(m[1]), minor: Number(m[2]) } : null;
53
+ } catch { return null; }
54
+ }
55
+
38
56
  /**
39
57
  * @param {{ platform?: string, has?: (cmd: string) => (string|null),
58
+ * tmuxVersion?: (bin: string) => ({major:number,minor:number}|null),
40
59
  * homedir?: string, repo?: string, write?: (s: string) => void,
41
60
  * env?: Record<string, string|undefined> }} [opts]
42
61
  * side effects are injectable for testing; defaults hit the real environment
@@ -45,6 +64,7 @@ function isExec(/** @type {string} */ f) {
45
64
  function run(opts = {}) {
46
65
  const platform = opts.platform || process.platform;
47
66
  const hasFn = opts.has || has;
67
+ const tmuxVerFn = opts.tmuxVersion || tmuxVersion;
48
68
  const homedir = opts.homedir || os.homedir();
49
69
  const REPO = opts.repo || path.join(__dirname, '..');
50
70
  const write = opts.write || ((s) => { process.stdout.write(s); });
@@ -53,8 +73,8 @@ function run(opts = {}) {
53
73
  let problems = 0;
54
74
 
55
75
  const [maj, min] = process.versions.node.split('.').map(Number);
56
- const nodeOk = maj > 18 || (maj === 18 && min >= 3);
57
- out.push(nodeOk ? ok(`node ${process.version}`) : bad(`node ${process.version} — need >= 18.3`));
76
+ const nodeOk = maj > 22 || (maj === 22 && min >= 17);
77
+ out.push(nodeOk ? ok(`node ${process.version}`) : bad(`node ${process.version} — need >= 22.17`));
58
78
  if (!nodeOk) problems++;
59
79
 
60
80
  const ccr = hasFn('ccr');
@@ -74,8 +94,24 @@ function run(opts = {}) {
74
94
  // temp settings file, so there's no shipped shim asset to check on Windows.
75
95
  } else {
76
96
  const tmux = hasFn('tmux');
77
- out.push(tmux ? ok(`tmux (${stripControl(tmux)})`) : warn('tmux missing — needed for the `ccr [profile]` sidebar'));
78
- if (!tmux) problems++;
97
+ if (!tmux) {
98
+ out.push(warn('tmux missing — needed for the `ccr [profile]` sidebar'));
99
+ problems++;
100
+ } else {
101
+ const v = tmuxVerFn(tmux);
102
+ // An unreadable version is not evidence of a problem — say so and move on
103
+ // rather than manufacturing a failure out of a parse miss.
104
+ if (!v) {
105
+ out.push(ok(`tmux (${stripControl(tmux)})`) + dim(' — version unreadable'));
106
+ } else if (v.major < TMUX_MIN.major
107
+ || (v.major === TMUX_MIN.major && v.minor < TMUX_MIN.minor)) {
108
+ out.push(warn(`tmux ${v.major}.${v.minor} (${stripControl(tmux)}) — ccr wants `
109
+ + `${TMUX_MIN.major}.${TMUX_MIN.minor}+; below 3.1 the sidebar split fails outright`));
110
+ problems++;
111
+ } else {
112
+ out.push(ok(`tmux ${v.major}.${v.minor} (${stripControl(tmux)})`));
113
+ }
114
+ }
79
115
  out.push(hasFn('bash') ? ok('bash') : warn('bash missing — needed for the launcher'));
80
116
 
81
117
  const sl = path.join(REPO, 'sidecar', 'ccr-statusline');
@@ -1,435 +0,0 @@
1
- // @ts-check
2
- 'use strict';
3
- // src/history-privacy.js — what a release would publish that is not published
4
- // yet, read out of the object store rather than out of the working tree.
5
- //
6
- // Why this is not simply `git grep` over the working tree: a scrubbed tip says
7
- // nothing about the blobs behind it. 0.4.0 made the shape concrete — its tree
8
- // grepped clean while fourteen of the twenty-three commits behind that tip
9
- // still carried a string the release had deliberately removed. Invisible to
10
- // every check anyone ran, because every check ran on the tree.
11
- //
12
- // That particular string turned out to be publishable after all (ruled
13
- // 2026-08-07; the flat squash it prompted cost nothing either way). The
14
- // arithmetic is the lesson rather than the string: fourteen commits, none of
15
- // them reachable from an inspection of the tip, and nothing in the release path
16
- // that would have looked.
17
- //
18
- // The disclosures this repository has ACTUALLY had were both mail addresses —
19
- // an owner address throughout early history, and a contributor's real address
20
- // on a branch that reached the public remote in 2026-06. Neither was in the tip
21
- // when it mattered. Both are what the real-inbox detector below is for.
22
- //
23
- // So the check reads history, not `HEAD`: every commit reachable from the tip
24
- // and not from the published ref, every blob in each of their trees.
25
- //
26
- // TWO KINDS OF FINDING, deliberately different:
27
- //
28
- // * DETECTORS (below) are generic and live here in the open, because they
29
- // describe SHAPES — an email that is not a noreply alias, an absolute home
30
- // path — not anyone's actual secrets. They cover both disclosures above.
31
- //
32
- // * The PRIVATE SUPPLEMENT is a list of literal patterns supplied from
33
- // OUTSIDE the repository (see loadPrivatePatterns). Whatever belongs on it
34
- // is by definition a string this file must not contain: writing it into a
35
- // public file in order to scan for it would be the disclosure it prevents.
36
- //
37
- // Detector hits are judged against a BASELINE: whatever the published tree
38
- // already contains is, by definition, already public and not news. That is why
39
- // there is no allowlist to maintain — the npm contact alias in package.json is
40
- // silent because it is already out there, and it would start speaking again
41
- // the moment it appeared somewhere it had not been. Supplement hits ignore the
42
- // baseline: those strings are never acceptable, published already or not.
43
-
44
- const fs = require('node:fs');
45
- const os = require('node:os');
46
- const path = require('node:path');
47
-
48
- const { readObject, parseTree } = require('./git-objects');
49
-
50
- /** Commits walked before the scan gives up rather than grind. */
51
- const MAX_COMMITS = 2000;
52
- /** Distinct blobs read per scan. Trees and blobs are deduplicated by oid, so
53
- * this counts real content, not path instances. */
54
- const MAX_BLOBS = 20000;
55
- /** A blob larger than this is not read. Secrets hide in text, and the object
56
- * reader has its own ceiling anyway. */
57
- const MAX_BLOB_BYTES = 1024 * 1024;
58
- /** Bytes inspected for NUL before calling a blob binary and skipping it. */
59
- const BINARY_SNIFF_BYTES = 8192;
60
-
61
- /**
62
- * A generic shape worth refusing to publish. `extract` returns the literal
63
- * strings found, which is what makes baseline comparison possible: the finding
64
- * is the STRING, so "already public" is a set membership test.
65
- *
66
- * @typedef {object} Detector
67
- * @property {string} name
68
- * @property {string} why Printed to the operator, so it must explain itself.
69
- * @property {(text: string) => string[]} extract
70
- */
71
-
72
- /**
73
- * Addresses that are aliases by construction. This is not an allowlist of
74
- * anyone's real mail — it is the set of names that CANNOT reach an inbox,
75
- * which is why a fixture is entitled to use them freely.
76
- *
77
- * The reserved names are RFC 2606 and RFC 6761: the `.invalid`, `.test`,
78
- * `.example` and `.localhost` TLDs, and the `example.com/org/net` domains.
79
- * They are matched as SUFFIXES, so `oracle@ccr.invalid` and
80
- * `someone@corp.example.com` are covered — an earlier version anchored on
81
- * `@invalid$` and flagged this repository's own test fixtures.
82
- */
83
- const ALIAS_MAIL = /(@users\.noreply\.github\.com|@noreply\.[A-Za-z0-9.-]+|^noreply@|\.(invalid|test|example|localhost)$|(^|@|\.)example\.(com|org|net)$)/i;
84
-
85
- const MAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
86
-
87
- /**
88
- * `/home/<user>/`, `/Users/<user>/`, `C:\Users\<user>\` — an absolute path
89
- * through somebody's home directory. Generic placeholders are not findings.
90
- */
91
- const HOME_RE = /(?:\/home\/|\/Users\/|[A-Za-z]:\\Users\\)([A-Za-z0-9._-]+)/g;
92
- const PLACEHOLDER_USER = /^(user|username|you|me|someone|test|runner|ci|root|foo|bar|example|\$\{?\w+\}?)$/i;
93
-
94
- /** @type {Detector[]} */
95
- const DETECTORS = [
96
- {
97
- name: 'real-inbox',
98
- why: 'an email address that is not a noreply alias',
99
- extract: (text) => (text.match(MAIL_RE) || []).filter((m) => !ALIAS_MAIL.test(m)),
100
- },
101
- {
102
- name: 'home-path',
103
- why: 'an absolute path naming a home directory',
104
- extract: (text) => {
105
- /** @type {string[]} */
106
- const out = [];
107
- for (const m of text.matchAll(HOME_RE)) {
108
- if (!PLACEHOLDER_USER.test(m[1])) out.push(m[0]);
109
- }
110
- return out;
111
- },
112
- },
113
- ];
114
-
115
- /**
116
- * The private supplement: literal ERE-ish patterns that must never appear,
117
- * supplied from outside the repository so the repository never states them.
118
- *
119
- * Sources, first one that exists wins:
120
- * 1. $CCR_PRIVATE_PATTERNS — patterns inline, comma or newline separated
121
- * 2. $CCR_PRIVATE_PATTERNS_FILE — a file of them, one per line, `#` comments
122
- * 3. ~/.config/ccr/private-patterns
123
- *
124
- * Absent all three the supplement is empty and `configured` is false, which
125
- * the gate reports rather than swallows: a check that quietly did not run is
126
- * worse than one that says so.
127
- *
128
- * @param {{ env?: NodeJS.ProcessEnv, homedir?: () => string }} [deps]
129
- * @returns {{ configured: boolean, source: string|null, patterns: RegExp[], invalid: string[] }}
130
- */
131
- function loadPrivatePatterns(deps = {}) {
132
- const env = deps.env || process.env;
133
- const home = (deps.homedir || os.homedir)();
134
-
135
- /** @type {string|null} */
136
- let raw = null;
137
- /** @type {string|null} */
138
- let source = null;
139
-
140
- if (env.CCR_PRIVATE_PATTERNS && env.CCR_PRIVATE_PATTERNS.trim()) {
141
- raw = env.CCR_PRIVATE_PATTERNS;
142
- source = '$CCR_PRIVATE_PATTERNS';
143
- } else {
144
- const file = env.CCR_PRIVATE_PATTERNS_FILE
145
- || path.join(home, '.config', 'ccr', 'private-patterns');
146
- try {
147
- raw = fs.readFileSync(file, 'utf8');
148
- source = file;
149
- } catch {
150
- raw = null;
151
- }
152
- }
153
-
154
- if (raw === null) return { configured: false, source: null, patterns: [], invalid: [] };
155
-
156
- /** @type {RegExp[]} */
157
- const patterns = [];
158
- /** @type {string[]} */
159
- const invalid = [];
160
- for (const line of raw.split(/[\n,]/)) {
161
- const s = line.trim();
162
- if (!s || s.startsWith('#')) continue;
163
- try {
164
- patterns.push(new RegExp(s, 'i'));
165
- } catch {
166
- invalid.push(s);
167
- }
168
- }
169
- return { configured: true, source, patterns, invalid };
170
- }
171
-
172
- /**
173
- * Commits reachable from `tip` and not from `published`.
174
- *
175
- * The exclusion side is walked first and in full, so a commit that is an
176
- * ancestor of the published ref is never reported no matter which order the
177
- * tip walk reaches it in.
178
- *
179
- * A commit whose object cannot be read is REPORTED, not skipped. Dropping it
180
- * would shrink the answer silently: an unreadable tip would walk to an empty
181
- * list, and an empty list of unpublished commits is indistinguishable from
182
- * having nothing to disclose. The caller must be able to tell those apart.
183
- *
184
- * @param {string} gitDir
185
- * @param {string} tip
186
- * @param {string|null} published Null means "nothing is published yet".
187
- * @returns {{ commits: string[], unreadable: string[], truncated: boolean }}
188
- */
189
- function unpublishedCommits(gitDir, tip, published) {
190
- const parentsOf = (/** @type {string} */ oid) => {
191
- const obj = readObject(gitDir, oid);
192
- if (obj === null || obj.type !== 'commit') return null;
193
- const header = obj.data.toString('utf8', 0, Math.min(obj.data.length, 8192));
194
- const end = header.indexOf('\n\n');
195
- const head = end === -1 ? header : header.slice(0, end);
196
- return [...head.matchAll(/^parent ([0-9a-f]{40}|[0-9a-f]{64})$/gm)].map((m) => m[1]);
197
- };
198
-
199
- /** @type {Set<string>} */
200
- const excluded = new Set();
201
- if (published !== null) {
202
- const stack = [published];
203
- while (stack.length > 0 && excluded.size < MAX_COMMITS) {
204
- const oid = /** @type {string} */ (stack.pop());
205
- if (excluded.has(oid)) continue;
206
- excluded.add(oid);
207
- const ps = parentsOf(oid);
208
- if (ps !== null) stack.push(...ps);
209
- }
210
- }
211
-
212
- /** @type {string[]} */
213
- const commits = [];
214
- /** @type {string[]} */
215
- const unreadable = [];
216
- /** @type {Set<string>} */
217
- const seen = new Set();
218
- const stack = [tip];
219
- let truncated = false;
220
- while (stack.length > 0) {
221
- const oid = /** @type {string} */ (stack.pop());
222
- if (seen.has(oid) || excluded.has(oid)) continue;
223
- seen.add(oid);
224
- if (commits.length >= MAX_COMMITS) { truncated = true; break; }
225
- const ps = parentsOf(oid);
226
- if (ps === null) { unreadable.push(oid); continue; }
227
- commits.push(oid);
228
- stack.push(...ps);
229
- }
230
- return { commits, unreadable, truncated };
231
- }
232
-
233
- /**
234
- * Every blob in a commit's tree, as `path -> oid`, following subtrees and
235
- * skipping gitlinks (mode 0o160000 — a submodule pointer names a commit in
236
- * another repository, whose contents are not ours to read).
237
- *
238
- * @param {string} gitDir
239
- * @param {string} treeOid
240
- * @param {Set<string>} treesSeen Shared across commits: sibling releases share
241
- * almost all of their trees, and re-walking them is the whole cost.
242
- * @param {(path: string, oid: string) => void} onBlob
243
- */
244
- function walkTree(gitDir, treeOid, treesSeen, onBlob, prefix = '') {
245
- if (treesSeen.has(treeOid)) return;
246
- treesSeen.add(treeOid);
247
- const obj = readObject(gitDir, treeOid);
248
- if (obj === null || obj.type !== 'tree') return;
249
- const entries = parseTree(obj.data, treeOid.length / 2);
250
- if (entries === null) return;
251
- for (const e of entries) {
252
- const full = prefix ? `${prefix}/${e.name}` : e.name;
253
- if (e.mode === 0o160000) continue;
254
- if (e.mode === 0o40000) walkTree(gitDir, e.oid, treesSeen, onBlob, full);
255
- else onBlob(full, e.oid);
256
- }
257
- }
258
-
259
- /**
260
- * Read a blob as text, or null when it is missing, oversized or binary.
261
- * @param {string} gitDir
262
- * @param {string} oid
263
- */
264
- function readTextBlob(gitDir, oid) {
265
- const obj = readObject(gitDir, oid);
266
- if (obj === null || obj.type !== 'blob') return null;
267
- if (obj.data.length > MAX_BLOB_BYTES) return null;
268
- if (obj.data.indexOf(0, 0) !== -1
269
- && obj.data.indexOf(0, 0) < BINARY_SNIFF_BYTES) return null;
270
- return obj.data.toString('utf8');
271
- }
272
-
273
- /**
274
- * Every literal a detector finds anywhere in a tree — the baseline of what is
275
- * already public, so the same string appearing again is not a new disclosure.
276
- *
277
- * @param {string} gitDir
278
- * @param {string} treeOid
279
- * @returns {Set<string>}
280
- */
281
- function baselineLiterals(gitDir, treeOid) {
282
- /** @type {Set<string>} */
283
- const found = new Set();
284
- /** @type {Set<string>} */
285
- const trees = new Set();
286
- /** @type {Set<string>} */
287
- const blobs = new Set();
288
- walkTree(gitDir, treeOid, trees, (_p, oid) => {
289
- if (blobs.has(oid) || blobs.size >= MAX_BLOBS) return;
290
- blobs.add(oid);
291
- const text = readTextBlob(gitDir, oid);
292
- if (text === null) return;
293
- for (const d of DETECTORS) for (const lit of d.extract(text)) found.add(lit);
294
- });
295
- return found;
296
- }
297
-
298
- /**
299
- * @typedef {object} PrivacyHit
300
- * @property {string} commit Oid of the commit whose tree carries it.
301
- * @property {string} path Path within that tree.
302
- * @property {string} kind Detector name, or 'private-pattern'.
303
- * @property {string} why Human sentence for the operator.
304
- * @property {string} [literal] The offending string, when it is safe to print
305
- * (detector hits only — a supplement hit prints its pattern, never its match).
306
- */
307
-
308
- /**
309
- * Scan the unpublished history.
310
- *
311
- * @param {string} gitDir
312
- * @param {object} opts
313
- * @param {string} opts.tip Commit about to be published from.
314
- * @param {string|null} opts.published Commit currently public, or null.
315
- * @param {RegExp[]} [opts.privatePatterns]
316
- * @param {string[]} [opts.allow] Literals known to be invented, joined to
317
- * the baseline. Detector hits only — a private pattern is never waived here.
318
- * @returns {{ state: 'clean'|'hits'|'unavailable', hits: PrivacyHit[],
319
- * commitsScanned: number, blobsScanned: number, truncated: boolean }}
320
- */
321
- function scanHistory(gitDir, opts) {
322
- const priv = opts.privatePatterns || [];
323
- const walk = unpublishedCommits(gitDir, opts.tip, opts.published);
324
- const { commits, truncated } = walk;
325
-
326
- // ANY commit the object store could not answer for makes the whole scan
327
- // inconclusive, and inconclusive is not clean. A gate that cleared a release
328
- // because it failed to read the evidence would be worse than no gate: it
329
- // would report success in exactly the situation it exists to catch.
330
- if (walk.unreadable.length > 0) {
331
- return {
332
- state: 'unavailable', hits: [], commitsScanned: 0, blobsScanned: 0, truncated,
333
- };
334
- }
335
-
336
- if (commits.length === 0) {
337
- return { state: 'clean', hits: [], commitsScanned: 0, blobsScanned: 0, truncated };
338
- }
339
-
340
- // What the published tree already says. Absent a published ref there is no
341
- // baseline and every detector hit is news, which is the correct reading of
342
- // a first publication.
343
- /** @type {Set<string>} */
344
- let baseline = new Set(opts.allow || []);
345
- if (opts.published !== null) {
346
- const obj = readObject(gitDir, opts.published);
347
- if (obj !== null && obj.type === 'commit') {
348
- const m = /^tree ([0-9a-f]{40}|[0-9a-f]{64})$/m.exec(
349
- obj.data.toString('latin1', 0, Math.min(obj.data.length, 256)));
350
- // Merged, not assigned: the allow list must survive the published tree's
351
- // own literals being read in on top of it.
352
- if (m) for (const lit of baselineLiterals(gitDir, m[1])) baseline.add(lit);
353
- }
354
- }
355
-
356
- /** @type {PrivacyHit[]} */
357
- const hits = [];
358
- let unreadable = 0;
359
- let blobCeiling = false;
360
-
361
- // Reading and matching a blob is the expensive half and is done ONCE per
362
- // oid. Attribution is the cheap half and is done per commit: the same blob
363
- // usually survives many commits, and an operator who is told only about the
364
- // first one would accept that commit and publish the other nine. The earlier
365
- // shape of this function made exactly that mistake.
366
- /** @type {Map<string, Array<{ kind: string, why: string, literal?: string }>>} */
367
- const verdicts = new Map();
368
- const verdictFor = (/** @type {string} */ oid) => {
369
- const cached = verdicts.get(oid);
370
- if (cached !== undefined) return cached;
371
- if (verdicts.size >= MAX_BLOBS) { blobCeiling = true; return []; }
372
- /** @type {Array<{ kind: string, why: string, literal?: string }>} */
373
- const found = [];
374
- const text = readTextBlob(gitDir, oid);
375
- if (text !== null) {
376
- // One finding per distinct literal per blob. A string repeated forty
377
- // times in a file is one disclosure, and forty lines of it would bury
378
- // the other findings the operator needs to see.
379
- /** @type {Set<string>} */
380
- const already = new Set();
381
- for (const d of DETECTORS) {
382
- for (const lit of d.extract(text)) {
383
- if (baseline.has(lit) || already.has(lit)) continue;
384
- already.add(lit);
385
- found.push({ kind: d.name, why: d.why, literal: lit });
386
- }
387
- }
388
- for (const re of priv) {
389
- if (re.test(text)) {
390
- found.push({ kind: 'private-pattern', why: `matches the private pattern /${re.source}/` });
391
- }
392
- }
393
- }
394
- verdicts.set(oid, found);
395
- return found;
396
- };
397
-
398
- for (const commit of commits) {
399
- const obj = readObject(gitDir, commit);
400
- if (obj === null || obj.type !== 'commit') { unreadable += 1; continue; }
401
- const m = /^tree ([0-9a-f]{40}|[0-9a-f]{64})$/m.exec(
402
- obj.data.toString('latin1', 0, Math.min(obj.data.length, 256)));
403
- if (!m) { unreadable += 1; continue; }
404
-
405
- // Per-commit, so a tree shared with an already-walked commit is still
406
- // attributed to this one. Within a commit it still collapses duplicates.
407
- /** @type {Set<string>} */
408
- const treesSeen = new Set();
409
- walkTree(gitDir, m[1], treesSeen, (p, oid) => {
410
- for (const v of verdictFor(oid)) hits.push({ commit, path: p, ...v });
411
- });
412
- }
413
-
414
- // Same rule one level down: a commit that resolved but whose tree header did
415
- // not parse leaves part of the history unexamined.
416
- if (unreadable > 0) {
417
- return {
418
- state: 'unavailable', hits: [], commitsScanned: 0,
419
- blobsScanned: verdicts.size, truncated: truncated || blobCeiling,
420
- };
421
- }
422
-
423
- return {
424
- state: hits.length > 0 ? 'hits' : 'clean',
425
- hits,
426
- commitsScanned: commits.length,
427
- blobsScanned: verdicts.size,
428
- truncated: truncated || blobCeiling,
429
- };
430
- }
431
-
432
- module.exports = {
433
- scanHistory, unpublishedCommits, loadPrivatePatterns, walkTree, baselineLiterals,
434
- DETECTORS, MAX_COMMITS, MAX_BLOBS, MAX_BLOB_BYTES,
435
- };