Skip to content

Commit 3c5a355

Browse files
committed
fix(core): enforce strict runtime safety
1 parent 9e7b119 commit 3c5a355

21 files changed

Lines changed: 318 additions & 96 deletions

packages/core/package.json

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -402,8 +402,10 @@
402402
"test": "bun run check:position-edits-render && vitest run",
403403
"test:watch": "vitest",
404404
"test:coverage": "vitest run --coverage",
405-
"typecheck": "tsc --noEmit",
406-
"lint:runtime-preview-guards": "tsx scripts/lint-runtime-preview-guards.ts",
405+
"test:runtime-coverage": "vitest run --coverage src/runtime",
406+
"typecheck": "tsc --noEmit && bun run typecheck:runtime",
407+
"typecheck:runtime": "tsc --noEmit -p tsconfig.runtime.json",
408+
"lint:runtime-preview-guards": "bun scripts/lint-runtime-preview-guards.ts",
407409
"build:hyperframes-runtime": "tsx scripts/build-hyperframes-runtime-artifact.ts",
408410
"build:position-edits-render": "tsx scripts/build-position-edits-render.ts",
409411
"check:position-edits-render": "bun run build:position-edits-render && git diff --exit-code -- src/generated/position-edits-render-inline.ts",
@@ -415,9 +417,9 @@
415417
"test:hyperframe-runtime-duration-guards": "tsx scripts/test-hyperframe-runtime-duration-guards.ts",
416418
"test:hyperframe-runtime-parity": "tsx scripts/test-hyperframe-runtime-parity.ts",
417419
"test:hyperframe-runtime-security": "tsx scripts/test-hyperframe-runtime-security.ts",
418-
"test:hyperframe-linter": "tsx scripts/test-hyperframe-linter.ts",
419-
"test:hyperframe-runtime-ci": "bun run build:hyperframes-runtime && bun run test:hyperframe-runtime-contract && bun run test:hyperframe-runtime-behavior && bun run test:hyperframe-runtime-seek && bun run test:hyperframe-runtime-duration-guards && bun run test:hyperframe-runtime-parity && bun run test:hyperframe-runtime-security",
420-
"check:hyperframe-html": "tsx scripts/check-hyperframe-static.ts",
420+
"test:hyperframe-linter": "bun scripts/test-hyperframe-linter.ts",
421+
"test:hyperframe-runtime-ci": "bun run typecheck:runtime && bun run lint:runtime-preview-guards && bun run build:hyperframes-runtime && bun run test:hyperframe-runtime-contract && bun run test:hyperframe-runtime-behavior && bun run test:hyperframe-runtime-seek && bun run test:hyperframe-runtime-duration-guards && bun run test:hyperframe-runtime-parity && bun run test:hyperframe-runtime-security && bun run test:runtime-coverage && bun run test:hyperframe-linter",
422+
"check:hyperframe-html": "bun scripts/check-hyperframe-static.ts",
421423
"debug:timeline": "tsx scripts/debug-timeline.ts",
422424
"prepublishOnly": "echo skip"
423425
},

packages/core/scripts/check-hyperframe-static.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import fs from "node:fs";
22
import path from "node:path";
3-
import { lintHyperframeHtml } from "../src/lint/hyperframeLinter";
4-
import type { HyperframeLintResult } from "../src/lint/types";
3+
import { lintHyperframeHtml, type HyperframeLintResult } from "@hyperframes/lint";
54

65
function formatCounts(result: HyperframeLintResult): string {
76
const parts = [`${result.warningCount} warning${result.warningCount === 1 ? "" : "s"}`];

packages/core/scripts/lint-runtime-preview-guards.ts

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,16 @@ type GuardCheckResult = {
1313
failed: GuardSpec[];
1414
};
1515

16+
// Keep only temporary source-shape guards that do not yet have behavioral
17+
// coverage. Timeline replacement and early-play rebinding are exercised by
18+
// init.test.ts and timelineRebindPolicy.test.ts instead of regexes.
1619
const GUARD_SPECS: GuardSpec[] = [
1720
{
1821
id: "external_compositions_gate",
1922
description: "Do not bind timelines before external compositions are loaded",
2023
filePath: "src/runtime/init.ts",
2124
pattern: /if\s*\(\s*!externalCompositionsReady\s*\)\s*return\s+false;/,
2225
},
23-
{
24-
id: "usable_timeline_gate",
25-
description: "Skip rebinding when current timeline is already usable",
26-
filePath: "src/runtime/init.ts",
27-
pattern: /if\s*\(\s*currentTimeline\s*&&\s*currentTimelineUsable\s*\)\s*return\s+false;/,
28-
},
2926
{
3027
id: "child_timeline_activation",
3128
description: "Force root child timelines active before composition binding",
@@ -39,18 +36,6 @@ const GUARD_SPECS: GuardSpec[] = [
3936
pattern:
4037
/if\s*\(\s*!isUsableTimelineDuration\(rootDurationSeconds\)\s*&&\s*rootChildCandidates\.length\s*>\s*0\s*\)/,
4138
},
42-
{
43-
id: "loop_guard_rebind",
44-
description: "Enable loop guard based timeline rebinding",
45-
filePath: "src/runtime/init.ts",
46-
pattern: /if\s*\(\s*rebindTimelineFromResolution\(resolution,\s*"loop_guard"\)\s*\)/,
47-
},
48-
{
49-
id: "early_play_rebind_hold",
50-
description: "Hold rebinding during first playback seconds",
51-
filePath: "src/runtime/init.ts",
52-
pattern: /shouldHoldRebindDuringEarlyPlay/,
53-
},
5439
{
5540
id: "external_script_ordering",
5641
description: "Inject external composition scripts with deterministic ordering",
Lines changed: 46 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,36 @@
11
import assert from "node:assert/strict";
22
import { execFileSync } from "node:child_process";
3-
import fs from "node:fs";
3+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4+
import { tmpdir } from "node:os";
45
import path from "node:path";
5-
import { lintHyperframeHtml } from "../src/lint/hyperframeLinter";
6+
import { fileURLToPath } from "node:url";
7+
import { lintHyperframeHtml } from "@hyperframes/lint";
68

7-
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
9+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
10+
const VALID_COMPOSITION = `
11+
<html>
12+
<body>
13+
<div id="root" data-composition-id="comp-1" data-width="1920" data-height="1080" data-start="0">
14+
<div id="stage"></div>
15+
</div>
16+
<script src="https://cdn.gsap.com/gsap.min.js"></script>
17+
<script>
18+
window.__timelines = window.__timelines || {};
19+
const tl = gsap.timeline({ paused: true });
20+
tl.to("#stage", { opacity: 1, duration: 1 }, 0);
21+
window.__timelines["comp-1"] = tl;
22+
</script>
23+
</body>
24+
</html>`;
825

9-
function testCleanFixturePasses() {
10-
const fixturePath = path.join(ROOT, "src/tests/chat-project-9/index.html");
11-
const html = fs.readFileSync(fixturePath, "utf8");
12-
const result = lintHyperframeHtml(html, { filePath: fixturePath });
26+
async function testCleanFixturePasses() {
27+
const result = await lintHyperframeHtml(VALID_COMPOSITION, { filePath: "valid.html" });
1328

14-
assert.equal(result.ok, true, "chat-project-9 should pass without lint errors");
15-
assert.equal(result.errorCount, 0, "chat-project-9 should have zero lint errors");
29+
assert.equal(result.ok, true, "valid composition should pass without lint errors");
30+
assert.equal(result.errorCount, 0, "valid composition should have zero lint errors");
1631
}
1732

18-
function testDetectsMissingCompositionHostId() {
33+
async function testDetectsMissingCompositionHostId() {
1934
const html = `
2035
<html>
2136
<body>
@@ -31,15 +46,15 @@ function testDetectsMissingCompositionHostId() {
3146
</html>
3247
`;
3348

34-
const result = lintHyperframeHtml(html);
49+
const result = await lintHyperframeHtml(html);
3550
const codes = result.findings.map((finding) => finding.code);
3651

3752
assert.equal(result.ok, false, "missing composition ids should fail lint");
3853
assert.ok(codes.includes("root_missing_composition_id"));
3954
assert.ok(codes.includes("host_missing_composition_id"));
4055
}
4156

42-
function testDetectsOverlappingGsapTweens() {
57+
async function testDetectsOverlappingGsapTweens() {
4358
const html = `
4459
<html>
4560
<body>
@@ -57,7 +72,7 @@ function testDetectsOverlappingGsapTweens() {
5772
</html>
5873
`;
5974

60-
const result = lintHyperframeHtml(html);
75+
const result = await lintHyperframeHtml(html);
6176
const overlapFinding = result.findings.find(
6277
(finding) => finding.code === "overlapping_gsap_tweens",
6378
);
@@ -67,29 +82,30 @@ function testDetectsOverlappingGsapTweens() {
6782
}
6883

6984
function testCliJsonOutput() {
70-
const fixturePath = path.join(ROOT, "src/tests/chat-project-9/index.html");
71-
const tsxBin = path.join(ROOT, "node_modules/.bin/tsx");
72-
const stdout = execFileSync(
73-
tsxBin,
74-
["scripts/check-hyperframe-static.ts", "--json", fixturePath],
75-
{
85+
const tempDir = mkdtempSync(path.join(tmpdir(), "hf-core-lint-script-"));
86+
try {
87+
const fixturePath = path.join(tempDir, "index.html");
88+
writeFileSync(fixturePath, VALID_COMPOSITION, "utf8");
89+
const stdout = execFileSync("bun", ["run", "check:hyperframe-html", "--json", fixturePath], {
7690
cwd: ROOT,
7791
encoding: "utf8",
78-
},
79-
);
80-
const payload = JSON.parse(stdout);
92+
});
93+
const payload = JSON.parse(stdout);
8194

82-
assert.equal(payload.ok, true);
83-
assert.equal(typeof payload.errorCount, "number");
84-
assert.ok(Array.isArray(payload.findings));
95+
assert.equal(payload.ok, true);
96+
assert.equal(typeof payload.errorCount, "number");
97+
assert.ok(Array.isArray(payload.findings));
98+
} finally {
99+
rmSync(tempDir, { recursive: true, force: true });
100+
}
85101
}
86102

87-
function main() {
88-
testCleanFixturePasses();
89-
testDetectsMissingCompositionHostId();
90-
testDetectsOverlappingGsapTweens();
103+
async function main() {
104+
await testCleanFixturePasses();
105+
await testDetectsMissingCompositionHostId();
106+
await testDetectsOverlappingGsapTweens();
91107
testCliJsonOutput();
92108
console.log("hyperframe linter tests passed");
93109
}
94110

95-
main();
111+
await main();

packages/core/src/runtime/adapters/css.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export function createCssAdapter(params?: {
3737
animation: Animation,
3838
startSeconds: number,
3939
): { endSeconds?: number; unbounded?: true } => {
40-
let timing: { endTime?: number | string } | null = null;
40+
let timing: ComputedEffectTiming | null = null;
4141
try {
4242
timing = animation.effect?.getComputedTiming?.() ?? null;
4343
} catch (err) {

packages/core/src/runtime/adapters/waapi.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ export function createWaapiAdapter(): RuntimeDeterministicAdapter {
100100
value: original,
101101
configurable: true,
102102
});
103-
const wrappedAnimate = function (...args: Parameters<Element["animate"]>) {
103+
const wrappedAnimate = function (this: Element, ...args: Parameters<Element["animate"]>) {
104104
const animation = original.apply(this, args);
105105
trackAnimation(animation, lastSeekTimeMs);
106106
return animation;
@@ -126,7 +126,7 @@ export function createWaapiAdapter(): RuntimeDeterministicAdapter {
126126
const inferAnimationEndSeconds = (
127127
animation: Animation,
128128
): { endSeconds?: number; unbounded?: true } => {
129-
let timing: { endTime?: number | string } | null = null;
129+
let timing: ComputedEffectTiming | null = null;
130130
try {
131131
timing = animation.effect?.getComputedTiming?.() ?? null;
132132
} catch (err) {

packages/core/src/runtime/applyVariableBindings.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ function isSafeMediaUrl(url: string): boolean {
6060
const normalized = url.replace(/[\u0000-\u0020]/g, "");
6161
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(normalized);
6262
if (!scheme) return true;
63-
const proto = scheme[1].toLowerCase();
63+
const proto = scheme[1]?.toLowerCase();
64+
if (!proto) return false;
6465
if (proto === "https" || proto === "http" || proto === "blob") return true;
6566
if (proto === "data") return /^data:image\//i.test(normalized);
6667
return false;

packages/core/src/runtime/captionOverrides.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ export function applyCaptionOverrides(): void {
135135

136136
// Use the first tween's color as the dim baseline — if no tweens,
137137
// fall back to computed style.
138-
const dimBaseline = colorTweens.length > 0 ? String(colorTweens[0].vars.color) : "";
138+
const dimBaseline = colorTweens[0] ? String(colorTweens[0].vars.color) : "";
139139

140140
for (const tw of colorTweens) {
141141
const tweenColor = String(tw.vars.color);

packages/core/src/runtime/colorGrading.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -484,7 +484,7 @@ function createProgram(
484484
return program;
485485
}
486486

487-
function createTexture(gl: WebGLRenderingContext, filter = gl.LINEAR): WebGLTexture | null {
487+
function createTexture(gl: WebGLRenderingContext, filter: number = gl.LINEAR): WebGLTexture | null {
488488
const texture = gl.createTexture();
489489
if (!texture) return null;
490490
gl.bindTexture(gl.TEXTURE_2D, texture);

packages/core/src/runtime/compositionLoader.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -366,14 +366,15 @@ async function mountCompositionContent(params: {
366366
details: Record<string, string | number | boolean | null | string[]>;
367367
}) => void;
368368
}): Promise<void> {
369-
let innerRoot: Element | null = null;
369+
let innerRoot: HTMLElement | null = null;
370370
if (params.authoredCompositionId) {
371371
const candidateRoots = Array.from(
372-
params.sourceNode.querySelectorAll<Element>("[data-composition-id]"),
372+
params.sourceNode.querySelectorAll<HTMLElement>("[data-composition-id]"),
373373
);
374374
innerRoot =
375375
candidateRoots.find(
376376
(candidate) =>
377+
candidate instanceof HTMLElement &&
377378
candidate.getAttribute("data-composition-id") === params.authoredCompositionId,
378379
) ?? null;
379380
}

0 commit comments

Comments
 (0)