@expo/code-review-cli 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +161 -13
- package/build/cli.js +12 -0
- package/build/commands/ci.js +299 -28
- package/build/commands/dismiss.js +6 -0
- package/build/commands/doctor.js +3 -0
- package/build/commands/feedback.js +433 -0
- package/build/commands/init.js +231 -15
- package/build/commands/ref-check.js +84 -0
- package/build/commands/review.js +191 -51
- package/build/commands/setup-auth.js +3 -0
- package/build/commands/verify-config.js +3 -0
- package/build/config/load.js +39 -0
- package/build/config/routing.js +7 -0
- package/build/config/schema.js +92 -0
- package/build/core/adjudicate.js +194 -0
- package/build/core/auth.js +5 -1
- package/build/core/claude-code.js +12 -1
- package/build/core/config-refs.js +772 -0
- package/build/core/context-file.js +42 -0
- package/build/core/coordinator.js +2 -2
- package/build/core/diff.js +1 -0
- package/build/core/exec.js +4 -0
- package/build/core/log.js +1 -0
- package/build/core/noise.js +5 -0
- package/build/core/opencode.js +22 -0
- package/build/core/prompts.js +311 -3
- package/build/core/render.js +268 -45
- package/build/core/responses.js +158 -0
- package/build/core/review.js +307 -15
- package/build/core/schema.js +223 -2
- package/build/core/scrub.js +4 -0
- package/build/core/stack-confirm.js +137 -0
- package/build/core/stack.js +25 -0
- package/build/core/step-summary.js +1 -0
- package/build/core/suppress.js +2 -0
- package/build/core/throttle.js +2 -0
- package/build/core/util.js +1 -0
- package/build/core/verify.js +5 -0
- package/build/reporters/github.js +465 -31
- package/build/reporters/terminal.js +10 -0
- package/build/sources/github-pr.js +272 -0
- package/build/sources/local-git.js +3 -0
- package/build/sources/source.js +35 -0
- package/package.json +2 -1
- package/templates/agents/consistency.md +6 -1
- package/templates/agents/correctness.md +9 -1
- package/templates/agents/security.md +11 -1
- package/templates/atlantis.yml +123 -0
- package/templates/command.yml +4 -0
- package/templates/config.jsonc +50 -1
- package/templates/coordinator.md +34 -9
- package/templates/dismiss.yml +4 -0
- package/templates/routing.jsonc +3 -0
- package/templates/scope-config.jsonc +1 -0
- package/templates/shared.md +99 -1
- package/templates/workflow.yml +5 -0
package/build/commands/init.js
CHANGED
|
@@ -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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
?
|
|
94
|
-
: "
|
|
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
|
-
? `
|
|
97
|
-
:
|
|
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
|
+
}
|