Files
hyperframes/packages/cli/src/commands/check.ts
T
Xinyu YANG 70cc4f59a1 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
2026-08-04 04:03:04 +02:00

508 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
import { setCommandExitCode } from "../utils/commandResult.js";
import { formatLayoutIssue } from "../utils/layoutAudit.js";
import { resolveProject, type ProjectDir } from "../utils/project.js";
import { withMeta } from "../utils/updateCheck.js";
import {
DEFAULT_CHECK_OPTIONS,
checkExitCode,
runCheckPipeline,
type CheckFinding,
type CheckOptions,
type CheckReport,
type CheckSection,
} from "../utils/checkPipeline.js";
import type { CaptionZoneOptions, FrameCheckOptions, LayoutOptions } from "../utils/checkTypes.js";
import { resolveLocalBrowserGpuMode } from "../browser/gpuPolicy.js";
export const examples: Example[] = [
["Run the full verification gate", "hyperframes check"],
["Output one agent-readable envelope", "hyperframes check --json"],
["Persist the five audited contrast frames", "hyperframes check --snapshots"],
["Also fail on warnings", "hyperframes check --strict"],
];
export interface CheckCommandDependencies {
resolveProject(dir: string | undefined): ProjectDir;
runPipeline(project: ProjectDir, options: CheckOptions): Promise<CheckReport>;
withMeta(value: object): object;
}
const DEFAULT_COMMAND_DEPENDENCIES: CheckCommandDependencies = {
resolveProject,
runPipeline: runCheckPipeline,
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,
) {
return defineCommand({
meta: {
name: "check",
description:
"Run lint, runtime, layout, motion, and WCAG contrast verification in one browser session",
},
args: CHECK_COMMAND_ARGS,
async run({ rawArgs }) {
const args = parseArgs(normalizeFrameCheckRawArgs(rawArgs), CHECK_COMMAND_ARGS);
const asJson = args.json === true;
try {
const project = dependencies.resolveProject(args.dir);
const options = parseCheckOptions(args);
if (!asJson) {
console.log(`${c.accent("◆")} Checking ${c.accent(project.name)}`);
}
const report = await dependencies.runPipeline(project, options);
if (asJson) {
console.log(JSON.stringify(dependencies.withMeta(report), null, 2));
} else {
printHumanReport(report);
}
setCommandExitCode(checkExitCode(report));
} catch (error) {
const message = normalizeErrorMessage(error);
if (asJson) {
console.log(
JSON.stringify(dependencies.withMeta({ ok: false, error: message }), null, 2),
);
} else {
console.error(`${c.error("✗")} Check failed: ${message}`);
}
setCommandExitCode(1);
}
},
});
}
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 {
samples: positiveInteger(args.samples, DEFAULT_CHECK_OPTIONS.samples),
at: parseAt(args.at),
atTransitions: args["at-transitions"] === true,
maxTransitionSamples: maxTransitionSamples > 0 ? maxTransitionSamples : undefined,
maxIssues: positiveInteger(args["max-issues"], DEFAULT_CHECK_OPTIONS.maxIssues),
collapseStatic: args["collapse-static"] !== false,
tolerance: nonNegativeNumber(args.tolerance, DEFAULT_CHECK_OPTIONS.tolerance),
timeout: Math.max(500, positiveInteger(args.timeout, DEFAULT_CHECK_OPTIONS.timeout)),
contrast: args.contrast !== false,
strict: args.strict === true,
snapshots: args.snapshots === true,
captionZone: parseCaptionZone(args["caption-zone"]),
frameCheck: parseFrameCheck(args["frame-check"]),
layout: parseLayout(args.layout),
autoProxy: args.proxy as boolean | undefined,
browserGpuMode: resolveLocalBrowserGpuMode(args["browser-gpu"] as boolean | undefined),
};
}
const CAPTION_ZONE_FIELDS = new Set(["x0", "y0", "x1", "y1", "severity", "seek"]);
const FRAME_CHECK_FIELDS = new Set(["severity", "seek", "tol"]);
const LAYOUT_FIELDS = new Set(["proseCoverageFloor"]);
// 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();
if (value.startsWith("-")) throw swallowedOptionError("frame-check", value);
const fields = parseFrameCheckFields(value);
const severity = captionSeverity(fields.get("severity"), frameCheckError);
const seek = captionSeeks(fields.get("seek"), frameCheckError);
const tol = parseFrameCheckTolerance(fields.get("tol"));
return {
...(severity ? { severity } : {}),
...(seek ? { seek } : {}),
...(tol !== undefined ? { tol } : {}),
};
}
function parseFrameCheckFields(value: string): Map<string, string> {
const fields = new Map<string, string>();
for (const part of value.split(";")) {
const { key, entry } = parseCaptionField(part, frameCheckError);
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 = parseNumberStrict(raw);
if (tol === null || 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 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;
if (value === true || value === "") throw layoutError();
if (typeof value !== "string") throw layoutError();
const fields = parseLayoutFields(value);
const proseCoverageFloor = parseProseCoverageFloor(fields.get("proseCoverageFloor"));
if (proseCoverageFloor === undefined) throw layoutError();
return { proseCoverageFloor };
}
function parseLayoutFields(value: string): Map<string, string> {
const fields = new Map<string, string>();
for (const part of value.split(";")) {
const trimmed = part.trim();
if (!trimmed) continue;
const separator = trimmed.indexOf("=");
if (separator <= 0) throw layoutError();
const key = trimmed.slice(0, separator).trim();
const entry = trimmed.slice(separator + 1).trim();
if (!LAYOUT_FIELDS.has(key) || fields.has(key)) throw layoutError();
fields.set(key, entry);
}
return fields;
}
function parseProseCoverageFloor(raw: string | undefined): number | undefined {
if (raw === undefined) return undefined;
const floor = parseNumberStrict(raw);
if (floor === null || floor < 0 || floor > 1) throw layoutError();
return floor;
}
function layoutError(): Error {
return new Error(
'Invalid --layout: use "proseCoverageFloor=0.05" with a fraction from 0 to 1 (inclusive)',
);
}
/** Reject trailing garbage that Number.parseFloat would silently accept (`4px`, `0.05abc`). */
function parseNumberStrict(raw: string): number | null {
if (raw === "") return null;
const value = Number(raw);
return Number.isFinite(value) ? value : null;
}
function parseCaptionZone(value: unknown): CaptionZoneOptions | undefined {
if (value === undefined || value === null) return undefined;
const fields = parseCaptionFields(captionZoneString(value));
const { x0, y0, x1, y1 } = parseCaptionBounds(fields);
const severity = captionSeverity(fields.get("severity"));
const seek = captionSeeks(fields.get("seek"));
return {
x0,
y0,
x1,
y1,
...(severity ? { severity } : {}),
...(seek ? { seek } : {}),
};
}
function captionZoneString(value: unknown): string {
if (typeof value !== "string" || value.trim() === "") throw captionZoneError();
return value;
}
function parseCaptionFields(value: string): Map<string, string> {
const fields = new Map<string, string>();
for (const part of value.split(";")) {
const { key, entry } = parseCaptionField(part);
if (!CAPTION_ZONE_FIELDS.has(key) || fields.has(key)) throw captionZoneError();
fields.set(key, entry);
}
return fields;
}
function parseCaptionField(
part: string,
errorFactory: () => Error = captionZoneError,
): { key: string; entry: string } {
const separator = part.indexOf("=");
if (separator <= 0) throw errorFactory();
return {
key: part.slice(0, separator).trim(),
entry: part.slice(separator + 1).trim(),
};
}
function parseCaptionBounds(fields: Map<string, string>): {
x0: number;
y0: number;
x1: number;
y1: number;
} {
const x0 = requiredCaptionFraction(fields, "x0");
const y0 = requiredCaptionFraction(fields, "y0");
const x1 = requiredCaptionFraction(fields, "x1");
const y1 = requiredCaptionFraction(fields, "y1");
if (x0 > x1 || y0 > y1) throw captionZoneError();
return { x0, y0, x1, y1 };
}
function requiredCaptionFraction(fields: Map<string, string>, key: string): number {
const value = captionFraction(fields.get(key));
if (value === null) throw captionZoneError();
return value;
}
function captionFraction(value: string | undefined): number | null {
if (value === undefined || value === "") return null;
const parsed = parseNumberStrict(value);
return parsed !== null && parsed >= 0 && parsed <= 1 ? parsed : null;
}
function captionSeverity(
value: string | undefined,
errorFactory: () => Error = captionZoneError,
): "error" | "warning" | undefined {
if (value === undefined) return undefined;
if (value === "error" || value === "warning") return value;
throw errorFactory();
}
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 errorFactory();
return values.flatMap((entry) => (entry === null ? [] : entry));
}
function captionZoneError(): Error {
return new Error(
'Invalid --caption-zone; use "x0=0;y0=.82;x1=1;y1=1[;severity=warning|error][;seek=.5,1]" with fractions from 0 to 1.',
);
}
function positiveInteger(value: unknown, fallback: number): number {
const parsed = parseInt(String(value ?? ""), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function nonNegativeNumber(value: unknown, fallback: number): number {
const parsed = parseFloat(String(value ?? ""));
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
}
function printHumanReport(report: CheckReport): void {
printSection("Lint", report.lint);
printSection("Runtime", report.runtime);
printLayoutSection("Layout", report.layout);
printSection("Motion", report.motion);
printContrastSection(report);
printSnapshotSection(report);
console.log();
const label = report.ok ? c.success("Check passed") : c.error("Check failed");
console.log(`${report.ok ? c.success("◇") : c.error("◇")} ${label}`);
}
function printSection(title: string, section: CheckSection): void {
console.log();
console.log(c.bold(title));
if (section.findings.length === 0) {
console.log(` ${c.success("◇")} 0 errors, 0 warnings`);
return;
}
for (const finding of section.findings) printFinding(finding);
printCounts(section);
}
function printLayoutSection(title: string, section: CheckReport["layout"]): void {
console.log();
console.log(c.bold(title));
if (section.findings.length === 0) {
console.log(` ${c.success("◇")} 0 issues across ${section.samples.length} sample(s)`);
} else {
for (const finding of section.findings) {
const formatted = formatLayoutIssue(finding).replace(/\n/g, "\n ");
console.log(` ${findingIcon(finding)} ${formatted}`);
}
printCounts(section);
}
if (section.transitionSamplesDropped > 0) {
console.log(
` ${c.warn("⚠")} ${section.transitionSamplesDropped} transition sample(s) omitted`,
);
}
}
function printContrastSection(report: CheckReport): void {
const section = report.contrast;
console.log();
console.log(c.bold("Contrast"));
if (!section.enabled) {
console.log(` ${c.dim("◇")} skipped`);
return;
}
if (section.findings.length === 0) {
console.log(
` ${c.success("◇")} ${section.passed}/${section.checked} text checks pass WCAG AA`,
);
return;
}
for (const finding of section.findings) {
console.log(
` ${c.error("✗")} ${finding.selector} ${finding.ratio}:1 (need ${finding.requiredRatio}:1, t=${finding.time}s)`,
);
console.log(` ${c.dim(`Try ${finding.suggestedColor}; source ${finding.sourceFile}`)}`);
}
printCounts(section);
}
function printSnapshotSection(report: CheckReport): void {
console.log();
console.log(c.bold("Snapshots"));
if (!report.snapshots.enabled) {
console.log(` ${c.dim("◇")} disabled`);
} else {
console.log(` ${c.success("◇")} ${report.snapshots.files.length} PNG(s) saved`);
for (const file of report.snapshots.files) console.log(` ${c.dim(file)}`);
if (report.snapshots.findingFiles.length > 0) {
console.log(
` ${c.success("◇")} ${report.snapshots.findingFiles.length} finding crop(s) saved`,
);
for (const file of report.snapshots.findingFiles) console.log(` ${c.dim(file)}`);
}
}
}
function printFinding(finding: CheckFinding): void {
const where = `${finding.sourceFile} ${finding.selector} t=${finding.time}s`;
console.log(` ${findingIcon(finding)} ${finding.code}: ${finding.message}`);
console.log(` ${c.dim(where)}`);
if (finding.fixHint) console.log(` ${c.dim(`Fix: ${finding.fixHint}`)}`);
}
function findingIcon(finding: CheckFinding): string {
if (finding.severity === "error") return c.error("✗");
if (finding.severity === "warning") return c.warn("⚠");
return c.dim("");
}
function printCounts(section: CheckSection): void {
console.log(
` ${c.dim(`${section.errorCount} error(s), ${section.warningCount} warning(s), ${section.infoCount} info(s)`)}`,
);
}
export default createCheckCommand();