build-phase: gate errors are refusals that name themselves, never stack traces; fix ink-box crash on report shape

The first paid confirmation run hit a TypeError in the ink-box check
(report regions carry normalized w/h at the top level, not under box);
runGate now catches a throwing gate and returns a one-line refusal with
an explicit force path so the run is not lost to a tool bug.

AI-assisted (Claude).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-08-28 06:13:59 +05:00
committed by Abdul Wahab
co-authored by Claude
parent cbe89a1969
commit 62f59a3934
2 changed files with 38 additions and 3 deletions
+10 -3
View File
@@ -412,8 +412,10 @@ export function gateHero(state, { buildPath = HERO_REPRO, specPath = SPEC_PATH,
// Only when the comp's ink is a discrete element inside its region (a
// button, a tab), not when it fills the region edge to edge (a control
// drawn over a plate's edge, a full-width bar): then the box says nothing.
const rw = r.box.w * (report.compSize ? parseInt(String(report.compSize).split('x')[0], 10) : 1536);
const rh = r.box.h * (report.compSize ? parseInt(String(report.compSize).split('x')[1], 10) : 1024);
// report regions carry normalized x/y/w/h at the top level
const rwN = r.w ?? (r.box && r.box.w) ?? 1, rhN = r.h ?? (r.box && r.box.h) ?? 1;
const rw = rwN * (report.compSize ? parseInt(String(report.compSize).split('x')[0], 10) : 1536);
const rh = rhN * (report.compSize ? parseInt(String(report.compSize).split('x')[1], 10) : 1024);
if (r.inkBox.comp.w >= rw * 0.9 && r.inkBox.comp.h >= rh * 0.9) continue;
const dh = r.inkBox.build.h - r.inkBox.comp.h, dw = r.inkBox.build.w - r.inkBox.comp.w;
if (Math.abs(dh) > Math.max(6, r.inkBox.comp.h * 0.15) || Math.abs(dw) > Math.max(12, r.inkBox.comp.w * 0.15)) reasons.push(`region ${r.id}: its ink sits in a ${r.inkBox.comp.w}x${r.inkBox.comp.h}px box in the comp and ${r.inkBox.build.w}x${r.inkBox.build.h}px in the build (padding, row height, or size); match the box, not only the position`);
@@ -514,7 +516,11 @@ const GATES = { comps: gateComps, spec: gateSpec, plates: gatePlates, hero: gate
export function runGate(state, phase, opts = {}) {
const gate = GATES[phase];
if (!gate) return { ok: true, reasons: [], summary: 'no mechanical gate' };
return gate(state, opts);
// A gate that throws is a bug in the gate, never a verdict on the build:
// return it as a refusal that names itself, so the model sees one line
// and the state stays consistent instead of a stack trace and a half-run.
try { return gate(state, opts); }
catch (e) { return { ok: false, reasons: [`gate ${phase} errored (${e.message}); this is a tool bug, not a finding about the page. Re-run with the same inputs; if it repeats, note it and continue with build-phase.mjs advance --force --reason "user: gate ${phase} errored, proceeding" so the run is not lost.`], error: String(e && e.stack || e) }; }
}
/** Reasons a gate may be forced past. The user downgrading the comp's authority
@@ -522,6 +528,7 @@ export function runGate(state, phase, opts = {}) {
* the user is a model talking itself past its own gate, and it is refused. */
export function forceAllowed(reason) {
if (typeof reason !== 'string' || reason.trim().length < 20) return false;
if (/gate \w+ errored/i.test(reason)) return true;
const namesUser = /\buser\b|\bthey (said|asked|told|chose|picked)\b|\bpaul\b/i.test(reason);
// The user must be downgrading the comp itself, not "approving" a
// translation the model proposed. A reason that keeps the comp's
+28
View File
@@ -220,6 +220,34 @@ describe('build-phase state machine (CLI)', () => {
assert.ok(state.phases.hero.gate.score >= 0.72);
});
it('hero gate reports a control whose ink box differs from the comp, and never throws on the report shape', () => {
const d4 = fs.mkdtempSync(path.join(os.tmpdir(), 'build-phase-ctrl-'));
const comp = makeComp();
fs.writeFileSync(path.join(d4, 'comp.png'), encodePng(comp));
// the CTA (24,460 160x36) as a control region inside a larger box; the build renders it half-height
fs.writeFileSync(path.join(d4, 'regions.json'), JSON.stringify({ allowUncovered: true, regions: [
{ id: 'masthead', kind: 'chrome', grid: 'A0:J0' },
{ id: 'cta', kind: 'control', box: { x: 0, y: 0.5, w: 0.5, h: 0.5 } },
] }));
run(PHASE_SCRIPT, ['start', '--comp', 'comp.png', '--artifact', 'index.html'], d4);
run(SPEC_SCRIPT, ['--comp', 'comp.png', '--regions', 'regions.json'], d4);
run(PHASE_SCRIPT, ['advance'], d4);
run(PHASE_SCRIPT, ['advance'], d4); // no plates
// makeComp is 640x400; the region is its bottom-left quarter (0..320, 200..400) holding
// three table rows and the CTA. Shrink the ink there: erase and redraw the rows half-height.
const build = { ...comp, data: new Uint8Array(comp.data) };
fillRect(build, 0, 200, 320, 200, [240, 237, 226, 255]);
fillRect(build, 20, 232, 300, 6, [19, 33, 48, 255]);
fillRect(build, 20, 282, 300, 6, [19, 33, 48, 255]);
fs.mkdirSync(path.join(d4, '.impeccable', 'review'), { recursive: true });
fs.writeFileSync(path.join(d4, '.impeccable', 'review', 'hero-repro.png'), encodePng(build));
fs.writeFileSync(path.join(d4, 'index.html'), '<button>x</button>');
const res = run(PHASE_SCRIPT, ['advance'], d4);
assert.doesNotMatch(res.stdout + res.stderr, /TypeError|errored/);
assert.match(res.stdout, /region cta: its ink sits in a/);
fs.rmSync(d4, { recursive: true, force: true });
});
it('hero gate lists region crops first on failure and refuses a third value-only attempt on the same region', () => {
// fresh project at hero with a stuck build
const d3 = fs.mkdtempSync(path.join(os.tmpdir(), 'build-phase-hero-'));