readfirst 0.8.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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "readfirst",
3
- "version": "0.8.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/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
@@ -15,9 +15,9 @@
15
15
  import { execFileSync, spawnSync } from 'node:child_process';
16
16
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
17
17
  import { tmpdir } from 'node:os';
18
- import { basename, isAbsolute, join, relative } from 'node:path';
18
+ import { basename, dirname, isAbsolute, join, relative } from 'node:path';
19
19
  import { importEdges } from './imports.mjs';
20
- import { assertMap, draftMap, fixMap, MAP_PATH, mapProblems, settleMap } from './map.mjs';
20
+ import { MAP_PATH, assertMap, draftChecks, draftMap, fixMap, mapProblems, settleMap } from './map.mjs';
21
21
  import { agentBrief } from './prompt.mjs';
22
22
  import { keepLocal, labelsBrief, LOCAL_DIR, readLabels } from './labels.mjs';
23
23
  import { viewData } from './view.mjs';
@@ -27,7 +27,7 @@ import { emptyList, readList, toMarkdown } from './readlist.mjs';
27
27
 
28
28
  const HELP = `readfirst — what to read and check before changing a file
29
29
 
30
- 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
31
31
  readfirst prompt brief for your coding agent to fill in the map
32
32
  readfirst check [--strict] check the map covers every tracked file (new files beside placed ones pass; --strict fails on them)
33
33
  readfirst fix place new files, drop patterns for deleted ones
@@ -41,7 +41,8 @@ const HELP = `readfirst — what to read and check before changing a file
41
41
  readfirst prompt --labels <lang> brief for your agent to write personal labels for the view (e.g. ko, en)
42
42
 
43
43
  Options: --root <dir>, --json, --limit <n>, --timeout <s> (verify, per check; default 900), --force (init: replace the map),
44
- --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)`;
45
46
 
46
47
  function parse(argv) {
47
48
  const args = { _: [], json: false };
@@ -54,6 +55,7 @@ function parse(argv) {
54
55
  else if (a === '--dry-run') args.dryRun = true;
55
56
  else if (a === '--strict') args.strict = true;
56
57
  else if (a === '--verify') args.verify = true;
58
+ else if (a === '--ci') args.ci = true;
57
59
  else if (a === '--stop') args.stop = true;
58
60
  else if (a === '-h' || a === '--help') args.help = true;
59
61
  else if (a.startsWith('--')) { const v = argv[++i]; if (v === undefined) throw new Error(`${a} needs a value`); args[a.slice(2)] = v; }
@@ -255,6 +257,34 @@ export function runStopHook(input, { stateDir = tmpdir(), timeout = 600 } = {})
255
257
  }
256
258
  }
257
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
+
258
288
  export const HOOK_COMMAND = 'npx --yes readfirst hook';
259
289
  export const STOP_HOOK_COMMAND = 'npx --yes readfirst hook --stop';
260
290
  // Adds the hook to the project's .claude/settings.json, keeping everything already there. With verify, also the Stop
@@ -315,14 +345,21 @@ export function main(argv, io = { out: (s) => process.stdout.write(s), err: (s)
315
345
  const map = draftMap(files, readText(root));
316
346
  mkdirSync(join(root, '.readfirst'), { recursive: true });
317
347
  writeFileSync(at, `${JSON.stringify(map, null, 2)}\n`);
318
- const checks = map.groups.find((g) => g.checks.length)?.checks ?? [];
319
- 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`);
320
351
  if (args.agent) {
321
352
  const wrote = addAgentLine(root);
322
353
  io.out(wrote.length ? `added the read-first line to ${wrote.join(', ')}.\n` : 'your agent notes already mention readfirst.\n');
323
- io.out('Using Claude Code? `readfirst hook --install` hands it the read list automatically before each edit.\n');
324
354
  }
325
- 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`);
326
363
  return 0;
327
364
  }
328
365
  if (cmd === 'prompt') {
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)');