@expo/code-review-cli 0.8.0 → 0.9.1

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
@@ -116,6 +116,7 @@ is a ready example to adapt.
116
116
  | `ecr ci` | Review the current GitHub PR and post/update a comment. For GitHub Actions. |
117
117
  | `ecr doctor [--list-scopes]` | Check environment, config, credentials, and (with a manifest) scopes. |
118
118
  | `ecr feedback [--repo <owner/repo>]` | Report which findings PR authors pushed back on, across history. See below. |
119
+ | `ecr ref-check [--json]` | Fail when the review setup cites code that moved or vanished. See below. |
119
120
 
120
121
  Extra flags for monorepos: `review`/`ci` `--config-dir <dir>` (load config from an
121
122
  alternate dir; also `ECR_CONFIG_DIR`), `ci --scopes a,b` (limit the fan-out to
@@ -127,6 +128,48 @@ untrusted external context; see below).
127
128
 
128
129
  ---
129
130
 
131
+ ## Keeping prompts true (`ecr ref-check`)
132
+
133
+ Good reviewer prompts cite real code: "the only session entry point is
134
+ `server/src/session.ts`", "every webhook router must call `sanitizeSecrets`". Then the
135
+ code moves and the prompt keeps citing a path that no longer exists — the reviewer
136
+ reasons from a fiction, on every PR, with nothing to warn you.
137
+
138
+ `ecr ref-check` makes those citations checkable. Pin each one with a ref, in a comment
139
+ of its own (`<!-- … -->` in Markdown, `//` in JSONC):
140
+
141
+ ```md
142
+ <!-- @ref server/src/session.ts#createSession — the only place a session is minted -->
143
+ <!-- @ref server/src/entities/oauth/ — every provider lives here -->
144
+ <!-- @ref glob:**/*WebhookRouter.ts — the routers this rule is about -->
145
+ ```
146
+
147
+ A target is a file, a `dir/`, `glob:<pattern>`, `file#symbol`, or `doc.md#heading` —
148
+ never a line number, since a line number rots without any signal. Symbol anchors are
149
+ checked by whole-word match, so code moving inside its file is fine and a rename is not.
150
+
151
+ The check is strict on purpose: a backticked token in `.expo-code-review/` that looks
152
+ like a repo path **must** be a ref, because the stale citations are exactly the ones
153
+ nobody thought to annotate. A token with no extension (`eas-build-worker/terraform`,
154
+ `general-central/{module,production}`, `finops`) counts when it names something that
155
+ exists — so those get pinned too, while `anthropic/claude-opus-5`, shaped the same way,
156
+ stays prose. For a token that only looks like a path, say so once:
157
+ `<!-- @ref-ignore knex.raw() -->`. It also checks what your config already declares —
158
+ `enforceAgents` ids, scope `config` directories, scope path globs.
159
+
160
+ Refs are repo-root-relative, including in a scope's own setup dir. A scope prompt that
161
+ cites `general-central/module` for `infrastructure/general-central/module` gets told the
162
+ root-relative form to use.
163
+
164
+ Two run points:
165
+
166
+ - `ecr ref-check` exits 1 on any problem. Run it in CI or a pre-commit hook.
167
+ - `ecr review` / `ecr ci` run it too, and never fail a PR's checks with it. The comment
168
+ carries a **Review setup** note instead: refs that no longer resolve, plus cited code
169
+ *this PR* changes, where the ref still resolves but the guidance may not.
170
+
171
+ ---
172
+
130
173
  ## Monorepos (routing manifest)
131
174
 
132
175
  A monorepo can route different subtrees to different reviewer rosters from a single
package/build/cli.js CHANGED
@@ -5,6 +5,7 @@ import { dismissCommand } from "./commands/dismiss.js";
5
5
  import { doctorCommand } from "./commands/doctor.js";
6
6
  import { feedbackCommand } from "./commands/feedback.js";
7
7
  import { initCommand } from "./commands/init.js";
8
+ import { refCheckCommand } from "./commands/ref-check.js";
8
9
  import { reviewCommand } from "./commands/review.js";
9
10
  import { setupAuthCommand } from "./commands/setup-auth.js";
10
11
  import { verifyConfigCommand } from "./commands/verify-config.js";
@@ -20,6 +21,7 @@ Usage:
20
21
  ecr setup-auth [--yes] Walk through getting model credentials for local runs.
21
22
  ecr doctor [--list-scopes] Check environment, config, credentials, and scopes.
22
23
  ecr verify-config [--expected <env>] [--json] Refuse to run if a config could redirect the credential (CI guard).
24
+ ecr ref-check [--root <dir>] [--json] Fail if the review setup cites code that moved or vanished.
23
25
 
24
26
  Agents live in each repo under .expo-code-review/. This CLI is the engine.
25
27
 
@@ -65,6 +67,9 @@ async function main() {
65
67
  case "verify-config":
66
68
  await verifyConfigCommand(rest);
67
69
  break;
70
+ case "ref-check":
71
+ await refCheckCommand(rest);
72
+ break;
68
73
  default:
69
74
  process.stderr.write(`Unknown command: ${sub}\n\n${USAGE}`);
70
75
  process.exitCode = 2;
@@ -0,0 +1,84 @@
1
+ // @ref LLP 0012#run-points-command-and-review — the gating run point: exit 1 on any broken ref
2
+ import path from "node:path";
3
+ import { checkConfigRefs } from "../core/config-refs.js";
4
+ import { repoRoot } from "../core/exec.js";
5
+ import { errorMessage } from "../core/util.js";
6
+ const USAGE = `ecr ref-check — fail when the review setup cites code that moved or vanished
7
+
8
+ Usage:
9
+ ecr ref-check [--root <dir>] [--json]
10
+
11
+ Sweeps every .expo-code-review/ directory in the repo (root and scopes) and checks
12
+ that each code citation still resolves against this checkout:
13
+ • \`@ref <target>\` annotations in prompts and configs. A target is a file, a
14
+ directory (trailing slash), \`glob:<pattern>\`, a \`file#symbol\`, or a
15
+ \`doc.md#heading\`. Never a line number — lines rot silently.
16
+ • Unannotated citations: a backticked token that looks like a repo path must be a
17
+ ref, so nothing cites code without being checked. Use \`@ref-ignore <token>\`
18
+ for a token that is not a path.
19
+ • Structural refs the config already declares: enforceAgents ids, scope config
20
+ directories, and scope path globs.
21
+ Exit 0 = every ref holds. Exit 1 = at least one is broken.
22
+
23
+ Options:
24
+ --root <dir> Repository root to check (default: the current git repo).
25
+ --json Emit {ok, problems:[{file, line, kind, problem}]} on stdout.
26
+ `;
27
+ const KIND_LABEL = {
28
+ "broken-ref": "broken ref",
29
+ "line-number-ref": "line-number ref",
30
+ "unannotated-citation": "unannotated citation",
31
+ structural: "structural ref",
32
+ };
33
+ export async function refCheckCommand(argv) {
34
+ if (argv.includes("-h") || argv.includes("--help")) {
35
+ process.stdout.write(USAGE);
36
+ return;
37
+ }
38
+ let root;
39
+ let json = false;
40
+ for (let i = 0; i < argv.length; i++) {
41
+ const arg = argv[i];
42
+ if (arg === "--json") {
43
+ json = true;
44
+ }
45
+ else if (arg === "--root") {
46
+ root = argv[++i];
47
+ // A flag-shaped value means the directory was forgotten: taking it would check
48
+ // some nonexistent path and report "all resolve" while swallowing the real flag.
49
+ if (!root || root.startsWith("-")) {
50
+ process.stderr.write("ecr ref-check: --root needs a directory\n");
51
+ process.exitCode = 2;
52
+ return;
53
+ }
54
+ }
55
+ else {
56
+ process.stderr.write(`ecr ref-check: unknown argument ${arg}\n${USAGE}`);
57
+ process.exitCode = 2;
58
+ return;
59
+ }
60
+ }
61
+ try {
62
+ const resolvedRoot = root ? path.resolve(root) : ((await repoRoot()) ?? process.cwd());
63
+ const report = await checkConfigRefs({ root: resolvedRoot });
64
+ if (json) {
65
+ process.stdout.write(`${JSON.stringify({ ok: report.ok, problems: report.problems })}\n`);
66
+ }
67
+ else if (report.ok) {
68
+ process.stdout.write(`ref-check: ${report.refs.length} ref(s) across ${report.scannedFiles.length} setup file(s) — all resolve\n`);
69
+ }
70
+ else {
71
+ for (const problem of report.problems) {
72
+ process.stderr.write(`${problem.file}:${problem.line}: ${KIND_LABEL[problem.kind]}: ${problem.problem}\n`);
73
+ }
74
+ process.stderr.write(`\nref-check: ${report.problems.length} problem(s). Update the ref or the prompt that cites it.\n`);
75
+ }
76
+ if (!report.ok) {
77
+ process.exitCode = 1;
78
+ }
79
+ }
80
+ catch (error) {
81
+ process.stderr.write(`ecr ref-check: ${errorMessage(error)}\n`);
82
+ process.exitCode = 2;
83
+ }
84
+ }
@@ -0,0 +1,776 @@
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
+ // Extension or wildcard tail ⇒ a citation on shape alone. Otherwise it only
678
+ // counts if it names something real: `eas-build-worker/terraform` and
679
+ // `general-central/{module,production}` are paths, `anthropic/claude-opus-5`
680
+ // is shaped identically and is not.
681
+ const named = isCodeCitation(token) ? null : await namedPath(token, scopeRoot);
682
+ if (!isCodeCitation(token) && !named) {
683
+ continue;
684
+ }
685
+ // Coverage must accept the path the token RESOLVED to, not just the token as
686
+ // written: a prompt says `cert-manager` and the ref that pins it is
687
+ // `infrastructure/cert-manager/`. Without this, the fix the message suggests
688
+ // does not silence the citation it was suggested for.
689
+ const forms = [...citationForms(token), ...(named ? coveringForms(named) : [])];
690
+ if (ignored.has(token) ||
691
+ forms.some((form) => covered.has(form)) ||
692
+ coveringGlobs.some((pattern) => forms.some((form) => matchesIgnore(form.replace(/^\.\.\.\//, ""), pattern)))) {
693
+ continue;
694
+ }
695
+ const lineCitation = LINE_CITATION_RE.exec(token);
696
+ // For an extensionless token the suggestion is the path that actually resolved,
697
+ // which is also how a scope-relative citation learns its root-relative form.
698
+ const suggestion = named ?? suggestedRef(token);
699
+ problems.push({
700
+ file: relative,
701
+ line,
702
+ kind: lineCitation ? "line-number-ref" : "unannotated-citation",
703
+ problem: lineCitation
704
+ ? `\`${token}\` pins a line number; cite the file or a \`#symbol\` instead, as \`${REF_MARK} ${suggestedRef(lineCitation[1])}\``
705
+ : `\`${token}\` cites code without a ref; add \`${REF_MARK} ${suggestion} — why it matters\` (or \`${IGNORE_MARK} ${token}\` if it is not a path)`,
706
+ });
707
+ }
708
+ }
709
+ const routing = path.join(dir, ROUTING_FILENAME);
710
+ if ((await pathKind(routing)) === "file") {
711
+ // The roster comes from the dir that OWNS the manifest, never from `root`:
712
+ // under `ecr ci` the setup is a trusted base-ref checkout while `root` is the PR
713
+ // head tree, so reading `root/.expo-code-review/agents` would judge enforceAgents
714
+ // against a different (or absent) roster than the one actually loaded.
715
+ problems.push(...(await checkRoutingManifest(routing, index, await agentIds(dir))));
716
+ }
717
+ }
718
+ problems.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.problem.localeCompare(b.problem));
719
+ return {
720
+ ok: problems.length === 0,
721
+ problems,
722
+ refs,
723
+ scannedFiles,
724
+ citedPaths: [...citedPaths].sort(),
725
+ };
726
+ }
727
+ /** How many examples a review-side note names before saying "and N more". */
728
+ const NOTE_EXAMPLES = 5;
729
+ function andMore(items) {
730
+ const shown = items.slice(0, NOTE_EXAMPLES).join(", ");
731
+ const rest = items.length - NOTE_EXAMPLES;
732
+ return rest > 0 ? `${shown}, and ${rest} more` : shown;
733
+ }
734
+ // @ref LLP 0012#run-points-command-and-review [constrained-by] — advises, never fails the review
735
+ /**
736
+ * The advice a review gives about its own setup: refs that no longer resolve, and cited
737
+ * code this PR changes (where the ref still resolves but the guidance may not). Returns
738
+ * an empty array when the setup is clean, so a healthy run stays silent.
739
+ */
740
+ export async function reviewSetupRefNotes(options) {
741
+ let report;
742
+ try {
743
+ report = await checkConfigRefs({ root: options.root, setupDirs: options.setupDirs });
744
+ }
745
+ catch {
746
+ return []; // a check that cannot run must never degrade the review
747
+ }
748
+ const notes = [];
749
+ const broken = report.problems.filter((problem) => problem.kind !== "unannotated-citation");
750
+ if (broken.length > 0) {
751
+ notes.push(`The reviewer setup cites code that no longer resolves (${broken.length} ref(s)): ` +
752
+ `${andMore(broken.map((problem) => `${problem.file}:${problem.line}`))}. ` +
753
+ "Run `ecr ref-check` — the prompts may be reviewing against code that moved.");
754
+ }
755
+ const touched = citedPathsTouchedBy(report, options.changedFiles);
756
+ if (touched.length > 0) {
757
+ notes.push(`This PR changes code the reviewer prompts cite (${andMore(touched)}). ` +
758
+ "Check that the guidance quoting it is still correct.");
759
+ }
760
+ return notes;
761
+ }
762
+ /**
763
+ * Which of this PR's changed files are cited by a ref. The review uses this to say
764
+ * "you moved code the reviewer prompts point at" even when the ref still resolves.
765
+ */
766
+ export function citedPathsTouchedBy(report, changedFiles) {
767
+ const touched = new Set();
768
+ for (const changed of changedFiles) {
769
+ for (const cited of report.citedPaths) {
770
+ if (changed === cited || changed.startsWith(`${cited}/`)) {
771
+ touched.add(cited);
772
+ }
773
+ }
774
+ }
775
+ return [...touched].sort();
776
+ }
@@ -156,6 +156,7 @@ export function renderMarkdown(review, tag, dismissed = [], link, feedback = [],
156
156
  if (review.incomplete.length > 0) {
157
157
  lines.push("> ⏱️ **Coverage note:** coverage is partial — some review passes did not", "> finish (timed out or failed), so issues may exist in areas not fully reviewed:", ...review.incomplete.map((note) => `> - ${stripStateMarkers(note)}`), "");
158
158
  }
159
+ lines.push(...setupNote(review.setupNotes));
159
160
  lines.push(...requalificationAuditNote(requalified.map((entry) => entry.finding)));
160
161
  lines.push(...feedbackAuditNote([...feedbackByFp.values()]));
161
162
  if (kept.length === 0) {
@@ -235,6 +236,14 @@ function renderFindingLines(finding, link, id = fingerprintFinding(finding), rep
235
236
  out.push("");
236
237
  return out;
237
238
  }
239
+ // @ref LLP 0012#run-points-command-and-review [implements] — setup advice renders outside the findings list, so it never blocks
240
+ /** Advice about the reviewer's own setup (stale refs, cited code this PR moves). */
241
+ function setupNote(notes = []) {
242
+ if (notes.length === 0) {
243
+ return [];
244
+ }
245
+ return ["> 🔗 **Review setup:**", ...notes.map((note) => `> - ${stripStateMarkers(note)}`), ""];
246
+ }
238
247
  // @ref LLP 0010#rendering-in-all-three-paths [implements] — the visible audit count is mandatory: requalification's only effect on a real finding is moving it out of the blocking set, so it must never be silent
239
248
  /**
240
249
  * The visible one-line audit note in the OPEN body, naming the addressing PRs. This
@@ -423,6 +432,10 @@ export function renderAggregateMarkdown(results, tag, dismissed, link, opts, fee
423
432
  }
424
433
  lines.push("");
425
434
  }
435
+ const setupLines = results.flatMap((result) => (result.review.setupNotes ?? []).map((note) => `> - [${stripStateMarkers(result.scope)}] ${stripStateMarkers(note)}`));
436
+ if (setupLines.length > 0) {
437
+ lines.push("> 🔗 **Review setup:**", ...setupLines, "");
438
+ }
426
439
  // Shown = the most-severe N kept findings per scope (N = limitPerScope). The
427
440
  // embedded state trims KEPT findings to the same set so a truncated comment
428
441
  // still fits GitHub's body limit (the hidden findings are noted, not silently
@@ -15,6 +15,7 @@ import { confirmStackRequalifications, patchConfirmer } from "./stack-confirm.js
15
15
  import { sortFindings } from "./render.js";
16
16
  import { appendStepSummary } from "./step-summary.js";
17
17
  import { errorMessage, sleep } from "./util.js";
18
+ import { reviewSetupRefNotes } from "./config-refs.js";
18
19
  import { verifyFindings } from "./verify.js";
19
20
  import { applyInlineIgnores } from "./suppress.js";
20
21
  /**
@@ -168,6 +169,19 @@ export async function runReview(source, options) {
168
169
  progress("Reviewing the PR-head tree (so reads match the PR, not the checkout).");
169
170
  process.chdir(readRoot.dir);
170
171
  }
172
+ // Ref integrity of the setup that is about to review this PR. Resolved against the
173
+ // tree the reviewers read (PR head when materialized), while the setup itself may
174
+ // come from the trusted base ref — so a PR that moves cited code is reported against
175
+ // the prompts that will actually judge it.
176
+ // @ref LLP 0012#run-points-command-and-review [implements] — every review checks its own refs; advice only, never a gate
177
+ const setupNotes = await reviewSetupRefNotes({
178
+ root: readRoot?.dir ?? originalCwd,
179
+ setupDirs: [config.configDir],
180
+ changedFiles: kept.map((entry) => entry.path),
181
+ });
182
+ for (const note of setupNotes) {
183
+ progress(` setup: ${note}`);
184
+ }
171
185
  const starting = [
172
186
  usesClaude ? "Claude Code engine" : null,
173
187
  usesOpencode ? "OpenCode server" : null,
@@ -862,7 +876,10 @@ export async function runReview(source, options) {
862
876
  findingCount: output.findings.length,
863
877
  summary: output.summary,
864
878
  });
865
- return feedbackRecords ? { ...output, feedback: feedbackRecords } : output;
879
+ // Engine-owned: overwrite whatever the coordinator may have emitted under this key,
880
+ // so setup advice is always the checker's, never model text.
881
+ const reviewed = { ...output, setupNotes };
882
+ return feedbackRecords ? { ...reviewed, feedback: feedbackRecords } : reviewed;
866
883
  }
867
884
  catch (error) {
868
885
  await safeLog(logPath, {
@@ -115,6 +115,16 @@ export const CoordinatorOutputSchema = z.object({
115
115
  * overrides the presentation instead).
116
116
  */
117
117
  couldNotComplete: z.boolean().optional(),
118
+ /**
119
+ * Advice about the review's OWN setup: refs in `.expo-code-review/` that no longer
120
+ * resolve, and cited code this PR changes. Engine-set (never the model, never a
121
+ * finding) and never blocking — a stale prompt is a maintenance signal, not a defect
122
+ * in the PR.
123
+ */
124
+ // @ref LLP 0012#run-points-command-and-review [implements] — the review advises about stale refs instead of failing on them
125
+ // Optional (like couldNotComplete) so every internal CoordinatorOutput literal stays
126
+ // valid without restating an engine-owned field.
127
+ setupNotes: z.array(z.string()).optional(),
118
128
  });
119
129
  /** How an author's reply to a finding held up against the source. */
120
130
  export const FEEDBACK_VERDICTS = ["accepted", "refuted", "unclear"];
@@ -51,6 +51,14 @@ export class TerminalReporter {
51
51
  }
52
52
  out.push("");
53
53
  }
54
+ const setupNotes = review.setupNotes ?? [];
55
+ if (setupNotes.length > 0) {
56
+ out.push(this.paint(BOLD, "🔗 Review setup:"));
57
+ for (const note of setupNotes) {
58
+ out.push(this.paint(DIM, ` - ${note}`));
59
+ }
60
+ out.push("");
61
+ }
54
62
  if (review.findings.length === 0) {
55
63
  out.push(this.paint(DIM, "No findings."), "");
56
64
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/code-review-cli",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "Generic, config-driven AI code reviewer engine. Repos supply their agents via .expo-code-review/.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -39,7 +39,10 @@ must support `--non-interactive` the way sibling commands do (a non-interactive
39
39
  path with no prompts, erroring clearly when a required value is missing), and it
40
40
  must expose flags to supply every prompted value so the command stays scriptable.
41
41
 
42
- <!-- TODO: replace the example above with this repo's most important conventions. -->
42
+ <!-- TODO: replace the example above with this repo's most important conventions.
43
+ Pin each file, directory, or symbol you cite with a ref on its own line —
44
+ @ref <path/to/file.ts>#<symbol> — why this matters
45
+ so `ecr ref-check` fails when the cited code moves. Never a line number. -->
43
46
 
44
47
  ## What NOT to flag
45
48
 
@@ -19,7 +19,13 @@ issues in the changed code.
19
19
  concrete trigger.
20
20
 
21
21
  <!-- TODO: customize for this repo — add project-specific correctness rules,
22
- e.g. framework conventions, required flag handling, API compatibility. -->
22
+ e.g. framework conventions, required flag handling, API compatibility.
23
+
24
+ Cite real code, and pin every citation with a ref so `ecr ref-check` fails when
25
+ it moves. In a comment of its own, on one line:
26
+ @ref <path/to/file.ts>#<symbol> — why this matters
27
+ Targets: a file, a `<dir>/`, `glob:<pattern>`, `<file>#<symbol>`, `<doc>.md#<heading>`.
28
+ Never a line number. Not a path? `@ref-ignore <token>`. -->
23
29
 
24
30
  ## What NOT to flag
25
31
 
@@ -26,7 +26,14 @@ average severity.
26
26
  - Insecure file permissions, or writing secrets to world-readable paths.
27
27
 
28
28
  <!-- TODO: customize for this repo — name the sensitive surfaces specific to this
29
- codebase (credential stores, tokens, arbitrary-command features, etc.). -->
29
+ codebase (credential stores, tokens, arbitrary-command features, etc.).
30
+
31
+ Pin every path or symbol you cite with a ref, on a line of its own:
32
+ @ref <path/to/file.ts>#<symbol> — why this matters
33
+ `ecr ref-check` then fails when that code moves, so this prompt never sends a
34
+ reviewer after a file that no longer exists. Never cite a line number. -->
35
+
36
+ <!-- @ref glob:.github/workflows/** — the workflows this section judges -->
30
37
 
31
38
  ## CI / workflow supply-chain (changes under `.github/workflows/**`)
32
39
 
@@ -12,6 +12,9 @@ These rules apply to every reviewer and are concatenated onto your role prompt.
12
12
  - **Do not judge the diff in isolation.** Before reporting, read the surrounding
13
13
  source with your file/read/grep tools and trace the relevant execution path.
14
14
  If you cannot substantiate a concrete failure or exploit path, do not report it.
15
+ <!-- Generic guidance, not a claim that either file exists. Once this repo has one,
16
+ replace this with a real ref: @ref AGENTS.md — the conventions to judge against -->
17
+ <!-- @ref-ignore AGENTS.md CLAUDE.md -->
15
18
  - Ground your judgment in the repo's own conventions (`AGENTS.md` / `CLAUDE.md`
16
19
  at the repo root, and any per-directory guidance) rather than generic
17
20
  best-practices.