refactor(core): unify composition contract (#2157)

* refactor(core): unify composition contract

* fix(parsers): parse start expressions linearly
This commit is contained in:
James Russo
2026-07-16 02:44:22 -04:00
committed by GitHub
parent 9ed255c0ef
commit 21cb722ebd
36 changed files with 1148 additions and 441 deletions
@@ -3,6 +3,33 @@ import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";
describe("composition rules", () => {
describe("canonical timing contract", () => {
it("rejects deprecated attributes even when canonical attributes are also present", async () => {
const result = await lintHyperframeHtml(`<!doctype html><html><body>
<div data-composition-id="main" data-start="0" data-duration="5">
<div class="clip" data-start="1" data-duration="2" data-end="9" data-track-index="1" data-layer="4"></div>
</div>
</body></html>`);
expect(result.findings.map(({ code }) => code)).toEqual(
expect.arrayContaining(["deprecated_data_end", "deprecated_data_layer"]),
);
});
it("uses canonical timing when deprecated attributes conflict", async () => {
const result = await lintHyperframeHtml(`<!doctype html><html><body>
<div data-composition-id="main" data-start="0" data-duration="5">
<div id="a" class="clip" data-start="0" data-duration="2" data-end="9" data-track-index="1"></div>
<div id="b" class="clip" data-start="2" data-duration="2" data-track-index="1"></div>
</div>
</body></html>`);
expect(
result.findings.find(({ code }) => code === "overlapping_clips_same_track"),
).toBeUndefined();
});
});
describe("subcomposition guidance", () => {
it("warns when any HTML composition file is over 300 lines", async () => {
const html = Array.from({ length: 301 }, (_, i) =>
+14 -11
View File
@@ -9,6 +9,7 @@ import {
WINDOW_TIMELINE_ASSIGN_PATTERN,
} from "../utils";
import { COMPOSITION_VARIABLE_TYPES } from "@hyperframes/parsers/composition";
import { COMPOSITION_ATTRIBUTES, readClipTiming } from "@hyperframes/parsers/composition-contract";
// Agent guidance thresholds: warning-only nudges for files/tracks that become hard
// to inspect and revise reliably in a single composition.
@@ -66,6 +67,10 @@ const INLINE_STYLE_DISPLAY_NONE_PATTERN = /(?:^|;)\s*display\s*:\s*none\b/i;
// below one 60fps frame (~16.67ms), so this only ever swallows float slop.
const OVERLAP_EPSILON_SECONDS = 1e-6;
function readTagTiming(rawTag: string) {
return readClipTiming({ getAttribute: (name) => readAttr(rawTag, name) });
}
function countPhysicalLines(source: string): number {
if (source.length === 0) return 0;
@@ -389,7 +394,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
if (isCompositionRootOrMount(tag.raw)) continue;
if (!readAttr(tag.raw, "data-start")) continue;
const track = readAttr(tag.raw, "data-track-index");
const track = readAttr(tag.raw, COMPOSITION_ATTRIBUTES.trackIndex);
if (!track) continue;
trackCounts.set(track, (trackCounts.get(track) ?? 0) + 1);
}
@@ -446,7 +451,8 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
for (const tag of tags) {
if (readAttr(tag.raw, "data-layer") && !readAttr(tag.raw, "data-track-index")) {
const timing = readTagTiming(tag.raw);
if (timing.diagnostics.some(({ code }) => code === "deprecated-layer")) {
const elementId = readAttr(tag.raw, "id") || undefined;
findings.push({
code: "deprecated_data_layer",
@@ -457,7 +463,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
snippet: truncateSnippet(tag.raw),
});
}
if (readAttr(tag.raw, "data-end") && !readAttr(tag.raw, "data-duration")) {
if (timing.diagnostics.some(({ code }) => code === "deprecated-end")) {
const elementId = readAttr(tag.raw, "id") || undefined;
findings.push({
code: "deprecated_data_end",
@@ -568,17 +574,14 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
const trackMap = new Map<string, ClipInfo[]>();
for (const tag of tags) {
const startStr = readAttr(tag.raw, "data-start");
const durationStr = readAttr(tag.raw, "data-duration");
const trackStr = readAttr(tag.raw, "data-track-index");
if (!startStr || !durationStr || !trackStr) continue;
const start = Number(startStr);
const duration = Number(durationStr);
const trackStr = readAttr(tag.raw, COMPOSITION_ATTRIBUTES.trackIndex);
if (!trackStr) continue;
const timing = readTagTiming(tag.raw);
const { start, duration } = timing;
const track = trackStr;
// Skip non-numeric (relative timing references like "intro-comp")
if (Number.isNaN(start) || Number.isNaN(duration)) continue;
if (start == null || duration == null) continue;
const clips = trackMap.get(track) || [];
clips.push({