readfirst 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +133 -2
- package/bin/readfirst.mjs +4 -0
- package/package.json +13 -4
- package/src/action.mjs +91 -0
- package/src/cli.mjs +117 -0
- package/src/imports.mjs +66 -0
- package/src/map.mjs +163 -0
- package/src/prompt.mjs +26 -0
- package/src/readlist.mjs +101 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 jeonghun917
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,134 @@
|
|
|
1
|
-
#
|
|
1
|
+
# readfirst
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**What to read and check before you — or your AI agent — change a file.**
|
|
4
|
+
|
|
5
|
+
Coding agents can follow imports. What they don't know is what your team knows: "amounts here are in cents",
|
|
6
|
+
"this table is read by the mobile app, keep old columns", "run the payment e2e after touching this". readfirst keeps that
|
|
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
|
+
|
|
9
|
+
```text
|
|
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
|
+
```
|
|
32
|
+
|
|
33
|
+
No server, no account, no model calls. It reads your git checkout and `.readfirst/map.json`.
|
|
34
|
+
|
|
35
|
+
## Start
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
npx readfirst init # first map from your folders: one group per folder, media folders ignored
|
|
39
|
+
npx readfirst prompt # a brief for your coding agent to fill in titles, checks, risks and rules
|
|
40
|
+
npx readfirst check # every tracked file is in exactly one group (or ignored); rules point at real files
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Commit `.readfirst/map.json`. From then on:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
npx readfirst read <file>... # before changing files
|
|
47
|
+
npx readfirst diff # everything your branch changed since main
|
|
48
|
+
npx readfirst diff --json # the same, for tools and agents
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Tell your agent to use it
|
|
52
|
+
|
|
53
|
+
Add this to `CLAUDE.md`, `AGENTS.md` or `.cursorrules`:
|
|
54
|
+
|
|
55
|
+
```md
|
|
56
|
+
Before changing a file, run `npx readfirst read <file>` and follow what it says: keep the rules, read the risks, run the
|
|
57
|
+
checks it lists. Before you finish, run `npx readfirst diff` and run every check it lists.
|
|
58
|
+
When you learn something a newcomer would get wrong, add it as a risk in .readfirst/map.json.
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## On every pull request (GitHub Action)
|
|
62
|
+
|
|
63
|
+
```yaml
|
|
64
|
+
# .github/workflows/readfirst.yml
|
|
65
|
+
name: readfirst
|
|
66
|
+
on: pull_request
|
|
67
|
+
permissions:
|
|
68
|
+
contents: read
|
|
69
|
+
pull-requests: write
|
|
70
|
+
jobs:
|
|
71
|
+
readfirst:
|
|
72
|
+
runs-on: ubuntu-latest
|
|
73
|
+
steps:
|
|
74
|
+
- uses: actions/checkout@v4
|
|
75
|
+
- uses: jeonghun917/readfirst@v0
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Each PR gets one comment, updated on every push, that lists:
|
|
79
|
+
- the rules, risks and checks for the files the PR changes, with the checks as boxes to tick;
|
|
80
|
+
- the tests that reach the changed code.
|
|
81
|
+
|
|
82
|
+
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.
|
|
84
|
+
|
|
85
|
+
| Input | Default | Meaning |
|
|
86
|
+
|---|---|---|
|
|
87
|
+
| `fail-on-map-problems` | `true` | `false` reports map problems without failing |
|
|
88
|
+
| `comment` | `true` | `false` writes only the job summary |
|
|
89
|
+
| `limit` | `30` | most tests and users listed per section |
|
|
90
|
+
|
|
91
|
+
On a PR from a fork, the token is read-only. The list then goes to the job summary instead of a comment.
|
|
92
|
+
|
|
93
|
+
## The map
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
{
|
|
97
|
+
"format": "readfirst/map@1",
|
|
98
|
+
"groups": [
|
|
99
|
+
{
|
|
100
|
+
"id": "src-billing",
|
|
101
|
+
"title": "charging cards",
|
|
102
|
+
"files": ["src/billing/**"],
|
|
103
|
+
"checks": [{ "run": "npm test -- billing", "why": "charges" }],
|
|
104
|
+
"risks": [
|
|
105
|
+
{ "text": "amounts are in cents" },
|
|
106
|
+
{ "text": "index re-exports everything; a rename breaks the API", "files": ["src/billing/index.ts"] }
|
|
107
|
+
]
|
|
108
|
+
}
|
|
109
|
+
],
|
|
110
|
+
"rules": [
|
|
111
|
+
{
|
|
112
|
+
"id": "no-double-charge",
|
|
113
|
+
"rule": "a charge runs once per order",
|
|
114
|
+
"why": "refunds are manual",
|
|
115
|
+
"files": ["src/billing/**", "src/api/route.js"],
|
|
116
|
+
"checks": [{ "run": "npm run e2e:pay" }]
|
|
117
|
+
}
|
|
118
|
+
],
|
|
119
|
+
"ignore": ["img/**"]
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
- **files**: a path, `folder/**`, or a `*` glob inside one path segment (`src/*.js`, `**/*.md`).
|
|
124
|
+
- **groups**: every tracked file belongs to exactly one group or to `ignore`. `readfirst check` fails otherwise, so the
|
|
125
|
+
map can't quietly fall behind the code (run it in CI).
|
|
126
|
+
- **risks**: what a newcomer would get wrong. Leave out `files` when the risk covers the whole group.
|
|
127
|
+
- **rules**: promises the code must keep. A rule can span groups.
|
|
128
|
+
|
|
129
|
+
Tests are found from imports: a test that imports the file directly, or through other files, is listed.
|
|
130
|
+
Imports are read for JavaScript/TypeScript (`import`, `export … from`, `import()`, `require`) and Python.
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "readfirst",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": { "type": "git", "url": "git+https://github.com/jeonghun917/readfirst.git" },
|
|
8
|
+
"homepage": "https://github.com/jeonghun917/readfirst#readme",
|
|
9
|
+
"bugs": "https://github.com/jeonghun917/readfirst/issues",
|
|
10
|
+
"bin": { "readfirst": "bin/readfirst.mjs" },
|
|
11
|
+
"files": ["bin", "src", "README.md"],
|
|
12
|
+
"engines": { "node": ">=20" },
|
|
13
|
+
"scripts": { "test": "node --test test/*.test.mjs" },
|
|
14
|
+
"keywords": ["ai", "agents", "claude-code", "cursor", "code-review", "impact-analysis", "monorepo"]
|
|
15
|
+
}
|
package/src/action.mjs
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// The GitHub Action: on a pull request, one comment with the read list for the files the PR changes (kept up to date
|
|
2
|
+
// on each push), and a failed check when the map no longer covers the code. Off a pull request it only checks the map.
|
|
3
|
+
// The changed files come from the GitHub API, so any checkout depth works.
|
|
4
|
+
import { appendFileSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { readMapFile, readText, repoFiles } from './cli.mjs';
|
|
6
|
+
import { importEdges } from './imports.mjs';
|
|
7
|
+
import { MAP_PATH, mapProblems } from './map.mjs';
|
|
8
|
+
import { readList, toMarkdown } from './readlist.mjs';
|
|
9
|
+
|
|
10
|
+
export const MARKER = '<!-- readfirst:pr-comment -->';
|
|
11
|
+
const MAX_COMMENT = 60000;
|
|
12
|
+
|
|
13
|
+
async function api(fetchImpl, env, method, path, body) {
|
|
14
|
+
const res = await fetchImpl(`${env.GITHUB_API_URL ?? 'https://api.github.com'}${path}`, {
|
|
15
|
+
method,
|
|
16
|
+
headers: { authorization: `Bearer ${env.INPUT_TOKEN}`, accept: 'application/vnd.github+json', 'x-github-api-version': '2022-11-28', ...(body ? { 'content-type': 'application/json' } : {}) },
|
|
17
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
18
|
+
});
|
|
19
|
+
if (!res.ok) throw new Error(`GitHub API ${method} ${path}: ${res.status} ${(await res.text()).slice(0, 300)}`);
|
|
20
|
+
return res.status === 204 ? null : res.json();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function pages(fetchImpl, env, path) {
|
|
24
|
+
const all = [];
|
|
25
|
+
for (let page = 1; page <= 30; page++) {
|
|
26
|
+
const batch = await api(fetchImpl, env, 'GET', `${path}${path.includes('?') ? '&' : '?'}per_page=100&page=${page}`);
|
|
27
|
+
all.push(...batch);
|
|
28
|
+
if (batch.length < 100) break;
|
|
29
|
+
}
|
|
30
|
+
return all;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function commentBody({ list, problems, removed, commit }) {
|
|
34
|
+
const parts = [MARKER, '## readfirst', ''];
|
|
35
|
+
if (problems.length) {
|
|
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
|
+
parts.push(...problems.slice(0, 20).map((p) => `- ${p}`), ...(problems.length > 20 ? [`- …and ${problems.length - 20} more`] : []), '');
|
|
38
|
+
}
|
|
39
|
+
if (list) parts.push(toMarkdown(list, { checklist: true, heading: null }));
|
|
40
|
+
else if (!problems.length) parts.push('No changed files to look up.', '');
|
|
41
|
+
if (removed.length) parts.push(`Removed in this PR: ${removed.map((f) => `\`${f}\``).join(', ')}`, '');
|
|
42
|
+
parts.push(`<sub>From \`${MAP_PATH}\`${commit ? ` at ${commit.slice(0, 7)}` : ''}. Tick each check once it has run.</sub>`);
|
|
43
|
+
let body = parts.join('\n');
|
|
44
|
+
if (body.length > MAX_COMMENT) body = `${body.slice(0, MAX_COMMENT)}\n\n…cut here: run \`npx readfirst diff\` for the whole list.`;
|
|
45
|
+
return body;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function runAction({ env = process.env, fetchImpl = fetch, root = env.GITHUB_WORKSPACE ?? process.cwd(), log = (s) => process.stdout.write(`${s}\n`) } = {}) {
|
|
49
|
+
const files = repoFiles(root);
|
|
50
|
+
const map = readMapFile(root);
|
|
51
|
+
const problems = mapProblems(map, files);
|
|
52
|
+
const failOnProblems = (env['INPUT_FAIL-ON-MAP-PROBLEMS'] ?? env.INPUT_FAIL_ON_MAP_PROBLEMS ?? 'true') !== 'false';
|
|
53
|
+
const event = env.GITHUB_EVENT_PATH ? JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, 'utf8')) : {};
|
|
54
|
+
const pr = event.pull_request;
|
|
55
|
+
const summary = (s) => { if (env.GITHUB_STEP_SUMMARY) appendFileSync(env.GITHUB_STEP_SUMMARY, `${s}\n`); };
|
|
56
|
+
|
|
57
|
+
if (!pr) {
|
|
58
|
+
log(problems.length ? `${MAP_PATH} has ${problems.length} problem(s):\n- ${problems.join('\n- ')}` : `${MAP_PATH} is good: ${files.length} files.`);
|
|
59
|
+
return { ok: !(problems.length && failOnProblems), problems };
|
|
60
|
+
}
|
|
61
|
+
const repo = env.GITHUB_REPOSITORY;
|
|
62
|
+
const changed = await pages(fetchImpl, env, `/repos/${repo}/pulls/${pr.number}/files`);
|
|
63
|
+
const removed = changed.filter((f) => f.status === 'removed').map((f) => f.filename);
|
|
64
|
+
const targets = changed.filter((f) => f.status !== 'removed').map((f) => f.filename);
|
|
65
|
+
// A broken map still gets a comment (the problems), but no read list from it.
|
|
66
|
+
const list = targets.length && !problems.length
|
|
67
|
+
? readList({ map, files, imports: importEdges(files, readText(root)), targets, commit: pr.head?.sha ?? null, limit: Number(env.INPUT_LIMIT || 30) })
|
|
68
|
+
: null;
|
|
69
|
+
const body = commentBody({ list, problems, removed, commit: pr.head?.sha ?? null });
|
|
70
|
+
summary(body);
|
|
71
|
+
|
|
72
|
+
if ((env.INPUT_COMMENT ?? 'true') !== 'false') {
|
|
73
|
+
const comments = await pages(fetchImpl, env, `/repos/${repo}/issues/${pr.number}/comments`);
|
|
74
|
+
const mine = comments.find((c) => typeof c.body === 'string' && c.body.startsWith(MARKER));
|
|
75
|
+
try {
|
|
76
|
+
if (mine) await api(fetchImpl, env, 'PATCH', `/repos/${repo}/issues/comments/${mine.id}`, { body });
|
|
77
|
+
else await api(fetchImpl, env, 'POST', `/repos/${repo}/issues/${pr.number}/comments`, { body });
|
|
78
|
+
log(`${mine ? 'updated' : 'posted'} the readfirst comment on #${pr.number}`);
|
|
79
|
+
} catch (e) {
|
|
80
|
+
// A PR from a fork gets a read-only token: the read list stays in the job summary.
|
|
81
|
+
if (!/: 403 /u.test(e.message)) throw e;
|
|
82
|
+
log(`::warning::readfirst could not comment (read-only token, e.g. a PR from a fork); the read list is in the job summary.`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (problems.length) log(`${MAP_PATH} has ${problems.length} problem(s):\n- ${problems.join('\n- ')}`);
|
|
86
|
+
return { ok: !(problems.length && failOnProblems), problems, list, body };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (process.argv[1]?.endsWith('/src/action.mjs')) {
|
|
90
|
+
runAction().then((r) => { process.exitCode = r.ok ? 0 : 1; }, (e) => { process.stderr.write(`::error::readfirst: ${e.message}\n`); process.exitCode = 1; });
|
|
91
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// readfirst: what to read and check before changing a file.
|
|
2
|
+
// readfirst init write a first .readfirst/map.json from the file tree
|
|
3
|
+
// readfirst prompt the brief for a coding agent to fill in titles, checks, risks and rules
|
|
4
|
+
// readfirst check the map covers every tracked file and is well formed
|
|
5
|
+
// readfirst read <file>... the read list for files you are about to change
|
|
6
|
+
// readfirst diff [--base <ref>] the read list for the files changed since <ref> (default: origin/main, else main)
|
|
7
|
+
// Options: --root <dir> (default: the current git repository), --json, --limit <n>.
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { importEdges } from './imports.mjs';
|
|
12
|
+
import { assertMap, draftMap, MAP_PATH, mapProblems } from './map.mjs';
|
|
13
|
+
import { agentBrief } from './prompt.mjs';
|
|
14
|
+
import { emptyList, readList, toMarkdown } from './readlist.mjs';
|
|
15
|
+
|
|
16
|
+
const HELP = `readfirst — what to read and check before changing a file
|
|
17
|
+
|
|
18
|
+
readfirst init write a first ${MAP_PATH} from the file tree
|
|
19
|
+
readfirst prompt brief for your coding agent to fill in the map
|
|
20
|
+
readfirst check check the map covers every tracked file
|
|
21
|
+
readfirst read <file>... read list for files you are about to change
|
|
22
|
+
readfirst diff [--base <ref>] read list for files changed since <ref>
|
|
23
|
+
|
|
24
|
+
Options: --root <dir>, --json, --limit <n>, --force (init: replace the map)`;
|
|
25
|
+
|
|
26
|
+
function parse(argv) {
|
|
27
|
+
const args = { _: [], json: false };
|
|
28
|
+
for (let i = 0; i < argv.length; i++) {
|
|
29
|
+
const a = argv[i];
|
|
30
|
+
if (a === '--json') args.json = true;
|
|
31
|
+
else if (a === '--force') args.force = true;
|
|
32
|
+
else if (a === '-h' || a === '--help') args.help = true;
|
|
33
|
+
else if (a.startsWith('--')) { const v = argv[++i]; if (v === undefined) throw new Error(`${a} needs a value`); args[a.slice(2)] = v; }
|
|
34
|
+
else args._.push(a);
|
|
35
|
+
}
|
|
36
|
+
return args;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const git = (root, ...a) => execFileSync('git', ['-C', root, ...a], { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
|
|
40
|
+
|
|
41
|
+
export function repoFiles(root) {
|
|
42
|
+
// Tracked files plus new files not ignored by .gitignore, so a file being added can be looked up before its commit.
|
|
43
|
+
return git(root, 'ls-files', '-z', '--cached', '--others', '--exclude-standard').split('\0').filter(Boolean)
|
|
44
|
+
.filter((f) => existsSync(join(root, f))).sort();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const readText = (root) => (path) => {
|
|
48
|
+
try { return readFileSync(join(root, path), 'utf8'); } catch { return null; }
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export function readMapFile(root) {
|
|
52
|
+
const at = join(root, MAP_PATH);
|
|
53
|
+
if (!existsSync(at)) throw new Error(`no ${MAP_PATH} here: run \`readfirst init\` first`);
|
|
54
|
+
try { return JSON.parse(readFileSync(at, 'utf8')); } catch (e) { throw new Error(`${MAP_PATH} is not valid JSON: ${e.message}`); }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function loadMap(root, files) {
|
|
58
|
+
return assertMap(readMapFile(root), files);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function changedFiles(root, base) {
|
|
62
|
+
const ref = base ?? ['origin/main', 'main', 'origin/master', 'master'].find((r) => { try { git(root, 'rev-parse', '--verify', '--quiet', r); return true; } catch { return false; } });
|
|
63
|
+
if (!ref) throw new Error('no base branch found: pass --base <ref>');
|
|
64
|
+
const since = git(root, 'merge-base', ref, 'HEAD').trim();
|
|
65
|
+
const names = new Set([
|
|
66
|
+
...git(root, 'diff', '--name-only', since).split('\n'),
|
|
67
|
+
...git(root, 'ls-files', '--others', '--exclude-standard').split('\n'),
|
|
68
|
+
].filter(Boolean));
|
|
69
|
+
return { ref, files: [...names].sort() };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function main(argv, io = { out: (s) => process.stdout.write(s), err: (s) => process.stderr.write(s) }) {
|
|
73
|
+
const args = parse(argv);
|
|
74
|
+
const [cmd, ...rest] = args._;
|
|
75
|
+
if (args.help || !cmd) { io.out(`${HELP}\n`); return cmd || args.help ? 0 : 2; }
|
|
76
|
+
const root = args.root ?? git(process.cwd(), 'rev-parse', '--show-toplevel').trim();
|
|
77
|
+
const files = repoFiles(root);
|
|
78
|
+
const commit = (() => { try { return git(root, 'rev-parse', 'HEAD').trim(); } catch { return null; } })();
|
|
79
|
+
|
|
80
|
+
if (cmd === 'init') {
|
|
81
|
+
const at = join(root, MAP_PATH);
|
|
82
|
+
if (existsSync(at) && args.force !== true) throw new Error(`${MAP_PATH} already exists (pass --force to replace it)`);
|
|
83
|
+
const map = draftMap(files);
|
|
84
|
+
mkdirSync(join(root, '.readfirst'), { recursive: true });
|
|
85
|
+
writeFileSync(at, `${JSON.stringify(map, null, 2)}\n`);
|
|
86
|
+
io.out(`wrote ${MAP_PATH}: ${map.groups.length} groups, ${map.ignore.length} ignored folders.\nNext: run \`readfirst prompt\` and give the brief to your coding agent, or fill in titles, checks, risks and rules by hand.\n`);
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
if (cmd === 'prompt') {
|
|
90
|
+
io.out(agentBrief(loadMap(root, files)));
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
if (cmd === 'check') {
|
|
94
|
+
const problems = mapProblems(readMapFile(root), files);
|
|
95
|
+
if (args.json) io.out(`${JSON.stringify({ ok: !problems.length, problems }, null, 2)}\n`);
|
|
96
|
+
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
|
+
return problems.length ? 1 : 0;
|
|
98
|
+
}
|
|
99
|
+
if (cmd === 'read' || cmd === 'diff') {
|
|
100
|
+
const map = loadMap(root, files);
|
|
101
|
+
let targets = rest;
|
|
102
|
+
if (cmd === 'diff') {
|
|
103
|
+
const changed = changedFiles(root, args.base);
|
|
104
|
+
targets = changed.files;
|
|
105
|
+
if (!targets.length) {
|
|
106
|
+
io.out(args.json ? `${JSON.stringify(emptyList(commit), null, 2)}\n` : `no files changed since ${changed.ref}.\n`);
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const limit = Number(args.limit ?? 30);
|
|
111
|
+
if (!Number.isInteger(limit) || limit < 1) throw new Error('--limit needs a whole number of 1 or more');
|
|
112
|
+
const list = readList({ map, files, imports: importEdges(files, readText(root)), targets, commit, limit });
|
|
113
|
+
io.out(args.json ? `${JSON.stringify(list, null, 2)}\n` : `${toMarkdown(list)}\n`);
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
throw new Error(`unknown command ${cmd}\n\n${HELP}`);
|
|
117
|
+
}
|
package/src/imports.mjs
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Which tracked file imports which, read from the code. Only imports that resolve to a tracked file count: packages and
|
|
2
|
+
// unresolved specifiers are left out. JavaScript/TypeScript (import, export from, import(), require) and Python
|
|
3
|
+
// (import a.b, from a.b import c, relative from . imports) are read.
|
|
4
|
+
import { posix } from 'node:path';
|
|
5
|
+
|
|
6
|
+
const JS = /\.(?:m?js|cjs|tsx?|jsx|mts|cts|vue|svelte)$/u;
|
|
7
|
+
const PY = /\.py$/u;
|
|
8
|
+
const JS_IMPORT = /\b(?:import|export)\s+(?:type\s+)?(?:[\w*{}\s,$]+?\s+from\s*)?['"]([^'"\n]+)['"]|\bimport\(\s*['"]([^'"\n]+)['"]\s*\)|\brequire\(\s*['"]([^'"\n]+)['"]\s*\)/gu;
|
|
9
|
+
// `from a import (b,\n c)` may span lines inside its parentheses.
|
|
10
|
+
const PY_IMPORT = /^[ \t]*(?:from[ \t]+(\.*[\w.]*)[ \t]+import[ \t]+(\([^)]*\)|[\w*, \t]+)|import[ \t]+([\w., \t]+))/gmu;
|
|
11
|
+
const JS_EXT = ['', '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.mts', '.vue', '.svelte', '/index.js', '/index.ts', '/index.tsx', '/index.mjs'];
|
|
12
|
+
|
|
13
|
+
const lineAt = (body, index) => body.slice(0, index).split('\n').length;
|
|
14
|
+
|
|
15
|
+
export function importEdges(files, read) {
|
|
16
|
+
const tracked = new Set(files);
|
|
17
|
+
const out = [];
|
|
18
|
+
const seen = new Set();
|
|
19
|
+
const add = (from, to, line) => {
|
|
20
|
+
if (!to || to === from || seen.has(`${from}\0${to}`)) return;
|
|
21
|
+
seen.add(`${from}\0${to}`);
|
|
22
|
+
out.push({ from, to, line });
|
|
23
|
+
};
|
|
24
|
+
const pyModule = (dotted, baseDir) => {
|
|
25
|
+
const base = posix.join(baseDir, ...dotted.split('.').filter(Boolean));
|
|
26
|
+
return [`${base}.py`, `${base}/__init__.py`].find((c) => tracked.has(c));
|
|
27
|
+
};
|
|
28
|
+
for (const from of [...files].sort()) {
|
|
29
|
+
if (JS.test(from)) {
|
|
30
|
+
const body = read(from);
|
|
31
|
+
if (body === null) continue;
|
|
32
|
+
for (const m of body.matchAll(JS_IMPORT)) {
|
|
33
|
+
const spec = (m[1] ?? m[2] ?? m[3]).split('?')[0];
|
|
34
|
+
if (!spec.startsWith('.')) continue;
|
|
35
|
+
const base = posix.normalize(posix.join(posix.dirname(from), spec));
|
|
36
|
+
// TypeScript sources import "./x.js" for the file "./x.ts".
|
|
37
|
+
const candidates = [...JS_EXT.map((e) => base + e), base.replace(/\.(m|c)?js$/u, '.$1ts'), base.replace(/\.js$/u, '.tsx')];
|
|
38
|
+
add(from, candidates.find((c) => tracked.has(c)), lineAt(body, m.index));
|
|
39
|
+
}
|
|
40
|
+
} else if (PY.test(from)) {
|
|
41
|
+
const body = read(from);
|
|
42
|
+
if (body === null) continue;
|
|
43
|
+
for (const m of body.matchAll(PY_IMPORT)) {
|
|
44
|
+
const line = lineAt(body, m.index + m[0].search(/\S/u));
|
|
45
|
+
if (m[1] !== undefined) {
|
|
46
|
+
const dots = m[1].match(/^\.*/u)[0].length;
|
|
47
|
+
const mod = m[1].slice(dots);
|
|
48
|
+
// Relative: one dot is this package. Absolute: from the repository root, or from a src/ layout.
|
|
49
|
+
const roots = dots ? [posix.join(posix.dirname(from), ...Array(dots - 1).fill('..'))] : ['', 'src'];
|
|
50
|
+
for (const root of roots) {
|
|
51
|
+
const target = mod ? pyModule(mod, root) : null;
|
|
52
|
+
if (target) { add(from, target, line); }
|
|
53
|
+
// "from pkg import mod" may name sub-modules.
|
|
54
|
+
for (const name of m[2].replace(/[()]|#[^\n]*/gu, '').split(',').map((s) => s.trim().split(/\s+/u)[0]).filter((s) => s && s !== '*'))
|
|
55
|
+
add(from, pyModule(mod ? `${mod}.${name}` : name, root), line);
|
|
56
|
+
if (target) break;
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
for (const name of m[3].split(',').map((s) => s.trim().split(/\s+/u)[0]).filter(Boolean))
|
|
60
|
+
add(from, pyModule(name, '') ?? pyModule(name, 'src'), line);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
package/src/map.mjs
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// The map file: groups of files with what to check and what to watch out for, plus project rules.
|
|
2
|
+
// Paths are repository paths with forward slashes. A pattern is a path, a folder ending in "/**", or a "*" glob
|
|
3
|
+
// within one path segment ("src/*.js", "*.md"). Every tracked file belongs to exactly one group or to "ignore".
|
|
4
|
+
|
|
5
|
+
export const MAP_FORMAT = 'readfirst/map@1';
|
|
6
|
+
export const MAP_PATH = '.readfirst/map.json';
|
|
7
|
+
|
|
8
|
+
const ID = /^[a-z0-9][a-z0-9-]{0,63}$/u;
|
|
9
|
+
const isRecord = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
10
|
+
const text = (v, max = 2000) => typeof v === 'string' && v.trim() !== '' && v.length <= max;
|
|
11
|
+
|
|
12
|
+
export function patternToRegExp(pattern) {
|
|
13
|
+
// Within one segment: `*` is any run of characters, `?` one character; everything else is literal.
|
|
14
|
+
const segment = (s) => s.replace(/[.+^${}()|[\]\\]/gu, '\\$&').replace(/\*/gu, '[^/]*').replace(/\?/gu, '[^/]');
|
|
15
|
+
if (pattern.endsWith('/**')) return new RegExp(`^${segment(pattern.slice(0, -3))}/`, 'u');
|
|
16
|
+
if (pattern === '**') return /^/u;
|
|
17
|
+
const body = pattern.split('**/').map(segment).join('(?:.*/)?');
|
|
18
|
+
return new RegExp(`^${body}$`, 'u');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const matcher = (patterns) => {
|
|
22
|
+
const res = patterns.map(patternToRegExp);
|
|
23
|
+
return (path) => res.some((r) => r.test(path));
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const strings = (v, where, p, max = 500) => {
|
|
27
|
+
if (!Array.isArray(v) || !v.every((s) => text(s, max))) p.push(`${where}: a list of non-empty strings`);
|
|
28
|
+
return Array.isArray(v) ? v.filter((s) => text(s, max)) : [];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const only = (v, keys, where, p) => {
|
|
32
|
+
for (const k of Object.keys(v)) if (!keys.includes(k)) p.push(`${where}.${k}: unknown field`);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const checkList = (v, where, p) => {
|
|
36
|
+
if (v === undefined) return;
|
|
37
|
+
if (!Array.isArray(v)) { p.push(`${where}: a list`); return; }
|
|
38
|
+
v.forEach((c, i) => {
|
|
39
|
+
if (!isRecord(c) || !text(c.run, 500)) { p.push(`${where}[${i}]: { run, why? }`); return; }
|
|
40
|
+
only(c, ['run', 'why'], `${where}[${i}]`, p);
|
|
41
|
+
if (c.why !== undefined && !text(c.why)) p.push(`${where}[${i}].why: text`);
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Problems with the map against the list of tracked files. An empty list means the map is good.
|
|
46
|
+
export function mapProblems(map, files) {
|
|
47
|
+
const p = [];
|
|
48
|
+
if (!isRecord(map)) return ['map: a JSON object'];
|
|
49
|
+
only(map, ['format', 'groups', 'rules', 'ignore'], 'map', p);
|
|
50
|
+
if (map.format !== MAP_FORMAT) p.push(`format: "${MAP_FORMAT}"`);
|
|
51
|
+
if (!Array.isArray(map.groups) || !map.groups.length) return [...p, 'groups: at least one group'];
|
|
52
|
+
const ids = new Set();
|
|
53
|
+
map.groups.forEach((g, i) => {
|
|
54
|
+
const at = `groups[${i}]`;
|
|
55
|
+
if (!isRecord(g)) { p.push(`${at}: an object`); return; }
|
|
56
|
+
only(g, ['id', 'title', 'files', 'checks', 'risks'], at, p);
|
|
57
|
+
if (typeof g.id !== 'string' || !ID.test(g.id)) p.push(`${at}.id: lowercase letters, digits and "-"`);
|
|
58
|
+
else if (ids.has(g.id)) p.push(`${at}.id: ${g.id} is used twice`);
|
|
59
|
+
ids.add(g.id);
|
|
60
|
+
if (!text(g.title, 200)) p.push(`${at}.title: text`);
|
|
61
|
+
if (!strings(g.files, `${at}.files`, p).length) p.push(`${at}.files: at least one path or pattern`);
|
|
62
|
+
checkList(g.checks, `${at}.checks`, p);
|
|
63
|
+
if (g.risks !== undefined) {
|
|
64
|
+
if (!Array.isArray(g.risks)) p.push(`${at}.risks: a list`);
|
|
65
|
+
else g.risks.forEach((r, j) => {
|
|
66
|
+
if (!isRecord(r) || !text(r.text)) { p.push(`${at}.risks[${j}]: { text, files? }`); return; }
|
|
67
|
+
only(r, ['text', 'files'], `${at}.risks[${j}]`, p);
|
|
68
|
+
if (r.files !== undefined) strings(r.files, `${at}.risks[${j}].files`, p);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
if (map.rules !== undefined) {
|
|
73
|
+
if (!Array.isArray(map.rules)) p.push('rules: a list');
|
|
74
|
+
else {
|
|
75
|
+
const ruleIds = new Set();
|
|
76
|
+
map.rules.forEach((r, i) => {
|
|
77
|
+
const at = `rules[${i}]`;
|
|
78
|
+
if (!isRecord(r)) { p.push(`${at}: an object`); return; }
|
|
79
|
+
only(r, ['id', 'rule', 'why', 'files', 'checks'], at, p);
|
|
80
|
+
if (typeof r.id !== 'string' || !ID.test(r.id)) p.push(`${at}.id: lowercase letters, digits and "-"`);
|
|
81
|
+
else if (ruleIds.has(r.id)) p.push(`${at}.id: ${r.id} is used twice`);
|
|
82
|
+
ruleIds.add(r.id);
|
|
83
|
+
if (!text(r.rule)) p.push(`${at}.rule: text`);
|
|
84
|
+
if (r.why !== undefined && !text(r.why)) p.push(`${at}.why: text`);
|
|
85
|
+
if (!strings(r.files, `${at}.files`, p).length) p.push(`${at}.files: at least one path or pattern`);
|
|
86
|
+
checkList(r.checks, `${at}.checks`, p);
|
|
87
|
+
for (const f of Array.isArray(r.files) ? r.files.filter((x) => typeof x === 'string') : [])
|
|
88
|
+
if (!files.some(matcher([f]))) p.push(`${at}.files: ${f} matches no tracked file`);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (map.ignore !== undefined) strings(map.ignore, 'ignore', p);
|
|
93
|
+
if (p.length) return p;
|
|
94
|
+
|
|
95
|
+
// Placement: each tracked file in exactly one group or ignored; each group pattern matches something.
|
|
96
|
+
const ignored = matcher(map.ignore ?? []);
|
|
97
|
+
const groupMatch = map.groups.map((g) => [g.id, matcher(g.files)]);
|
|
98
|
+
const unplaced = [];
|
|
99
|
+
for (const f of files) {
|
|
100
|
+
if (f === MAP_PATH) continue;
|
|
101
|
+
const owners = groupMatch.filter(([, m]) => m(f)).map(([id]) => id);
|
|
102
|
+
if (owners.length > 1) p.push(`${f}: in more than one group (${owners.join(', ')})`);
|
|
103
|
+
else if (!owners.length && !ignored(f)) unplaced.push(f);
|
|
104
|
+
}
|
|
105
|
+
if (unplaced.length) p.push(`${unplaced.length} file(s) in no group and not ignored: ${unplaced.slice(0, 10).join(', ')}${unplaced.length > 10 ? ', ...' : ''}`);
|
|
106
|
+
for (const g of map.groups) for (const pat of g.files)
|
|
107
|
+
if (!files.some(matcher([pat]))) p.push(`groups.${g.id}.files: ${pat} matches no tracked file`);
|
|
108
|
+
return p;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function assertMap(map, files) {
|
|
112
|
+
const p = mapProblems(map, files);
|
|
113
|
+
if (p.length) throw new Error(`${MAP_PATH} has problems:\n- ${p.join('\n- ')}`);
|
|
114
|
+
return map;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const groupOf = (map, path) => map.groups.find((g) => matcher(g.files)(path)) ?? null;
|
|
118
|
+
|
|
119
|
+
// A first map from the file tree alone: one group per top folder (one per sub-folder under src/, lib/ and app/),
|
|
120
|
+
// tests together. Folders of only media or fonts get a group of their own too: code usually loads them, so the risks of
|
|
121
|
+
// that code belong there; "ignore" is left for a person to fill with what nothing depends on. Titles and risks are left
|
|
122
|
+
// for a person or an agent to write.
|
|
123
|
+
const MEDIA = /\.(?:png|jpe?g|gif|webp|svg|ico|mp3|wav|ogg|m4a|flac|mp4|webm|mov|woff2?|ttf|otf|eot|glb|gltf|fbx|blend|zip|pdf)$/iu;
|
|
124
|
+
export function draftMap(files) {
|
|
125
|
+
const nested = new Set(['src', 'lib', 'app', 'packages', 'apps']);
|
|
126
|
+
const keyOf = (path) => {
|
|
127
|
+
const parts = path.split('/');
|
|
128
|
+
if (parts.length === 1) return { key: 'root', pattern: null };
|
|
129
|
+
if (/^(?:tests?|__tests__|spec|e2e)$/u.test(parts[0])) return { key: 'tests', pattern: `${parts[0]}/**` };
|
|
130
|
+
if (nested.has(parts[0]) && parts.length > 2) return { key: `${parts[0]}-${parts[1]}`, pattern: `${parts[0]}/${parts[1]}/**` };
|
|
131
|
+
return { key: parts[0], pattern: `${parts[0]}/**` };
|
|
132
|
+
};
|
|
133
|
+
const groups = new Map();
|
|
134
|
+
const ignore = new Set();
|
|
135
|
+
const byFolder = new Map();
|
|
136
|
+
for (const f of files) {
|
|
137
|
+
if (f === MAP_PATH) continue;
|
|
138
|
+
const { key, pattern } = keyOf(f);
|
|
139
|
+
const slot = byFolder.get(key) ?? { pattern, files: [] };
|
|
140
|
+
slot.files.push(f);
|
|
141
|
+
byFolder.set(key, slot);
|
|
142
|
+
}
|
|
143
|
+
for (const [key, slot] of byFolder) {
|
|
144
|
+
const media = Boolean(slot.pattern) && slot.files.every((f) => MEDIA.test(f));
|
|
145
|
+
const id = key.toLowerCase().replace(/[^a-z0-9-]+/gu, '-').replace(/^-+|-+$/gu, '').slice(0, 64) || 'group';
|
|
146
|
+
const patterns = slot.pattern ? [slot.pattern] : slot.files;
|
|
147
|
+
const prev = groups.get(id);
|
|
148
|
+
const title = media
|
|
149
|
+
? `TODO: what loads the media files in ${slot.pattern} (binary, not read)`
|
|
150
|
+
: `TODO: what ${slot.pattern ?? 'the top-level files'} ${slot.pattern ? 'is' : 'are'} for`;
|
|
151
|
+
groups.set(id, { id, title, files: prev ? [...prev.files, ...patterns] : patterns, checks: [], risks: [] });
|
|
152
|
+
}
|
|
153
|
+
// A nested pattern ("src/a/**") and the parent's loose files ("src/x.js") must not overlap; loose files under a nested root
|
|
154
|
+
// become their own group.
|
|
155
|
+
for (const root of nested) {
|
|
156
|
+
const loose = files.filter((f) => f.startsWith(`${root}/`) && f.split('/').length === 2);
|
|
157
|
+
if (!loose.length) continue;
|
|
158
|
+
const covered = groups.get(root);
|
|
159
|
+
const hasNested = [...byFolder.values()].some((slot) => slot.pattern?.startsWith(`${root}/`) && slot.pattern !== `${root}/**`);
|
|
160
|
+
if (covered && hasNested) covered.files = covered.files.filter((p) => p !== `${root}/**`).concat(loose);
|
|
161
|
+
}
|
|
162
|
+
return { format: MAP_FORMAT, groups: [...groups.values()].sort((a, b) => a.id.localeCompare(b.id)), rules: [], ignore: [...ignore].sort() };
|
|
163
|
+
}
|
package/src/prompt.mjs
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// The brief for a coding agent (Claude Code, Cursor, Codex...) to turn the drafted map into a useful one. The map is
|
|
2
|
+
// filled by the agent the team already uses, so readfirst itself calls no model and needs no key.
|
|
3
|
+
import { MAP_FORMAT, MAP_PATH } from './map.mjs';
|
|
4
|
+
|
|
5
|
+
export function agentBrief(map) {
|
|
6
|
+
return `You are filling in ${MAP_PATH} for this repository. readfirst uses it to tell people and coding agents what to
|
|
7
|
+
read and check before they change a file. A first draft is already there (groups by folder, format ${MAP_FORMAT}).
|
|
8
|
+
|
|
9
|
+
Do this:
|
|
10
|
+
1. For each group, read its files enough to write:
|
|
11
|
+
- "title": one line on what this part of the code does.
|
|
12
|
+
- "checks": the commands that prove this part still works, e.g. {"run": "npm test -- auth", "why": "login and sessions"}.
|
|
13
|
+
Use commands that exist in this repo (package.json scripts, Makefile, pytest...). Leave the list empty if there is none.
|
|
14
|
+
- "risks": things a newcomer would get wrong here, each {"text": "...", "files": ["optional/path/or/pattern/**"]}.
|
|
15
|
+
Good risks are specific: hidden coupling, data that must stay compatible, money or auth paths, slow or paid calls,
|
|
16
|
+
generated files that must not be edited by hand. Do not write generic advice ("be careful", "write tests").
|
|
17
|
+
2. Split or merge groups when the folder split is wrong. Every tracked file must stay in exactly one group or in "ignore".
|
|
18
|
+
Patterns: a path, "folder/**", or a "*" glob within one segment.
|
|
19
|
+
3. Add "rules": promises the code must keep, each {"id": "kebab-id", "rule": "...", "why": "...", "files": [...],
|
|
20
|
+
"checks": [{"run": "..."}]}. Take them from comments, docs, tests and commit messages. Only write rules you can point to
|
|
21
|
+
in the code or docs. Ask the user for the ones only they know.
|
|
22
|
+
4. Run \`npx readfirst check\` until it says the map is good, then show the user the diff of ${MAP_PATH}.
|
|
23
|
+
|
|
24
|
+
Current groups: ${map.groups.map((g) => `${g.id} (${g.files.join(', ')})`).join('; ')}.
|
|
25
|
+
`;
|
|
26
|
+
}
|
package/src/readlist.mjs
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// The read list for files someone is about to change: the rules that cover them, the risks written for their group,
|
|
2
|
+
// the checks to run (the group's commands, the rules' commands, and the tests that import them), and who uses them.
|
|
3
|
+
import { groupOf, matcher, MAP_PATH } from './map.mjs';
|
|
4
|
+
|
|
5
|
+
export const READ_LIST_FORMAT = 'readfirst/read-list@1';
|
|
6
|
+
const TEST = /(^|\/)(?:tests?|__tests__|spec|e2e)\/|\.(?:test|spec)\.[cm]?[jt]sx?$|(^|\/)test[-_][^/]*\.(?:[cm]?[jt]sx?|py)$|_test\.(?:py|go)$/u;
|
|
7
|
+
export const isTest = (path) => TEST.test(path);
|
|
8
|
+
|
|
9
|
+
// The read list for no files, so `diff --json` is always JSON.
|
|
10
|
+
export const emptyList = (commit = null) => ({ format: READ_LIST_FORMAT, commit, targets: [], rules: [], risks: [], checks: [], tests: [], usedBy: [], usedByIndirectly: 0, notCovered: [], truncated: { tests: 0, usedBy: 0 } });
|
|
11
|
+
|
|
12
|
+
export function readList({ map, files, imports, targets, commit = null, limit = 30 }) {
|
|
13
|
+
if (!targets.length || targets.some((t) => !t.trim())) throw new Error('give at least one file path, none empty');
|
|
14
|
+
const tracked = new Set(files);
|
|
15
|
+
const ignored = matcher(map.ignore ?? []);
|
|
16
|
+
const importers = new Map();
|
|
17
|
+
for (const e of imports) importers.set(e.to, [...(importers.get(e.to) ?? []), e]);
|
|
18
|
+
|
|
19
|
+
const out = { format: READ_LIST_FORMAT, commit, targets: [], rules: [], risks: [], checks: [], tests: [], usedBy: [], usedByIndirectly: 0, notCovered: [] };
|
|
20
|
+
const seen = { rules: new Map(), risks: new Map(), checks: new Map(), tests: new Map(), usedBy: new Map() };
|
|
21
|
+
const note = (kind, key, item, target) => {
|
|
22
|
+
const have = seen[kind].get(key);
|
|
23
|
+
if (have) { if (!have.for.includes(target)) have.for.push(target); return; }
|
|
24
|
+
const row = { ...item, for: [target] };
|
|
25
|
+
seen[kind].set(key, row);
|
|
26
|
+
out[kind].push(row);
|
|
27
|
+
};
|
|
28
|
+
const indirect = new Set();
|
|
29
|
+
|
|
30
|
+
for (const raw of targets) {
|
|
31
|
+
const target = raw.replace(/^\.\//u, '').replace(/\\/gu, '/');
|
|
32
|
+
if (!tracked.has(target) && target !== MAP_PATH) {
|
|
33
|
+
out.targets.push({ path: target, group: null, status: 'NOT_TRACKED' });
|
|
34
|
+
out.notCovered.push({ path: target, why: 'not a tracked file at this commit (new, deleted or misspelt)' });
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (target === MAP_PATH) { out.targets.push({ path: target, group: null, status: 'MAP' }); continue; }
|
|
38
|
+
const group = groupOf(map, target);
|
|
39
|
+
out.targets.push({ path: target, group: group?.id ?? null, status: group ? 'IN_MAP' : ignored(target) ? 'IGNORED' : 'NO_GROUP' });
|
|
40
|
+
if (!group) {
|
|
41
|
+
out.notCovered.push({ path: target, why: ignored(target) ? 'ignored by the map' : 'in no group: run `readfirst check`' });
|
|
42
|
+
} else {
|
|
43
|
+
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);
|
|
44
|
+
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);
|
|
45
|
+
}
|
|
46
|
+
for (const rule of map.rules ?? []) {
|
|
47
|
+
if (!matcher(rule.files)(target)) continue;
|
|
48
|
+
note('rules', rule.id, { id: rule.id, rule: rule.rule, why: rule.why ?? null, files: rule.files }, target);
|
|
49
|
+
for (const c of rule.checks ?? []) note('checks', c.run, { run: c.run, why: c.why ?? `keeps rule ${rule.id}`, from: `rule ${rule.id}` }, target);
|
|
50
|
+
}
|
|
51
|
+
// Who reaches the target through imports: direct importers by line, then everything further up, for tests.
|
|
52
|
+
const depth = new Map([[target, 0]]);
|
|
53
|
+
const queue = [target];
|
|
54
|
+
while (queue.length) {
|
|
55
|
+
const at = queue.shift();
|
|
56
|
+
for (const e of importers.get(at) ?? []) {
|
|
57
|
+
if (depth.has(e.from)) continue;
|
|
58
|
+
depth.set(e.from, depth.get(at) + 1);
|
|
59
|
+
queue.push(e.from);
|
|
60
|
+
if (at === target && !isTest(e.from)) note('usedBy', e.from, { path: e.from, line: e.line }, target);
|
|
61
|
+
if (isTest(e.from)) note('tests', e.from, { path: e.from, via: at === target ? null : at }, target);
|
|
62
|
+
else if (at !== target) indirect.add(e.from);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (isTest(target)) note('tests', target, { path: target, via: null }, target);
|
|
66
|
+
}
|
|
67
|
+
for (const u of out.usedBy) indirect.delete(u.path);
|
|
68
|
+
out.usedByIndirectly = indirect.size;
|
|
69
|
+
out.tests.sort((a, b) => Number(a.via !== null) - Number(b.via !== null) || a.path.localeCompare(b.path));
|
|
70
|
+
out.truncated = { tests: Math.max(0, out.tests.length - limit), usedBy: Math.max(0, out.usedBy.length - limit) };
|
|
71
|
+
out.tests = out.tests.slice(0, limit);
|
|
72
|
+
out.usedBy = out.usedBy.slice(0, limit);
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// checklist: checks as task-list boxes (for PR comments); heading: the title line, or null for none.
|
|
77
|
+
export function toMarkdown(list, { checklist = false, heading } = {}) {
|
|
78
|
+
const title = heading === undefined ? `# Read first: ${list.targets.map((t) => `\`${t.path}\``).join(', ')}` : heading;
|
|
79
|
+
const lines = title === null ? [] : [title, ''];
|
|
80
|
+
const many = list.targets.length > 1;
|
|
81
|
+
const forWhom = (row, also = null) => {
|
|
82
|
+
const parts = [also, many ? row.for.join(', ') : null].filter(Boolean);
|
|
83
|
+
return parts.length ? ` _(${parts.join(' · ')})_` : '';
|
|
84
|
+
};
|
|
85
|
+
const section = (title, rows, render) => {
|
|
86
|
+
if (!rows.length) return;
|
|
87
|
+
lines.push(`## ${title}`, '', ...rows.map(render), '');
|
|
88
|
+
};
|
|
89
|
+
section('Rules that must keep holding', list.rules, (r) => `- **${r.id}**: ${r.rule}${r.why ? ` — why: ${r.why}` : ''}${forWhom(r)}`);
|
|
90
|
+
section('Watch out', list.risks, (r) => `- ${r.text}${forWhom(r, `group ${r.group}`)}`);
|
|
91
|
+
section('Run these checks', list.checks, (c) => `- ${checklist ? '[ ] ' : ''}\`${c.run}\` — ${c.why}${forWhom(c)}`);
|
|
92
|
+
section('Tests that reach this code', list.tests, (t) => `- \`${t.path}\`${t.via ? ` (through \`${t.via}\`)` : ''}${forWhom(t)}`);
|
|
93
|
+
if (list.truncated?.tests) lines.splice(lines.length - 1, 0, `- …and ${list.truncated.tests} more`);
|
|
94
|
+
section('Used by', list.usedBy, (u) => `- \`${u.path}:${u.line}\`${forWhom(u)}`);
|
|
95
|
+
if (list.truncated?.usedBy) lines.splice(lines.length - 1, 0, `- …and ${list.truncated.usedBy} more direct users`);
|
|
96
|
+
if (list.usedByIndirectly) lines.push(`${list.usedByIndirectly} more file(s) use this code indirectly.`, '');
|
|
97
|
+
section('Not covered by the map', list.notCovered, (n) => `- \`${n.path}\`: ${n.why}`);
|
|
98
|
+
if (!list.rules.length && !list.risks.length && !list.checks.length && !list.tests.length && !list.usedBy.length && !list.notCovered.length)
|
|
99
|
+
lines.push('Nothing in the map is tied to these files.', '');
|
|
100
|
+
return lines.join('\n');
|
|
101
|
+
}
|