readfirst 0.6.2 → 0.8.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 +44 -3
- package/package.json +1 -1
- package/src/action.mjs +54 -7
- package/src/cli.mjs +94 -15
- package/src/imports.mjs +122 -4
- package/src/langs.mjs +238 -0
- package/src/map.mjs +30 -0
- package/src/readlist.mjs +3 -1
package/README.md
CHANGED
|
@@ -12,6 +12,8 @@ No server, no account, no model calls. It reads your git checkout and `.readfirs
|
|
|
12
12
|
|
|
13
13
|
## Start
|
|
14
14
|
|
|
15
|
+
Any language; needs Node 20+ for `npx`.
|
|
16
|
+
|
|
15
17
|
```sh
|
|
16
18
|
npx readfirst init --agent # first map from your folders, checks from your scripts, plus the line for CLAUDE.md / AGENTS.md
|
|
17
19
|
npx readfirst prompt # a brief for your coding agent to fill in titles, checks, risks and rules
|
|
@@ -48,6 +50,18 @@ npx readfirst hook --install
|
|
|
48
50
|
adds a hook to `.claude/settings.json`. Before each Edit or Write, Claude Code gets the rules, risks and checks for that
|
|
49
51
|
file as context, once per file per session. Nothing to remember, and the edit is never blocked.
|
|
50
52
|
|
|
53
|
+
Want the checks enforced, not just suggested?
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
npx readfirst hook --install --verify
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
also adds a Stop hook: before Claude Code finishes its turn, it runs the map's checks for the files it edited in that
|
|
60
|
+
session. If one fails, Claude Code cannot finish yet and gets the failure to fix. It gets one try per turn, so a check
|
|
61
|
+
it cannot fix never traps the session. It runs only check commands that the map on your main branch already lists: a
|
|
62
|
+
command someone's branch adds or changes is named, not run, until it is merged (`npx readfirst verify` runs it when you
|
|
63
|
+
choose to).
|
|
64
|
+
|
|
51
65
|
### See the map
|
|
52
66
|
|
|
53
67
|
```sh
|
|
@@ -109,17 +123,39 @@ Each PR gets one comment, updated on every push, that lists:
|
|
|
109
123
|
- the rules, risks and checks for the files the PR changes, with the checks as boxes to tick;
|
|
110
124
|
- the tests that reach the changed code.
|
|
111
125
|
|
|
112
|
-
|
|
113
|
-
|
|
126
|
+
A new file next to files the map already places joins their group without anyone touching the map: the check passes
|
|
127
|
+
and the comment notes where it went (`npx readfirst fix` writes it down whenever you like). The check fails only on what
|
|
128
|
+
needs a person: a new top-level folder, a rule whose files are all gone, a map that is broken in itself. The comment
|
|
129
|
+
lists what `npx readfirst fix` would change.
|
|
114
130
|
|
|
115
131
|
| Input | Default | Meaning |
|
|
116
132
|
|---|---|---|
|
|
117
133
|
| `fail-on-map-problems` | `true` | `false` reports map problems without failing |
|
|
134
|
+
| `strict` | `false` | `true` also fails on new files that are only placed by their neighbours |
|
|
118
135
|
| `comment` | `true` | `false` writes only the job summary |
|
|
136
|
+
| `auto-fix` | `false` | `true` commits what `readfirst fix` settles (new files placed by their neighbours) to the PR branch |
|
|
119
137
|
| `limit` | `30` | most tests and users listed per section |
|
|
120
138
|
|
|
121
139
|
On a PR from a fork, the token is read-only. The list then goes to the job summary instead of a comment.
|
|
122
140
|
|
|
141
|
+
### Keep the map in step without thinking about it
|
|
142
|
+
|
|
143
|
+
```yaml
|
|
144
|
+
permissions:
|
|
145
|
+
contents: write # for auto-fix
|
|
146
|
+
pull-requests: write
|
|
147
|
+
# ...
|
|
148
|
+
- uses: jeonghun917/readfirst@v0
|
|
149
|
+
with:
|
|
150
|
+
auto-fix: true
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
With `auto-fix`, when a PR adds files next to ones the map already places, the Action writes them into the map and
|
|
154
|
+
commits that to the PR branch. That is the mechanical part `npx readfirst fix` does, nothing else: new risks and
|
|
155
|
+
rules stay for a person. It skips PRs from forks, and it skips the commit if the branch's map changed since the run
|
|
156
|
+
started, so a push is never overwritten. A commit made with the default `GITHUB_TOKEN` does not start new workflow
|
|
157
|
+
runs. If your branch protection requires checks, pass a GitHub App or personal token as `token`.
|
|
158
|
+
|
|
123
159
|
## The map
|
|
124
160
|
|
|
125
161
|
```json
|
|
@@ -157,7 +193,12 @@ On a PR from a fork, the token is read-only. The list then goes to the job summa
|
|
|
157
193
|
- **rules**: promises the code must keep. A rule can span groups.
|
|
158
194
|
|
|
159
195
|
Tests are found from imports: a test that imports the file directly, or through other files, is listed.
|
|
160
|
-
Imports are read for JavaScript/TypeScript
|
|
196
|
+
Imports are read for JavaScript/TypeScript, Python, Go, Rust, Elixir, C#, Java, Kotlin, Ruby (including Rails autoloaded
|
|
197
|
+
constants) and PHP (through composer's PSR-4 map). `init` suggests the checks those projects already have: `npm test`,
|
|
198
|
+
`pytest`, `go test ./...`, `cargo test`, `mix test`, `dotnet test`, `./gradlew test` or `mvn test`, `bundle exec rspec`,
|
|
199
|
+
`composer test`.
|
|
200
|
+
|
|
201
|
+
readfirst needs Node 20 or newer to run, whatever language the project is in. `npx` comes with Node.
|
|
161
202
|
|
|
162
203
|
## License
|
|
163
204
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "readfirst",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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/action.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { appendFileSync, readFileSync } from 'node:fs';
|
|
5
5
|
import { readMapFile, readText, repoFiles } from './cli.mjs';
|
|
6
6
|
import { importEdges } from './imports.mjs';
|
|
7
|
-
import {
|
|
7
|
+
import { MAP_PATH, settleMap } from './map.mjs';
|
|
8
8
|
import { readList, toMarkdown } from './readlist.mjs';
|
|
9
9
|
|
|
10
10
|
export const MARKER = '<!-- readfirst:pr-comment -->';
|
|
@@ -30,12 +30,16 @@ async function pages(fetchImpl, env, path) {
|
|
|
30
30
|
return all;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
export function commentBody({ list, problems, removed, commit, fixes = [] }) {
|
|
33
|
+
export function commentBody({ list, problems, removed, commit, fixes = [], settled = [], committed = null }) {
|
|
34
34
|
const parts = [MARKER, '## readfirst', ''];
|
|
35
35
|
if (problems.length) {
|
|
36
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
37
|
parts.push(...problems.slice(0, 20).map((p) => `- ${p}`), ...(problems.length > 20 ? [`- …and ${problems.length - 20} more`] : []), '');
|
|
38
38
|
if (fixes.length) parts.push('`npx readfirst fix` would make these changes:', ...fixes.slice(0, 10).map((f) => `- ${f}`), ...(fixes.length > 10 ? [`- …and ${fixes.length - 10} more`] : []), '');
|
|
39
|
+
} else if (committed) {
|
|
40
|
+
parts.push(`<details><summary>readfirst placed ${settled.length} new file(s) in the map and committed it to this branch (${committed.slice(0, 7)})</summary>`, '', ...settled.slice(0, 20).map((f) => `- ${f}`), ...(settled.length > 20 ? [`- …and ${settled.length - 20} more`] : []), '', 'Pull before your next push.', '</details>', '');
|
|
41
|
+
} else if (settled.length) {
|
|
42
|
+
parts.push(`<details><summary>New files placed by their neighbours (${settled.length})</summary>`, '', ...settled.slice(0, 20).map((f) => `- ${f}`), ...(settled.length > 20 ? [`- …and ${settled.length - 20} more`] : []), '', '`npx readfirst fix` writes this into the map; nothing to do for this PR.', '</details>', '');
|
|
39
43
|
}
|
|
40
44
|
if (list) parts.push(toMarkdown(list, { checklist: true, heading: null }));
|
|
41
45
|
else if (!problems.length) parts.push('No changed files to look up.', '');
|
|
@@ -48,8 +52,17 @@ export function commentBody({ list, problems, removed, commit, fixes = [] }) {
|
|
|
48
52
|
|
|
49
53
|
export async function runAction({ env = process.env, fetchImpl = fetch, root = env.GITHUB_WORKSPACE ?? process.cwd(), log = (s) => process.stdout.write(`${s}\n`) } = {}) {
|
|
50
54
|
const files = repoFiles(root);
|
|
51
|
-
|
|
52
|
-
const
|
|
55
|
+
// New files beside placed ones and patterns for deleted files are settled as `fix` would; only the rest fails.
|
|
56
|
+
const strict = (env.INPUT_STRICT ?? 'false') === 'true';
|
|
57
|
+
const raw = readMapFile(root);
|
|
58
|
+
const s = settleMap(raw, files);
|
|
59
|
+
// Strict: what fix would settle still counts as a problem, and the map is used as written.
|
|
60
|
+
const strictOff = !strict || !s.settled.length;
|
|
61
|
+
const map = strictOff ? s.map : raw;
|
|
62
|
+
const problems = strictOff ? s.problems : [...s.problems, ...s.settled.map((c) => `not yet written in the map: ${c}`)];
|
|
63
|
+
// With problems left, what fix would change is the help; without, it is a note.
|
|
64
|
+
const settled = problems.length ? [] : s.settled;
|
|
65
|
+
const fixes = problems.length ? s.settled : [];
|
|
53
66
|
const failOnProblems = (env['INPUT_FAIL-ON-MAP-PROBLEMS'] ?? env.INPUT_FAIL_ON_MAP_PROBLEMS ?? 'true') !== 'false';
|
|
54
67
|
const event = env.GITHUB_EVENT_PATH ? JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, 'utf8')) : {};
|
|
55
68
|
const pr = event.pull_request;
|
|
@@ -61,14 +74,16 @@ export async function runAction({ env = process.env, fetchImpl = fetch, root = e
|
|
|
61
74
|
}
|
|
62
75
|
const repo = env.GITHUB_REPOSITORY;
|
|
63
76
|
const changed = await pages(fetchImpl, env, `/repos/${repo}/pulls/${pr.number}/files`);
|
|
77
|
+
const committed = (env['INPUT_AUTO-FIX'] ?? env.INPUT_AUTO_FIX ?? 'false') === 'true' && settled.length
|
|
78
|
+
? await commitFix({ fetchImpl, env, repo, pr, raw, fixed: map, settled, log })
|
|
79
|
+
: null;
|
|
64
80
|
const removed = changed.filter((f) => f.status === 'removed').map((f) => f.filename);
|
|
65
81
|
const targets = changed.filter((f) => f.status !== 'removed').map((f) => f.filename);
|
|
66
82
|
// A broken map still gets a comment (the problems), but no read list from it.
|
|
67
83
|
const list = targets.length && !problems.length
|
|
68
84
|
? readList({ map, files, imports: importEdges(files, readText(root)), targets, commit: pr.head?.sha ?? null, limit: Number(env.INPUT_LIMIT || 30) })
|
|
69
85
|
: null;
|
|
70
|
-
const
|
|
71
|
-
const body = commentBody({ list, problems, removed, commit: pr.head?.sha ?? null, fixes });
|
|
86
|
+
const body = commentBody({ list, problems, removed, commit: pr.head?.sha ?? null, fixes, settled, committed });
|
|
72
87
|
summary(body);
|
|
73
88
|
|
|
74
89
|
if ((env.INPUT_COMMENT ?? 'true') !== 'false') {
|
|
@@ -85,7 +100,39 @@ export async function runAction({ env = process.env, fetchImpl = fetch, root = e
|
|
|
85
100
|
}
|
|
86
101
|
}
|
|
87
102
|
if (problems.length) log(`${MAP_PATH} has ${problems.length} problem(s):\n- ${problems.join('\n- ')}`);
|
|
88
|
-
return { ok: !(problems.length && failOnProblems), problems, list, body };
|
|
103
|
+
return { ok: !(problems.length && failOnProblems), problems, list, body, committed };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// auto-fix: write what `readfirst fix` settles (new files placed by their neighbours, patterns for deleted files) into
|
|
107
|
+
// the map on the PR's branch, as one commit through the contents API. Only the mechanical part, never anything a person
|
|
108
|
+
// has to decide; only on a branch of this repository; only when the branch's map is the one this run read, so a push in
|
|
109
|
+
// between is never overwritten. Returns the new commit's sha, or null with a warning.
|
|
110
|
+
async function commitFix({ fetchImpl, env, repo, pr, raw, fixed, settled, log }) {
|
|
111
|
+
if (pr.head?.repo?.full_name !== repo || !pr.head?.ref) {
|
|
112
|
+
log('::warning::readfirst auto-fix: this PR comes from a fork, so the map update stays a suggestion in the comment.');
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
const path = MAP_PATH.split('/').map(encodeURIComponent).join('/');
|
|
116
|
+
try {
|
|
117
|
+
const now = await api(fetchImpl, env, 'GET', `/repos/${repo}/contents/${path}?ref=${encodeURIComponent(pr.head.ref)}`);
|
|
118
|
+
let onBranch = null;
|
|
119
|
+
try { onBranch = JSON.parse(Buffer.from(now.content ?? '', 'base64').toString('utf8')); } catch { onBranch = null; }
|
|
120
|
+
if (JSON.stringify(onBranch) !== JSON.stringify(raw)) {
|
|
121
|
+
log('::warning::readfirst auto-fix: the map on the branch differs from the one this run read (a newer push, or the base changed it); nothing committed.');
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
const res = await api(fetchImpl, env, 'PUT', `/repos/${repo}/contents/${path}`, {
|
|
125
|
+
message: `readfirst fix: place ${settled.length} new file(s) in the map`,
|
|
126
|
+
content: Buffer.from(`${JSON.stringify(fixed, null, 2)}\n`, 'utf8').toString('base64'),
|
|
127
|
+
sha: now.sha,
|
|
128
|
+
branch: pr.head.ref,
|
|
129
|
+
});
|
|
130
|
+
log(`readfirst auto-fix: committed the map update to ${pr.head.ref}`);
|
|
131
|
+
return res?.commit?.sha ?? 'committed';
|
|
132
|
+
} catch (e) {
|
|
133
|
+
log(`::warning::readfirst auto-fix could not commit (${/: 403 /u.test(e.message) ? 'the token needs contents: write' : e.message.slice(0, 200)}); the comment says what \`npx readfirst fix\` would change.`);
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
89
136
|
}
|
|
90
137
|
|
|
91
138
|
if (process.argv[1]?.endsWith('/src/action.mjs')) {
|
package/src/cli.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// readfirst diff [--base <ref>] the read list for the files changed since <ref> (default: origin/main, else main)
|
|
9
9
|
// readfirst verify [--base <ref>] run the checks the read list names for the changed files; exit 1 if one fails
|
|
10
10
|
// readfirst hook [--install] a Claude Code PreToolUse hook: the read list for the file an Edit or Write is about to change
|
|
11
|
+
// (--install --verify also adds a Stop hook, `hook --stop`, that runs verify before Claude Code finishes)
|
|
11
12
|
// readfirst mcp an MCP server on stdio: read_list, changed_read_list and check_map as tools for any agent
|
|
12
13
|
// readfirst view [--out <file>] the map as a city in one HTML page (default .readfirst/local/view.html)
|
|
13
14
|
// Options: --root <dir> (default: the current git repository), --json, --limit <n>.
|
|
@@ -16,7 +17,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
|
16
17
|
import { tmpdir } from 'node:os';
|
|
17
18
|
import { basename, isAbsolute, join, relative } from 'node:path';
|
|
18
19
|
import { importEdges } from './imports.mjs';
|
|
19
|
-
import { assertMap, draftMap, fixMap, MAP_PATH, mapProblems } from './map.mjs';
|
|
20
|
+
import { assertMap, draftMap, fixMap, MAP_PATH, mapProblems, settleMap } from './map.mjs';
|
|
20
21
|
import { agentBrief } from './prompt.mjs';
|
|
21
22
|
import { keepLocal, labelsBrief, LOCAL_DIR, readLabels } from './labels.mjs';
|
|
22
23
|
import { viewData } from './view.mjs';
|
|
@@ -28,12 +29,13 @@ const HELP = `readfirst — what to read and check before changing a file
|
|
|
28
29
|
|
|
29
30
|
readfirst init [--agent] write a first ${MAP_PATH} from the file tree
|
|
30
31
|
readfirst prompt brief for your coding agent to fill in the map
|
|
31
|
-
readfirst check
|
|
32
|
+
readfirst check [--strict] check the map covers every tracked file (new files beside placed ones pass; --strict fails on them)
|
|
32
33
|
readfirst fix place new files, drop patterns for deleted ones
|
|
33
34
|
readfirst read <file>... read list for files you are about to change
|
|
34
35
|
readfirst diff [--base <ref>] read list for files changed since <ref>
|
|
35
36
|
readfirst verify [--base <ref>] run the checks for the files changed since <ref> (--dry-run lists them)
|
|
36
|
-
readfirst hook [--install] Claude Code hook: the read list before each edit (--install adds it to .claude/settings.json
|
|
37
|
+
readfirst hook [--install] Claude Code hook: the read list before each edit (--install adds it to .claude/settings.json;
|
|
38
|
+
--install --verify also runs the checks for edited files before Claude Code finishes)
|
|
37
39
|
readfirst view [--out <file>] the map as a city in one HTML page
|
|
38
40
|
readfirst mcp MCP server on stdio for Cursor, Codex, Windsurf and other agents
|
|
39
41
|
readfirst prompt --labels <lang> brief for your agent to write personal labels for the view (e.g. ko, en)
|
|
@@ -50,6 +52,9 @@ function parse(argv) {
|
|
|
50
52
|
else if (a === '--agent') args.agent = true;
|
|
51
53
|
else if (a === '--install') args.install = true;
|
|
52
54
|
else if (a === '--dry-run') args.dryRun = true;
|
|
55
|
+
else if (a === '--strict') args.strict = true;
|
|
56
|
+
else if (a === '--verify') args.verify = true;
|
|
57
|
+
else if (a === '--stop') args.stop = true;
|
|
53
58
|
else if (a === '-h' || a === '--help') args.help = true;
|
|
54
59
|
else if (a.startsWith('--')) { const v = argv[++i]; if (v === undefined) throw new Error(`${a} needs a value`); args[a.slice(2)] = v; }
|
|
55
60
|
else args._.push(a);
|
|
@@ -86,9 +91,10 @@ function loadMapLenient(root, files, io) {
|
|
|
86
91
|
const map = readMapFile(root);
|
|
87
92
|
const broken = mapProblems(map, files, { placement: false });
|
|
88
93
|
if (broken.length) throw new Error(`${MAP_PATH} has problems:\n- ${broken.join('\n- ')}`);
|
|
89
|
-
const behind =
|
|
94
|
+
const { map: settled, problems: behind, settled: placed } = settleMap(map, files);
|
|
90
95
|
if (behind.length) io.err(`note: ${MAP_PATH} is behind the code (${behind.length} problem${behind.length > 1 ? 's' : ''}); \`readfirst fix\` repairs most of it.\n`);
|
|
91
|
-
|
|
96
|
+
else if (placed.length) io.err(`note: ${placed.length} change${placed.length > 1 ? 's' : ''} not yet written in ${MAP_PATH} (new files placed by their neighbours); \`readfirst fix\` writes ${placed.length > 1 ? 'them' : 'it'} down.\n`);
|
|
97
|
+
return settled;
|
|
92
98
|
}
|
|
93
99
|
|
|
94
100
|
// The one line that makes a coding agent use readfirst, added to the agent notes the project already has (CLAUDE.md,
|
|
@@ -162,6 +168,9 @@ export function brief(list) {
|
|
|
162
168
|
return lines;
|
|
163
169
|
}
|
|
164
170
|
|
|
171
|
+
// Which files the PreToolUse hook has seen in a session: the Stop hook verifies those.
|
|
172
|
+
const seenFile = (stateDir, session) => join(stateDir, `readfirst-hook-${String(session ?? 'none').replace(/[^\w-]/gu, '')}.json`);
|
|
173
|
+
|
|
165
174
|
// Claude Code PreToolUse hook. Reads the hook's JSON on stdin; for an Edit, Write or MultiEdit of a file the map covers,
|
|
166
175
|
// prints the read list as additionalContext, once per file per session. Never blocks the edit: any problem means no output.
|
|
167
176
|
export function runHook(input, { now = Date.now(), stateDir = tmpdir() } = {}) {
|
|
@@ -173,7 +182,7 @@ export function runHook(input, { now = Date.now(), stateDir = tmpdir() } = {}) {
|
|
|
173
182
|
const root = git(cwd, 'rev-parse', '--show-toplevel').trim();
|
|
174
183
|
const path = (isAbsolute(file) ? relative(root, file) : file).replace(/\\/gu, '/');
|
|
175
184
|
if (path.startsWith('..') || path === MAP_PATH || !existsSync(join(root, MAP_PATH))) return null;
|
|
176
|
-
const seenAt =
|
|
185
|
+
const seenAt = seenFile(stateDir, event.session_id);
|
|
177
186
|
let seen = {};
|
|
178
187
|
try { seen = JSON.parse(readFileSync(seenAt, 'utf8')); } catch { seen = {}; }
|
|
179
188
|
if (seen[path] && now - seen[path] < 6 * 3600_000) return null;
|
|
@@ -193,17 +202,78 @@ export function runHook(input, { now = Date.now(), stateDir = tmpdir() } = {}) {
|
|
|
193
202
|
}
|
|
194
203
|
}
|
|
195
204
|
|
|
205
|
+
// The check commands of the map as the base branch has it. The Stop hook runs a command without anyone asking, so it
|
|
206
|
+
// runs only these: a command a branch adds or changes in the map is repository content nobody has merged yet.
|
|
207
|
+
function trustedRuns(root) {
|
|
208
|
+
try {
|
|
209
|
+
const { ref } = changedFiles(root);
|
|
210
|
+
const since = git(root, 'merge-base', ref, 'HEAD').trim();
|
|
211
|
+
const map = JSON.parse(git(root, 'show', `${since}:${MAP_PATH}`));
|
|
212
|
+
return new Set([...(map.groups ?? []), ...(map.rules ?? [])].flatMap((x) => (x.checks ?? []).map((c) => c.run)));
|
|
213
|
+
} catch {
|
|
214
|
+
return new Set();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Claude Code Stop hook (opt-in, `hook --install --verify`): before Claude Code ends its turn, run the map's checks for the
|
|
219
|
+
// files it edited in this session that are still changed. A failing check blocks the stop once, with the failure as the
|
|
220
|
+
// reason, so the agent fixes it; on the second stop of the same turn it lets go, so a check it cannot fix never traps
|
|
221
|
+
// the session. Only commands the base branch's map already lists run; others are named for `readfirst verify`, which
|
|
222
|
+
// someone runs on purpose. Nothing edited, nothing changed or no checks: no output.
|
|
223
|
+
export function runStopHook(input, { stateDir = tmpdir(), timeout = 600 } = {}) {
|
|
224
|
+
let event;
|
|
225
|
+
try { event = JSON.parse(input); } catch { return null; }
|
|
226
|
+
if (event.stop_hook_active) return null;
|
|
227
|
+
try {
|
|
228
|
+
const root = git(event.cwd ?? process.cwd(), 'rev-parse', '--show-toplevel').trim();
|
|
229
|
+
if (!existsSync(join(root, MAP_PATH))) return null;
|
|
230
|
+
let seen = {};
|
|
231
|
+
try { seen = JSON.parse(readFileSync(seenFile(stateDir, event.session_id), 'utf8')); } catch { return null; }
|
|
232
|
+
const dirty = new Set(git(root, 'status', '--porcelain', '-z', '--untracked-files=all').split('\0').filter(Boolean).map((l) => l.slice(3)));
|
|
233
|
+
let changed = [];
|
|
234
|
+
try { changed = changedFiles(root).files; } catch { /* no base branch: the working tree is enough */ }
|
|
235
|
+
const targets = Object.keys(seen).filter((f) => dirty.has(f) || changed.includes(f)).sort();
|
|
236
|
+
if (!targets.length) return null;
|
|
237
|
+
const files = repoFiles(root);
|
|
238
|
+
const map = readMapFile(root);
|
|
239
|
+
if (mapProblems(map, files, { placement: false }).length) return null;
|
|
240
|
+
const list = readList({ map, files, imports: importEdges(files, readText(root)), targets, limit: 1 });
|
|
241
|
+
if (!list.checks.length) return null;
|
|
242
|
+
const trusted = trustedRuns(root);
|
|
243
|
+
const skipped = list.checks.filter((c) => !trusted.has(c.run)).map((c) => c.run);
|
|
244
|
+
const result = verifyChecks(root, list.checks.filter((c) => trusted.has(c.run)), { timeout });
|
|
245
|
+
const failed = result.filter((c) => c.status !== 'pass');
|
|
246
|
+
const notRun = skipped.length ? ` Not run, because the base branch's map does not list them yet: ${skipped.map((r) => `\`${r}\``).join(', ')}; run \`npx readfirst verify\` to run them on purpose.` : '';
|
|
247
|
+
if (!failed.length) return JSON.stringify({ systemMessage: `readfirst verify: ${result.length} check(s) passed for ${targets.length} edited file(s).${notRun}` });
|
|
248
|
+
const reason = [
|
|
249
|
+
`readfirst verify: ${failed.length} of ${result.length} check(s) the map lists for the files you edited failed. Fix them before you finish.${notRun}`,
|
|
250
|
+
...failed.map((c) => `\n--- ${c.run} (${c.status}${c.code != null ? `, exit ${c.code}` : ''})${c.why ? ` covers ${c.why}` : ''}; last lines:\n${c.output ?? ''}`),
|
|
251
|
+
].join('\n');
|
|
252
|
+
return JSON.stringify({ decision: 'block', reason });
|
|
253
|
+
} catch {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
196
258
|
export const HOOK_COMMAND = 'npx --yes readfirst hook';
|
|
197
|
-
|
|
198
|
-
|
|
259
|
+
export const STOP_HOOK_COMMAND = 'npx --yes readfirst hook --stop';
|
|
260
|
+
// Adds the hook to the project's .claude/settings.json, keeping everything already there. With verify, also the Stop
|
|
261
|
+
// hook that runs the checks before Claude Code finishes.
|
|
262
|
+
export function installHook(root, { verify = false } = {}) {
|
|
199
263
|
const at = join(root, '.claude', 'settings.json');
|
|
200
264
|
let settings = {};
|
|
201
265
|
if (existsSync(at)) {
|
|
202
266
|
try { settings = JSON.parse(readFileSync(at, 'utf8')); } catch { throw new Error('.claude/settings.json is not valid JSON; fix it first'); }
|
|
203
267
|
}
|
|
204
268
|
const pre = ((settings.hooks ??= {}).PreToolUse ??= []);
|
|
205
|
-
|
|
206
|
-
|
|
269
|
+
const has = (list) => list.some((m) => (m.hooks ?? []).some((h) => /readfirst hook/u.test(h.command ?? '')));
|
|
270
|
+
let added = false;
|
|
271
|
+
if (!has(pre)) { pre.push({ matcher: 'Edit|Write|MultiEdit', hooks: [{ type: 'command', command: HOOK_COMMAND, timeout: 15 }] }); added = true; }
|
|
272
|
+
if (verify) {
|
|
273
|
+
const stop = (settings.hooks.Stop ??= []);
|
|
274
|
+
if (!has(stop)) { stop.push({ hooks: [{ type: 'command', command: STOP_HOOK_COMMAND, timeout: 1800 }] }); added = true; }
|
|
275
|
+
}
|
|
276
|
+
if (!added) return false;
|
|
207
277
|
mkdirSync(join(root, '.claude'), { recursive: true });
|
|
208
278
|
writeFileSync(at, `${JSON.stringify(settings, null, 2)}\n`);
|
|
209
279
|
return true;
|
|
@@ -214,7 +284,7 @@ export function main(argv, io = { out: (s) => process.stdout.write(s), err: (s)
|
|
|
214
284
|
const [cmd, ...rest] = args._;
|
|
215
285
|
if (args.help || !cmd) { io.out(`${HELP}\n`); return cmd || args.help ? 0 : 2; }
|
|
216
286
|
if (cmd === 'hook' && !args.install) {
|
|
217
|
-
const out = runHook(stdin());
|
|
287
|
+
const out = args.stop ? runStopHook(stdin()) : runHook(stdin());
|
|
218
288
|
if (out) io.out(`${out}\n`);
|
|
219
289
|
return 0;
|
|
220
290
|
}
|
|
@@ -279,13 +349,22 @@ export function main(argv, io = { out: (s) => process.stdout.write(s), err: (s)
|
|
|
279
349
|
return 0;
|
|
280
350
|
}
|
|
281
351
|
if (cmd === 'check') {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
352
|
+
// Placing new files next to their neighbours and dropping patterns for deleted files is what `fix` does; check
|
|
353
|
+
// settles those the same way and passes. --strict fails on them too.
|
|
354
|
+
const map = readMapFile(root);
|
|
355
|
+
const { problems, settled } = args.strict ? { problems: mapProblems(map, files), settled: [] } : settleMap(map, files);
|
|
356
|
+
if (args.json) io.out(`${JSON.stringify({ ok: !problems.length, problems, settled }, null, 2)}\n`);
|
|
357
|
+
else {
|
|
358
|
+
if (problems.length) io.out(`${MAP_PATH} has ${problems.length} problem(s):\n- ${problems.join('\n- ')}\n`);
|
|
359
|
+
else io.out(`${MAP_PATH} is good: ${files.length} files.\n`);
|
|
360
|
+
if (settled.length) io.out(`settled without writing (\`readfirst fix\` writes it down):\n- ${settled.join('\n- ')}\n`);
|
|
361
|
+
}
|
|
285
362
|
return problems.length ? 1 : 0;
|
|
286
363
|
}
|
|
287
364
|
if (cmd === 'hook') {
|
|
288
|
-
io.out(installHook(root
|
|
365
|
+
io.out(installHook(root, { verify: args.verify })
|
|
366
|
+
? `added the readfirst hook to .claude/settings.json: Claude Code now sees the read list before each edit${args.verify ? ', and cannot finish while a check for a file it edited fails (it gets one try to fix it)' : ''}.\n`
|
|
367
|
+
: '.claude/settings.json already has the readfirst hook.\n');
|
|
289
368
|
return 0;
|
|
290
369
|
}
|
|
291
370
|
if (cmd === 'fix') {
|
package/src/imports.mjs
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
// Which tracked file imports which, read from the code. Only imports that resolve to a tracked file count: packages
|
|
2
|
-
// unresolved specifiers are left out. JavaScript/TypeScript (import, export from, import(),
|
|
3
|
-
//
|
|
1
|
+
// Which tracked file imports which, read from the code. Only imports that resolve to a tracked file count: packages from
|
|
2
|
+
// outside the repository and unresolved specifiers are left out. JavaScript/TypeScript (import, export from, import(),
|
|
3
|
+
// require; relative paths, the repository's own workspace packages and tsconfig/jsconfig "paths" aliases) and Python
|
|
4
|
+
// (import a.b, from a.b import c, relative from . imports, and "a.b.c" strings that name a module of the repository,
|
|
5
|
+
// as hooks and settings do) are read here; Go, Rust, Elixir, C#, Java/Kotlin, Ruby and PHP in langs.mjs.
|
|
4
6
|
import { posix } from 'node:path';
|
|
7
|
+
import { moreEdges } from './langs.mjs';
|
|
5
8
|
|
|
6
9
|
const JS = /\.(?:m?js|cjs|tsx?|jsx|mts|cts|vue|svelte)$/u;
|
|
7
10
|
const PY = /\.py$/u;
|
|
@@ -11,6 +14,87 @@ const PY_IMPORT = /^[ \t]*(?:from[ \t]+(\.*[\w.]*)[ \t]+import[ \t]+(\([^)]*\)|[
|
|
|
11
14
|
const JS_EXT = ['', '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.mts', '.vue', '.svelte', '/index.js', '/index.ts', '/index.tsx', '/index.mjs'];
|
|
12
15
|
|
|
13
16
|
const lineAt = (body, index) => body.slice(0, index).split('\n').length;
|
|
17
|
+
const dirOf = (f) => (posix.dirname(f) === '.' ? '' : posix.dirname(f));
|
|
18
|
+
const within = (dir, file) => dir === '' || file.startsWith(`${dir}/`);
|
|
19
|
+
|
|
20
|
+
// tsconfig.json allows comments and trailing commas.
|
|
21
|
+
export function parseJsonc(text) {
|
|
22
|
+
if (text === null || text === undefined) return null;
|
|
23
|
+
let out = '';
|
|
24
|
+
for (let i = 0; i < text.length; i++) {
|
|
25
|
+
const c = text[i];
|
|
26
|
+
if (c === '"') {
|
|
27
|
+
const end = text.slice(i).match(/^"(?:[^"\\\n]|\\.)*"/u);
|
|
28
|
+
if (!end) return null;
|
|
29
|
+
out += end[0]; i += end[0].length - 1;
|
|
30
|
+
} else if (c === '/' && text[i + 1] === '/') {
|
|
31
|
+
while (i < text.length && text[i] !== '\n') i++;
|
|
32
|
+
out += '\n';
|
|
33
|
+
} else if (c === '/' && text[i + 1] === '*') {
|
|
34
|
+
const close = text.indexOf('*/', i + 2);
|
|
35
|
+
if (close < 0) return null;
|
|
36
|
+
i = close + 1;
|
|
37
|
+
} else out += c;
|
|
38
|
+
}
|
|
39
|
+
try { return JSON.parse(out.replace(/,(\s*[}\]])/gu, '$1')); } catch { return null; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Bare specifiers that point back into the repository: a workspace package by the name in its package.json
|
|
43
|
+
// ("@acme/db", "@acme/db/schema"), or a "paths" alias from the tsconfig/jsconfig that covers the importing file ("@/lib/x").
|
|
44
|
+
function jsResolver(files, tracked, read) {
|
|
45
|
+
const jsCandidates = (base) => [...JS_EXT.map((e) => base + e), base.replace(/\.(m|c)?js$/u, '.$1ts'), base.replace(/\.js$/u, '.tsx')];
|
|
46
|
+
const firstTracked = (bases) => {
|
|
47
|
+
for (const b of bases) {
|
|
48
|
+
const hit = jsCandidates(posix.normalize(b)).find((c) => tracked.has(c));
|
|
49
|
+
if (hit) return hit;
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
};
|
|
53
|
+
const pkgs = [];
|
|
54
|
+
for (const f of files) {
|
|
55
|
+
if (!/(?:^|\/)package\.json$/u.test(f) || f.includes('node_modules/')) continue;
|
|
56
|
+
let j;
|
|
57
|
+
try { j = JSON.parse(read(f) ?? ''); } catch { continue; }
|
|
58
|
+
if (typeof j?.name !== 'string' || !j.name || dirOf(f) === '') continue;
|
|
59
|
+
const dot = typeof j.exports === 'string' ? j.exports : j.exports?.['.'];
|
|
60
|
+
const exp = typeof dot === 'string' ? [dot] : dot && typeof dot === 'object' ? ['types', 'import', 'default', 'require'].map((k) => dot[k]).filter((v) => typeof v === 'string') : [];
|
|
61
|
+
// An entry pointing at build output (dist/index.js) is read as the source next to it (src/index.ts).
|
|
62
|
+
const entries = [j.source, ...exp, j.types, j.typings, j.module, j.main].filter((v) => typeof v === 'string')
|
|
63
|
+
.flatMap((e) => [e, e.replace(/^\.?\/?(?:dist|build|lib|out)\//u, 'src/').replace(/\.d\.ts$/u, '.ts')]);
|
|
64
|
+
pkgs.push({ name: j.name, dir: dirOf(f), entries: [...entries, 'src/index', 'index'] });
|
|
65
|
+
}
|
|
66
|
+
pkgs.sort((a, b) => b.name.length - a.name.length);
|
|
67
|
+
const aliases = [];
|
|
68
|
+
for (const f of files) {
|
|
69
|
+
if (!/(?:^|\/)(?:tsconfig|jsconfig)(?:\.[\w-]+)?\.json$/u.test(f) || f.includes('node_modules/')) continue;
|
|
70
|
+
const opts = parseJsonc(read(f))?.compilerOptions;
|
|
71
|
+
if (!opts?.paths || typeof opts.paths !== 'object') continue;
|
|
72
|
+
const base = posix.join(dirOf(f), opts.baseUrl ?? '.');
|
|
73
|
+
for (const [pattern, targets] of Object.entries(opts.paths)) {
|
|
74
|
+
if (Array.isArray(targets)) aliases.push({ scope: dirOf(f), pattern, targets: targets.filter((t) => typeof t === 'string').map((t) => posix.join(base, t)) });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// The nearest config wins, as it would for the compiler.
|
|
78
|
+
aliases.sort((a, b) => b.scope.length - a.scope.length);
|
|
79
|
+
return (from, spec) => {
|
|
80
|
+
for (const a of aliases) {
|
|
81
|
+
if (!within(a.scope, from)) continue;
|
|
82
|
+
const star = a.pattern.indexOf('*');
|
|
83
|
+
let rest = null;
|
|
84
|
+
if (star < 0) rest = spec === a.pattern ? '' : null;
|
|
85
|
+
else if (spec.startsWith(a.pattern.slice(0, star)) && spec.endsWith(a.pattern.slice(star + 1))) rest = spec.slice(star, spec.length - (a.pattern.length - star - 1));
|
|
86
|
+
if (rest === null) continue;
|
|
87
|
+
const hit = firstTracked(a.targets.map((t) => t.replace('*', rest)));
|
|
88
|
+
if (hit) return hit;
|
|
89
|
+
}
|
|
90
|
+
const pkg = pkgs.find((p) => spec === p.name || spec.startsWith(`${p.name}/`));
|
|
91
|
+
if (!pkg) return undefined;
|
|
92
|
+
const sub = spec.slice(pkg.name.length + 1);
|
|
93
|
+
return sub
|
|
94
|
+
? firstTracked([posix.join(pkg.dir, sub), posix.join(pkg.dir, 'src', sub)])
|
|
95
|
+
: firstTracked(pkg.entries.map((e) => posix.join(pkg.dir, e.replace(/\.(?:m|c)?js$|\.tsx?$/u, ''))));
|
|
96
|
+
};
|
|
97
|
+
}
|
|
14
98
|
|
|
15
99
|
export function importEdges(files, read) {
|
|
16
100
|
const tracked = new Set(files);
|
|
@@ -25,13 +109,19 @@ export function importEdges(files, read) {
|
|
|
25
109
|
const base = posix.join(baseDir, ...dotted.split('.').filter(Boolean));
|
|
26
110
|
return [`${base}.py`, `${base}/__init__.py`].find((c) => tracked.has(c));
|
|
27
111
|
};
|
|
112
|
+
let bare = null;
|
|
28
113
|
for (const from of [...files].sort()) {
|
|
29
114
|
if (JS.test(from)) {
|
|
30
115
|
const body = read(from);
|
|
31
116
|
if (body === null) continue;
|
|
32
117
|
for (const m of body.matchAll(JS_IMPORT)) {
|
|
33
118
|
const spec = (m[1] ?? m[2] ?? m[3]).split('?')[0];
|
|
34
|
-
if (!spec.startsWith('.'))
|
|
119
|
+
if (!spec.startsWith('.')) {
|
|
120
|
+
if (spec.startsWith('node:') || /^[a-z]+:/u.test(spec)) continue;
|
|
121
|
+
bare ??= jsResolver(files, tracked, read);
|
|
122
|
+
add(from, bare(from, spec), lineAt(body, m.index));
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
35
125
|
const base = posix.normalize(posix.join(posix.dirname(from), spec));
|
|
36
126
|
// TypeScript sources import "./x.js" for the file "./x.ts".
|
|
37
127
|
const candidates = [...JS_EXT.map((e) => base + e), base.replace(/\.(m|c)?js$/u, '.$1ts'), base.replace(/\.js$/u, '.tsx')];
|
|
@@ -62,5 +152,33 @@ export function importEdges(files, read) {
|
|
|
62
152
|
}
|
|
63
153
|
}
|
|
64
154
|
}
|
|
155
|
+
dottedEdges(files, tracked, read, pyModule, add);
|
|
156
|
+
moreEdges([...files].sort(), read, add);
|
|
65
157
|
return out;
|
|
66
158
|
}
|
|
159
|
+
|
|
160
|
+
// Python projects name code by dotted path in strings: Frappe hooks.py ("app.module.function"), frappe.call from
|
|
161
|
+
// JavaScript, Django settings and urls, Celery task names. A string counts when its first part is a top-level Python
|
|
162
|
+
// package of the repository and the longest leading part names one of its modules.
|
|
163
|
+
function dottedEdges(files, tracked, read, pyModule, add) {
|
|
164
|
+
const top = new Map();
|
|
165
|
+
for (const f of files) {
|
|
166
|
+
const m = f.match(/^(?:(src)\/)?([A-Za-z_]\w*)\/__init__\.py$/u);
|
|
167
|
+
if (m) top.set(m[2], m[1] ?? '');
|
|
168
|
+
}
|
|
169
|
+
if (!top.size) return;
|
|
170
|
+
const DOTTED = /["'`]([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+)["'`]/gu;
|
|
171
|
+
for (const from of files) {
|
|
172
|
+
if (!/\.(?:py|m?js|tsx?|json)$/u.test(from) || from.includes('node_modules/')) continue;
|
|
173
|
+
const body = read(from);
|
|
174
|
+
if (body === null || body.length > 2_000_000) continue;
|
|
175
|
+
for (const m of body.matchAll(DOTTED)) {
|
|
176
|
+
const parts = m[1].split('.');
|
|
177
|
+
if (!top.has(parts[0])) continue;
|
|
178
|
+
for (let n = parts.length; n >= 2; n--) {
|
|
179
|
+
const to = pyModule(parts.slice(0, n).join('.'), top.get(parts[0]));
|
|
180
|
+
if (to) { add(from, to, lineAt(body, m.index)); break; }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
package/src/langs.mjs
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// Who uses which file, for languages whose imports name modules, packages or types rather than paths: Go, Rust,
|
|
2
|
+
// Elixir, C#, Java/Kotlin, Ruby and PHP. Like the JavaScript and Python readers in imports.mjs, only edges that land on
|
|
3
|
+
// a tracked file count; anything that names a package from outside the repository is left out. Each reader is a
|
|
4
|
+
// careful approximation of the language's own rules, good enough to find the tests and callers of a file.
|
|
5
|
+
import { posix } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const lineAt = (body, index) => body.slice(0, index).split('\n').length;
|
|
8
|
+
const dirOf = (f) => posix.dirname(f);
|
|
9
|
+
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
10
|
+
// Comments are blanked (same length, so line numbers stay right) before looking for names in code.
|
|
11
|
+
const blank = (body, re) => body.replace(re, (m) => m.replace(/[^\n]/gu, ' '));
|
|
12
|
+
const noComments = (body) => blank(body, /\/\*[\s\S]*?\*\/|\/\/[^\n]*/gu);
|
|
13
|
+
|
|
14
|
+
// Go: `import "module/path/pkg"` reaches every non-test .go file of that package's folder. The module path comes from
|
|
15
|
+
// the nearest go.mod.
|
|
16
|
+
function goEdges(files, read, add) {
|
|
17
|
+
const mods = files.filter((f) => /(?:^|\/)go\.mod$/u.test(f)).map((f) => {
|
|
18
|
+
const m = (read(f) ?? '').match(/^module\s+(\S+)/mu);
|
|
19
|
+
return m ? { dir: dirOf(f) === '.' ? '' : dirOf(f), path: m[1] } : null;
|
|
20
|
+
}).filter(Boolean).sort((a, b) => b.path.length - a.path.length);
|
|
21
|
+
if (!mods.length) return;
|
|
22
|
+
const byDir = new Map();
|
|
23
|
+
for (const f of files) if (/\.go$/u.test(f) && !/_test\.go$/u.test(f)) byDir.set(dirOf(f), [...(byDir.get(dirOf(f)) ?? []), f]);
|
|
24
|
+
for (const from of files.filter((f) => /\.go$/u.test(f))) {
|
|
25
|
+
const body = read(from);
|
|
26
|
+
if (body === null) continue;
|
|
27
|
+
const specs = [];
|
|
28
|
+
for (const m of body.matchAll(/^\s*import\s*\(([\s\S]*?)\)/gmu)) for (const s of m[1].matchAll(/"([^"\n]+)"/gu)) specs.push([s[1], m.index + m[0].indexOf(s[0])]);
|
|
29
|
+
for (const m of body.matchAll(/^\s*import\s+(?:[\w.]+\s+)?"([^"\n]+)"/gmu)) specs.push([m[1], m.index]);
|
|
30
|
+
for (const [spec, at] of specs) {
|
|
31
|
+
const mod = mods.find((x) => spec === x.path || spec.startsWith(`${x.path}/`));
|
|
32
|
+
if (!mod) continue;
|
|
33
|
+
const dir = posix.join(mod.dir, spec.slice(mod.path.length).replace(/^\//u, '')) || '.';
|
|
34
|
+
for (const to of byDir.get(dir === '' ? '.' : dir) ?? []) add(from, to, lineAt(body, at));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Rust: `mod x;` declares a child module file; `use crate::a::b` (and `super::`, `self::`, or the crate's own name
|
|
40
|
+
// from tests/) reaches the file of the longest module path that exists.
|
|
41
|
+
function rustEdges(files, read, add) {
|
|
42
|
+
const tracked = new Set(files);
|
|
43
|
+
const rs = files.filter((f) => /\.rs$/u.test(f));
|
|
44
|
+
if (!rs.length) return;
|
|
45
|
+
const crates = files.filter((f) => /(?:^|\/)Cargo\.toml$/u.test(f)).map((f) => {
|
|
46
|
+
const name = (read(f) ?? '').match(/^\s*name\s*=\s*"([^"]+)"/mu)?.[1]?.replace(/-/gu, '_');
|
|
47
|
+
const root = dirOf(f) === '.' ? '' : dirOf(f);
|
|
48
|
+
return { root, name, src: posix.join(root, 'src') };
|
|
49
|
+
});
|
|
50
|
+
const crateOf = (f) => crates.filter((c) => !c.root || f.startsWith(`${c.root}/`)).sort((a, b) => b.root.length - a.root.length)[0] ?? { root: '', name: null, src: 'src' };
|
|
51
|
+
const moduleFile = (dir, parts) => {
|
|
52
|
+
for (let n = parts.length; n > 0; n--) {
|
|
53
|
+
const base = posix.join(dir, ...parts.slice(0, n));
|
|
54
|
+
const hit = [`${base}.rs`, `${base}/mod.rs`].find((c) => tracked.has(c));
|
|
55
|
+
if (hit) return hit;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
};
|
|
59
|
+
// The folder a file's own child modules live in: src/a.rs and src/a/mod.rs both keep theirs in src/a/.
|
|
60
|
+
const childDir = (f) => (/(?:^|\/)(?:mod|lib|main)\.rs$/u.test(f) ? dirOf(f) : f.replace(/\.rs$/u, ''));
|
|
61
|
+
const modulePath = (f, c) => {
|
|
62
|
+
const rel = posix.relative(c.src, f).replace(/\.rs$/u, '').split('/');
|
|
63
|
+
if (['lib', 'main'].includes(rel.at(-1)) && rel.length === 1) return [];
|
|
64
|
+
if (rel.at(-1) === 'mod') rel.pop();
|
|
65
|
+
return rel;
|
|
66
|
+
};
|
|
67
|
+
for (const from of rs) {
|
|
68
|
+
const body = read(from);
|
|
69
|
+
if (body === null) continue;
|
|
70
|
+
const code = noComments(body);
|
|
71
|
+
const c = crateOf(from);
|
|
72
|
+
for (const m of code.matchAll(/^\s*(?:pub(?:\([^)]*\))?\s+)?mod\s+(\w+)\s*;/gmu)) add(from, moduleFile(childDir(from), [m[1]]), lineAt(body, m.index));
|
|
73
|
+
// `use a::b::{c, d::e}` is read as a::b::c and a::b::d::e (one level of braces).
|
|
74
|
+
const paths = [];
|
|
75
|
+
for (const m of code.matchAll(/\buse\s+((?:crate|super|self|\w+)(?:::\w+)*)(?:::\{([^}]*)\})?/gu)) {
|
|
76
|
+
const inner = (m[2] ?? '').split(',').map((x) => x.trim().replace(/\s+as\s+\w+$/u, '')).filter((x) => /^\w+(?:::\w+)*$/u.test(x) && x !== 'self');
|
|
77
|
+
for (const p of inner.length ? inner.map((x) => `${m[1]}::${x}`) : [m[1]]) paths.push([p, m.index]);
|
|
78
|
+
}
|
|
79
|
+
for (const [path, index] of paths) {
|
|
80
|
+
const m = { index };
|
|
81
|
+
const parts = path.split('::');
|
|
82
|
+
let base;
|
|
83
|
+
if (parts[0] === 'crate' || (c.name && parts[0] === c.name)) base = [];
|
|
84
|
+
else if (parts[0] === 'self') base = modulePath(from, c);
|
|
85
|
+
else if (parts[0] === 'super') { base = modulePath(from, c); while (parts[0] === 'super') { parts.shift(); base = base.slice(0, -1); } parts.unshift('_'); }
|
|
86
|
+
else continue;
|
|
87
|
+
// Nothing below the crate root names a file: the crate root itself (lib.rs, else main.rs) is what is used.
|
|
88
|
+
add(from, moduleFile(c.src, [...base, ...parts.slice(1)]) ?? (base.length ? null : [posix.join(c.src, 'lib.rs'), posix.join(c.src, 'main.rs')].find((x) => tracked.has(x) && x !== from)), lineAt(body, m.index));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Elixir: modules are found by `defmodule`; any dotted module name the code names (alias, import, use, require or a
|
|
94
|
+
// call such as MyApp.Billing.charge) reaches the file that defines it. `alias A.{B, C}` is expanded.
|
|
95
|
+
function elixirEdges(files, read, add) {
|
|
96
|
+
const ex = files.filter((f) => /\.exs?$/u.test(f));
|
|
97
|
+
const defines = new Map();
|
|
98
|
+
const bodies = new Map();
|
|
99
|
+
for (const f of ex) {
|
|
100
|
+
const body = read(f);
|
|
101
|
+
if (body === null) continue;
|
|
102
|
+
bodies.set(f, body);
|
|
103
|
+
for (const m of body.matchAll(/^\s*defmodule\s+([A-Z][\w.]*)/gmu)) if (!defines.has(m[1])) defines.set(m[1], f);
|
|
104
|
+
}
|
|
105
|
+
if (!defines.size) return;
|
|
106
|
+
for (const [from, body] of bodies) {
|
|
107
|
+
const code = blank(body, /#[^\n]*/gu);
|
|
108
|
+
for (const m of code.matchAll(/\balias\s+([A-Z][\w.]*)\.\{([^}]*)\}/gu))
|
|
109
|
+
for (const n of m[2].split(',').map((s) => s.trim()).filter(Boolean)) add(from, defines.get(`${m[1]}.${n}`), lineAt(body, m.index));
|
|
110
|
+
for (const m of code.matchAll(/(?<![\w.])([A-Z]\w*(?:\.[A-Z]\w*)+)/gu)) {
|
|
111
|
+
// The longest defined prefix: MyApp.Billing.Invoice.new names MyApp.Billing.Invoice.
|
|
112
|
+
const parts = m[1].split('.');
|
|
113
|
+
for (let n = parts.length; n > 0; n--) {
|
|
114
|
+
const to = defines.get(parts.slice(0, n).join('.'));
|
|
115
|
+
if (to) { add(from, to, lineAt(body, m.index)); break; }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Types by namespace, for C# and Java/Kotlin: a file reaches the files declaring types it names, when that type's
|
|
122
|
+
// namespace (package) is its own or one it imports. Names are matched as whole words, outside comments and strings.
|
|
123
|
+
function typeEdges(files, read, add, { ext, nsRe, typeRe, importRe, wildcard }) {
|
|
124
|
+
const src = files.filter((f) => ext.test(f));
|
|
125
|
+
if (!src.length) return;
|
|
126
|
+
const types = new Map(); // namespace -> Map(type -> file)
|
|
127
|
+
const info = new Map();
|
|
128
|
+
for (const f of src) {
|
|
129
|
+
const body = read(f);
|
|
130
|
+
if (body === null) continue;
|
|
131
|
+
const code = blank(noComments(body), /"(?:[^"\\\n]|\\.)*"/gu);
|
|
132
|
+
const ns = code.match(nsRe)?.[1] ?? '';
|
|
133
|
+
const declared = new Set([...code.matchAll(typeRe)].map((m) => m[1]));
|
|
134
|
+
if (!declared.size && /\.(?:java|kt)$/u.test(f)) declared.add(posix.basename(f).replace(/\.\w+$/u, ''));
|
|
135
|
+
const space = types.get(ns) ?? new Map();
|
|
136
|
+
for (const t of declared) if (!space.has(t)) space.set(t, f);
|
|
137
|
+
types.set(ns, space);
|
|
138
|
+
info.set(f, { body, code, ns, declared });
|
|
139
|
+
}
|
|
140
|
+
for (const [from, { body, code, ns, declared }] of info) {
|
|
141
|
+
const spaces = new Set([ns]);
|
|
142
|
+
const single = new Map();
|
|
143
|
+
for (const m of code.matchAll(importRe)) {
|
|
144
|
+
const name = m[1];
|
|
145
|
+
if (wildcard(name)) spaces.add(wildcard(name));
|
|
146
|
+
else if (types.has(name)) spaces.add(name);
|
|
147
|
+
else { const i = name.lastIndexOf('.'); if (i > 0) single.set(name.slice(i + 1), name.slice(0, i)); }
|
|
148
|
+
}
|
|
149
|
+
// C#: an enclosing namespace's types are visible too (Company.App.Billing sees Company.App).
|
|
150
|
+
for (let n = ns; n.includes('.'); ) { n = n.slice(0, n.lastIndexOf('.')); spaces.add(n); }
|
|
151
|
+
const candidates = new Map();
|
|
152
|
+
for (const s of spaces) for (const [t, f] of types.get(s) ?? []) if (!candidates.has(t)) candidates.set(t, f);
|
|
153
|
+
for (const [t, s] of single) { const f = types.get(s)?.get(t); if (f) candidates.set(t, f); }
|
|
154
|
+
for (const [t, to] of candidates) {
|
|
155
|
+
if (to === from || declared.has(t)) continue;
|
|
156
|
+
const m = new RegExp(`\\b${escape(t)}\\b`, 'u').exec(code);
|
|
157
|
+
if (m) add(from, to, lineAt(body, m.index));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const csharpEdges = (files, read, add) => typeEdges(files, read, add, {
|
|
163
|
+
ext: /\.cs$/u,
|
|
164
|
+
nsRe: /^\s*namespace\s+([\w.]+)/mu,
|
|
165
|
+
typeRe: /\b(?:class|record|interface|struct|enum)\s+(\w+)/gu,
|
|
166
|
+
importRe: /^\s*(?:global\s+)?using\s+(?:static\s+)?([\w.]+)\s*;/gmu,
|
|
167
|
+
wildcard: () => null,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const jvmEdges = (files, read, add) => typeEdges(files, read, add, {
|
|
171
|
+
ext: /\.(?:java|kt)$/u,
|
|
172
|
+
nsRe: /^\s*package\s+([\w.]+)/mu,
|
|
173
|
+
typeRe: /\b(?:class|interface|enum|record|object)\s+(\w+)/gu,
|
|
174
|
+
importRe: /^\s*import\s+(?:static\s+)?([\w.]+(?:\.\*)?)\s*;?/gmu,
|
|
175
|
+
wildcard: (name) => (name.endsWith('.*') ? name.slice(0, -2) : null),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// Ruby: require_relative and require of a file in lib/, plus Rails-style constants (Billing::Invoice) found by the file
|
|
179
|
+
// path that autoloading would use (app/*/billing/invoice.rb, lib/billing/invoice.rb).
|
|
180
|
+
const camel = (s) => s.split('_').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join('');
|
|
181
|
+
function rubyEdges(files, read, add) {
|
|
182
|
+
const tracked = new Set(files);
|
|
183
|
+
const rb = files.filter((f) => /\.rb$/u.test(f));
|
|
184
|
+
if (!rb.length) return;
|
|
185
|
+
const constants = new Map();
|
|
186
|
+
for (const f of rb) {
|
|
187
|
+
const m = f.match(/^(?:app\/[^/]+|lib)\/(.+)\.rb$/u);
|
|
188
|
+
if (m && !f.startsWith('app/views/')) constants.set(m[1].split('/').map(camel).join('::'), f);
|
|
189
|
+
}
|
|
190
|
+
for (const from of rb) {
|
|
191
|
+
const body = read(from);
|
|
192
|
+
if (body === null) continue;
|
|
193
|
+
const code = blank(body, /#[^\n{]*$/gmu);
|
|
194
|
+
for (const m of code.matchAll(/\brequire_relative\s+['"]([^'"]+)['"]/gu)) {
|
|
195
|
+
const p = posix.normalize(posix.join(dirOf(from), m[1]));
|
|
196
|
+
add(from, [p, `${p}.rb`].find((c) => tracked.has(c)), lineAt(body, m.index));
|
|
197
|
+
}
|
|
198
|
+
for (const m of code.matchAll(/\brequire\s+['"]([^'"]+)['"]/gu)) add(from, [`lib/${m[1]}.rb`, `${m[1]}.rb`].find((c) => tracked.has(c)), lineAt(body, m.index));
|
|
199
|
+
for (const m of code.matchAll(/(?<![\w:])((?:[A-Z]\w*::)*[A-Z]\w*)/gu)) add(from, constants.get(m[1]), lineAt(body, m.index));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// PHP: `use Vendor\Pkg\Class;` and fully qualified names, mapped to files through composer.json's PSR-4 prefixes.
|
|
204
|
+
function phpEdges(files, read, add) {
|
|
205
|
+
const tracked = new Set(files);
|
|
206
|
+
const php = files.filter((f) => /\.php$/u.test(f));
|
|
207
|
+
if (!php.length) return;
|
|
208
|
+
const prefixes = [];
|
|
209
|
+
for (const c of files.filter((f) => /(?:^|\/)composer\.json$/u.test(f))) {
|
|
210
|
+
let j;
|
|
211
|
+
try { j = JSON.parse(read(c) ?? '{}'); } catch { continue; }
|
|
212
|
+
for (const section of [j.autoload, j['autoload-dev']]) for (const [ns, dirs] of Object.entries(section?.['psr-4'] ?? {}))
|
|
213
|
+
for (const d of [dirs].flat()) prefixes.push({ ns: ns.replace(/\\+$/u, ''), dir: posix.join(dirOf(c), d) });
|
|
214
|
+
}
|
|
215
|
+
prefixes.sort((a, b) => b.ns.length - a.ns.length);
|
|
216
|
+
const fileOf = (fq) => {
|
|
217
|
+
const name = fq.replace(/^\\/u, '');
|
|
218
|
+
for (const p of prefixes) if (name === p.ns || name.startsWith(`${p.ns}\\`)) {
|
|
219
|
+
const f = posix.join(p.dir, `${name.slice(p.ns.length + 1).replace(/\\/gu, '/')}.php`);
|
|
220
|
+
if (tracked.has(f)) return f;
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
};
|
|
224
|
+
if (!prefixes.length) return;
|
|
225
|
+
for (const from of php) {
|
|
226
|
+
const body = read(from);
|
|
227
|
+
if (body === null) continue;
|
|
228
|
+
const code = noComments(body);
|
|
229
|
+
for (const m of code.matchAll(/^\s*use\s+([\w\\]+)(?:\s+as\s+\w+)?\s*;/gmu)) add(from, fileOf(m[1]), lineAt(body, m.index));
|
|
230
|
+
for (const m of code.matchAll(/\\?\b([A-Z]\w*(?:\\[A-Z]\w*)+)\b/gu)) add(from, fileOf(m[1]), lineAt(body, m.index));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export const LANGUAGES = ['Go', 'Rust', 'Elixir', 'C#', 'Java', 'Kotlin', 'Ruby', 'PHP'];
|
|
235
|
+
|
|
236
|
+
export function moreEdges(files, read, add) {
|
|
237
|
+
for (const reader of [goEdges, rustEdges, elixirEdges, csharpEdges, jvmEdges, rubyEdges, phpEdges]) reader(files, read, add);
|
|
238
|
+
}
|
package/src/map.mjs
CHANGED
|
@@ -219,6 +219,22 @@ export function fixMap(map, files) {
|
|
|
219
219
|
return { map: out, changes, problems: mapProblems(out, files) };
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
+
// A map that is only behind the code (new files that have a neighbour's group, patterns for deleted files) is settled
|
|
223
|
+
// the way `fix` would settle it, without writing anything; only what fix cannot decide stays a problem. Read lists,
|
|
224
|
+
// check and the Action use the settled map, so adding a file to an existing folder never fails a pull request.
|
|
225
|
+
// A new top-level folder is the one placement fix guesses at (it joins the root files' group), so it stays a problem:
|
|
226
|
+
// someone should give it a group.
|
|
227
|
+
export function settleMap(map, files) {
|
|
228
|
+
const problems = mapProblems(map, files);
|
|
229
|
+
if (!problems.length) return { map, problems: [], settled: [] };
|
|
230
|
+
let fixed;
|
|
231
|
+
try { fixed = fixMap(map, files); } catch { return { map, problems, settled: [] }; }
|
|
232
|
+
const owned = matcher(map.groups.flatMap((g) => g.files));
|
|
233
|
+
const newTop = fixed.changes.map((c) => c.match(/^groups\.[^:]+: added ((\w[^/]*)\/.*)$/u)).filter((m) => m && !files.some((f) => f.startsWith(`${m[2]}/`) && owned(f)));
|
|
234
|
+
const left = [...fixed.problems, ...newTop.map((m) => `new top-level folder ${m[2]}/ is in no group: give it one (\`readfirst fix\` would put ${m[1]} with the root files)`)];
|
|
235
|
+
return { map: left.length ? map : fixed.map, problems: left.length ? problems.filter((p) => !/in no group|matches no/u.test(p)).concat(left) : [], settled: left.length ? fixed.changes.filter((c) => !newTop.some((m) => c.endsWith(`added ${m[1]}`))) : fixed.changes };
|
|
236
|
+
}
|
|
237
|
+
|
|
222
238
|
// The project's own check commands, found in its build files, so a first map already says what to run. Repository-wide
|
|
223
239
|
// on purpose: narrowing a check to the group it covers is left to a person or an agent.
|
|
224
240
|
export function draftChecks(files, read = () => null) {
|
|
@@ -240,5 +256,19 @@ export function draftChecks(files, read = () => null) {
|
|
|
240
256
|
if (has.has('Cargo.toml')) add('cargo test', 'the Rust tests');
|
|
241
257
|
const py = has.has('pyproject.toml') || has.has('setup.py') || has.has('pytest.ini');
|
|
242
258
|
if (py && files.some((f) => /(?:^|\/)(?:test_[^/]*|[^/]*_test)\.py$/u.test(f))) add('pytest', 'the Python tests');
|
|
259
|
+
if (files.some((f) => /\.(?:sln|csproj|fsproj)$/u.test(f))) add('dotnet test', 'the .NET tests');
|
|
260
|
+
if (has.has('mix.exs')) add('mix test', 'the Elixir tests');
|
|
261
|
+
if (has.has('gradlew') || has.has('build.gradle') || has.has('build.gradle.kts')) add(has.has('gradlew') ? './gradlew test' : 'gradle test', 'the JVM tests (Gradle)');
|
|
262
|
+
else if (has.has('pom.xml')) add(has.has('mvnw') ? './mvnw test' : 'mvn test', 'the JVM tests (Maven)');
|
|
263
|
+
if (has.has('Gemfile')) {
|
|
264
|
+
if (files.some((f) => /(?:^|\/)spec\/.*_spec\.rb$/u.test(f))) add('bundle exec rspec', 'the Ruby specs');
|
|
265
|
+
else if (files.some((f) => /(?:^|\/)test\/.*_test\.rb$/u.test(f))) add(has.has('bin/rails') ? 'bin/rails test' : 'bundle exec rake test', 'the Ruby tests');
|
|
266
|
+
}
|
|
267
|
+
if (has.has('composer.json')) {
|
|
268
|
+
let scripts = {};
|
|
269
|
+
try { scripts = JSON.parse(read('composer.json') ?? '{}').scripts ?? {}; } catch { scripts = {}; }
|
|
270
|
+
if (scripts.test) add('composer test', 'the PHP tests');
|
|
271
|
+
else if (has.has('phpunit.xml') || has.has('phpunit.xml.dist')) add('vendor/bin/phpunit', 'the PHP tests');
|
|
272
|
+
}
|
|
243
273
|
return out;
|
|
244
274
|
}
|
package/src/readlist.mjs
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
import { fixMap, groupOf, matcher, MAP_PATH } from './map.mjs';
|
|
4
4
|
|
|
5
5
|
export const READ_LIST_FORMAT = 'readfirst/read-list@1';
|
|
6
|
-
|
|
6
|
+
// Test files by folder or name, across the languages imports.mjs reads: JS/TS, Python, Go, Rust (tests/), Elixir,
|
|
7
|
+
// C# (*Tests.cs, *.Tests/ projects), Java/Kotlin (src/test/, *Test), Ruby (spec/, *_spec.rb) and PHP (*Test.php).
|
|
8
|
+
const TEST = /(^|\/)(?:tests?|__tests__|spec|e2e)\/|\.(?:test|spec)\.[cm]?[jt]sx?$|(^|\/)test[-_][^/]*\.(?:[cm]?[jt]sx?|py)$|_test\.(?:py|go|exs|rb)$|_spec\.rb$|(^|\/)[^/]+\.(?:Unit|Integration)?Tests?\/|Tests?\.(?:cs|java|kt|php)$|(^|\/)src\/(?:test|androidTest|integrationTest)\//u;
|
|
7
9
|
export const isTest = (path) => TEST.test(path);
|
|
8
10
|
|
|
9
11
|
// The read list for no files, so `diff --json` is always JSON.
|