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
+4 -2
View File
@@ -800,8 +800,10 @@ export async function bundleToSingleHtml(
parseHostVariables: parseHostVariableValues,
buildScopeSelector: (compId: string) => cssAttributeSelector("data-composition-id", compId),
scriptErrorLabel: "[HyperFrames] composition script error:",
onMissingComposition: (srcPath: string) => {
console.warn(`[Bundler] Composition file not found: ${srcPath}`);
onMissingComposition: (srcPath: string, reason?: string) => {
console.warn(
`[Bundler] Skipping sub-composition "${srcPath}": ${reason ?? "the file could not be found"}.`,
);
},
});
const compStyleChunks: string[] = [...subCompResult.styles];
+10
View File
@@ -59,5 +59,15 @@ export {
type InlineSubCompositionsResult,
} from "./inlineSubCompositions";
// Sub-composition usability check (shared between the inliner, lint, and the
// render pre-flight abort) — single source of truth for "is this
// data-composition-src file usable?"
export {
checkSubCompositionUsability,
type ParsableDocumentLike,
type SubCompositionValidity,
type SubCompositionValidityReason,
} from "./subCompositionValidity";
// Asset-path primitives (shared across core, producer, CLI)
export { CSS_URL_RE, PATH_ATTRS, isNonRelativeUrl, isPathInside } from "./assetPaths";
@@ -45,6 +45,12 @@ describe("inlineSubCompositions #ID selector scoping divergence", () => {
label: "valid-parse-empty-body",
html: "<!doctype html><html><head></head><body></body></html>",
},
// linkedom's parseHTML("just some text") returns documentElement === null.
// Any code that then touches .head/.body (as linkedom's own internals do)
// throws "Cannot destructure property 'firstElementChild' of
// 'documentElement' as it is null" — the #1 raw crash in production
// telemetry. Must be skipped gracefully, not crash.
{ label: "malformed non-HTML text", html: "just some plain text, no tags at all" },
])("skips $label sub-composition files gracefully", ({ html }) => {
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
@@ -61,6 +67,21 @@ describe("inlineSubCompositions #ID selector scoping divergence", () => {
expect(result.scripts).toHaveLength(0);
});
it("passes the failure reason through to onMissingComposition", () => {
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
const reasons: Array<string | undefined> = [];
inlineSubCompositions(document, [host], {
resolveHtml: () => "",
parseHtml: (h) => parseHTML(h).document,
onMissingComposition: (_src, reason) => reasons.push(reason),
});
expect(reasons).toHaveLength(1);
expect(reasons[0]).toContain("empty");
});
it("producer path (no flattenInnerRoot): strips inner root, losing #id attribute", () => {
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
@@ -19,6 +19,7 @@ import {
wrapInlineScriptWithErrorBoundary,
wrapScopedCompositionScript,
} from "./compositionScoping";
import { checkSubCompositionUsability } from "@hyperframes/parsers/sub-composition-validity";
// ---------------------------------------------------------------------------
// Public interface
@@ -101,10 +102,14 @@ export interface InlineSubCompositionsOptions {
scriptErrorLabel?: string;
/**
* Log a warning when a composition file cannot be resolved.
* Log a warning when a composition file cannot be resolved. `reason` is a
* short, human-readable explanation (e.g. "the file is empty (0 bytes or
* whitespace-only)") from `checkSubCompositionUsability` present for
* every skip except when `resolveHtml` returns `null` (file not found,
* which callers detect themselves before calling `resolveHtml`).
* Defaults to `console.warn`.
*/
onMissingComposition?: (srcPath: string) => void;
onMissingComposition?: (srcPath: string, reason?: string) => void;
}
export interface InlineSubCompositionsResult {
@@ -176,16 +181,26 @@ export function inlineSubCompositions(
if (!src) continue;
const compHtml = resolveHtml(src);
if (compHtml == null || !compHtml.trim()) {
// Shared with lint + render pre-flight (@hyperframes/parsers'
// subCompositionValidity.ts) so all three callers agree on what counts
// as a usable sub-composition file. This path stays intentionally
// tolerant (skip, don't throw) — preview and studio must keep bundling
// around a scene that's still being authored. Lint and the render
// pre-flight check use the same helper to fail loudly instead.
const validity = checkSubCompositionUsability(compHtml, parseHtml);
if (!validity.ok) {
onMissingComposition?.(src, validity.detail);
continue;
}
if (compHtml == null) {
// Unreachable in practice — checkSubCompositionUsability's "empty"
// reason already covers null/undefined — but this lets TypeScript
// narrow compHtml to `string` below without an `as T` assertion.
onMissingComposition?.(src);
continue;
}
const compDoc = parseHtml(compHtml);
if (!compDoc.documentElement) {
onMissingComposition?.(src);
continue;
}
// Determine composition IDs
let compId: string | null;
@@ -0,0 +1,2 @@
/** @deprecated Import from @hyperframes/parsers/sub-composition-validity */
export * from "@hyperframes/parsers/sub-composition-validity";
+6
View File
@@ -149,6 +149,12 @@ export {
rewriteInlineStyleAssetUrls,
} from "./compiler/rewriteSubCompPaths";
export { CSS_URL_RE, isNonRelativeUrl, isPathInside } from "./compiler/assetPaths";
export {
checkSubCompositionUsability,
type ParsableDocumentLike,
type SubCompositionValidity,
type SubCompositionValidityReason,
} from "./compiler/subCompositionValidity";
export { queryByAttr } from "./utils/cssSelector";
export { decodeUrlPathVariants } from "./utils/urlPath";
export { parseAnimatedGifMetadata, type AnimatedGifMetadata } from "./media/gif";