readfirst 0.7.0 → 0.9.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
@@ -15,11 +15,16 @@ No server, no account, no model calls. It reads your git checkout and `.readfirs
15
15
  Any language; needs Node 20+ for `npx`.
16
16
 
17
17
  ```sh
18
- npx readfirst init --agent # first map from your folders, checks from your scripts, plus the line for CLAUDE.md / AGENTS.md
18
+ npx readfirst init --agent --ci # first map from your folders, checks from your scripts, the line for CLAUDE.md / AGENTS.md,
19
+ # and the GitHub Action that comments on PRs and keeps the map in step
19
20
  npx readfirst prompt # a brief for your coding agent to fill in titles, checks, risks and rules
20
21
  npx readfirst check # every tracked file is in exactly one group (or ignored); rules point at real files
21
22
  ```
22
23
 
24
+ In a monorepo, each workspace package with its own `package.json` becomes a group with its own checks
25
+ (`pnpm --filter @acme/db test`, `yarn workspace …`, `npm test -w …`), so `verify` runs that package's tests, not the
26
+ whole repository's, and its description becomes the group's title.
27
+
23
28
  Commit `.readfirst/map.json`. From then on:
24
29
 
25
30
  ```sh
@@ -50,6 +55,18 @@ npx readfirst hook --install
50
55
  adds a hook to `.claude/settings.json`. Before each Edit or Write, Claude Code gets the rules, risks and checks for that
51
56
  file as context, once per file per session. Nothing to remember, and the edit is never blocked.
52
57
 
58
+ Want the checks enforced, not just suggested?
59
+
60
+ ```sh
61
+ npx readfirst hook --install --verify
62
+ ```
63
+
64
+ also adds a Stop hook: before Claude Code finishes its turn, it runs the map's checks for the files it edited in that
65
+ session. If one fails, Claude Code cannot finish yet and gets the failure to fix. It gets one try per turn, so a check
66
+ it cannot fix never traps the session. It runs only check commands that the map on your main branch already lists: a
67
+ command someone's branch adds or changes is named, not run, until it is merged (`npx readfirst verify` runs it when you
68
+ choose to).
69
+
53
70
  ### See the map
54
71
 
55
72
  ```sh
@@ -121,10 +138,29 @@ lists what `npx readfirst fix` would change.
121
138
  | `fail-on-map-problems` | `true` | `false` reports map problems without failing |
122
139
  | `strict` | `false` | `true` also fails on new files that are only placed by their neighbours |
123
140
  | `comment` | `true` | `false` writes only the job summary |
141
+ | `auto-fix` | `false` | `true` commits what `readfirst fix` settles (new files placed by their neighbours) to the PR branch |
124
142
  | `limit` | `30` | most tests and users listed per section |
125
143
 
126
144
  On a PR from a fork, the token is read-only. The list then goes to the job summary instead of a comment.
127
145
 
146
+ ### Keep the map in step without thinking about it
147
+
148
+ ```yaml
149
+ permissions:
150
+ contents: write # for auto-fix
151
+ pull-requests: write
152
+ # ...
153
+ - uses: jeonghun917/readfirst@v0
154
+ with:
155
+ auto-fix: true
156
+ ```
157
+
158
+ With `auto-fix`, when a PR adds files next to ones the map already places, the Action writes them into the map and
159
+ commits that to the PR branch. That is the mechanical part `npx readfirst fix` does, nothing else: new risks and
160
+ rules stay for a person. It skips PRs from forks, and it skips the commit if the branch's map changed since the run
161
+ started, so a push is never overwritten. A commit made with the default `GITHUB_TOKEN` does not start new workflow
162
+ runs. If your branch protection requires checks, pass a GitHub App or personal token as `token`.
163
+
128
164
  ## The map
129
165
 
130
166
  ```json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "readfirst",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "mcpName": "io.github.jeonghun917/readfirst",
5
5
  "description": "What to read and check before you (or your AI agent) change a file: project rules, risks and tests tied to code.",
6
6
  "type": "module",
package/src/action.mjs CHANGED
@@ -30,12 +30,14 @@ async function pages(fetchImpl, env, path) {
30
30
  return all;
31
31
  }
32
32
 
33
- export function commentBody({ list, problems, removed, commit, fixes = [], settled = [] }) {
33
+ export function commentBody({ list, problems, removed, commit, fixes = [], settled = [], committed = null }) {
34
34
  const parts = [MARKER, '## readfirst', ''];
35
35
  if (problems.length) {
36
36
  parts.push(`> [!WARNING]\n> \`${MAP_PATH}\` no longer matches the code (${problems.length} problem${problems.length > 1 ? 's' : ''}). Run \`npx readfirst check\` and fix the map in this PR.`, '');
37
37
  parts.push(...problems.slice(0, 20).map((p) => `- ${p}`), ...(problems.length > 20 ? [`- …and ${problems.length - 20} more`] : []), '');
38
38
  if (fixes.length) parts.push('`npx readfirst fix` would make these changes:', ...fixes.slice(0, 10).map((f) => `- ${f}`), ...(fixes.length > 10 ? [`- …and ${fixes.length - 10} more`] : []), '');
39
+ } else if (committed) {
40
+ parts.push(`<details><summary>readfirst placed ${settled.length} new file(s) in the map and committed it to this branch (${committed.slice(0, 7)})</summary>`, '', ...settled.slice(0, 20).map((f) => `- ${f}`), ...(settled.length > 20 ? [`- …and ${settled.length - 20} more`] : []), '', 'Pull before your next push.', '</details>', '');
39
41
  } else if (settled.length) {
40
42
  parts.push(`<details><summary>New files placed by their neighbours (${settled.length})</summary>`, '', ...settled.slice(0, 20).map((f) => `- ${f}`), ...(settled.length > 20 ? [`- …and ${settled.length - 20} more`] : []), '', '`npx readfirst fix` writes this into the map; nothing to do for this PR.', '</details>', '');
41
43
  }
@@ -72,13 +74,16 @@ export async function runAction({ env = process.env, fetchImpl = fetch, root = e
72
74
  }
73
75
  const repo = env.GITHUB_REPOSITORY;
74
76
  const changed = await pages(fetchImpl, env, `/repos/${repo}/pulls/${pr.number}/files`);
77
+ const committed = (env['INPUT_AUTO-FIX'] ?? env.INPUT_AUTO_FIX ?? 'false') === 'true' && settled.length
78
+ ? await commitFix({ fetchImpl, env, repo, pr, raw, fixed: map, settled, log })
79
+ : null;
75
80
  const removed = changed.filter((f) => f.status === 'removed').map((f) => f.filename);
76
81
  const targets = changed.filter((f) => f.status !== 'removed').map((f) => f.filename);
77
82
  // A broken map still gets a comment (the problems), but no read list from it.
78
83
  const list = targets.length && !problems.length
79
84
  ? readList({ map, files, imports: importEdges(files, readText(root)), targets, commit: pr.head?.sha ?? null, limit: Number(env.INPUT_LIMIT || 30) })
80
85
  : null;
81
- const body = commentBody({ list, problems, removed, commit: pr.head?.sha ?? null, fixes, settled });
86
+ const body = commentBody({ list, problems, removed, commit: pr.head?.sha ?? null, fixes, settled, committed });
82
87
  summary(body);
83
88
 
84
89
  if ((env.INPUT_COMMENT ?? 'true') !== 'false') {
@@ -95,7 +100,39 @@ export async function runAction({ env = process.env, fetchImpl = fetch, root = e
95
100
  }
96
101
  }
97
102
  if (problems.length) log(`${MAP_PATH} has ${problems.length} problem(s):\n- ${problems.join('\n- ')}`);
98
- return { ok: !(problems.length && failOnProblems), problems, list, body };
103
+ return { ok: !(problems.length && failOnProblems), problems, list, body, committed };
104
+ }
105
+
106
+ // auto-fix: write what `readfirst fix` settles (new files placed by their neighbours, patterns for deleted files) into
107
+ // the map on the PR's branch, as one commit through the contents API. Only the mechanical part, never anything a person
108
+ // has to decide; only on a branch of this repository; only when the branch's map is the one this run read, so a push in
109
+ // between is never overwritten. Returns the new commit's sha, or null with a warning.
110
+ async function commitFix({ fetchImpl, env, repo, pr, raw, fixed, settled, log }) {
111
+ if (pr.head?.repo?.full_name !== repo || !pr.head?.ref) {
112
+ log('::warning::readfirst auto-fix: this PR comes from a fork, so the map update stays a suggestion in the comment.');
113
+ return null;
114
+ }
115
+ const path = MAP_PATH.split('/').map(encodeURIComponent).join('/');
116
+ try {
117
+ const now = await api(fetchImpl, env, 'GET', `/repos/${repo}/contents/${path}?ref=${encodeURIComponent(pr.head.ref)}`);
118
+ let onBranch = null;
119
+ try { onBranch = JSON.parse(Buffer.from(now.content ?? '', 'base64').toString('utf8')); } catch { onBranch = null; }
120
+ if (JSON.stringify(onBranch) !== JSON.stringify(raw)) {
121
+ log('::warning::readfirst auto-fix: the map on the branch differs from the one this run read (a newer push, or the base changed it); nothing committed.');
122
+ return null;
123
+ }
124
+ const res = await api(fetchImpl, env, 'PUT', `/repos/${repo}/contents/${path}`, {
125
+ message: `readfirst fix: place ${settled.length} new file(s) in the map`,
126
+ content: Buffer.from(`${JSON.stringify(fixed, null, 2)}\n`, 'utf8').toString('base64'),
127
+ sha: now.sha,
128
+ branch: pr.head.ref,
129
+ });
130
+ log(`readfirst auto-fix: committed the map update to ${pr.head.ref}`);
131
+ return res?.commit?.sha ?? 'committed';
132
+ } catch (e) {
133
+ log(`::warning::readfirst auto-fix could not commit (${/: 403 /u.test(e.message) ? 'the token needs contents: write' : e.message.slice(0, 200)}); the comment says what \`npx readfirst fix\` would change.`);
134
+ return null;
135
+ }
99
136
  }
100
137
 
101
138
  if (process.argv[1]?.endsWith('/src/action.mjs')) {
package/src/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // readfirst: what to read and check before changing a file.
2
- // readfirst init [--agent] write a first .readfirst/map.json from the file tree (and the agent-notes line)
2
+ // readfirst init [--agent] [--ci] write a first .readfirst/map.json from the file tree (and the agent-notes line, the Action)
3
3
  // readfirst prompt the brief for a coding agent to fill in titles, checks, risks and rules
4
4
  // readfirst prompt --labels <lang> the brief for personal labels (a translation and plain notes) for the view
5
5
  // readfirst check the map covers every tracked file and is well formed
@@ -8,15 +8,16 @@
8
8
  // readfirst diff [--base <ref>] the read list for the files changed since <ref> (default: origin/main, else main)
9
9
  // readfirst verify [--base <ref>] run the checks the read list names for the changed files; exit 1 if one fails
10
10
  // readfirst hook [--install] a Claude Code PreToolUse hook: the read list for the file an Edit or Write is about to change
11
+ // (--install --verify also adds a Stop hook, `hook --stop`, that runs verify before Claude Code finishes)
11
12
  // readfirst mcp an MCP server on stdio: read_list, changed_read_list and check_map as tools for any agent
12
13
  // readfirst view [--out <file>] the map as a city in one HTML page (default .readfirst/local/view.html)
13
14
  // Options: --root <dir> (default: the current git repository), --json, --limit <n>.
14
15
  import { execFileSync, spawnSync } from 'node:child_process';
15
16
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
16
17
  import { tmpdir } from 'node:os';
17
- import { basename, isAbsolute, join, relative } from 'node:path';
18
+ import { basename, dirname, isAbsolute, join, relative } from 'node:path';
18
19
  import { importEdges } from './imports.mjs';
19
- import { assertMap, draftMap, fixMap, MAP_PATH, mapProblems, settleMap } from './map.mjs';
20
+ import { MAP_PATH, assertMap, draftChecks, draftMap, fixMap, mapProblems, settleMap } from './map.mjs';
20
21
  import { agentBrief } from './prompt.mjs';
21
22
  import { keepLocal, labelsBrief, LOCAL_DIR, readLabels } from './labels.mjs';
22
23
  import { viewData } from './view.mjs';
@@ -26,20 +27,22 @@ import { emptyList, readList, toMarkdown } from './readlist.mjs';
26
27
 
27
28
  const HELP = `readfirst — what to read and check before changing a file
28
29
 
29
- readfirst init [--agent] write a first ${MAP_PATH} from the file tree
30
+ readfirst init [--agent] [--ci] write a first ${MAP_PATH} from the file tree
30
31
  readfirst prompt brief for your coding agent to fill in the map
31
32
  readfirst check [--strict] check the map covers every tracked file (new files beside placed ones pass; --strict fails on them)
32
33
  readfirst fix place new files, drop patterns for deleted ones
33
34
  readfirst read <file>... read list for files you are about to change
34
35
  readfirst diff [--base <ref>] read list for files changed since <ref>
35
36
  readfirst verify [--base <ref>] run the checks for the files changed since <ref> (--dry-run lists them)
36
- readfirst hook [--install] Claude Code hook: the read list before each edit (--install adds it to .claude/settings.json)
37
+ readfirst hook [--install] Claude Code hook: the read list before each edit (--install adds it to .claude/settings.json;
38
+ --install --verify also runs the checks for edited files before Claude Code finishes)
37
39
  readfirst view [--out <file>] the map as a city in one HTML page
38
40
  readfirst mcp MCP server on stdio for Cursor, Codex, Windsurf and other agents
39
41
  readfirst prompt --labels <lang> brief for your agent to write personal labels for the view (e.g. ko, en)
40
42
 
41
43
  Options: --root <dir>, --json, --limit <n>, --timeout <s> (verify, per check; default 900), --force (init: replace the map),
42
- --agent (init: add the read-first line to CLAUDE.md / AGENTS.md / .cursorrules)`;
44
+ --agent (init: add the read-first line to CLAUDE.md / AGENTS.md / .cursorrules),
45
+ --ci (init: add .github/workflows/readfirst.yml, the Action with auto-fix)`;
43
46
 
44
47
  function parse(argv) {
45
48
  const args = { _: [], json: false };
@@ -51,6 +54,9 @@ function parse(argv) {
51
54
  else if (a === '--install') args.install = true;
52
55
  else if (a === '--dry-run') args.dryRun = true;
53
56
  else if (a === '--strict') args.strict = true;
57
+ else if (a === '--verify') args.verify = true;
58
+ else if (a === '--ci') args.ci = true;
59
+ else if (a === '--stop') args.stop = true;
54
60
  else if (a === '-h' || a === '--help') args.help = true;
55
61
  else if (a.startsWith('--')) { const v = argv[++i]; if (v === undefined) throw new Error(`${a} needs a value`); args[a.slice(2)] = v; }
56
62
  else args._.push(a);
@@ -164,6 +170,9 @@ export function brief(list) {
164
170
  return lines;
165
171
  }
166
172
 
173
+ // Which files the PreToolUse hook has seen in a session: the Stop hook verifies those.
174
+ const seenFile = (stateDir, session) => join(stateDir, `readfirst-hook-${String(session ?? 'none').replace(/[^\w-]/gu, '')}.json`);
175
+
167
176
  // Claude Code PreToolUse hook. Reads the hook's JSON on stdin; for an Edit, Write or MultiEdit of a file the map covers,
168
177
  // prints the read list as additionalContext, once per file per session. Never blocks the edit: any problem means no output.
169
178
  export function runHook(input, { now = Date.now(), stateDir = tmpdir() } = {}) {
@@ -175,7 +184,7 @@ export function runHook(input, { now = Date.now(), stateDir = tmpdir() } = {}) {
175
184
  const root = git(cwd, 'rev-parse', '--show-toplevel').trim();
176
185
  const path = (isAbsolute(file) ? relative(root, file) : file).replace(/\\/gu, '/');
177
186
  if (path.startsWith('..') || path === MAP_PATH || !existsSync(join(root, MAP_PATH))) return null;
178
- const seenAt = join(stateDir, `readfirst-hook-${String(event.session_id ?? 'none').replace(/[^\w-]/gu, '')}.json`);
187
+ const seenAt = seenFile(stateDir, event.session_id);
179
188
  let seen = {};
180
189
  try { seen = JSON.parse(readFileSync(seenAt, 'utf8')); } catch { seen = {}; }
181
190
  if (seen[path] && now - seen[path] < 6 * 3600_000) return null;
@@ -195,17 +204,106 @@ export function runHook(input, { now = Date.now(), stateDir = tmpdir() } = {}) {
195
204
  }
196
205
  }
197
206
 
207
+ // The check commands of the map as the base branch has it. The Stop hook runs a command without anyone asking, so it
208
+ // runs only these: a command a branch adds or changes in the map is repository content nobody has merged yet.
209
+ function trustedRuns(root) {
210
+ try {
211
+ const { ref } = changedFiles(root);
212
+ const since = git(root, 'merge-base', ref, 'HEAD').trim();
213
+ const map = JSON.parse(git(root, 'show', `${since}:${MAP_PATH}`));
214
+ return new Set([...(map.groups ?? []), ...(map.rules ?? [])].flatMap((x) => (x.checks ?? []).map((c) => c.run)));
215
+ } catch {
216
+ return new Set();
217
+ }
218
+ }
219
+
220
+ // Claude Code Stop hook (opt-in, `hook --install --verify`): before Claude Code ends its turn, run the map's checks for the
221
+ // files it edited in this session that are still changed. A failing check blocks the stop once, with the failure as the
222
+ // reason, so the agent fixes it; on the second stop of the same turn it lets go, so a check it cannot fix never traps
223
+ // the session. Only commands the base branch's map already lists run; others are named for `readfirst verify`, which
224
+ // someone runs on purpose. Nothing edited, nothing changed or no checks: no output.
225
+ export function runStopHook(input, { stateDir = tmpdir(), timeout = 600 } = {}) {
226
+ let event;
227
+ try { event = JSON.parse(input); } catch { return null; }
228
+ if (event.stop_hook_active) return null;
229
+ try {
230
+ const root = git(event.cwd ?? process.cwd(), 'rev-parse', '--show-toplevel').trim();
231
+ if (!existsSync(join(root, MAP_PATH))) return null;
232
+ let seen = {};
233
+ try { seen = JSON.parse(readFileSync(seenFile(stateDir, event.session_id), 'utf8')); } catch { return null; }
234
+ const dirty = new Set(git(root, 'status', '--porcelain', '-z', '--untracked-files=all').split('\0').filter(Boolean).map((l) => l.slice(3)));
235
+ let changed = [];
236
+ try { changed = changedFiles(root).files; } catch { /* no base branch: the working tree is enough */ }
237
+ const targets = Object.keys(seen).filter((f) => dirty.has(f) || changed.includes(f)).sort();
238
+ if (!targets.length) return null;
239
+ const files = repoFiles(root);
240
+ const map = readMapFile(root);
241
+ if (mapProblems(map, files, { placement: false }).length) return null;
242
+ const list = readList({ map, files, imports: importEdges(files, readText(root)), targets, limit: 1 });
243
+ if (!list.checks.length) return null;
244
+ const trusted = trustedRuns(root);
245
+ const skipped = list.checks.filter((c) => !trusted.has(c.run)).map((c) => c.run);
246
+ const result = verifyChecks(root, list.checks.filter((c) => trusted.has(c.run)), { timeout });
247
+ const failed = result.filter((c) => c.status !== 'pass');
248
+ const notRun = skipped.length ? ` Not run, because the base branch's map does not list them yet: ${skipped.map((r) => `\`${r}\``).join(', ')}; run \`npx readfirst verify\` to run them on purpose.` : '';
249
+ if (!failed.length) return JSON.stringify({ systemMessage: `readfirst verify: ${result.length} check(s) passed for ${targets.length} edited file(s).${notRun}` });
250
+ const reason = [
251
+ `readfirst verify: ${failed.length} of ${result.length} check(s) the map lists for the files you edited failed. Fix them before you finish.${notRun}`,
252
+ ...failed.map((c) => `\n--- ${c.run} (${c.status}${c.code != null ? `, exit ${c.code}` : ''})${c.why ? ` covers ${c.why}` : ''}; last lines:\n${c.output ?? ''}`),
253
+ ].join('\n');
254
+ return JSON.stringify({ decision: 'block', reason });
255
+ } catch {
256
+ return null;
257
+ }
258
+ }
259
+
260
+ export const WORKFLOW_PATH = '.github/workflows/readfirst.yml';
261
+ // The Action as `init --ci` writes it: on each PR, the read-list comment, and new files placed in the map on the PR's
262
+ // branch (auto-fix). Never over a workflow that is already there.
263
+ export function writeWorkflow(root) {
264
+ const at = join(root, WORKFLOW_PATH);
265
+ if (existsSync(at)) return false;
266
+ mkdirSync(dirname(at), { recursive: true });
267
+ writeFileSync(at, [
268
+ '# readfirst: one comment per PR with the rules, risks and checks for the files it changes,',
269
+ '# and new files placed in .readfirst/map.json on the PR branch (auto-fix). https://github.com/jeonghun917/readfirst',
270
+ 'name: readfirst',
271
+ 'on: pull_request',
272
+ 'permissions:',
273
+ ' contents: write # auto-fix commits the map update to the PR branch',
274
+ ' pull-requests: write # the comment',
275
+ 'jobs:',
276
+ ' readfirst:',
277
+ ' runs-on: ubuntu-latest',
278
+ ' steps:',
279
+ ' - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0',
280
+ ' - uses: jeonghun917/readfirst@v0',
281
+ ' with:',
282
+ ' auto-fix: true',
283
+ '',
284
+ ].join('\n'));
285
+ return true;
286
+ }
287
+
198
288
  export const HOOK_COMMAND = 'npx --yes readfirst hook';
199
- // Adds the hook to the project's .claude/settings.json, keeping everything already there.
200
- export function installHook(root) {
289
+ export const STOP_HOOK_COMMAND = 'npx --yes readfirst hook --stop';
290
+ // Adds the hook to the project's .claude/settings.json, keeping everything already there. With verify, also the Stop
291
+ // hook that runs the checks before Claude Code finishes.
292
+ export function installHook(root, { verify = false } = {}) {
201
293
  const at = join(root, '.claude', 'settings.json');
202
294
  let settings = {};
203
295
  if (existsSync(at)) {
204
296
  try { settings = JSON.parse(readFileSync(at, 'utf8')); } catch { throw new Error('.claude/settings.json is not valid JSON; fix it first'); }
205
297
  }
206
298
  const pre = ((settings.hooks ??= {}).PreToolUse ??= []);
207
- if (pre.some((m) => (m.hooks ?? []).some((h) => /readfirst hook/u.test(h.command ?? '')))) return false;
208
- pre.push({ matcher: 'Edit|Write|MultiEdit', hooks: [{ type: 'command', command: HOOK_COMMAND, timeout: 15 }] });
299
+ const has = (list) => list.some((m) => (m.hooks ?? []).some((h) => /readfirst hook/u.test(h.command ?? '')));
300
+ let added = false;
301
+ if (!has(pre)) { pre.push({ matcher: 'Edit|Write|MultiEdit', hooks: [{ type: 'command', command: HOOK_COMMAND, timeout: 15 }] }); added = true; }
302
+ if (verify) {
303
+ const stop = (settings.hooks.Stop ??= []);
304
+ if (!has(stop)) { stop.push({ hooks: [{ type: 'command', command: STOP_HOOK_COMMAND, timeout: 1800 }] }); added = true; }
305
+ }
306
+ if (!added) return false;
209
307
  mkdirSync(join(root, '.claude'), { recursive: true });
210
308
  writeFileSync(at, `${JSON.stringify(settings, null, 2)}\n`);
211
309
  return true;
@@ -216,7 +314,7 @@ export function main(argv, io = { out: (s) => process.stdout.write(s), err: (s)
216
314
  const [cmd, ...rest] = args._;
217
315
  if (args.help || !cmd) { io.out(`${HELP}\n`); return cmd || args.help ? 0 : 2; }
218
316
  if (cmd === 'hook' && !args.install) {
219
- const out = runHook(stdin());
317
+ const out = args.stop ? runStopHook(stdin()) : runHook(stdin());
220
318
  if (out) io.out(`${out}\n`);
221
319
  return 0;
222
320
  }
@@ -247,14 +345,21 @@ export function main(argv, io = { out: (s) => process.stdout.write(s), err: (s)
247
345
  const map = draftMap(files, readText(root));
248
346
  mkdirSync(join(root, '.readfirst'), { recursive: true });
249
347
  writeFileSync(at, `${JSON.stringify(map, null, 2)}\n`);
250
- const checks = map.groups.find((g) => g.checks.length)?.checks ?? [];
251
- io.out(`wrote ${MAP_PATH}: ${map.groups.length} group${map.groups.length === 1 ? "" : "s"}${checks.length ? `, checks found: ${checks.map((c) => c.run).join(', ')}` : ''}.\n`);
348
+ const repoChecks = draftChecks(files, readText(root)).map((c) => c.run);
349
+ const scoped = map.groups.filter((g) => g.checks.length && g.checks.some((c) => !repoChecks.includes(c.run))).length;
350
+ io.out(`wrote ${MAP_PATH}: ${map.groups.length} group${map.groups.length === 1 ? '' : 's'}${repoChecks.length ? `, checks found: ${repoChecks.join(', ')}` : ''}${scoped ? `; ${scoped} workspace package${scoped === 1 ? '' : 's'} got ${scoped === 1 ? 'its' : 'their'} own checks` : ''}.\n`);
252
351
  if (args.agent) {
253
352
  const wrote = addAgentLine(root);
254
353
  io.out(wrote.length ? `added the read-first line to ${wrote.join(', ')}.\n` : 'your agent notes already mention readfirst.\n');
255
- io.out('Using Claude Code? `readfirst hook --install` hands it the read list automatically before each edit.\n');
256
354
  }
257
- io.out(`Next: run \`readfirst prompt\` and give the brief to your coding agent, or fill in titles, checks, risks and rules by hand.${args.agent ? '' : '\nTip: `readfirst init --agent` also adds the one line your coding agent needs to CLAUDE.md or AGENTS.md.'}\n`);
355
+ if (args.ci) io.out(writeWorkflow(root) ? `wrote ${WORKFLOW_PATH}: a PR comment with the read list, and new files placed in the map on the PR branch.\n` : `${WORKFLOW_PATH} already exists; left as it is.\n`);
356
+ const next = [
357
+ 'run `readfirst prompt` and give the brief to your coding agent (or fill in titles, risks and rules by hand)',
358
+ ...(args.agent ? [] : ['`readfirst init --agent` would add the one line your agent needs to CLAUDE.md or AGENTS.md']),
359
+ 'Claude Code: `readfirst hook --install` gives it the read list before each edit (`--verify` also runs the checks before it finishes)',
360
+ ...(args.ci ? [] : ['`readfirst init --ci` would add the GitHub Action that comments on PRs and keeps the map in step']),
361
+ ];
362
+ io.out(`Next:\n${next.map((n) => `- ${n}`).join('\n')}\n`);
258
363
  return 0;
259
364
  }
260
365
  if (cmd === 'prompt') {
@@ -294,7 +399,9 @@ export function main(argv, io = { out: (s) => process.stdout.write(s), err: (s)
294
399
  return problems.length ? 1 : 0;
295
400
  }
296
401
  if (cmd === 'hook') {
297
- io.out(installHook(root) ? 'added the readfirst hook to .claude/settings.json: Claude Code now sees the read list before each edit.\n' : '.claude/settings.json already has the readfirst hook.\n');
402
+ io.out(installHook(root, { verify: args.verify })
403
+ ? `added the readfirst hook to .claude/settings.json: Claude Code now sees the read list before each edit${args.verify ? ', and cannot finish while a check for a file it edited fails (it gets one try to fix it)' : ''}.\n`
404
+ : '.claude/settings.json already has the readfirst hook.\n');
298
405
  return 0;
299
406
  }
300
407
  if (cmd === 'fix') {
package/src/imports.mjs CHANGED
@@ -1,7 +1,8 @@
1
- // Which tracked file imports which, read from the code. Only imports that resolve to a tracked file count: packages and
2
- // unresolved specifiers are left out. JavaScript/TypeScript (import, export from, import(), require) and Python
3
- // (import a.b, from a.b import c, relative from . imports) are read here; Go, Rust, Elixir, C#, Java/Kotlin, Ruby and
4
- // PHP in langs.mjs.
1
+ // Which tracked file imports which, read from the code. Only imports that resolve to a tracked file count: packages from
2
+ // outside the repository and unresolved specifiers are left out. JavaScript/TypeScript (import, export from, import(),
3
+ // require; relative paths, the repository's own workspace packages and tsconfig/jsconfig "paths" aliases) and Python
4
+ // (import a.b, from a.b import c, relative from . imports, and "a.b.c" strings that name a module of the repository,
5
+ // as hooks and settings do) are read here; Go, Rust, Elixir, C#, Java/Kotlin, Ruby and PHP in langs.mjs.
5
6
  import { posix } from 'node:path';
6
7
  import { moreEdges } from './langs.mjs';
7
8
 
@@ -13,6 +14,87 @@ const PY_IMPORT = /^[ \t]*(?:from[ \t]+(\.*[\w.]*)[ \t]+import[ \t]+(\([^)]*\)|[
13
14
  const JS_EXT = ['', '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.mts', '.vue', '.svelte', '/index.js', '/index.ts', '/index.tsx', '/index.mjs'];
14
15
 
15
16
  const lineAt = (body, index) => body.slice(0, index).split('\n').length;
17
+ const dirOf = (f) => (posix.dirname(f) === '.' ? '' : posix.dirname(f));
18
+ const within = (dir, file) => dir === '' || file.startsWith(`${dir}/`);
19
+
20
+ // tsconfig.json allows comments and trailing commas.
21
+ export function parseJsonc(text) {
22
+ if (text === null || text === undefined) return null;
23
+ let out = '';
24
+ for (let i = 0; i < text.length; i++) {
25
+ const c = text[i];
26
+ if (c === '"') {
27
+ const end = text.slice(i).match(/^"(?:[^"\\\n]|\\.)*"/u);
28
+ if (!end) return null;
29
+ out += end[0]; i += end[0].length - 1;
30
+ } else if (c === '/' && text[i + 1] === '/') {
31
+ while (i < text.length && text[i] !== '\n') i++;
32
+ out += '\n';
33
+ } else if (c === '/' && text[i + 1] === '*') {
34
+ const close = text.indexOf('*/', i + 2);
35
+ if (close < 0) return null;
36
+ i = close + 1;
37
+ } else out += c;
38
+ }
39
+ try { return JSON.parse(out.replace(/,(\s*[}\]])/gu, '$1')); } catch { return null; }
40
+ }
41
+
42
+ // Bare specifiers that point back into the repository: a workspace package by the name in its package.json
43
+ // ("@acme/db", "@acme/db/schema"), or a "paths" alias from the tsconfig/jsconfig that covers the importing file ("@/lib/x").
44
+ function jsResolver(files, tracked, read) {
45
+ const jsCandidates = (base) => [...JS_EXT.map((e) => base + e), base.replace(/\.(m|c)?js$/u, '.$1ts'), base.replace(/\.js$/u, '.tsx')];
46
+ const firstTracked = (bases) => {
47
+ for (const b of bases) {
48
+ const hit = jsCandidates(posix.normalize(b)).find((c) => tracked.has(c));
49
+ if (hit) return hit;
50
+ }
51
+ return undefined;
52
+ };
53
+ const pkgs = [];
54
+ for (const f of files) {
55
+ if (!/(?:^|\/)package\.json$/u.test(f) || f.includes('node_modules/')) continue;
56
+ let j;
57
+ try { j = JSON.parse(read(f) ?? ''); } catch { continue; }
58
+ if (typeof j?.name !== 'string' || !j.name || dirOf(f) === '') continue;
59
+ const dot = typeof j.exports === 'string' ? j.exports : j.exports?.['.'];
60
+ const exp = typeof dot === 'string' ? [dot] : dot && typeof dot === 'object' ? ['types', 'import', 'default', 'require'].map((k) => dot[k]).filter((v) => typeof v === 'string') : [];
61
+ // An entry pointing at build output (dist/index.js) is read as the source next to it (src/index.ts).
62
+ const entries = [j.source, ...exp, j.types, j.typings, j.module, j.main].filter((v) => typeof v === 'string')
63
+ .flatMap((e) => [e, e.replace(/^\.?\/?(?:dist|build|lib|out)\//u, 'src/').replace(/\.d\.ts$/u, '.ts')]);
64
+ pkgs.push({ name: j.name, dir: dirOf(f), entries: [...entries, 'src/index', 'index'] });
65
+ }
66
+ pkgs.sort((a, b) => b.name.length - a.name.length);
67
+ const aliases = [];
68
+ for (const f of files) {
69
+ if (!/(?:^|\/)(?:tsconfig|jsconfig)(?:\.[\w-]+)?\.json$/u.test(f) || f.includes('node_modules/')) continue;
70
+ const opts = parseJsonc(read(f))?.compilerOptions;
71
+ if (!opts?.paths || typeof opts.paths !== 'object') continue;
72
+ const base = posix.join(dirOf(f), opts.baseUrl ?? '.');
73
+ for (const [pattern, targets] of Object.entries(opts.paths)) {
74
+ if (Array.isArray(targets)) aliases.push({ scope: dirOf(f), pattern, targets: targets.filter((t) => typeof t === 'string').map((t) => posix.join(base, t)) });
75
+ }
76
+ }
77
+ // The nearest config wins, as it would for the compiler.
78
+ aliases.sort((a, b) => b.scope.length - a.scope.length);
79
+ return (from, spec) => {
80
+ for (const a of aliases) {
81
+ if (!within(a.scope, from)) continue;
82
+ const star = a.pattern.indexOf('*');
83
+ let rest = null;
84
+ if (star < 0) rest = spec === a.pattern ? '' : null;
85
+ else if (spec.startsWith(a.pattern.slice(0, star)) && spec.endsWith(a.pattern.slice(star + 1))) rest = spec.slice(star, spec.length - (a.pattern.length - star - 1));
86
+ if (rest === null) continue;
87
+ const hit = firstTracked(a.targets.map((t) => t.replace('*', rest)));
88
+ if (hit) return hit;
89
+ }
90
+ const pkg = pkgs.find((p) => spec === p.name || spec.startsWith(`${p.name}/`));
91
+ if (!pkg) return undefined;
92
+ const sub = spec.slice(pkg.name.length + 1);
93
+ return sub
94
+ ? firstTracked([posix.join(pkg.dir, sub), posix.join(pkg.dir, 'src', sub)])
95
+ : firstTracked(pkg.entries.map((e) => posix.join(pkg.dir, e.replace(/\.(?:m|c)?js$|\.tsx?$/u, ''))));
96
+ };
97
+ }
16
98
 
17
99
  export function importEdges(files, read) {
18
100
  const tracked = new Set(files);
@@ -27,13 +109,19 @@ export function importEdges(files, read) {
27
109
  const base = posix.join(baseDir, ...dotted.split('.').filter(Boolean));
28
110
  return [`${base}.py`, `${base}/__init__.py`].find((c) => tracked.has(c));
29
111
  };
112
+ let bare = null;
30
113
  for (const from of [...files].sort()) {
31
114
  if (JS.test(from)) {
32
115
  const body = read(from);
33
116
  if (body === null) continue;
34
117
  for (const m of body.matchAll(JS_IMPORT)) {
35
118
  const spec = (m[1] ?? m[2] ?? m[3]).split('?')[0];
36
- if (!spec.startsWith('.')) continue;
119
+ if (!spec.startsWith('.')) {
120
+ if (spec.startsWith('node:') || /^[a-z]+:/u.test(spec)) continue;
121
+ bare ??= jsResolver(files, tracked, read);
122
+ add(from, bare(from, spec), lineAt(body, m.index));
123
+ continue;
124
+ }
37
125
  const base = posix.normalize(posix.join(posix.dirname(from), spec));
38
126
  // TypeScript sources import "./x.js" for the file "./x.ts".
39
127
  const candidates = [...JS_EXT.map((e) => base + e), base.replace(/\.(m|c)?js$/u, '.$1ts'), base.replace(/\.js$/u, '.tsx')];
@@ -64,6 +152,33 @@ export function importEdges(files, read) {
64
152
  }
65
153
  }
66
154
  }
155
+ dottedEdges(files, tracked, read, pyModule, add);
67
156
  moreEdges([...files].sort(), read, add);
68
157
  return out;
69
158
  }
159
+
160
+ // Python projects name code by dotted path in strings: Frappe hooks.py ("app.module.function"), frappe.call from
161
+ // JavaScript, Django settings and urls, Celery task names. A string counts when its first part is a top-level Python
162
+ // package of the repository and the longest leading part names one of its modules.
163
+ function dottedEdges(files, tracked, read, pyModule, add) {
164
+ const top = new Map();
165
+ for (const f of files) {
166
+ const m = f.match(/^(?:(src)\/)?([A-Za-z_]\w*)\/__init__\.py$/u);
167
+ if (m) top.set(m[2], m[1] ?? '');
168
+ }
169
+ if (!top.size) return;
170
+ const DOTTED = /["'`]([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+)["'`]/gu;
171
+ for (const from of files) {
172
+ if (!/\.(?:py|m?js|tsx?|json)$/u.test(from) || from.includes('node_modules/')) continue;
173
+ const body = read(from);
174
+ if (body === null || body.length > 2_000_000) continue;
175
+ for (const m of body.matchAll(DOTTED)) {
176
+ const parts = m[1].split('.');
177
+ if (!top.has(parts[0])) continue;
178
+ for (let n = parts.length; n >= 2; n--) {
179
+ const to = pyModule(parts.slice(0, n).join('.'), top.get(parts[0]));
180
+ if (to) { add(from, to, lineAt(body, m.index)); break; }
181
+ }
182
+ }
183
+ }
184
+ }
package/src/map.mjs CHANGED
@@ -160,9 +160,44 @@ export function draftMap(files, read = () => null) {
160
160
  const hasNested = [...byFolder.values()].some((slot) => slot.pattern?.startsWith(`${root}/`) && slot.pattern !== `${root}/**`);
161
161
  if (covered && hasNested) covered.files = covered.files.filter((p) => p !== `${root}/**`).concat(loose);
162
162
  }
163
+ // A folder that is a workspace package of its own (packages/db/package.json) gets that package's checks, run through the
164
+ // package manager's workspace filter, so verify runs the package's tests instead of the whole repository's. Its title
165
+ // comes from the package's description when it has one.
166
+ for (const g of groups.values()) {
167
+ const dir = g.files.length === 1 && /^(.+)\/\*\*$/u.exec(g.files[0])?.[1];
168
+ if (!dir || !files.includes(`${dir}/package.json`)) continue;
169
+ let pkg;
170
+ try { pkg = JSON.parse(read(`${dir}/package.json`) ?? ''); } catch { continue; }
171
+ const checks = workspaceChecks(files, dir, pkg);
172
+ if (checks.length) g.checks = checks;
173
+ const about = typeof pkg?.description === 'string' ? pkg.description.trim().replace(/\s+/gu, ' ') : '';
174
+ if (about && g.title.startsWith('TODO:')) g.title = `${typeof pkg.name === 'string' ? `${pkg.name}: ` : ''}${about}`.slice(0, 200);
175
+ }
163
176
  return { format: MAP_FORMAT, groups: [...groups.values()].sort((a, b) => a.id.localeCompare(b.id)), rules: [], ignore: [...ignore].sort() };
164
177
  }
165
178
 
179
+ const packageManager = (has) => (has.has('pnpm-lock.yaml') ? 'pnpm' : has.has('yarn.lock') ? 'yarn' : has.has('bun.lockb') || has.has('bun.lock') ? 'bun' : 'npm');
180
+ const PLACEHOLDER = /no test specified/u;
181
+ // The scripts worth running as checks, in order, with what each covers.
182
+ const scriptChecks = (scripts, run) => [
183
+ ...(typeof scripts.test === 'string' && !PLACEHOLDER.test(scripts.test) ? [{ run: run('test'), why: 'the test suite' }] : []),
184
+ ...['typecheck', 'type-check', 'check-types', 'lint'].filter((n) => typeof scripts[n] === 'string').map((n) => ({ run: run(n), why: n === 'lint' ? 'lint rules' : 'types' })),
185
+ ];
186
+
187
+ // One workspace package's own scripts, scoped to it: pnpm --filter, yarn workspace, bun --filter or npm -w.
188
+ export function workspaceChecks(files, dir, pkg) {
189
+ const scripts = pkg?.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {};
190
+ const name = typeof pkg?.name === 'string' && pkg.name ? pkg.name : null;
191
+ const pm = packageManager(new Set(files));
192
+ const run = (script) => {
193
+ if (pm === 'pnpm') return `pnpm --filter ${name ?? `./${dir}`} ${script === 'test' ? 'test' : `run ${script}`}`;
194
+ if (pm === 'yarn' && name) return `yarn workspace ${name} ${script === 'test' ? 'test' : `run ${script}`}`;
195
+ if (pm === 'bun' && name) return `bun --filter ${name} run ${script}`;
196
+ return script === 'test' ? `npm test -w ${dir}` : `npm run ${script} -w ${dir}`;
197
+ };
198
+ return scriptChecks(scripts, run).map((c) => ({ ...c, why: `${c.why} of ${name ?? dir}` }));
199
+ }
200
+
166
201
  // Repairs the two ways a good map goes stale as code moves, without guessing at meaning:
167
202
  // - a tracked file in no group joins the group of its nearest neighbour (same folder, else the closest parent folder);
168
203
  // a new folder whose files are all unplaced joins as "<folder>/**", a lone new file by its path;
@@ -244,11 +279,9 @@ export function draftChecks(files, read = () => null) {
244
279
  if (has.has('package.json')) {
245
280
  let scripts = {};
246
281
  try { scripts = JSON.parse(read('package.json') ?? '{}').scripts ?? {}; } catch { scripts = {}; }
247
- const pm = has.has('pnpm-lock.yaml') ? 'pnpm' : has.has('yarn.lock') ? 'yarn' : has.has('bun.lockb') || has.has('bun.lock') ? 'bun' : 'npm';
282
+ const pm = packageManager(has);
248
283
  const run = (name) => (pm === 'npm' ? (name === 'test' ? 'npm test' : `npm run ${name}`) : `${pm} ${name === 'test' && pm !== 'bun' ? 'test' : `run ${name}`}`);
249
- const placeholder = /no test specified/u;
250
- if (typeof scripts.test === 'string' && !placeholder.test(scripts.test)) add(run('test'), 'the test suite');
251
- for (const name of ['typecheck', 'type-check', 'check-types', 'lint']) if (typeof scripts[name] === 'string') add(run(name), name === 'lint' ? 'lint rules' : 'types');
284
+ for (const c of scriptChecks(scripts, run)) add(c.run, c.why);
252
285
  }
253
286
  const makefile = has.has('Makefile') ? read('Makefile') ?? '' : '';
254
287
  if (/^test:/mu.test(makefile)) add('make test', 'the test suite (Makefile)');