@a11y-lens/cli 0.4.1 → 0.5.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/README.md CHANGED
@@ -31,6 +31,8 @@ husky - pre-commit hook exited with code 1
31
31
 
32
32
  **Infrastructure never blocks a commit.** No agent CLI, no network, agent crash → a11y-lens warns and exits 0. Only real accessibility findings gate.
33
33
 
34
+ **…but a skipped check does not pass for a clean one.** Exiting 0 means your hook runner shows the same ✔️ either way, so when a staged check could not review a file — the agent timed out or failed, its output could not be parsed, or the file was dropped for the prompt size budget — a11y-lens records it as *pending*. The next check warns about it, and `a11y-lens check --pending` reviews it later. See [Skipped checks](#skipped-checks).
35
+
34
36
  ## It samples; it does not audit
35
37
 
36
38
  a11y-lens is an AI reviewer, not a deterministic linter. The same files reviewed twice can return different findings — even zero on a run that flagged issues a moment earlier. Read the output with that in mind:
@@ -76,11 +78,42 @@ a11y-lens check --staged # what the git hook runs
76
78
  a11y-lens check src/Modal.tsx # review specific files
77
79
  a11y-lens check --staged --strict # warnings also fail
78
80
  a11y-lens check --staged --agent codex
81
+ a11y-lens check --pending # review files an earlier check skipped
79
82
  a11y-lens rules # list rule categories
80
83
  ```
81
84
 
82
85
  Escape hatches: `A11Y_LENS_SKIP=1 git commit …` or `git commit --no-verify`.
83
86
 
87
+ | Environment | Effect |
88
+ |---|---|
89
+ | `A11Y_LENS_AGENT` | same as `--agent` |
90
+ | `A11Y_LENS_MODEL` | model passed to `claude` |
91
+ | `A11Y_LENS_TIMEOUT_MS` | agent timeout in milliseconds (default `180000`) |
92
+ | `A11Y_LENS_SKIP=1` | skip the check entirely |
93
+
94
+ ## Skipped checks
95
+
96
+ A staged check records a file as pending when it could not review it:
97
+
98
+ | Why the file was not reviewed | Recorded? |
99
+ |---|---|
100
+ | Agent timed out, crashed, or exited non-zero (including logged out or out of quota) | yes |
101
+ | Agent output could not be parsed as findings | yes |
102
+ | Dropped because the prompt size budget was spent | yes |
103
+ | `A11Y_LENS_SKIP=1`, no agent CLI installed, file over 48KB, no UI files staged | no: deliberate, or it would be skipped again |
104
+
105
+ An entry is cleared when `check --pending` reviews it, or when a later `check --staged` in the same worktree reviews the very same staged content (a commit that was aborted and retried). If the skipped commit landed, its change is no longer in the next diff, so only `--pending` clears it. `--pending` reviews the content that was **staged at the time**, with its staged diff. That way it reports on the skipped change, not on the whole file as it is now. It works even after the file has changed or its worktree is gone. Each skipped check is reviewed in its own agent call. Anything that times out or is dropped again stays pending.
106
+
107
+ **Layout (public contract, version 1).** Other tools may read this, for example a hook that reminds an agent to run `--pending`:
108
+
109
+ ```
110
+ <git rev-parse --git-common-dir>/a11y-lens/pending/<sha1>.json
111
+ { "version": 1, "worktree": "/abs/path", "path": "src/A.tsx", "blob": "<index sha>",
112
+ "diff": "<staged diff>", "reason": "agent failed: …", "at": "2026-09-23T06:00:00.000Z" }
113
+ ```
114
+
115
+ A non-empty directory means something was not reviewed. Any change to this layout bumps `version`. A record this version cannot read (another version wrote it, or it is damaged) is reported and never cleared automatically. Once it has been dealt with, delete the file by hand.
116
+
84
117
  ## Rule set
85
118
 
86
119
  One markdown file per category in `skills/a11y-lens/references/`, consumed by both the skill and the CLI. Each separates the **static baseline** (what eslint/axe already catch — not re-reported) from the **semantic checks** this tool exists for.
package/bin/a11y-lens.mjs CHANGED
@@ -7,6 +7,7 @@ import { installHook, detectRunner } from '../src/hooks.mjs';
7
7
  import { detectAgent, runAgent } from '../src/agent.mjs';
8
8
  import { buildPrompt } from '../src/prompt.mjs';
9
9
  import { parseFindings, printReport, exitCodeFor } from '../src/report.mjs';
10
+ import { recordPending, listPending, clearReviewed, clearEntry, contentOf, pendingDir } from '../src/pending.mjs';
10
11
 
11
12
  const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
12
13
 
@@ -15,6 +16,7 @@ const HELP = `a11y-lens — AI-powered semantic accessibility linter
15
16
  Usage:
16
17
  a11y-lens check --staged review staged UI files (for git hooks)
17
18
  a11y-lens check <files...> review specific files
19
+ a11y-lens check --pending review files an earlier check skipped
18
20
  a11y-lens init install the pre-commit hook (lefthook/husky/git hooks,
19
21
  auto-detected) + inject rules reference into ./AGENTS.md
20
22
  a11y-lens rules list rule categories
@@ -30,15 +32,18 @@ Environment:
30
32
  A11Y_LENS_AGENT same as --agent
31
33
  A11Y_LENS_MODEL model override passed to claude (optional)
32
34
  A11Y_LENS_SKIP=1 skip the check entirely (escape hatch)
35
+ A11Y_LENS_TIMEOUT_MS agent timeout in ms (default 180000)
33
36
 
34
37
  Infrastructure failures (no agent CLI, no network, agent error) never block:
35
- a11y-lens warns and exits 0. Only accessibility findings gate.`;
38
+ a11y-lens warns and exits 0. Only accessibility findings gate. When a staged
39
+ check could not review a file (agent failure or timeout, unparseable output,
40
+ prompt budget), the file is recorded as pending until a later run reviews it.`;
36
41
 
37
42
  function parseArgs(argv) {
38
43
  const args = { _: [], flags: {} };
39
44
  for (let i = 0; i < argv.length; i++) {
40
45
  const token = argv[i];
41
- if (token === '--staged' || token === '--strict' || token === '--no-hook') {
46
+ if (token === '--staged' || token === '--strict' || token === '--no-hook' || token === '--pending') {
42
47
  args.flags[token.slice(2)] = true;
43
48
  }
44
49
  else if (token === '--agent') args.flags.agent = argv[++i];
@@ -48,17 +53,60 @@ function parseArgs(argv) {
48
53
  return args;
49
54
  }
50
55
 
56
+ const RECHECK = 'a11y-lens check --pending';
57
+
51
58
  function softFail(message) {
52
59
  console.warn(`a11y-lens: ${message} — skipping check (commits are never blocked by infrastructure).`);
53
60
  process.exit(0);
54
61
  }
55
62
 
63
+ /** Record what a staged run could not review, and say so where the committer will see it. */
64
+ const RUN_STARTED = new Date();
65
+
66
+ function notePending(files, reason) {
67
+ const result = recordPending(files, reason, { now: RUN_STARTED });
68
+ if (result.error) {
69
+ console.warn(`a11y-lens: could not record ${files.length} unreviewed file(s) as pending (${result.error}).`);
70
+ } else {
71
+ console.warn(`a11y-lens: recorded ${result.recorded} unreviewed file(s) as pending — review them later with: ${RECHECK}`);
72
+ }
73
+ }
74
+
75
+ function skipStaged(files, message) {
76
+ notePending(files, message);
77
+ softFail(message);
78
+ }
79
+
80
+ function warnUnreadable(count) {
81
+ console.warn(
82
+ `a11y-lens: ${count} pending record(s) in ${pendingDir()} could not be read (another a11y-lens version, or damaged). ` +
83
+ 'This version never clears them; delete them by hand once you have dealt with them.',
84
+ );
85
+ }
86
+
87
+ /**
88
+ * Before anything can exit early (a skip, a commit with no UI files): a skipped check looks like a
89
+ * clean one, so the next run is the first chance to say it happened.
90
+ */
91
+ function warnPending() {
92
+ const { entries, unreadable } = listPending();
93
+ if (entries.length) {
94
+ console.warn(
95
+ `a11y-lens: ${entries.length} file(s) from earlier commits were never reviewed (the check was skipped). Review them with: ${RECHECK}`,
96
+ );
97
+ }
98
+ if (unreadable) {
99
+ warnUnreadable(unreadable);
100
+ }
101
+ }
102
+
56
103
  function commandCheck(args) {
104
+ if (args.flags.pending) return commandCheckPending(args);
105
+ warnPending();
57
106
  if (process.env.A11Y_LENS_SKIP === '1') softFail('A11Y_LENS_SKIP=1');
58
107
 
59
- const { files, error } = args.flags.staged
60
- ? collectStagedUIFiles()
61
- : collectPathArgs(args._);
108
+ const staged = Boolean(args.flags.staged);
109
+ const { files, error } = staged ? collectStagedUIFiles() : collectPathArgs(args._);
62
110
  if (error) softFail(error);
63
111
 
64
112
  const reviewable = files.filter((f) => !f.skipped);
@@ -66,7 +114,7 @@ function commandCheck(args) {
66
114
  console.warn(`a11y-lens: skipping ${f.path} (${f.skipped})`);
67
115
  }
68
116
  if (reviewable.length === 0) {
69
- if (args.flags.staged) console.log('a11y-lens: no staged UI files, nothing to review.');
117
+ if (staged) console.log('a11y-lens: no staged UI files, nothing to review.');
70
118
  else console.log('a11y-lens: no reviewable files given. Try: a11y-lens check src/Component.tsx');
71
119
  process.exit(0);
72
120
  }
@@ -78,20 +126,106 @@ function commandCheck(args) {
78
126
  for (const path of dropped) {
79
127
  console.warn(`a11y-lens: dropped ${path} (prompt size budget exceeded)`);
80
128
  }
129
+ const included = reviewable.filter((f) => !dropped.includes(f.path));
130
+ if (staged && dropped.length) {
131
+ notePending(reviewable.filter((f) => dropped.includes(f.path)), 'prompt size budget exceeded');
132
+ }
81
133
 
82
- console.log(
83
- `a11y-lens: reviewing ${reviewable.length - dropped.length} file(s) with ${detection.name}…`,
84
- );
134
+ if (included.length === 0) process.exit(0);
135
+ console.log(`a11y-lens: reviewing ${included.length} file(s) with ${detection.name}…`);
85
136
  const result = runAgent(detection.agent, prompt);
86
- if (!result.ok) softFail(`agent failed: ${result.error}`);
137
+ if (!result.ok) {
138
+ if (staged) skipStaged(included, `agent failed: ${result.error}`);
139
+ softFail(`agent failed: ${result.error}`);
140
+ }
87
141
 
88
142
  const parsed = parseFindings(result.output);
89
- if (parsed.error) softFail(parsed.error);
143
+ if (parsed.error) {
144
+ if (staged) skipStaged(included, parsed.error);
145
+ softFail(parsed.error);
146
+ }
147
+
148
+ // Reviewed now, so an entry left by an earlier skip of the same path — say, a commit that
149
+ // typecheck aborted in parallel — is answered.
150
+ if (staged) clearReviewed(included.map((f) => f.path));
90
151
 
91
152
  printReport(parsed.findings, { agentName: detection.name });
92
153
  process.exit(exitCodeFor(parsed.findings, { strict: args.flags.strict }));
93
154
  }
94
155
 
156
+ /**
157
+ * Review what earlier staged runs skipped. One agent call per recorded skip, not one for all of
158
+ * them: a bad session leaves dozens of entries, and a single prompt that big would time out again
159
+ * and never let the list shrink. Each batch clears only what it actually reviewed.
160
+ */
161
+ function commandCheckPending(args) {
162
+ const { entries, unreadable } = listPending();
163
+ if (unreadable) {
164
+ warnUnreadable(unreadable);
165
+ }
166
+ if (entries.length === 0) {
167
+ console.log('a11y-lens: nothing pending.');
168
+ process.exit(0);
169
+ }
170
+
171
+ const detection = detectAgent(args.flags.agent);
172
+ if (detection.error) softFail(`${detection.error}; ${entries.length} file(s) remain pending`);
173
+
174
+ const batches = new Map();
175
+ for (const item of [...entries].sort((a, b) => a.entry.at.localeCompare(b.entry.at))) {
176
+ const key = `${item.entry.worktree}\0${item.entry.at}`;
177
+ if (!batches.has(key)) batches.set(key, []);
178
+ batches.get(key).push(item);
179
+ }
180
+
181
+ const findings = [];
182
+ let remaining = 0;
183
+ for (const batch of batches.values()) {
184
+ const files = [];
185
+ for (const item of batch) {
186
+ const found = contentOf(item.entry);
187
+ if (found.gone) {
188
+ console.warn(`a11y-lens: dropping pending ${item.entry.path} (${found.gone})`);
189
+ clearEntry(item);
190
+ continue;
191
+ }
192
+ files.push({ path: item.entry.path, content: found.content, diff: item.entry.diff, item });
193
+ }
194
+ if (files.length === 0) continue;
195
+
196
+ const { prompt, dropped } = buildPrompt(files);
197
+ const included = files.filter((f) => !dropped.includes(f.path));
198
+ if (included.length === 0) {
199
+ console.warn(`a11y-lens: every file skipped at ${batch[0].entry.at} exceeds the prompt budget — they stay pending.`);
200
+ remaining += files.length;
201
+ continue;
202
+ }
203
+ console.log(
204
+ `a11y-lens: reviewing ${included.length} pending file(s) skipped at ${batch[0].entry.at} with ${detection.name}…`,
205
+ );
206
+ const result = runAgent(detection.agent, prompt);
207
+ const parsed = result.ok ? parseFindings(result.output) : { error: `agent failed: ${result.error}` };
208
+ if (parsed.error) {
209
+ console.warn(`a11y-lens: ${parsed.error} — ${files.length} file(s) stay pending.`);
210
+ remaining += files.length;
211
+ continue;
212
+ }
213
+ findings.push(...parsed.findings);
214
+ for (const f of files) {
215
+ if (dropped.includes(f.path)) {
216
+ console.warn(`a11y-lens: dropped ${f.path} (prompt size budget exceeded) — it stays pending.`);
217
+ remaining++;
218
+ } else {
219
+ clearEntry(f.item);
220
+ }
221
+ }
222
+ }
223
+
224
+ printReport(findings, { agentName: detection.name });
225
+ if (remaining) console.warn(`a11y-lens: ${remaining} file(s) are still pending — run ${RECHECK} again.`);
226
+ process.exit(exitCodeFor(findings, { strict: args.flags.strict }));
227
+ }
228
+
95
229
  function commandInit(args) {
96
230
  // 1. Pre-commit hook (lefthook / husky / plain git hooks, auto-detected)
97
231
  if (!args.flags['no-hook']) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a11y-lens/cli",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -19,7 +19,7 @@
19
19
  "LICENSE"
20
20
  ],
21
21
  "scripts": {
22
- "test": "node --test test/"
22
+ "test": "node --test test/*.test.mjs"
23
23
  },
24
24
  "keywords": [
25
25
  "accessibility",
package/src/agent.mjs CHANGED
@@ -1,37 +1,47 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
 
3
- const TIMEOUT_MS = 180_000;
3
+ const DEFAULT_TIMEOUT_MS = 180_000;
4
+
5
+ /** A11Y_LENS_TIMEOUT_MS if it is a positive integer, else the default (with a warning for a bad value). */
6
+ export function timeoutMs(env = process.env) {
7
+ const raw = env.A11Y_LENS_TIMEOUT_MS;
8
+ if (raw === undefined || raw === '') return DEFAULT_TIMEOUT_MS;
9
+ const value = Number(raw);
10
+ if (Number.isInteger(value) && value > 0) return value;
11
+ console.warn(`a11y-lens: ignoring A11Y_LENS_TIMEOUT_MS="${raw}" (not a positive integer); using ${DEFAULT_TIMEOUT_MS}ms`);
12
+ return DEFAULT_TIMEOUT_MS;
13
+ }
4
14
 
5
15
  const AGENTS = {
6
16
  claude: {
7
17
  bin: 'claude',
8
- invoke(prompt) {
18
+ invoke(prompt, timeout) {
9
19
  const args = ['-p', '--output-format', 'text'];
10
20
  if (process.env.A11Y_LENS_MODEL) args.push('--model', process.env.A11Y_LENS_MODEL);
11
21
  return spawnSync('claude', args, {
12
22
  input: prompt,
13
23
  encoding: 'utf8',
14
- timeout: TIMEOUT_MS,
24
+ timeout,
15
25
  maxBuffer: 10 * 1024 * 1024,
16
26
  });
17
27
  },
18
28
  },
19
29
  codex: {
20
30
  bin: 'codex',
21
- invoke(prompt) {
31
+ invoke(prompt, timeout) {
22
32
  return spawnSync('codex', ['exec', prompt], {
23
33
  encoding: 'utf8',
24
- timeout: TIMEOUT_MS,
34
+ timeout,
25
35
  maxBuffer: 10 * 1024 * 1024,
26
36
  });
27
37
  },
28
38
  },
29
39
  cursor: {
30
40
  bin: 'cursor-agent',
31
- invoke(prompt) {
41
+ invoke(prompt, timeout) {
32
42
  return spawnSync('cursor-agent', ['-p', prompt, '--output-format', 'text'], {
33
43
  encoding: 'utf8',
34
- timeout: TIMEOUT_MS,
44
+ timeout,
35
45
  maxBuffer: 10 * 1024 * 1024,
36
46
  });
37
47
  },
@@ -61,10 +71,10 @@ export function detectAgent(preferred) {
61
71
  }
62
72
 
63
73
  /** Run the review prompt through the agent. Returns { ok, output, error }. */
64
- export function runAgent(agent, prompt) {
74
+ export function runAgent(agent, prompt, timeout = timeoutMs()) {
65
75
  let result;
66
76
  try {
67
- result = agent.invoke(prompt);
77
+ result = agent.invoke(prompt, timeout);
68
78
  } catch (err) {
69
79
  return { ok: false, error: String(err) };
70
80
  }
@@ -0,0 +1,166 @@
1
+ // Pending reviews: staged files a check could not review, kept so the skip is visible later.
2
+ //
3
+ // A skipped check exits 0 so a commit is never blocked by infrastructure — which also makes it look
4
+ // exactly like a clean one. Each file it failed to review is recorded here, and stays until a later
5
+ // run actually reviews it (`check --staged` on the same path, or `check --pending`).
6
+ //
7
+ // Layout (a public contract — other tools read it; bump PENDING_VERSION on any change):
8
+ // <git-common-dir>/a11y-lens/pending/<sha1(worktree + NUL + path)>.json
9
+ // { version, worktree, path, blob, diff, reason, at }
10
+ //
11
+ // - One file per entry: parallel commits in several worktrees record at the same moment, and a
12
+ // single shared file would lose one of them. Each is written to a temp file and renamed, so a
13
+ // reader never sees half an entry (untested: the race cannot be staged deterministically).
14
+ // - The common dir, so an entry recorded in a worktree is still visible after that worktree is gone.
15
+ // - `blob` is the index object that was staged. It lives in the shared object store, so the exact
16
+ // content that went unreviewed can still be read after the worktree is removed or the file edited.
17
+ // - `diff` is the staged diff, so a re-check reviews the change rather than the whole file and does
18
+ // not report pre-existing issues as if the skipped commit introduced them.
19
+ import { execFileSync } from 'node:child_process';
20
+ import { createHash } from 'node:crypto';
21
+ import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
22
+ import { join } from 'node:path';
23
+
24
+ export const PENDING_VERSION = 1;
25
+
26
+ function git(args, cwd) {
27
+ return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 10 * 1024 * 1024 });
28
+ }
29
+
30
+ /** Absolute pending directory, or null outside a git repository. */
31
+ export function pendingDir(cwd = process.cwd()) {
32
+ try {
33
+ const common = git(['rev-parse', '--path-format=absolute', '--git-common-dir'], cwd).trim();
34
+ return join(common, 'a11y-lens', 'pending');
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ function worktreeOf(cwd) {
41
+ return git(['rev-parse', '--show-toplevel'], cwd).trim();
42
+ }
43
+
44
+ function entryName(worktree, path) {
45
+ return `${createHash('sha1').update(`${worktree}\0${path}`).digest('hex')}.json`;
46
+ }
47
+
48
+ /**
49
+ * Record staged files that were not reviewed. `files` are staged entries ({ path, diff }).
50
+ * Returns { recorded } or { error } — never throws, because a failure here must not block a commit.
51
+ */
52
+ export function recordPending(files, reason, { cwd = process.cwd(), now = new Date() } = {}) {
53
+ if (files.length === 0) return { recorded: 0 };
54
+ try {
55
+ const dir = pendingDir(cwd);
56
+ if (!dir) return { error: 'not a git repository' };
57
+ const worktree = worktreeOf(cwd);
58
+ mkdirSync(dir, { recursive: true });
59
+ const at = now.toISOString();
60
+ for (const file of files) {
61
+ let blob = null;
62
+ try {
63
+ blob = git(['rev-parse', `:${file.path}`], worktree).trim();
64
+ } catch {
65
+ /* not in the index (non-staged run); the working-tree path is the only locator */
66
+ }
67
+ const entry = { version: PENDING_VERSION, worktree, path: file.path, blob, diff: file.diff ?? '', reason, at };
68
+ const target = join(dir, entryName(worktree, file.path));
69
+ const temp = `${target}.${process.pid}.tmp`;
70
+ writeFileSync(temp, JSON.stringify(entry));
71
+ renameSync(temp, target);
72
+ }
73
+ return { recorded: files.length };
74
+ } catch (err) {
75
+ return { error: String(err?.message ?? err) };
76
+ }
77
+ }
78
+
79
+ /** All readable entries, each with its file name. Unreadable or foreign-version files are counted, not returned. */
80
+ export function listPending(cwd = process.cwd()) {
81
+ const dir = pendingDir(cwd);
82
+ if (!dir) return { entries: [], unreadable: 0 };
83
+ let names;
84
+ try {
85
+ names = readdirSync(dir).filter((n) => n.endsWith('.json'));
86
+ } catch {
87
+ return { entries: [], unreadable: 0 };
88
+ }
89
+ const entries = [];
90
+ let unreadable = 0;
91
+ for (const name of names) {
92
+ try {
93
+ const raw = readFileSync(join(dir, name), 'utf8');
94
+ const entry = JSON.parse(raw);
95
+ if (entry?.version !== PENDING_VERSION || typeof entry.path !== 'string') {
96
+ unreadable++;
97
+ continue;
98
+ }
99
+ entries.push({ name, raw, entry });
100
+ } catch {
101
+ unreadable++;
102
+ }
103
+ }
104
+ return { entries, unreadable };
105
+ }
106
+
107
+ /**
108
+ * Drop entries a staged run has just reviewed — only when the recorded blob is the one staged now.
109
+ * That is a commit aborted (typecheck failed in parallel, say) and retried as it was: the skipped
110
+ * content is exactly what was reviewed. If the skipped commit landed instead, its change is in HEAD
111
+ * and absent from the new diff, so this review did not look at it and the entry must stay.
112
+ */
113
+ export function clearReviewed(paths, cwd = process.cwd()) {
114
+ try {
115
+ const dir = pendingDir(cwd);
116
+ if (!dir) return;
117
+ const worktree = worktreeOf(cwd);
118
+ for (const path of paths) {
119
+ const file = join(dir, entryName(worktree, path));
120
+ let entry;
121
+ try {
122
+ entry = JSON.parse(readFileSync(file, 'utf8'));
123
+ } catch {
124
+ continue;
125
+ }
126
+ const staged = git(['rev-parse', `:${path}`], worktree).trim();
127
+ if (entry.blob && entry.blob === staged) rmSync(file, { force: true });
128
+ }
129
+ } catch {
130
+ /* best effort: a stale entry is re-checked later, never lost */
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Drop an entry read earlier, but only if it has not been re-recorded since — a commit that skipped
136
+ * the same path while `--pending` ran has content this run did not review.
137
+ */
138
+ export function clearEntry({ name, raw }, cwd = process.cwd()) {
139
+ const dir = pendingDir(cwd);
140
+ if (!dir) return;
141
+ const file = join(dir, name);
142
+ try {
143
+ if (readFileSync(file, 'utf8') === raw) rmSync(file, { force: true });
144
+ } catch {
145
+ /* already gone */
146
+ }
147
+ }
148
+
149
+ /**
150
+ * The content an entry stands for: the staged blob, else the working-tree file.
151
+ * Returns { content } or { gone: reason }.
152
+ */
153
+ export function contentOf(entry, cwd = process.cwd()) {
154
+ if (entry.blob) {
155
+ try {
156
+ return { content: git(['cat-file', '-p', entry.blob], cwd) };
157
+ } catch {
158
+ /* pruned by gc — fall through to the working tree */
159
+ }
160
+ }
161
+ try {
162
+ return { content: readFileSync(join(entry.worktree, entry.path), 'utf8') };
163
+ } catch {
164
+ return { gone: 'neither the staged blob nor the file exists any more' };
165
+ }
166
+ }
package/src/prompt.mjs CHANGED
@@ -34,19 +34,19 @@ export function buildPrompt(files) {
34
34
  let budget = MAX_TOTAL_BYTES;
35
35
 
36
36
  for (const file of files) {
37
- const block = [
38
- `### FILE: ${file.path}`,
39
- '```',
40
- numbered(file.content),
41
- '```',
42
- file.diff ? `#### Staged diff for ${file.path} (focus your review here)\n\`\`\`diff\n${file.diff}\n\`\`\`` : '',
43
- ].join('\n');
44
- const size = Buffer.byteLength(block, 'utf8');
45
- if (size > budget) {
37
+ const fileBlock = [`### FILE: ${file.path}`, '```', numbered(file.content), '```'].join('\n');
38
+ const withDiff = file.diff
39
+ ? `${fileBlock}\n#### Staged diff for ${file.path} (focus your review here)\n\`\`\`diff\n${file.diff}\n\`\`\``
40
+ : fileBlock;
41
+ // Content is capped at 48KB and a diff is not, so a rewrite can make one file's block larger
42
+ // than the whole budget dropped every time, including on every `--pending` re-check. Without
43
+ // its diff it always fits an empty budget, so it is reviewed whole rather than never.
44
+ const block = [withDiff, fileBlock].find((b) => Buffer.byteLength(b, 'utf8') <= budget);
45
+ if (!block) {
46
46
  dropped.push(file.path);
47
47
  continue;
48
48
  }
49
- budget -= size;
49
+ budget -= Buffer.byteLength(block, 'utf8');
50
50
  included.push(block);
51
51
  }
52
52
 
package/src/staged.mjs CHANGED
@@ -58,7 +58,7 @@ export function collectStagedUIFiles() {
58
58
 
59
59
  let diff = '';
60
60
  try {
61
- diff = git(['diff', '--cached', '--unified=3', '--', path]);
61
+ diff = git(['diff', '--cached', '--unified=3', '--', `:(top)${path}`]);
62
62
  } catch {
63
63
  /* diff is best-effort context */
64
64
  }