@expo/code-review-cli 0.7.0 → 0.9.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 +161 -13
- package/build/cli.js +12 -0
- package/build/commands/ci.js +299 -28
- package/build/commands/dismiss.js +6 -0
- package/build/commands/doctor.js +3 -0
- package/build/commands/feedback.js +433 -0
- package/build/commands/init.js +231 -15
- package/build/commands/ref-check.js +84 -0
- package/build/commands/review.js +191 -51
- package/build/commands/setup-auth.js +3 -0
- package/build/commands/verify-config.js +3 -0
- package/build/config/load.js +39 -0
- package/build/config/routing.js +7 -0
- package/build/config/schema.js +92 -0
- package/build/core/adjudicate.js +194 -0
- package/build/core/auth.js +5 -1
- package/build/core/claude-code.js +12 -1
- package/build/core/config-refs.js +772 -0
- package/build/core/context-file.js +42 -0
- package/build/core/coordinator.js +2 -2
- package/build/core/diff.js +1 -0
- package/build/core/exec.js +4 -0
- package/build/core/log.js +1 -0
- package/build/core/noise.js +5 -0
- package/build/core/opencode.js +22 -0
- package/build/core/prompts.js +311 -3
- package/build/core/render.js +268 -45
- package/build/core/responses.js +158 -0
- package/build/core/review.js +307 -15
- package/build/core/schema.js +223 -2
- package/build/core/scrub.js +4 -0
- package/build/core/stack-confirm.js +137 -0
- package/build/core/stack.js +25 -0
- package/build/core/step-summary.js +1 -0
- package/build/core/suppress.js +2 -0
- package/build/core/throttle.js +2 -0
- package/build/core/util.js +1 -0
- package/build/core/verify.js +5 -0
- package/build/reporters/github.js +465 -31
- package/build/reporters/terminal.js +10 -0
- package/build/sources/github-pr.js +272 -0
- package/build/sources/local-git.js +3 -0
- package/build/sources/source.js +35 -0
- package/package.json +2 -1
- package/templates/agents/consistency.md +6 -1
- package/templates/agents/correctness.md +9 -1
- package/templates/agents/security.md +11 -1
- package/templates/atlantis.yml +123 -0
- package/templates/command.yml +4 -0
- package/templates/config.jsonc +50 -1
- package/templates/coordinator.md +34 -9
- package/templates/dismiss.yml +4 -0
- package/templates/routing.jsonc +3 -0
- package/templates/scope-config.jsonc +1 -0
- package/templates/shared.md +99 -1
- package/templates/workflow.yml +5 -0
|
@@ -0,0 +1,772 @@
|
|
|
1
|
+
// @ref LLP 0012#the-ref-grammar — one grammar for every code citation in a review setup
|
|
2
|
+
// @ref LLP 0012#no-line-numbers-symbol-anchors-instead — targets are files, dirs, globs, symbols; never line numbers
|
|
3
|
+
// @ref LLP 0012#unannotated-citations-are-broken-refs — a path-like backtick token that is not a ref fails the check
|
|
4
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { CONFIG_DIRNAME, stripJsonComments, stripTrailingCommas } from "../config/load.js";
|
|
7
|
+
import { ROUTING_FILENAME } from "../config/routing.js";
|
|
8
|
+
import { git, pathInside } from "./exec.js";
|
|
9
|
+
import { matchesIgnore } from "./noise.js";
|
|
10
|
+
/**
|
|
11
|
+
* Built by concatenation so this module's own regexes and doc comments are not
|
|
12
|
+
* themselves collected as refs by a scanner (the repo's `ref-check` does the same).
|
|
13
|
+
*/
|
|
14
|
+
const REF_MARK = "@" + "ref";
|
|
15
|
+
const IGNORE_MARK = REF_MARK + "-ignore";
|
|
16
|
+
/** `@ref <target> [relation] — gloss` inside a `//`-style comment. */
|
|
17
|
+
const LINE_REF_RE = new RegExp(String.raw `${REF_MARK}\s+(.+)`);
|
|
18
|
+
/** `<!-- @ref <target> — gloss -->`, possibly spanning lines. */
|
|
19
|
+
const MD_REF_RE = new RegExp(String.raw `<!--\s*${REF_MARK}\s+([\s\S]+?)-->`, "g");
|
|
20
|
+
const IGNORE_RE = new RegExp(String.raw `${IGNORE_MARK}\s+(.+)`);
|
|
21
|
+
/** LLP-corpus targets (`LLP 0004#anchor`) belong to the engine repo, not an adopting one. */
|
|
22
|
+
const LLP_TARGET_RE = /^LLP\s+\d{1,4}(?:#\S+)?$/;
|
|
23
|
+
const URL_RE = /^https?:\/\/\S+$/;
|
|
24
|
+
/** `<path>`, `<agent-id>`: documenting the grammar, not citing a file. */
|
|
25
|
+
const PLACEHOLDER_RE = /^[<`]/;
|
|
26
|
+
const GLOB_PREFIX = "glob:";
|
|
27
|
+
/** A citation that pins a line (`src/a.ts:42`, `src/a.ts:42-51`) — always refused. */
|
|
28
|
+
const LINE_CITATION_RE = /^(.+?):(\d+)(?:-\d+)?$/;
|
|
29
|
+
/**
|
|
30
|
+
* Extensions that make a backticked token a *code citation* rather than prose.
|
|
31
|
+
* Deliberately an allowlist: `openai/gpt-5.5` and `label:<agent>` must not read as
|
|
32
|
+
* paths, while `session.ts` and `.github/workflows/**` must.
|
|
33
|
+
*/
|
|
34
|
+
const CITATION_EXTENSIONS = new Set([
|
|
35
|
+
".c",
|
|
36
|
+
".cc",
|
|
37
|
+
".cjs",
|
|
38
|
+
".cpp",
|
|
39
|
+
".cs",
|
|
40
|
+
".css",
|
|
41
|
+
".go",
|
|
42
|
+
".graphql",
|
|
43
|
+
".h",
|
|
44
|
+
".hpp",
|
|
45
|
+
".html",
|
|
46
|
+
".java",
|
|
47
|
+
".js",
|
|
48
|
+
".json",
|
|
49
|
+
".jsonc",
|
|
50
|
+
".jsx",
|
|
51
|
+
".kt",
|
|
52
|
+
".lock",
|
|
53
|
+
".md",
|
|
54
|
+
".mjs",
|
|
55
|
+
".mts",
|
|
56
|
+
".php",
|
|
57
|
+
".prisma",
|
|
58
|
+
".proto",
|
|
59
|
+
".py",
|
|
60
|
+
".rb",
|
|
61
|
+
".rs",
|
|
62
|
+
".scss",
|
|
63
|
+
".sh",
|
|
64
|
+
".sql",
|
|
65
|
+
".swift",
|
|
66
|
+
".toml",
|
|
67
|
+
".ts",
|
|
68
|
+
".tsx",
|
|
69
|
+
".txt",
|
|
70
|
+
".vue",
|
|
71
|
+
".yaml",
|
|
72
|
+
".yml",
|
|
73
|
+
".zsh",
|
|
74
|
+
]);
|
|
75
|
+
/** Files inside a review-setup dir that are prompts or config (everything else is skipped). */
|
|
76
|
+
const SCANNED_EXTENSIONS = new Set([".md", ".markdown", ".json", ".jsonc", ".txt"]);
|
|
77
|
+
function escapeRegExp(text) {
|
|
78
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
79
|
+
}
|
|
80
|
+
/** GitHub-style heading slug: lowercase, spaces to hyphens, drop other punctuation. */
|
|
81
|
+
export function slugifyHeading(text) {
|
|
82
|
+
return [...text.replace(/`/g, "").trim().toLowerCase()]
|
|
83
|
+
.map((ch) => (/[a-z0-9\-_]/.test(ch) ? ch : /\s/.test(ch) ? "-" : ""))
|
|
84
|
+
.join("");
|
|
85
|
+
}
|
|
86
|
+
/** Heading texts outside fenced code blocks. */
|
|
87
|
+
function markdownHeadings(text) {
|
|
88
|
+
const headings = [];
|
|
89
|
+
let fenced = false;
|
|
90
|
+
for (const line of text.split(/\r?\n/)) {
|
|
91
|
+
if (line.trimStart().startsWith("```")) {
|
|
92
|
+
fenced = !fenced;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const match = fenced ? null : /^#{1,6}\s+(.+?)\s*$/.exec(line);
|
|
96
|
+
if (match) {
|
|
97
|
+
headings.push(match[1]);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return headings;
|
|
101
|
+
}
|
|
102
|
+
/** Strip the trailing `— gloss` and `[relation]` so only the target remains. */
|
|
103
|
+
function refTarget(body) {
|
|
104
|
+
const withoutGloss = body.split(/\s+(?:[—–]|--)(?:\s+|$)/)[0].trim();
|
|
105
|
+
const withoutRelation = withoutGloss.replace(/\s*\[[a-z-]+\]\s*$/, "").trim();
|
|
106
|
+
// `LLP 0009#anchor` is ONE target that contains a space; taking the first
|
|
107
|
+
// whitespace-separated token would leave a bare `LLP` and read as a broken path.
|
|
108
|
+
const llp = /^LLP\s+\d{1,4}(?:#\S+)?/.exec(withoutRelation);
|
|
109
|
+
return llp ? llp[0] : (withoutRelation.split(/\s+/)[0] ?? "");
|
|
110
|
+
}
|
|
111
|
+
/** Line numbers (1-based) that sit inside a fenced code block. */
|
|
112
|
+
function fencedLines(text) {
|
|
113
|
+
const fenced = new Set();
|
|
114
|
+
let open = false;
|
|
115
|
+
text.split(/\r?\n/).forEach((line, index) => {
|
|
116
|
+
if (line.trimStart().startsWith("```")) {
|
|
117
|
+
fenced.add(index + 1); // the fence line itself is never a ref site
|
|
118
|
+
open = !open;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (open) {
|
|
122
|
+
fenced.add(index + 1);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
return fenced;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Every `@ref` annotation in a setup file, markdown comments included. Fenced code
|
|
129
|
+
* blocks are skipped: an annotation shown inside one is documenting the grammar, and
|
|
130
|
+
* resolving it would make every doc that explains refs fail the check.
|
|
131
|
+
*/
|
|
132
|
+
export function parseRefAnnotations(text, file) {
|
|
133
|
+
const refs = [];
|
|
134
|
+
if (file.endsWith(".md") || file.endsWith(".markdown")) {
|
|
135
|
+
const fenced = fencedLines(text);
|
|
136
|
+
for (const match of text.matchAll(MD_REF_RE)) {
|
|
137
|
+
const line = text.slice(0, match.index).split("\n").length;
|
|
138
|
+
if (fenced.has(line)) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
refs.push({ file, line, target: refTarget(match[1]) });
|
|
142
|
+
}
|
|
143
|
+
return refs;
|
|
144
|
+
}
|
|
145
|
+
text.split(/\r?\n/).forEach((lineText, index) => {
|
|
146
|
+
const match = LINE_REF_RE.exec(lineText);
|
|
147
|
+
if (match) {
|
|
148
|
+
refs.push({ file, line: index + 1, target: refTarget(match[1]) });
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
return refs;
|
|
152
|
+
}
|
|
153
|
+
/** Tokens an author declared as prose, not citations (`@ref-ignore knex.raw()`). */
|
|
154
|
+
export function parseRefIgnores(text) {
|
|
155
|
+
const ignored = new Set();
|
|
156
|
+
for (const lineText of text.split(/\r?\n/)) {
|
|
157
|
+
const match = IGNORE_RE.exec(lineText);
|
|
158
|
+
if (!match) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
for (const token of match[1].replace(/-->\s*$/, "").split(/[\s,]+/)) {
|
|
162
|
+
const cleaned = token.replace(/^`|`$/g, "").trim();
|
|
163
|
+
if (cleaned) {
|
|
164
|
+
ignored.add(cleaned);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return ignored;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Does this backticked token cite code? True for `a/b/c.ts`, `session.ts`,
|
|
172
|
+
* `src/entities/oauth/` and `.github/workflows/**`; false for `ecr ci`,
|
|
173
|
+
* `label:<agent>`, `knex.raw()`, `openai/gpt-5.5` and bare `**`.
|
|
174
|
+
*/
|
|
175
|
+
export function isCodeCitation(token) {
|
|
176
|
+
if (!token || /[\s<>(){}"'|=,;]/.test(token) || token.length > 200) {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
const withoutLine = LINE_CITATION_RE.exec(token)?.[1] ?? token;
|
|
180
|
+
if (withoutLine.includes(":")) {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
if (withoutLine.endsWith("/")) {
|
|
184
|
+
return withoutLine.replace(/[/*]/g, "").length > 0;
|
|
185
|
+
}
|
|
186
|
+
const base = withoutLine.split("/").pop() ?? "";
|
|
187
|
+
// A wildcard tail inside a path (`.github/workflows/**`) is a citation with no extension.
|
|
188
|
+
if (base.includes("*") && withoutLine.includes("/")) {
|
|
189
|
+
return withoutLine.replace(/[/*]/g, "").length > 0;
|
|
190
|
+
}
|
|
191
|
+
// A bare extension (`.ts`, `.js`) is prose about a suffix, not a file.
|
|
192
|
+
if (CITATION_EXTENSIONS.has(base.toLowerCase())) {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
const dot = base.lastIndexOf(".");
|
|
196
|
+
if (dot <= 0 && !base.startsWith(".")) {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
const extension = base.slice(base.lastIndexOf("."));
|
|
200
|
+
return CITATION_EXTENSIONS.has(extension.toLowerCase()) && base.replace(/[*.]/g, "").length > 0;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* The ref an unannotated citation should have become. Abbreviated paths (`.../a/B.kt`),
|
|
204
|
+
* wildcards (`*Policy.ts`) and bare filenames (`session.ts`) become suffix globs, so a
|
|
205
|
+
* prompt keeps its short readable form and still gets checked.
|
|
206
|
+
*/
|
|
207
|
+
export function suggestedRef(token) {
|
|
208
|
+
const trimmed = token.replace(/^\.\.\.\/?/, "").replace(/^\/+/, "");
|
|
209
|
+
if (token.startsWith("...") || !trimmed.includes("/")) {
|
|
210
|
+
return `${GLOB_PREFIX}**/${trimmed}`;
|
|
211
|
+
}
|
|
212
|
+
return trimmed.includes("*") ? `${GLOB_PREFIX}${trimmed}` : trimmed;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Every form under which an annotated target can cover a prose citation, so one ref
|
|
216
|
+
* silences the token it is about: `glob:src/commands/*.ts` covers `src/commands/*.ts`,
|
|
217
|
+
* and `src/core/util.ts#errorMessage` covers `src/core/util.ts`.
|
|
218
|
+
*/
|
|
219
|
+
function coveringForms(target) {
|
|
220
|
+
const bare = target.startsWith(GLOB_PREFIX) ? target.slice(GLOB_PREFIX.length) : target;
|
|
221
|
+
const withoutAnchor = splitAnchor(bare)[0];
|
|
222
|
+
return [target, bare, withoutAnchor].flatMap((form) => [
|
|
223
|
+
form,
|
|
224
|
+
form.replace(/\/$/, ""),
|
|
225
|
+
`${form}/`,
|
|
226
|
+
]);
|
|
227
|
+
}
|
|
228
|
+
/** The forms a prose citation could have been annotated as. */
|
|
229
|
+
function citationForms(token) {
|
|
230
|
+
const suggested = suggestedRef(token);
|
|
231
|
+
return [
|
|
232
|
+
token,
|
|
233
|
+
token.replace(/\/$/, ""),
|
|
234
|
+
suggested,
|
|
235
|
+
suggested.startsWith(GLOB_PREFIX) ? suggested.slice(GLOB_PREFIX.length) : suggested,
|
|
236
|
+
];
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Paths to test when a token has no extension to give it away — `eas-build-worker/terraform`,
|
|
240
|
+
* `general-central/{module,production}`, a bare `finops`. These are only citations if they
|
|
241
|
+
* actually resolve, since `anthropic/claude-opus-5` is shaped exactly the same and is not
|
|
242
|
+
* a path. Returns the candidate paths to probe, or [] when the token cannot be one.
|
|
243
|
+
*/
|
|
244
|
+
export function pathishCandidates(token) {
|
|
245
|
+
if (!token || token.length > 200) {
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
248
|
+
// Cut a brace list or wildcard tail off FIRST: the part before it is the path to
|
|
249
|
+
// probe, and only that part has to look like one (`a/{b,c}` carries a comma).
|
|
250
|
+
const cut = Math.min(...[token.indexOf("{"), token.indexOf("*")].filter((index) => index >= 0), token.length);
|
|
251
|
+
const prefix = token.slice(0, cut).replace(/\/$/, "");
|
|
252
|
+
if (!prefix ||
|
|
253
|
+
!/[a-zA-Z]/.test(prefix) ||
|
|
254
|
+
/[\s<>()"'|=,;:]/.test(prefix) ||
|
|
255
|
+
prefix.startsWith("-")) {
|
|
256
|
+
return [];
|
|
257
|
+
}
|
|
258
|
+
return prefix === token ? [token] : [prefix];
|
|
259
|
+
}
|
|
260
|
+
/** Backticked code citations, outside fenced code blocks, with their line numbers. */
|
|
261
|
+
export function findProseCitations(text) {
|
|
262
|
+
const found = [];
|
|
263
|
+
let fenced = false;
|
|
264
|
+
text.split(/\r?\n/).forEach((lineText, index) => {
|
|
265
|
+
if (lineText.trimStart().startsWith("```")) {
|
|
266
|
+
fenced = !fenced;
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (fenced) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
for (const match of lineText.matchAll(/`([^`]+)`/g)) {
|
|
273
|
+
found.push({ line: index + 1, token: match[1] });
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
return found;
|
|
277
|
+
}
|
|
278
|
+
async function pathKind(absolute) {
|
|
279
|
+
try {
|
|
280
|
+
const stats = await stat(absolute);
|
|
281
|
+
return stats.isDirectory() ? "dir" : "file";
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return "missing";
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Resolve one ref target against the repo. Returns the problem text, or null when
|
|
289
|
+
* the ref holds. Refs are ALWAYS repo-root-relative — a scope's prompts cite the
|
|
290
|
+
* same paths the reviewer sees in the diff, so there is no per-directory base.
|
|
291
|
+
*/
|
|
292
|
+
async function resolveTarget(target, index,
|
|
293
|
+
/** A scope's own subtree, used only to say "did you mean" on a scope-relative path. */
|
|
294
|
+
scopeRoot) {
|
|
295
|
+
if (!target) {
|
|
296
|
+
return `empty ${REF_MARK} target`;
|
|
297
|
+
}
|
|
298
|
+
if (URL_RE.test(target) || LLP_TARGET_RE.test(target) || PLACEHOLDER_RE.test(target)) {
|
|
299
|
+
// URLs are shape-only (never fetched); LLP numbers belong to the engine's own
|
|
300
|
+
// corpus (`./ref-check`); `<placeholder>` targets are documentation of the syntax.
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
if (target.startsWith(GLOB_PREFIX)) {
|
|
304
|
+
const pattern = target.slice(GLOB_PREFIX.length);
|
|
305
|
+
if (!pattern) {
|
|
306
|
+
return `empty glob in ${REF_MARK} target`;
|
|
307
|
+
}
|
|
308
|
+
if (index.files.length === 0) {
|
|
309
|
+
return null; // no file list to match against (not a git checkout) — uncheckable
|
|
310
|
+
}
|
|
311
|
+
return index.files.some((file) => matchesIgnore(file, pattern))
|
|
312
|
+
? null
|
|
313
|
+
: `glob matches no file in the repo: ${pattern}`;
|
|
314
|
+
}
|
|
315
|
+
const lineCitation = LINE_CITATION_RE.exec(target);
|
|
316
|
+
if (lineCitation) {
|
|
317
|
+
return `cites a line number (${target}); refs pin a file, dir, glob, or \`#symbol\` — line numbers rot silently`;
|
|
318
|
+
}
|
|
319
|
+
const [rawPath, anchor] = splitAnchor(target);
|
|
320
|
+
if (path.isAbsolute(rawPath) || rawPath.startsWith("~")) {
|
|
321
|
+
return `absolute path (${rawPath}); refs are repo-root-relative`;
|
|
322
|
+
}
|
|
323
|
+
const absolute = path.resolve(index.root, rawPath);
|
|
324
|
+
if (!pathInside(absolute, index.root)) {
|
|
325
|
+
return `escapes the repository (${rawPath})`;
|
|
326
|
+
}
|
|
327
|
+
const kind = await pathKind(absolute);
|
|
328
|
+
if (kind === "missing") {
|
|
329
|
+
// A scope's prompts naturally say `general-central/module` for what is really
|
|
330
|
+
// `infrastructure/general-central/module`. That is still a broken ref (one base, no
|
|
331
|
+
// ambiguity), but the fix is named instead of left as a puzzle.
|
|
332
|
+
const scoped = scopeRoot ? path.resolve(scopeRoot, rawPath) : null;
|
|
333
|
+
if (scoped && pathInside(scoped, index.root) && (await pathKind(scoped)) !== "missing") {
|
|
334
|
+
const suggestion = path.relative(index.root, scoped).split(path.sep).join("/");
|
|
335
|
+
return `no such path in the repo: ${rawPath} — refs are repo-root-relative; did you mean ${suggestion}?`;
|
|
336
|
+
}
|
|
337
|
+
return `no such path in the repo: ${rawPath}`;
|
|
338
|
+
}
|
|
339
|
+
if (rawPath.endsWith("/") && kind !== "dir") {
|
|
340
|
+
return `${rawPath} is a file, not a directory (drop the trailing slash)`;
|
|
341
|
+
}
|
|
342
|
+
if (!anchor) {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
if (kind === "dir") {
|
|
346
|
+
return `${rawPath} is a directory, so \`#${anchor}\` cannot resolve`;
|
|
347
|
+
}
|
|
348
|
+
let content;
|
|
349
|
+
try {
|
|
350
|
+
content = await readFile(absolute, "utf8");
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
return null; // unreadable (binary, permissions) — uncheckable, never a failure
|
|
354
|
+
}
|
|
355
|
+
if (rawPath.endsWith(".md") || rawPath.endsWith(".markdown")) {
|
|
356
|
+
const slugs = new Set(markdownHeadings(content).map(slugifyHeading));
|
|
357
|
+
return slugs.has(slugifyHeading(anchor)) ? null : `${rawPath} has no heading #${anchor}`;
|
|
358
|
+
}
|
|
359
|
+
return new RegExp(String.raw `\b${escapeRegExp(anchor)}\b`).test(content)
|
|
360
|
+
? null
|
|
361
|
+
: `${rawPath} no longer contains \`${anchor}\``;
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* How a setup file is named in a problem. Normally repo-relative, but in CI the setup
|
|
365
|
+
* is materialized from the trusted base ref OUTSIDE the code tree, where a relative
|
|
366
|
+
* path would read as `../../tmp/…`; there, name it by its position in the setup dir.
|
|
367
|
+
*/
|
|
368
|
+
function fileLabel(root, setupDir, file) {
|
|
369
|
+
const relative = path.relative(root, file);
|
|
370
|
+
if (!relative.startsWith("..")) {
|
|
371
|
+
return relative;
|
|
372
|
+
}
|
|
373
|
+
return path.join(CONFIG_DIRNAME, path.relative(setupDir, file));
|
|
374
|
+
}
|
|
375
|
+
function splitAnchor(target) {
|
|
376
|
+
const hash = target.indexOf("#");
|
|
377
|
+
return hash === -1 ? [target, undefined] : [target.slice(0, hash), target.slice(hash + 1)];
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* The repo's files, repo-relative with `/` separators — what `glob:` targets and scope
|
|
381
|
+
* globs match against. `git ls-files` when possible (fast, and "tracked" is the right
|
|
382
|
+
* notion for reviewable code), else a filesystem walk so the check still works in a
|
|
383
|
+
* plain directory. Empty only for an unreadable root.
|
|
384
|
+
*/
|
|
385
|
+
async function listRepoFiles(root) {
|
|
386
|
+
try {
|
|
387
|
+
// Tracked AND untracked-but-not-ignored: a plain path ref resolves with `stat` and
|
|
388
|
+
// therefore sees a file the moment it exists, so a glob must too — otherwise a
|
|
389
|
+
// freshly scaffolded (uncommitted) tree reports globs as matching nothing.
|
|
390
|
+
const tracked = (await git(["ls-files", "--cached", "--others", "--exclude-standard"], root))
|
|
391
|
+
.split("\n")
|
|
392
|
+
.map((line) => line.trim())
|
|
393
|
+
.filter(Boolean);
|
|
394
|
+
if (tracked.length > 0) {
|
|
395
|
+
return tracked;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
// not a git checkout — fall through to the walk
|
|
400
|
+
}
|
|
401
|
+
const found = [];
|
|
402
|
+
async function walk(dir) {
|
|
403
|
+
let entries;
|
|
404
|
+
try {
|
|
405
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
for (const entry of entries) {
|
|
411
|
+
if (entry.name === "node_modules" || (entry.name.startsWith(".") && entry.isDirectory())) {
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
const child = path.join(dir, entry.name);
|
|
415
|
+
if (entry.isDirectory()) {
|
|
416
|
+
await walk(child);
|
|
417
|
+
}
|
|
418
|
+
else if (entry.isFile()) {
|
|
419
|
+
found.push(path.relative(root, child).split(path.sep).join("/"));
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
await walk(root);
|
|
424
|
+
return found.sort();
|
|
425
|
+
}
|
|
426
|
+
// @ref LLP 0012#what-gets-scanned [implements] — on-disk sweep of every setup dir, .runs/ excluded
|
|
427
|
+
/**
|
|
428
|
+
* Every review-setup directory in the repo, found by walking the tree (not the
|
|
429
|
+
* routing manifest): a scope dir the manifest forgot still ships prompts to nobody,
|
|
430
|
+
* and its stale refs are exactly what this check exists to surface.
|
|
431
|
+
*/
|
|
432
|
+
export async function discoverSetupDirs(root) {
|
|
433
|
+
const found = [];
|
|
434
|
+
async function walk(dir) {
|
|
435
|
+
let entries;
|
|
436
|
+
try {
|
|
437
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
for (const entry of entries) {
|
|
443
|
+
if (!entry.isDirectory()) {
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
const child = path.join(dir, entry.name);
|
|
447
|
+
if (entry.name === CONFIG_DIRNAME) {
|
|
448
|
+
found.push(child);
|
|
449
|
+
continue; // a setup dir never nests another
|
|
450
|
+
}
|
|
451
|
+
// Other dot dirs are skipped wholesale: `.claude/worktrees/` holds checkouts of
|
|
452
|
+
// this same repo, whose setup dirs would otherwise be swept as if they were scopes.
|
|
453
|
+
if (entry.name === "node_modules" || entry.name.startsWith(".")) {
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
await walk(child);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
await walk(root);
|
|
460
|
+
return found.sort();
|
|
461
|
+
}
|
|
462
|
+
/** Prompt and config files inside a setup dir. Skips `.runs/` and other dot entries. */
|
|
463
|
+
async function setupFiles(dir) {
|
|
464
|
+
const found = [];
|
|
465
|
+
async function walk(current) {
|
|
466
|
+
let entries;
|
|
467
|
+
try {
|
|
468
|
+
entries = await readdir(current, { withFileTypes: true });
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
for (const entry of entries) {
|
|
474
|
+
if (entry.name.startsWith(".")) {
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
const child = path.join(current, entry.name);
|
|
478
|
+
if (entry.isDirectory()) {
|
|
479
|
+
await walk(child);
|
|
480
|
+
}
|
|
481
|
+
else if (SCANNED_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
|
|
482
|
+
found.push(child);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
await walk(dir);
|
|
487
|
+
return found.sort();
|
|
488
|
+
}
|
|
489
|
+
function parseJsonc(text) {
|
|
490
|
+
try {
|
|
491
|
+
const value = JSON.parse(stripTrailingCommas(stripJsonComments(text)));
|
|
492
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
493
|
+
? value
|
|
494
|
+
: null;
|
|
495
|
+
}
|
|
496
|
+
catch {
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
function stringArray(value) {
|
|
501
|
+
return Array.isArray(value)
|
|
502
|
+
? value.filter((item) => typeof item === "string")
|
|
503
|
+
: [];
|
|
504
|
+
}
|
|
505
|
+
// @ref LLP 0012#structural-refs-need-no-annotation [implements] — ids and scope dirs are refs the config already declares
|
|
506
|
+
/**
|
|
507
|
+
* Refs the config declares structurally, so they are checked without an annotation:
|
|
508
|
+
* every `enforceAgents` id must have a root `agents/<id>.md`, every scope must point
|
|
509
|
+
* at a real setup dir, and every scope glob must match at least one tracked file (a
|
|
510
|
+
* glob matching nothing means those prompts review nothing).
|
|
511
|
+
*/
|
|
512
|
+
async function checkRoutingManifest(file, index, rootAgentIds) {
|
|
513
|
+
const problems = [];
|
|
514
|
+
const relative = fileLabel(index.root, path.dirname(file), file);
|
|
515
|
+
let parsed;
|
|
516
|
+
try {
|
|
517
|
+
parsed = parseJsonc(await readFile(file, "utf8"));
|
|
518
|
+
}
|
|
519
|
+
catch {
|
|
520
|
+
return problems;
|
|
521
|
+
}
|
|
522
|
+
if (!parsed) {
|
|
523
|
+
return [
|
|
524
|
+
{ file: relative, line: 1, kind: "structural", problem: "could not be parsed as JSONC" },
|
|
525
|
+
];
|
|
526
|
+
}
|
|
527
|
+
const defaults = (parsed.defaults ?? {});
|
|
528
|
+
const enforced = new Set(stringArray(defaults.enforceAgents));
|
|
529
|
+
const scopes = Array.isArray(parsed.scopes) ? parsed.scopes : [];
|
|
530
|
+
for (const scope of scopes) {
|
|
531
|
+
if (!scope || typeof scope !== "object") {
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
const entry = scope;
|
|
535
|
+
const name = typeof entry.name === "string" ? entry.name : "(unnamed)";
|
|
536
|
+
for (const id of stringArray(entry.enforceAgents)) {
|
|
537
|
+
enforced.add(id);
|
|
538
|
+
}
|
|
539
|
+
const configDir = typeof entry.config === "string" ? entry.config : null;
|
|
540
|
+
if (configDir) {
|
|
541
|
+
const setupDir = path.resolve(index.root, configDir, CONFIG_DIRNAME);
|
|
542
|
+
if (!pathInside(setupDir, index.root)) {
|
|
543
|
+
problems.push({
|
|
544
|
+
file: relative,
|
|
545
|
+
line: 1,
|
|
546
|
+
kind: "structural",
|
|
547
|
+
problem: `scope "${name}" points outside the repository (config: ${configDir})`,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
else if ((await pathKind(setupDir)) !== "dir") {
|
|
551
|
+
problems.push({
|
|
552
|
+
file: relative,
|
|
553
|
+
line: 1,
|
|
554
|
+
kind: "structural",
|
|
555
|
+
problem: `scope "${name}" has no ${configDir}/${CONFIG_DIRNAME}/ directory`,
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
if (index.files.length > 0) {
|
|
560
|
+
for (const pattern of stringArray(entry.paths)) {
|
|
561
|
+
const variants = [pattern, pattern.replace(/\*\*\//g, "")];
|
|
562
|
+
if (!index.files.some((repoFile) => variants.some((v) => v && matchesIgnore(repoFile, v)))) {
|
|
563
|
+
problems.push({
|
|
564
|
+
file: relative,
|
|
565
|
+
line: 1,
|
|
566
|
+
kind: "structural",
|
|
567
|
+
problem: `scope "${name}" glob matches no file in the repo: ${pattern}`,
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
for (const id of enforced) {
|
|
574
|
+
if (!rootAgentIds.has(id)) {
|
|
575
|
+
problems.push({
|
|
576
|
+
file: relative,
|
|
577
|
+
line: 1,
|
|
578
|
+
kind: "structural",
|
|
579
|
+
problem: `enforceAgents names "${id}", but the root setup has no agents/${id}.md`,
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return problems;
|
|
584
|
+
}
|
|
585
|
+
/** Agent ids in a setup dir (id = filename without `.md`, per the loader). */
|
|
586
|
+
async function agentIds(setupDir) {
|
|
587
|
+
try {
|
|
588
|
+
const entries = await readdir(path.join(setupDir, "agents"));
|
|
589
|
+
return new Set(entries.filter((name) => name.endsWith(".md")).map((name) => name.slice(0, -3)));
|
|
590
|
+
}
|
|
591
|
+
catch {
|
|
592
|
+
return new Set();
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
// @ref LLP 0012#run-points-command-and-review [implements] — one pure-ish entry point both the command and the review call
|
|
596
|
+
/**
|
|
597
|
+
* Check every code citation in the repo's review setup. Deterministic, no model, no
|
|
598
|
+
* network: refs either resolve against the tree or they do not.
|
|
599
|
+
*/
|
|
600
|
+
export async function checkConfigRefs(options) {
|
|
601
|
+
const root = path.resolve(options.root);
|
|
602
|
+
const index = { root, files: await listRepoFiles(root) };
|
|
603
|
+
const dirs = options.setupDirs ?? (await discoverSetupDirs(root));
|
|
604
|
+
const problems = [];
|
|
605
|
+
const refs = [];
|
|
606
|
+
const scannedFiles = [];
|
|
607
|
+
const citedPaths = new Set();
|
|
608
|
+
// Does an extensionless token name something real? Cached: prompts repeat the same
|
|
609
|
+
// paths, and each miss would otherwise cost a stat per occurrence.
|
|
610
|
+
const resolvable = new Map();
|
|
611
|
+
/** The root-relative path an extensionless token names, or null if it names nothing. */
|
|
612
|
+
const namedPath = async (token, scopeRoot) => {
|
|
613
|
+
for (const candidate of pathishCandidates(token)) {
|
|
614
|
+
for (const base of [root, scopeRoot]) {
|
|
615
|
+
const absolute = path.resolve(base, candidate);
|
|
616
|
+
let resolved = resolvable.get(absolute);
|
|
617
|
+
if (resolved === undefined) {
|
|
618
|
+
const kind = pathInside(absolute, root) ? await pathKind(absolute) : "missing";
|
|
619
|
+
resolved =
|
|
620
|
+
kind === "missing"
|
|
621
|
+
? null
|
|
622
|
+
: path.relative(root, absolute).split(path.sep).join("/") +
|
|
623
|
+
(kind === "dir" ? "/" : "");
|
|
624
|
+
resolvable.set(absolute, resolved);
|
|
625
|
+
}
|
|
626
|
+
if (resolved) {
|
|
627
|
+
return resolved;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return null;
|
|
632
|
+
};
|
|
633
|
+
for (const dir of dirs) {
|
|
634
|
+
const scopeRoot = path.dirname(dir);
|
|
635
|
+
for (const file of await setupFiles(dir)) {
|
|
636
|
+
const relative = fileLabel(root, dir, file);
|
|
637
|
+
let text;
|
|
638
|
+
try {
|
|
639
|
+
text = await readFile(file, "utf8");
|
|
640
|
+
}
|
|
641
|
+
catch {
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
scannedFiles.push(relative);
|
|
645
|
+
const annotated = parseRefAnnotations(text, relative);
|
|
646
|
+
const ignored = parseRefIgnores(text);
|
|
647
|
+
const covered = new Set(annotated.flatMap((ref) => coveringForms(ref.target)));
|
|
648
|
+
// A glob ref also covers any citation the glob itself matches, so a prompt can
|
|
649
|
+
// cite `.../nested/Handler.kt` and pin it once with `glob:**/Handler.kt`.
|
|
650
|
+
const coveringGlobs = annotated
|
|
651
|
+
.filter((ref) => ref.target.startsWith(GLOB_PREFIX))
|
|
652
|
+
.map((ref) => ref.target.slice(GLOB_PREFIX.length));
|
|
653
|
+
for (const ref of annotated) {
|
|
654
|
+
const problem = await resolveTarget(ref.target, index, scopeRoot);
|
|
655
|
+
if (problem) {
|
|
656
|
+
problems.push({
|
|
657
|
+
file: relative,
|
|
658
|
+
line: ref.line,
|
|
659
|
+
kind: problem.startsWith("cites a line number") ? "line-number-ref" : "broken-ref",
|
|
660
|
+
problem,
|
|
661
|
+
});
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
// An `LLP NNNN` target belongs to a different mechanism (a design corpus, owned
|
|
665
|
+
// by that repo's own checker). ecr neither resolves nor counts it: the refs it
|
|
666
|
+
// owns are the ones citing the reviewed code.
|
|
667
|
+
if (LLP_TARGET_RE.test(ref.target)) {
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
refs.push(ref);
|
|
671
|
+
const cited = splitAnchor(ref.target)[0];
|
|
672
|
+
if (cited && !cited.startsWith(GLOB_PREFIX) && !URL_RE.test(cited)) {
|
|
673
|
+
citedPaths.add(cited.replace(/\/$/, ""));
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
for (const { line, token } of findProseCitations(text)) {
|
|
677
|
+
const forms = citationForms(token);
|
|
678
|
+
if (ignored.has(token) ||
|
|
679
|
+
forms.some((form) => covered.has(form)) ||
|
|
680
|
+
coveringGlobs.some((pattern) => forms.some((form) => matchesIgnore(form.replace(/^\.\.\.\//, ""), pattern)))) {
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
// Extension or wildcard tail ⇒ a citation on shape alone. Otherwise it only
|
|
684
|
+
// counts if it names something real: `eas-build-worker/terraform` and
|
|
685
|
+
// `general-central/{module,production}` are paths, `anthropic/claude-opus-5`
|
|
686
|
+
// is shaped identically and is not.
|
|
687
|
+
const named = isCodeCitation(token) ? null : await namedPath(token, scopeRoot);
|
|
688
|
+
if (!isCodeCitation(token) && !named) {
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
const lineCitation = LINE_CITATION_RE.exec(token);
|
|
692
|
+
// For an extensionless token the suggestion is the path that actually resolved,
|
|
693
|
+
// which is also how a scope-relative citation learns its root-relative form.
|
|
694
|
+
const suggestion = named ?? suggestedRef(token);
|
|
695
|
+
problems.push({
|
|
696
|
+
file: relative,
|
|
697
|
+
line,
|
|
698
|
+
kind: lineCitation ? "line-number-ref" : "unannotated-citation",
|
|
699
|
+
problem: lineCitation
|
|
700
|
+
? `\`${token}\` pins a line number; cite the file or a \`#symbol\` instead, as \`${REF_MARK} ${suggestedRef(lineCitation[1])}\``
|
|
701
|
+
: `\`${token}\` cites code without a ref; add \`${REF_MARK} ${suggestion} — why it matters\` (or \`${IGNORE_MARK} ${token}\` if it is not a path)`,
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
const routing = path.join(dir, ROUTING_FILENAME);
|
|
706
|
+
if ((await pathKind(routing)) === "file") {
|
|
707
|
+
// The roster comes from the dir that OWNS the manifest, never from `root`:
|
|
708
|
+
// under `ecr ci` the setup is a trusted base-ref checkout while `root` is the PR
|
|
709
|
+
// head tree, so reading `root/.expo-code-review/agents` would judge enforceAgents
|
|
710
|
+
// against a different (or absent) roster than the one actually loaded.
|
|
711
|
+
problems.push(...(await checkRoutingManifest(routing, index, await agentIds(dir))));
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
problems.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.problem.localeCompare(b.problem));
|
|
715
|
+
return {
|
|
716
|
+
ok: problems.length === 0,
|
|
717
|
+
problems,
|
|
718
|
+
refs,
|
|
719
|
+
scannedFiles,
|
|
720
|
+
citedPaths: [...citedPaths].sort(),
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
/** How many examples a review-side note names before saying "and N more". */
|
|
724
|
+
const NOTE_EXAMPLES = 5;
|
|
725
|
+
function andMore(items) {
|
|
726
|
+
const shown = items.slice(0, NOTE_EXAMPLES).join(", ");
|
|
727
|
+
const rest = items.length - NOTE_EXAMPLES;
|
|
728
|
+
return rest > 0 ? `${shown}, and ${rest} more` : shown;
|
|
729
|
+
}
|
|
730
|
+
// @ref LLP 0012#run-points-command-and-review [constrained-by] — advises, never fails the review
|
|
731
|
+
/**
|
|
732
|
+
* The advice a review gives about its own setup: refs that no longer resolve, and cited
|
|
733
|
+
* code this PR changes (where the ref still resolves but the guidance may not). Returns
|
|
734
|
+
* an empty array when the setup is clean, so a healthy run stays silent.
|
|
735
|
+
*/
|
|
736
|
+
export async function reviewSetupRefNotes(options) {
|
|
737
|
+
let report;
|
|
738
|
+
try {
|
|
739
|
+
report = await checkConfigRefs({ root: options.root, setupDirs: options.setupDirs });
|
|
740
|
+
}
|
|
741
|
+
catch {
|
|
742
|
+
return []; // a check that cannot run must never degrade the review
|
|
743
|
+
}
|
|
744
|
+
const notes = [];
|
|
745
|
+
const broken = report.problems.filter((problem) => problem.kind !== "unannotated-citation");
|
|
746
|
+
if (broken.length > 0) {
|
|
747
|
+
notes.push(`The reviewer setup cites code that no longer resolves (${broken.length} ref(s)): ` +
|
|
748
|
+
`${andMore(broken.map((problem) => `${problem.file}:${problem.line}`))}. ` +
|
|
749
|
+
"Run `ecr ref-check` — the prompts may be reviewing against code that moved.");
|
|
750
|
+
}
|
|
751
|
+
const touched = citedPathsTouchedBy(report, options.changedFiles);
|
|
752
|
+
if (touched.length > 0) {
|
|
753
|
+
notes.push(`This PR changes code the reviewer prompts cite (${andMore(touched)}). ` +
|
|
754
|
+
"Check that the guidance quoting it is still correct.");
|
|
755
|
+
}
|
|
756
|
+
return notes;
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* Which of this PR's changed files are cited by a ref. The review uses this to say
|
|
760
|
+
* "you moved code the reviewer prompts point at" even when the ref still resolves.
|
|
761
|
+
*/
|
|
762
|
+
export function citedPathsTouchedBy(report, changedFiles) {
|
|
763
|
+
const touched = new Set();
|
|
764
|
+
for (const changed of changedFiles) {
|
|
765
|
+
for (const cited of report.citedPaths) {
|
|
766
|
+
if (changed === cited || changed.startsWith(`${cited}/`)) {
|
|
767
|
+
touched.add(cited);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
return [...touched].sort();
|
|
772
|
+
}
|