mirror of
https://github.com/expo/code-review-cli.git
synced 2026-11-13 20:28:06 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbcc822c2a | ||
|
|
9f57046c86 |
No files matched your search
@@ -35,10 +35,22 @@ the same kind of thing, so the codebase stays uniform and predictable.
|
||||
|
||||
## This repo's conventions
|
||||
|
||||
<!-- @ref glob:src/commands/*.ts — every command follows the shape described here -->
|
||||
<!-- @ref src/core/util.ts#errorMessage — the required error path -->
|
||||
<!-- @ref src/cli.ts — where a new command must be registered -->
|
||||
<!-- @ref src/config/schema.ts — one of the three places a config option lands -->
|
||||
<!-- @ref src/config/load.ts — the second -->
|
||||
<!-- @ref templates/config.jsonc — the third; a missing option here is a finding -->
|
||||
<!-- @ref templates/ — the scaffolding source that must stay in sync with src/ -->
|
||||
<!-- @ref src/ — the engine side of that sync -->
|
||||
<!-- @ref src/reporters/reporter.ts — the reporter interface siblings implement -->
|
||||
<!-- @ref src/sources/source.ts — the source interface siblings implement -->
|
||||
<!-- @ref src/__tests__/ — where a new module's bun test file belongs -->
|
||||
|
||||
- **New CLI commands** (`src/commands/*.ts`): a `USAGE` string, `-h`/`--help`
|
||||
handled first, errors written to stderr via `errorMessage(error)` from
|
||||
`core/util.ts`, and `process.exitCode = 2` on failure — never `process.exit()`.
|
||||
Registered in `src/cli.ts`.
|
||||
`src/core/util.ts`, and `process.exitCode = 2` on failure — never
|
||||
`process.exit()`. Registered in `src/cli.ts`.
|
||||
- **New config options** land in three places together: the zod schema
|
||||
(`src/config/schema.ts`), loading/defaults (`src/config/load.ts`), and the
|
||||
commented example in `templates/config.jsonc`. An option missing from the
|
||||
|
||||
@@ -19,6 +19,10 @@ issues in the changed code.
|
||||
|
||||
## This repo's footguns
|
||||
|
||||
<!-- @ref src/core/verify.ts#matchEvidence — where model claims become trusted -->
|
||||
<!-- @ref-ignore .js -->
|
||||
|
||||
|
||||
- **ESM specifiers**: TypeScript with NodeNext resolution — relative imports need
|
||||
the `.js` suffix. A missing suffix can type-check yet break the built CLI at
|
||||
runtime.
|
||||
|
||||
@@ -23,6 +23,14 @@ average severity.
|
||||
|
||||
## This repo's sensitive surfaces
|
||||
|
||||
<!-- @ref src/core/auth.ts#FORBIDDEN_TOKEN_ENVS — the deny-list this section is about -->
|
||||
<!-- @ref src/core/exec.ts — the single child-process seam (execFile, no shell) -->
|
||||
<!-- @ref src/core/prompts.ts#sanitizeUntrusted — the injection boundary named below -->
|
||||
<!-- @ref src/core/render.ts — writes the state markers a PR must not forge -->
|
||||
<!-- @ref templates/workflow.yml — scaffolded into every adopting repo -->
|
||||
<!-- @ref templates/config.jsonc — the tokenEnv guard must stay in sync with it -->
|
||||
<!-- @ref-ignore .runs/ -->
|
||||
|
||||
This CLI's core job is forwarding a model-provider credential and executing
|
||||
PR-controlled config, so these areas are critical surface:
|
||||
|
||||
@@ -50,6 +58,8 @@ PR-controlled config, so these areas are critical surface:
|
||||
guard step affect all downstream users, and the guard must stay in sync with
|
||||
`templates/config.jsonc`.
|
||||
|
||||
<!-- @ref glob:.github/workflows/** — the workflows this section judges -->
|
||||
|
||||
## CI / workflow supply-chain (changes under `.github/workflows/**`)
|
||||
|
||||
Treat any changed workflow as high-risk and reason about the *trigger*, not just
|
||||
|
||||
@@ -11,6 +11,8 @@ These rules apply to every reviewer and are concatenated onto your role prompt.
|
||||
- **Do not judge the diff in isolation.** Before reporting, read the surrounding
|
||||
source with your file/read/grep tools and trace the relevant execution path.
|
||||
If you cannot substantiate a concrete failure or exploit path, do not report it.
|
||||
<!-- @ref AGENTS.md — the conventions reviewers must judge against -->
|
||||
<!-- @ref-ignore CLAUDE.md -->
|
||||
- Ground your judgment in the repo's own conventions (`AGENTS.md` / `CLAUDE.md`
|
||||
at the repo root, and any per-directory guidance) rather than generic
|
||||
best-practices.
|
||||
|
||||
@@ -17,3 +17,13 @@ jobs:
|
||||
|
||||
- name: Validate LLP references and metadata
|
||||
run: ./ref-check --verbose
|
||||
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- run: bun install --frozen-lockfile
|
||||
|
||||
# The same grammar, applied to this repo's own review setup (see LLP 0012).
|
||||
- name: Validate review-setup refs
|
||||
run: bun run src/cli.ts ref-check
|
||||
@@ -33,6 +33,10 @@ verifies their findings, and posts one updating PR comment.
|
||||
don't silently "fix" either side.
|
||||
- `./ref-check` (`bun run llp:check`) validates refs and metadata; CI runs it
|
||||
on every push and PR.
|
||||
- `ecr ref-check` validates the SAME `@ref` grammar inside a repo's
|
||||
`.expo-code-review/` setup (see LLP 0012). The two checkers share the grammar:
|
||||
a change to target kinds, the `<placeholder>` rule, or the glob dialect must
|
||||
land in `./ref-check` and `src/core/config-refs.ts` together.
|
||||
|
||||
## Conventions
|
||||
|
||||
|
||||
@@ -116,6 +116,7 @@ is a ready example to adapt.
|
||||
| `ecr ci` | Review the current GitHub PR and post/update a comment. For GitHub Actions. |
|
||||
| `ecr doctor [--list-scopes]` | Check environment, config, credentials, and (with a manifest) scopes. |
|
||||
| `ecr feedback [--repo <owner/repo>]` | Report which findings PR authors pushed back on, across history. See below. |
|
||||
| `ecr ref-check [--json]` | Fail when the review setup cites code that moved or vanished. See below. |
|
||||
|
||||
Extra flags for monorepos: `review`/`ci` `--config-dir <dir>` (load config from an
|
||||
alternate dir; also `ECR_CONFIG_DIR`), `ci --scopes a,b` (limit the fan-out to
|
||||
@@ -127,6 +128,48 @@ untrusted external context; see below).
|
||||
|
||||
---
|
||||
|
||||
## Keeping prompts true (`ecr ref-check`)
|
||||
|
||||
Good reviewer prompts cite real code: "the only session entry point is
|
||||
`server/src/session.ts`", "every webhook router must call `sanitizeSecrets`". Then the
|
||||
code moves and the prompt keeps citing a path that no longer exists — the reviewer
|
||||
reasons from a fiction, on every PR, with nothing to warn you.
|
||||
|
||||
`ecr ref-check` makes those citations checkable. Pin each one with a ref, in a comment
|
||||
of its own (`<!-- … -->` in Markdown, `//` in JSONC):
|
||||
|
||||
```md
|
||||
<!-- @ref server/src/session.ts#createSession — the only place a session is minted -->
|
||||
<!-- @ref server/src/entities/oauth/ — every provider lives here -->
|
||||
<!-- @ref glob:**/*WebhookRouter.ts — the routers this rule is about -->
|
||||
```
|
||||
|
||||
A target is a file, a `dir/`, `glob:<pattern>`, `file#symbol`, or `doc.md#heading` —
|
||||
never a line number, since a line number rots without any signal. Symbol anchors are
|
||||
checked by whole-word match, so code moving inside its file is fine and a rename is not.
|
||||
|
||||
The check is strict on purpose: a backticked token in `.expo-code-review/` that looks
|
||||
like a repo path **must** be a ref, because the stale citations are exactly the ones
|
||||
nobody thought to annotate. A token with no extension (`eas-build-worker/terraform`,
|
||||
`general-central/{module,production}`, `finops`) counts when it names something that
|
||||
exists — so those get pinned too, while `anthropic/claude-opus-5`, shaped the same way,
|
||||
stays prose. For a token that only looks like a path, say so once:
|
||||
`<!-- @ref-ignore knex.raw() -->`. It also checks what your config already declares —
|
||||
`enforceAgents` ids, scope `config` directories, scope path globs.
|
||||
|
||||
Refs are repo-root-relative, including in a scope's own setup dir. A scope prompt that
|
||||
cites `general-central/module` for `infrastructure/general-central/module` gets told the
|
||||
root-relative form to use.
|
||||
|
||||
Two run points:
|
||||
|
||||
- `ecr ref-check` exits 1 on any problem. Run it in CI or a pre-commit hook.
|
||||
- `ecr review` / `ecr ci` run it too, and never fail a PR's checks with it. The comment
|
||||
carries a **Review setup** note instead: refs that no longer resolve, plus cited code
|
||||
*this PR* changes, where the ref still resolves but the guidance may not.
|
||||
|
||||
---
|
||||
|
||||
## Monorepos (routing manifest)
|
||||
|
||||
A monorepo can route different subtrees to different reviewer rosters from a single
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
# LLP 0012: Review-Setup Ref Integrity
|
||||
|
||||
**Type:** Explainer
|
||||
**Status:** Active
|
||||
**Systems:** Engine, CLI
|
||||
**Author:** Philippe Loulidi / Claude
|
||||
**Date:** 2026-08-03
|
||||
**Related:** [LLP 0006 Config Schema, Loading, and Routing](0006-config-schema-loading-routing.explainer.md), [LLP 0007 CLI Commands and CI](0007-cli-commands-and-ci.explainer.md), [LLP 0009 Adoption Templates and CI Workflows](0009-adoption-templates-and-ci-workflows.guide.md)
|
||||
|
||||
A repo's reviewer prompts are full of citations into its own code: "the only session
|
||||
entry point is `server/www/src/utils/session.ts`", "every webhook router must call
|
||||
`sanitizeSecretPatterns`". Those citations are load-bearing — they are how a prompt
|
||||
carries repo knowledge the model does not have — and nothing kept them true. Code
|
||||
moves, symbols get renamed, and the prompt keeps citing a path that no longer exists.
|
||||
The reviewer then reasons from a fiction, silently, on every PR.
|
||||
|
||||
`ecr ref-check` (`src/commands/ref-check.ts`, over `src/core/config-refs.ts`) closes
|
||||
that gap: every code citation in a `.expo-code-review/` setup must be a ref, and every
|
||||
ref must resolve against the checkout. It is deterministic — no model, no network —
|
||||
in the same family as `ecr verify-config` (see [LLP 0007][verify]).
|
||||
|
||||
Two measurements motivated the strict form. Swept against a real adopting monorepo
|
||||
(universe) the check found 165 citations, none of them annotated or verified by
|
||||
anything; swept against this repo's own setup it found 24 [observed]. Not one of those
|
||||
citations was checked before this existed.
|
||||
|
||||
## The `@ref` Grammar
|
||||
|
||||
One grammar, reused from the engine's own LLP annotations, so an author learns it once:
|
||||
|
||||
@ref <target> [relation] — why it matters
|
||||
|
||||
In markdown it lives in an HTML comment, in JSONC after `//`. Targets:
|
||||
|
||||
| Target | Resolves when |
|
||||
|---|---|
|
||||
| `path/to/file.ts` | the file exists |
|
||||
| `path/to/dir/` | the directory exists (trailing slash is required) |
|
||||
| `path/to/file.ts#symbolName` | the file exists and still contains that whole word |
|
||||
| `path/to/doc.md#heading` | the file exists and has that heading |
|
||||
| `glob:**/Handler.kt` | at least one file in the repo matches |
|
||||
| `https://…` | shape only, never fetched |
|
||||
|
||||
Refs are **always repo-root-relative**, including inside a scope's setup dir. A scope's
|
||||
prompts describe the same tree the reviewer sees in the diff, so a second base
|
||||
directory would only create ambiguity about which one a ref meant. Scoped prompts do
|
||||
naturally cite their own subtree (`general-central/module` for
|
||||
`infrastructure/general-central/module`), so that stays a broken ref — but the message
|
||||
resolves it against the scope root and names the root-relative form to use.
|
||||
|
||||
`glob:` exists for readability. A Kotlin path 90 characters deep gets abbreviated in
|
||||
prose (`.../config/MetricGroupCounter.kt`) and an abbreviation cannot be resolved, so
|
||||
the check suggests the suffix glob `glob:**/config/MetricGroupCounter.kt` — short in the
|
||||
prompt, still verified.
|
||||
|
||||
**Invariant:** a target is data, never a command. Resolution reads files inside the repo
|
||||
root only, via the same `pathInside` confinement the verifier uses ([LLP 0005][verify5]);
|
||||
absolute paths and `..` escapes are refused, not resolved.
|
||||
|
||||
## No Line Numbers, Symbol Anchors Instead
|
||||
|
||||
A ref may not pin a line. `src/auth.ts:42` is refused in an annotation *and* in prose,
|
||||
because a line number is the one form of citation that rots without any signal: insert
|
||||
a line above it and the ref still "resolves", now pointing at unrelated code. `#symbol`
|
||||
is the robust alternative — it survives the code moving inside its file, and breaks
|
||||
exactly when the thing cited is renamed or deleted, which is when a human should look.
|
||||
|
||||
For the same reason there is no content hash or pinned revision in the grammar. A digest
|
||||
would trip on every edit to the cited file, including edits that leave the prompt
|
||||
perfectly correct, and a check that cries wolf gets disabled. The drift signal lives on
|
||||
the review side instead (below).
|
||||
|
||||
## Unannotated Citations Are Broken Refs
|
||||
|
||||
Checking only what an author annotated would make the mechanism decorative: the stale
|
||||
citations are precisely the ones nobody thought about. So a backticked token in a setup
|
||||
file that *looks like* a repo path and is not a ref fails the check.
|
||||
|
||||
That rule needs a precise notion of "looks like a path", because prompts also backtick
|
||||
config keys, severities, commands and model ids. It comes in two tiers.
|
||||
|
||||
**Shape alone** (`isCodeCitation`): an allowlisted source extension, a trailing slash, or
|
||||
a wildcard tail inside a path — and never whitespace, `:`, `<`, `(` or quotes. So
|
||||
`session.ts`, `src/entities/oauth/` and `.github/workflows/**` are citations, while
|
||||
`ecr ci`, `label:<agent>`, `knex.raw()`, `critical` and a bare `.ts` are prose. Fenced
|
||||
code blocks are skipped whole: they are examples, not claims about the tree.
|
||||
|
||||
**Shape plus the filesystem** (`pathishCandidates`), for the citations shape cannot
|
||||
settle. A reviewer of this feature named the case: a Terraform prompt citing
|
||||
`eas-build-worker/terraform`, `general-central/{module,production}` and `finops`, with
|
||||
"this is very likely going to drift" [observed] — not one of those has an extension, and
|
||||
`anthropic/claude-opus-5` is shaped identically while naming no file. Syntax cannot tell
|
||||
them apart, so an extensionless token counts as a citation exactly when it *names
|
||||
something real*: probed against the repo root and the scope's own subtree, brace lists
|
||||
and wildcards cut back to their fixed prefix. Model ids resolve to nothing and stay prose.
|
||||
|
||||
The consequence is deliberate: an extensionless path that had already drifted before
|
||||
adoption is invisible (nothing distinguishes it from prose), while every one that is true
|
||||
today gets pinned and will be caught the moment it moves. This mechanism prevents future
|
||||
drift; it does not retroactively find old drift.
|
||||
|
||||
The escape hatch is explicit and per-token: `@ref-ignore knex.raw()` in a comment.
|
||||
Anything the heuristic misclassifies is one short line away from silence, which is why
|
||||
the rule can afford to be strict rather than advisory.
|
||||
|
||||
**Invariant:** the check never rewrites a prompt. It reports; a human decides whether the
|
||||
ref or the prompt was wrong. A stale citation often means the *guidance* is stale, not
|
||||
just the path.
|
||||
|
||||
## Structural Refs Need No Annotation
|
||||
|
||||
Some refs the config already declares, so they are checked without annotation:
|
||||
`enforceAgents` ids must exist as `agents/<id>.md` in the ROOT setup (that is where the
|
||||
loader resolves them, see [LLP 0006][routing]), every scope's `config` must point at a
|
||||
real `.expo-code-review/` directory inside the repo, and every scope glob must match at
|
||||
least one file — a scope whose paths match nothing ships prompts that review nothing.
|
||||
|
||||
## What Gets Scanned
|
||||
|
||||
Setup dirs are found by walking the tree, not by reading `routing.jsonc`: a scope dir the
|
||||
manifest forgot still ships prompts, and its stale refs are exactly what this exists to
|
||||
surface — the same reason `ecr verify-config` sweeps on disk instead of trusting the
|
||||
manifest ([LLP 0007][verify]). Inside a setup dir the scan covers prompts and configs
|
||||
(`.md`, `.json`, `.jsonc`, `.txt`) and skips dot entries, so `.runs/` artifacts are never
|
||||
scanned. Dot directories elsewhere are skipped too, which keeps `.claude/worktrees/`
|
||||
checkouts of the same repo from being swept as if they were scopes.
|
||||
|
||||
## Run Points: Command and Review
|
||||
|
||||
`ecr ref-check` exits 1 on any problem: that is the gate, for CI or a pre-commit hook.
|
||||
|
||||
The review path also runs it, and never fails a PR's checks with it — that invariant
|
||||
holds ([LLP 0002][pipeline]). Instead the review *advises*: broken refs in the setup
|
||||
become a note the reviewer comment carries, and `citedPathsTouchedBy` adds the drift
|
||||
signal a static check cannot give — "this PR changes code your reviewer prompts cite, so
|
||||
the guidance may need updating" — even when the ref still resolves. In CI the config
|
||||
comes from the trusted base ref while refs resolve against the PR head tree, so a PR that
|
||||
moves cited code is reported against the config that will actually review it.
|
||||
|
||||
## Two Checkers, One Grammar
|
||||
|
||||
The engine repo now has two ref checkers, and they scan overlapping files: `./ref-check`
|
||||
(the LLP corpus validator, which walks the whole tree) and `ecr ref-check` (setup dirs
|
||||
only). Running both surfaced two genuine conflicts, where one checker failed an
|
||||
annotation the other accepted [observed]:
|
||||
|
||||
- `glob:` targets were unknown to `./ref-check`, which read them as a literal path and
|
||||
reported "path not found".
|
||||
- Documenting the syntax broke both: a usage line containing the comment form with a
|
||||
`<target>` placeholder was collected as a real ref, and so were the README's fenced
|
||||
examples — the docs that teach refs were the first thing the checkers rejected.
|
||||
|
||||
All of it is fixed in the grammar rather than per-checker: `glob:` is understood by both,
|
||||
a target starting with `<` or a backtick is a documentation placeholder, and an annotation
|
||||
inside a fenced code block is an example that neither checker resolves.
|
||||
|
||||
Ownership stays split by target kind, not by file: an `LLP NNNN` target belongs to the
|
||||
design corpus and its own checker, so `ecr ref-check` neither resolves nor counts one —
|
||||
it owns exactly the refs that cite the reviewed repo's code. That split is why the
|
||||
templates can keep their engine annotations and still scaffold a repo that passes
|
||||
`ecr ref-check` on day one (asserted in `init-scaffold.test.ts`). An earlier attempt had
|
||||
`ecr init` strip those annotations while scaffolding; it mixed the two mechanisms and
|
||||
broke the "workflow templates land byte-identical" invariant [observed]. Parsing is what
|
||||
must be right: `LLP 0009#anchor` is one target containing a space, and taking the first
|
||||
whitespace-separated token left a bare `LLP` that read as a broken path.
|
||||
|
||||
**Invariant:** the grammar is shared. A change to target kinds, the placeholder rule, or
|
||||
the glob dialect must land in `./ref-check` and `src/core/config-refs.ts` together, or the
|
||||
same annotation will pass one checker and fail the other.
|
||||
|
||||
[verify]: 0007-cli-commands-and-ci.explainer.md#verify-config-the-config-guard
|
||||
[verify5]: 0005-verification-fingerprints-rendering.explainer.md#verifier-confinement-and-fail-open
|
||||
[routing]: 0006-config-schema-loading-routing.explainer.md#routing-manifest
|
||||
[pipeline]: 0002-review-engine-pipeline.explainer.md#pipeline-stages
|
||||
@@ -52,6 +52,12 @@ CODE_REF_RE = re.compile(re.escape(REF_MARK) + r"\s+(.+)")
|
||||
MD_REF_RE = re.compile(r"<!--\s*" + re.escape(REF_MARK) + r"\s+(.+?)-->", re.DOTALL)
|
||||
LLP_TARGET_RE = re.compile(r"^LLP\s+(\d{1,4})(?:#(\S+))?$")
|
||||
SHORTHAND_RE = re.compile(r"^[A-Z][A-Z0-9_]*(?:#\S+)?$")
|
||||
# Shared with `ecr ref-check`, which validates the SAME @ref grammar inside a repo's
|
||||
# .expo-code-review/ setup: `glob:<pattern>` targets a set of files, and a `<…>` target
|
||||
# documents the syntax rather than citing a file. Both checkers must agree on these or
|
||||
# the same annotation passes one and fails the other.
|
||||
GLOB_PREFIX = "glob:"
|
||||
PLACEHOLDER_RE = re.compile(r"^[<`]")
|
||||
URL_RE = re.compile(r"^https?://[^\s/]+\.[^\s/]+\S*$")
|
||||
HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
|
||||
LLP_FILE_RE = re.compile(r"^(\d{4})-[a-z0-9-]+\.([a-z0-9]+)\.md$")
|
||||
@@ -141,17 +147,68 @@ def parse_target(rest):
|
||||
token = rest.split()[0] if rest.split() else ""
|
||||
if token.startswith(("http://", "https://")):
|
||||
return "url", token, None
|
||||
if PLACEHOLDER_RE.match(token):
|
||||
return "placeholder", token, None
|
||||
if token.startswith(GLOB_PREFIX):
|
||||
return "glob", token[len(GLOB_PREFIX):], None
|
||||
if SHORTHAND_RE.match(token):
|
||||
return "shorthand", token, None
|
||||
path, _, anchor = token.partition("#")
|
||||
return "path", path, anchor or None
|
||||
|
||||
|
||||
def glob_to_regex(pattern):
|
||||
"""The mini-glob dialect shared with the CLI: `**` crosses `/`, `*` does not."""
|
||||
out, i = "", 0
|
||||
while i < len(pattern):
|
||||
ch = pattern[i]
|
||||
if ch == "*":
|
||||
if pattern[i + 1:i + 2] == "*":
|
||||
out += ".*"
|
||||
i += 1
|
||||
else:
|
||||
out += "[^/]*"
|
||||
elif ch in ".+^${}()|[]\\?":
|
||||
out += "\\" + ch
|
||||
else:
|
||||
out += ch
|
||||
i += 1
|
||||
return re.compile(f"^{out}$")
|
||||
|
||||
|
||||
def repo_files(root):
|
||||
"""Every file in the tree (SKIP_DIRS pruned), root-relative with / separators."""
|
||||
found = []
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
||||
for name in filenames:
|
||||
rel = os.path.relpath(os.path.join(dirpath, name), root)
|
||||
found.append(rel.replace(os.sep, "/"))
|
||||
return found
|
||||
|
||||
|
||||
def fenced_lines(text):
|
||||
"""1-based line numbers inside a fenced code block (the fence lines included)."""
|
||||
fenced, open_fence = set(), False
|
||||
for i, line in enumerate(text.splitlines(), 1):
|
||||
if line.lstrip().startswith("```"):
|
||||
fenced.add(i)
|
||||
open_fence = not open_fence
|
||||
elif open_fence:
|
||||
fenced.add(i)
|
||||
return fenced
|
||||
|
||||
|
||||
def collect_refs(path, text):
|
||||
refs = [] # (lineno, rest)
|
||||
if path.endswith(".md"):
|
||||
# An annotation inside a fence documents the grammar; resolving it would make
|
||||
# every doc that explains refs fail. Same rule in ecr's config-refs.ts.
|
||||
fenced = fenced_lines(text)
|
||||
for m in MD_REF_RE.finditer(text):
|
||||
lineno = text.count("\n", 0, m.start()) + 1
|
||||
if lineno in fenced:
|
||||
continue
|
||||
refs.append((lineno, m.group(1)))
|
||||
else:
|
||||
for i, line in enumerate(text.splitlines(), 1):
|
||||
@@ -205,6 +262,7 @@ def check(root):
|
||||
|
||||
# --- refs: scan the tree ---
|
||||
n_files = n_refs = 0
|
||||
all_files = repo_files(root)
|
||||
exempt = (os.path.join("llp", "tombstones"), os.path.join("llp", "reviews"))
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
||||
@@ -237,6 +295,13 @@ def check(root):
|
||||
elif kind == "url":
|
||||
if not URL_RE.match(target):
|
||||
rep.error(f"{where}: malformed URL target: {target}")
|
||||
elif kind == "placeholder":
|
||||
rep.info(f"{where}: documentation placeholder, not a citation: {target}")
|
||||
elif kind == "glob":
|
||||
if not target:
|
||||
rep.error(f"{where}: empty glob target")
|
||||
elif not any(glob_to_regex(target).match(f) for f in all_files):
|
||||
rep.error(f"{where}: broken ref: glob matches no file: {target}")
|
||||
elif kind == "shorthand":
|
||||
rep.info(f"{where}: unchecked shorthand: {target} (no mapping defined)")
|
||||
else: # path
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
checkConfigRefs,
|
||||
citedPathsTouchedBy,
|
||||
discoverSetupDirs,
|
||||
findProseCitations,
|
||||
isCodeCitation,
|
||||
parseRefAnnotations,
|
||||
parseRefIgnores,
|
||||
reviewSetupRefNotes,
|
||||
slugifyHeading,
|
||||
suggestedRef,
|
||||
} from "../core/config-refs.js";
|
||||
import { refCheckCommand } from "../commands/ref-check.js";
|
||||
|
||||
const REF = "@ref";
|
||||
|
||||
async function makeRepo(files: Record<string, string>): Promise<string> {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "ecr-refs-test-"));
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
const full = path.join(root, rel);
|
||||
await mkdir(path.dirname(full), { recursive: true });
|
||||
await writeFile(full, content, "utf8");
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
/** A minimal setup dir: agents/ plus whatever the test adds. */
|
||||
function setup(extra: Record<string, string>): Record<string, string> {
|
||||
return {
|
||||
".expo-code-review/config.jsonc": "{}\n",
|
||||
".expo-code-review/agents/security.md": "# Security\n",
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("parses refs from markdown comments and from // comments, with line numbers", () => {
|
||||
const md = `# Title\n\n<!-- ${REF} src/a.ts — the invariant -->\nprose\n<!-- ${REF} src/b.ts#foo [implements] -->\n`;
|
||||
expect(parseRefAnnotations(md, "agents/x.md")).toEqual([
|
||||
{ file: "agents/x.md", line: 3, target: "src/a.ts" },
|
||||
{ file: "agents/x.md", line: 5, target: "src/b.ts#foo" },
|
||||
]);
|
||||
|
||||
const jsonc = `{\n // ${REF} src/c.ts — why\n "a": 1\n}\n`;
|
||||
expect(parseRefAnnotations(jsonc, "config.jsonc")).toEqual([
|
||||
{ file: "config.jsonc", line: 2, target: "src/c.ts" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("@ref-ignore lists tokens that are prose, not citations", () => {
|
||||
const text = `<!-- ${REF}-ignore knex.raw() session.ts -->\n`;
|
||||
expect([...parseRefIgnores(text)].sort()).toEqual(["knex.raw()", "session.ts"]);
|
||||
// the ignore marker must not be collected as a ref itself
|
||||
expect(parseRefAnnotations(text, "a.md")).toEqual([]);
|
||||
});
|
||||
|
||||
test("isCodeCitation separates paths from prose tokens", () => {
|
||||
for (const token of [
|
||||
"server/www/src/utils/session.ts",
|
||||
"session.ts",
|
||||
"src/entities/oauth/",
|
||||
".github/workflows/**",
|
||||
"*PrivacyPolicy.ts",
|
||||
]) {
|
||||
expect(isCodeCitation(token)).toBe(true);
|
||||
}
|
||||
for (const token of [
|
||||
"ecr ci",
|
||||
"label:<agent>",
|
||||
"knex.raw()",
|
||||
"openai/gpt-5.5",
|
||||
"**",
|
||||
"/",
|
||||
"critical",
|
||||
'{ "findings": [] }',
|
||||
]) {
|
||||
expect(isCodeCitation(token)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("prose citations skip fenced code blocks", () => {
|
||||
const text = "`a/b.ts` here\n\n```\n`c/d.ts` example\n```\n\n`e/f.ts` again\n";
|
||||
expect(findProseCitations(text).map((c) => c.token)).toEqual(["a/b.ts", "e/f.ts"]);
|
||||
});
|
||||
|
||||
test("suggestedRef turns abbreviated and bare citations into suffix globs", () => {
|
||||
expect(suggestedRef("server/www/src/a.ts")).toBe("server/www/src/a.ts");
|
||||
expect(suggestedRef(".../config/MetricGroupCounter.kt")).toBe(
|
||||
"glob:**/config/MetricGroupCounter.kt",
|
||||
);
|
||||
expect(suggestedRef("session.ts")).toBe("glob:**/session.ts");
|
||||
expect(suggestedRef("*PrivacyPolicy.ts")).toBe("glob:**/*PrivacyPolicy.ts");
|
||||
});
|
||||
|
||||
test("slugifyHeading matches GitHub anchors", () => {
|
||||
expect(slugifyHeading("The `@ref` Grammar")).toBe("the-ref-grammar");
|
||||
expect(slugifyHeading("No Line Numbers, Symbol Anchors")).toBe("no-line-numbers-symbol-anchors");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("a ref to an existing file, dir, symbol, heading, and glob resolves", async () => {
|
||||
const root = await makeRepo(
|
||||
setup({
|
||||
"src/auth.ts": "export function verifySession() {}\n",
|
||||
"src/entities/oauth/index.ts": "export const x = 1;\n",
|
||||
"docs/DESIGN.md": "# Design\n\n## Session Rules\n\ntext\n",
|
||||
".expo-code-review/agents/security.md":
|
||||
`<!-- ${REF} src/auth.ts — the only session entry point -->\n` +
|
||||
`<!-- ${REF} src/auth.ts#verifySession -->\n` +
|
||||
`<!-- ${REF} src/entities/oauth/ -->\n` +
|
||||
`<!-- ${REF} docs/DESIGN.md#session-rules -->\n` +
|
||||
`<!-- ${REF} glob:src/**/*.ts -->\n`,
|
||||
}),
|
||||
);
|
||||
const report = await checkConfigRefs({ root });
|
||||
expect(report.problems).toEqual([]);
|
||||
expect(report.ok).toBe(true);
|
||||
expect(report.refs).toHaveLength(5);
|
||||
expect(report.citedPaths).toContain("src/auth.ts");
|
||||
});
|
||||
|
||||
test("a ref to a moved file, a renamed symbol, or a missing heading is broken", async () => {
|
||||
const root = await makeRepo(
|
||||
setup({
|
||||
"src/auth.ts": "export function verifySession() {}\n",
|
||||
"docs/DESIGN.md": "# Design\n",
|
||||
".expo-code-review/agents/security.md":
|
||||
`<!-- ${REF} src/moved.ts — gone -->\n` +
|
||||
`<!-- ${REF} src/auth.ts#checkSession -->\n` +
|
||||
`<!-- ${REF} docs/DESIGN.md#session-rules -->\n`,
|
||||
}),
|
||||
);
|
||||
const report = await checkConfigRefs({ root });
|
||||
expect(report.ok).toBe(false);
|
||||
expect(report.problems.map((p) => [p.line, p.kind])).toEqual([
|
||||
[1, "broken-ref"],
|
||||
[2, "broken-ref"],
|
||||
[3, "broken-ref"],
|
||||
]);
|
||||
expect(report.problems[0]!.problem).toContain("no such path");
|
||||
expect(report.problems[1]!.problem).toContain("no longer contains");
|
||||
expect(report.problems[2]!.problem).toContain("no heading");
|
||||
});
|
||||
|
||||
test("line-number refs and citations are refused, in an annotation or in prose", async () => {
|
||||
const root = await makeRepo(
|
||||
setup({
|
||||
"src/auth.ts": "export function verifySession() {}\n",
|
||||
".expo-code-review/agents/security.md": `<!-- ${REF} src/auth.ts:42 — the check -->\n\nSee \`src/auth.ts:42-51\`.\n`,
|
||||
}),
|
||||
);
|
||||
const report = await checkConfigRefs({ root });
|
||||
expect(report.problems.every((p) => p.kind === "line-number-ref")).toBe(true);
|
||||
expect(report.problems).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("a ref may never escape the repository", async () => {
|
||||
const root = await makeRepo(
|
||||
setup({
|
||||
".expo-code-review/agents/security.md": `<!-- ${REF} ../outside/secrets.ts -->\n<!-- ${REF} /etc/passwd -->\n`,
|
||||
}),
|
||||
);
|
||||
const report = await checkConfigRefs({ root });
|
||||
expect(report.problems).toHaveLength(2);
|
||||
expect(report.problems[0]!.problem).toContain("escapes the repository");
|
||||
expect(report.problems[1]!.problem).toContain("absolute path");
|
||||
});
|
||||
|
||||
test("an unannotated path citation fails, and @ref-ignore or an annotation clears it", async () => {
|
||||
const root = await makeRepo(
|
||||
setup({
|
||||
"src/auth.ts": "export const a = 1;\n",
|
||||
".expo-code-review/agents/security.md":
|
||||
"Read `src/auth.ts` and `knex.raw()` before flagging.\n",
|
||||
}),
|
||||
);
|
||||
const before = await checkConfigRefs({ root });
|
||||
expect(before.problems).toHaveLength(1);
|
||||
expect(before.problems[0]!.kind).toBe("unannotated-citation");
|
||||
expect(before.problems[0]!.problem).toContain(`${REF} src/auth.ts`);
|
||||
|
||||
const fixed = await makeRepo(
|
||||
setup({
|
||||
"src/auth.ts": "export const a = 1;\n",
|
||||
".expo-code-review/agents/security.md":
|
||||
`<!-- ${REF} src/auth.ts — session entry point -->\n` +
|
||||
`<!-- ${REF}-ignore knex.raw() -->\n` +
|
||||
"Read `src/auth.ts` and `knex.raw()` before flagging.\n",
|
||||
}),
|
||||
);
|
||||
expect((await checkConfigRefs({ root: fixed })).problems).toEqual([]);
|
||||
});
|
||||
|
||||
test("extensionless citations count only when they name something real", async () => {
|
||||
const root = await makeRepo({
|
||||
"infrastructure/eas-build-worker/terraform/main.tf": "resource {}\n",
|
||||
"infrastructure/general-central/module/main.tf": "resource {}\n",
|
||||
"infrastructure/finops/main.tf": "resource {}\n",
|
||||
"infrastructure/.expo-code-review/config.jsonc": "{}\n",
|
||||
"infrastructure/.expo-code-review/agents/terraform-safety.md":
|
||||
"State reviewer for `general-central/{module,production}`, `finops`,\n" +
|
||||
"`eas-build-worker/terraform`, on `anthropic/claude-opus-5`.\n",
|
||||
});
|
||||
const report = await checkConfigRefs({ root });
|
||||
const tokens = report.problems.map((problem) => problem.problem);
|
||||
expect(tokens).toHaveLength(3);
|
||||
// the model id is shaped like a path and must never be flagged
|
||||
expect(tokens.some((problem) => problem.includes("claude-opus-5"))).toBe(false);
|
||||
// a scope-relative citation is taught its root-relative ref
|
||||
expect(
|
||||
tokens.some((problem) =>
|
||||
problem.includes(`${REF} infrastructure/eas-build-worker/terraform/ —`),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(tokens.some((problem) => problem.includes(`${REF} infrastructure/finops/ —`))).toBe(true);
|
||||
});
|
||||
|
||||
test("the ref the message suggests silences the citation it was suggested for", async () => {
|
||||
// A scoped prompt names an app by its directory basename; the ref that pins it is
|
||||
// the root-relative path. Coverage has to connect the two, or the suggested fix is
|
||||
// not a fix. Both the plain and the glob form of that path must count.
|
||||
const files = (pin: string): Record<string, string> => ({
|
||||
"infrastructure/cert-manager/kustomization.yaml": "resources: []\n",
|
||||
"infrastructure/.expo-code-review/config.jsonc": "{}\n",
|
||||
"infrastructure/.expo-code-review/agents/k8s.md": `${pin}\n\nApps: \`cert-manager\`.\n`,
|
||||
});
|
||||
|
||||
const unpinned = await checkConfigRefs({ root: await makeRepo(files("")) });
|
||||
expect(unpinned.problems).toHaveLength(1);
|
||||
expect(unpinned.problems[0]!.problem).toContain(`${REF} infrastructure/cert-manager/ —`);
|
||||
|
||||
for (const pin of [
|
||||
`<!-- ${REF} infrastructure/cert-manager/ — the app -->`,
|
||||
`<!-- ${REF} glob:infrastructure/cert-manager/** — the app -->`,
|
||||
]) {
|
||||
expect((await checkConfigRefs({ root: await makeRepo(files(pin)) })).problems).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test("a scope-relative annotated ref is broken, and names the root-relative fix", async () => {
|
||||
const root = await makeRepo({
|
||||
"infrastructure/general-central/module/main.tf": "resource {}\n",
|
||||
"infrastructure/.expo-code-review/config.jsonc": "{}\n",
|
||||
"infrastructure/.expo-code-review/agents/terraform-safety.md": `<!-- ${REF} general-central/module/ — the module tree -->\n`,
|
||||
});
|
||||
const report = await checkConfigRefs({ root });
|
||||
expect(report.problems).toHaveLength(1);
|
||||
expect(report.problems[0]!.kind).toBe("broken-ref");
|
||||
expect(report.problems[0]!.problem).toContain(
|
||||
"did you mean infrastructure/general-central/module",
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// structural refs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("routing: a scope with no setup dir, an unknown enforced agent, and a dead glob all fail", async () => {
|
||||
const root = await makeRepo(
|
||||
setup({
|
||||
"src/a.ts": "export const a = 1;\n",
|
||||
".expo-code-review/routing.jsonc": JSON.stringify({
|
||||
defaults: { enforceAgents: ["security", "ghost"] },
|
||||
scopes: [
|
||||
{ name: "default", paths: ["**/*"], config: "." },
|
||||
{ name: "billing", paths: ["nope/**"], config: "packages/billing" },
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const report = await checkConfigRefs({ root });
|
||||
const problems = report.problems.filter((p) => p.kind === "structural").map((p) => p.problem);
|
||||
expect(problems).toHaveLength(3);
|
||||
expect(problems.some((p) => p.includes('scope "billing" has no packages/billing/'))).toBe(true);
|
||||
expect(problems.some((p) => p.includes("agents/ghost.md"))).toBe(true);
|
||||
expect(problems.some((p) => p.includes("matches no file in the repo: nope/**"))).toBe(true);
|
||||
});
|
||||
|
||||
test("enforceAgents is judged against the manifest's own setup dir, not the code tree", async () => {
|
||||
// The `ecr ci` shape: setup from a trusted base-ref checkout, code from the PR head.
|
||||
const code = await makeRepo({ "src/a.ts": "export const a = 1;\n" });
|
||||
const trustedBase = await makeRepo({
|
||||
".expo-code-review/agents/security.md": "# Security\n",
|
||||
".expo-code-review/routing.jsonc": JSON.stringify({
|
||||
defaults: { enforceAgents: ["security"] },
|
||||
scopes: [{ name: "default", paths: ["**/*"], config: "." }],
|
||||
}),
|
||||
});
|
||||
const report = await checkConfigRefs({
|
||||
root: code,
|
||||
setupDirs: [path.join(trustedBase, ".expo-code-review")],
|
||||
});
|
||||
expect(report.problems.filter((problem) => problem.problem.includes("enforceAgents"))).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("scope setup dirs are swept too, and dot dirs (other worktrees) are not", async () => {
|
||||
const root = await makeRepo(
|
||||
setup({
|
||||
"packages/billing/.expo-code-review/agents/money.md": `<!-- ${REF} src/gone.ts -->\n`,
|
||||
".claude/worktrees/other/.expo-code-review/agents/x.md": `<!-- ${REF} src/gone.ts -->\n`,
|
||||
}),
|
||||
);
|
||||
const dirs = await discoverSetupDirs(root);
|
||||
expect(dirs.map((d) => path.relative(root, d)).sort()).toEqual([
|
||||
".expo-code-review",
|
||||
path.join("packages", "billing", ".expo-code-review"),
|
||||
]);
|
||||
const report = await checkConfigRefs({ root });
|
||||
expect(report.problems).toHaveLength(1);
|
||||
expect(report.problems[0]!.file).toBe(
|
||||
path.join("packages", "billing", ".expo-code-review", "agents", "money.md"),
|
||||
);
|
||||
});
|
||||
|
||||
test("one ref covers the prose form of the same citation", async () => {
|
||||
const root = await makeRepo(
|
||||
setup({
|
||||
"src/commands/review.ts": "export const a = 1;\n",
|
||||
"src/core/util.ts": "export function errorMessage() {}\n",
|
||||
"docs/deep/nested/Handler.kt": "class Handler\n",
|
||||
".expo-code-review/agents/security.md":
|
||||
`<!-- ${REF} glob:src/commands/*.ts — the command shape -->\n` +
|
||||
`<!-- ${REF} src/core/util.ts#errorMessage — the error path -->\n` +
|
||||
`<!-- ${REF} glob:**/Handler.kt — the handlers -->\n` +
|
||||
"Commands in `src/commands/*.ts` report via `src/core/util.ts`, like `.../nested/Handler.kt`.\n",
|
||||
}),
|
||||
);
|
||||
expect((await checkConfigRefs({ root })).problems).toEqual([]);
|
||||
});
|
||||
|
||||
test("annotations inside a fenced block document the grammar and are not resolved", () => {
|
||||
const text = `<!-- ${REF} src/real.ts -->\n\n\`\`\`md\n<!-- ${REF} src/example.ts — how to write one -->\n\`\`\`\n`;
|
||||
expect(parseRefAnnotations(text, "agents/x.md").map((ref) => ref.target)).toEqual([
|
||||
"src/real.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
test("an LLP target belongs to another mechanism: not resolved, not counted", async () => {
|
||||
const root = await makeRepo(
|
||||
setup({
|
||||
".expo-code-review/agents/security.md":
|
||||
`<!-- ${REF} LLP 0009#config-and-prompt-templates [implements] — engine corpus -->\n` +
|
||||
`<!-- ${REF} LLP 4 -->\n`,
|
||||
}),
|
||||
);
|
||||
const report = await checkConfigRefs({ root });
|
||||
expect(report.problems).toEqual([]);
|
||||
expect(report.refs).toEqual([]);
|
||||
expect(report.citedPaths).toEqual([]);
|
||||
});
|
||||
|
||||
test("a target starting with < is a documented placeholder, not a citation", async () => {
|
||||
const root = await makeRepo(
|
||||
setup({
|
||||
".expo-code-review/agents/security.md": `<!-- ${REF} <path/to/file.ts>#<symbol> — example -->\n`,
|
||||
}),
|
||||
);
|
||||
expect((await checkConfigRefs({ root })).problems).toEqual([]);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// review-side signal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("reviewSetupRefNotes advises on broken refs and on cited code the PR changes", async () => {
|
||||
const root = await makeRepo(
|
||||
setup({
|
||||
"src/auth.ts": "export const a = 1;\n",
|
||||
".expo-code-review/agents/security.md": `<!-- ${REF} src/auth.ts — entry point -->\n<!-- ${REF} src/gone.ts — moved -->\n`,
|
||||
}),
|
||||
);
|
||||
const setupDir = path.join(root, ".expo-code-review");
|
||||
const notes = await reviewSetupRefNotes({
|
||||
root,
|
||||
setupDirs: [setupDir],
|
||||
changedFiles: ["src/auth.ts"],
|
||||
});
|
||||
expect(notes).toHaveLength(2);
|
||||
expect(notes[0]).toContain("no longer resolves");
|
||||
expect(notes[0]).toContain("ecr ref-check");
|
||||
expect(notes[1]).toContain("src/auth.ts");
|
||||
|
||||
// An unannotated citation is a command-level failure, not review advice.
|
||||
const prose = await makeRepo(
|
||||
setup({
|
||||
"src/auth.ts": "export const a = 1;\n",
|
||||
".expo-code-review/agents/security.md": "Read `src/auth.ts`.\n",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
await reviewSetupRefNotes({
|
||||
root: prose,
|
||||
setupDirs: [path.join(prose, ".expo-code-review")],
|
||||
changedFiles: [],
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("setup problems are labelled by setup-dir position when the config lives outside the tree", async () => {
|
||||
const code = await makeRepo({ "src/auth.ts": "export const a = 1;\n" });
|
||||
const trustedBase = await makeRepo(
|
||||
setup({ ".expo-code-review/agents/security.md": `<!-- ${REF} src/gone.ts -->\n` }),
|
||||
);
|
||||
const report = await checkConfigRefs({
|
||||
root: code,
|
||||
setupDirs: [path.join(trustedBase, ".expo-code-review")],
|
||||
});
|
||||
expect(report.problems).toHaveLength(1);
|
||||
expect(report.problems[0]!.file).toBe(path.join(".expo-code-review", "agents", "security.md"));
|
||||
});
|
||||
|
||||
test("citedPathsTouchedBy reports cited paths a PR changed, dirs included", async () => {
|
||||
const root = await makeRepo(
|
||||
setup({
|
||||
"src/auth.ts": "export const a = 1;\n",
|
||||
"src/entities/oauth/index.ts": "export const b = 2;\n",
|
||||
".expo-code-review/agents/security.md": `<!-- ${REF} src/auth.ts — entry point -->\n<!-- ${REF} src/entities/oauth/ -->\n`,
|
||||
}),
|
||||
);
|
||||
const report = await checkConfigRefs({ root });
|
||||
expect(citedPathsTouchedBy(report, ["src/auth.ts", "README.md"])).toEqual(["src/auth.ts"]);
|
||||
expect(citedPathsTouchedBy(report, ["src/entities/oauth/token.ts"])).toEqual([
|
||||
"src/entities/oauth",
|
||||
]);
|
||||
expect(citedPathsTouchedBy(report, ["README.md"])).toEqual([]);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the command's argument parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("--root refuses a flag-shaped value instead of checking a nonexistent tree", async () => {
|
||||
await refCheckCommand(["--root", "--json"]);
|
||||
expect(process.exitCode).toBe(2);
|
||||
process.exitCode = 0;
|
||||
|
||||
await refCheckCommand(["--root"]);
|
||||
expect(process.exitCode).toBe(2);
|
||||
process.exitCode = 0;
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { initCommand, parseTokenEnvs, substituteTokenEnv } from "../commands/init.js";
|
||||
import { checkConfigRefs } from "../core/config-refs.js";
|
||||
import { git } from "../core/exec.js";
|
||||
import { CONFIG_DIRNAME } from "../config/load.js";
|
||||
|
||||
@@ -302,3 +303,13 @@ test("every `uses:` in templates/*.yml is pinned by 40-hex commit SHA with a ver
|
||||
// Each template pins checkout + setup-node (+ upload-artifact in workflow.yml).
|
||||
expect(usesLines).toBeGreaterThanOrEqual(7);
|
||||
});
|
||||
|
||||
// @ref LLP 0012#what-gets-scanned [constrained-by] — a scaffold must pass `ecr ref-check` on day one
|
||||
test("a freshly scaffolded repo passes ref-check", async () => {
|
||||
const root = await freshRepo();
|
||||
await initCommand(["--monorepo"]);
|
||||
|
||||
// LLP annotations the templates carry belong to the engine's own corpus and are
|
||||
// skipped here, not reported: ecr only judges citations into the reviewed repo.
|
||||
expect((await checkConfigRefs({ root })).problems).toEqual([]);
|
||||
});
|
||||
@@ -31,6 +31,18 @@ test("parseEmbeddedFingerprints round-trips even with a regex-metachar comment t
|
||||
expect(parseEmbeddedFingerprints(body, tag).length).toBe(1);
|
||||
});
|
||||
|
||||
test("setup note renders only when there is ref advice, and never as a finding", () => {
|
||||
expect(renderMarkdown(base, "tag")).not.toContain("Review setup");
|
||||
const body = renderMarkdown(
|
||||
{ ...base, setupNotes: ["The reviewer setup cites code that no longer resolves (1 ref(s))."] },
|
||||
"tag",
|
||||
);
|
||||
expect(body).toContain("🔗 **Review setup:**");
|
||||
expect(body).toContain("no longer resolves");
|
||||
// advice does not change the decision or the findings list
|
||||
expect(body).toContain("No findings.");
|
||||
});
|
||||
|
||||
test("coverage note only renders when incomplete is non-empty (no more wolf-crying)", () => {
|
||||
expect(renderMarkdown(base, "tag")).not.toContain("Coverage note");
|
||||
expect(renderMarkdown({ ...base, incomplete: ["a pass timed out"] }, "tag")).toContain(
|
||||
|
||||
@@ -5,6 +5,7 @@ import { dismissCommand } from "./commands/dismiss.js";
|
||||
import { doctorCommand } from "./commands/doctor.js";
|
||||
import { feedbackCommand } from "./commands/feedback.js";
|
||||
import { initCommand } from "./commands/init.js";
|
||||
import { refCheckCommand } from "./commands/ref-check.js";
|
||||
import { reviewCommand } from "./commands/review.js";
|
||||
import { setupAuthCommand } from "./commands/setup-auth.js";
|
||||
import { verifyConfigCommand } from "./commands/verify-config.js";
|
||||
@@ -21,6 +22,7 @@ Usage:
|
||||
ecr setup-auth [--yes] Walk through getting model credentials for local runs.
|
||||
ecr doctor [--list-scopes] Check environment, config, credentials, and scopes.
|
||||
ecr verify-config [--expected <env>] [--json] Refuse to run if a config could redirect the credential (CI guard).
|
||||
ecr ref-check [--root <dir>] [--json] Fail if the review setup cites code that moved or vanished.
|
||||
|
||||
Agents live in each repo under .expo-code-review/. This CLI is the engine.
|
||||
|
||||
@@ -70,6 +72,9 @@ async function main(): Promise<void> {
|
||||
case "verify-config":
|
||||
await verifyConfigCommand(rest);
|
||||
break;
|
||||
case "ref-check":
|
||||
await refCheckCommand(rest);
|
||||
break;
|
||||
default:
|
||||
process.stderr.write(`Unknown command: ${sub}\n\n${USAGE}`);
|
||||
process.exitCode = 2;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// @ref LLP 0012#run-points-command-and-review — the gating run point: exit 1 on any broken ref
|
||||
import path from "node:path";
|
||||
|
||||
import { checkConfigRefs } from "../core/config-refs.js";
|
||||
import type { RefProblem } from "../core/config-refs.js";
|
||||
import { repoRoot } from "../core/exec.js";
|
||||
import { errorMessage } from "../core/util.js";
|
||||
|
||||
const USAGE = `ecr ref-check — fail when the review setup cites code that moved or vanished
|
||||
|
||||
Usage:
|
||||
ecr ref-check [--root <dir>] [--json]
|
||||
|
||||
Sweeps every .expo-code-review/ directory in the repo (root and scopes) and checks
|
||||
that each code citation still resolves against this checkout:
|
||||
• \`@ref <target>\` annotations in prompts and configs. A target is a file, a
|
||||
directory (trailing slash), \`glob:<pattern>\`, a \`file#symbol\`, or a
|
||||
\`doc.md#heading\`. Never a line number — lines rot silently.
|
||||
• Unannotated citations: a backticked token that looks like a repo path must be a
|
||||
ref, so nothing cites code without being checked. Use \`@ref-ignore <token>\`
|
||||
for a token that is not a path.
|
||||
• Structural refs the config already declares: enforceAgents ids, scope config
|
||||
directories, and scope path globs.
|
||||
Exit 0 = every ref holds. Exit 1 = at least one is broken.
|
||||
|
||||
Options:
|
||||
--root <dir> Repository root to check (default: the current git repo).
|
||||
--json Emit {ok, problems:[{file, line, kind, problem}]} on stdout.
|
||||
`;
|
||||
|
||||
const KIND_LABEL: Record<RefProblem["kind"], string> = {
|
||||
"broken-ref": "broken ref",
|
||||
"line-number-ref": "line-number ref",
|
||||
"unannotated-citation": "unannotated citation",
|
||||
structural: "structural ref",
|
||||
};
|
||||
|
||||
export async function refCheckCommand(argv: string[]): Promise<void> {
|
||||
if (argv.includes("-h") || argv.includes("--help")) {
|
||||
process.stdout.write(USAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
let root: string | undefined;
|
||||
let json = false;
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i]!;
|
||||
if (arg === "--json") {
|
||||
json = true;
|
||||
} else if (arg === "--root") {
|
||||
root = argv[++i];
|
||||
// A flag-shaped value means the directory was forgotten: taking it would check
|
||||
// some nonexistent path and report "all resolve" while swallowing the real flag.
|
||||
if (!root || root.startsWith("-")) {
|
||||
process.stderr.write("ecr ref-check: --root needs a directory\n");
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
process.stderr.write(`ecr ref-check: unknown argument ${arg}\n${USAGE}`);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const resolvedRoot = root ? path.resolve(root) : ((await repoRoot()) ?? process.cwd());
|
||||
const report = await checkConfigRefs({ root: resolvedRoot });
|
||||
|
||||
if (json) {
|
||||
process.stdout.write(`${JSON.stringify({ ok: report.ok, problems: report.problems })}\n`);
|
||||
} else if (report.ok) {
|
||||
process.stdout.write(
|
||||
`ref-check: ${report.refs.length} ref(s) across ${report.scannedFiles.length} setup file(s) — all resolve\n`,
|
||||
);
|
||||
} else {
|
||||
for (const problem of report.problems) {
|
||||
process.stderr.write(
|
||||
`${problem.file}:${problem.line}: ${KIND_LABEL[problem.kind]}: ${problem.problem}\n`,
|
||||
);
|
||||
}
|
||||
process.stderr.write(
|
||||
`\nref-check: ${report.problems.length} problem(s). Update the ref or the prompt that cites it.\n`,
|
||||
);
|
||||
}
|
||||
if (!report.ok) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} catch (error) {
|
||||
process.stderr.write(`ecr ref-check: ${errorMessage(error)}\n`);
|
||||
process.exitCode = 2;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,883 @@
|
||||
// @ref LLP 0012#the-ref-grammar — one grammar for every code citation in a review setup
|
||||
// @ref LLP 0012#no-line-numbers-symbol-anchors-instead — targets are files, dirs, globs, symbols; never line numbers
|
||||
// @ref LLP 0012#unannotated-citations-are-broken-refs — a path-like backtick token that is not a ref fails the check
|
||||
import { readdir, readFile, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { CONFIG_DIRNAME, stripJsonComments, stripTrailingCommas } from "../config/load.js";
|
||||
import { ROUTING_FILENAME } from "../config/routing.js";
|
||||
import { git, pathInside } from "./exec.js";
|
||||
import { matchesIgnore } from "./noise.js";
|
||||
|
||||
/**
|
||||
* Built by concatenation so this module's own regexes and doc comments are not
|
||||
* themselves collected as refs by a scanner (the repo's `ref-check` does the same).
|
||||
*/
|
||||
const REF_MARK = "@" + "ref";
|
||||
const IGNORE_MARK = REF_MARK + "-ignore";
|
||||
|
||||
/** `@ref <target> [relation] — gloss` inside a `//`-style comment. */
|
||||
const LINE_REF_RE = new RegExp(String.raw`${REF_MARK}\s+(.+)`);
|
||||
/** `<!-- @ref <target> — gloss -->`, possibly spanning lines. */
|
||||
const MD_REF_RE = new RegExp(String.raw`<!--\s*${REF_MARK}\s+([\s\S]+?)-->`, "g");
|
||||
const IGNORE_RE = new RegExp(String.raw`${IGNORE_MARK}\s+(.+)`);
|
||||
|
||||
/** LLP-corpus targets (`LLP 0004#anchor`) belong to the engine repo, not an adopting one. */
|
||||
const LLP_TARGET_RE = /^LLP\s+\d{1,4}(?:#\S+)?$/;
|
||||
const URL_RE = /^https?:\/\/\S+$/;
|
||||
/** `<path>`, `<agent-id>`: documenting the grammar, not citing a file. */
|
||||
const PLACEHOLDER_RE = /^[<`]/;
|
||||
const GLOB_PREFIX = "glob:";
|
||||
|
||||
/** A citation that pins a line (`src/a.ts:42`, `src/a.ts:42-51`) — always refused. */
|
||||
const LINE_CITATION_RE = /^(.+?):(\d+)(?:-\d+)?$/;
|
||||
|
||||
/**
|
||||
* Extensions that make a backticked token a *code citation* rather than prose.
|
||||
* Deliberately an allowlist: `openai/gpt-5.5` and `label:<agent>` must not read as
|
||||
* paths, while `session.ts` and `.github/workflows/**` must.
|
||||
*/
|
||||
const CITATION_EXTENSIONS = new Set([
|
||||
".c",
|
||||
".cc",
|
||||
".cjs",
|
||||
".cpp",
|
||||
".cs",
|
||||
".css",
|
||||
".go",
|
||||
".graphql",
|
||||
".h",
|
||||
".hpp",
|
||||
".html",
|
||||
".java",
|
||||
".js",
|
||||
".json",
|
||||
".jsonc",
|
||||
".jsx",
|
||||
".kt",
|
||||
".lock",
|
||||
".md",
|
||||
".mjs",
|
||||
".mts",
|
||||
".php",
|
||||
".prisma",
|
||||
".proto",
|
||||
".py",
|
||||
".rb",
|
||||
".rs",
|
||||
".scss",
|
||||
".sh",
|
||||
".sql",
|
||||
".swift",
|
||||
".toml",
|
||||
".ts",
|
||||
".tsx",
|
||||
".txt",
|
||||
".vue",
|
||||
".yaml",
|
||||
".yml",
|
||||
".zsh",
|
||||
]);
|
||||
|
||||
/** Files inside a review-setup dir that are prompts or config (everything else is skipped). */
|
||||
const SCANNED_EXTENSIONS = new Set([".md", ".markdown", ".json", ".jsonc", ".txt"]);
|
||||
|
||||
export type RefProblemKind =
|
||||
| "broken-ref"
|
||||
| "line-number-ref"
|
||||
| "unannotated-citation"
|
||||
| "structural";
|
||||
|
||||
export interface RefProblem {
|
||||
/** Repo-relative file the problem was found in. */
|
||||
file: string;
|
||||
line: number;
|
||||
kind: RefProblemKind;
|
||||
/** One sentence, imperative where a fix is obvious. */
|
||||
problem: string;
|
||||
}
|
||||
|
||||
export interface ParsedRef {
|
||||
file: string;
|
||||
line: number;
|
||||
/** The target as written, gloss and relation stripped (`src/a.ts#foo`, `glob:**\/*.ts`). */
|
||||
target: string;
|
||||
}
|
||||
|
||||
export interface RefCheckReport {
|
||||
ok: boolean;
|
||||
problems: RefProblem[];
|
||||
/** Every resolvable ref found, so callers can tell when a PR touches cited code. */
|
||||
refs: ParsedRef[];
|
||||
/** Repo-relative review-setup files that were scanned. */
|
||||
scannedFiles: string[];
|
||||
/** Repo-relative paths (files and dirs) that some ref cites. */
|
||||
citedPaths: string[];
|
||||
}
|
||||
|
||||
interface RepoIndex {
|
||||
root: string;
|
||||
/** Tracked files, repo-relative with `/` separators. Empty when git is unavailable. */
|
||||
files: string[];
|
||||
}
|
||||
|
||||
function escapeRegExp(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/** GitHub-style heading slug: lowercase, spaces to hyphens, drop other punctuation. */
|
||||
export function slugifyHeading(text: string): string {
|
||||
return [...text.replace(/`/g, "").trim().toLowerCase()]
|
||||
.map((ch) => (/[a-z0-9\-_]/.test(ch) ? ch : /\s/.test(ch) ? "-" : ""))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** Heading texts outside fenced code blocks. */
|
||||
function markdownHeadings(text: string): string[] {
|
||||
const headings: string[] = [];
|
||||
let fenced = false;
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
if (line.trimStart().startsWith("```")) {
|
||||
fenced = !fenced;
|
||||
continue;
|
||||
}
|
||||
const match = fenced ? null : /^#{1,6}\s+(.+?)\s*$/.exec(line);
|
||||
if (match) {
|
||||
headings.push(match[1]!);
|
||||
}
|
||||
}
|
||||
return headings;
|
||||
}
|
||||
|
||||
/** Strip the trailing `— gloss` and `[relation]` so only the target remains. */
|
||||
function refTarget(body: string): string {
|
||||
const withoutGloss = body.split(/\s+(?:[—–]|--)(?:\s+|$)/)[0]!.trim();
|
||||
const withoutRelation = withoutGloss.replace(/\s*\[[a-z-]+\]\s*$/, "").trim();
|
||||
// `LLP 0009#anchor` is ONE target that contains a space; taking the first
|
||||
// whitespace-separated token would leave a bare `LLP` and read as a broken path.
|
||||
const llp = /^LLP\s+\d{1,4}(?:#\S+)?/.exec(withoutRelation);
|
||||
return llp ? llp[0] : (withoutRelation.split(/\s+/)[0] ?? "");
|
||||
}
|
||||
|
||||
/** Line numbers (1-based) that sit inside a fenced code block. */
|
||||
function fencedLines(text: string): Set<number> {
|
||||
const fenced = new Set<number>();
|
||||
let open = false;
|
||||
text.split(/\r?\n/).forEach((line, index) => {
|
||||
if (line.trimStart().startsWith("```")) {
|
||||
fenced.add(index + 1); // the fence line itself is never a ref site
|
||||
open = !open;
|
||||
return;
|
||||
}
|
||||
if (open) {
|
||||
fenced.add(index + 1);
|
||||
}
|
||||
});
|
||||
return fenced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `@ref` annotation in a setup file, markdown comments included. Fenced code
|
||||
* blocks are skipped: an annotation shown inside one is documenting the grammar, and
|
||||
* resolving it would make every doc that explains refs fail the check.
|
||||
*/
|
||||
export function parseRefAnnotations(text: string, file: string): ParsedRef[] {
|
||||
const refs: ParsedRef[] = [];
|
||||
if (file.endsWith(".md") || file.endsWith(".markdown")) {
|
||||
const fenced = fencedLines(text);
|
||||
for (const match of text.matchAll(MD_REF_RE)) {
|
||||
const line = text.slice(0, match.index).split("\n").length;
|
||||
if (fenced.has(line)) {
|
||||
continue;
|
||||
}
|
||||
refs.push({ file, line, target: refTarget(match[1]!) });
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
text.split(/\r?\n/).forEach((lineText, index) => {
|
||||
const match = LINE_REF_RE.exec(lineText);
|
||||
if (match) {
|
||||
refs.push({ file, line: index + 1, target: refTarget(match[1]!) });
|
||||
}
|
||||
});
|
||||
return refs;
|
||||
}
|
||||
|
||||
/** Tokens an author declared as prose, not citations (`@ref-ignore knex.raw()`). */
|
||||
export function parseRefIgnores(text: string): Set<string> {
|
||||
const ignored = new Set<string>();
|
||||
for (const lineText of text.split(/\r?\n/)) {
|
||||
const match = IGNORE_RE.exec(lineText);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
for (const token of match[1]!.replace(/-->\s*$/, "").split(/[\s,]+/)) {
|
||||
const cleaned = token.replace(/^`|`$/g, "").trim();
|
||||
if (cleaned) {
|
||||
ignored.add(cleaned);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ignored;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this backticked token cite code? True for `a/b/c.ts`, `session.ts`,
|
||||
* `src/entities/oauth/` and `.github/workflows/**`; false for `ecr ci`,
|
||||
* `label:<agent>`, `knex.raw()`, `openai/gpt-5.5` and bare `**`.
|
||||
*/
|
||||
export function isCodeCitation(token: string): boolean {
|
||||
if (!token || /[\s<>(){}"'|=,;]/.test(token) || token.length > 200) {
|
||||
return false;
|
||||
}
|
||||
const withoutLine = LINE_CITATION_RE.exec(token)?.[1] ?? token;
|
||||
if (withoutLine.includes(":")) {
|
||||
return false;
|
||||
}
|
||||
if (withoutLine.endsWith("/")) {
|
||||
return withoutLine.replace(/[/*]/g, "").length > 0;
|
||||
}
|
||||
const base = withoutLine.split("/").pop() ?? "";
|
||||
// A wildcard tail inside a path (`.github/workflows/**`) is a citation with no extension.
|
||||
if (base.includes("*") && withoutLine.includes("/")) {
|
||||
return withoutLine.replace(/[/*]/g, "").length > 0;
|
||||
}
|
||||
// A bare extension (`.ts`, `.js`) is prose about a suffix, not a file.
|
||||
if (CITATION_EXTENSIONS.has(base.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
const dot = base.lastIndexOf(".");
|
||||
if (dot <= 0 && !base.startsWith(".")) {
|
||||
return false;
|
||||
}
|
||||
const extension = base.slice(base.lastIndexOf("."));
|
||||
return CITATION_EXTENSIONS.has(extension.toLowerCase()) && base.replace(/[*.]/g, "").length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ref an unannotated citation should have become. Abbreviated paths (`.../a/B.kt`),
|
||||
* wildcards (`*Policy.ts`) and bare filenames (`session.ts`) become suffix globs, so a
|
||||
* prompt keeps its short readable form and still gets checked.
|
||||
*/
|
||||
export function suggestedRef(token: string): string {
|
||||
const trimmed = token.replace(/^\.\.\.\/?/, "").replace(/^\/+/, "");
|
||||
if (token.startsWith("...") || !trimmed.includes("/")) {
|
||||
return `${GLOB_PREFIX}**/${trimmed}`;
|
||||
}
|
||||
return trimmed.includes("*") ? `${GLOB_PREFIX}${trimmed}` : trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every form under which an annotated target can cover a prose citation, so one ref
|
||||
* silences the token it is about: `glob:src/commands/*.ts` covers `src/commands/*.ts`,
|
||||
* and `src/core/util.ts#errorMessage` covers `src/core/util.ts`.
|
||||
*/
|
||||
function coveringForms(target: string): string[] {
|
||||
const bare = target.startsWith(GLOB_PREFIX) ? target.slice(GLOB_PREFIX.length) : target;
|
||||
const withoutAnchor = splitAnchor(bare)[0];
|
||||
return [target, bare, withoutAnchor].flatMap((form) => [
|
||||
form,
|
||||
form.replace(/\/$/, ""),
|
||||
`${form}/`,
|
||||
]);
|
||||
}
|
||||
|
||||
/** The forms a prose citation could have been annotated as. */
|
||||
function citationForms(token: string): string[] {
|
||||
const suggested = suggestedRef(token);
|
||||
return [
|
||||
token,
|
||||
token.replace(/\/$/, ""),
|
||||
suggested,
|
||||
suggested.startsWith(GLOB_PREFIX) ? suggested.slice(GLOB_PREFIX.length) : suggested,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Paths to test when a token has no extension to give it away — `eas-build-worker/terraform`,
|
||||
* `general-central/{module,production}`, a bare `finops`. These are only citations if they
|
||||
* actually resolve, since `anthropic/claude-opus-5` is shaped exactly the same and is not
|
||||
* a path. Returns the candidate paths to probe, or [] when the token cannot be one.
|
||||
*/
|
||||
export function pathishCandidates(token: string): string[] {
|
||||
if (!token || token.length > 200) {
|
||||
return [];
|
||||
}
|
||||
// Cut a brace list or wildcard tail off FIRST: the part before it is the path to
|
||||
// probe, and only that part has to look like one (`a/{b,c}` carries a comma).
|
||||
const cut = Math.min(
|
||||
...[token.indexOf("{"), token.indexOf("*")].filter((index) => index >= 0),
|
||||
token.length,
|
||||
);
|
||||
const prefix = token.slice(0, cut).replace(/\/$/, "");
|
||||
if (
|
||||
!prefix ||
|
||||
!/[a-zA-Z]/.test(prefix) ||
|
||||
/[\s<>()"'|=,;:]/.test(prefix) ||
|
||||
prefix.startsWith("-")
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return prefix === token ? [token] : [prefix];
|
||||
}
|
||||
|
||||
/** Backticked code citations, outside fenced code blocks, with their line numbers. */
|
||||
export function findProseCitations(text: string): Array<{ line: number; token: string }> {
|
||||
const found: Array<{ line: number; token: string }> = [];
|
||||
let fenced = false;
|
||||
text.split(/\r?\n/).forEach((lineText, index) => {
|
||||
if (lineText.trimStart().startsWith("```")) {
|
||||
fenced = !fenced;
|
||||
return;
|
||||
}
|
||||
if (fenced) {
|
||||
return;
|
||||
}
|
||||
for (const match of lineText.matchAll(/`([^`]+)`/g)) {
|
||||
found.push({ line: index + 1, token: match[1]! });
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
async function pathKind(absolute: string): Promise<"file" | "dir" | "missing"> {
|
||||
try {
|
||||
const stats = await stat(absolute);
|
||||
return stats.isDirectory() ? "dir" : "file";
|
||||
} catch {
|
||||
return "missing";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one ref target against the repo. Returns the problem text, or null when
|
||||
* the ref holds. Refs are ALWAYS repo-root-relative — a scope's prompts cite the
|
||||
* same paths the reviewer sees in the diff, so there is no per-directory base.
|
||||
*/
|
||||
async function resolveTarget(
|
||||
target: string,
|
||||
index: RepoIndex,
|
||||
/** A scope's own subtree, used only to say "did you mean" on a scope-relative path. */
|
||||
scopeRoot?: string,
|
||||
): Promise<string | null> {
|
||||
if (!target) {
|
||||
return `empty ${REF_MARK} target`;
|
||||
}
|
||||
if (URL_RE.test(target) || LLP_TARGET_RE.test(target) || PLACEHOLDER_RE.test(target)) {
|
||||
// URLs are shape-only (never fetched); LLP numbers belong to the engine's own
|
||||
// corpus (`./ref-check`); `<placeholder>` targets are documentation of the syntax.
|
||||
return null;
|
||||
}
|
||||
if (target.startsWith(GLOB_PREFIX)) {
|
||||
const pattern = target.slice(GLOB_PREFIX.length);
|
||||
if (!pattern) {
|
||||
return `empty glob in ${REF_MARK} target`;
|
||||
}
|
||||
if (index.files.length === 0) {
|
||||
return null; // no file list to match against (not a git checkout) — uncheckable
|
||||
}
|
||||
return index.files.some((file) => matchesIgnore(file, pattern))
|
||||
? null
|
||||
: `glob matches no file in the repo: ${pattern}`;
|
||||
}
|
||||
const lineCitation = LINE_CITATION_RE.exec(target);
|
||||
if (lineCitation) {
|
||||
return `cites a line number (${target}); refs pin a file, dir, glob, or \`#symbol\` — line numbers rot silently`;
|
||||
}
|
||||
|
||||
const [rawPath, anchor] = splitAnchor(target);
|
||||
if (path.isAbsolute(rawPath) || rawPath.startsWith("~")) {
|
||||
return `absolute path (${rawPath}); refs are repo-root-relative`;
|
||||
}
|
||||
const absolute = path.resolve(index.root, rawPath);
|
||||
if (!pathInside(absolute, index.root)) {
|
||||
return `escapes the repository (${rawPath})`;
|
||||
}
|
||||
const kind = await pathKind(absolute);
|
||||
if (kind === "missing") {
|
||||
// A scope's prompts naturally say `general-central/module` for what is really
|
||||
// `infrastructure/general-central/module`. That is still a broken ref (one base, no
|
||||
// ambiguity), but the fix is named instead of left as a puzzle.
|
||||
const scoped = scopeRoot ? path.resolve(scopeRoot, rawPath) : null;
|
||||
if (scoped && pathInside(scoped, index.root) && (await pathKind(scoped)) !== "missing") {
|
||||
const suggestion = path.relative(index.root, scoped).split(path.sep).join("/");
|
||||
return `no such path in the repo: ${rawPath} — refs are repo-root-relative; did you mean ${suggestion}?`;
|
||||
}
|
||||
return `no such path in the repo: ${rawPath}`;
|
||||
}
|
||||
if (rawPath.endsWith("/") && kind !== "dir") {
|
||||
return `${rawPath} is a file, not a directory (drop the trailing slash)`;
|
||||
}
|
||||
if (!anchor) {
|
||||
return null;
|
||||
}
|
||||
if (kind === "dir") {
|
||||
return `${rawPath} is a directory, so \`#${anchor}\` cannot resolve`;
|
||||
}
|
||||
let content: string;
|
||||
try {
|
||||
content = await readFile(absolute, "utf8");
|
||||
} catch {
|
||||
return null; // unreadable (binary, permissions) — uncheckable, never a failure
|
||||
}
|
||||
if (rawPath.endsWith(".md") || rawPath.endsWith(".markdown")) {
|
||||
const slugs = new Set(markdownHeadings(content).map(slugifyHeading));
|
||||
return slugs.has(slugifyHeading(anchor)) ? null : `${rawPath} has no heading #${anchor}`;
|
||||
}
|
||||
return new RegExp(String.raw`\b${escapeRegExp(anchor)}\b`).test(content)
|
||||
? null
|
||||
: `${rawPath} no longer contains \`${anchor}\``;
|
||||
}
|
||||
|
||||
/**
|
||||
* How a setup file is named in a problem. Normally repo-relative, but in CI the setup
|
||||
* is materialized from the trusted base ref OUTSIDE the code tree, where a relative
|
||||
* path would read as `../../tmp/…`; there, name it by its position in the setup dir.
|
||||
*/
|
||||
function fileLabel(root: string, setupDir: string, file: string): string {
|
||||
const relative = path.relative(root, file);
|
||||
if (!relative.startsWith("..")) {
|
||||
return relative;
|
||||
}
|
||||
return path.join(CONFIG_DIRNAME, path.relative(setupDir, file));
|
||||
}
|
||||
|
||||
function splitAnchor(target: string): [string, string | undefined] {
|
||||
const hash = target.indexOf("#");
|
||||
return hash === -1 ? [target, undefined] : [target.slice(0, hash), target.slice(hash + 1)];
|
||||
}
|
||||
|
||||
/**
|
||||
* The repo's files, repo-relative with `/` separators — what `glob:` targets and scope
|
||||
* globs match against. `git ls-files` when possible (fast, and "tracked" is the right
|
||||
* notion for reviewable code), else a filesystem walk so the check still works in a
|
||||
* plain directory. Empty only for an unreadable root.
|
||||
*/
|
||||
async function listRepoFiles(root: string): Promise<string[]> {
|
||||
try {
|
||||
// Tracked AND untracked-but-not-ignored: a plain path ref resolves with `stat` and
|
||||
// therefore sees a file the moment it exists, so a glob must too — otherwise a
|
||||
// freshly scaffolded (uncommitted) tree reports globs as matching nothing.
|
||||
const tracked = (await git(["ls-files", "--cached", "--others", "--exclude-standard"], root))
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
if (tracked.length > 0) {
|
||||
return tracked;
|
||||
}
|
||||
} catch {
|
||||
// not a git checkout — fall through to the walk
|
||||
}
|
||||
const found: string[] = [];
|
||||
async function walk(dir: string): Promise<void> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.name === "node_modules" || (entry.name.startsWith(".") && entry.isDirectory())) {
|
||||
continue;
|
||||
}
|
||||
const child = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(child);
|
||||
} else if (entry.isFile()) {
|
||||
found.push(path.relative(root, child).split(path.sep).join("/"));
|
||||
}
|
||||
}
|
||||
}
|
||||
await walk(root);
|
||||
return found.sort();
|
||||
}
|
||||
|
||||
// @ref LLP 0012#what-gets-scanned [implements] — on-disk sweep of every setup dir, .runs/ excluded
|
||||
/**
|
||||
* Every review-setup directory in the repo, found by walking the tree (not the
|
||||
* routing manifest): a scope dir the manifest forgot still ships prompts to nobody,
|
||||
* and its stale refs are exactly what this check exists to surface.
|
||||
*/
|
||||
export async function discoverSetupDirs(root: string): Promise<string[]> {
|
||||
const found: string[] = [];
|
||||
async function walk(dir: string): Promise<void> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const child = path.join(dir, entry.name);
|
||||
if (entry.name === CONFIG_DIRNAME) {
|
||||
found.push(child);
|
||||
continue; // a setup dir never nests another
|
||||
}
|
||||
// Other dot dirs are skipped wholesale: `.claude/worktrees/` holds checkouts of
|
||||
// this same repo, whose setup dirs would otherwise be swept as if they were scopes.
|
||||
if (entry.name === "node_modules" || entry.name.startsWith(".")) {
|
||||
continue;
|
||||
}
|
||||
await walk(child);
|
||||
}
|
||||
}
|
||||
await walk(root);
|
||||
return found.sort();
|
||||
}
|
||||
|
||||
/** Prompt and config files inside a setup dir. Skips `.runs/` and other dot entries. */
|
||||
async function setupFiles(dir: string): Promise<string[]> {
|
||||
const found: string[] = [];
|
||||
async function walk(current: string): Promise<void> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(current, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith(".")) {
|
||||
continue;
|
||||
}
|
||||
const child = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(child);
|
||||
} else if (SCANNED_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
|
||||
found.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
await walk(dir);
|
||||
return found.sort();
|
||||
}
|
||||
|
||||
function parseJsonc(text: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const value = JSON.parse(stripTrailingCommas(stripJsonComments(text))) as unknown;
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function stringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === "string")
|
||||
: [];
|
||||
}
|
||||
|
||||
// @ref LLP 0012#structural-refs-need-no-annotation [implements] — ids and scope dirs are refs the config already declares
|
||||
/**
|
||||
* Refs the config declares structurally, so they are checked without an annotation:
|
||||
* every `enforceAgents` id must have a root `agents/<id>.md`, every scope must point
|
||||
* at a real setup dir, and every scope glob must match at least one tracked file (a
|
||||
* glob matching nothing means those prompts review nothing).
|
||||
*/
|
||||
async function checkRoutingManifest(
|
||||
file: string,
|
||||
index: RepoIndex,
|
||||
rootAgentIds: Set<string>,
|
||||
): Promise<RefProblem[]> {
|
||||
const problems: RefProblem[] = [];
|
||||
const relative = fileLabel(index.root, path.dirname(file), file);
|
||||
let parsed: Record<string, unknown> | null;
|
||||
try {
|
||||
parsed = parseJsonc(await readFile(file, "utf8"));
|
||||
} catch {
|
||||
return problems;
|
||||
}
|
||||
if (!parsed) {
|
||||
return [
|
||||
{ file: relative, line: 1, kind: "structural", problem: "could not be parsed as JSONC" },
|
||||
];
|
||||
}
|
||||
|
||||
const defaults = (parsed.defaults ?? {}) as Record<string, unknown>;
|
||||
const enforced = new Set(stringArray(defaults.enforceAgents));
|
||||
const scopes = Array.isArray(parsed.scopes) ? parsed.scopes : [];
|
||||
for (const scope of scopes) {
|
||||
if (!scope || typeof scope !== "object") {
|
||||
continue;
|
||||
}
|
||||
const entry = scope as Record<string, unknown>;
|
||||
const name = typeof entry.name === "string" ? entry.name : "(unnamed)";
|
||||
for (const id of stringArray(entry.enforceAgents)) {
|
||||
enforced.add(id);
|
||||
}
|
||||
const configDir = typeof entry.config === "string" ? entry.config : null;
|
||||
if (configDir) {
|
||||
const setupDir = path.resolve(index.root, configDir, CONFIG_DIRNAME);
|
||||
if (!pathInside(setupDir, index.root)) {
|
||||
problems.push({
|
||||
file: relative,
|
||||
line: 1,
|
||||
kind: "structural",
|
||||
problem: `scope "${name}" points outside the repository (config: ${configDir})`,
|
||||
});
|
||||
} else if ((await pathKind(setupDir)) !== "dir") {
|
||||
problems.push({
|
||||
file: relative,
|
||||
line: 1,
|
||||
kind: "structural",
|
||||
problem: `scope "${name}" has no ${configDir}/${CONFIG_DIRNAME}/ directory`,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (index.files.length > 0) {
|
||||
for (const pattern of stringArray(entry.paths)) {
|
||||
const variants = [pattern, pattern.replace(/\*\*\//g, "")];
|
||||
if (
|
||||
!index.files.some((repoFile) => variants.some((v) => v && matchesIgnore(repoFile, v)))
|
||||
) {
|
||||
problems.push({
|
||||
file: relative,
|
||||
line: 1,
|
||||
kind: "structural",
|
||||
problem: `scope "${name}" glob matches no file in the repo: ${pattern}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of enforced) {
|
||||
if (!rootAgentIds.has(id)) {
|
||||
problems.push({
|
||||
file: relative,
|
||||
line: 1,
|
||||
kind: "structural",
|
||||
problem: `enforceAgents names "${id}", but the root setup has no agents/${id}.md`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
/** Agent ids in a setup dir (id = filename without `.md`, per the loader). */
|
||||
async function agentIds(setupDir: string): Promise<Set<string>> {
|
||||
try {
|
||||
const entries = await readdir(path.join(setupDir, "agents"));
|
||||
return new Set(entries.filter((name) => name.endsWith(".md")).map((name) => name.slice(0, -3)));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
export interface CheckConfigRefsOptions {
|
||||
/** Repo root. Refs resolve against this tree — in CI, the PR head checkout. */
|
||||
root: string;
|
||||
/** Restrict the sweep to these setup dirs (absolute). Default: every one in the repo. */
|
||||
setupDirs?: string[];
|
||||
}
|
||||
|
||||
// @ref LLP 0012#run-points-command-and-review [implements] — one pure-ish entry point both the command and the review call
|
||||
/**
|
||||
* Check every code citation in the repo's review setup. Deterministic, no model, no
|
||||
* network: refs either resolve against the tree or they do not.
|
||||
*/
|
||||
export async function checkConfigRefs(options: CheckConfigRefsOptions): Promise<RefCheckReport> {
|
||||
const root = path.resolve(options.root);
|
||||
const index: RepoIndex = { root, files: await listRepoFiles(root) };
|
||||
const dirs = options.setupDirs ?? (await discoverSetupDirs(root));
|
||||
|
||||
const problems: RefProblem[] = [];
|
||||
const refs: ParsedRef[] = [];
|
||||
const scannedFiles: string[] = [];
|
||||
const citedPaths = new Set<string>();
|
||||
|
||||
// Does an extensionless token name something real? Cached: prompts repeat the same
|
||||
// paths, and each miss would otherwise cost a stat per occurrence.
|
||||
const resolvable = new Map<string, string | null>();
|
||||
/** The root-relative path an extensionless token names, or null if it names nothing. */
|
||||
const namedPath = async (token: string, scopeRoot: string): Promise<string | null> => {
|
||||
for (const candidate of pathishCandidates(token)) {
|
||||
for (const base of [root, scopeRoot]) {
|
||||
const absolute = path.resolve(base, candidate);
|
||||
let resolved = resolvable.get(absolute);
|
||||
if (resolved === undefined) {
|
||||
const kind = pathInside(absolute, root) ? await pathKind(absolute) : "missing";
|
||||
resolved =
|
||||
kind === "missing"
|
||||
? null
|
||||
: path.relative(root, absolute).split(path.sep).join("/") +
|
||||
(kind === "dir" ? "/" : "");
|
||||
resolvable.set(absolute, resolved);
|
||||
}
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
for (const dir of dirs) {
|
||||
const scopeRoot = path.dirname(dir);
|
||||
for (const file of await setupFiles(dir)) {
|
||||
const relative = fileLabel(root, dir, file);
|
||||
let text: string;
|
||||
try {
|
||||
text = await readFile(file, "utf8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
scannedFiles.push(relative);
|
||||
|
||||
const annotated = parseRefAnnotations(text, relative);
|
||||
const ignored = parseRefIgnores(text);
|
||||
const covered = new Set(annotated.flatMap((ref) => coveringForms(ref.target)));
|
||||
// A glob ref also covers any citation the glob itself matches, so a prompt can
|
||||
// cite `.../nested/Handler.kt` and pin it once with `glob:**/Handler.kt`.
|
||||
const coveringGlobs = annotated
|
||||
.filter((ref) => ref.target.startsWith(GLOB_PREFIX))
|
||||
.map((ref) => ref.target.slice(GLOB_PREFIX.length));
|
||||
|
||||
for (const ref of annotated) {
|
||||
const problem = await resolveTarget(ref.target, index, scopeRoot);
|
||||
if (problem) {
|
||||
problems.push({
|
||||
file: relative,
|
||||
line: ref.line,
|
||||
kind: problem.startsWith("cites a line number") ? "line-number-ref" : "broken-ref",
|
||||
problem,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// An `LLP NNNN` target belongs to a different mechanism (a design corpus, owned
|
||||
// by that repo's own checker). ecr neither resolves nor counts it: the refs it
|
||||
// owns are the ones citing the reviewed code.
|
||||
if (LLP_TARGET_RE.test(ref.target)) {
|
||||
continue;
|
||||
}
|
||||
refs.push(ref);
|
||||
const cited = splitAnchor(ref.target)[0];
|
||||
if (cited && !cited.startsWith(GLOB_PREFIX) && !URL_RE.test(cited)) {
|
||||
citedPaths.add(cited.replace(/\/$/, ""));
|
||||
}
|
||||
}
|
||||
|
||||
for (const { line, token } of findProseCitations(text)) {
|
||||
// Extension or wildcard tail ⇒ a citation on shape alone. Otherwise it only
|
||||
// counts if it names something real: `eas-build-worker/terraform` and
|
||||
// `general-central/{module,production}` are paths, `anthropic/claude-opus-5`
|
||||
// is shaped identically and is not.
|
||||
const named = isCodeCitation(token) ? null : await namedPath(token, scopeRoot);
|
||||
if (!isCodeCitation(token) && !named) {
|
||||
continue;
|
||||
}
|
||||
// Coverage must accept the path the token RESOLVED to, not just the token as
|
||||
// written: a prompt says `cert-manager` and the ref that pins it is
|
||||
// `infrastructure/cert-manager/`. Without this, the fix the message suggests
|
||||
// does not silence the citation it was suggested for.
|
||||
const forms = [...citationForms(token), ...(named ? coveringForms(named) : [])];
|
||||
if (
|
||||
ignored.has(token) ||
|
||||
forms.some((form) => covered.has(form)) ||
|
||||
coveringGlobs.some((pattern) =>
|
||||
forms.some((form) => matchesIgnore(form.replace(/^\.\.\.\//, ""), pattern)),
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const lineCitation = LINE_CITATION_RE.exec(token);
|
||||
// For an extensionless token the suggestion is the path that actually resolved,
|
||||
// which is also how a scope-relative citation learns its root-relative form.
|
||||
const suggestion = named ?? suggestedRef(token);
|
||||
problems.push({
|
||||
file: relative,
|
||||
line,
|
||||
kind: lineCitation ? "line-number-ref" : "unannotated-citation",
|
||||
problem: lineCitation
|
||||
? `\`${token}\` pins a line number; cite the file or a \`#symbol\` instead, as \`${REF_MARK} ${suggestedRef(lineCitation[1]!)}\``
|
||||
: `\`${token}\` cites code without a ref; add \`${REF_MARK} ${suggestion} — why it matters\` (or \`${IGNORE_MARK} ${token}\` if it is not a path)`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const routing = path.join(dir, ROUTING_FILENAME);
|
||||
if ((await pathKind(routing)) === "file") {
|
||||
// The roster comes from the dir that OWNS the manifest, never from `root`:
|
||||
// under `ecr ci` the setup is a trusted base-ref checkout while `root` is the PR
|
||||
// head tree, so reading `root/.expo-code-review/agents` would judge enforceAgents
|
||||
// against a different (or absent) roster than the one actually loaded.
|
||||
problems.push(...(await checkRoutingManifest(routing, index, await agentIds(dir))));
|
||||
}
|
||||
}
|
||||
|
||||
problems.sort(
|
||||
(a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.problem.localeCompare(b.problem),
|
||||
);
|
||||
return {
|
||||
ok: problems.length === 0,
|
||||
problems,
|
||||
refs,
|
||||
scannedFiles,
|
||||
citedPaths: [...citedPaths].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
/** How many examples a review-side note names before saying "and N more". */
|
||||
const NOTE_EXAMPLES = 5;
|
||||
|
||||
function andMore(items: string[]): string {
|
||||
const shown = items.slice(0, NOTE_EXAMPLES).join(", ");
|
||||
const rest = items.length - NOTE_EXAMPLES;
|
||||
return rest > 0 ? `${shown}, and ${rest} more` : shown;
|
||||
}
|
||||
|
||||
// @ref LLP 0012#run-points-command-and-review [constrained-by] — advises, never fails the review
|
||||
/**
|
||||
* The advice a review gives about its own setup: refs that no longer resolve, and cited
|
||||
* code this PR changes (where the ref still resolves but the guidance may not). Returns
|
||||
* an empty array when the setup is clean, so a healthy run stays silent.
|
||||
*/
|
||||
export async function reviewSetupRefNotes(options: {
|
||||
root: string;
|
||||
setupDirs: string[];
|
||||
changedFiles: string[];
|
||||
}): Promise<string[]> {
|
||||
let report: RefCheckReport;
|
||||
try {
|
||||
report = await checkConfigRefs({ root: options.root, setupDirs: options.setupDirs });
|
||||
} catch {
|
||||
return []; // a check that cannot run must never degrade the review
|
||||
}
|
||||
const notes: string[] = [];
|
||||
const broken = report.problems.filter((problem) => problem.kind !== "unannotated-citation");
|
||||
if (broken.length > 0) {
|
||||
notes.push(
|
||||
`The reviewer setup cites code that no longer resolves (${broken.length} ref(s)): ` +
|
||||
`${andMore(broken.map((problem) => `${problem.file}:${problem.line}`))}. ` +
|
||||
"Run `ecr ref-check` — the prompts may be reviewing against code that moved.",
|
||||
);
|
||||
}
|
||||
const touched = citedPathsTouchedBy(report, options.changedFiles);
|
||||
if (touched.length > 0) {
|
||||
notes.push(
|
||||
`This PR changes code the reviewer prompts cite (${andMore(touched)}). ` +
|
||||
"Check that the guidance quoting it is still correct.",
|
||||
);
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of this PR's changed files are cited by a ref. The review uses this to say
|
||||
* "you moved code the reviewer prompts point at" even when the ref still resolves.
|
||||
*/
|
||||
export function citedPathsTouchedBy(report: RefCheckReport, changedFiles: string[]): string[] {
|
||||
const touched = new Set<string>();
|
||||
for (const changed of changedFiles) {
|
||||
for (const cited of report.citedPaths) {
|
||||
if (changed === cited || changed.startsWith(`${cited}/`)) {
|
||||
touched.add(cited);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...touched].sort();
|
||||
}
|
||||
@@ -238,6 +238,7 @@ export function renderMarkdown(
|
||||
);
|
||||
}
|
||||
|
||||
lines.push(...setupNote(review.setupNotes));
|
||||
lines.push(...requalificationAuditNote(requalified.map((entry) => entry.finding)));
|
||||
lines.push(...feedbackAuditNote([...feedbackByFp.values()]));
|
||||
|
||||
@@ -352,6 +353,15 @@ function renderFindingLines(
|
||||
return out;
|
||||
}
|
||||
|
||||
// @ref LLP 0012#run-points-command-and-review [implements] — setup advice renders outside the findings list, so it never blocks
|
||||
/** Advice about the reviewer's own setup (stale refs, cited code this PR moves). */
|
||||
function setupNote(notes: string[] = []): string[] {
|
||||
if (notes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return ["> 🔗 **Review setup:**", ...notes.map((note) => `> - ${stripStateMarkers(note)}`), ""];
|
||||
}
|
||||
|
||||
// @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
|
||||
/**
|
||||
* The visible one-line audit note in the OPEN body, naming the addressing PRs. This
|
||||
@@ -610,6 +620,15 @@ export function renderAggregateMarkdown(
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
const setupLines = results.flatMap((result) =>
|
||||
(result.review.setupNotes ?? []).map(
|
||||
(note) => `> - [${stripStateMarkers(result.scope)}] ${stripStateMarkers(note)}`,
|
||||
),
|
||||
);
|
||||
if (setupLines.length > 0) {
|
||||
lines.push("> 🔗 **Review setup:**", ...setupLines, "");
|
||||
}
|
||||
|
||||
// Shown = the most-severe N kept findings per scope (N = limitPerScope). The
|
||||
// embedded state trims KEPT findings to the same set so a truncated comment
|
||||
// still fits GitHub's body limit (the hidden findings are noted, not silently
|
||||
|
||||
+19
-1
@@ -50,6 +50,7 @@ import { confirmStackRequalifications, patchConfirmer } from "./stack-confirm.js
|
||||
import { sortFindings } from "./render.js";
|
||||
import { appendStepSummary } from "./step-summary.js";
|
||||
import { errorMessage, sleep } from "./util.js";
|
||||
import { reviewSetupRefNotes } from "./config-refs.js";
|
||||
import { verifyFindings } from "./verify.js";
|
||||
import { applyInlineIgnores } from "./suppress.js";
|
||||
|
||||
@@ -297,6 +298,20 @@ export async function runReview(
|
||||
process.chdir(readRoot.dir);
|
||||
}
|
||||
|
||||
// Ref integrity of the setup that is about to review this PR. Resolved against the
|
||||
// tree the reviewers read (PR head when materialized), while the setup itself may
|
||||
// come from the trusted base ref — so a PR that moves cited code is reported against
|
||||
// the prompts that will actually judge it.
|
||||
// @ref LLP 0012#run-points-command-and-review [implements] — every review checks its own refs; advice only, never a gate
|
||||
const setupNotes = await reviewSetupRefNotes({
|
||||
root: readRoot?.dir ?? originalCwd,
|
||||
setupDirs: [config.configDir],
|
||||
changedFiles: kept.map((entry) => entry.path),
|
||||
});
|
||||
for (const note of setupNotes) {
|
||||
progress(` setup: ${note}`);
|
||||
}
|
||||
|
||||
const starting = [
|
||||
usesClaude ? "Claude Code engine" : null,
|
||||
usesOpencode ? "OpenCode server" : null,
|
||||
@@ -1167,7 +1182,10 @@ export async function runReview(
|
||||
summary: output.summary,
|
||||
});
|
||||
|
||||
return feedbackRecords ? { ...output, feedback: feedbackRecords } : output;
|
||||
// Engine-owned: overwrite whatever the coordinator may have emitted under this key,
|
||||
// so setup advice is always the checker's, never model text.
|
||||
const reviewed = { ...output, setupNotes };
|
||||
return feedbackRecords ? { ...reviewed, feedback: feedbackRecords } : reviewed;
|
||||
} catch (error) {
|
||||
await safeLog(logPath, {
|
||||
...baseRecord,
|
||||
|
||||
@@ -170,6 +170,16 @@ export const CoordinatorOutputSchema = z.object({
|
||||
* overrides the presentation instead).
|
||||
*/
|
||||
couldNotComplete: z.boolean().optional(),
|
||||
/**
|
||||
* Advice about the review's OWN setup: refs in `.expo-code-review/` that no longer
|
||||
* resolve, and cited code this PR changes. Engine-set (never the model, never a
|
||||
* finding) and never blocking — a stale prompt is a maintenance signal, not a defect
|
||||
* in the PR.
|
||||
*/
|
||||
// @ref LLP 0012#run-points-command-and-review [implements] — the review advises about stale refs instead of failing on them
|
||||
// Optional (like couldNotComplete) so every internal CoordinatorOutput literal stays
|
||||
// valid without restating an engine-owned field.
|
||||
setupNotes: z.array(z.string()).optional(),
|
||||
});
|
||||
export type CoordinatorOutput = z.infer<typeof CoordinatorOutputSchema>;
|
||||
|
||||
|
||||
@@ -65,6 +65,15 @@ export class TerminalReporter implements Reporter {
|
||||
out.push("");
|
||||
}
|
||||
|
||||
const setupNotes = review.setupNotes ?? [];
|
||||
if (setupNotes.length > 0) {
|
||||
out.push(this.paint(BOLD, "🔗 Review setup:"));
|
||||
for (const note of setupNotes) {
|
||||
out.push(this.paint(DIM, ` - ${note}`));
|
||||
}
|
||||
out.push("");
|
||||
}
|
||||
|
||||
if (review.findings.length === 0) {
|
||||
out.push(this.paint(DIM, "No findings."), "");
|
||||
} else {
|
||||
|
||||
@@ -39,7 +39,10 @@ must support `--non-interactive` the way sibling commands do (a non-interactive
|
||||
path with no prompts, erroring clearly when a required value is missing), and it
|
||||
must expose flags to supply every prompted value so the command stays scriptable.
|
||||
|
||||
<!-- TODO: replace the example above with this repo's most important conventions. -->
|
||||
<!-- TODO: replace the example above with this repo's most important conventions.
|
||||
Pin each file, directory, or symbol you cite with a ref on its own line —
|
||||
@ref <path/to/file.ts>#<symbol> — why this matters
|
||||
so `ecr ref-check` fails when the cited code moves. Never a line number. -->
|
||||
|
||||
## What NOT to flag
|
||||
|
||||
|
||||
@@ -19,7 +19,13 @@ issues in the changed code.
|
||||
concrete trigger.
|
||||
|
||||
<!-- TODO: customize for this repo — add project-specific correctness rules,
|
||||
e.g. framework conventions, required flag handling, API compatibility. -->
|
||||
e.g. framework conventions, required flag handling, API compatibility.
|
||||
|
||||
Cite real code, and pin every citation with a ref so `ecr ref-check` fails when
|
||||
it moves. In a comment of its own, on one line:
|
||||
@ref <path/to/file.ts>#<symbol> — why this matters
|
||||
Targets: a file, a `<dir>/`, `glob:<pattern>`, `<file>#<symbol>`, `<doc>.md#<heading>`.
|
||||
Never a line number. Not a path? `@ref-ignore <token>`. -->
|
||||
|
||||
## What NOT to flag
|
||||
|
||||
|
||||
@@ -26,7 +26,14 @@ average severity.
|
||||
- Insecure file permissions, or writing secrets to world-readable paths.
|
||||
|
||||
<!-- TODO: customize for this repo — name the sensitive surfaces specific to this
|
||||
codebase (credential stores, tokens, arbitrary-command features, etc.). -->
|
||||
codebase (credential stores, tokens, arbitrary-command features, etc.).
|
||||
|
||||
Pin every path or symbol you cite with a ref, on a line of its own:
|
||||
@ref <path/to/file.ts>#<symbol> — why this matters
|
||||
`ecr ref-check` then fails when that code moves, so this prompt never sends a
|
||||
reviewer after a file that no longer exists. Never cite a line number. -->
|
||||
|
||||
<!-- @ref glob:.github/workflows/** — the workflows this section judges -->
|
||||
|
||||
## CI / workflow supply-chain (changes under `.github/workflows/**`)
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ These rules apply to every reviewer and are concatenated onto your role prompt.
|
||||
- **Do not judge the diff in isolation.** Before reporting, read the surrounding
|
||||
source with your file/read/grep tools and trace the relevant execution path.
|
||||
If you cannot substantiate a concrete failure or exploit path, do not report it.
|
||||
<!-- Generic guidance, not a claim that either file exists. Once this repo has one,
|
||||
replace this with a real ref: @ref AGENTS.md — the conventions to judge against -->
|
||||
<!-- @ref-ignore AGENTS.md CLAUDE.md -->
|
||||
- Ground your judgment in the repo's own conventions (`AGENTS.md` / `CLAUDE.md`
|
||||
at the repo root, and any per-directory guidance) rather than generic
|
||||
best-practices.
|
||||
|
||||
Reference in new issue
Block a user