diff --git a/bin/gstack-eval-plan b/bin/gstack-eval-plan index 345cd2f82..718fc6594 100755 --- a/bin/gstack-eval-plan +++ b/bin/gstack-eval-plan @@ -166,6 +166,7 @@ function doPriceGate(rest: string[]): number { const planFile = flag(rest, '--plan-file'); if (planFile && !fs.existsSync(planFile)) return usage(`--plan-file ${planFile} does not exist`); const plan = resolvePlanSource(repoRoot, { planFile, base: resolveBaseBranch(repoRoot, flag(rest, '--base')) }); + if ('code' in plan) { process.stderr.write(resultLine('gstack-eval-plan price-gate', plan.code, plan.message) + '\n'); return plan.refused ? EXIT.refused : EXIT.fail; } const r = priceGate({ repoRoot, gate, plan, capUsd: num(rest, '--cap-usd'), smokeCapUsd: num(rest, '--smoke-cap-usd'), allowUnpriced: flag(rest, '--allow-unpriced') }); if (r.receipt) { const out = path.resolve(flag(rest, '--out') ?? path.join(repoRoot, '.gstack', 'tmp', 'price-gate')); diff --git a/docs/eval-calls.md b/docs/eval-calls.md index 16349a17f..69dc9c527 100644 --- a/docs/eval-calls.md +++ b/docs/eval-calls.md @@ -79,7 +79,11 @@ approved: file itself changed between them (a committed plan file sits one commit on top of the head it was computed at, so its own commit is allowed); - an input's sha256 of its bytes in the HEAD commit differs from its - `GSTACK_EVAL_INPUT` line, or the input is not in HEAD. + `GSTACK_EVAL_INPUT` line, or the input is not in HEAD; +- the base policy declares a `price_file` that is not in the HEAD commit. + +`--allow-unpriced` never overrides these: only a gate with no plan declared at +all, or a plan with zero call lines, is unpriced. ## Pricing outcomes @@ -98,10 +102,12 @@ GATE_PRICE: total=6.7 calls=200 purposes=answer,judge receipt=.gstack/tmp/price- | a model with no row in `lib/pricing.ts`: that row and the total print `unknown`, never zero | 1 | `EVAL_PRICE_MISSING` | | total above `--cap-usd` | 1 | `EVAL_GATE_OVER_CAP` | | one call's reservation above `--cap-usd`, or above `--smoke-cap-usd` for a `scope=smoke` row (`CHECK: reservation-fits-cap exceeds (…)`) | 1 | `EVAL_GATE_OVER_CAP` | -| no plan file (no `--plan-file`, no `price_file`, file not in HEAD) or a file with zero call lines | 1 | `EVAL_GATE_UNPRICED` | +| no plan declared (no `--plan-file` and no `price_file` key) or a file with zero call lines | 1 | `EVAL_GATE_UNPRICED` | | the same with `--allow-unpriced ""` | 0 | prints `GATE_UNPRICED_OVERRIDE: (owner-authorized override; not authorization to spend)` and records it | | malformed or partial file | 1 | `EVAL_DRY_RUN_FAILED` | -| stale binding | 1 | `EVAL_PLAN_STALE` | +| stale binding, or a declared `price_file` missing from HEAD (not overridable) | 1 | `EVAL_PLAN_STALE` | +| `origin/` unresolvable while reading `price_file` | 3 | `POLICY_SOURCE_UNAVAILABLE` | +| `.gstack/pregate.json` on the base is invalid | 3 | `PREGATE_CONFIG_INVALID` | | unknown flag or missing `--gate` | 2 | usage | The price is an estimate: the table prices standard text tokens and excludes diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index a3aad6dfc..6fe386fc8 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2486,8 +2486,8 @@ passes and carries the `PREGATE:` line in its handoff. **Meaning.** `gstack-eval-plan price-gate` found no call-plan file for the paid gate: no `--plan-file`, no `price_file` in the base branch's -`.gstack/pregate.json`, the declared file is missing from the HEAD commit, or -the file has no `GSTACK_EVAL_CALLS` lines. A gate whose cost is unknown cannot +`.gstack/pregate.json`, or the file has no `GSTACK_EVAL_CALLS` lines. (A +declared `price_file` missing from HEAD is `EVAL_PLAN_STALE`, not this.) A gate whose cost is unknown cannot be approved as priced. **Fix.** The consuming repo commits a call-plan file in the format of @@ -2514,7 +2514,9 @@ to slip under it. **Meaning.** The call-plan file prices something other than what is being approved: its `GSTACK_EVAL_GATE` command is not the exact `--gate` string, its `GSTACK_EVAL_HEAD` is behind HEAD with files other than the plan file changed -since, or an input's sha256 at HEAD differs from its `GSTACK_EVAL_INPUT` line. +since, an input's sha256 at HEAD differs from its `GSTACK_EVAL_INPUT` line, or +the base policy's `price_file` is not in the HEAD commit. `--allow-unpriced` +does not override it. **Fix.** Regenerate the plan file at HEAD for the exact gate command (the repo's own producer), commit it, and rerun `gstack-eval-plan price-gate`. diff --git a/lib/eval-plan.ts b/lib/eval-plan.ts index ee1e82992..3cfd12eba 100644 --- a/lib/eval-plan.ts +++ b/lib/eval-plan.ts @@ -413,20 +413,26 @@ function gitOut(repoRoot: string, args: string[]): Buffer | null { return r.status === 0 ? r.stdout : null; } -/** `--plan-file` wins; else the base policy's `price_file`, read from the HEAD commit's bytes. */ -export function resolvePlanSource(repoRoot: string, o: { planFile?: string; base: string }): PlanSource { +export interface PlanSourceRefusal { code: ResultCodeName; message: string; refused: boolean } + +/** + * `--plan-file` wins; else the base policy's `price_file`, read from the HEAD + * commit's bytes. Only "no plan declared" is unpriced (overridable); a declared + * file missing at HEAD is stale, an unresolvable base or an invalid policy refuses. + */ +export function resolvePlanSource(repoRoot: string, o: { planFile?: string; base: string }): PlanSource | PlanSourceRefusal { if (o.planFile) { const file = path.resolve(o.planFile); const rel = path.relative(repoRoot, file); return { text: fs.readFileSync(file), file, source: 'flag', repoPath: rel && !rel.startsWith('..') && !path.isAbsolute(rel) ? rel.split(path.sep).join('/') : null, why: null }; } const load = loadConfigFromBase(repoRoot, o.base); - if (isConfigUnavailable(load)) return { text: null, file: null, source: 'none', repoPath: null, why: `no --plan-file and the base policy is unavailable (${load.detail})` }; - if (load.errors.length) return { text: null, file: null, source: 'none', repoPath: null, why: `${load.label} is invalid: ${load.errors.map(e => `${e.path}: ${e.message}`).join('; ')}` }; + if (isConfigUnavailable(load)) return { code: 'POLICY_SOURCE_UNAVAILABLE', message: `no --plan-file and the base policy cannot be read (${load.detail})`, refused: true }; + if (load.errors.length) return { code: 'PREGATE_CONFIG_INVALID', message: `${load.label}: ${load.errors.map(e => `${e.path}: ${e.message}`).join('; ')}`, refused: true }; const rel = load.config.price_file; if (!rel) return { text: null, file: null, source: 'none', repoPath: null, why: `no --plan-file and ${load.label} declares no price_file` }; const text = gitOut(repoRoot, ['show', `HEAD:${rel}`]); - if (!text) return { text: null, file: rel, source: 'price_file', repoPath: rel, why: `price_file ${rel} (${load.label}) is not in the HEAD commit` }; + if (!text) return { code: 'EVAL_PLAN_STALE', message: `price_file ${rel} (declared in ${load.label}) is not in the HEAD commit`, refused: false }; return { text, file: rel, source: 'price_file', repoPath: rel, why: null }; } diff --git a/lib/result-codes.ts b/lib/result-codes.ts index b858c332c..5d8b2d693 100644 --- a/lib/result-codes.ts +++ b/lib/result-codes.ts @@ -483,7 +483,7 @@ export const RESULT_CODES = { }, EVAL_PLAN_STALE: { anchor: 'eval-plan-stale', - summary: 'the call-plan file\'s binding does not match: a different gate command, a head behind HEAD, or an input whose sha256 at HEAD differs', + summary: 'the call-plan file\'s binding does not match (a different gate command, a head behind HEAD, an input whose sha256 at HEAD differs) or the declared price_file is not in HEAD', fix: 'regenerate the call-plan file at HEAD for the exact gate command you are approving, commit it, and rerun `gstack-eval-plan price-gate`', }, } as const satisfies Record; diff --git a/test/pricing-eval-plan.test.ts b/test/pricing-eval-plan.test.ts index ecd5d1c22..a090d05e4 100644 --- a/test/pricing-eval-plan.test.ts +++ b/test/pricing-eval-plan.test.ts @@ -280,6 +280,30 @@ describe('price-gate: binding, pricing and the receipt (bin, simulated Capy host expect(r.err).toMatch(/behind HEAD.*README\.md.*\(EVAL_PLAN_STALE\)/); }); + test('a declared price_file missing at HEAD refuses EVAL_PLAN_STALE (not overridable); an unresolvable base or an invalid pregate.json exits 3', () => { + const { repo } = scratchRepo(host.env); + const git = (...args: string[]) => spawnSync('git', args, { cwd: repo, env: host.env, encoding: 'utf8' }).stdout.trim(); + const out = path.join(host.base, 'receipts'); + let r = runGate(host, repo, ['--gate', GATE, '--base', 'main', '--out', out, '--allow-unpriced', 'owner said so']); + expect(r.code).toBe(3); + expect(r.err).toContain('(POLICY_SOURCE_UNAVAILABLE)'); + fs.mkdirSync(path.join(repo, '.gstack'), { recursive: true }); + fs.writeFileSync(path.join(repo, '.gstack', 'pregate.json'), JSON.stringify({ price_file: 'evals/missing.calls' })); + git('add', '.gstack/pregate.json'); git('commit', '-q', '-m', 'policy'); + git('update-ref', 'refs/remotes/origin/main', git('rev-parse', 'HEAD')); + r = runGate(host, repo, ['--gate', GATE, '--base', 'main', '--out', out, '--allow-unpriced', 'owner said so']); + expect(r.code).toBe(1); + expect(r.err).toMatch(/evals\/missing\.calls.*\(EVAL_PLAN_STALE\)/); + expect(r.out).not.toContain('GATE_UNPRICED_OVERRIDE'); + fs.writeFileSync(path.join(repo, '.gstack', 'pregate.json'), JSON.stringify({ price_file: '../outside.calls' })); + git('add', '.gstack/pregate.json'); git('commit', '-q', '-m', 'bad policy'); + git('update-ref', 'refs/remotes/origin/main', git('rev-parse', 'HEAD')); + r = runGate(host, repo, ['--gate', GATE, '--base', 'main', '--out', out, '--allow-unpriced', 'owner said so']); + expect(r.code).toBe(3); + expect(r.err).toContain('(PREGATE_CONFIG_INVALID)'); + expect(fs.existsSync(out)).toBe(false); + }); + test('an unknown or misspelled flag exits 2 instead of pricing without it', () => { const { repo } = scratchRepo(host.env); expect(runGate(host, repo, ['--gate', GATE, '--cap', '5']).code).toBe(2); @@ -330,6 +354,7 @@ describe('price-gate: binding, pricing and the receipt (bin, simulated Capy host test('no plan file, or a file with no call lines, is EVAL_GATE_UNPRICED exit 1; --allow-unpriced exits 0 with a recorded override', () => { const { repo, head, rowsSha } = scratchRepo(host.env); + spawnSync('git', ['update-ref', 'refs/remotes/origin/main', head], { cwd: repo, env: host.env }); const out = path.join(host.base, 'receipts'); let r = runGate(host, repo, ['--gate', GATE, '--base', 'main', '--out', out]); expect(r.code).toBe(1);