fix(cli): stop check --at false alarms and name a stale CLI version (#4417)

* fix(cli): stop check --at failing still frames as a frozen timeline

* fix(cli): warn when the running CLI is older than the project's pin

* fix(cli): keep judging spread samples when check --at is combined with other gates
This commit is contained in:
Miguel Ángel
2026-09-24 10:09:21 -04:00
committed by GitHub
parent 881481f56b
commit 7e8d2bd1f3
4 changed files with 73 additions and 13 deletions
+23
View File
@@ -1479,6 +1479,29 @@ describe("check pipeline", () => {
).toBe(true);
});
it("does not flag --at times the user picked on a still end card", async () => {
const driver = fakeDriver({
getDuration: vi.fn(async () => 53.7),
collectLayoutGeometry: vi.fn(async () => "frozen"),
});
const { report } = await runScenario(driver, { at: [51, 52.5] });
expect(report.layout.samples).toEqual([51, 52.5]);
expect(report.layout.findings.some((finding) => finding.code === "sweep_static")).toBe(false);
});
it("still judges the spread samples --at-transitions adds to an --at run", async () => {
const driver = fakeDriver({
getDuration: vi.fn(async () => 53.7),
getTransitionBoundaries: vi.fn(async () => [10, 20]),
collectLayoutGeometry: vi.fn(async () => "frozen"),
});
const { report } = await runScenario(driver, { at: [51, 52.5], atTransitions: true });
expect(report.layout.samples).toEqual([10, 15, 20, 51, 52.5]);
expect(report.layout.findings.some((finding) => finding.code === "sweep_static")).toBe(true);
});
it("does not flag intentional static content declared with data-no-timeline", async () => {
const driver = fakeDriver({
getDuration: vi.fn(async () => 6),
+9 -3
View File
@@ -110,6 +110,8 @@ function buildMotionSampleTimes(duration: number): number[] {
interface SampleGrid {
duration: number;
layoutSamples: number[];
/** `--at` times: they can all land on a still stretch, so the frozen-sweep guard never judges them. */
userPickedSamples: number[];
captionSamples: number[];
frameSamples: number[];
transitionSamples: number[];
@@ -158,6 +160,7 @@ async function buildSampleGrid(
return {
duration,
layoutSamples,
userPickedSamples: options.at?.length ? baseSamples : [],
captionSamples,
frameSamples,
transitionSamples: transitions.times,
@@ -213,7 +216,7 @@ interface GridSamples {
screenshots: CheckScreenshot[];
contrastMs: number;
/** One geometry+opacity fingerprint per layout sample (#U10 frozen-sweep guard). */
geometrySignatures: string[];
geometrySignatures: { time: number; signature: string }[];
/** Every rotatable element's geometry at each layout sample; grouped by
* selector after the run to detect rotation_pivot_drift. */
rotationSamples: RotationSample[];
@@ -417,7 +420,7 @@ async function collectGridSamples(
const layoutIssues = await driver.collectLayout(time, options.tolerance, options.layout);
collected.layoutIssues.push(...layoutIssues);
issuesAtTime.push(...layoutIssues);
collected.geometrySignatures.push(await driver.collectLayoutGeometry());
collected.geometrySignatures.push({ time, signature: await driver.collectLayoutGeometry() });
collected.rotationSamples.push(...(await driver.collectRotationSample(time)));
collected.indicatorFrames.push(await driver.collectOffPivotRotationSample(time));
}
@@ -1089,9 +1092,12 @@ export async function runAuditGrid(
);
motionIssues = [...motionIssues, ...(await driver.anchorMotionIssues(evaluated))];
}
const userPicked = new Set(grid.userPickedSamples);
const sweepFindings = detectSweepStatic(
grid.duration,
collected.geometrySignatures,
collected.geometrySignatures
.filter((sample) => !userPicked.has(sample.time))
.map((sample) => sample.signature),
motionIssues,
await driver.hasNoTimelineDeclaration(),
);
@@ -8,6 +8,12 @@ import { join } from "node:path";
// "./updateCheck.js" would only override the export binding, not the internal
// call printStalePinNotice makes to it from within the same module.
let store: Record<string, unknown> = {};
let runningVersion = "0.7.55";
vi.mock("../version.js", () => ({
get VERSION() {
return runningVersion;
},
}));
// isDevMode() is true under vitest (module path ends in .ts), which would
// suppress the notice unconditionally — mock ./env.js like updateCheck.test.ts does.
vi.mock("./env.js", () => ({ isDevMode: () => false }));
@@ -27,6 +33,7 @@ describe("printStalePinNotice", () => {
const origWrite = process.stderr.write.bind(process.stderr);
beforeEach(() => {
store = { latestVersion: "0.7.55" };
runningVersion = "0.7.55";
writes = [];
dir = mkdtempSync(join(tmpdir(), "hf-pin-"));
process.stderr.write = ((s: unknown) => {
@@ -62,6 +69,21 @@ describe("printStalePinNotice", () => {
expect(writes.join("")).toBe("");
});
it("warns on every run when an older CLI than the pin is running", () => {
store = { latestVersion: "0.8.71" };
runningVersion = "0.7.71";
writeFileSync(
join(dir, "package.json"),
JSON.stringify({ scripts: { check: "npx --yes hyperframes@0.8.66 check" } }),
);
printStalePinNotice(dir);
printStalePinNotice(dir);
const mismatch = writes.filter((w) => w.includes("This is hyperframes 0.7.71"));
expect(mismatch.length).toBe(2);
expect(mismatch[0]).toContain("npx hyperframes@0.8.66");
expect(writes.join("")).not.toContain("upgrade --project");
});
it("silent under CI", () => {
process.env.CI = "true";
writeFileSync(
+19 -10
View File
@@ -197,21 +197,17 @@ export function printUpdateNotice(): void {
const STALE_PIN_THROTTLE_MS = 24 * 60 * 60 * 1000;
/**
* Actionable, throttled notice for a project whose package.json still pins an
* OLD hyperframes version. Unlike printUpdateNotice this DOES fire on non-TTY
* (agents render with piped stderr) \u2014 but only when there's a concrete stale
* pin to act on, at most once/24h per install, and never under --json/CI/dev/
* opt-out. The whole cli.ts update block is already skipped for --json, so a
* JSON stdout stays clean regardless.
* Actionable notice when the running CLI is older than the project's pin (every
* run), or when the pin is older than the latest release (once/24h per install).
* Unlike printUpdateNotice this DOES fire on non-TTY (agents render with piped
* stderr), but never under --json/CI/dev/opt-out: the whole cli.ts update block
* is skipped for --json, so a JSON stdout stays clean regardless.
*/
export function printStalePinNotice(cwd: string = process.cwd()): void {
if (isDevMode()) return;
if (process.env["CI"] === "true" || process.env["CI"] === "1") return;
if (process.env["HYPERFRAMES_NO_UPDATE_CHECK"] === "1") return;
const latest = getUpdateMeta().latestVersion;
if (!latest || !isSafeVersion(latest)) return;
let scripts: Record<string, string> = {};
try {
const pkgPath = join(cwd, "package.json");
@@ -220,7 +216,20 @@ export function printStalePinNotice(cwd: string = process.cwd()): void {
} catch {
return;
}
const stale = readPinnedHyperframesVersions(scripts).filter((v) => {
const pins = readPinnedHyperframesVersions(scripts);
// A CLI older than the pin (e.g. a stale npx cache) misjudges every run, so this is never throttled.
const newerPins = pins.filter((v) => isNewerSemver(v, VERSION)).sort(compareVersions);
if (newerPins.length > 0) {
process.stderr.write(
`\n This is hyperframes ${VERSION}, but this project pins hyperframes@${newerPins.join(", ")}.\n` +
` Run it through the project's npm scripts, or npx hyperframes@${newerPins.at(-1)}.\n\n`,
);
return;
}
const latest = getUpdateMeta().latestVersion;
if (!latest || !isSafeVersion(latest)) return;
const stale = pins.filter((v) => {
try {
return compareVersions(latest, v) > 0;
} catch {