From 659cb6a23605546e93e95977315f473c86d0f8fe Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 10 Jul 2026 18:04:09 -0400 Subject: [PATCH] feat(cli): --frame-check accepts a severity/seek/tol spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pipeline already carried FrameCheckOptions; only the flag was boolean, which meant a pipeline caller tuning severity or seek points would have them silently dropped — the two sides only agreed because today's caller happens to match the defaults. Bare --frame-check keeps the defaults; the value form mirrors --caption-zone's grammar, freezing the contract before a release pins it. --- packages/cli/src/commands/check.test.ts | 16 +++++++ packages/cli/src/commands/check.ts | 52 +++++++++++++++++++-- packages/cli/src/utils/checkBrowser.test.ts | 30 ++++++------ 3 files changed, 76 insertions(+), 22 deletions(-) diff --git a/packages/cli/src/commands/check.test.ts b/packages/cli/src/commands/check.test.ts index 3048a1000..06af01eb1 100644 --- a/packages/cli/src/commands/check.test.ts +++ b/packages/cli/src/commands/check.test.ts @@ -1112,6 +1112,22 @@ describe("check pipeline", () => { }); }); +describe("frame-check flag grammar", () => { + it("keeps bare --frame-check on defaults and parses the value form", async () => { + const { parseFrameCheck } = await import("./check.js"); + expect(parseFrameCheck(undefined)).toBeUndefined(); + expect(parseFrameCheck(true)).toEqual({}); + expect(parseFrameCheck("")).toEqual({}); + expect(parseFrameCheck("severity=error;seek=.25,.75;tol=4")).toEqual({ + severity: "error", + seek: [0.25, 0.75], + tol: 4, + }); + expect(() => parseFrameCheck("bogus=1")).toThrow("Invalid --frame-check"); + expect(() => parseFrameCheck("tol=-2")).toThrow("Invalid --frame-check"); + }); +}); + describe("contrast persistence", () => { it("demotes a single-sample contrast failure to warning but gates held failures", async () => { const driver = fakeDriver({ diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts index 96f2f32c0..a0adf3767 100644 --- a/packages/cli/src/commands/check.ts +++ b/packages/cli/src/commands/check.ts @@ -15,7 +15,7 @@ import { type CheckReport, type CheckSection, } from "../utils/checkPipeline.js"; -import type { CaptionZoneOptions } from "../utils/checkTypes.js"; +import type { CaptionZoneOptions, FrameCheckOptions } from "../utils/checkTypes.js"; export const examples: Example[] = [ ["Run the full verification gate", "hyperframes check"], @@ -109,10 +109,9 @@ export function createCheckCommand( 'Caption band "x0=0;y0=.82;x1=1;y1=1[;severity=warning|error][;seek=.5,1]" (fractions 0-1; defaults: warning, seek=1)', }, "frame-check": { - type: "boolean", + type: "string", description: - "Use as --frame-check (boolean/no value; tol=2px, severity=warning, seek=.5; breach floor=max(120px, 6% of shorter canvas edge))", - default: false, + 'Bare --frame-check uses defaults (tol=2px, severity=warning, seek=.5; breach floor=max(120px, 6% of shorter canvas edge)); or pass "severity=error;seek=.25,.75;tol=4" to tune', }, }, async run({ args }) { @@ -161,12 +160,55 @@ function parseCheckOptions(args: Record): CheckOptions { strict: args.strict === true, snapshots: args.snapshots === true, captionZone: parseCaptionZone(args["caption-zone"]), - frameCheck: args["frame-check"] === true ? {} : undefined, + frameCheck: parseFrameCheck(args["frame-check"]), }; } const CAPTION_ZONE_FIELDS = new Set(["x0", "y0", "x1", "y1", "severity", "seek"]); +const FRAME_CHECK_FIELDS = new Set(["severity", "seek", "tol"]); + +// Mirrors --caption-zone's spec grammar so the EF bridge's severity/seek/tol +// options survive the migration instead of being silently dropped by a +// boolean flag (bare --frame-check keeps today's defaults). +export function parseFrameCheck(value: unknown): FrameCheckOptions | undefined { + if (value === undefined || value === null || value === false) return undefined; + if (value === true || value === "") return {}; + if (typeof value !== "string") throw frameCheckError(); + const fields = parseFrameCheckFields(value); + const severity = captionSeverity(fields.get("severity")); + const seek = captionSeeks(fields.get("seek")); + const tol = parseFrameCheckTolerance(fields.get("tol")); + return { + ...(severity ? { severity } : {}), + ...(seek ? { seek } : {}), + ...(tol !== undefined ? { tol } : {}), + }; +} + +function parseFrameCheckFields(value: string): Map { + const fields = new Map(); + for (const part of value.split(";")) { + const { key, entry } = parseCaptionField(part); + if (!FRAME_CHECK_FIELDS.has(key) || fields.has(key)) throw frameCheckError(); + fields.set(key, entry); + } + return fields; +} + +function parseFrameCheckTolerance(raw: string | undefined): number | undefined { + if (raw === undefined) return undefined; + const tol = Number.parseFloat(raw); + if (!Number.isFinite(tol) || tol < 0) throw frameCheckError(); + return tol; +} + +function frameCheckError(): Error { + return new Error( + 'Invalid --frame-check: use bare --frame-check or "severity=warning|error;seek=.25,.75;tol=4" (all fields optional)', + ); +} + function parseCaptionZone(value: unknown): CaptionZoneOptions | undefined { if (value === undefined || value === null) return undefined; const fields = parseCaptionFields(captionZoneString(value)); diff --git a/packages/cli/src/utils/checkBrowser.test.ts b/packages/cli/src/utils/checkBrowser.test.ts index 1e1933166..3a2a3f0d5 100644 --- a/packages/cli/src/utils/checkBrowser.test.ts +++ b/packages/cli/src/utils/checkBrowser.test.ts @@ -70,21 +70,25 @@ function installSessionMock(page: ReturnType): void { ); } +function mountCanvasFixture(inner = ""): void { + document.body.innerHTML = ` +
${inner}
+ `; + Object.defineProperty(window, "innerWidth", { configurable: true, value: 640 }); + Object.defineProperty(window, "innerHeight", { configurable: true, value: 360 }); +} + it("carries raw browser geometry through the page driver and pipeline", async () => { vi.spyOn(Date, "now") .mockReturnValueOnce(100) .mockReturnValueOnce(160) .mockReturnValueOnce(200) .mockReturnValueOnce(240); - document.body.innerHTML = ` -
+ mountCanvasFixture(`
-
- `; - Object.defineProperty(window, "innerWidth", { configurable: true, value: 640 }); - Object.defineProperty(window, "innerHeight", { configurable: true, value: 360 }); + `); installRects(); const page = fakePage(); installSessionMock(page); @@ -120,13 +124,9 @@ it("round-trips the browser script's raw contrast candidates back into finish", // candidate (width/height) makes every sample rect NaN and the audit // silently reports zero checked elements as green. vi.spyOn(Date, "now").mockReturnValue(100); - document.body.innerHTML = ` -
+ mountCanvasFixture(`
Readable copy
-
- `; - Object.defineProperty(window, "innerWidth", { configurable: true, value: 640 }); - Object.defineProperty(window, "innerHeight", { configurable: true, value: 360 }); + `); const root = document.querySelector("[data-composition-id]"); const headline = document.querySelector("#headline"); if (!root || !headline) throw new Error("Contrast fixture failed to mount"); @@ -215,11 +215,7 @@ it("round-trips the browser script's raw contrast candidates back into finish", it("carries validate's clip-duration audit into the runtime findings", async () => { vi.spyOn(Date, "now").mockReturnValue(100); - document.body.innerHTML = ` -
- `; - Object.defineProperty(window, "innerWidth", { configurable: true, value: 640 }); - Object.defineProperty(window, "innerHeight", { configurable: true, value: 360 }); + mountCanvasFixture(); const validateModule = await import("../commands/validate.js"); vi.mocked(validateModule.auditClipDurations).mockResolvedValue([ {