readfirst 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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
@@ -109,12 +111,15 @@ Each PR gets one comment, updated on every push, that lists:
109
111
  - the rules, risks and checks for the files the PR changes, with the checks as boxes to tick;
110
112
  - the tests that reach the changed code.
111
113
 
112
- The check fails when `.readfirst/map.json` no longer covers the code, for example when a new file is in no group. So the
113
- map is fixed in the same PR. The comment lists what `npx readfirst fix` would change, so the fix is usually one command.
114
+ A new file next to files the map already places joins their group without anyone touching the map: the check passes
115
+ and the comment notes where it went (`npx readfirst fix` writes it down whenever you like). The check fails only on what
116
+ needs a person: a new top-level folder, a rule whose files are all gone, a map that is broken in itself. The comment
117
+ lists what `npx readfirst fix` would change.
114
118
 
115
119
  | Input | Default | Meaning |
116
120
  |---|---|---|
117
121
  | `fail-on-map-problems` | `true` | `false` reports map problems without failing |
122
+ | `strict` | `false` | `true` also fails on new files that are only placed by their neighbours |
118
123
  | `comment` | `true` | `false` writes only the job summary |
119
124
  | `limit` | `30` | most tests and users listed per section |
120
125
 
@@ -157,7 +162,12 @@ On a PR from a fork, the token is read-only. The list then goes to the job summa
157
162
  - **rules**: promises the code must keep. A rule can span groups.
158
163
 
159
164
  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 (`import`, `export … from`, `import()`, `require`) and Python.
165
+ Imports are read for JavaScript/TypeScript, Python, Go, Rust, Elixir, C#, Java, Kotlin, Ruby (including Rails autoloaded
166
+ constants) and PHP (through composer's PSR-4 map). `init` suggests the checks those projects already have: `npm test`,
167
+ `pytest`, `go test ./...`, `cargo test`, `mix test`, `dotnet test`, `./gradlew test` or `mvn test`, `bundle exec rspec`,
168
+ `composer test`.
169
+
170
+ readfirst needs Node 20 or newer to run, whatever language the project is in. `npx` comes with Node.
161
171
 
162
172
  ## License
163
173
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "readfirst",
3
- "version": "0.6.1",
3
+ "version": "0.7.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 { fixMap, MAP_PATH, mapProblems } from './map.mjs';
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,14 @@ 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 = [] }) {
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 (settled.length) {
40
+ 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
41
  }
40
42
  if (list) parts.push(toMarkdown(list, { checklist: true, heading: null }));
41
43
  else if (!problems.length) parts.push('No changed files to look up.', '');
@@ -48,8 +50,17 @@ export function commentBody({ list, problems, removed, commit, fixes = [] }) {
48
50
 
49
51
  export async function runAction({ env = process.env, fetchImpl = fetch, root = env.GITHUB_WORKSPACE ?? process.cwd(), log = (s) => process.stdout.write(`${s}\n`) } = {}) {
50
52
  const files = repoFiles(root);
51
- const map = readMapFile(root);
52
- const problems = mapProblems(map, files);
53
+ // New files beside placed ones and patterns for deleted files are settled as `fix` would; only the rest fails.
54
+ const strict = (env.INPUT_STRICT ?? 'false') === 'true';
55
+ const raw = readMapFile(root);
56
+ const s = settleMap(raw, files);
57
+ // Strict: what fix would settle still counts as a problem, and the map is used as written.
58
+ const strictOff = !strict || !s.settled.length;
59
+ const map = strictOff ? s.map : raw;
60
+ const problems = strictOff ? s.problems : [...s.problems, ...s.settled.map((c) => `not yet written in the map: ${c}`)];
61
+ // With problems left, what fix would change is the help; without, it is a note.
62
+ const settled = problems.length ? [] : s.settled;
63
+ const fixes = problems.length ? s.settled : [];
53
64
  const failOnProblems = (env['INPUT_FAIL-ON-MAP-PROBLEMS'] ?? env.INPUT_FAIL_ON_MAP_PROBLEMS ?? 'true') !== 'false';
54
65
  const event = env.GITHUB_EVENT_PATH ? JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, 'utf8')) : {};
55
66
  const pr = event.pull_request;
@@ -67,8 +78,7 @@ export async function runAction({ env = process.env, fetchImpl = fetch, root = e
67
78
  const list = targets.length && !problems.length
68
79
  ? readList({ map, files, imports: importEdges(files, readText(root)), targets, commit: pr.head?.sha ?? null, limit: Number(env.INPUT_LIMIT || 30) })
69
80
  : null;
70
- const fixes = problems.length ? (() => { try { return fixMap(map, files).changes; } catch { return []; } })() : [];
71
- const body = commentBody({ list, problems, removed, commit: pr.head?.sha ?? null, fixes });
81
+ const body = commentBody({ list, problems, removed, commit: pr.head?.sha ?? null, fixes, settled });
72
82
  summary(body);
73
83
 
74
84
  if ((env.INPUT_COMMENT ?? 'true') !== 'false') {
package/src/cli.mjs CHANGED
@@ -16,7 +16,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
16
16
  import { tmpdir } from 'node:os';
17
17
  import { basename, isAbsolute, join, relative } from 'node:path';
18
18
  import { importEdges } from './imports.mjs';
19
- import { assertMap, draftMap, fixMap, MAP_PATH, mapProblems } from './map.mjs';
19
+ import { assertMap, draftMap, fixMap, MAP_PATH, mapProblems, settleMap } from './map.mjs';
20
20
  import { agentBrief } from './prompt.mjs';
21
21
  import { keepLocal, labelsBrief, LOCAL_DIR, readLabels } from './labels.mjs';
22
22
  import { viewData } from './view.mjs';
@@ -28,7 +28,7 @@ const HELP = `readfirst — what to read and check before changing a file
28
28
 
29
29
  readfirst init [--agent] write a first ${MAP_PATH} from the file tree
30
30
  readfirst prompt brief for your coding agent to fill in the map
31
- readfirst check check the map covers every tracked file
31
+ readfirst check [--strict] check the map covers every tracked file (new files beside placed ones pass; --strict fails on them)
32
32
  readfirst fix place new files, drop patterns for deleted ones
33
33
  readfirst read <file>... read list for files you are about to change
34
34
  readfirst diff [--base <ref>] read list for files changed since <ref>
@@ -50,6 +50,7 @@ function parse(argv) {
50
50
  else if (a === '--agent') args.agent = true;
51
51
  else if (a === '--install') args.install = true;
52
52
  else if (a === '--dry-run') args.dryRun = true;
53
+ else if (a === '--strict') args.strict = true;
53
54
  else if (a === '-h' || a === '--help') args.help = true;
54
55
  else if (a.startsWith('--')) { const v = argv[++i]; if (v === undefined) throw new Error(`${a} needs a value`); args[a.slice(2)] = v; }
55
56
  else args._.push(a);
@@ -86,9 +87,10 @@ function loadMapLenient(root, files, io) {
86
87
  const map = readMapFile(root);
87
88
  const broken = mapProblems(map, files, { placement: false });
88
89
  if (broken.length) throw new Error(`${MAP_PATH} has problems:\n- ${broken.join('\n- ')}`);
89
- const behind = mapProblems(map, files);
90
+ const { map: settled, problems: behind, settled: placed } = settleMap(map, files);
90
91
  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
- return map;
92
+ 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`);
93
+ return settled;
92
94
  }
93
95
 
94
96
  // The one line that makes a coding agent use readfirst, added to the agent notes the project already has (CLAUDE.md,
@@ -218,6 +220,23 @@ export function main(argv, io = { out: (s) => process.stdout.write(s), err: (s)
218
220
  if (out) io.out(`${out}\n`);
219
221
  return 0;
220
222
  }
223
+ if (cmd === 'mcp') {
224
+ // The server starts and answers initialize and tools/list anywhere, also outside a git repository (directories
225
+ // start it in an empty container to check it). The repository is looked up at each tool call.
226
+ const version = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
227
+ const run = (argv) => {
228
+ let out = ''; let err = ''; let code;
229
+ try {
230
+ const root = args.root ?? (() => {
231
+ try { return git(process.cwd(), 'rev-parse', '--show-toplevel').trim(); } catch { throw new Error(`not inside a git repository (${process.cwd()}): start the server in your repository, or pass --root <dir>`); }
232
+ })();
233
+ code = main(['--root', root, ...argv], { out: (s) => { out += s; }, err: (s) => { err += s; } });
234
+ } catch (e) { code = 2; err += e.message; }
235
+ return { code, out, err };
236
+ };
237
+ serveMcp({ handle: mcpHandler({ run, version }), input: process.stdin, output: process.stdout });
238
+ return 0;
239
+ }
221
240
  const root = args.root ?? git(process.cwd(), 'rev-parse', '--show-toplevel').trim();
222
241
  const files = repoFiles(root);
223
242
  const commit = (() => { try { return git(root, 'rev-parse', 'HEAD').trim(); } catch { return null; } })();
@@ -262,9 +281,16 @@ export function main(argv, io = { out: (s) => process.stdout.write(s), err: (s)
262
281
  return 0;
263
282
  }
264
283
  if (cmd === 'check') {
265
- const problems = mapProblems(readMapFile(root), files);
266
- if (args.json) io.out(`${JSON.stringify({ ok: !problems.length, problems }, null, 2)}\n`);
267
- 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`);
284
+ // Placing new files next to their neighbours and dropping patterns for deleted files is what `fix` does; check
285
+ // settles those the same way and passes. --strict fails on them too.
286
+ const map = readMapFile(root);
287
+ const { problems, settled } = args.strict ? { problems: mapProblems(map, files), settled: [] } : settleMap(map, files);
288
+ if (args.json) io.out(`${JSON.stringify({ ok: !problems.length, problems, settled }, null, 2)}\n`);
289
+ else {
290
+ if (problems.length) io.out(`${MAP_PATH} has ${problems.length} problem(s):\n- ${problems.join('\n- ')}\n`);
291
+ else io.out(`${MAP_PATH} is good: ${files.length} files.\n`);
292
+ if (settled.length) io.out(`settled without writing (\`readfirst fix\` writes it down):\n- ${settled.join('\n- ')}\n`);
293
+ }
268
294
  return problems.length ? 1 : 0;
269
295
  }
270
296
  if (cmd === 'hook') {
@@ -298,16 +324,6 @@ export function main(argv, io = { out: (s) => process.stdout.write(s), err: (s)
298
324
  io.out(args.json ? `${JSON.stringify(list, null, 2)}\n` : `${toMarkdown(list)}\n`);
299
325
  return 0;
300
326
  }
301
- if (cmd === 'mcp') {
302
- const version = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
303
- const run = (argv) => {
304
- let out = ''; let err = ''; let code;
305
- try { code = main(['--root', root, ...argv], { out: (s) => { out += s; }, err: (s) => { err += s; } }); } catch (e) { code = 2; err += e.message; }
306
- return { code, out, err };
307
- };
308
- serveMcp({ handle: mcpHandler({ run, version }), input: process.stdin, output: process.stdout });
309
- return 0;
310
- }
311
327
  if (cmd === 'verify') {
312
328
  const map = loadMapLenient(root, files, io);
313
329
  const changed = changedFiles(root, args.base);
package/src/imports.mjs CHANGED
@@ -1,7 +1,9 @@
1
1
  // Which tracked file imports which, read from the code. Only imports that resolve to a tracked file count: packages and
2
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.
3
+ // (import a.b, from a.b import c, relative from . imports) are read here; Go, Rust, Elixir, C#, Java/Kotlin, Ruby and
4
+ // PHP in langs.mjs.
4
5
  import { posix } from 'node:path';
6
+ import { moreEdges } from './langs.mjs';
5
7
 
6
8
  const JS = /\.(?:m?js|cjs|tsx?|jsx|mts|cts|vue|svelte)$/u;
7
9
  const PY = /\.py$/u;
@@ -62,5 +64,6 @@ export function importEdges(files, read) {
62
64
  }
63
65
  }
64
66
  }
67
+ moreEdges([...files].sort(), read, add);
65
68
  return out;
66
69
  }
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
- const TEST = /(^|\/)(?:tests?|__tests__|spec|e2e)\/|\.(?:test|spec)\.[cm]?[jt]sx?$|(^|\/)test[-_][^/]*\.(?:[cm]?[jt]sx?|py)$|_test\.(?:py|go)$/u;
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.