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 +3 -24
- package/package.json +1 -1
- package/src/action.mjs +5 -3
- package/src/cli.mjs +13 -1
- package/src/map.mjs +56 -0
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
|
-
|
|
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
|
+

|
|
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
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
|
|
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
|
+
}
|