mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-15 23:56:29 +03:00
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:
committed by
Abdul Wahab
co-authored by
Claude
parent
cbe89a1969
commit
62f59a3934
@@ -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
|
||||
|
||||
@@ -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-'));
|
||||
|
||||
Reference in New Issue
Block a user