Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions packages/core/src/lint/rules/composition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,148 @@ import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";

describe("composition rules", () => {
describe("subcomposition guidance", () => {
it("warns when any HTML composition file is over 300 lines", () => {
const html = Array.from({ length: 301 }, (_, i) =>
i === 0 ? "<html><body>" : `<!-- filler ${i} -->`,
).join("\n");

const result = lintHyperframeHtml(html, { filePath: "/project/compositions/scene.html" });
const finding = result.findings.find((f) => f.code === "composition_file_too_large");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});

it("does not warn when an HTML composition file is exactly 300 lines", () => {
const html = Array.from({ length: 300 }, (_, i) =>
i === 0 ? "<html><body>" : `<!-- filler ${i} -->`,
).join("\n");

const result = lintHyperframeHtml(html, { filePath: "/project/index.html" });
const finding = result.findings.find((f) => f.code === "composition_file_too_large");
expect(finding).toBeUndefined();
});

it("does not count a final trailing newline as an extra physical line", () => {
const html =
Array.from({ length: 300 }, (_, i) =>
i === 0 ? "<html><body>" : `<!-- filler ${i} -->`,
).join("\n") + "\n";

const result = lintHyperframeHtml(html, { filePath: "/project/index.html" });
const finding = result.findings.find((f) => f.code === "composition_file_too_large");
expect(finding).toBeUndefined();
});

it("warns on large HTML files regardless of path", () => {
const html = Array.from({ length: 301 }, (_, i) =>
i === 0 ? "<html><body>" : `<!-- filler ${i} -->`,
).join("\n");

const result = lintHyperframeHtml(html, {
filePath: "/project/registry/blocks/data-chart.html",
});
const finding = result.findings.find((f) => f.code === "composition_file_too_large");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});

it("uses nested split copy for large sub-composition files", () => {
const html = Array.from({ length: 301 }, (_, i) =>
i === 0 ? "<html><body>" : `<!-- filler ${i} -->`,
).join("\n");

const result = lintHyperframeHtml(html, {
filePath: "/project/compositions/scene.html",
isSubComposition: true,
});
const finding = result.findings.find((f) => f.code === "composition_file_too_large");
expect(finding?.fixHint).toContain("Split this sub-composition further");
});

it("warns when more than 3 timed elements share the same track", () => {
const html = `<!DOCTYPE html>
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0">
<div class="clip" data-start="0" data-duration="1" data-track-index="0">A</div>
<div class="clip" data-start="1" data-duration="1" data-track-index="0">B</div>
<div class="clip" data-start="2" data-duration="1" data-track-index="0">C</div>
<div class="clip" data-start="3" data-duration="1" data-track-index="0">D</div>
</div>
</body></html>`;

const result = lintHyperframeHtml(html, { filePath: "/project/compositions/scene.html" });
const finding = result.findings.find((f) => f.code === "timeline_track_too_dense");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
expect(finding?.message).toContain("Track 0 has 4 timed elements");
});

it("does not warn when 3 timed elements share the same track", () => {
const html = `<!DOCTYPE html>
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0">
<div class="clip" data-start="0" data-duration="1" data-track-index="0">A</div>
<div class="clip" data-start="1" data-duration="1" data-track-index="0">B</div>
<div class="clip" data-start="2" data-duration="1" data-track-index="0">C</div>
</div>
</body></html>`;

const result = lintHyperframeHtml(html, { filePath: "/project/index.html" });
const finding = result.findings.find((f) => f.code === "timeline_track_too_dense");
expect(finding).toBeUndefined();
});

it("does not warn when timed elements are split across tracks", () => {
const html = `<!DOCTYPE html>
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0">
<div class="clip" data-start="0" data-duration="1" data-track-index="0">A</div>
<div class="clip" data-start="1" data-duration="1" data-track-index="0">B</div>
<div class="clip" data-start="2" data-duration="1" data-track-index="1">C</div>
<div class="clip" data-start="3" data-duration="1" data-track-index="1">D</div>
</div>
</body></html>`;

const result = lintHyperframeHtml(html, { filePath: "/project/index.html" });
const finding = result.findings.find((f) => f.code === "timeline_track_too_dense");
expect(finding).toBeUndefined();
});

it("does not count timed media or script/style tags as dense track elements", () => {
const html = `<!DOCTYPE html>
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0">
<audio data-start="0" data-duration="1" data-track-index="0"></audio>
<audio data-start="1" data-duration="1" data-track-index="0"></audio>
<video muted data-start="2" data-duration="1" data-track-index="0"></video>
<script data-start="3" data-duration="1" data-track-index="0"></script>
<style data-start="4" data-duration="1" data-track-index="0"></style>
</div>
</body></html>`;

const result = lintHyperframeHtml(html, { filePath: "/project/index.html" });
const finding = result.findings.find((f) => f.code === "timeline_track_too_dense");
expect(finding).toBeUndefined();
});

it("does not count root composition or mounted sub-compositions as dense elements", () => {
const html = `<!DOCTYPE html>
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-track-index="0">
<div data-composition-id="a" data-composition-src="compositions/a.html" data-start="0" data-duration="1" data-track-index="0"></div>
<div data-composition-id="b" data-composition-src="compositions/b.html" data-start="1" data-duration="1" data-track-index="0"></div>
<div data-composition-id="c" data-composition-src="compositions/c.html" data-start="2" data-duration="1" data-track-index="0"></div>
<div data-composition-id="d" data-composition-src="compositions/d.html" data-start="3" data-duration="1" data-track-index="0"></div>
</div>
</body></html>`;

const result = lintHyperframeHtml(html, { filePath: "/project/index.html" });
const finding = result.findings.find((f) => f.code === "timeline_track_too_dense");
expect(finding).toBeUndefined();
});
});

it("reports info for composition with external CDN script dependency", () => {
const html = `<template id="rockets-template">
<div data-composition-id="rockets" data-width="1920" data-height="1080">
Expand Down
69 changes: 69 additions & 0 deletions packages/core/src/lint/rules/composition.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,76 @@
import type { LintContext, HyperframeLintFinding } from "../context";
import { readAttr, truncateSnippet } from "../utils";

// Agent guidance thresholds: warning-only nudges for files/tracks that become hard
// to inspect and revise reliably in a single composition.
const MAX_COMPOSITION_LINES = 300;
const MAX_TIMED_ELEMENTS_PER_TRACK = 3;
const TRACK_DENSITY_EXEMPT_TAGS = new Set(["audio", "script", "style", "video"]);

function countPhysicalLines(source: string): number {
if (source.length === 0) return 0;

const normalized = source.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const withoutFinalNewline = normalized.endsWith("\n") ? normalized.slice(0, -1) : normalized;
return withoutFinalNewline.split("\n").length;
}

function isCompositionRootOrMount(rawTag: string): boolean {
return Boolean(
readAttr(rawTag, "data-composition-id") || readAttr(rawTag, "data-composition-src"),
);
}

export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
// composition_file_too_large
({ rawSource, options }) => {
const lineCount = countPhysicalLines(rawSource);
if (lineCount <= MAX_COMPOSITION_LINES) return [];

const splitTarget = options.isSubComposition
? "Split this sub-composition further into smaller .html files"
: "Split coherent scenes or layers into separate .html files under compositions/";

return [
{
code: "composition_file_too_large",
severity: "warning",
message: `This HTML composition file has ${lineCount} lines. Agents produce better results when large scenes are split into smaller sub-compositions.`,
fixHint: `${splitTarget}, then mount them from the parent with data-composition-src so each file stays small enough to inspect, revise, and validate independently.`,
},
];
},

// timeline_track_too_dense
({ tags, options }) => {
const trackCounts = new Map<string, number>();
for (const tag of tags) {
if (TRACK_DENSITY_EXEMPT_TAGS.has(tag.name)) continue;
if (isCompositionRootOrMount(tag.raw)) continue;
if (!readAttr(tag.raw, "data-start")) continue;

const track = readAttr(tag.raw, "data-track-index");
if (!track) continue;
trackCounts.set(track, (trackCounts.get(track) ?? 0) + 1);
}

const findings: HyperframeLintFinding[] = [];
for (const [track, count] of trackCounts) {
if (count <= MAX_TIMED_ELEMENTS_PER_TRACK) continue;
const splitTarget = options.isSubComposition
? "Move coherent scene groups into smaller .html files"
: "Move coherent scene groups into separate .html files under compositions/";
findings.push({
code: "timeline_track_too_dense",
severity: "warning",
message: `Track ${track} has ${count} timed elements in this HTML file. Agents produce better timelines when dense tracks are split into smaller sub-compositions.`,
fixHint: `${splitTarget} and mount them from the parent with data-composition-src so the timeline stays easier to inspect, revise, and validate.`,
});
}

return findings;
},

// timed_element_missing_visibility_hidden
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
Expand Down
Loading