readfirst 0.8.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 +31 -1
- package/package.json +1 -1
- package/src/cli.mjs +46 -9
- package/src/map.mjs +71 -5
- package/src/readlist.mjs +7 -4
package/README.md
CHANGED
|
@@ -10,16 +10,27 @@ 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`.
|
|
16
18
|
|
|
17
19
|
```sh
|
|
18
|
-
npx readfirst init --agent # first map from your folders, checks from your scripts,
|
|
20
|
+
npx readfirst init --agent --ci # first map from your folders, checks from your scripts, the line for CLAUDE.md / AGENTS.md,
|
|
21
|
+
# and the GitHub Action that comments on PRs and keeps the map in step
|
|
19
22
|
npx readfirst prompt # a brief for your coding agent to fill in titles, checks, risks and rules
|
|
20
23
|
npx readfirst check # every tracked file is in exactly one group (or ignored); rules point at real files
|
|
21
24
|
```
|
|
22
25
|
|
|
26
|
+
In a monorepo, each workspace package with its own `package.json` becomes a group with its own checks
|
|
27
|
+
(`pnpm --filter @acme/db test`, `yarn workspace …`, `npm test -w …`), so `verify` runs that package's tests, not the
|
|
28
|
+
whole repository's, and its description becomes the group's title.
|
|
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
|
+
|
|
23
34
|
Commit `.readfirst/map.json`. From then on:
|
|
24
35
|
|
|
25
36
|
```sh
|
|
@@ -68,6 +79,8 @@ choose to).
|
|
|
68
79
|
npx readfirst view # the map as a city in one HTML page: .readfirst/local/view.html
|
|
69
80
|
```
|
|
70
81
|
|
|
82
|
+

|
|
83
|
+
|
|
71
84
|
Groups are neighbourhoods, files are buildings (taller when more code depends on them), and traffic between
|
|
72
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
|
|
73
86
|
building to see its read list. The page loads three.js from a CDN.
|
|
@@ -191,6 +204,9 @@ runs. If your branch protection requires checks, pass a GitHub App or personal t
|
|
|
191
204
|
map can't quietly fall behind the code (run it in CI).
|
|
192
205
|
- **risks**: what a newcomer would get wrong. Leave out `files` when the risk covers the whole group.
|
|
193
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.
|
|
194
210
|
|
|
195
211
|
Tests are found from imports: a test that imports the file directly, or through other files, is listed.
|
|
196
212
|
Imports are read for JavaScript/TypeScript, Python, Go, Rust, Elixir, C#, Java, Kotlin, Ruby (including Rails autoloaded
|
|
@@ -200,6 +216,20 @@ constants) and PHP (through composer's PSR-4 map). `init` suggests the checks th
|
|
|
200
216
|
|
|
201
217
|
readfirst needs Node 20 or newer to run, whatever language the project is in. `npx` comes with Node.
|
|
202
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
|
+
|
|
203
233
|
## License
|
|
204
234
|
|
|
205
235
|
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "readfirst",
|
|
3
|
-
"version": "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/cli.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// readfirst: what to read and check before changing a file.
|
|
2
|
-
// readfirst init [--agent]
|
|
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,
|
|
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]
|
|
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
|
|
319
|
-
|
|
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(`
|
|
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
|
@@ -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.
|
|
@@ -160,9 +185,44 @@ export function draftMap(files, read = () => null) {
|
|
|
160
185
|
const hasNested = [...byFolder.values()].some((slot) => slot.pattern?.startsWith(`${root}/`) && slot.pattern !== `${root}/**`);
|
|
161
186
|
if (covered && hasNested) covered.files = covered.files.filter((p) => p !== `${root}/**`).concat(loose);
|
|
162
187
|
}
|
|
188
|
+
// A folder that is a workspace package of its own (packages/db/package.json) gets that package's checks, run through the
|
|
189
|
+
// package manager's workspace filter, so verify runs the package's tests instead of the whole repository's. Its title
|
|
190
|
+
// comes from the package's description when it has one.
|
|
191
|
+
for (const g of groups.values()) {
|
|
192
|
+
const dir = g.files.length === 1 && /^(.+)\/\*\*$/u.exec(g.files[0])?.[1];
|
|
193
|
+
if (!dir || !files.includes(`${dir}/package.json`)) continue;
|
|
194
|
+
let pkg;
|
|
195
|
+
try { pkg = JSON.parse(read(`${dir}/package.json`) ?? ''); } catch { continue; }
|
|
196
|
+
const checks = workspaceChecks(files, dir, pkg);
|
|
197
|
+
if (checks.length) g.checks = checks;
|
|
198
|
+
const about = typeof pkg?.description === 'string' ? pkg.description.trim().replace(/\s+/gu, ' ') : '';
|
|
199
|
+
if (about && g.title.startsWith('TODO:')) g.title = `${typeof pkg.name === 'string' ? `${pkg.name}: ` : ''}${about}`.slice(0, 200);
|
|
200
|
+
}
|
|
163
201
|
return { format: MAP_FORMAT, groups: [...groups.values()].sort((a, b) => a.id.localeCompare(b.id)), rules: [], ignore: [...ignore].sort() };
|
|
164
202
|
}
|
|
165
203
|
|
|
204
|
+
const packageManager = (has) => (has.has('pnpm-lock.yaml') ? 'pnpm' : has.has('yarn.lock') ? 'yarn' : has.has('bun.lockb') || has.has('bun.lock') ? 'bun' : 'npm');
|
|
205
|
+
const PLACEHOLDER = /no test specified/u;
|
|
206
|
+
// The scripts worth running as checks, in order, with what each covers.
|
|
207
|
+
const scriptChecks = (scripts, run) => [
|
|
208
|
+
...(typeof scripts.test === 'string' && !PLACEHOLDER.test(scripts.test) ? [{ run: run('test'), why: 'the test suite' }] : []),
|
|
209
|
+
...['typecheck', 'type-check', 'check-types', 'lint'].filter((n) => typeof scripts[n] === 'string').map((n) => ({ run: run(n), why: n === 'lint' ? 'lint rules' : 'types' })),
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
// One workspace package's own scripts, scoped to it: pnpm --filter, yarn workspace, bun --filter or npm -w.
|
|
213
|
+
export function workspaceChecks(files, dir, pkg) {
|
|
214
|
+
const scripts = pkg?.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {};
|
|
215
|
+
const name = typeof pkg?.name === 'string' && pkg.name ? pkg.name : null;
|
|
216
|
+
const pm = packageManager(new Set(files));
|
|
217
|
+
const run = (script) => {
|
|
218
|
+
if (pm === 'pnpm') return `pnpm --filter ${name ?? `./${dir}`} ${script === 'test' ? 'test' : `run ${script}`}`;
|
|
219
|
+
if (pm === 'yarn' && name) return `yarn workspace ${name} ${script === 'test' ? 'test' : `run ${script}`}`;
|
|
220
|
+
if (pm === 'bun' && name) return `bun --filter ${name} run ${script}`;
|
|
221
|
+
return script === 'test' ? `npm test -w ${dir}` : `npm run ${script} -w ${dir}`;
|
|
222
|
+
};
|
|
223
|
+
return scriptChecks(scripts, run).map((c) => ({ ...c, why: `${c.why} of ${name ?? dir}` }));
|
|
224
|
+
}
|
|
225
|
+
|
|
166
226
|
// Repairs the two ways a good map goes stale as code moves, without guessing at meaning:
|
|
167
227
|
// - a tracked file in no group joins the group of its nearest neighbour (same folder, else the closest parent folder);
|
|
168
228
|
// a new folder whose files are all unplaced joins as "<folder>/**", a lone new file by its path;
|
|
@@ -191,6 +251,14 @@ export function fixMap(map, files) {
|
|
|
191
251
|
if (keep.length) r.files = keep;
|
|
192
252
|
}
|
|
193
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
|
+
|
|
194
262
|
const ignored = matcher(out.ignore ?? []);
|
|
195
263
|
const owner = (f) => out.groups.find((g) => matcher(g.files)(f));
|
|
196
264
|
const unplaced = files.filter((f) => f !== MAP_PATH && !ignored(f) && !owner(f));
|
|
@@ -244,11 +312,9 @@ export function draftChecks(files, read = () => null) {
|
|
|
244
312
|
if (has.has('package.json')) {
|
|
245
313
|
let scripts = {};
|
|
246
314
|
try { scripts = JSON.parse(read('package.json') ?? '{}').scripts ?? {}; } catch { scripts = {}; }
|
|
247
|
-
const pm =
|
|
315
|
+
const pm = packageManager(has);
|
|
248
316
|
const run = (name) => (pm === 'npm' ? (name === 'test' ? 'npm test' : `npm run ${name}`) : `${pm} ${name === 'test' && pm !== 'bun' ? 'test' : `run ${name}`}`);
|
|
249
|
-
const
|
|
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');
|
|
317
|
+
for (const c of scriptChecks(scripts, run)) add(c.run, c.why);
|
|
252
318
|
}
|
|
253
319
|
const makefile = has.has('Makefile') ? read('Makefile') ?? '' : '';
|
|
254
320
|
if (/^test:/mu.test(makefile)) add('make test', 'the test suite (Makefile)');
|
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
|
}
|