fix(core,producer,cli): pre-flight validation for empty/malformed sub-compositions (#1831)

* fix(core,producer,cli): pre-flight validation for empty/malformed sub-compositions

The #1 render failure bucket in production telemetry (PostHog project 356858,
dashboard 1783183 "HyperFrames — Bottom-Line & Activation"; ~65-69K
occurrences / ~27-28K affected users over 30 days, ~80% via AI-agent
authoring flows) is a `data-composition-src` reference pointing at a scene
file that is empty, malformed, or missing.

Root cause, traced end-to-end:
- The literal error "Composition HTML is empty or could not be parsed: <path>"
  is real (not a PostHog paraphrase) — thrown by a since-reverted guard in
  packages/core/src/compiler/inlineSubCompositions.ts (#1364), then changed to
  a silent skip in #1678 to avoid aborting renders on partial content during
  authoring. #1629 added per-assembler guards for 3 skill workflows
  (product-launch-video, faceless-explainer, pr-to-video), but general-video
  and hand-authored flows — where the dominant filename `scene-title.html`
  (40K+/68K of the bucket) originates — have no assembler and thus no guard.
  #1678 assumed the assembler guards from #1629 covered this pre-render; they
  only covered 3 of the many authoring flows.
- On current `main`, an empty/malformed data-composition-src file no longer
  crashes or throws during render — it's silently dropped by the tolerant
  inliner. Reproduced locally: `hyperframes render` on a project with an
  empty scene-title.html "succeeds" after ~93s (two 45s
  pollSubCompositionTimelines timeouts) with the scene silently missing from
  the output video. `hyperframes validate` also reports "No console errors"
  for the same broken project.
- The raw `Cannot destructure property 'firstElementChild' of
  'documentElement' as it is null` crash reproduces directly against
  linkedom (the DOMParser polyfill packages/cli/src/utils/dom.ts installs in
  the real CLI runtime) for empty and non-HTML input — confirmed with a
  standalone repro script, not just inferred. jsdom/happy-dom (used in this
  repo's own test environment) are spec-compliant and never produce a null
  documentElement, which is why this needed a linkedom-specific test file.

Fix:
- New shared helper `checkSubCompositionUsability`
  (packages/core/src/compiler/subCompositionValidity.ts) is the single
  source of truth for "is this data-composition-src file usable" — mirrors
  the inliner's own parse/template/body logic so all callers agree.
- `inlineSubCompositions.ts` (preview/studio bundling) now uses the shared
  helper internally but keeps its #1678 tolerant skip-and-continue behavior
  unchanged — mid-authoring iteration on a partial project must keep
  working. `onMissingComposition` now also receives a human-readable reason.
- New render-only pre-flight (`assertSubCompositionsUsable` in
  packages/producer/src/services/htmlCompiler.ts) walks every
  data-composition-src reference (including nested ones, root-relative,
  matching parseSubCompositions' own resolution) before any compilation
  work starts, and throws naming every offending file at once. This is
  unconditional — not gated behind --strict — because a render that
  silently drops a scene is strictly worse than one that refuses to start.
  Confirmed locally: render now fails in ~0.4s with an actionable message
  instead of "succeeding" after 93s with a missing scene.
- New `hyperframes lint` rule `missing_or_empty_sub_composition`
  (packages/cli/src/utils/lintProject.ts) surfaces the same check as a
  file-scoped, actionable lint error (already unconditional — lint exits 1
  on any error).
- `hyperframes validate` now also runs this check before launching a
  browser, so it no longer reports "No console errors" for a project with a
  broken sub-composition.
- `packages/core/src/parsers/htmlParser.ts`: guarded every
  `documentElement`-may-be-null access (parseHtml, updateElementInHtml,
  addElementToHtml, removeElementFromHtml, extractCompositionMetadata,
  validateCompositionHtml) with a new typed `CompositionHtmlParseError` (or,
  for validateCompositionHtml's collect-and-report contract, a typed
  validation failure) instead of a raw crash.

Tests: empty file, whitespace-only, malformed/non-HTML, missing file, nested
sub-compositions (both happy path and broken-grandchild), and the happy path
— at the shared-helper, lint, and render pre-flight layers.

Not changed: the AI-agent authoring skills (skills/*). general-video and
hand-authored flows have no assemble-index.mjs equivalent to guard, so the
fix is at the CLI/render layer instead — flow-agnostic, covers every
authoring path, and the skills' existing "run lint/validate and stop on
failure" guidance now actually catches this class of mistake once run.

Not run in this environment: the producer package's full regression-harness
test suite (`bun test` in packages/producer) — it performs heavy real
rendering (S3 asset downloads, Google Fonts fetches, full video encodes) and
did not complete in a reasonable time in this sandbox. Verified instead via
the targeted test file for all touched code (76/76 passing), whole-repo
typecheck/build/oxlint, `fallow audit` (complexity/duplication/dead-code
gate, clean), and manual end-to-end CLI runs (render/lint/validate) against
reproduction projects, including a nested sub-composition scenario. CI
should run the full producer suite before merge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* refactor(parsers,lint): port empty-composition pre-flight to extracted packages

Rebased onto main, which extracted @hyperframes/lint from core (lint depends
only on parsers, not core). Relocate checkSubCompositionUsability from core to
@hyperframes/parsers so both core (inliner) and lint can consume it without a
core<->lint cycle; core keeps a @deprecated re-export shim.

Correctness fixes from code review:
- checkSubCompositionUsability now returns "no-composition-root" when the
  <template>/<body> content has no [data-composition-id] element (previously
  a marker-free placeholder body passed both guards).
- lint's missing/empty sub-composition rule now only checks files reachable
  via data-composition-src from the root (matching render pre-flight), instead
  of a raw filesystem walk that false-positived on orphaned files.
- drop `as string` cast in inlineSubCompositions in favor of an explicit
  null guard (per CLAUDE.md).

Review-comment items:
- move EmptyCompositionError JSDoc above the class (was above the adapter fn).
- correct stale circular-ref comment to match actual silent-skip behavior.
- rewrite self-contradicting lint message ("silently drop") to describe the
  new loud render-pre-flight abort.
- add the __PLACEHOLDER__ (/^__[A-Z_]+__$/) skip to the render pre-flight so
  it agrees with lint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
James Russo
2026-07-01 14:25:44 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 9c4d9e50a0
commit cf573f7f3f
23 changed files with 1335 additions and 35 deletions
+106 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { shouldIgnoreRequestFailure } from "./validate.js";
import { extractCompositionErrorsFromLint, shouldIgnoreRequestFailure } from "./validate.js";
import type { ProjectLintResult } from "../utils/lintProject.js";
describe("shouldIgnoreRequestFailure", () => {
it("ignores aborted media preload requests", () => {
@@ -34,3 +35,107 @@ describe("shouldIgnoreRequestFailure", () => {
).toBe(false);
});
});
describe("extractCompositionErrorsFromLint", () => {
// `bundleToSingleHtml` (the inliner validate.ts bundles through) is
// intentionally tolerant of missing/empty/unparsable data-composition-src
// files — it skips the scene and keeps going, silently, so `validate`
// would otherwise report "No console errors" for a project that renders a
// materially broken video. extractCompositionErrorsFromLint pulls the
// lintProject finding into validate's error list so this is a real
// validate failure instead.
function makeLintResult(
findings: Array<{ code: string; severity: "error" | "warning" | "info"; message: string }>,
): Pick<ProjectLintResult, "results"> {
return {
results: [
{
file: "index.html",
result: {
ok: findings.length === 0,
errorCount: 0,
warningCount: 0,
infoCount: 0,
findings,
},
},
],
};
}
it("surfaces missing_or_empty_sub_composition errors as ConsoleEntry errors", () => {
const lintResult = makeLintResult([
{
code: "missing_or_empty_sub_composition",
severity: "error",
message:
'data-composition-src references "compositions/scene-title.html", but the file is empty.',
},
]);
const errors = extractCompositionErrorsFromLint(lintResult);
expect(errors).toEqual([
{
level: "error",
text: 'data-composition-src references "compositions/scene-title.html", but the file is empty.',
},
]);
});
it("ignores unrelated lint finding codes", () => {
const lintResult = makeLintResult([
{ code: "audio_src_not_found", severity: "error", message: "unrelated" },
{ code: "root_missing_composition_id", severity: "error", message: "also unrelated" },
]);
expect(extractCompositionErrorsFromLint(lintResult)).toEqual([]);
});
it("returns an empty array for a clean project", () => {
expect(extractCompositionErrorsFromLint(makeLintResult([]))).toEqual([]);
});
it("collects findings across multiple result files", () => {
const lintResult: Pick<ProjectLintResult, "results"> = {
results: [
{
file: "index.html",
result: {
ok: false,
errorCount: 1,
warningCount: 0,
infoCount: 0,
findings: [
{
code: "missing_or_empty_sub_composition",
severity: "error",
message: "scene-a is empty",
},
],
},
},
{
file: "compositions/nested.html",
result: {
ok: false,
errorCount: 1,
warningCount: 0,
infoCount: 0,
findings: [
{
code: "missing_or_empty_sub_composition",
severity: "error",
message: "scene-b is empty",
},
],
},
},
],
};
const errors = extractCompositionErrorsFromLint(lintResult);
expect(errors).toHaveLength(2);
expect(errors.map((e) => e.text)).toEqual(["scene-a is empty", "scene-b is empty"]);
});
});
+32 -4
View File
@@ -2,8 +2,9 @@ import { defineCommand } from "citty";
import { existsSync, readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { resolveProject } from "../utils/project.js";
import { resolveProject, type ProjectDir } from "../utils/project.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
import type { ProjectLintResult } from "../utils/lintProject.js";
import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js";
import { c } from "../ui/colors.js";
import { withMeta } from "../utils/updateCheck.js";
@@ -179,13 +180,40 @@ function loadContrastAuditScript(): string {
throw new Error("Missing contrast audit browser script");
}
/**
* Pull the `missing_or_empty_sub_composition` lint findings out of a
* `lintProject` result and shape them as `ConsoleEntry`s. Extracted as a
* pure function so it's testable without a headless browser or a real
* project directory — see validate.test.ts.
*/
export function extractCompositionErrorsFromLint(
lintResult: Pick<ProjectLintResult, "results">,
): ConsoleEntry[] {
return lintResult.results
.flatMap((r) => r.result.findings)
.filter((f) => f.code === "missing_or_empty_sub_composition" && f.severity === "error")
.map((f) => ({ level: "error" as const, text: f.message }));
}
async function validateInBrowser(
projectDir: string,
project: ProjectDir,
opts: { timeout?: number; contrast?: boolean },
): Promise<{ errors: ConsoleEntry[]; warnings: ConsoleEntry[]; contrast?: ContrastEntry[] }> {
const projectDir = project.dir;
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
const { ensureBrowser } = await import("../browser/manager.js");
const { serveStaticProjectHtml } = await import("../utils/staticProjectServer.js");
const { lintProject } = await import("../utils/lintProject.js");
// Fail fast on missing/empty/unparsable data-composition-src references
// before spending time bundling and launching a browser. The bundler
// (bundleToSingleHtml → inlineSubCompositions) is intentionally tolerant of
// these — it skips the broken scene and keeps going, silently, with only a
// console.warn — so validate would otherwise report "No console errors"
// for a project that renders a materially broken video. Surface it as a
// real validate failure instead.
const lintResult = await lintProject(projectDir);
const compositionErrors = extractCompositionErrorsFromLint(lintResult);
// `bundleToSingleHtml` now inlines the runtime IIFE by default, so the
// previous post-bundle regex substitution (which matched `src="..."` on the
@@ -194,7 +222,7 @@ async function validateInBrowser(
const server = await serveStaticProjectHtml(projectDir, html);
const errors: ConsoleEntry[] = [];
const errors: ConsoleEntry[] = [...compositionErrors];
const warnings: ConsoleEntry[] = [];
let contrast: ContrastEntry[] | undefined;
const viewport = resolveCompositionViewportFromHtml(html);
@@ -391,7 +419,7 @@ Examples:
}
try {
const result = await validateInBrowser(project.dir, { timeout, contrast: useContrast });
const result = await validateInBrowser(project, { timeout, contrast: useContrast });
const exitCode = printValidationResult(result, asJson);
process.exit(exitCode);
} catch (err: unknown) {
+110
View File
@@ -2,6 +2,7 @@ import { describe, it, expect, afterEach } from "vitest";
import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { HyperframeLintFinding } from "@hyperframes/core/lint";
import { lintProject, shouldBlockRender } from "./lintProject.js";
function tmpProject(name: string): string {
@@ -1027,6 +1028,115 @@ describe("duplicate_audio_track", () => {
});
});
describe("missing_or_empty_sub_composition", () => {
function htmlWithSubComp(srcPath: string): string {
return `<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10">
<div data-composition-src="${srcPath}" data-composition-id="scene-title" data-start="0" data-duration="5"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
</body></html>`;
}
function validSubCompHtml(): string {
return `<!doctype html><html><body>
<div data-composition-id="scene-title" data-width="1920" data-height="1080">
<div class="title">Hello</div>
</div>
</body></html>`;
}
// Shared assertion: lint a project referencing "compositions/scene-title.html"
// (or a custom srcPath) and return the missing_or_empty_sub_composition
// finding, if any, plus the raw lint result for callers that need totalErrors.
async function lintSubComp(
srcPath: string,
subCompFiles?: Record<string, string>,
): Promise<{ finding: HyperframeLintFinding | undefined; totalErrors: number }> {
const project = makeProject(htmlWithSubComp(srcPath), subCompFiles);
const { totalErrors, results } = await lintProject(project);
const finding = results
.flatMap((r) => r.result.findings)
.find((f) => f.code === "missing_or_empty_sub_composition");
return { finding, totalErrors };
}
it.each([
{
label: "empty",
content: "",
expectMessageContains: "empty",
},
{
label: "whitespace-only",
content: " \n\t ",
expectMessageContains: "empty",
},
{
label: "malformed / non-HTML",
content: "just some plain text, no tags at all",
expectMessageContains: "could not be parsed",
},
])(
"errors when the referenced sub-composition file is $label",
async ({ content, expectMessageContains }) => {
const { finding, totalErrors } = await lintSubComp("compositions/scene-title.html", {
"scene-title.html": content,
});
expect(totalErrors).toBeGreaterThan(0);
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain(expectMessageContains);
},
);
it("errors when the referenced sub-composition file does not exist", async () => {
// No subComps passed — compositions/ directory doesn't even exist.
const { finding, totalErrors } = await lintSubComp("compositions/does-not-exist.html");
expect(totalErrors).toBeGreaterThan(0);
expect(finding).toBeDefined();
expect(finding?.message).toContain("compositions/does-not-exist.html");
expect(finding?.message).toContain("does not exist");
});
it("does not error when the referenced sub-composition file is valid (happy path)", async () => {
const { finding } = await lintSubComp("compositions/scene-title.html", {
"scene-title.html": validSubCompHtml(),
});
expect(finding).toBeUndefined();
});
it("does not error on a project with no data-composition-src references", async () => {
const project = makeProject(validHtml());
const { results } = await lintProject(project);
const finding = results
.flatMap((r) => r.result.findings)
.find((f) => f.code === "missing_or_empty_sub_composition");
expect(finding).toBeUndefined();
});
it("dedupes a single bad reference into one finding even if repeated", async () => {
const html = `<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10">
<div data-composition-src="compositions/scene-title.html" data-composition-id="a" data-start="0" data-duration="5"></div>
<div data-composition-src="compositions/scene-title.html" data-composition-id="b" data-start="5" data-duration="5"></div>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const project = makeProject(html, { "scene-title.html": "" });
const { results } = await lintProject(project);
const findings = results
.flatMap((r) => r.result.findings)
.filter((f) => f.code === "missing_or_empty_sub_composition");
expect(findings).toHaveLength(1);
});
});
describe("shouldBlockRender", () => {
it("default: does not block on errors", async () => {
expect(shouldBlockRender(false, false, 5, 0)).toBe(false);