readfirst 0.9.0 → 0.10.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
@@ -10,6 +10,8 @@ knowledge in one file next to the code and hands it out, file by file, at the mo
10
10
 
11
11
  No server, no account, no model calls. It reads your git checkout and `.readfirst/map.json`.
12
12
 
13
+ Filled maps for real repositories (vercel/ai, cal.com, excalidraw and two more), with sample output: [examples/](examples/).
14
+
13
15
  ## Start
14
16
 
15
17
  Any language; needs Node 20+ for `npx`.
@@ -25,6 +27,10 @@ In a monorepo, each workspace package with its own `package.json` becomes a grou
25
27
  (`pnpm --filter @acme/db test`, `yarn workspace …`, `npm test -w …`), so `verify` runs that package's tests, not the
26
28
  whole repository's, and its description becomes the group's title.
27
29
 
30
+ The first map is the part nobody gets to. [readfirst-app](https://readfirst-cloud.vercel.app/) can write it for you: it
31
+ reads the repository and your notes, and opens the map as a pull request with the file and line behind every risk.
32
+ Free once per account; see [Hosted](#hosted-readfirst-app).
33
+
28
34
  Commit `.readfirst/map.json`. From then on:
29
35
 
30
36
  ```sh
@@ -73,6 +79,8 @@ choose to).
73
79
  npx readfirst view # the map as a city in one HTML page: .readfirst/local/view.html
74
80
  ```
75
81
 
82
+ ![vercel/ai as a city: each workspace package is a neighbourhood, and the panel shows the rules for one file](docs/readfirst-city.jpg)
83
+
76
84
  Groups are neighbourhoods, files are buildings (taller when more code depends on them), and traffic between
77
85
  neighbourhoods is the imports between them. A red-lit building has a risk on it; an amber one is under a rule. Point at a
78
86
  building to see its read list. The page loads three.js from a CDN.
@@ -196,6 +204,9 @@ runs. If your branch protection requires checks, pass a GitHub App or personal t
196
204
  map can't quietly fall behind the code (run it in CI).
197
205
  - **risks**: what a newcomer would get wrong. Leave out `files` when the risk covers the whole group.
198
206
  - **rules**: promises the code must keep. A rule can span groups.
207
+ - **hotspots** (optional, usually written by a tool): functions where risky code meets a rule, as
208
+ `{ "file", "symbol", "line", "labels": ["db-write", "money"], "rules"?: [...], "note"? }`. `read` lists the ones in the
209
+ file being changed under "Hot spots in this code"; `fix` drops those whose file is gone.
199
210
 
200
211
  Tests are found from imports: a test that imports the file directly, or through other files, is listed.
201
212
  Imports are read for JavaScript/TypeScript, Python, Go, Rust, Elixir, C#, Java, Kotlin, Ruby (including Rails autoloaded
@@ -205,6 +216,20 @@ constants) and PHP (through composer's PSR-4 map). `init` suggests the checks th
205
216
 
206
217
  readfirst needs Node 20 or newer to run, whatever language the project is in. `npx` comes with Node.
207
218
 
219
+ ## Hosted (readfirst-app)
220
+
221
+ Everything above is free and stays free. The GitHub App adds the parts that take a person's time:
222
+
223
+ - **First map:** install [readfirst-app](https://github.com/apps/readfirst-app/installations/new) on a repository and
224
+ comment `/readfirst fill` on any issue. 5–20 minutes later a pull request arrives with the map, each risk and rule
225
+ citing the file and line it came from. Free once per account, for repositories up to 200 files.
226
+ - **Pro, $19/month:** fills for up to 3 private repositories that also read where the code broke before and the known
227
+ traps of its libraries; after each merge, a small PR when the map needs it; on every pull request, a comment on the
228
+ files it touches that broke before. [Get Pro](https://readfirst-cloud.vercel.app/#pricing).
229
+
230
+ The CLI, hook, MCP server and Action never send your code anywhere. What the app reads and keeps is listed on
231
+ [the site](https://readfirst-cloud.vercel.app/#your-code).
232
+
208
233
  ## License
209
234
 
210
235
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "readfirst",
3
- "version": "0.9.0",
3
+ "version": "0.10.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/map.mjs CHANGED
@@ -42,11 +42,35 @@ const checkList = (v, where, p) => {
42
42
  });
43
43
  };
44
44
 
45
+ // Hot spots: functions where code that does something risky (writes the database, moves money, calls out) meets a rule.
46
+ // Optional; written by tools, read by `read`. Each names a tracked file, a symbol in it and the line it starts on.
47
+ export const MAX_HOTSPOTS = 500;
48
+ function hotspotProblems(map, files, p) {
49
+ if (!Array.isArray(map.hotspots)) { p.push('hotspots: a list'); return; }
50
+ if (map.hotspots.length > MAX_HOTSPOTS) p.push(`hotspots: at most ${MAX_HOTSPOTS}`);
51
+ const ruleIds = new Set((Array.isArray(map.rules) ? map.rules : []).map((r) => r?.id));
52
+ const tracked = new Set(files);
53
+ map.hotspots.forEach((h, i) => {
54
+ const at = `hotspots[${i}]`;
55
+ if (!isRecord(h)) { p.push(`${at}: { file, symbol, line, labels, rules?, note? }`); return; }
56
+ only(h, ['file', 'symbol', 'line', 'labels', 'rules', 'note'], at, p);
57
+ if (typeof h.file !== 'string' || !h.file) p.push(`${at}.file: a path`);
58
+ else if (!tracked.has(h.file)) p.push(`${at}.file: ${h.file} is not a tracked file`);
59
+ if (!text(h.symbol, 200)) p.push(`${at}.symbol: text`);
60
+ if (!Number.isInteger(h.line) || h.line < 1) p.push(`${at}.line: a line number`);
61
+ const labels = strings(h.labels, `${at}.labels`, p, 64);
62
+ if (!labels.length) p.push(`${at}.labels: at least one label`);
63
+ for (const l of labels) if (!ID.test(l)) p.push(`${at}.labels: ${l}: lowercase letters, digits and "-"`);
64
+ if (h.rules !== undefined) for (const r of strings(h.rules, `${at}.rules`, p, 64)) if (!ruleIds.has(r)) p.push(`${at}.rules: no rule ${r}`);
65
+ if (h.note !== undefined && !text(h.note, 500)) p.push(`${at}.note: text`);
66
+ });
67
+ }
68
+
45
69
  // Problems with the map against the list of tracked files. An empty list means the map is good.
46
70
  export function mapProblems(map, files, { placement = true } = {}) {
47
71
  const p = [];
48
72
  if (!isRecord(map)) return ['map: a JSON object'];
49
- only(map, ['format', 'groups', 'rules', 'ignore'], 'map', p);
73
+ only(map, ['format', 'groups', 'rules', 'ignore', 'hotspots'], 'map', p);
50
74
  if (map.format !== MAP_FORMAT) p.push(`format: "${MAP_FORMAT}"`);
51
75
  if (!Array.isArray(map.groups) || !map.groups.length) return [...p, 'groups: at least one group'];
52
76
  const ids = new Set();
@@ -90,6 +114,7 @@ export function mapProblems(map, files, { placement = true } = {}) {
90
114
  }
91
115
  }
92
116
  if (map.ignore !== undefined) strings(map.ignore, 'ignore', p);
117
+ if (map.hotspots !== undefined) hotspotProblems(map, files, p);
93
118
  if (p.length || !placement) return p;
94
119
 
95
120
  // Placement: each tracked file in exactly one group or ignored; each group pattern matches something.
@@ -226,6 +251,14 @@ export function fixMap(map, files) {
226
251
  if (keep.length) r.files = keep;
227
252
  }
228
253
 
254
+ if (Array.isArray(out.hotspots)) {
255
+ const tracked = new Set(files);
256
+ const gone = out.hotspots.filter((h) => !tracked.has(h?.file));
257
+ for (const h of gone) changes.push(`hotspots: dropped ${h?.symbol} in ${h?.file} (file gone)`);
258
+ out.hotspots = out.hotspots.filter((h) => tracked.has(h?.file));
259
+ if (!out.hotspots.length) delete out.hotspots;
260
+ }
261
+
229
262
  const ignored = matcher(out.ignore ?? []);
230
263
  const owner = (f) => out.groups.find((g) => matcher(g.files)(f));
231
264
  const unplaced = files.filter((f) => f !== MAP_PATH && !ignored(f) && !owner(f));
package/src/readlist.mjs CHANGED
@@ -9,7 +9,7 @@ const TEST = /(^|\/)(?:tests?|__tests__|spec|e2e)\/|\.(?:test|spec)\.[cm]?[jt]sx
9
9
  export const isTest = (path) => TEST.test(path);
10
10
 
11
11
  // The read list for no files, so `diff --json` is always JSON.
12
- export const emptyList = (commit = null) => ({ format: READ_LIST_FORMAT, commit, targets: [], rules: [], risks: [], checks: [], tests: [], usedBy: [], usedByIndirectly: 0, notCovered: [], truncated: { tests: 0, usedBy: 0 } });
12
+ export const emptyList = (commit = null) => ({ format: READ_LIST_FORMAT, commit, targets: [], rules: [], risks: [], hotspots: [], checks: [], tests: [], usedBy: [], usedByIndirectly: 0, notCovered: [], truncated: { tests: 0, usedBy: 0 } });
13
13
 
14
14
  export function readList({ map, files, imports, targets, commit = null, limit = 30 }) {
15
15
  if (!targets.length || targets.some((t) => !t.trim())) throw new Error('give at least one file path, none empty');
@@ -18,8 +18,8 @@ export function readList({ map, files, imports, targets, commit = null, limit =
18
18
  const importers = new Map();
19
19
  for (const e of imports) importers.set(e.to, [...(importers.get(e.to) ?? []), e]);
20
20
 
21
- const out = { format: READ_LIST_FORMAT, commit, targets: [], rules: [], risks: [], checks: [], tests: [], usedBy: [], usedByIndirectly: 0, notCovered: [] };
22
- const seen = { rules: new Map(), risks: new Map(), checks: new Map(), tests: new Map(), usedBy: new Map() };
21
+ const out = { format: READ_LIST_FORMAT, commit, targets: [], rules: [], risks: [], hotspots: [], checks: [], tests: [], usedBy: [], usedByIndirectly: 0, notCovered: [] };
22
+ const seen = { rules: new Map(), risks: new Map(), hotspots: new Map(), checks: new Map(), tests: new Map(), usedBy: new Map() };
23
23
  const note = (kind, key, item, target) => {
24
24
  const have = seen[kind].get(key);
25
25
  if (have) { if (!have.for.includes(target)) have.for.push(target); return; }
@@ -52,6 +52,7 @@ export function readList({ map, files, imports, targets, commit = null, limit =
52
52
  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);
53
53
  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);
54
54
  }
55
+ for (const h of map.hotspots ?? []) if (h.file === target) note('hotspots', `${h.file}\0${h.symbol}\0${h.line}`, { file: h.file, symbol: h.symbol, line: h.line, labels: h.labels, rules: h.rules ?? [], note: h.note ?? null }, target);
55
56
  for (const rule of map.rules ?? []) {
56
57
  if (!matcher(rule.files)(target)) continue;
57
58
  note('rules', rule.id, { id: rule.id, rule: rule.rule, why: rule.why ?? null, files: rule.files }, target);
@@ -73,6 +74,7 @@ export function readList({ map, files, imports, targets, commit = null, limit =
73
74
  }
74
75
  if (isTest(target)) note('tests', target, { path: target, via: null }, target);
75
76
  }
77
+ out.hotspots.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
76
78
  for (const u of out.usedBy) indirect.delete(u.path);
77
79
  out.usedByIndirectly = indirect.size;
78
80
  out.tests.sort((a, b) => Number(a.via !== null) - Number(b.via !== null) || a.path.localeCompare(b.path));
@@ -97,6 +99,7 @@ export function toMarkdown(list, { checklist = false, heading } = {}) {
97
99
  };
98
100
  section('Rules that must keep holding', list.rules, (r) => `- **${r.id}**: ${r.rule}${r.why ? ` — why: ${r.why}` : ''}${forWhom(r)}`);
99
101
  section('Watch out', list.risks, (r) => `- ${r.text}${forWhom(r, `group ${r.group}`)}`);
102
+ section('Hot spots in this code', list.hotspots ?? [], (h) => `- \`${h.symbol}\` (\`${h.file}:${h.line}\`): ${h.labels.join(', ')}${h.rules.length ? `, under ${h.rules.map((r) => `**${r}**`).join(', ')}` : ''}${h.note ? `: ${h.note}` : ''}`);
100
103
  section('Run these checks', list.checks, (c) => `- ${checklist ? '[ ] ' : ''}\`${c.run}\` — ${c.why}${forWhom(c)}`);
101
104
  section('Tests that reach this code', list.tests, (t) => `- \`${t.path}\`${t.via ? ` (through \`${t.via}\`)` : ''}${forWhom(t)}`);
102
105
  if (list.truncated?.tests) lines.splice(lines.length - 1, 0, `- …and ${list.truncated.tests} more`);
@@ -104,7 +107,7 @@ export function toMarkdown(list, { checklist = false, heading } = {}) {
104
107
  if (list.truncated?.usedBy) lines.splice(lines.length - 1, 0, `- …and ${list.truncated.usedBy} more direct users`);
105
108
  if (list.usedByIndirectly) lines.push(`${list.usedByIndirectly} more file(s) use this code indirectly.`, '');
106
109
  section('Not covered by the map', list.notCovered, (n) => `- \`${n.path}\`: ${n.why}`);
107
- if (!list.rules.length && !list.risks.length && !list.checks.length && !list.tests.length && !list.usedBy.length && !list.notCovered.length)
110
+ if (!list.rules.length && !list.risks.length && !(list.hotspots ?? []).length && !list.checks.length && !list.tests.length && !list.usedBy.length && !list.notCovered.length)
108
111
  lines.push('Nothing in the map is tied to these files.', '');
109
112
  return lines.join('\n');
110
113
  }