@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 +43 -0
- package/build/cli.js +5 -0
- package/build/commands/ref-check.js +84 -0
- package/build/core/config-refs.js +776 -0
- package/build/core/render.js +13 -0
- package/build/core/review.js +18 -1
- package/build/core/schema.js +10 -0
- package/build/reporters/terminal.js +8 -0
- package/package.json +1 -1
- package/templates/agents/consistency.md +4 -1
- package/templates/agents/correctness.md +7 -1
- package/templates/agents/security.md +8 -1
- package/templates/shared.md +3 -0
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
|
+
}
|
package/build/core/render.js
CHANGED
|
@@ -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
|
package/build/core/review.js
CHANGED
|
@@ -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
|
-
|
|
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, {
|
package/build/core/schema.js
CHANGED
|
@@ -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
|
@@ -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
|
|
package/templates/shared.md
CHANGED
|
@@ -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.
|