readfirst 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +3 -1
  2. package/package.json +1 -1
  3. package/src/cli.mjs +51 -3
package/README.md CHANGED
@@ -24,6 +24,7 @@ Commit `.readfirst/map.json`. From then on:
24
24
  npx readfirst read <file>... # before changing files
25
25
  npx readfirst diff # everything your branch changed since main
26
26
  npx readfirst diff --json # the same, for tools and agents
27
+ npx readfirst verify # run the checks the map lists for everything your branch changed (exit 1 if one fails)
27
28
  npx readfirst fix # after moving code: place new files, drop patterns for deleted ones
28
29
  ```
29
30
 
@@ -33,7 +34,8 @@ npx readfirst fix # after moving code: place new files, drop patt
33
34
 
34
35
  ```md
35
36
  Before changing a file, run `npx readfirst read <file>` and follow what it says: keep the rules, read the risks, run the
36
- checks it lists. Before you finish, run `npx readfirst diff` and run every check it lists.
37
+ checks it lists. Before you finish, run `npx readfirst verify`: it runs the checks the map lists for everything you
38
+ changed; fix what fails.
37
39
  When you learn something a newcomer would get wrong, add it as a risk in .readfirst/map.json.
38
40
  ```
39
41
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "readfirst",
3
- "version": "0.4.0",
3
+ "version": "0.5.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/cli.mjs CHANGED
@@ -6,10 +6,11 @@
6
6
  // readfirst fix place new files and drop patterns for deleted ones, then check
7
7
  // readfirst read <file>... the read list for files you are about to change
8
8
  // readfirst diff [--base <ref>] the read list for the files changed since <ref> (default: origin/main, else main)
9
+ // readfirst verify [--base <ref>] run the checks the read list names for the changed files; exit 1 if one fails
9
10
  // readfirst hook [--install] a Claude Code PreToolUse hook: the read list for the file an Edit or Write is about to change
10
11
  // readfirst view [--out <file>] the map as a city in one HTML page (default .readfirst/local/view.html)
11
12
  // Options: --root <dir> (default: the current git repository), --json, --limit <n>.
12
- import { execFileSync } from 'node:child_process';
13
+ import { execFileSync, spawnSync } from 'node:child_process';
13
14
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
14
15
  import { tmpdir } from 'node:os';
15
16
  import { basename, isAbsolute, join, relative } from 'node:path';
@@ -29,11 +30,12 @@ const HELP = `readfirst — what to read and check before changing a file
29
30
  readfirst fix place new files, drop patterns for deleted ones
30
31
  readfirst read <file>... read list for files you are about to change
31
32
  readfirst diff [--base <ref>] read list for files changed since <ref>
33
+ readfirst verify [--base <ref>] run the checks for the files changed since <ref> (--dry-run lists them)
32
34
  readfirst hook [--install] Claude Code hook: the read list before each edit (--install adds it to .claude/settings.json)
33
35
  readfirst view [--out <file>] the map as a city in one HTML page
34
36
  readfirst prompt --labels <lang> brief for your agent to write personal labels for the view (e.g. ko, en)
35
37
 
36
- Options: --root <dir>, --json, --limit <n>, --force (init: replace the map),
38
+ Options: --root <dir>, --json, --limit <n>, --timeout <s> (verify, per check; default 900), --force (init: replace the map),
37
39
  --agent (init: add the read-first line to CLAUDE.md / AGENTS.md / .cursorrules)`;
38
40
 
39
41
  function parse(argv) {
@@ -44,6 +46,7 @@ function parse(argv) {
44
46
  else if (a === '--force') args.force = true;
45
47
  else if (a === '--agent') args.agent = true;
46
48
  else if (a === '--install') args.install = true;
49
+ else if (a === '--dry-run') args.dryRun = true;
47
50
  else if (a === '-h' || a === '--help') args.help = true;
48
51
  else if (a.startsWith('--')) { const v = argv[++i]; if (v === undefined) throw new Error(`${a} needs a value`); args[a.slice(2)] = v; }
49
52
  else args._.push(a);
@@ -87,7 +90,7 @@ function loadMapLenient(root, files, io) {
87
90
 
88
91
  // The one line that makes a coding agent use readfirst, added to the agent notes the project already has (CLAUDE.md,
89
92
  // AGENTS.md, .cursorrules), or to a new AGENTS.md when it has none. A file that already mentions readfirst is left alone.
90
- 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.';
93
+ 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 verify`: it runs the checks the map lists for everything you changed; fix what fails. When you learn something a newcomer would get wrong, add it as a risk in .readfirst/map.json.';
91
94
  function addAgentLine(root) {
92
95
  const notes = ['CLAUDE.md', 'AGENTS.md', '.cursorrules'].filter((f) => existsSync(join(root, f)));
93
96
  const wrote = [];
@@ -112,6 +115,37 @@ function changedFiles(root, base) {
112
115
  return { ref, files: [...names].sort() };
113
116
  }
114
117
 
118
+ // Runs each check with the shell in the repository root, one after another, and keeps the end of the output of the ones
119
+ // that fail. readfirst writes nothing itself; the commands are the map's own and may write, as they would by hand.
120
+ export function verifyChecks(root, checks, { dryRun = false, timeout = 900, progress = () => {} } = {}) {
121
+ const out = [];
122
+ for (const c of checks) {
123
+ const row = { run: c.run, why: c.why, for: c.for ?? [] };
124
+ if (dryRun) { out.push({ ...row, status: 'listed' }); continue; }
125
+ progress(`running ${c.run}\n`);
126
+ const started = Date.now();
127
+ const r = spawnSync(c.run, { cwd: root, shell: true, encoding: 'utf8', timeout: timeout * 1000, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, CI: process.env.CI ?? '1' } });
128
+ const seconds = Math.round((Date.now() - started) / 100) / 10;
129
+ const timedOut = r.error?.code === 'ETIMEDOUT';
130
+ const status = timedOut ? 'timeout' : r.status === 0 ? 'pass' : 'fail';
131
+ const tail = status === 'pass' ? undefined : `${r.stdout ?? ''}${r.stderr ?? ''}${r.error && !timedOut ? r.error.message : ''}`.trimEnd().split('\n').slice(-30).join('\n');
132
+ out.push({ ...row, status, code: r.status, seconds, ...(tail !== undefined ? { output: tail } : {}) });
133
+ }
134
+ return out;
135
+ }
136
+
137
+ function verifyText(report, dryRun) {
138
+ if (!report.files.length) return `no files changed since ${report.base}: nothing to verify.\n`;
139
+ if (!report.checks.length) return `${report.files.length} changed file(s) since ${report.base}; the map lists no checks for them.\n`;
140
+ if (dryRun) return `checks for ${report.files.length} changed file(s) since ${report.base}:\n${report.checks.map((c) => `- ${c.run}${c.why ? ` — ${c.why}` : ''}`).join('\n')}\n`;
141
+ const mark = { pass: 'pass', fail: 'FAIL', timeout: 'TIMEOUT' };
142
+ let s = `\nverify: ${report.checks.length} check(s) for ${report.files.length} changed file(s) since ${report.base}\n`;
143
+ for (const c of report.checks) s += `${mark[c.status]} ${c.run} (${c.seconds}s)\n`;
144
+ for (const c of report.checks.filter((x) => x.status !== 'pass')) s += `\n--- ${c.run} (${c.status}${c.code != null ? `, exit ${c.code}` : ''}); last lines:\n${c.output}\n`;
145
+ s += report.ok ? '\nall checks passed.\n' : `\n${report.checks.filter((c) => c.status !== 'pass').length} check(s) failed.\n`;
146
+ return s;
147
+ }
148
+
115
149
  // The read list as a few factual lines for an agent's context: rules, risks and checks, nothing else.
116
150
  export function brief(list) {
117
151
  const lines = [];
@@ -258,5 +292,19 @@ export function main(argv, io = { out: (s) => process.stdout.write(s), err: (s)
258
292
  io.out(args.json ? `${JSON.stringify(list, null, 2)}\n` : `${toMarkdown(list)}\n`);
259
293
  return 0;
260
294
  }
295
+ if (cmd === 'verify') {
296
+ const map = loadMapLenient(root, files, io);
297
+ const changed = changedFiles(root, args.base);
298
+ const list = changed.files.length
299
+ ? readList({ map, files, imports: importEdges(files, readText(root)), targets: changed.files, commit, limit: 1 })
300
+ : emptyList(commit);
301
+ const timeout = Number(args.timeout ?? 900);
302
+ if (!(timeout > 0)) throw new Error('--timeout needs a number of seconds above 0');
303
+ const result = verifyChecks(root, list.checks, { dryRun: args.dryRun, timeout, progress: args.json ? () => {} : io.out });
304
+ const report = { format: 'readfirst/verify@1', commit, base: changed.ref, files: changed.files, ok: result.every((c) => c.status === 'pass' || c.status === 'listed'), checks: result };
305
+ if (args.json) io.out(`${JSON.stringify(report, null, 2)}\n`);
306
+ else io.out(verifyText(report, args.dryRun));
307
+ return report.ok ? 0 : 1;
308
+ }
261
309
  throw new Error(`unknown command ${cmd}\n\n${HELP}`);
262
310
  }