readfirst 0.1.1 → 0.2.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,29 +6,7 @@ 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
 
@@ -46,6 +24,7 @@ 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
@@ -80,7 +59,7 @@ Each PR gets one comment, updated on every push, that lists:
80
59
  - the tests that reach the changed code.
81
60
 
82
61
  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.
62
+ map is fixed in the same PR. The comment lists what `npx readfirst fix` would change, so the fix is usually one command.
84
63
 
85
64
  | Input | Default | Meaning |
86
65
  |---|---|---|
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "readfirst",
3
- "version": "0.1.1",
3
+ "version": "0.2.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
@@ -2,6 +2,7 @@
2
2
  // readfirst init write a first .readfirst/map.json from the file tree
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)
7
8
  // Options: --root <dir> (default: the current git repository), --json, --limit <n>.
@@ -9,7 +10,7 @@ import { execFileSync } from 'node:child_process';
9
10
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
10
11
  import { join } from 'node:path';
11
12
  import { importEdges } from './imports.mjs';
12
- import { assertMap, draftMap, MAP_PATH, mapProblems } from './map.mjs';
13
+ import { assertMap, draftMap, fixMap, MAP_PATH, mapProblems } from './map.mjs';
13
14
  import { agentBrief } from './prompt.mjs';
14
15
  import { emptyList, readList, toMarkdown } from './readlist.mjs';
15
16
 
@@ -18,6 +19,7 @@ const HELP = `readfirst — what to read and check before changing a file
18
19
  readfirst init write a first ${MAP_PATH} from the file tree
19
20
  readfirst prompt brief for your coding agent to fill in the map
20
21
  readfirst check check the map covers every tracked file
22
+ readfirst fix place new files, drop patterns for deleted ones
21
23
  readfirst read <file>... read list for files you are about to change
22
24
  readfirst diff [--base <ref>] read list for files changed since <ref>
23
25
 
@@ -96,6 +98,16 @@ export function main(argv, io = { out: (s) => process.stdout.write(s), err: (s)
96
98
  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
99
  return problems.length ? 1 : 0;
98
100
  }
101
+ if (cmd === 'fix') {
102
+ const { map, changes, problems } = fixMap(readMapFile(root), files);
103
+ if (changes.length) writeFileSync(join(root, MAP_PATH), `${JSON.stringify(map, null, 2)}\n`);
104
+ if (args.json) io.out(`${JSON.stringify({ ok: !problems.length, changes, problems }, null, 2)}\n`);
105
+ else {
106
+ io.out(changes.length ? `updated ${MAP_PATH}:\n- ${changes.join('\n- ')}\n` : 'nothing to fix automatically.\n');
107
+ io.out(problems.length ? `still to fix by hand:\n- ${problems.join('\n- ')}\n` : `${MAP_PATH} is good: ${files.length} files.\n`);
108
+ }
109
+ return problems.length ? 1 : 0;
110
+ }
99
111
  if (cmd === 'read' || cmd === 'diff') {
100
112
  const map = loadMap(root, files);
101
113
  let targets = rest;
package/src/map.mjs CHANGED
@@ -161,3 +161,59 @@ export function draftMap(files) {
161
161
  }
162
162
  return { format: MAP_FORMAT, groups: [...groups.values()].sort((a, b) => a.id.localeCompare(b.id)), rules: [], ignore: [...ignore].sort() };
163
163
  }
164
+
165
+ // Repairs the two ways a good map goes stale as code moves, without guessing at meaning:
166
+ // - a tracked file in no group joins the group of its nearest neighbour (same folder, else the closest parent folder);
167
+ // a new folder whose files are all unplaced joins as "<folder>/**", a lone new file by its path;
168
+ // - a pattern that no longer matches any file is dropped (from a group, a risk or a rule), unless it is the last one.
169
+ // What it cannot decide (a file with no placed neighbour, a group or rule left with no files) stays for a person.
170
+ // Returns the fixed map, the changes as sentences, and the problems still left.
171
+ export function fixMap(map, files) {
172
+ const out = structuredClone(map);
173
+ const changes = [];
174
+ const live = (pat) => files.some(matcher([pat]));
175
+ for (const g of out.groups) {
176
+ const keep = g.files.filter(live);
177
+ for (const pat of g.files.filter((p) => !live(p))) {
178
+ if (keep.length) changes.push(`groups.${g.id}: dropped ${pat} (matches no file)`);
179
+ }
180
+ if (keep.length) g.files = keep;
181
+ for (const r of g.risks ?? []) if (Array.isArray(r.files)) {
182
+ const k = r.files.filter(live);
183
+ for (const pat of r.files.filter((p) => !live(p))) changes.push(`groups.${g.id} risk "${r.text.slice(0, 40)}": dropped ${pat}`);
184
+ if (k.length) r.files = k; else delete r.files;
185
+ }
186
+ }
187
+ for (const r of out.rules ?? []) {
188
+ const keep = r.files.filter(live);
189
+ for (const pat of r.files.filter((p) => !live(p))) if (keep.length) changes.push(`rules.${r.id}: dropped ${pat} (matches no file)`);
190
+ if (keep.length) r.files = keep;
191
+ }
192
+
193
+ const ignored = matcher(out.ignore ?? []);
194
+ const owner = (f) => out.groups.find((g) => matcher(g.files)(f));
195
+ const unplaced = files.filter((f) => f !== MAP_PATH && !ignored(f) && !owner(f));
196
+ const dir = (f) => (f.includes('/') ? f.slice(0, f.lastIndexOf('/')) : '');
197
+ const placedIn = (d) => files.filter((f) => f !== MAP_PATH && dir(f) === d && owner(f));
198
+ const doneDirs = new Set();
199
+ for (const f of unplaced) {
200
+ if (owner(f)) continue; // placed by a folder pattern added earlier in this loop
201
+ // The nearest folder, going up, that holds a placed file decides the group.
202
+ let d = dir(f);
203
+ let neighbours = placedIn(d);
204
+ while (!neighbours.length && d) { d = dir(d); neighbours = placedIn(d); }
205
+ if (!neighbours.length) continue;
206
+ const counts = new Map();
207
+ for (const n of neighbours) { const g = owner(n); counts.set(g, (counts.get(g) ?? 0) + 1); }
208
+ const group = [...counts].sort((a, b) => b[1] - a[1])[0][0];
209
+ // A whole new folder (nothing in it or under it placed or ignored) joins as one pattern.
210
+ const own = dir(f);
211
+ const under = files.filter((x) => x.startsWith(`${own}/`));
212
+ const fresh = own && own !== d && !doneDirs.has(own) && under.every((x) => unplaced.includes(x));
213
+ const pat = fresh ? `${own}/**` : f;
214
+ if (fresh) doneDirs.add(own);
215
+ group.files.push(pat);
216
+ changes.push(`groups.${group.id}: added ${pat}`);
217
+ }
218
+ return { map: out, changes, problems: mapProblems(out, files) };
219
+ }