@expo/code-review-cli 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +161 -13
  2. package/build/cli.js +12 -0
  3. package/build/commands/ci.js +299 -28
  4. package/build/commands/dismiss.js +6 -0
  5. package/build/commands/doctor.js +3 -0
  6. package/build/commands/feedback.js +433 -0
  7. package/build/commands/init.js +231 -15
  8. package/build/commands/ref-check.js +84 -0
  9. package/build/commands/review.js +191 -51
  10. package/build/commands/setup-auth.js +3 -0
  11. package/build/commands/verify-config.js +3 -0
  12. package/build/config/load.js +39 -0
  13. package/build/config/routing.js +7 -0
  14. package/build/config/schema.js +92 -0
  15. package/build/core/adjudicate.js +194 -0
  16. package/build/core/auth.js +5 -1
  17. package/build/core/claude-code.js +12 -1
  18. package/build/core/config-refs.js +772 -0
  19. package/build/core/context-file.js +42 -0
  20. package/build/core/coordinator.js +2 -2
  21. package/build/core/diff.js +1 -0
  22. package/build/core/exec.js +4 -0
  23. package/build/core/log.js +1 -0
  24. package/build/core/noise.js +5 -0
  25. package/build/core/opencode.js +22 -0
  26. package/build/core/prompts.js +311 -3
  27. package/build/core/render.js +268 -45
  28. package/build/core/responses.js +158 -0
  29. package/build/core/review.js +307 -15
  30. package/build/core/schema.js +223 -2
  31. package/build/core/scrub.js +4 -0
  32. package/build/core/stack-confirm.js +137 -0
  33. package/build/core/stack.js +25 -0
  34. package/build/core/step-summary.js +1 -0
  35. package/build/core/suppress.js +2 -0
  36. package/build/core/throttle.js +2 -0
  37. package/build/core/util.js +1 -0
  38. package/build/core/verify.js +5 -0
  39. package/build/reporters/github.js +465 -31
  40. package/build/reporters/terminal.js +10 -0
  41. package/build/sources/github-pr.js +272 -0
  42. package/build/sources/local-git.js +3 -0
  43. package/build/sources/source.js +35 -0
  44. package/package.json +2 -1
  45. package/templates/agents/consistency.md +6 -1
  46. package/templates/agents/correctness.md +9 -1
  47. package/templates/agents/security.md +11 -1
  48. package/templates/atlantis.yml +123 -0
  49. package/templates/command.yml +4 -0
  50. package/templates/config.jsonc +50 -1
  51. package/templates/coordinator.md +34 -9
  52. package/templates/dismiss.yml +4 -0
  53. package/templates/routing.jsonc +3 -0
  54. package/templates/scope-config.jsonc +1 -0
  55. package/templates/shared.md +99 -1
  56. package/templates/workflow.yml +5 -0
@@ -1,3 +1,4 @@
1
+ // @ref LLP 0007#init-and-dismiss — scaffolds .expo-code-review/; --scope validates before any file lands on disk
1
2
  import { cp, mkdir, readFile, writeFile } from "node:fs/promises";
2
3
  import { existsSync } from "node:fs";
3
4
  import path from "node:path";
@@ -5,6 +6,7 @@ import { fileURLToPath } from "node:url";
5
6
  import { CONFIG_DIRNAME } from "../config/load.js";
6
7
  import { ROUTING_FILENAME } from "../config/routing.js";
7
8
  import { RoutingScopeSchema } from "../config/schema.js";
9
+ import { FORBIDDEN_TOKEN_ENVS } from "../core/auth.js";
8
10
  import { repoRoot } from "../core/exec.js";
9
11
  import { errorMessage } from "../core/util.js";
10
12
  const TEMPLATES_DIR = fileURLToPath(new URL("../../templates/", import.meta.url));
@@ -21,9 +23,18 @@ Options:
21
23
  --scope <dir> Scaffold <dir>/.expo-code-review/ (no auth) + add a scope entry
22
24
  --no-workflow Skip writing the CI workflows (review, command, and dismiss
23
25
  under .github/workflows/)
26
+ --token-env <name[,name…]>
27
+ Env var(s) holding the model credential (default OPENAI_API_KEY,
28
+ e.g. CLAUDE_CODE_OAUTH_TOKEN). The scaffolded workflows forward
29
+ the matching repo secret(s) and expect this tokenEnv
24
30
  --force Overwrite existing files
31
+ --force-workflows
32
+ Overwrite only the CI workflow files, keeping your customized
33
+ config.jsonc, prompts, and agents/. Use this to re-run
34
+ --token-env on an already-scaffolded repo.
25
35
  -h, --help Show this help
26
36
  `;
37
+ const DEFAULT_TOKEN_ENV = "OPENAI_API_KEY";
27
38
  export async function initCommand(argv) {
28
39
  if (argv.includes("-h") || argv.includes("--help")) {
29
40
  process.stdout.write(USAGE);
@@ -32,6 +43,9 @@ export async function initCommand(argv) {
32
43
  try {
33
44
  const scopeDir = parseValue(argv, "--scope");
34
45
  if (scopeDir != null) {
46
+ if (parseValue(argv, "--token-env") != null) {
47
+ throw new Error("--token-env applies to the root scaffold's workflows; drop it from `--scope`");
48
+ }
35
49
  await scaffoldScope(argv, scopeDir);
36
50
  }
37
51
  else {
@@ -46,13 +60,58 @@ export async function initCommand(argv) {
46
60
  /** Scaffold .expo-code-review/ (and optionally the CI workflow + routing manifest). */
47
61
  async function scaffold(argv) {
48
62
  const force = argv.includes("--force");
63
+ // Re-running --token-env on an adopted repo needs to rewrite the workflow YAML
64
+ // without clobbering the adopter's tuned prompts and auth config, so the
65
+ // workflow writes honor a narrower force that leaves everything else skipped.
66
+ const forceWorkflows = force || argv.includes("--force-workflows");
49
67
  // The CI workflow is scaffolded by default (most repos adopting this want it);
50
68
  // `--no-workflow` opts out. `--with-workflow` is still accepted as a no-op for
51
69
  // back-compat.
52
70
  const withWorkflow = !argv.includes("--no-workflow");
53
71
  const monorepo = argv.includes("--monorepo");
72
+ // Validate before any file is written so a bad flag can't leave a half scaffold.
73
+ const tokenEnvs = parseTokenEnvs(parseValue(argv, "--token-env"));
74
+ if (!withWorkflow && parseValue(argv, "--token-env") != null) {
75
+ throw new Error("--token-env customizes the CI workflows; drop it or remove --no-workflow");
76
+ }
54
77
  const root = (await repoRoot()) ?? process.cwd();
55
78
  const configDir = path.join(root, CONFIG_DIRNAME);
79
+ // A non-default --token-env only takes effect through the review workflows,
80
+ // but existing workflow files are skipped (not rewritten) without --force —
81
+ // the flag would silently never reach CI while the next steps claim the
82
+ // workflow references the new secret. Refuse before any file is written.
83
+ // @ref LLP 0007#init-and-dismiss [implements] — validated before any file is written; no half scaffold
84
+ if (withWorkflow && tokenEnvs.join(",") !== DEFAULT_TOKEN_ENV && !forceWorkflows) {
85
+ const existing = ["expo-code-review.yml", "expo-code-review-command.yml"]
86
+ .map((name) => path.join(".github", "workflows", name))
87
+ .filter((rel) => existsSync(path.join(root, rel)));
88
+ if (existing.length > 0) {
89
+ throw new Error(`--token-env cannot take effect: ${existing.join(" and ")} already ` +
90
+ `exist${existing.length > 1 ? "" : "s"} and would be skipped, so CI would keep ` +
91
+ `forwarding the default secret. Re-run with --force-workflows to rewrite just the ` +
92
+ `workflows (keeps your config.jsonc, prompts, and agents/), with --force to rewrite ` +
93
+ `everything, or edit their ECR_EXPECTED_TOKEN_ENV fallback and forwarded secret ` +
94
+ `lines by hand.`);
95
+ }
96
+ }
97
+ // The reverse of the guard above: --force-workflows (or --force) always rewrites
98
+ // the review workflows from the pristine template, which forwards the default
99
+ // OPENAI_API_KEY unless --token-env names the credential again. An adopter who
100
+ // scaffolded with a non-default tokenEnv and later refreshes the workflow YAML
101
+ // without re-passing --token-env would silently lose the forwarded secret, and
102
+ // CI would keep passing the auth lock but run with an empty credential. Refuse
103
+ // before any file is written; naming --token-env again (or the default, to reset
104
+ // on purpose) is the explicit opt-in.
105
+ if (withWorkflow && forceWorkflows && parseValue(argv, "--token-env") == null) {
106
+ const baked = await detectWorkflowTokenEnv(root);
107
+ if (baked != null && baked !== DEFAULT_TOKEN_ENV) {
108
+ throw new Error(`refusing to revert the CI credential wiring: the existing review workflows forward ` +
109
+ `${baked}, but this run has no --token-env, so rewriting them would restore the ` +
110
+ `default ${DEFAULT_TOKEN_ENV} and CI would run with an empty credential. Re-run with ` +
111
+ `--token-env ${baked} to keep the current credential, or --token-env ${DEFAULT_TOKEN_ENV} ` +
112
+ `to reset to OpenAI on purpose.`);
113
+ }
114
+ }
56
115
  // Create only the config dir; let copyInto create prompts/ so it reports
57
116
  // accurately as created vs skipped.
58
117
  await mkdir(configDir, { recursive: true });
@@ -78,25 +137,35 @@ async function scaffold(argv) {
78
137
  await mkdir(workflowDir, { recursive: true });
79
138
  // The auto (pull_request) workflow, plus the two issue_comment command
80
139
  // workflows: `/review` (on-demand one-shot) and `/dismiss` (hide a finding).
81
- await copyInto(path.join(TEMPLATES_DIR, "workflow.yml"), path.join(workflowDir, "expo-code-review.yml"), force, created, skipped, root);
82
- await copyInto(path.join(TEMPLATES_DIR, "command.yml"), path.join(workflowDir, "expo-code-review-command.yml"), force, created, skipped, root);
83
- await copyInto(path.join(TEMPLATES_DIR, "dismiss.yml"), path.join(workflowDir, "expo-code-review-dismiss.yml"), force, created, skipped, root);
140
+ // The two review-running workflows get the tokenEnv substituted so the
141
+ // credential mapping stays STATIC in the committed YAML (the auth lock relies
142
+ // on that); dismiss.yml runs no model and needs no credential.
143
+ await copyTemplate(path.join(TEMPLATES_DIR, "workflow.yml"), path.join(workflowDir, "expo-code-review.yml"), forceWorkflows, created, skipped, root, (raw) => substituteTokenEnv(raw, tokenEnvs));
144
+ await copyTemplate(path.join(TEMPLATES_DIR, "command.yml"), path.join(workflowDir, "expo-code-review-command.yml"), forceWorkflows, created, skipped, root, (raw) => substituteTokenEnv(raw, tokenEnvs));
145
+ await copyInto(path.join(TEMPLATES_DIR, "dismiss.yml"), path.join(workflowDir, "expo-code-review-dismiss.yml"), forceWorkflows, created, skipped, root);
84
146
  }
85
147
  reportFiles(created, skipped);
86
- process.stdout.write([
87
- "",
88
- "Next steps:",
89
- ` 1. Customize ${CONFIG_DIRNAME}/agents/*.md (and shared.md, coordinator.md) for this repo.`,
90
- " 2. Configure a model provider in OpenCode (or set REVIEWER_MODEL).",
91
- " 3. Run `ecr doctor`, then `ecr review`.",
148
+ const names = tokenEnvs.map((name) => `\`${name}\``).join(" + ");
149
+ const steps = [
150
+ `Customize ${CONFIG_DIRNAME}/agents/*.md (and shared.md, coordinator.md) for this repo.`,
151
+ // --token-env only rewires the workflows; the scaffolded config.jsonc still
152
+ // declares OPENAI_API_KEY, and CI's `ecr verify-config` refuses to review
153
+ // until the config's tokenEnv set matches the workflow's expected set.
154
+ ...(tokenEnvs.join(",") !== DEFAULT_TOKEN_ENV
155
+ ? [
156
+ `Point ${CONFIG_DIRNAME}/config.jsonc at ${tokenEnvs.length > 1 ? "these credentials" : "this credential"}: set \`auth\` (and \`model\`) per the file's comments — CI's \`ecr verify-config\` refuses to review until the config names ${names}.`,
157
+ ]
158
+ : []),
159
+ "Configure a model provider in OpenCode (or set REVIEWER_MODEL).",
160
+ "Run `ecr doctor`, then `ecr review`.",
92
161
  withWorkflow
93
- ? " 4. Add the model-key secret referenced by the workflow, then add an `ai-review` label to a PR."
94
- : " 4. (No CI workflow written — re-run without `--no-workflow` to add it.)",
162
+ ? `Add the ${names} repo secret${tokenEnvs.length > 1 ? "s" : ""} referenced by the workflow, then add an \`ai-review\` label to a PR.`
163
+ : "(No CI workflow written — re-run without `--no-workflow` to add it.)",
95
164
  monorepo
96
- ? ` 5. Add per-team scopes with \`ecr init --scope <dir>\` (see ${CONFIG_DIRNAME}/${ROUTING_FILENAME}).`
97
- : ` 5. Monorepo? Run \`ecr init --monorepo\` to add a routing manifest.`,
98
- "",
99
- ].join("\n"));
165
+ ? `Add per-team scopes with \`ecr init --scope <dir>\` (see ${CONFIG_DIRNAME}/${ROUTING_FILENAME}).`
166
+ : "Monorepo? Run `ecr init --monorepo` to add a routing manifest.",
167
+ ];
168
+ process.stdout.write(["", "Next steps:", ...steps.map((step, index) => ` ${index + 1}. ${step}`), ""].join("\n"));
100
169
  }
101
170
  /**
102
171
  * Scaffold a per-team scope under <dir>: <dir>/.expo-code-review/ with a no-auth
@@ -110,6 +179,7 @@ async function scaffoldScope(argv, scopeDirRaw) {
110
179
  if (!existsSync(routingPath)) {
111
180
  throw new Error(`no ${CONFIG_DIRNAME}/${ROUTING_FILENAME} — run \`ecr init --monorepo\` first`);
112
181
  }
182
+ // @ref LLP 0007#init-and-dismiss [implements] — traversal and unparsable-routing.jsonc prevented before any file lands
113
183
  // Derive the scope entry and validate it BEFORE creating any files: the name must
114
184
  // satisfy RoutingScopeSchema's kebab-case rule (derived by sanitizing the dir,
115
185
  // apps/Foo_Bar -> apps-foo-bar), and the config path is rejected when absolute or
@@ -172,6 +242,7 @@ async function scaffoldScope(argv, scopeDirRaw) {
172
242
  "",
173
243
  ].join("\n"));
174
244
  }
245
+ // @ref LLP 0007#init-and-dismiss [implements] — comment-preserving text surgery; idempotent; comma placement rules
175
246
  /**
176
247
  * Insert a scope entry before the closing ] of the "scopes" array in raw JSONC,
177
248
  * preserving comments/formatting. Returns the new text, the original text unchanged
@@ -293,3 +364,148 @@ async function copyInto(src, dest, force, created, skipped, root) {
293
364
  await cp(src, dest, { recursive: true, force, errorOnExist: false });
294
365
  (existed && !force ? skipped : created).push(path.relative(root, dest));
295
366
  }
367
+ /** Like copyInto for a single file, but pipes the content through `transform`. */
368
+ async function copyTemplate(src, dest, force, created, skipped, root, transform) {
369
+ if (existsSync(dest) && !force) {
370
+ skipped.push(path.relative(root, dest));
371
+ return;
372
+ }
373
+ await writeFile(dest, transform(await readFile(src, "utf8")), "utf8");
374
+ created.push(path.relative(root, dest));
375
+ }
376
+ /**
377
+ * Parse + validate `--token-env`: a comma-separated list of env var names holding
378
+ * the model credential(s). Refuses names the runtime would refuse anyway
379
+ * (FORBIDDEN_TOKEN_ENVS) so a bad choice fails here, not at review time.
380
+ */
381
+ // @ref LLP 0007#init-and-dismiss [implements] — validated before any file is written; no half scaffold
382
+ export function parseTokenEnvs(value) {
383
+ if (value == null) {
384
+ return [DEFAULT_TOKEN_ENV];
385
+ }
386
+ const names = value.split(",").map((name) => name.trim());
387
+ if (names.some((name) => !/^[A-Z][A-Z0-9_]*$/.test(name))) {
388
+ throw new Error(`--token-env must be UPPER_SNAKE_CASE env var name(s), got "${value}"`);
389
+ }
390
+ for (const name of names) {
391
+ if (FORBIDDEN_TOKEN_ENVS.has(name)) {
392
+ throw new Error(`--token-env ${name} is a well-known unrelated secret; the reviewer refuses it`);
393
+ }
394
+ }
395
+ if (new Set(names).size !== names.length) {
396
+ throw new Error(`--token-env has duplicate names: "${value}"`);
397
+ }
398
+ return names;
399
+ }
400
+ // Secrets that a review workflow forwards for reasons other than the model
401
+ // credential, so a non-default name here is not a baked credential to preserve.
402
+ const NON_MODEL_FORWARDED_SECRETS = new Set(["GH_TOKEN", "GITHUB_TOKEN"]);
403
+ /**
404
+ * Detect the non-default model credential an existing review workflow bakes in,
405
+ * so a --force-workflows run can refuse to silently revert it to the default
406
+ * instead of rewriting from the pristine (OpenAI) template. Reads two
407
+ * independent signals and returns whichever is non-default:
408
+ * - the `ECR_EXPECTED_TOKEN_ENV` fallback (`vars.ECR_EXPECTED_TOKEN_ENV || '<name>'`),
409
+ * the joined list init bakes for the auth lock; and
410
+ * - the forwarded credential lines (`<NAME>: ${{ secrets.<NAME> }}`), which are
411
+ * what actually exposes the secret to the job. A hand edit — or a repo-variable
412
+ * lock (`vars.ECR_EXPECTED_TOKEN_ENV`) that leaves the YAML fallback at the
413
+ * default — can change the forwarded line alone, so reading only the fallback
414
+ * would miss the baked credential and revert it.
415
+ * Returns the credential value to re-pass via --token-env, or null when the
416
+ * workflows forward only the default (or none exist).
417
+ */
418
+ async function detectWorkflowTokenEnv(root) {
419
+ for (const name of ["expo-code-review.yml", "expo-code-review-command.yml"]) {
420
+ const file = path.join(root, ".github", "workflows", name);
421
+ if (!existsSync(file)) {
422
+ continue;
423
+ }
424
+ const raw = await readFile(file, "utf8");
425
+ const fallback = raw.match(/vars\.ECR_EXPECTED_TOKEN_ENV \|\| '([^']*)'/);
426
+ if (fallback && fallback[1] !== DEFAULT_TOKEN_ENV) {
427
+ return fallback[1];
428
+ }
429
+ const forwarded = forwardedModelCredentials(raw);
430
+ if (forwarded.length > 0) {
431
+ return forwarded.join(",");
432
+ }
433
+ }
434
+ return null;
435
+ }
436
+ /**
437
+ * Slice the `Run AI review` step (its `env:` maps the model credential) out of a
438
+ * workflow file, so credential detection reads only the block the scaffold owns
439
+ * and not secrets a hand edit forwards to unrelated steps (a deploy step's AWS
440
+ * keys, an acknowledge step's GH_TOKEN, …). Steps sit at 6-space `- ` indent, so
441
+ * the step runs until the next such line or EOF. Returns "" when the step is
442
+ * absent (drifted or removed template).
443
+ */
444
+ function runAiReviewStep(raw) {
445
+ const start = raw.indexOf("- name: Run AI review");
446
+ if (start === -1) {
447
+ return "";
448
+ }
449
+ const rest = raw.slice(start);
450
+ const nextStep = rest.search(/\n {6}- /);
451
+ return nextStep === -1 ? rest : rest.slice(0, nextStep);
452
+ }
453
+ /**
454
+ * Names of the non-default model credential a workflow's `Run AI review` step
455
+ * forwards, read from its `<NAME>: ${{ secrets.<...> }}` env lines. Skips the
456
+ * default, the known non-model secrets (GH_TOKEN), and any FORBIDDEN_TOKEN_ENVS
457
+ * name — the runtime refuses those as a model credential, so surfacing one as a
458
+ * baked credential would produce a remediation (`--token-env <name>`) that either
459
+ * cannot pass parseTokenEnvs or would wire an unrelated secret to the provider.
460
+ * Scanning is scoped to the credential block the scaffold owns so a secret a hand
461
+ * edit forwards to another step is never mistaken for the model credential.
462
+ */
463
+ function forwardedModelCredentials(raw) {
464
+ const names = [];
465
+ const re = /^\s*([A-Z][A-Z0-9_]*):\s*\$\{\{\s*secrets\.[A-Z][A-Z0-9_]*\s*\}\}/gm;
466
+ for (const match of runAiReviewStep(raw).matchAll(re)) {
467
+ const name = match[1];
468
+ if (name === DEFAULT_TOKEN_ENV ||
469
+ NON_MODEL_FORWARDED_SECRETS.has(name) ||
470
+ FORBIDDEN_TOKEN_ENVS.has(name)) {
471
+ continue;
472
+ }
473
+ if (!names.includes(name)) {
474
+ names.push(name);
475
+ }
476
+ }
477
+ return names;
478
+ }
479
+ /**
480
+ * Rewrite a scaffolded review workflow for a non-default tokenEnv: the
481
+ * ECR_EXPECTED_TOKEN_ENV fallback and the forwarded credential secret(s). GitHub
482
+ * Actions never exposes a secret the YAML doesn't map explicitly, so without this
483
+ * a Claude/Codex setup would pass the auth lock but run with an empty credential.
484
+ * Throws when a template marker is missing (template drift must fail loudly, not
485
+ * scaffold a workflow that silently keeps the OpenAI-only wiring).
486
+ */
487
+ // @ref LLP 0009#what-ecr-init-scaffolds [implements] — static credential mapping baked at init from a trusted flag, never resolved at run time
488
+ export function substituteTokenEnv(raw, tokenEnvs) {
489
+ const joined = tokenEnvs.join(",");
490
+ if (joined === DEFAULT_TOKEN_ENV) {
491
+ return raw;
492
+ }
493
+ const expectedFallback = `vars.ECR_EXPECTED_TOKEN_ENV || '${DEFAULT_TOKEN_ENV}'`;
494
+ const credentialBlock = [
495
+ " # OpenAI API key — the env var named by auth.tokenEnv in config.jsonc.",
496
+ " # Store it as a repo secret; a project-scoped key restricted to model",
497
+ " # inference (with a spend limit) is all the reviewer needs.",
498
+ " OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}",
499
+ ].join("\n");
500
+ if (!raw.includes(expectedFallback) || !raw.includes(credentialBlock)) {
501
+ throw new Error("workflow template drifted: tokenEnv markers not found (report this bug)");
502
+ }
503
+ const replacement = [
504
+ ` # Model credential${tokenEnvs.length > 1 ? "s" : ""} — the env var${tokenEnvs.length > 1 ? "s" : ""} named by auth.tokenEnv in config.jsonc.`,
505
+ " # Store each as a repo secret under the same name.",
506
+ ...tokenEnvs.map((name) => ` ${name}: \${{ secrets.${name} }}`),
507
+ ].join("\n");
508
+ return raw
509
+ .replaceAll(expectedFallback, `vars.ECR_EXPECTED_TOKEN_ENV || '${joined}'`)
510
+ .replace(credentialBlock, replacement);
511
+ }
@@ -0,0 +1,84 @@
1
+ // @ref LLP 0012#run-points-command-and-review — the gating run point: exit 1 on any broken ref
2
+ import path from "node:path";
3
+ import { checkConfigRefs } from "../core/config-refs.js";
4
+ import { repoRoot } from "../core/exec.js";
5
+ import { errorMessage } from "../core/util.js";
6
+ const USAGE = `ecr ref-check — fail when the review setup cites code that moved or vanished
7
+
8
+ Usage:
9
+ ecr ref-check [--root <dir>] [--json]
10
+
11
+ Sweeps every .expo-code-review/ directory in the repo (root and scopes) and checks
12
+ that each code citation still resolves against this checkout:
13
+ • \`@ref <target>\` annotations in prompts and configs. A target is a file, a
14
+ directory (trailing slash), \`glob:<pattern>\`, a \`file#symbol\`, or a
15
+ \`doc.md#heading\`. Never a line number — lines rot silently.
16
+ • Unannotated citations: a backticked token that looks like a repo path must be a
17
+ ref, so nothing cites code without being checked. Use \`@ref-ignore <token>\`
18
+ for a token that is not a path.
19
+ • Structural refs the config already declares: enforceAgents ids, scope config
20
+ directories, and scope path globs.
21
+ Exit 0 = every ref holds. Exit 1 = at least one is broken.
22
+
23
+ Options:
24
+ --root <dir> Repository root to check (default: the current git repo).
25
+ --json Emit {ok, problems:[{file, line, kind, problem}]} on stdout.
26
+ `;
27
+ const KIND_LABEL = {
28
+ "broken-ref": "broken ref",
29
+ "line-number-ref": "line-number ref",
30
+ "unannotated-citation": "unannotated citation",
31
+ structural: "structural ref",
32
+ };
33
+ export async function refCheckCommand(argv) {
34
+ if (argv.includes("-h") || argv.includes("--help")) {
35
+ process.stdout.write(USAGE);
36
+ return;
37
+ }
38
+ let root;
39
+ let json = false;
40
+ for (let i = 0; i < argv.length; i++) {
41
+ const arg = argv[i];
42
+ if (arg === "--json") {
43
+ json = true;
44
+ }
45
+ else if (arg === "--root") {
46
+ root = argv[++i];
47
+ // A flag-shaped value means the directory was forgotten: taking it would check
48
+ // some nonexistent path and report "all resolve" while swallowing the real flag.
49
+ if (!root || root.startsWith("-")) {
50
+ process.stderr.write("ecr ref-check: --root needs a directory\n");
51
+ process.exitCode = 2;
52
+ return;
53
+ }
54
+ }
55
+ else {
56
+ process.stderr.write(`ecr ref-check: unknown argument ${arg}\n${USAGE}`);
57
+ process.exitCode = 2;
58
+ return;
59
+ }
60
+ }
61
+ try {
62
+ const resolvedRoot = root ? path.resolve(root) : ((await repoRoot()) ?? process.cwd());
63
+ const report = await checkConfigRefs({ root: resolvedRoot });
64
+ if (json) {
65
+ process.stdout.write(`${JSON.stringify({ ok: report.ok, problems: report.problems })}\n`);
66
+ }
67
+ else if (report.ok) {
68
+ process.stdout.write(`ref-check: ${report.refs.length} ref(s) across ${report.scannedFiles.length} setup file(s) — all resolve\n`);
69
+ }
70
+ else {
71
+ for (const problem of report.problems) {
72
+ process.stderr.write(`${problem.file}:${problem.line}: ${KIND_LABEL[problem.kind]}: ${problem.problem}\n`);
73
+ }
74
+ process.stderr.write(`\nref-check: ${report.problems.length} problem(s). Update the ref or the prompt that cites it.\n`);
75
+ }
76
+ if (!report.ok) {
77
+ process.exitCode = 1;
78
+ }
79
+ }
80
+ catch (error) {
81
+ process.stderr.write(`ecr ref-check: ${errorMessage(error)}\n`);
82
+ process.exitCode = 2;
83
+ }
84
+ }