fix(cli): stop bare --frame-check from swallowing the next flag (#2966)

citty parses string options greedily, so a bare --frame-check consumed
the following flag as its value (--caption-zone silently dropped,
--json disabled) and failed with an error blaming --caption-zone.

Normalize rawArgs at the check command boundary (bare --frame-check
followed by a flag or at end becomes --frame-check=), attribute
frame-check spec errors to --frame-check, and detect dash-prefixed
values with corrective guidance.

Fixes #2965
This commit is contained in:
Xinyu YANG
2026-08-04 04:03:04 +02:00
committed by GitHub
parent 127eb19371
commit 70cc4f59a1
2 changed files with 194 additions and 97 deletions
+69
View File
@@ -359,6 +359,66 @@ it("parses the caption-zone grammar and enables the frame gate", async () => {
);
});
it("preserves caption-zone after bare --frame-check", async () => {
const { report } = await runScenario(fakeDriver());
const runPipeline = vi.fn(async (_project: ProjectDir, _options: CheckOptions) => report);
vi.spyOn(console, "log").mockImplementation(() => undefined);
const command = createCheckCommand({
resolveProject: () => PROJECT,
runPipeline,
withMeta: (value) => value,
});
await runCommand(command, {
rawArgs: [
"--frame-check",
"--caption-zone",
"x0=0;y0=.82;x1=1;y1=1;severity=error;seek=.25,1",
"--json",
],
});
expect(runPipeline).toHaveBeenCalledWith(
PROJECT,
expect.objectContaining({
captionZone: {
x0: 0,
y0: 0.82,
x1: 1,
y1: 1,
severity: "error",
seek: [0.25, 1],
},
frameCheck: {},
}),
);
});
it("preserves --json after bare --frame-check", async () => {
const { report } = await runScenario(fakeDriver());
const runPipeline = vi.fn(async (_project: ProjectDir, _options: CheckOptions) => report);
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
const command = createCheckCommand({
resolveProject: () => PROJECT,
runPipeline,
withMeta: (value) => value,
});
await runCommand(command, {
rawArgs: ["--snapshots", "--samples", "15", "--frame-check", "--json"],
});
expect(runPipeline).toHaveBeenCalledWith(
PROJECT,
expect.objectContaining({
samples: 15,
snapshots: true,
frameCheck: {},
}),
);
expect(log).toHaveBeenCalledWith(expect.stringContaining('"ok"'));
});
it("threads --no-proxy into the browser check options", async () => {
const { report } = await runScenario(fakeDriver());
const runPipeline = vi.fn(async (_project: ProjectDir, _options: CheckOptions) => report);
@@ -1166,6 +1226,15 @@ describe("frame-check flag grammar", () => {
expect(() => parseFrameCheck("tol=4px")).toThrow("Invalid --frame-check");
expect(() => parseFrameCheck("tol=2garbage")).toThrow("Invalid --frame-check");
});
it("attributes swallowed option values to --frame-check", async () => {
const { parseFrameCheck } = await import("./check.js");
expect(() => parseFrameCheck("--json")).toThrow(
'Invalid --frame-check: value "--json" appears to have swallowed the next option; use --frame-check= or move --frame-check to the end',
);
expect(() => parseFrameCheck("severity")).toThrow("Invalid --frame-check");
});
});
describe("layout flag grammar", () => {
+125 -97
View File
@@ -1,4 +1,5 @@
import { defineCommand } from "citty";
import { defineCommand, parseArgs } from "citty";
import type { ArgsDef } from "citty";
import type { Example } from "./_examples.js";
import { parseAt } from "./layout.js";
import { c } from "../ui/colors.js";
@@ -38,6 +39,93 @@ const DEFAULT_COMMAND_DEPENDENCIES: CheckCommandDependencies = {
withMeta,
};
const CHECK_COMMAND_ARGS = {
dir: { type: "positional", description: "Project directory", required: false },
json: { type: "boolean", description: "Output agent-readable JSON", default: false },
samples: {
type: "string",
description: "Number of midpoint samples across the duration (default: 9)",
default: "9",
},
at: {
type: "string",
description: "Comma-separated timestamps in seconds (e.g., --at 1.5,4,7.25)",
},
"at-transitions": {
type: "boolean",
description:
"Also sample at every tween start/end boundary (plus segment midpoints) to catch transient overlaps at transition seams",
default: false,
},
"max-transition-samples": {
type: "string",
description:
"Optional cap on transition-derived samples; when it truncates, the omitted count is reported (default: unlimited)",
},
"max-issues": {
type: "string",
description: "Maximum issues to print or return after static collapse (default: 80)",
default: "80",
},
"collapse-static": {
type: "boolean",
description: "Collapse repeated static issues across samples (default: true)",
default: true,
},
tolerance: {
type: "string",
description: "Allowed pixel overflow before reporting an issue (default: 2)",
default: "2",
},
timeout: {
type: "string",
description:
"Initial render-ready timeout in ms; also sets the navigation minimum (10s floor, default: 3000)",
default: "3000",
},
contrast: {
type: "boolean",
description: "Run the WCAG AA contrast pass (enabled by default)",
default: true,
},
strict: {
type: "boolean",
description: "Exit non-zero on warnings too",
default: false,
},
proxy: {
type: "boolean",
description:
"Auto-transcode browser-hostile video codecs (default: hyperframes.json media.autoProxy, which defaults on)",
default: undefined,
},
"browser-gpu": {
type: "boolean",
description:
"Use hardware browser GPU capture; pass --no-browser-gpu for deterministic SwiftShader (default: auto-detect, PRODUCER_BROWSER_GPU_MODE overrides)",
default: undefined,
},
snapshots: {
type: "boolean",
description: "Save the five contrast-pass PNGs under snapshots/",
default: false,
},
"caption-zone": {
type: "string",
description:
'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: "string",
description:
'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',
},
layout: {
type: "string",
description: 'Layout knobs: "proseCoverageFloor=0.05" (01; default 0.15).',
},
} satisfies ArgsDef;
export function createCheckCommand(
dependencies: CheckCommandDependencies = DEFAULT_COMMAND_DEPENDENCIES,
) {
@@ -47,93 +135,9 @@ export function createCheckCommand(
description:
"Run lint, runtime, layout, motion, and WCAG contrast verification in one browser session",
},
args: {
dir: { type: "positional", description: "Project directory", required: false },
json: { type: "boolean", description: "Output agent-readable JSON", default: false },
samples: {
type: "string",
description: "Number of midpoint samples across the duration (default: 9)",
default: "9",
},
at: {
type: "string",
description: "Comma-separated timestamps in seconds (e.g., --at 1.5,4,7.25)",
},
"at-transitions": {
type: "boolean",
description:
"Also sample at every tween start/end boundary (plus segment midpoints) to catch transient overlaps at transition seams",
default: false,
},
"max-transition-samples": {
type: "string",
description:
"Optional cap on transition-derived samples; when it truncates, the omitted count is reported (default: unlimited)",
},
"max-issues": {
type: "string",
description: "Maximum issues to print or return after static collapse (default: 80)",
default: "80",
},
"collapse-static": {
type: "boolean",
description: "Collapse repeated static issues across samples (default: true)",
default: true,
},
tolerance: {
type: "string",
description: "Allowed pixel overflow before reporting an issue (default: 2)",
default: "2",
},
timeout: {
type: "string",
description:
"Initial render-ready timeout in ms; also sets the navigation minimum (10s floor, default: 3000)",
default: "3000",
},
contrast: {
type: "boolean",
description: "Run the WCAG AA contrast pass (enabled by default)",
default: true,
},
strict: {
type: "boolean",
description: "Exit non-zero on warnings too",
default: false,
},
proxy: {
type: "boolean",
description:
"Auto-transcode browser-hostile video codecs (default: hyperframes.json media.autoProxy, which defaults on)",
default: undefined,
},
"browser-gpu": {
type: "boolean",
description:
"Use hardware browser GPU capture; pass --no-browser-gpu for deterministic SwiftShader (default: auto-detect, PRODUCER_BROWSER_GPU_MODE overrides)",
default: undefined,
},
snapshots: {
type: "boolean",
description: "Save the five contrast-pass PNGs under snapshots/",
default: false,
},
"caption-zone": {
type: "string",
description:
'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: "string",
description:
'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',
},
layout: {
type: "string",
description: 'Layout knobs: "proseCoverageFloor=0.05" (01; default 0.15).',
},
},
async run({ args }) {
args: CHECK_COMMAND_ARGS,
async run({ rawArgs }) {
const args = parseArgs(normalizeFrameCheckRawArgs(rawArgs), CHECK_COMMAND_ARGS);
const asJson = args.json === true;
try {
@@ -164,6 +168,14 @@ export function createCheckCommand(
});
}
function normalizeFrameCheckRawArgs(rawArgs: string[]): string[] {
return rawArgs.map((arg, index) => {
if (arg !== "--frame-check") return arg;
const next = rawArgs[index + 1];
return next === undefined || next.startsWith("-") ? "--frame-check=" : arg;
});
}
function parseCheckOptions(args: Record<string, unknown>): CheckOptions {
const maxTransitionSamples = positiveInteger(args["max-transition-samples"], 0);
return {
@@ -199,9 +211,10 @@ 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();
if (value.startsWith("-")) throw swallowedOptionError("frame-check", value);
const fields = parseFrameCheckFields(value);
const severity = captionSeverity(fields.get("severity"));
const seek = captionSeeks(fields.get("seek"));
const severity = captionSeverity(fields.get("severity"), frameCheckError);
const seek = captionSeeks(fields.get("seek"), frameCheckError);
const tol = parseFrameCheckTolerance(fields.get("tol"));
return {
...(severity ? { severity } : {}),
@@ -213,7 +226,7 @@ export function parseFrameCheck(value: unknown): FrameCheckOptions | undefined {
function parseFrameCheckFields(value: string): Map<string, string> {
const fields = new Map<string, string>();
for (const part of value.split(";")) {
const { key, entry } = parseCaptionField(part);
const { key, entry } = parseCaptionField(part, frameCheckError);
if (!FRAME_CHECK_FIELDS.has(key) || fields.has(key)) throw frameCheckError();
fields.set(key, entry);
}
@@ -233,6 +246,12 @@ function frameCheckError(): Error {
);
}
function swallowedOptionError(flag: string, value: string): Error {
return new Error(
`Invalid --${flag}: value "${value}" appears to have swallowed the next option; use --${flag}= or move --${flag} to the end`,
);
}
/** Parse `--layout "proseCoverageFloor=0.05"` (semicolon-separated key=value, like caption-zone). */
export function parseLayout(value: unknown): LayoutOptions | undefined {
if (value === undefined || value === null || value === false) return undefined;
@@ -310,9 +329,12 @@ function parseCaptionFields(value: string): Map<string, string> {
return fields;
}
function parseCaptionField(part: string): { key: string; entry: string } {
function parseCaptionField(
part: string,
errorFactory: () => Error = captionZoneError,
): { key: string; entry: string } {
const separator = part.indexOf("=");
if (separator <= 0) throw captionZoneError();
if (separator <= 0) throw errorFactory();
return {
key: part.slice(0, separator).trim(),
entry: part.slice(separator + 1).trim(),
@@ -345,17 +367,23 @@ function captionFraction(value: string | undefined): number | null {
return parsed !== null && parsed >= 0 && parsed <= 1 ? parsed : null;
}
function captionSeverity(value: string | undefined): "error" | "warning" | undefined {
function captionSeverity(
value: string | undefined,
errorFactory: () => Error = captionZoneError,
): "error" | "warning" | undefined {
if (value === undefined) return undefined;
if (value === "error" || value === "warning") return value;
throw captionZoneError();
throw errorFactory();
}
function captionSeeks(value: string | undefined): number[] | undefined {
function captionSeeks(
value: string | undefined,
errorFactory: () => Error = captionZoneError,
): number[] | undefined {
if (value === undefined) return undefined;
if (value === "") return [];
const values = value.split(",").map(captionFraction);
if (values.some((entry) => entry === null)) throw captionZoneError();
if (values.some((entry) => entry === null)) throw errorFactory();
return values.flatMap((entry) => (entry === null ? [] : entry));
}