readfirst 0.1.1 → 0.3.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
@@ -6,38 +6,16 @@ Coding agents can follow imports. What they don't know is what your team knows:
6
6
  "this table is read by the mobile app, keep old columns", "run the payment e2e after touching this". readfirst keeps that
7
7
  knowledge in one file next to the code and hands it out, file by file, at the moment someone is about to change something.
8
8
 
9
- ```text
10
- $ npx readfirst read src/billing/charge.ts
11
-
12
- # Read first: `src/billing/charge.ts`
13
-
14
- ## Rules that must keep holding
15
- - **no-double-charge**: a charge runs once per order — why: refunds are manual
16
-
17
- ## Watch out
18
- - amounts are in cents _(group src-billing)_
19
-
20
- ## Run these checks
21
- - `npm test -- billing` — charges
22
- - `npm run e2e:pay` — keeps rule no-double-charge
23
-
24
- ## Tests that reach this code
25
- - `test/charge.test.ts`
26
- - `test/route.test.js` (through `src/api/route.js`)
27
-
28
- ## Used by
29
- - `src/billing/index.ts:1`
30
- 2 more file(s) use this code indirectly.
31
- ```
9
+ ![npx readfirst read src/imports.mjs prints the rules, risks, checks, tests and callers for that file](docs/readfirst-read.gif)
32
10
 
33
11
  No server, no account, no model calls. It reads your git checkout and `.readfirst/map.json`.
34
12
 
35
13
  ## Start
36
14
 
37
15
  ```sh
38
- npx readfirst init # first map from your folders: one group per folder, media folders ignored
39
- npx readfirst prompt # a brief for your coding agent to fill in titles, checks, risks and rules
40
- npx readfirst check # every tracked file is in exactly one group (or ignored); rules point at real files
16
+ npx readfirst init --agent # first map from your folders, checks from your scripts, plus the line for CLAUDE.md / AGENTS.md
17
+ npx readfirst prompt # a brief for your coding agent to fill in titles, checks, risks and rules
18
+ npx readfirst check # every tracked file is in exactly one group (or ignored); rules point at real files
41
19
  ```
42
20
 
43
21
  Commit `.readfirst/map.json`. From then on:
@@ -46,11 +24,12 @@ Commit `.readfirst/map.json`. From then on:
46
24
  npx readfirst read <file>... # before changing files
47
25
  npx readfirst diff # everything your branch changed since main
48
26
  npx readfirst diff --json # the same, for tools and agents
27
+ npx readfirst fix # after moving code: place new files, drop patterns for deleted ones
49
28
  ```
50
29
 
51
30
  ### Tell your agent to use it
52
31
 
53
- Add this to `CLAUDE.md`, `AGENTS.md` or `.cursorrules`:
32
+ `init --agent` adds this to your `CLAUDE.md`, `AGENTS.md` or `.cursorrules` (or a new `AGENTS.md`); or add it yourself:
54
33
 
55
34
  ```md
56
35
  Before changing a file, run `npx readfirst read <file>` and follow what it says: keep the rules, read the risks, run the
@@ -58,6 +37,15 @@ checks it lists. Before you finish, run `npx readfirst diff` and run every check
58
37
  When you learn something a newcomer would get wrong, add it as a risk in .readfirst/map.json.
59
38
  ```
60
39
 
40
+ ### Or let Claude Code pick it up by itself
41
+
42
+ ```sh
43
+ npx readfirst hook --install
44
+ ```
45
+
46
+ adds a hook to `.claude/settings.json`. Before each Edit or Write, Claude Code gets the rules, risks and checks for that
47
+ file as context, once per file per session. Nothing to remember, and the edit is never blocked.
48
+
61
49
  ## On every pull request (GitHub Action)
62
50
 
63
51
  ```yaml
@@ -80,7 +68,7 @@ Each PR gets one comment, updated on every push, that lists:
80
68
  - the tests that reach the changed code.
81
69
 
82
70
  The check fails when `.readfirst/map.json` no longer covers the code, for example when a new file is in no group. So the
83
- map is fixed in the same PR.
71
+ map is fixed in the same PR. The comment lists what `npx readfirst fix` would change, so the fix is usually one command.
84
72
 
85
73
  | Input | Default | Meaning |
86
74
  |---|---|---|
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "readfirst",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "What to read and check before you (or your AI agent) change a file: project rules, risks and tests tied to code.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/action.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  import { appendFileSync, readFileSync } from 'node:fs';
5
5
  import { readMapFile, readText, repoFiles } from './cli.mjs';
6
6
  import { importEdges } from './imports.mjs';
7
- import { MAP_PATH, mapProblems } from './map.mjs';
7
+ import { fixMap, MAP_PATH, mapProblems } from './map.mjs';
8
8
  import { readList, toMarkdown } from './readlist.mjs';
9
9
 
10
10
  export const MARKER = '<!-- readfirst:pr-comment -->';
@@ -30,11 +30,12 @@ async function pages(fetchImpl, env, path) {
30
30
  return all;
31
31
  }
32
32
 
33
- export function commentBody({ list, problems, removed, commit }) {
33
+ export function commentBody({ list, problems, removed, commit, fixes = [] }) {
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
+ 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`] : []), '');
38
39
  }
39
40
  if (list) parts.push(toMarkdown(list, { checklist: true, heading: null }));
40
41
  else if (!problems.length) parts.push('No changed files to look up.', '');
@@ -66,7 +67,8 @@ export async function runAction({ env = process.env, fetchImpl = fetch, root = e
66
67
  const list = targets.length && !problems.length
67
68
  ? readList({ map, files, imports: importEdges(files, readText(root)), targets, commit: pr.head?.sha ?? null, limit: Number(env.INPUT_LIMIT || 30) })
68
69
  : null;
69
- const body = commentBody({ list, problems, removed, commit: pr.head?.sha ?? null });
70
+ const fixes = problems.length ? (() => { try { return fixMap(map, files).changes; } catch { return []; } })() : [];
71
+ const body = commentBody({ list, problems, removed, commit: pr.head?.sha ?? null, fixes });
70
72
  summary(body);
71
73
 
72
74
  if ((env.INPUT_COMMENT ?? 'true') !== 'false') {
package/src/cli.mjs CHANGED
@@ -1,27 +1,33 @@
1
1
  // readfirst: what to read and check before changing a file.
2
- // readfirst init write a first .readfirst/map.json from the file tree
2
+ // readfirst init [--agent] write a first .readfirst/map.json from the file tree (and the agent-notes line)
3
3
  // readfirst prompt the brief for a coding agent to fill in titles, checks, risks and rules
4
4
  // readfirst check the map covers every tracked file and is well formed
5
+ // readfirst fix place new files and drop patterns for deleted ones, then check
5
6
  // readfirst read <file>... the read list for files you are about to change
6
7
  // readfirst diff [--base <ref>] the read list for the files changed since <ref> (default: origin/main, else main)
8
+ // readfirst hook [--install] a Claude Code PreToolUse hook: the read list for the file an Edit or Write is about to change
7
9
  // Options: --root <dir> (default: the current git repository), --json, --limit <n>.
8
10
  import { execFileSync } from 'node:child_process';
9
11
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
10
- import { join } from 'node:path';
12
+ import { tmpdir } from 'node:os';
13
+ import { isAbsolute, join, relative } from 'node:path';
11
14
  import { importEdges } from './imports.mjs';
12
- import { assertMap, draftMap, MAP_PATH, mapProblems } from './map.mjs';
15
+ import { assertMap, draftMap, fixMap, MAP_PATH, mapProblems } from './map.mjs';
13
16
  import { agentBrief } from './prompt.mjs';
14
17
  import { emptyList, readList, toMarkdown } from './readlist.mjs';
15
18
 
16
19
  const HELP = `readfirst — what to read and check before changing a file
17
20
 
18
- readfirst init write a first ${MAP_PATH} from the file tree
21
+ readfirst init [--agent] write a first ${MAP_PATH} from the file tree
19
22
  readfirst prompt brief for your coding agent to fill in the map
20
23
  readfirst check check the map covers every tracked file
24
+ readfirst fix place new files, drop patterns for deleted ones
21
25
  readfirst read <file>... read list for files you are about to change
22
26
  readfirst diff [--base <ref>] read list for files changed since <ref>
27
+ readfirst hook [--install] Claude Code hook: the read list before each edit (--install adds it to .claude/settings.json)
23
28
 
24
- Options: --root <dir>, --json, --limit <n>, --force (init: replace the map)`;
29
+ Options: --root <dir>, --json, --limit <n>, --force (init: replace the map),
30
+ --agent (init: add the read-first line to CLAUDE.md / AGENTS.md / .cursorrules)`;
25
31
 
26
32
  function parse(argv) {
27
33
  const args = { _: [], json: false };
@@ -29,6 +35,8 @@ function parse(argv) {
29
35
  const a = argv[i];
30
36
  if (a === '--json') args.json = true;
31
37
  else if (a === '--force') args.force = true;
38
+ else if (a === '--agent') args.agent = true;
39
+ else if (a === '--install') args.install = true;
32
40
  else if (a === '-h' || a === '--help') args.help = true;
33
41
  else if (a.startsWith('--')) { const v = argv[++i]; if (v === undefined) throw new Error(`${a} needs a value`); args[a.slice(2)] = v; }
34
42
  else args._.push(a);
@@ -58,6 +66,33 @@ function loadMap(root, files) {
58
66
  return assertMap(readMapFile(root), files);
59
67
  }
60
68
 
69
+ // read and diff keep working while the map is only behind the code (new files in no group, patterns for deleted files):
70
+ // they warn and point at `readfirst fix`. A map that is broken in itself still stops them.
71
+ function loadMapLenient(root, files, io) {
72
+ const map = readMapFile(root);
73
+ const broken = mapProblems(map, files, { placement: false });
74
+ if (broken.length) throw new Error(`${MAP_PATH} has problems:\n- ${broken.join('\n- ')}`);
75
+ const behind = mapProblems(map, files);
76
+ if (behind.length) io.err(`note: ${MAP_PATH} is behind the code (${behind.length} problem${behind.length > 1 ? 's' : ''}); \`readfirst fix\` repairs most of it.\n`);
77
+ return map;
78
+ }
79
+
80
+ // The one line that makes a coding agent use readfirst, added to the agent notes the project already has (CLAUDE.md,
81
+ // AGENTS.md, .cursorrules), or to a new AGENTS.md when it has none. A file that already mentions readfirst is left alone.
82
+ export const AGENT_LINE = 'Before changing a file, run `npx readfirst read <file>` and follow what it says: keep the rules, read the risks, run the checks it lists. Before you finish, run `npx readfirst diff` and run every check it lists. When you learn something a newcomer would get wrong, add it as a risk in .readfirst/map.json.';
83
+ function addAgentLine(root) {
84
+ const notes = ['CLAUDE.md', 'AGENTS.md', '.cursorrules'].filter((f) => existsSync(join(root, f)));
85
+ const wrote = [];
86
+ for (const f of notes.length ? notes : ['AGENTS.md']) {
87
+ const at = join(root, f);
88
+ const now = existsSync(at) ? readFileSync(at, 'utf8') : '';
89
+ if (/readfirst/u.test(now)) continue;
90
+ writeFileSync(at, `${now}${now && !now.endsWith('\n') ? '\n' : ''}${now ? '\n' : ''}${AGENT_LINE}\n`);
91
+ wrote.push(f);
92
+ }
93
+ return wrote;
94
+ }
95
+
61
96
  function changedFiles(root, base) {
62
97
  const ref = base ?? ['origin/main', 'main', 'origin/master', 'master'].find((r) => { try { git(root, 'rev-parse', '--verify', '--quiet', r); return true; } catch { return false; } });
63
98
  if (!ref) throw new Error('no base branch found: pass --base <ref>');
@@ -69,10 +104,72 @@ function changedFiles(root, base) {
69
104
  return { ref, files: [...names].sort() };
70
105
  }
71
106
 
72
- export function main(argv, io = { out: (s) => process.stdout.write(s), err: (s) => process.stderr.write(s) }) {
107
+ // The read list as a few factual lines for an agent's context: rules, risks and checks, nothing else.
108
+ export function brief(list) {
109
+ const lines = [];
110
+ for (const r of list.rules) lines.push(`Rule ${r.id}: ${r.rule}${r.why ? ` (why: ${r.why})` : ''}`);
111
+ for (const r of list.risks) lines.push(`Risk: ${r.text}`);
112
+ for (const c of list.checks) lines.push(`Check to run after the change: ${c.run}${c.why ? ` (${c.why})` : ''}`);
113
+ if (list.tests.length) lines.push(`Tests that reach this file: ${list.tests.slice(0, 5).map((t) => t.path).join(', ')}`);
114
+ return lines;
115
+ }
116
+
117
+ // Claude Code PreToolUse hook. Reads the hook's JSON on stdin; for an Edit, Write or MultiEdit of a file the map covers,
118
+ // prints the read list as additionalContext, once per file per session. Never blocks the edit: any problem means no output.
119
+ export function runHook(input, { now = Date.now(), stateDir = tmpdir() } = {}) {
120
+ try {
121
+ const event = JSON.parse(input);
122
+ const file = event.tool_input?.file_path;
123
+ if (!file || !/^(?:Edit|Write|MultiEdit)$/u.test(event.tool_name ?? '')) return null;
124
+ const cwd = event.cwd ?? process.cwd();
125
+ const root = git(cwd, 'rev-parse', '--show-toplevel').trim();
126
+ const path = (isAbsolute(file) ? relative(root, file) : file).replace(/\\/gu, '/');
127
+ if (path.startsWith('..') || path === MAP_PATH || !existsSync(join(root, MAP_PATH))) return null;
128
+ const seenAt = join(stateDir, `readfirst-hook-${String(event.session_id ?? 'none').replace(/[^\w-]/gu, '')}.json`);
129
+ let seen = {};
130
+ try { seen = JSON.parse(readFileSync(seenAt, 'utf8')); } catch { seen = {}; }
131
+ if (seen[path] && now - seen[path] < 6 * 3600_000) return null;
132
+ const files = repoFiles(root);
133
+ const map = readMapFile(root);
134
+ if (mapProblems(map, files, { placement: false }).length) return null;
135
+ const list = readList({ map, files: files.includes(path) ? files : [...files, path].sort(), imports: importEdges(files, readText(root)), targets: [path], limit: 10 });
136
+ const lines = brief(list);
137
+ seen[path] = now;
138
+ try { writeFileSync(seenAt, JSON.stringify(seen)); } catch { /* best effort */ }
139
+ if (!lines.length) return null;
140
+ const shown = lines.slice(0, 12);
141
+ if (lines.length > shown.length) shown.push(`${lines.length - shown.length} more in \`npx readfirst read ${path}\``);
142
+ return JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: `readfirst notes for ${path} (from ${MAP_PATH}):\n- ${shown.join('\n- ')}` } });
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+
148
+ export const HOOK_COMMAND = 'npx --yes readfirst hook';
149
+ // Adds the hook to the project's .claude/settings.json, keeping everything already there.
150
+ export function installHook(root) {
151
+ const at = join(root, '.claude', 'settings.json');
152
+ let settings = {};
153
+ if (existsSync(at)) {
154
+ try { settings = JSON.parse(readFileSync(at, 'utf8')); } catch { throw new Error('.claude/settings.json is not valid JSON; fix it first'); }
155
+ }
156
+ const pre = ((settings.hooks ??= {}).PreToolUse ??= []);
157
+ if (pre.some((m) => (m.hooks ?? []).some((h) => /readfirst hook/u.test(h.command ?? '')))) return false;
158
+ pre.push({ matcher: 'Edit|Write|MultiEdit', hooks: [{ type: 'command', command: HOOK_COMMAND, timeout: 15 }] });
159
+ mkdirSync(join(root, '.claude'), { recursive: true });
160
+ writeFileSync(at, `${JSON.stringify(settings, null, 2)}\n`);
161
+ return true;
162
+ }
163
+
164
+ export function main(argv, io = { out: (s) => process.stdout.write(s), err: (s) => process.stderr.write(s) }, stdin = () => readFileSync(0, 'utf8')) {
73
165
  const args = parse(argv);
74
166
  const [cmd, ...rest] = args._;
75
167
  if (args.help || !cmd) { io.out(`${HELP}\n`); return cmd || args.help ? 0 : 2; }
168
+ if (cmd === 'hook' && !args.install) {
169
+ const out = runHook(stdin());
170
+ if (out) io.out(`${out}\n`);
171
+ return 0;
172
+ }
76
173
  const root = args.root ?? git(process.cwd(), 'rev-parse', '--show-toplevel').trim();
77
174
  const files = repoFiles(root);
78
175
  const commit = (() => { try { return git(root, 'rev-parse', 'HEAD').trim(); } catch { return null; } })();
@@ -80,10 +177,17 @@ export function main(argv, io = { out: (s) => process.stdout.write(s), err: (s)
80
177
  if (cmd === 'init') {
81
178
  const at = join(root, MAP_PATH);
82
179
  if (existsSync(at) && args.force !== true) throw new Error(`${MAP_PATH} already exists (pass --force to replace it)`);
83
- const map = draftMap(files);
180
+ const map = draftMap(files, readText(root));
84
181
  mkdirSync(join(root, '.readfirst'), { recursive: true });
85
182
  writeFileSync(at, `${JSON.stringify(map, null, 2)}\n`);
86
- io.out(`wrote ${MAP_PATH}: ${map.groups.length} groups, ${map.ignore.length} ignored folders.\nNext: run \`readfirst prompt\` and give the brief to your coding agent, or fill in titles, checks, risks and rules by hand.\n`);
183
+ const checks = map.groups.find((g) => g.checks.length)?.checks ?? [];
184
+ io.out(`wrote ${MAP_PATH}: ${map.groups.length} groups${checks.length ? `, checks found: ${checks.map((c) => c.run).join(', ')}` : ''}.\n`);
185
+ if (args.agent) {
186
+ const wrote = addAgentLine(root);
187
+ io.out(wrote.length ? `added the read-first line to ${wrote.join(', ')}.\n` : 'your agent notes already mention readfirst.\n');
188
+ io.out('Using Claude Code? `readfirst hook --install` hands it the read list automatically before each edit.\n');
189
+ }
190
+ 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`);
87
191
  return 0;
88
192
  }
89
193
  if (cmd === 'prompt') {
@@ -96,8 +200,22 @@ export function main(argv, io = { out: (s) => process.stdout.write(s), err: (s)
96
200
  else io.out(problems.length ? `${MAP_PATH} has ${problems.length} problem(s):\n- ${problems.join('\n- ')}\n` : `${MAP_PATH} is good: ${files.length} files.\n`);
97
201
  return problems.length ? 1 : 0;
98
202
  }
203
+ if (cmd === 'hook') {
204
+ 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');
205
+ return 0;
206
+ }
207
+ if (cmd === 'fix') {
208
+ const { map, changes, problems } = fixMap(readMapFile(root), files);
209
+ if (changes.length) writeFileSync(join(root, MAP_PATH), `${JSON.stringify(map, null, 2)}\n`);
210
+ if (args.json) io.out(`${JSON.stringify({ ok: !problems.length, changes, problems }, null, 2)}\n`);
211
+ else {
212
+ io.out(changes.length ? `updated ${MAP_PATH}:\n- ${changes.join('\n- ')}\n` : 'nothing to fix automatically.\n');
213
+ io.out(problems.length ? `still to fix by hand:\n- ${problems.join('\n- ')}\n` : `${MAP_PATH} is good: ${files.length} files.\n`);
214
+ }
215
+ return problems.length ? 1 : 0;
216
+ }
99
217
  if (cmd === 'read' || cmd === 'diff') {
100
- const map = loadMap(root, files);
218
+ const map = loadMapLenient(root, files, io);
101
219
  let targets = rest;
102
220
  if (cmd === 'diff') {
103
221
  const changed = changedFiles(root, args.base);
package/src/map.mjs CHANGED
@@ -43,7 +43,7 @@ const checkList = (v, where, p) => {
43
43
  };
44
44
 
45
45
  // Problems with the map against the list of tracked files. An empty list means the map is good.
46
- export function mapProblems(map, files) {
46
+ export function mapProblems(map, files, { placement = true } = {}) {
47
47
  const p = [];
48
48
  if (!isRecord(map)) return ['map: a JSON object'];
49
49
  only(map, ['format', 'groups', 'rules', 'ignore'], 'map', p);
@@ -90,7 +90,7 @@ export function mapProblems(map, files) {
90
90
  }
91
91
  }
92
92
  if (map.ignore !== undefined) strings(map.ignore, 'ignore', p);
93
- if (p.length) return p;
93
+ if (p.length || !placement) return p;
94
94
 
95
95
  // Placement: each tracked file in exactly one group or ignored; each group pattern matches something.
96
96
  const ignored = matcher(map.ignore ?? []);
@@ -121,7 +121,7 @@ export const groupOf = (map, path) => map.groups.find((g) => matcher(g.files)(pa
121
121
  // that code belong there; "ignore" is left for a person to fill with what nothing depends on. Titles and risks are left
122
122
  // for a person or an agent to write.
123
123
  const MEDIA = /\.(?:png|jpe?g|gif|webp|svg|ico|mp3|wav|ogg|m4a|flac|mp4|webm|mov|woff2?|ttf|otf|eot|glb|gltf|fbx|blend|zip|pdf)$/iu;
124
- export function draftMap(files) {
124
+ export function draftMap(files, read = () => null) {
125
125
  const nested = new Set(['src', 'lib', 'app', 'packages', 'apps']);
126
126
  const keyOf = (path) => {
127
127
  const parts = path.split('/');
@@ -133,6 +133,7 @@ export function draftMap(files) {
133
133
  const groups = new Map();
134
134
  const ignore = new Set();
135
135
  const byFolder = new Map();
136
+ const projectChecks = draftChecks(files, read);
136
137
  for (const f of files) {
137
138
  if (f === MAP_PATH) continue;
138
139
  const { key, pattern } = keyOf(f);
@@ -148,7 +149,7 @@ export function draftMap(files) {
148
149
  const title = media
149
150
  ? `TODO: what loads the media files in ${slot.pattern} (binary, not read)`
150
151
  : `TODO: what ${slot.pattern ?? 'the top-level files'} ${slot.pattern ? 'is' : 'are'} for`;
151
- groups.set(id, { id, title, files: prev ? [...prev.files, ...patterns] : patterns, checks: [], risks: [] });
152
+ groups.set(id, { id, title, files: prev ? [...prev.files, ...patterns] : patterns, checks: media ? [] : projectChecks, risks: [] });
152
153
  }
153
154
  // A nested pattern ("src/a/**") and the parent's loose files ("src/x.js") must not overlap; loose files under a nested root
154
155
  // become their own group.
@@ -161,3 +162,83 @@ export function draftMap(files) {
161
162
  }
162
163
  return { format: MAP_FORMAT, groups: [...groups.values()].sort((a, b) => a.id.localeCompare(b.id)), rules: [], ignore: [...ignore].sort() };
163
164
  }
165
+
166
+ // Repairs the two ways a good map goes stale as code moves, without guessing at meaning:
167
+ // - a tracked file in no group joins the group of its nearest neighbour (same folder, else the closest parent folder);
168
+ // a new folder whose files are all unplaced joins as "<folder>/**", a lone new file by its path;
169
+ // - a pattern that no longer matches any file is dropped (from a group, a risk or a rule), unless it is the last one.
170
+ // What it cannot decide (a file with no placed neighbour, a group or rule left with no files) stays for a person.
171
+ // Returns the fixed map, the changes as sentences, and the problems still left.
172
+ export function fixMap(map, files) {
173
+ const out = structuredClone(map);
174
+ const changes = [];
175
+ const live = (pat) => files.some(matcher([pat]));
176
+ for (const g of out.groups) {
177
+ const keep = g.files.filter(live);
178
+ for (const pat of g.files.filter((p) => !live(p))) {
179
+ if (keep.length) changes.push(`groups.${g.id}: dropped ${pat} (matches no file)`);
180
+ }
181
+ if (keep.length) g.files = keep;
182
+ for (const r of g.risks ?? []) if (Array.isArray(r.files)) {
183
+ const k = r.files.filter(live);
184
+ for (const pat of r.files.filter((p) => !live(p))) changes.push(`groups.${g.id} risk "${r.text.slice(0, 40)}": dropped ${pat}`);
185
+ if (k.length) r.files = k; else delete r.files;
186
+ }
187
+ }
188
+ for (const r of out.rules ?? []) {
189
+ const keep = r.files.filter(live);
190
+ for (const pat of r.files.filter((p) => !live(p))) if (keep.length) changes.push(`rules.${r.id}: dropped ${pat} (matches no file)`);
191
+ if (keep.length) r.files = keep;
192
+ }
193
+
194
+ const ignored = matcher(out.ignore ?? []);
195
+ const owner = (f) => out.groups.find((g) => matcher(g.files)(f));
196
+ const unplaced = files.filter((f) => f !== MAP_PATH && !ignored(f) && !owner(f));
197
+ const dir = (f) => (f.includes('/') ? f.slice(0, f.lastIndexOf('/')) : '');
198
+ const placedIn = (d) => files.filter((f) => f !== MAP_PATH && dir(f) === d && owner(f));
199
+ const doneDirs = new Set();
200
+ for (const f of unplaced) {
201
+ if (owner(f)) continue; // placed by a folder pattern added earlier in this loop
202
+ // The nearest folder, going up, that holds a placed file decides the group.
203
+ let d = dir(f);
204
+ let neighbours = placedIn(d);
205
+ while (!neighbours.length && d) { d = dir(d); neighbours = placedIn(d); }
206
+ if (!neighbours.length) continue;
207
+ const counts = new Map();
208
+ for (const n of neighbours) { const g = owner(n); counts.set(g, (counts.get(g) ?? 0) + 1); }
209
+ const group = [...counts].sort((a, b) => b[1] - a[1])[0][0];
210
+ // A whole new folder (nothing in it or under it placed or ignored) joins as one pattern.
211
+ const own = dir(f);
212
+ const under = files.filter((x) => x.startsWith(`${own}/`));
213
+ const fresh = own && own !== d && !doneDirs.has(own) && under.every((x) => unplaced.includes(x));
214
+ const pat = fresh ? `${own}/**` : f;
215
+ if (fresh) doneDirs.add(own);
216
+ group.files.push(pat);
217
+ changes.push(`groups.${group.id}: added ${pat}`);
218
+ }
219
+ return { map: out, changes, problems: mapProblems(out, files) };
220
+ }
221
+
222
+ // The project's own check commands, found in its build files, so a first map already says what to run. Repository-wide
223
+ // on purpose: narrowing a check to the group it covers is left to a person or an agent.
224
+ export function draftChecks(files, read = () => null) {
225
+ const has = new Set(files);
226
+ const out = [];
227
+ const add = (run, why) => { if (!out.some((c) => c.run === run)) out.push({ run, why }); };
228
+ if (has.has('package.json')) {
229
+ let scripts = {};
230
+ try { scripts = JSON.parse(read('package.json') ?? '{}').scripts ?? {}; } catch { scripts = {}; }
231
+ const pm = has.has('pnpm-lock.yaml') ? 'pnpm' : has.has('yarn.lock') ? 'yarn' : has.has('bun.lockb') || has.has('bun.lock') ? 'bun' : 'npm';
232
+ const run = (name) => (pm === 'npm' ? (name === 'test' ? 'npm test' : `npm run ${name}`) : `${pm} ${name === 'test' && pm !== 'bun' ? 'test' : `run ${name}`}`);
233
+ const placeholder = /no test specified/u;
234
+ if (typeof scripts.test === 'string' && !placeholder.test(scripts.test)) add(run('test'), 'the test suite');
235
+ for (const name of ['typecheck', 'type-check', 'check-types', 'lint']) if (typeof scripts[name] === 'string') add(run(name), name === 'lint' ? 'lint rules' : 'types');
236
+ }
237
+ const makefile = has.has('Makefile') ? read('Makefile') ?? '' : '';
238
+ if (/^test:/mu.test(makefile)) add('make test', 'the test suite (Makefile)');
239
+ if (has.has('go.mod')) add('go test ./...', 'the Go tests');
240
+ if (has.has('Cargo.toml')) add('cargo test', 'the Rust tests');
241
+ const py = has.has('pyproject.toml') || has.has('setup.py') || has.has('pytest.ini');
242
+ if (py && files.some((f) => /(?:^|\/)(?:test_[^/]*|[^/]*_test)\.py$/u.test(f))) add('pytest', 'the Python tests');
243
+ return out;
244
+ }
package/src/prompt.mjs CHANGED
@@ -19,7 +19,8 @@ Do this:
19
19
  3. Add "rules": promises the code must keep, each {"id": "kebab-id", "rule": "...", "why": "...", "files": [...],
20
20
  "checks": [{"run": "..."}]}. Take them from comments, docs, tests and commit messages. Only write rules you can point to
21
21
  in the code or docs. Ask the user for the ones only they know.
22
- 4. Run \`npx readfirst check\` until it says the map is good, then show the user the diff of ${MAP_PATH}.
22
+ 4. Run \`npx readfirst check\` until it says the map is good (\`npx readfirst fix\` places new files and drops dead
23
+ patterns for you), then show the user the diff of ${MAP_PATH}.
23
24
 
24
25
  Current groups: ${map.groups.map((g) => `${g.id} (${g.files.join(', ')})`).join('; ')}.
25
26
  `;
package/src/readlist.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // The read list for files someone is about to change: the rules that cover them, the risks written for their group,
2
2
  // the checks to run (the group's commands, the rules' commands, and the tests that import them), and who uses them.
3
- import { groupOf, matcher, MAP_PATH } from './map.mjs';
3
+ import { fixMap, groupOf, matcher, MAP_PATH } from './map.mjs';
4
4
 
5
5
  export const READ_LIST_FORMAT = 'readfirst/read-list@1';
6
6
  const TEST = /(^|\/)(?:tests?|__tests__|spec|e2e)\/|\.(?:test|spec)\.[cm]?[jt]sx?$|(^|\/)test[-_][^/]*\.(?:[cm]?[jt]sx?|py)$|_test\.(?:py|go)$/u;
@@ -26,6 +26,12 @@ export function readList({ map, files, imports, targets, commit = null, limit =
26
26
  out[kind].push(row);
27
27
  };
28
28
  const indirect = new Set();
29
+ // Where `readfirst fix` would put files that are in no group; worked out only when such a file is asked about.
30
+ let fixes = null;
31
+ const suggestFor = (target) => {
32
+ fixes ??= (() => { try { return fixMap(map, files).changes.map((c) => c.match(/^groups\.([^:]+): added (.+)$/u)).filter(Boolean).map((m) => ({ group: m[1], target: m[2] })); } catch { return []; } })();
33
+ return fixes.find((c) => target === c.target || (c.target.endsWith('/**') && target.startsWith(c.target.slice(0, -2))));
34
+ };
29
35
 
30
36
  for (const raw of targets) {
31
37
  const target = raw.replace(/^\.\//u, '').replace(/\\/gu, '/');
@@ -38,7 +44,8 @@ export function readList({ map, files, imports, targets, commit = null, limit =
38
44
  const group = groupOf(map, target);
39
45
  out.targets.push({ path: target, group: group?.id ?? null, status: group ? 'IN_MAP' : ignored(target) ? 'IGNORED' : 'NO_GROUP' });
40
46
  if (!group) {
41
- out.notCovered.push({ path: target, why: ignored(target) ? 'ignored by the map' : 'in no group: run `readfirst check`' });
47
+ const suggest = ignored(target) ? null : suggestFor(target);
48
+ out.notCovered.push({ path: target, why: ignored(target) ? 'ignored by the map' : suggest ? `in no group: \`readfirst fix\` would add it to group ${suggest.group}` : 'in no group: run `readfirst check`' });
42
49
  } else {
43
50
  for (const r of group.risks ?? []) if (!r.files || matcher(r.files)(target)) note('risks', `${group.id}\0${r.text}`, { group: group.id, text: r.text }, target);
44
51
  for (const c of group.checks ?? []) note('checks', c.run, { run: c.run, why: c.why ?? `checks for ${group.title}`, from: `group ${group.id}` }, target);