feat(cli): report which catalog items a render actually used (#3470)

`registry_item_added` fires when a catalog block is installed and
`render_complete` fires when a video is produced, but nothing joined them, so
"did this video use the catalog?" had no answer.

`hyperframes add` now records each installed item in `hyperframes.json`
(installed files are plain composition HTML with no provenance marker, so this
manifest is the only record that a file came from the registry), and
`render_complete` reports both the items the project installed and the blocks
the rendered composition actually reaches. An item installed and then never
mounted was tried and dropped, which no add-time event can express.

The scan answering "which sub-compositions does this file mount" now has one
owner, `collectSubCompositionSrcs` in `@hyperframes/parsers`, shared with
lint's `lintMissingOrEmptySubComposition`. It holds two invariants that were
previously restated per call site and got re-derived wrongly: it is a text scan
rather than a DOM query, because `<template>` content is inert and every
sub-composition except the render entry is wrapped in one; and references
resolve root-relative at every nesting level, matching `parseSubCompositions`.
It walks tag by tag rather than running open-ended spans across the whole file,
so a malformed composition cannot stall the render plan.

Also: `registryItems` is declared in the config schema, which closes with
`additionalProperties: false`, with an ajv-backed test pinning every key the CLI
writes; counts are never truncated by the name cap, and the reported used blocks
stay a subset of the reported installed ones, with `registry_items_truncated`
marking a windowed list; and an unreadable manifest reports itself rather than
posing as a project that never used the catalog.
This commit is contained in:
Miguel Ángel
2026-08-24 19:56:06 -04:00
committed by GitHub
parent b2fc18b2df
commit 045b3a4fd7
18 changed files with 1129 additions and 40 deletions
+14 -13
View File
@@ -24,7 +24,7 @@
},
"packages/aws-lambda": {
"name": "@hyperframes/aws-lambda",
"version": "0.8.5",
"version": "0.8.13",
"dependencies": {
"@aws-sdk/client-s3": "^3.700.0",
"@aws-sdk/client-sfn": "^3.700.0",
@@ -57,7 +57,7 @@
},
"packages/cli": {
"name": "@hyperframes/cli",
"version": "0.8.5",
"version": "0.8.13",
"bin": {
"hyperframes": "./bin/hyperframes.mjs",
},
@@ -94,6 +94,7 @@
"@types/fontkit": "^2.0.9",
"@types/mime-types": "^3.0.1",
"@types/node": "^25.0.10",
"ajv": "^8.20.0",
"linkedom": "^0.18.12",
"picocolors": "^1.1.1",
"tsup": "^8.0.0",
@@ -107,7 +108,7 @@
},
"packages/core": {
"name": "@hyperframes/core",
"version": "0.8.5",
"version": "0.8.13",
"dependencies": {
"@chenglou/pretext": "^0.0.5",
"@hyperframes/lint": "workspace:*",
@@ -132,7 +133,7 @@
},
"packages/engine": {
"name": "@hyperframes/engine",
"version": "0.8.5",
"version": "0.8.13",
"dependencies": {
"@hono/node-server": "^2.0.5",
"@hyperframes/core": "workspace:^",
@@ -151,7 +152,7 @@
},
"packages/gcp-cloud-run": {
"name": "@hyperframes/gcp-cloud-run",
"version": "0.8.5",
"version": "0.8.13",
"dependencies": {
"@google-cloud/storage": "^7.14.0",
"@google-cloud/workflows": "^4.2.0",
@@ -172,7 +173,7 @@
},
"packages/lint": {
"name": "@hyperframes/lint",
"version": "0.8.5",
"version": "0.8.13",
"dependencies": {
"@hyperframes/parsers": "workspace:*",
"htmlparser2": "^10.1.0",
@@ -190,7 +191,7 @@
},
"packages/parsers": {
"name": "@hyperframes/parsers",
"version": "0.8.5",
"version": "0.8.13",
"dependencies": {
"@babel/parser": "^7.27.0",
"acorn": "^8.17.0",
@@ -210,7 +211,7 @@
},
"packages/player": {
"name": "@hyperframes/player",
"version": "0.8.5",
"version": "0.8.13",
"dependencies": {
"@hyperframes/core": "workspace:*",
},
@@ -225,7 +226,7 @@
},
"packages/producer": {
"name": "@hyperframes/producer",
"version": "0.8.5",
"version": "0.8.13",
"dependencies": {
"@fontsource/archivo-black": "^5.2.8",
"@fontsource/eb-garamond": "^5.2.7",
@@ -270,7 +271,7 @@
},
"packages/sdk": {
"name": "@hyperframes/sdk",
"version": "0.8.5",
"version": "0.8.13",
"dependencies": {
"@hyperframes/core": "workspace:*",
"@hyperframes/parsers": "workspace:*",
@@ -300,7 +301,7 @@
},
"packages/shader-transitions": {
"name": "@hyperframes/shader-transitions",
"version": "0.8.5",
"version": "0.8.13",
"dependencies": {
"html2canvas": "^1.4.1",
},
@@ -312,7 +313,7 @@
},
"packages/studio": {
"name": "@hyperframes/studio",
"version": "0.8.5",
"version": "0.8.13",
"dependencies": {
"@codemirror/autocomplete": "^6.20.1",
"@codemirror/commands": "^6.10.3",
@@ -363,7 +364,7 @@
},
"packages/studio-server": {
"name": "@hyperframes/studio-server",
"version": "0.8.5",
"version": "0.8.13",
"dependencies": {
"@hyperframes/core": "workspace:*",
"@hyperframes/parsers": "workspace:*",
+23
View File
@@ -56,6 +56,29 @@
"type": "string",
"pattern": "^[a-z0-9][a-z0-9-]{0,63}$",
"description": "Owning authoring-workflow skill slug (e.g. product-launch-video). Set by `hyperframes init --skill` or seeded from the first `hyperframes render --skill`; every render of this project is then attributed to it on anonymous telemetry, without re-passing the flag."
},
"registryItems": {
"type": "array",
"description": "Catalog items installed by `hyperframes add`, in install order. Installed files are plain composition HTML with no provenance marker, so this is the only record that a file came from the registry; a render reads it back to report which catalog items the finished video actually used. Append-only, deduped by name.",
"items": {
"type": "object",
"required": ["name", "type", "target"],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"description": "Registry item name, e.g. data-chart."
},
"type": {
"type": "string",
"description": "Registry item type, e.g. hyperframes:block."
},
"target": {
"type": "string",
"description": "Primary installed file, relative to the project root."
}
}
}
}
}
}
+1
View File
@@ -61,6 +61,7 @@
"@types/fontkit": "^2.0.9",
"@types/mime-types": "^3.0.1",
"@types/node": "^25.0.10",
"ajv": "^8.20.0",
"linkedom": "^0.18.12",
"picocolors": "^1.1.1",
"tsup": "^8.0.0",
+32 -5
View File
@@ -25,6 +25,7 @@ import {
DEFAULT_PROJECT_CONFIG,
loadProjectConfig,
projectConfigPath,
recordProjectRegistryItems,
writeProjectConfig,
} from "../utils/projectConfig.js";
import { copyToClipboard } from "../utils/clipboard.js";
@@ -78,6 +79,23 @@ function variableValuesAttribute(values: Record<string, unknown> | null): string
return ` data-variable-values='${json}'`;
}
/**
* The file a consumer points at for this item: the snippet if it has one, else
* its composition, else whatever landed first. Project-relative, empty when the
* item installed no files.
*
* One owner for this choice. The paste snippet's `data-composition-src` and the
* recorded manifest target must name the same file, or a render would look for
* a block at a path the composition never mounts and report it as dropped.
*/
function primaryInstalledTarget(item: RegistryItem): string {
const primary =
item.files.find((f) => f.type === "hyperframes:snippet") ??
item.files.find((f) => f.type === "hyperframes:composition") ??
item.files[0];
return primary?.target ?? "";
}
export function buildSnippet(
item: RegistryItem,
relativeTarget: string,
@@ -321,13 +339,22 @@ export async function runAdd(opts: RunAddArgs): Promise<RunAddResult> {
});
}
// Persist what came from the registry. Installed files are plain composition
// HTML with no provenance marker, so without this a later render cannot tell
// a catalog block from one the user wrote — and "did the catalog item survive
// into the video?" stays unanswerable.
recordProjectRegistryItems(
projectDir,
installPlan.map((planItem) => ({
name: planItem.name,
type: planItem.type,
target: primaryInstalledTarget(planItem),
})),
);
// 6. Build include snippet + clipboard copy for the requested item.
const itemForInstall = installPlan[installPlan.length - 1]!;
const primaryFile =
itemForInstall.files.find((f) => f.type === "hyperframes:snippet") ??
itemForInstall.files.find((f) => f.type === "hyperframes:composition") ??
itemForInstall.files[0];
const snippetTargetRel = primaryFile?.target ?? "";
const snippetTargetRel = primaryInstalledTarget(itemForInstall);
const snippet = buildSnippet(item, snippetTargetRel, variableValues);
const clipboardCopied = !opts.skipClipboard && snippet ? copyToClipboard(snippet) : false;
+9
View File
@@ -4,6 +4,7 @@ import type { Example } from "./_examples.js";
import { mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync, rmSync } from "node:fs";
import { createRenderPlan, resolveBrowserGpuForCli, type RenderFormat } from "./render/plan.js";
import { seedProjectAuthoringSkill } from "../utils/projectConfig.js";
import type { CatalogUsage } from "../utils/catalogUsage.js";
import { presentRenderPlan } from "./render/present.js";
import { executeRenderPlan, renderLintContinuationHint, runRenderLint } from "./render/execute.js";
// Test-only seams retained at the command boundary for render behavior tests.
@@ -377,6 +378,12 @@ export interface RenderOptions {
quality: "draft" | "standard" | "high";
/** Authoring workflow skill that drove this render (telemetry attribution). */
authoringSkill?: string;
/**
* Catalog items installed in this project and those the rendered composition
* reaches. Resolved once in the render plan; absent on programmatic callers
* that build options by hand, which simply omit the catalog properties.
*/
catalogUsage?: CatalogUsage;
format: RenderFormat;
gifLoop?: number;
workers?: number;
@@ -763,6 +770,7 @@ async function renderDocker(
docker: true,
gpu: options.gpu,
authoringSkill: options.authoringSkill,
catalogUsage: options.catalogUsage,
...getMemorySnapshot(),
}),
);
@@ -1498,6 +1506,7 @@ function trackRenderMetrics(
docker,
gpu: options.gpu,
authoringSkill: options.authoringSkill,
catalogUsage: options.catalogUsage,
staticDedupEnabled: perf?.staticDedup?.enabled,
staticDedupArmed: perf?.staticDedup?.armed,
staticDedupSkipReason: perf?.staticDedup?.skipReason,
@@ -101,6 +101,7 @@ export async function executeRenderPlan(
fps: plan.fps,
quality: plan.quality,
authoringSkill: plan.authoringSkill,
catalogUsage: plan.catalogUsage,
format: plan.format,
gifLoop: plan.gifLoop,
workers: plan.workers,
@@ -254,6 +255,7 @@ async function executeBatchRender(
fps: plan.fps,
quality: plan.quality,
authoringSkill: plan.authoringSkill,
catalogUsage: plan.catalogUsage,
format: plan.format,
workers: plan.workers,
gpu: plan.useGpu,
+37 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { CliUsageError } from "../../utils/commandResult.js";
@@ -37,6 +37,42 @@ describe("createRenderPlan", () => {
expect(Object.isFrozen(plan.environment)).toBe(true);
});
// The catalog join reaches the render event through the plan, so a plan that
// silently drops it would leave every render reporting no catalog items.
it("resolves catalog usage from the project manifest and the render entry", () => {
writeFileSync(
join(projectDir, "index.html"),
'<main data-composition-id="main" data-width="1920" data-height="1080" data-fps="24">' +
'<div data-composition-src="compositions/kept.html" data-duration="2"></div></main>',
);
mkdirSync(join(projectDir, "compositions"), { recursive: true });
// `<template>`-wrapped, as sub-compositions are actually authored: template
// content is inert, so a DOM scan of these files would find nothing.
for (const name of ["kept", "dropped"]) {
writeFileSync(
join(projectDir, "compositions", `${name}.html`),
`<template id="${name}-template"><div data-composition-id="${name}" data-width="1920" data-height="1080"></div></template>`,
);
}
writeFileSync(
join(projectDir, "hyperframes.json"),
JSON.stringify({
registry: "https://example.test",
registryItems: [
{ name: "kept", type: "hyperframes:block", target: "compositions/kept.html" },
{ name: "dropped", type: "hyperframes:block", target: "compositions/dropped.html" },
],
}),
);
const plan = createRenderPlan({ dir: projectDir, output: "result.mp4" });
expect(plan.catalogUsage).toEqual({
installed: ["dropped", "kept"],
usedBlocks: ["kept"],
manifestUnreadable: false,
});
});
it("preserves an explicit strict-readiness opt-in", () => {
const plan = createRenderPlan({ dir: projectDir, "best-effort": false });
expect(plan.bestEffort).toBe(false);
+8 -1
View File
@@ -29,6 +29,7 @@ import {
} from "../../utils/renderArgs.js";
import { normalizeSkillSlug } from "../../telemetry/skill.js";
import { loadProjectConfig } from "../../utils/projectConfig.js";
import { type CatalogUsage, summarizeCatalogUsage } from "../../utils/catalogUsage.js";
const VALID_QUALITY = new Set(["draft", "standard", "high"]);
const RENDER_FORMATS = ["mp4", "webm", "mov", "png-sequence", "gif"] as const;
@@ -99,6 +100,8 @@ export interface RenderPlan {
quality: RenderQuality;
authoringSkill?: string;
invalidAuthoringSkill?: string;
/** Catalog items installed in this project, and those the entry reaches. */
catalogUsage: CatalogUsage;
format: RenderFormat;
gifLoop?: number;
gifFpsCapped: boolean;
@@ -204,6 +207,10 @@ export function createRenderPlan(args: RenderCommandArgs, now = new Date()): Ren
? args.skill
: undefined;
// Resolved here, once, from the same entry the render will use: batch rows
// vary only their variables, so every row shares this composition tree.
const catalogUsage = summarizeCatalogUsage(project.dir, renderTarget);
const formatRaw = args.format ?? "mp4";
const format = parseRenderFormat(formatRaw);
if (!format) {
@@ -412,6 +419,7 @@ export function createRenderPlan(args: RenderCommandArgs, now = new Date()): Ren
quality,
authoringSkill,
invalidAuthoringSkill,
catalogUsage,
format,
gifLoop,
gifFpsCapped,
@@ -461,7 +469,6 @@ export function renderOutputDirectory(plan: RenderPlan): string {
/** Resolve browser GPU mode from Docker, CLI, env, then the auto default. */
// Re-exported by render.ts to preserve its tested public seam.
// fallow-ignore-next-line unused-export
export function resolveBrowserGpuForCli(
useDocker: boolean,
browserGpuArg: boolean | undefined,
+122
View File
@@ -190,6 +190,128 @@ describe("render telemetry events", () => {
flush.mockClear();
});
// The catalog join. Counts must be present at zero: the no-catalog cohort is
// what the with-catalog cohort is compared against, and an absent property is
// indistinguishable from an older CLI that never sent one.
it("reports zero catalog counts for a project with no registry items", () => {
trackRenderComplete({
durationMs: 1000,
fps: 30,
quality: "draft",
docker: false,
gpu: false,
catalogUsage: { installed: [], usedBlocks: [], manifestUnreadable: false },
});
const props = trackEvent.mock.calls[0]?.[1] as Record<string, unknown>;
expect(props.registry_item_count).toBe(0);
expect(props.registry_blocks_used_count).toBe(0);
expect(props.registry_items).toBeUndefined();
});
it("names the installed items and the subset the render reached", () => {
trackRenderComplete({
durationMs: 1000,
fps: 30,
quality: "draft",
docker: false,
gpu: false,
catalogUsage: {
installed: ["bar-chart-race", "data-chart"],
usedBlocks: ["data-chart"],
manifestUnreadable: false,
},
});
const props = trackEvent.mock.calls[0]?.[1] as Record<string, unknown>;
expect(props.registry_items).toBe("bar-chart-race,data-chart");
expect(props.registry_item_count).toBe(2);
expect(props.registry_blocks_used).toBe("data-chart");
expect(props.registry_blocks_used_count).toBe(1);
});
// A count is one integer with no cardinality risk. Capping it would lose the
// real number with no way downstream to tell 40 installs from 400.
it("caps the item names but reports the true counts past the cap", () => {
const installed = Array.from({ length: 45 }, (_, i) => `b${String(i + 1).padStart(2, "0")}`);
trackRenderComplete({
durationMs: 1000,
fps: 30,
quality: "draft",
docker: false,
gpu: false,
catalogUsage: { installed, usedBlocks: installed.slice(-5), manifestUnreadable: false },
});
const props = trackEvent.mock.calls[0]?.[1] as Record<string, unknown>;
expect(props.registry_item_count).toBe(45);
expect(props.registry_blocks_used_count).toBe(5);
expect(String(props.registry_items).split(",")).toHaveLength(40);
// The names are a window, and a query joining on them would otherwise read
// this project as 45 abandoned items: every used block sits past the cap,
// so `registry_blocks_used` is absent against a count of 5.
expect(props.registry_items_truncated).toBe(true);
expect(props.registry_blocks_used).toBeUndefined();
});
it("does not claim truncation when every name fits", () => {
trackRenderComplete({
durationMs: 1000,
fps: 30,
quality: "draft",
docker: false,
gpu: false,
catalogUsage: {
installed: ["bar-chart-race", "data-chart"],
usedBlocks: ["data-chart"],
manifestUnreadable: false,
},
});
const props = trackEvent.mock.calls[0]?.[1] as Record<string, unknown>;
expect(props.registry_items_truncated).toBeUndefined();
});
// Sliced independently the two lists come out disjoint, which breaks the one
// relationship any drop-off query relies on.
it("keeps the used names a subset of the reported installed names", () => {
const installed = Array.from({ length: 45 }, (_, i) => `b${String(i + 1).padStart(2, "0")}`);
trackRenderComplete({
durationMs: 1000,
fps: 30,
quality: "draft",
docker: false,
gpu: false,
catalogUsage: { installed, usedBlocks: installed.slice(-5), manifestUnreadable: false },
});
const props = trackEvent.mock.calls[0]?.[1] as Record<string, unknown>;
const reported = new Set(String(props.registry_items).split(","));
const used =
props.registry_blocks_used === undefined ? [] : String(props.registry_blocks_used).split(",");
expect(used.every((name) => reported.has(name))).toBe(true);
});
// The control cohort is the one that must not silently absorb failures: a
// project whose manifest cannot be read is not a project without a catalog.
it("flags an unreadable manifest instead of reporting it as zero catalog items", () => {
trackRenderComplete({
durationMs: 1000,
fps: 30,
quality: "draft",
docker: false,
gpu: false,
catalogUsage: { installed: [], usedBlocks: [], manifestUnreadable: true },
});
const props = trackEvent.mock.calls[0]?.[1] as Record<string, unknown>;
expect(props.registry_manifest_unreadable).toBe(true);
expect(props.registry_item_count).toBeUndefined();
});
// A caller that built render options by hand makes no catalog claim, rather
// than claiming zero items.
it("omits the catalog props entirely when usage was never resolved", () => {
trackRenderComplete({ durationMs: 1, fps: 30, quality: "draft", docker: false, gpu: false });
const props = trackEvent.mock.calls[0]?.[1] as Record<string, unknown>;
expect(props.registry_item_count).toBeUndefined();
expect(props.registry_blocks_used_count).toBeUndefined();
});
it("flushes immediately after render_complete and render_error (exit races the lazy flush)", () => {
trackRenderComplete({ durationMs: 1000, fps: 30, quality: "draft", docker: false, gpu: false });
expect(flush).toHaveBeenCalledTimes(1);
+63
View File
@@ -1,6 +1,7 @@
import { redactTelemetryString, type OutputResolutionIssueKind } from "@hyperframes/core";
import type { SubTimelineWaitOutcome } from "@hyperframes/engine";
import { FEEDBACK_RATING_SCALE } from "../utils/feedbackRating.js";
import type { CatalogUsage } from "../utils/catalogUsage.js";
import { flush, shouldTrack, trackEvent } from "./client.js";
import { readConfig } from "./config.js";
import { getPowerState } from "./system.js";
@@ -173,6 +174,60 @@ export function trackCommand(command: string, runId?: string): void {
});
}
/**
* Cap on item names in one event. Registry names are low-cardinality slugs, but
* a project with a hundred blocks should not push a hundred-name string into
* every render. The cap belongs at the boundary that builds the string, and
* deliberately NOT on the counts: a count is one integer with no cardinality
* risk, and a saturated one loses the real number with no way downstream to
* tell 40 installs from 400.
*/
const MAX_REPORTED_ITEM_NAMES = 40;
/**
* Catalog half of `render_complete`.
*
* Counts are emitted even when zero: the no-catalog cohort is exactly what the
* with-catalog cohort gets compared against, and a property that is simply
* absent is indistinguishable from an older CLI that never sent one. Names ride
* as a comma-joined string because event property values are scalars only (same
* shape as `recent_render_ids` on `cli_render_feedback`).
*
* The used names are narrowed to the reported installed names, so
* `registry_blocks_used` stays a subset of `registry_items` even when the cap
* bites. Sliced independently, the two lists can come out disjoint, breaking
* the one relationship a drop-off query relies on. When the cap does bite,
* `registry_items_truncated` says so: the counts still carry the truth, but the
* names are a window, and a query that joins on names must not read the
* difference as abandonment.
*
* An unreadable manifest reports itself and omits the counts rather than
* sending zeros, so a failed read cannot pose as a project that never used the
* catalog. Undefined usage means the caller built render options by hand rather
* than through the render plan, so it makes no catalog claim at all.
*/
function catalogEventProperties(
usage: CatalogUsage | undefined,
): Record<string, string | number | boolean> {
if (!usage) return {};
if (usage.manifestUnreadable) return { registry_manifest_unreadable: true };
const names = usage.installed.slice(0, MAX_REPORTED_ITEM_NAMES);
const reportedNames = new Set(names);
const used = usage.usedBlocks.filter((name) => reportedNames.has(name));
const truncated = names.length < usage.installed.length;
return {
registry_item_count: usage.installed.length,
registry_blocks_used_count: usage.usedBlocks.length,
// Say when the name lists are a window rather than the whole set. Without
// it a name-joining drop-off query silently reads a truncated project as
// all-abandoned: the used blocks can all sit past the cap, leaving an empty
// `registry_blocks_used` against a non-zero count.
...(truncated ? { registry_items_truncated: true } : {}),
...(names.length > 0 ? { registry_items: names.join(",") } : {}),
...(used.length > 0 ? { registry_blocks_used: used.join(",") } : {}),
};
}
export function trackRenderComplete(
props: {
durationMs: number;
@@ -180,6 +235,13 @@ export function trackRenderComplete(
quality: string;
/** Authoring workflow skill that drove this render (e.g. "product-launch-video"). */
authoringSkill?: string;
/**
* Catalog items installed in this project, and those the rendered
* composition reaches. The pair is what joins `registry_item_added` to a
* finished video: an installed item missing from the used set was tried
* and dropped, which no add-time event can express.
*/
catalogUsage?: CatalogUsage;
workers?: number;
// Worker auto-sizing provenance (RenderPerfSummary.workerSizing). Answers
// "why N workers?" fleet-wide, and validates the advisory per-worker heap
@@ -292,6 +354,7 @@ export function trackRenderComplete(
fps: props.fps,
quality: props.quality,
authoring_skill: props.authoringSkill,
...catalogEventProperties(props.catalogUsage),
workers: props.workers,
workers_bound_by: props.workersBoundBy,
workers_cpu_based: props.workersCpuBased,
+215
View File
@@ -0,0 +1,215 @@
import { describe, expect, it } from "vitest";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { summarizeCatalogUsage, type CatalogUsage } from "./catalogUsage.js";
import type { RegistryItemRecord } from "./projectConfig.js";
/**
* Materialize a throwaway project, summarize it, and clean up. Every case here
* needs the same fixture, so the shape lives once.
*/
function usageOf(
files: Record<string, string>,
registryItems?: RegistryItemRecord[],
entry = "index.html",
): CatalogUsage {
const dir = mkdtempSync(join(tmpdir(), "hf-catalog-test-"));
try {
writeFileSync(
join(dir, "hyperframes.json"),
JSON.stringify({
registry: "https://example.test",
...(registryItems ? { registryItems } : {}),
}),
);
for (const [rel, html] of Object.entries(files)) {
const path = join(dir, rel);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, html);
}
return summarizeCatalogUsage(dir, join(dir, entry));
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
/** Same as {@link usageOf}, but writes `hyperframes.json` verbatim. */
function usageOfRawConfig(configText: string | null): CatalogUsage {
const dir = mkdtempSync(join(tmpdir(), "hf-catalog-test-"));
try {
if (configText !== null) writeFileSync(join(dir, "hyperframes.json"), configText);
writeFileSync(join(dir, "index.html"), entryDoc());
return summarizeCatalogUsage(dir, join(dir, "index.html"));
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
function mountTag(src: string): string {
return `<div data-composition-src="${src}" data-duration="3" data-width="1920" data-height="1080"></div>`;
}
/** Render entry: a plain document, never `<template>`-wrapped (lint forbids it). */
function entryDoc(...srcs: string[]): string {
return `<!doctype html><html><body><div id="root" data-composition-id="main" data-width="1920" data-height="1080">${srcs
.map(mountTag)
.join("")}</div></body></html>`;
}
/**
* A sub-composition as they are actually authored everywhere in this repo:
* wrapped in `<template>`. Template content is inert, so a DOM scan of this
* file finds nothing the fixture exists to keep that failure from returning.
*/
function subCompDoc(id: string, ...srcs: string[]): string {
return `<template id="${id}-template"><div data-composition-id="${id}" data-width="1920" data-height="1080">${srcs
.map(mountTag)
.join("")}</div></template>`;
}
const BLOCK = (name: string): RegistryItemRecord => ({
name,
type: "hyperframes:block",
target: `compositions/${name}.html`,
});
describe("summarizeCatalogUsage", () => {
it("reports nothing for a project that never added a catalog item", () => {
expect(usageOf({ "index.html": entryDoc() })).toEqual({
installed: [],
usedBlocks: [],
manifestUnreadable: false,
});
});
// The whole point of the manifest: an item that was installed and then not
// mounted is a rejection, and no add-time event can say so.
it("separates an installed block that the entry mounts from one it dropped", () => {
expect(
usageOf(
{
"index.html": entryDoc("compositions/kept.html"),
"compositions/kept.html": subCompDoc("kept"),
"compositions/dropped.html": subCompDoc("dropped"),
},
[BLOCK("kept"), BLOCK("dropped")],
),
).toEqual({
installed: ["dropped", "kept"],
usedBlocks: ["kept"],
manifestUnreadable: false,
});
});
// Regression for two invariants that a DOM scan of raw files gets wrong, each
// of which reports a block that renders in every video as abandoned:
// `<template>` content is invisible to `querySelectorAll`, and nested
// `data-composition-src` is root-relative, not relative to its own file.
it("follows a root-relative mount from inside a template-wrapped sub-composition", () => {
expect(
usageOf(
{
"index.html": entryDoc("compositions/outer.html"),
"compositions/outer.html": subCompDoc("outer", "compositions/inner.html"),
"compositions/inner.html": subCompDoc("inner"),
},
[BLOCK("outer"), BLOCK("inner")],
).usedBlocks,
).toEqual(["inner", "outer"]);
});
// A cyclic project must not wedge a render that already produced a video.
it("terminates on a mount cycle", () => {
expect(
usageOf(
{
"index.html": entryDoc("compositions/a.html"),
"compositions/a.html": subCompDoc("a", "compositions/b.html"),
"compositions/b.html": subCompDoc("b", "compositions/a.html"),
},
[BLOCK("a"), BLOCK("b")],
).usedBlocks,
).toEqual(["a", "b"]);
});
// A mount the author commented out does not render, so it is not "used".
it("ignores a commented-out mount", () => {
expect(
usageOf(
{
"index.html": `<!doctype html><html><body><!-- ${mountTag("compositions/kept.html")} --></body></html>`,
"compositions/kept.html": subCompDoc("kept"),
},
[BLOCK("kept")],
),
).toEqual({ installed: ["kept"], usedBlocks: [], manifestUnreadable: false });
});
// Components are pasted inline, so there is no src to match. Reporting one as
// "used" would be a guess; reporting it as installed is a fact.
it("counts a component as installed but never as used", () => {
expect(
usageOf({ "index.html": entryDoc() }, [
{
name: "film-grain",
type: "hyperframes:component",
target: "compositions/components/film-grain.html",
},
]),
).toEqual({ installed: ["film-grain"], usedBlocks: [], manifestUnreadable: false });
});
it("drops a manifest name that is not a safe slug rather than sending it", () => {
expect(
usageOf({ "index.html": entryDoc() }, [
{ name: "/Users/someone/secret", type: "hyperframes:block", target: "compositions/x.html" },
BLOCK("fine"),
]).installed,
).toEqual(["fine"]);
});
it("never matches a manifest target that escapes the project directory", () => {
expect(
usageOf({ "index.html": entryDoc("compositions/kept.html") }, [
{ name: "escaping", type: "hyperframes:block", target: "../outside.html" },
]).usedBlocks,
).toEqual([]);
});
it("survives an entry file that does not exist", () => {
expect(usageOf({}, [BLOCK("kept")], "missing.html")).toEqual({
installed: ["kept"],
usedBlocks: [],
manifestUnreadable: false,
});
});
it("contributes nothing for a remote mount, which has no local file to match", () => {
expect(
usageOf({ "index.html": entryDoc("https://example.test/compositions/kept.html") }, [
BLOCK("kept"),
]).usedBlocks,
).toEqual([]);
});
// A degraded read must not enrol a catalog user into the no-catalog control
// group, which would bias the comparison toward "the catalog changes nothing".
it("distinguishes a corrupt manifest from a project that never used the catalog", () => {
expect(usageOfRawConfig("{ not valid json")).toEqual({
installed: [],
usedBlocks: [],
manifestUnreadable: true,
});
expect(usageOfRawConfig('{ "registry": "https://example.test" }')).toEqual({
installed: [],
usedBlocks: [],
manifestUnreadable: false,
});
expect(usageOfRawConfig(null)).toEqual({
installed: [],
usedBlocks: [],
manifestUnreadable: false,
});
});
});
+153
View File
@@ -0,0 +1,153 @@
/**
* Which catalog (registry) items a project installed, and which of them the
* composition being rendered actually reaches.
*
* `hyperframes add` is the only place that knows a file came from the registry
* installed files are plain composition HTML and carry no provenance marker
* so it records each item in `hyperframes.json`. Render reads that manifest
* back and walks the composition's `data-composition-src` tree, letting the
* render event report both halves: what the project pulled in, and what
* survived into the video.
*
* The delta is the part no add-time event can produce. `registry_item_added`
* says a block was installed; only this says it was then thrown away.
*/
import { readFileSync } from "node:fs";
import { isAbsolute, relative, resolve } from "node:path";
import { collectSubCompositionSrcs } from "@hyperframes/parsers/asset-resolution";
import { type RegistryItemRecord, readProjectConfigWithStatus } from "./projectConfig.js";
/** Installed catalog items, and the subset the rendered composition reaches. */
export interface CatalogUsage {
/**
* Every item name recorded by `hyperframes add`, sorted, deduped, slug-gated.
* Not truncated: the reporting cap belongs to whoever builds the event
* string, so a count taken from this array is the real number.
*/
installed: string[];
/**
* Installed `hyperframes:block` items whose file is reachable from the render
* entry. Always a subset of {@link installed}. Components are excluded: they
* are pasted inline into the user's own markup rather than mounted by src, so
* a component leaves no trace to match.
*/
usedBlocks: string[];
/**
* True when `hyperframes.json` exists but could not be read or parsed.
*
* A degraded read must not look like a project that never touched the
* catalog: the no-catalog cohort is the control this whole feature is
* measured against, and quietly enrolling failures into it biases the
* comparison toward "the catalog makes no difference".
*/
manifestUnreadable: boolean;
}
const EMPTY: CatalogUsage = Object.freeze({
installed: [],
usedBlocks: [],
manifestUnreadable: false,
});
const UNREADABLE: CatalogUsage = Object.freeze({
installed: [],
usedBlocks: [],
manifestUnreadable: true,
});
/**
* Cap on files visited while walking the sub-composition tree. A composition
* nests a handful of blocks; anything past this is a pathological or cyclic
* project, and telemetry must not turn into an unbounded filesystem crawl.
*/
const MAX_VISITED_FILES = 250;
/** Cap on a single file fed to the scanner, mirroring the composition census. */
const MAX_HTML_BYTES = 20 * 1024 * 1024;
/**
* Item names are slug-gated before they reach the anonymous event stream, the
* same guard `normalizeSkillSlug` applies to authoring skills: a custom or
* hand-edited registry must not be able to push paths, PII, or unbounded
* cardinality into telemetry. The two rules share a shape but not an owner
* a registry name and a skill slug are free to diverge.
*/
const REGISTRY_ITEM_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
/**
* Absolute paths of every composition file reachable from `entryPath` through
* `data-composition-src`, entry included.
*
* Two invariants are borrowed rather than re-derived, because getting either
* wrong silently reports a block that renders in every video as abandoned:
* references are collected by text scan (`collectSubCompositionSrcs`, so
* `<template>`-wrapped sub-compositions are visible), and each one resolves
* against the PROJECT ROOT at every nesting level, never the referencing
* file's directory. Both mirror the renderer's `parseSubCompositions`.
*
* Unreadable files are skipped rather than thrown: this feeds a telemetry
* property, and a render that produced a video must never fail on the way to
* reporting it.
*/
function reachableCompositions(projectDir: string, entryPath: string): Set<string> {
const seen = new Set<string>();
const queue = [resolve(entryPath)];
while (queue.length > 0 && seen.size < MAX_VISITED_FILES) {
const current = queue.shift()!;
if (seen.has(current)) continue;
seen.add(current);
let html: string;
try {
html = readFileSync(current, "utf-8");
} catch {
continue;
}
if (html.length > MAX_HTML_BYTES) continue;
for (const src of collectSubCompositionSrcs(html)) {
queue.push(resolve(projectDir, src));
}
}
return seen;
}
/** True when `target` (project-relative, per the manifest) is in `reachable`. */
function isReached(projectDir: string, target: string, reachable: Set<string>): boolean {
// A manifest target is written project-relative. Guard against an absolute
// or escaping one rather than resolving it against the wrong root.
if (isAbsolute(target)) return false;
const abs = resolve(projectDir, target);
if (relative(projectDir, abs).startsWith("..")) return false;
return reachable.has(abs);
}
function reportableNames(names: string[]): string[] {
return [...new Set(names.filter((name) => REGISTRY_ITEM_NAME.test(name)))].sort();
}
/**
* Read the project's catalog manifest and resolve it against the composition
* being rendered. Returns empty sets for a project that never ran
* `hyperframes add`, which is the honest answer: no catalog items, not unknown.
*/
export function summarizeCatalogUsage(projectDir: string, entryPath: string): CatalogUsage {
const { status, config } = readProjectConfigWithStatus(projectDir);
if (status === "unreadable") return UNREADABLE;
const items: RegistryItemRecord[] = config?.registryItems ?? [];
if (items.length === 0) return EMPTY;
const installed = reportableNames(items.map((item) => item.name));
if (installed.length === 0) return EMPTY;
const reachable = reachableCompositions(projectDir, entryPath);
const usedBlocks = reportableNames(
items
.filter(
(item) =>
item.type === "hyperframes:block" && isReached(projectDir, item.target, reachable),
)
.map((item) => item.name),
);
return { installed, usedBlocks, manifestUnreadable: false };
}
@@ -9,6 +9,7 @@ import {
projectConfigPath,
readProjectConfig,
resolveAutoProxy,
recordProjectRegistryItems,
seedProjectAuthoringSkill,
writeProjectConfig,
PROJECT_CONFIG_FILENAME,
@@ -364,4 +365,101 @@ describe("projectConfig", () => {
}
});
});
describe("recordProjectRegistryItems", () => {
const BLOCK = {
name: "data-chart",
type: "hyperframes:block",
target: "compositions/data-chart.html",
};
it("appends installed items to an existing config", () => {
const dir = tmp();
try {
writeProjectConfig(dir);
recordProjectRegistryItems(dir, [BLOCK]);
expect(loadProjectConfig(dir).registryItems).toEqual([BLOCK]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("dedupes by name, so re-adding an item does not grow the manifest", () => {
const dir = tmp();
try {
writeProjectConfig(dir);
recordProjectRegistryItems(dir, [BLOCK]);
recordProjectRegistryItems(dir, [BLOCK, { ...BLOCK, name: "bar-chart-race" }]);
expect(loadProjectConfig(dir).registryItems?.map((i) => i.name)).toEqual([
"data-chart",
"bar-chart-race",
]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
// The old guard compared object identity, and `add` always builds fresh
// records, so a redundant re-add always rewrote the file. Pinned through
// the file's own byte-level formatting: a rewrite would re-serialize it.
it("does not rewrite the file when every item is already recorded", () => {
const dir = tmp();
try {
const text = `{\n\t"registry": "https://example.com/r",\n\t"registryItems": [\n\t\t{ "name": "data-chart", "type": "hyperframes:block", "target": "compositions/data-chart.html" }\n\t]\n}\n`;
writeFileSync(projectConfigPath(dir), text, "utf-8");
recordProjectRegistryItems(dir, [{ ...BLOCK }]);
expect(readFileSync(projectConfigPath(dir), "utf-8")).toBe(text);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
// The config is normally committed, so an install must not rewrite keys it
// does not own or reflow the file.
it("preserves unknown keys and the file's own indentation", () => {
const dir = tmp();
try {
writeFileSync(
projectConfigPath(dir),
JSON.stringify({ registry: "https://example.com/r", customKey: 42 }, null, 4),
"utf-8",
);
recordProjectRegistryItems(dir, [BLOCK]);
const text = readFileSync(projectConfigPath(dir), "utf-8");
expect(text).toContain('"customKey": 42');
expect(text).toContain('\n "registry"');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("does not create a config for a project that has none", () => {
const dir = tmp();
try {
recordProjectRegistryItems(dir, [BLOCK]);
expect(readProjectConfig(dir)).toBeUndefined();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("leaves a corrupt config untouched", () => {
const dir = tmp();
try {
writeFileSync(projectConfigPath(dir), "{ not valid json", "utf-8");
recordProjectRegistryItems(dir, [BLOCK]);
expect(readFileSync(projectConfigPath(dir), "utf-8")).toBe("{ not valid json");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
// normalizeConfig rebuilds from a whitelist; an omission there would read
// as "this project never installed a catalog item".
it("survives a normalizeConfig round-trip", () => {
expect(normalizeConfig({ registryItems: [BLOCK] }).registryItems).toEqual([BLOCK]);
expect(
normalizeConfig({ registryItems: [{ name: "x" }] as never }).registryItems,
).toBeUndefined();
});
});
});
+144 -11
View File
@@ -33,6 +33,23 @@ export interface ProjectConfigMedia {
autoProxy?: boolean;
}
/**
* One catalog item installed into this project by `hyperframes add`.
*
* Installed files are plain composition HTML with no provenance marker, so
* this manifest is the only record that a file came from the registry. It is
* what lets a render report which catalog items a finished video actually
* used, instead of only which ones were once downloaded.
*/
export interface RegistryItemRecord {
/** Registry item name, e.g. "data-chart". */
name: string;
/** Registry item type, e.g. "hyperframes:block". */
type: string;
/** Primary installed file, relative to the project root. */
target: string;
}
export interface ProjectConfig {
$schema?: string;
/** Base URL of the registry to pull items from. */
@@ -49,6 +66,13 @@ export interface ProjectConfig {
* telemetry without the caller re-passing the flag.
*/
authoringSkill?: string;
/**
* Catalog items installed by `hyperframes add`, in install order. Append-only
* and deduped by name; removing an item from the project does not prune it,
* so a later render still reports it as installed-but-unused rather than
* silently forgetting it was ever tried.
*/
registryItems?: RegistryItemRecord[];
}
export const DEFAULT_PROJECT_CONFIG: ProjectConfig = {
@@ -69,16 +93,41 @@ export function projectConfigPath(projectDir: string): string {
return join(resolve(projectDir), PROJECT_CONFIG_FILENAME);
}
/**
* Why a project-config read produced no config.
*
* Most callers only need "did I get one", but a caller reporting on the config
* must not treat a broken file as an absent one: a project whose config cannot
* be read is not a project without a config, and collapsing the two enrolls a
* failure into whatever the empty case means.
*/
export type ProjectConfigReadStatus = "ok" | "missing" | "unreadable";
/** Read `hyperframes.json`, distinguishing an absent file from a broken one. */
export function readProjectConfigWithStatus(projectDir: string): {
status: ProjectConfigReadStatus;
config?: ProjectConfig;
} {
const path = projectConfigPath(projectDir);
let text: string;
try {
text = readFileSync(path, "utf-8");
} catch (error) {
// Anything other than "not there" (permissions, I/O) is a real failure to
// read a config that may well exist.
return { status: isFileNotFound(error) ? "missing" : "unreadable" };
}
try {
return { status: "ok", config: normalizeConfig(JSON.parse(text) as Partial<ProjectConfig>) };
} catch {
return { status: "unreadable" };
}
}
/** Read `hyperframes.json` from a project directory. */
export function readProjectConfig(projectDir: string): ProjectConfig | undefined {
const path = projectConfigPath(projectDir);
try {
const parsed = JSON.parse(readFileSync(path, "utf-8")) as Partial<ProjectConfig>;
return normalizeConfig(parsed);
} catch {
// Missing file or corrupt JSON → no config.
return undefined;
}
// Missing file or corrupt JSON → no config.
return readProjectConfigWithStatus(projectDir).config;
}
/**
@@ -104,9 +153,31 @@ export function normalizeConfig(partial: Partial<ProjectConfig>): ProjectConfig
// Slug-gate on read so a hand-edited or corrupt value never reaches the
// telemetry stream; an invalid slug simply drops the attribution.
authoringSkill: normalizeSkillSlug(partial.authoringSkill),
// Whitelist rebuild - an omission here silently drops the manifest on
// every config round-trip, which would read as "this project never
// installed a catalog item".
registryItems: normalizeRegistryItems(partial.registryItems),
};
}
/**
* Keep only well-formed records. A hand-edited or partially-written manifest
* degrades to the entries that still parse rather than failing a command that
* merely wanted to read the registry URL.
*/
function normalizeRegistryItems(raw: unknown): RegistryItemRecord[] | undefined {
if (!Array.isArray(raw)) return undefined;
const items = raw.filter(
(entry): entry is RegistryItemRecord =>
isJsonObject(entry) &&
typeof entry.name === "string" &&
entry.name !== "" &&
typeof entry.type === "string" &&
typeof entry.target === "string",
);
return items.length > 0 ? items : undefined;
}
/** Write `hyperframes.json` to a project directory. Overwrites if present. */
export function writeProjectConfig(
projectDir: string,
@@ -162,9 +233,10 @@ function isFileNotFound(error: unknown): boolean {
* empty slug is ignored. Best effort: a read-only or missing project directory
* never fails the render it rode in on.
*
* This is the only writer that touches an ALREADY EXISTING `hyperframes.json`
* (every other `writeProjectConfig` call site is guarded to write only when the
* file is absent), so it must not round-trip through {@link normalizeConfig}:
* One of two writers that touch an ALREADY EXISTING `hyperframes.json` (the
* other is {@link recordProjectRegistryItems}; every plain `writeProjectConfig`
* call site is guarded to write only when the file is absent), so it must not
* round-trip through {@link normalizeConfig}:
* that rebuilds the object from a field whitelist, which would drop keys it
* does not know about and materialize defaults the user never wrote. The file
* is normally committed, so a render must not introduce a diff beyond the one
@@ -210,3 +282,64 @@ export function seedProjectAuthoringSkill(projectDir: string, rawSkill: unknown)
// never a render blocker.
}
}
/**
* Append installed catalog items to `hyperframes.json` so a later render can
* report which of them the finished video actually used.
*
* Same in-place patch discipline as {@link seedProjectAuthoringSkill}, and for
* the same reason: this writes an ALREADY EXISTING, normally committed config,
* so it must not round-trip through {@link normalizeConfig} (a whitelist
* rebuild would drop unknown keys and materialize defaults the user never
* wrote). Existing entries are kept and deduped by name, so re-adding an item
* does not grow the file and a manually pruned entry is not resurrected twice.
*
* Best effort throughout: a read-only project, a missing config, or corrupt
* JSON must never fail the `add` it rode in on.
*/
export function recordProjectRegistryItems(
projectDir: string,
items: readonly RegistryItemRecord[],
): void {
if (items.length === 0) return;
const path = projectConfigPath(projectDir);
let text: string;
try {
text = readFileSync(path, "utf-8");
} catch {
// No config to patch. `add` outside an initialized project is a valid
// flow; it simply leaves no manifest behind.
return;
}
try {
const parsed: unknown = JSON.parse(text);
if (!isJsonObject(parsed)) return;
const existing = normalizeRegistryItems(parsed.registryItems) ?? [];
const byName = new Map(existing.map((item) => [item.name, item]));
for (const item of items) byName.set(item.name, item);
// Compare by value, not identity: `add` always constructs fresh record
// objects, so an identity check would never match and re-adding an
// unchanged item would rewrite the file on every install.
const merged = [...byName.values()];
const unchanged =
merged.length === existing.length &&
merged.every((item, index) => {
const prior = existing[index];
return (
prior !== undefined &&
prior.name === item.name &&
prior.type === item.type &&
prior.target === item.target
);
});
if (unchanged) return;
parsed.registryItems = merged;
const indent = /\n([ \t]+)"/.exec(text)?.[1] ?? " ";
writeFileSync(path, JSON.stringify(parsed, null, indent) + "\n", "utf-8");
} catch {
// Corrupt JSON or a read-only file: the manifest is telemetry provenance,
// never an install blocker.
}
}
@@ -0,0 +1,76 @@
/**
* `docs/schema/hyperframes.json` is authored by hand (sync-schemas.ts mirrors
* only the registry schemas and skips this one), sets `additionalProperties:
* false` at both levels, and is the schema every generated `hyperframes.json`
* points at. So a new config key that lands in code but not in the schema turns
* a valid, committed config into one that fails validation in any schema-aware
* editor, with nothing in CI to notice.
*
* This pins the two together by validating a config that exercises every key
* the CLI can write, nested objects included. A key-name check alone is not
* enough: `registryItems` is an array of objects with their own
* `additionalProperties: false`, so a fourth field on `RegistryItemRecord`
* would recreate the same break one level down.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { Ajv2020 } from "ajv/dist/2020.js";
import {
DEFAULT_PROJECT_CONFIG,
type ProjectConfig,
type RegistryItemRecord,
} from "./projectConfig.js";
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../../..");
const SCHEMA_PATH = resolve(REPO_ROOT, "docs/schema/hyperframes.json");
/**
* One record carrying every field the CLI writes. Typed, so a new field on
* `RegistryItemRecord` is a compile error here until it is listed, and then a
* validation failure until it is declared in the schema.
*/
const EVERY_RECORD_FIELD: Required<RegistryItemRecord> = {
name: "data-chart",
type: "hyperframes:block",
target: "compositions/data-chart.html",
};
/** A config exercising every key the CLI can write, at every level. */
const EVERY_WRITTEN_KEY: Required<ProjectConfig> = {
$schema: "https://hyperframes.heygen.com/schema/hyperframes.json",
registry: "https://example.test/registry",
paths: DEFAULT_PROJECT_CONFIG.paths,
media: { autoProxy: true },
authoringSkill: "product-launch-video",
registryItems: [EVERY_RECORD_FIELD],
};
describe("hyperframes.json schema", () => {
const schema = JSON.parse(readFileSync(SCHEMA_PATH, "utf-8")) as Record<string, unknown>;
const validate = new Ajv2020({ strict: false, validateFormats: false }).compile(schema);
it("accepts a config using every key the CLI can write", () => {
// Guard the guard: without a closed schema at both levels the assertion
// below would pass for an undeclared key rather than failing.
expect(schema.additionalProperties).toBe(false);
const items = (schema.properties as Record<string, { items?: Record<string, unknown> }>)
.registryItems?.items;
expect(items?.additionalProperties).toBe(false);
expect(validate(EVERY_WRITTEN_KEY), JSON.stringify(validate.errors)).toBe(true);
});
// Proves the check above can fail, rather than passing because ajv was
// handed something it never rejects.
it("rejects a key the schema does not declare, at either level", () => {
expect(validate({ ...EVERY_WRITTEN_KEY, unknownTopLevelKey: 1 })).toBe(false);
expect(
validate({
...EVERY_WRITTEN_KEY,
registryItems: [{ ...EVERY_RECORD_FIELD, unknownItemKey: 1 }],
}),
).toBe(false);
});
});
+4 -8
View File
@@ -7,6 +7,7 @@ import { checkSubCompositionUsability } from "@hyperframes/parsers/sub-compositi
import { parseHTML } from "linkedom";
import {
cleanAssetUrl,
collectSubCompositionSrcs,
isRemoteOrInlineUrl,
isUnresolvedAssetPlaceholder,
isWithinProjectRoot,
@@ -588,14 +589,9 @@ function lintMissingOrEmptySubComposition(
// fallow-ignore-next-line complexity
const walk = (html: string): void => {
const compositionSrcRe = /<[^>]*\bdata-composition-src\s*=\s*["']([^"']+)["'][^>]*>/gi;
const scannable = maskNonScannableRanges(html);
let match: RegExpExecArray | null;
while ((match = compositionSrcRe.exec(scannable)) !== null) {
const srcPath = (match[1] ?? "").trim();
if (!srcPath) continue;
if (isUnresolvedAssetPlaceholder(srcPath)) continue; // __UPPER__ placeholder or late-bound templating token
// Shared scanner — see collectSubCompositionSrcs for why this must be a
// text scan rather than a DOM query (template content is inert).
for (const srcPath of collectSubCompositionSrcs(html)) {
// data-composition-src is always written root-relative (even from a
// nested sub-composition) — matches the resolution the renderer uses
// in packages/producer/src/services/htmlCompiler.ts (parseSubCompositions
+59 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { isUnresolvedAssetPlaceholder, maskNonScannableRanges } from "./assetResolution.js";
import {
collectSubCompositionSrcs,
isUnresolvedAssetPlaceholder,
maskNonScannableRanges,
} from "./assetResolution.js";
describe("maskNonScannableRanges", () => {
it("masks complete comments without changing offsets", () => {
@@ -55,3 +59,57 @@ describe("isUnresolvedAssetPlaceholder", () => {
}
});
});
describe("collectSubCompositionSrcs", () => {
it("finds mounts inside a template, which a DOM query cannot see", () => {
const html =
'<!doctype html><html><body><div data-composition-src="compositions/a.html"></div>' +
'<template id="t"><div data-composition-src="compositions/b.html"></div></template></body></html>';
expect(collectSubCompositionSrcs(html)).toEqual(["compositions/a.html", "compositions/b.html"]);
});
it("skips commented-out, scripted, and styled mounts", () => {
const html =
'<!-- <div data-composition-src="commented.html"></div> -->' +
"<script>const s = '<div data-composition-src=\"scripted.html\"></div>';</script>" +
'<style>/* <div data-composition-src="styled.html"></div> */</style>' +
'<div data-composition-src="real.html"></div>';
expect(collectSubCompositionSrcs(html)).toEqual(["real.html"]);
});
it("skips build-time placeholders and dedupes repeats", () => {
const html =
'<div data-composition-src="__SCENE__"></div>' +
'<div data-composition-src="{{scene}}"></div>' +
'<div data-composition-src="a.html"></div><div data-composition-src="a.html"></div>';
expect(collectSubCompositionSrcs(html)).toEqual(["a.html"]);
});
// A remote mount names no file on disk, and every caller resolves what comes
// back against the project root. Letting one through yields a nonsense path
// (`<projectDir>/https:/host/a.html`): a false "does not exist" for lint, and
// a wasted slot against the telemetry walk's file budget.
it("drops remote and inline mounts, keeping local ones", () => {
const html =
'<div data-composition-src="https://host/remote.html"></div>' +
'<div data-composition-src="//host/protocol-relative.html"></div>' +
'<div data-composition-src="data:text/html,inline"></div>' +
'<div data-composition-src="compositions/local.html"></div>';
expect(collectSubCompositionSrcs(html)).toEqual(["compositions/local.html"]);
});
it("ignores an unterminated final tag and an attribute outside any tag", () => {
expect(collectSubCompositionSrcs('<div data-composition-src="a.html"')).toEqual([]);
expect(collectSubCompositionSrcs('data-composition-src="a.html"')).toEqual([]);
});
// Regression guard, and it needs no timing assertion to bite: the previous
// whole-file regex had two open-ended `[^>]*` spans, which is quadratic on
// input full of `<` with no `>`. At 1MB that ran for minutes, so this case
// failed on the suite timeout. This scan walks tag by tag and is linear.
// The function is on the render-plan path, so a truncated download or a blob
// of stray `<` must not be able to hang a render before it starts.
it("stays fast on a megabyte of unterminated tag openings", () => {
expect(collectSubCompositionSrcs("<".repeat(1024 * 1024))).toEqual([]);
});
});
+69
View File
@@ -42,6 +42,75 @@ export function isUnresolvedAssetPlaceholder(rawSrc: string): boolean {
return /^__[A-Z_]+__$/.test(rawSrc.trim()) || hasUnresolvedTemplatingToken(rawSrc);
}
/** `data-composition-src="..."`, matched within a single already-delimited tag. */
const COMPOSITION_SRC_ATTR = /\bdata-composition-src\s*=\s*["']([^"']+)["']/i;
/**
* Every `data-composition-src` reference in one composition file's raw text, in
* document order, deduped. The single owner of "which sub-compositions does
* this file mount", so lint, telemetry, and any future scanner cannot drift
* apart on the answer.
*
* Text-scanning rather than DOM-walking, and that is the load-bearing choice.
* Every sub-composition except the render entry is authored inside a
* `<template>` (the root index.html is forbidden from using that wrapper;
* everything else prefers it). Template content is inert: it lives under
* `template.content`, not the live document, so `document.querySelectorAll`
* on a raw sub-composition file finds nothing and every nested reference
* disappears. The renderer only gets away with a DOM scan because it recurses
* on COMPILED html, where the wrapper is already gone.
*
* Callers must resolve each value against the PROJECT ROOT, never the
* referencing file's directory: `data-composition-src` is root-relative at
* every nesting level (see `parseSubCompositions` in htmlCompiler.ts).
*
* Comments, `<style>`, and `<script>` bodies are masked first so a
* commented-out mount is not counted as a real one. Build-time placeholders and
* remote or inline URLs are dropped: neither names a file on disk, and every
* caller resolves what comes back against the project root.
*
* The scan walks tag by tag with `indexOf` rather than running one regex with
* two open-ended `[^>]*` spans across the whole file. That shape is quadratic:
* on input full of `<` with no `>`, every `<` starts a scan to end-of-string
* that then backtracks, measured at 41ms / 165ms / 660ms / 2640ms for 10k /
* 20k / 40k / 80k characters. This function runs on every render (via the
* render plan), so a truncated download or a blob full of stray `<` would hang
* the plan step before any video is produced. Bounding each regex to one
* already-delimited tag makes the whole scan linear.
*/
export function collectSubCompositionSrcs(html: string): string[] {
const scannable = maskNonScannableRanges(html);
const srcs: string[] = [];
const seen = new Set<string>();
let cursor = 0;
while (cursor < scannable.length) {
const open = scannable.indexOf("<", cursor);
if (open === -1) break;
const close = scannable.indexOf(">", open + 1);
// An unterminated final tag is not a tag. The previous whole-file regex
// also required a closing `>`, so this drops nothing it used to find.
if (close === -1) break;
cursor = close + 1;
const match = COMPOSITION_SRC_ATTR.exec(scannable.slice(open, cursor));
if (!match) continue;
const src = (match[1] ?? "").trim();
if (!src || seen.has(src)) continue;
// __UPPER__ placeholder or late-bound templating token — not a real reference.
if (isUnresolvedAssetPlaceholder(src)) continue;
// A remote or inline mount names no file on disk. Every caller resolves
// these against the project root, so letting one through produces a
// nonsense path (`<projectDir>/https:/host/a.html`) that then reads as a
// missing local file: a false "does not exist" for lint, and a wasted
// visit against the telemetry walk's file budget.
if (isRemoteOrInlineUrl(src)) continue;
seen.add(src);
srcs.push(src);
}
return srcs;
}
export function cleanAssetUrl(url: string): string {
return url.trim().split(/[?#]/, 1)[0] ?? "";
}