@trazum/cli 1.8.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.
@@ -0,0 +1,225 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ /**
6
+ * Not asking the model the same question twice.
7
+ *
8
+ * The roadmap item this answers was "prompt caching for `--suggest`", meaning
9
+ * the API feature: mark a stable prefix with `cache_control` and pay a tenth of
10
+ * the price for it on every later call. **That cannot work here, and the reason
11
+ * is a number rather than an opinion.**
12
+ *
13
+ * Prompt caching has a minimum cacheable prefix — 512 tokens on the newest
14
+ * models, 1,024 on most, 4,096 on some — and a prefix shorter than the minimum
15
+ * is *silently* not cached: no error, no warning, `cache_creation_input_tokens`
16
+ * comes back zero. Trazum's suggest prompt is **291 tokens**. Marking it would
17
+ * have looked like an optimisation, cost a line of code, changed nothing, and
18
+ * been impossible to notice. `suggest-cache.test.js` measures it against the
19
+ * published minima so that stays true, or stops being true loudly.
20
+ *
21
+ * The stable prefix is also the only thing that *could* be cached: the rest of
22
+ * the request is the author's prompt, which is different every time. So there
23
+ * is no arrangement of `cache_control` that helps.
24
+ *
25
+ * What does help is the observation behind the request — running `--suggest`
26
+ * over a directory asks the same questions again on every run, and most of the
27
+ * prompts have not changed since the last one. Answering those from disk is not
28
+ * a 90% saving on the call, it is the whole call. On a re-run after editing two
29
+ * files out of forty, thirty-eight requests do not happen.
30
+ *
31
+ * Three decisions worth arguing with:
32
+ *
33
+ * **The raw response is cached, not the parsed suggestions.** Everything
34
+ * `suggestRewrites` does after the model answers — checking each `before`
35
+ * appears byte for byte, refusing anything that touches protected content,
36
+ * dropping overlaps — is deterministic and lives in the core. Caching the text
37
+ * means a hit is re-validated by *today's* rules rather than replaying a
38
+ * verdict reached by an older version. Same reasoning as recomputing token
39
+ * counts on read instead of storing them.
40
+ *
41
+ * **It is opt-in.** A cache hit returns what the model said last time, and a
42
+ * model is not a pure function — silently answering from a week-old response
43
+ * would be a surprise, in a tool whose other model-touching features
44
+ * (`--suggest`, `--apply-suggestions`, `--reorder`) all require asking twice.
45
+ *
46
+ * **The files are 0600 in a 0700 directory.** The cache holds prompt text, and
47
+ * a prompt is the most sensitive thing this tool ever touches — it is somebody's
48
+ * unreleased product behaviour. A world-readable cache in a shared home
49
+ * directory would publish it to every account on the machine.
50
+ */
51
+ /**
52
+ * Bumped when anything that shapes the answer changes and is not already in the
53
+ * key — the suggest system prompt, the response format, the checking rules.
54
+ * A stale entry answers a question that is no longer the one being asked.
55
+ *
56
+ * Exported so the test can derive a key independently rather than comparing
57
+ * `cacheKey` to itself.
58
+ */
59
+ export const SCHEMA = 2;
60
+ /** Seven days. Long enough for a working week, short enough that an alias that started pointing at a new model does not answer forever. */
61
+ export const DEFAULT_TTL_DAYS = 7;
62
+ /**
63
+ * Where the cache lives.
64
+ *
65
+ * `XDG_CACHE_HOME` first, because a user who set it meant it. Not the project
66
+ * directory: two checkouts of the same repository ask the same questions, and a
67
+ * per-checkout cache answers neither of them from the other.
68
+ */
69
+ export function cacheDir(env = process.env) {
70
+ const base = env.XDG_CACHE_HOME?.trim() || join(homedir(), '.cache');
71
+ return join(base, 'trazum', 'suggestions');
72
+ }
73
+ /**
74
+ * The key: everything that changes the answer, and nothing that does not.
75
+ *
76
+ * `provider` and `model` are in here rather than only in the entry because two
77
+ * models answer differently — a hit from the wrong one is not a hit. The system
78
+ * prompt is in here rather than relying on `SCHEMA` alone, so a caller passing
79
+ * their own system prompt gets their own entries without anybody remembering to
80
+ * bump a constant.
81
+ */
82
+ export function cacheKey(input) {
83
+ // Length-prefixed rather than delimiter-joined: a delimiter that can occur
84
+ // inside a prompt lets two different inputs produce one key.
85
+ const parts = [String(SCHEMA), input.provider, input.model, input.system, input.user];
86
+ const canonical = parts.map((part) => `${part.length}:${part}`).join('');
87
+ return createHash('sha256').update(canonical, 'utf8').digest('hex');
88
+ }
89
+ function entryPath(dir, key) {
90
+ return join(dir, `${key}.json`);
91
+ }
92
+ export function readEntry(dir, key, now, ttlDays) {
93
+ let raw;
94
+ try {
95
+ raw = readFileSync(entryPath(dir, key), 'utf8');
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ let entry;
101
+ try {
102
+ entry = JSON.parse(raw);
103
+ }
104
+ catch {
105
+ // A truncated write from an interrupted run. Treated as a miss rather than
106
+ // an error: the answer is one API call away, and refusing to run because a
107
+ // cache file is corrupt would be worse than the problem.
108
+ return null;
109
+ }
110
+ if (entry.schema !== SCHEMA)
111
+ return null;
112
+ if (typeof entry.response !== 'string')
113
+ return null;
114
+ if (typeof entry.at !== 'number')
115
+ return null;
116
+ if (now - entry.at > ttlDays * 86_400_000)
117
+ return null;
118
+ return entry;
119
+ }
120
+ export function writeEntry(dir, key, entry) {
121
+ try {
122
+ // 0700: the cache holds prompt text. A default-permission directory in a
123
+ // shared home publishes it to every other account on the machine.
124
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
125
+ writeFileSync(entryPath(dir, key), `${JSON.stringify(entry, null, 2)}\n`, { mode: 0o600 });
126
+ }
127
+ catch {
128
+ // A cache that cannot be written is a cache that is not used. Read-only
129
+ // home directory, full disk, hostile umask — none of them are reasons to
130
+ // fail a command that was going to work.
131
+ }
132
+ }
133
+ /** Delete every entry. Returns how many went. */
134
+ export function clearCache(dir) {
135
+ let removed = 0;
136
+ let names;
137
+ try {
138
+ names = readdirSync(dir);
139
+ }
140
+ catch {
141
+ return 0;
142
+ }
143
+ for (const name of names) {
144
+ // Only files this module wrote. A cache directory that deletes whatever it
145
+ // finds is a cache directory somebody eventually points at their home.
146
+ if (!/^[0-9a-f]{64}\.json$/.test(name))
147
+ continue;
148
+ try {
149
+ unlinkSync(join(dir, name));
150
+ removed += 1;
151
+ }
152
+ catch {
153
+ // Already gone, or not ours to delete.
154
+ }
155
+ }
156
+ return removed;
157
+ }
158
+ /** Entry count and total bytes, so `--clear-suggestion-cache` can say what it emptied. */
159
+ export function cacheStats(dir) {
160
+ let names;
161
+ try {
162
+ names = readdirSync(dir);
163
+ }
164
+ catch {
165
+ return { entries: 0, bytes: 0 };
166
+ }
167
+ let entries = 0;
168
+ let bytes = 0;
169
+ for (const name of names) {
170
+ if (!/^[0-9a-f]{64}\.json$/.test(name))
171
+ continue;
172
+ try {
173
+ bytes += statSync(join(dir, name)).size;
174
+ entries += 1;
175
+ }
176
+ catch {
177
+ // Raced with a clear. Not counted.
178
+ }
179
+ }
180
+ return { entries, bytes };
181
+ }
182
+ /**
183
+ * Wraps a provider so identical questions are asked once.
184
+ *
185
+ * A wrapper rather than a change inside `suggestRewrites`, for two reasons: the
186
+ * core stays free of `node:fs` (it is browser-safe, and a test asserts the
187
+ * import graph), and every command that reaches for an LLM gets the cache by
188
+ * passing through one function rather than by each remembering to.
189
+ */
190
+ export function cachingProvider(inner, options) {
191
+ const { dir, ttlDays = DEFAULT_TTL_DAYS, now = Date.now } = options;
192
+ let hits = 0;
193
+ let misses = 0;
194
+ return {
195
+ name: inner.name,
196
+ model: inner.model,
197
+ get hits() {
198
+ return hits;
199
+ },
200
+ get misses() {
201
+ return misses;
202
+ },
203
+ async complete({ system, user }) {
204
+ const key = cacheKey({ provider: inner.name, model: inner.model, system, user });
205
+ const cached = readEntry(dir, key, now(), ttlDays);
206
+ if (cached) {
207
+ hits += 1;
208
+ return cached.response;
209
+ }
210
+ const response = await inner.complete({ system, user });
211
+ misses += 1;
212
+ // Written after the call succeeds, so a failed request is not remembered
213
+ // as an answer. A thrown error propagates untouched.
214
+ writeEntry(dir, key, {
215
+ schema: SCHEMA,
216
+ at: now(),
217
+ provider: inner.name,
218
+ model: inner.model,
219
+ response,
220
+ });
221
+ return response;
222
+ },
223
+ };
224
+ }
225
+ //# sourceMappingURL=suggest-cache.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"suggest-cache.js","sourceRoot":"","sources":["../src/suggest-cache.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,SAAS,EAAE,YAAY,EAAE,WAAW,EAAE,QAAQ,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AACpG,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAClC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAIjC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6CG;AAEH;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,MAAM,GAAG,CAAC,CAAC;AAExB,2IAA2I;AAC3I,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,CAAC;AAYlC;;;;;;GAMG;AACH,MAAM,UAAU,QAAQ,CAAC,GAAG,GAAsB,OAAO,CAAC,GAAG;IAC3D,MAAM,IAAI,GAAG,GAAG,CAAC,cAAc,EAAE,IAAI,EAAE,IAAI,IAAI,CAAC,OAAO,EAAE,EAAE,QAAQ,CAAC,CAAC;IACrE,OAAO,IAAI,CAAC,IAAI,EAAE,QAAQ,EAAE,aAAa,CAAC,CAAC;AAC7C,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,QAAQ,CAAC,KAKxB;IACC,2EAA2E;IAC3E,6DAA6D;IAC7D,MAAM,KAAK,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,KAAK,CAAC,QAAQ,EAAE,KAAK,CAAC,KAAK,EAAE,KAAK,CAAC,MAAM,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC;IACtF,MAAM,SAAS,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,IAAI,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACzE,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,SAAS,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AACtE,CAAC;AAED,SAAS,SAAS,CAAC,GAAW,EAAE,GAAW;IACzC,OAAO,IAAI,CAAC,GAAG,EAAE,GAAG,GAAG,OAAO,CAAC,CAAC;AAClC,CAAC;AAED,MAAM,UAAU,SAAS,CACvB,GAAW,EACX,GAAW,EACX,GAAW,EACX,OAAe;IAEf,IAAI,GAAW,CAAC;IAChB,IAAI,CAAC;QACH,GAAG,GAAG,YAAY,CAAC,SAAS,CAAC,GAAG,EAAE,GAAG,CAAC,EAAE,MAAM,CAAC,CAAC;IAClD,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;IAED,IAAI,KAAiB,CAAC;IACtB,IAAI,CAAC;QACH,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAe,CAAC;IACxC,CAAC;IAAC,MAAM,CAAC;QACP,2EAA2E;QAC3E,2EAA2E;QAC3E,yDAAyD;QACzD,OAAO,IAAI,CAAC;IACd,CAAC;IAED,IAAI,KAAK,CAAC,MAAM,KAAK,MAAM;QAAE,OAAO,IAAI,CAAC;IACzC,IAAI,OAAO,KAAK,CAAC,QAAQ,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC;IACpD,IAAI,OAAO,KAAK,CAAC,EAAE,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC;IAC9C,IAAI,GAAG,GAAG,KAAK,CAAC,EAAE,GAAG,OAAO,GAAG,UAAU;QAAE,OAAO,IAAI,CAAC;IAEvD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,MAAM,UAAU,UAAU,CAAC,GAAW,EAAE,GAAW,EAAE,KAAiB;IACpE,IAAI,CAAC;QACH,yEAAyE;QACzE,kEAAkE;QAClE,SAAS,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;QACjD,aAAa,CAAC,SAAS,CAAC,GAAG,EAAE,GAAG,CAAC,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;IAC7F,CAAC;IAAC,MAAM,CAAC;QACP,wEAAwE;QACxE,yEAAyE;QACzE,yCAAyC;IAC3C,CAAC;AACH,CAAC;AAED,iDAAiD;AACjD,MAAM,UAAU,UAAU,CAAC,GAAW;IACpC,IAAI,OAAO,GAAG,CAAC,CAAC;IAChB,IAAI,KAAe,CAAC;IACpB,IAAI,CAAC;QACH,KAAK,GAAG,WAAW,CAAC,GAAG,CAAC,CAAC;IAC3B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,CAAC,CAAC;IACX,CAAC;IAED,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,2EAA2E;QAC3E,uEAAuE;QACvE,IAAI,CAAC,sBAAsB,CAAC,IAAI,CAAC,IAAI,CAAC;YAAE,SAAS;QACjD,IAAI,CAAC;YACH,UAAU,CAAC,IAAI,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC,CAAC;YAC5B,OAAO,IAAI,CAAC,CAAC;QACf,CAAC;QAAC,MAAM,CAAC;YACP,uCAAuC;QACzC,CAAC;IACH,CAAC;IACD,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,0FAA0F;AAC1F,MAAM,UAAU,UAAU,CAAC,GAAW;IACpC,IAAI,KAAe,CAAC;IACpB,IAAI,CAAC;QACH,KAAK,GAAG,WAAW,CAAC,GAAG,CAAC,CAAC;IAC3B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC;IAClC,CAAC;IAED,IAAI,OAAO,GAAG,CAAC,CAAC;IAChB,IAAI,KAAK,GAAG,CAAC,CAAC;IACd,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,IAAI,CAAC,sBAAsB,CAAC,IAAI,CAAC,IAAI,CAAC;YAAE,SAAS;QACjD,IAAI,CAAC;YACH,KAAK,IAAI,QAAQ,CAAC,IAAI,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC;YACxC,OAAO,IAAI,CAAC,CAAC;QACf,CAAC;QAAC,MAAM,CAAC;YACP,mCAAmC;QACrC,CAAC;IACH,CAAC;IACD,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC;AAC5B,CAAC;AASD;;;;;;;GAOG;AACH,MAAM,UAAU,eAAe,CAC7B,KAAkB,EAClB,OAIC;IAED,MAAM,EAAE,GAAG,EAAE,OAAO,GAAG,gBAAgB,EAAE,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,OAAO,CAAC;IACpE,IAAI,IAAI,GAAG,CAAC,CAAC;IACb,IAAI,MAAM,GAAG,CAAC,CAAC;IAEf,OAAO;QACL,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,KAAK,EAAE,KAAK,CAAC,KAAK;QAClB,IAAI,IAAI;YACN,OAAO,IAAI,CAAC;QACd,CAAC;QACD,IAAI,MAAM;YACR,OAAO,MAAM,CAAC;QAChB,CAAC;QACD,KAAK,CAAC,QAAQ,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE;YAC7B,MAAM,GAAG,GAAG,QAAQ,CAAC,EAAE,QAAQ,EAAE,KAAK,CAAC,IAAI,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC;YAEjF,MAAM,MAAM,GAAG,SAAS,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,EAAE,OAAO,CAAC,CAAC;YACnD,IAAI,MAAM,EAAE,CAAC;gBACX,IAAI,IAAI,CAAC,CAAC;gBACV,OAAO,MAAM,CAAC,QAAQ,CAAC;YACzB,CAAC;YAED,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,QAAQ,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC;YACxD,MAAM,IAAI,CAAC,CAAC;YAEZ,yEAAyE;YACzE,qDAAqD;YACrD,UAAU,CAAC,GAAG,EAAE,GAAG,EAAE;gBACnB,MAAM,EAAE,MAAM;gBACd,EAAE,EAAE,GAAG,EAAE;gBACT,QAAQ,EAAE,KAAK,CAAC,IAAI;gBACpB,KAAK,EAAE,KAAK,CAAC,KAAK;gBAClB,QAAQ;aACT,CAAC,CAAC;YAEH,OAAO,QAAQ,CAAC;QAClB,CAAC;KACF,CAAC;AACJ,CAAC"}
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@trazum/cli",
3
+ "version": "1.8.0",
4
+ "description": "Trazum CLI: find where your LLM bill goes, price every finding per month, and enforce token budgets in CI.",
5
+ "license": "MIT",
6
+ "author": "David Mu\u00f1oz Rey",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Davmunrey/Trazum.git",
10
+ "directory": "packages/cli"
11
+ },
12
+ "keywords": [
13
+ "prompt",
14
+ "llm",
15
+ "tokens",
16
+ "cost",
17
+ "cli",
18
+ "ci"
19
+ ],
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "type": "module",
24
+ "bin": {
25
+ "trazum": "dist/index.js"
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "src",
30
+ "LICENSE",
31
+ "README.md"
32
+ ],
33
+ "scripts": {
34
+ "build": "tsc -p tsconfig.json && chmod +x dist/index.js",
35
+ "typecheck": "tsc -p tsconfig.json --noEmit",
36
+ "test": "npm run build && node --test test/*.test.js",
37
+ "prepublishOnly": "npm run build && npm test"
38
+ },
39
+ "dependencies": {
40
+ "@trazum/core": "1.8.0"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^26.2.0",
44
+ "typescript": "^7.0.2"
45
+ },
46
+ "engines": {
47
+ "node": ">=20"
48
+ }
49
+ }
package/src/git.ts ADDED
@@ -0,0 +1,294 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
3
+
4
+ /**
5
+ * The only place in this repository that runs another program.
6
+ *
7
+ * `trazum blame` needs a file's history, and the history lives in git. Nothing
8
+ * else here has ever shelled out, so this module is written as if it were the
9
+ * whole attack surface — because it is.
10
+ *
11
+ * The rules, and why each one is here rather than in a comment on the call site:
12
+ *
13
+ * - **No shell, ever.** `spawnSync` with an argv array and `shell: false`, which
14
+ * is the default and is stated anyway. The moment a path reaches a shell
15
+ * string, a file called `; rm -rf ~` is a command.
16
+ * - **Paths go after `--`.** Without it, a file named `--upload-pack=curl…` or
17
+ * `--output=…` is read by git as an option, and git has options that run
18
+ * programs. This is the specific reason `trazum blame -- <path>` is not
19
+ * enough on its own: the separator has to be in *our* argv, not the user's.
20
+ * - **Object names are validated before they are used.** `git show <sha>:<path>`
21
+ * glues two values into one argument, so the sha is checked against
22
+ * `/^[0-9a-f]{40}$/` before it can contribute anything to it.
23
+ * - **Everything is bounded.** A timeout so a hung git does not wedge the CLI, a
24
+ * `maxBuffer` so a large blob cannot exhaust memory, and a revision cap so
25
+ * `blame` on a file with 40,000 commits terminates.
26
+ * - **The path must be inside the repository.** Reading history for
27
+ * `../../elsewhere/secrets.txt` is not a thing this command exists to do.
28
+ *
29
+ * Failures are `null` or an empty array rather than exceptions with git's own
30
+ * wording. The caller turns them into a sentence in the reader's language.
31
+ */
32
+
33
+ /** Ten seconds is a long time for `git log`; a hung one should not be forever. */
34
+ const TIMEOUT_MS = 10_000;
35
+
36
+ /** 32 MB. A prompt file this big is not a prompt file. */
37
+ const MAX_BUFFER = 32 * 1024 * 1024;
38
+
39
+ const SHA = /^[0-9a-f]{40}$/;
40
+
41
+ export interface Revision {
42
+ sha: string;
43
+ shortSha: string;
44
+ author: string;
45
+ /** ISO 8601, author date. */
46
+ date: string;
47
+ subject: string;
48
+ }
49
+
50
+ /**
51
+ * Errors that mean *the process could not be started*, not that git said no.
52
+ *
53
+ * `EAGAIN` is the kernel refusing a fork because the process or thread limit is
54
+ * momentarily full; `ENOMEM` is the same story with memory. Both are properties
55
+ * of the machine at that instant and both are gone a moment later, which is
56
+ * exactly why they surface on a loaded CI runner and never on a laptop.
57
+ */
58
+ const TRANSIENT = new Set(['EAGAIN', 'ENOMEM']);
59
+
60
+ /**
61
+ * Why a git invocation produced nothing — which is not one question but two.
62
+ *
63
+ * `git log` exiting 0 with no output means *this file has no history*. Failing
64
+ * to spawn git at all means *we do not know what history this file has*. They
65
+ * had the same representation here — `null` — so `revisionsFor` returned `[]`
66
+ * for both and `blame` told the author "git has no commits touching p.txt",
67
+ * confidently, on the strength of never having asked.
68
+ *
69
+ * That is the shape of issue #58: zero rows, exit 0, only on CI, never
70
+ * reproducible. A fork that fails under load is invisible and looks like a fact
71
+ * about the repository.
72
+ */
73
+ type GitOutcome =
74
+ | { ran: true; stdout: string }
75
+ | { ran: true; stdout: null }
76
+ | { ran: false; stdout: null; detail: string };
77
+
78
+ /**
79
+ * The spawn, injectable — the same seam the LLM providers use for `fetch`.
80
+ *
81
+ * Exported because the retry below is otherwise untestable: provoking a real
82
+ * `EAGAIN` means exhausting the process table, which is not something a test
83
+ * suite should do to the machine running it. A retry nothing checks is a retry
84
+ * somebody deletes in a refactor, and mutation testing said exactly that.
85
+ *
86
+ * It widens nothing in practice: the CLI has no library entry, so this module
87
+ * is reachable only from inside this package and from its tests.
88
+ */
89
+ export type SpawnLike = typeof spawnSync;
90
+
91
+ function runGit(
92
+ args: readonly string[],
93
+ cwd: string,
94
+ spawn: SpawnLike = spawnSync,
95
+ ): GitOutcome {
96
+ /**
97
+ * Bounded by the loop, not by a condition inside it.
98
+ *
99
+ * Written as `for (;;)` with a `continue` guarded by `attempt === 0` first,
100
+ * which is one edit away from retrying for ever — and mutation testing does
101
+ * not report that as a surviving mutant, it reports it as the suite hanging
102
+ * until the runner is killed. In CI that is a job that burns its whole
103
+ * timeout instead of failing in a second.
104
+ *
105
+ * Two attempts, and only for a failure to *start* the process. A git that ran
106
+ * and exited non-zero is answering, and asking it twice would just re-run a
107
+ * command that already failed for a reason.
108
+ */
109
+ const ATTEMPTS = 2;
110
+ let last: GitOutcome = { ran: false, stdout: null, detail: 'not attempted' };
111
+
112
+ for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
113
+ const result = spawn('git', args, {
114
+ cwd,
115
+ // Stated rather than left to the default: this is the line that matters.
116
+ shell: false,
117
+ encoding: 'utf8',
118
+ timeout: TIMEOUT_MS,
119
+ maxBuffer: MAX_BUFFER,
120
+ // No prompting for credentials, no pager waiting on a TTY that is not there.
121
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_PAGER: 'cat', PAGER: 'cat' },
122
+ });
123
+
124
+ if (result.error !== undefined) {
125
+ const code = (result.error as NodeJS.ErrnoException).code ?? '';
126
+ last = { ran: false, stdout: null, detail: code || result.error.message };
127
+ if (TRANSIENT.has(code)) continue;
128
+ return last;
129
+ }
130
+
131
+ if (result.status !== 0) return { ran: true, stdout: null };
132
+ return { ran: true, stdout: result.stdout };
133
+ }
134
+
135
+ // Every attempt was refused before git started. The machine is out of
136
+ // whatever it ran out of, and saying so beats a third try.
137
+ return last;
138
+ }
139
+
140
+ function git(args: readonly string[], cwd: string): string | null {
141
+ return runGit(args, cwd).stdout;
142
+ }
143
+
144
+ /**
145
+ * Thrown when git could not be run, as distinct from git having nothing to say.
146
+ *
147
+ * A distinct type rather than a message, so a caller cannot accidentally treat
148
+ * it as an empty result — which is the whole bug.
149
+ */
150
+ export class GitUnavailableError extends Error {
151
+ constructor(detail: string) {
152
+ super(`could not run git (${detail})`);
153
+ this.name = 'GitUnavailableError';
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Whether `git` can be run at all.
159
+ *
160
+ * Separate from `repositoryRoot` because the two failures need different
161
+ * advice: "install git" and "run this inside a repository" have nothing to do
162
+ * with each other, and a single "could not read history" would send half the
163
+ * readers looking in the wrong place.
164
+ */
165
+ export function gitAvailable(cwd: string): boolean {
166
+ return git(['--version'], cwd) !== null;
167
+ }
168
+
169
+ /** The repository root containing `cwd`, or `null` if there is not one. */
170
+ export function repositoryRoot(cwd: string): string | null {
171
+ const out = git(['rev-parse', '--show-toplevel'], cwd);
172
+ return out === null ? null : out.trim() || null;
173
+ }
174
+
175
+ /**
176
+ * The path as git knows it — relative to the repository root, forward slashes —
177
+ * or `null` when it falls outside the repository.
178
+ */
179
+ export function pathInRepository(root: string, target: string): string | null {
180
+ const rel = relative(resolve(root), resolve(target));
181
+ // Empty means the target *is* the root, `..` at the front means it escaped,
182
+ // and an absolute result means the two are on different Windows drives. All
183
+ // three are "outside the repository".
184
+ if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
185
+ return null;
186
+ }
187
+ return rel.split(sep).join('/');
188
+ }
189
+
190
+ /**
191
+ * Commits that touched `repoPath`, newest first.
192
+ *
193
+ * `--follow` so a renamed prompt keeps its history: a file moved from
194
+ * `prompt.txt` to `prompts/support.txt` is the same prompt, and a cost history
195
+ * that restarts at the rename is telling you the growth began the day somebody
196
+ * tidied the directory.
197
+ */
198
+ export function revisionsFor(
199
+ repoPath: string,
200
+ options: { cwd: string; max: number; spawn?: SpawnLike },
201
+ ): Revision[] {
202
+ // A unit separator between fields and a record separator between commits:
203
+ // both are characters git will not emit inside a name or a subject, unlike
204
+ // the tab and newline that a commit subject can absolutely contain.
205
+ // Written as escapes, not typed in. A raw control byte in a source file is
206
+ // how `scripts/measure-token-band.mjs` ended up with no reviewable diff for
207
+ // three commits, one of which was a security fix.
208
+ const FIELD = '\u001f';
209
+ const RECORD = '\u001e';
210
+ const format = ['%H', '%h', '%an', '%aI', '%s'].join(FIELD) + RECORD;
211
+
212
+ const outcome = runGit(
213
+ [
214
+ 'log',
215
+ '--follow',
216
+ `--max-count=${Math.max(1, Math.floor(options.max))}`,
217
+ `--format=${format}`,
218
+ // Everything after this is a path, whatever it looks like.
219
+ '--',
220
+ repoPath,
221
+ ],
222
+ options.cwd,
223
+ options.spawn,
224
+ );
225
+
226
+ // The distinction this function used to lose. An empty list now means git
227
+ // looked and found nothing; being unable to look throws instead, so it can
228
+ // never be reported to somebody as a fact about their repository.
229
+ if (!outcome.ran) throw new GitUnavailableError(outcome.detail);
230
+ const out = outcome.stdout;
231
+ if (out === null) return [];
232
+
233
+ return out
234
+ .split(RECORD)
235
+ .map((record) => record.replace(/^\n/, ''))
236
+ .filter((record) => record.trim() !== '')
237
+ .map((record) => {
238
+ const [sha = '', shortSha = '', author = '', date = '', subject = ''] = record.split(FIELD);
239
+ return { sha, shortSha, author, date, subject };
240
+ })
241
+ .filter((revision) => SHA.test(revision.sha));
242
+ }
243
+
244
+ /**
245
+ * The file's content at a commit, or `null` if it did not exist there.
246
+ *
247
+ * `--follow` above means the path can differ from the one at that commit, so
248
+ * this asks git for the name it had rather than assuming today's.
249
+ */
250
+ export function contentAt(sha: string, repoPath: string, cwd: string): string | null {
251
+ // The sha becomes part of a single `sha:path` argument, so it is checked
252
+ // before it can contribute anything to one.
253
+ if (!SHA.test(sha)) return null;
254
+ return git(['show', `${sha}:${repoPath}`], cwd);
255
+ }
256
+
257
+ /**
258
+ * The name the file had at each commit, keyed by sha.
259
+ *
260
+ * One `git log` rather than one per revision, and it replaces a version that
261
+ * asked per commit and got nothing back: `git log --follow --max-count=1 <sha>
262
+ * -- <today's name>` returns an empty list for every commit before a rename,
263
+ * because at those commits that name did not exist. The effect was that a
264
+ * renamed prompt showed "not present" for its entire history before the move —
265
+ * the data was there, under the old name, and the report said there was none.
266
+ *
267
+ * Asking once, without a starting commit, lets `--follow` do the mapping it
268
+ * exists for: the output pairs each sha with the path it touched.
269
+ */
270
+ export function namesByRevision(repoPath: string, cwd: string, max: number): Map<string, string> {
271
+ const MARK = '\u001e';
272
+ const out = git(
273
+ [
274
+ 'log',
275
+ '--follow',
276
+ '--name-only',
277
+ `--max-count=${Math.max(1, Math.floor(max))}`,
278
+ `--format=${MARK}%H`,
279
+ '--',
280
+ repoPath,
281
+ ],
282
+ cwd,
283
+ );
284
+
285
+ const names = new Map<string, string>();
286
+ if (out === null) return names;
287
+
288
+ for (const record of out.split(MARK)) {
289
+ const lines = record.split('\n').map((line) => line.trim()).filter((line) => line !== '');
290
+ const [sha, name] = lines;
291
+ if (sha !== undefined && name !== undefined && SHA.test(sha)) names.set(sha, name);
292
+ }
293
+ return names;
294
+ }