Merge pull request #2048 from heygen-com/07-07-feat_sdk_variable_usage_scan_preview-values_adapter_seam

feat(sdk): variable usage scan + preview-values adapter seam
This commit is contained in:
James Russo
2026-07-09 13:32:04 -07:00
committed by GitHub
11 changed files with 508 additions and 1 deletions
+2
View File
@@ -23,7 +23,9 @@ export {
parseCompositionVariables,
isCompositionVariable,
isScalarVariableValue,
scanVariableUsage,
} from "@hyperframes/parsers/composition";
export type { VariableUsageScan } from "@hyperframes/parsers/composition";
export { getVariables, readDeclaredDefaults } from "./runtime/getVariables.js";
export {
+1
View File
@@ -15,3 +15,4 @@ export {
isCompositionVariable,
isScalarVariableValue,
} from "./compositionVariables.js";
export { scanVariableUsage, type VariableUsageScan } from "./variableUsage.js";
+1
View File
@@ -12,6 +12,7 @@ export { queryByAttr } from "./utils/cssSelector.js";
// path helpers live behind the ./asset-paths subpath to keep this entry
// browser-safe.
export { decodeUrlPathVariants } from "./utils/urlPath.js";
export { scanVariableUsage, type VariableUsageScan } from "./variableUsage.js";
export {
FONT_ALIAS_MAP,
FONT_ALIAS_KEYS,
@@ -0,0 +1,78 @@
import { describe, it, expect } from "vitest";
import { scanVariableUsage } from "./variableUsage.js";
describe("scanVariableUsage", () => {
it("collects ids from direct destructuring with defaults", () => {
const scan = scanVariableUsage(`
const { title = "Untitled", accent } = __hyperframes.getVariables();
document.querySelector("h1").textContent = title;
`);
expect(scan.usedIds).toEqual(["title", "accent"]);
expect(scan.scanIncomplete).toBe(false);
});
it("handles bare getVariables (sub-comp scoped shadow) and window-qualified calls", () => {
expect(scanVariableUsage(`const { a } = getVariables();`).usedIds).toEqual(["a"]);
expect(scanVariableUsage(`const { b } = window.__hyperframes.getVariables();`).usedIds).toEqual(
["b"],
);
});
it("collects ids from member access on the call and on an alias", () => {
const scan = scanVariableUsage(`
const x = __hyperframes.getVariables().headline;
const vars = __hyperframes.getVariables();
el.style.color = vars.accent;
const size = vars["font-size"];
const { title } = vars;
`);
expect(scan.usedIds).toEqual(["headline", "accent", "font-size", "title"]);
expect(scan.scanIncomplete).toBe(false);
});
it("collects string-literal destructuring keys", () => {
const scan = scanVariableUsage(`const { "kebab-id": kebab } = getVariables();`);
expect(scan.usedIds).toEqual(["kebab-id"]);
expect(scan.scanIncomplete).toBe(false);
});
it("flags dynamic access as incomplete without losing static ids", () => {
const scan = scanVariableUsage(`
const vars = getVariables();
const known = vars.known;
const dynamic = vars[someKey];
`);
expect(scan.usedIds).toEqual(["known"]);
expect(scan.scanIncomplete).toBe(true);
});
it("flags rest spreads, escaping values, and chained aliases", () => {
expect(scanVariableUsage(`const { a, ...rest } = getVariables();`).scanIncomplete).toBe(true);
expect(scanVariableUsage(`render(getVariables());`).scanIncomplete).toBe(true);
expect(
scanVariableUsage(`const vars = getVariables(); const v2 = vars; use(v2.x);`).scanIncomplete,
).toBe(true);
});
it("flags unparseable scripts as incomplete", () => {
const scan = scanVariableUsage(`const { = broken`);
expect(scan.usedIds).toEqual([]);
expect(scan.scanIncomplete).toBe(true);
});
it("ignores unrelated code and same-named object keys", () => {
const scan = scanVariableUsage(`
const vars = getVariables();
const config = { vars: 1, other: vars.real };
gsap.timeline({ paused: true });
`);
expect(scan.usedIds).toEqual(["real"]);
expect(scan.scanIncomplete).toBe(false);
});
it("returns empty for scripts that never touch variables", () => {
const scan = scanVariableUsage(`gsap.timeline({ paused: true }).to(".x", { opacity: 1 });`);
expect(scan.usedIds).toEqual([]);
expect(scan.scanIncomplete).toBe(false);
});
});
+172
View File
@@ -0,0 +1,172 @@
/**
* Browser-safe static scan for composition-variable reads in script text.
*
* Compositions read variables by calling the runtime API — `getVariables()`
* bare (sub-comp scoped shadow) or via `__hyperframes.getVariables()` /
* `window.__hyperframes.getVariables()` — and there is no DOM-attribute
* binding to scan, so "which variables does this composition use" can only be
* derived from the scripts. This is a best-effort static analysis: the
* patterns agents actually write (destructuring, member access, a single
* alias variable) resolve to ids; anything opaque flips `scanIncomplete`
* so consumers can present usage as a lower bound instead of a fact.
*
* AST nodes are handled untyped (same convention as gsapParserAcorn.ts) —
* acorn's structural types don't survive acorn-walk's visitor signatures.
*/
import * as acorn from "acorn";
import * as acornWalk from "acorn-walk";
export interface VariableUsageScan {
/** Variable ids statically read by the script, in first-seen order. */
usedIds: string[];
/**
* True when the script accesses variables in a way the scan cannot resolve
* (computed keys, rest spreads, the values object escaping into a call…) or
* when the script fails to parse — usedIds is then a lower bound.
*/
scanIncomplete: boolean;
}
interface Sink {
use(id: string): void;
incomplete(): void;
}
// oxlint-disable no-explicit-any -- untyped acorn AST traversal, see header
function isGetVariablesCallee(callee: any): boolean {
if (callee?.type === "Identifier") return callee.name === "getVariables";
if (callee?.type === "MemberExpression" && !callee.computed) {
return callee.property?.type === "Identifier" && callee.property.name === "getVariables";
}
return false;
}
/** Collect ids from an ObjectPattern destructuring of the values object. */
// Exhaustive AST-node classification — branchy by nature, same as gsapParserAcorn.
// fallow-ignore-next-line complexity
function collectFromObjectPattern(pattern: any, out: Sink): void {
for (const prop of pattern.properties ?? []) {
if (prop?.type === "RestElement") {
out.incomplete();
continue;
}
if (prop?.type !== "Property") continue;
if (prop.computed === true) {
out.incomplete();
} else if (prop.key?.type === "Identifier") {
out.use(String(prop.key.name));
} else if (prop.key?.type === "Literal" && typeof prop.key.value === "string") {
out.use(prop.key.value);
} else {
out.incomplete();
}
}
}
/** Collect an id from a MemberExpression reading the values object. */
function collectFromMemberAccess(member: any, out: Sink): void {
if (member.computed !== true && member.property?.type === "Identifier") {
out.use(String(member.property.name));
} else if (
member.computed === true &&
member.property?.type === "Literal" &&
typeof member.property.value === "string"
) {
out.use(member.property.value);
} else {
out.incomplete();
}
}
/**
* Classify one read of the values object (a getVariables() call result or an
* alias holding it) by its immediate syntactic context. Returns the alias
* name when the value is bound to a plain variable (`const vars = …`).
*/
// fallow-ignore-next-line complexity
function classifyValueRead(parent: any, valueNode: any, out: Sink): string | null {
if (!parent || parent.type === "ExpressionStatement") {
// Bare statement — value unused, nothing read.
return null;
}
if (parent.type === "MemberExpression" && parent.object === valueNode) {
collectFromMemberAccess(parent, out);
return null;
}
if (parent.type === "VariableDeclarator" && parent.init === valueNode) {
if (parent.id?.type === "ObjectPattern") {
collectFromObjectPattern(parent.id, out);
return null;
}
if (parent.id?.type === "Identifier") return String(parent.id.name);
out.incomplete();
return null;
}
// The values object escapes (argument, return, spread, assignment…) —
// reads beyond this point are invisible to the scan.
out.incomplete();
return null;
}
export function scanVariableUsage(scriptText: string): VariableUsageScan {
const usedIds: string[] = [];
const seen = new Set<string>();
let scanIncomplete = false;
const sink: Sink = {
use(id: string) {
if (!seen.has(id)) {
seen.add(id);
usedIds.push(id);
}
},
incomplete() {
scanIncomplete = true;
},
};
let ast: any;
try {
ast = acorn.parse(scriptText, { ecmaVersion: "latest", sourceType: "script" });
} catch {
return { usedIds: [], scanIncomplete: true };
}
const aliases = new Set<string>();
// Pass 1: classify every getVariables() call by its parent context.
acornWalk.ancestor(ast, {
CallExpression(node: any, _: unknown, ancestors: any[]) {
if (!isGetVariablesCallee(node.callee)) return;
const parent = ancestors[ancestors.length - 2];
const alias = classifyValueRead(parent, node, sink);
if (alias) aliases.add(alias);
},
} as any);
// Pass 2: classify every reference to an alias of the values object.
// Scope-naive by design: an unrelated same-named identifier can only make
// the scan report extra ids or flip scanIncomplete, never miss a read.
if (aliases.size > 0) {
acornWalk.ancestor(ast, {
// fallow-ignore-next-line complexity
Identifier(node: any, _: unknown, ancestors: any[]) {
if (!aliases.has(String(node.name))) return;
const parent = ancestors[ancestors.length - 2];
if (!parent) return;
// Skip the declarator that introduced the alias and property-position
// identifiers that merely share the name.
if (parent.type === "VariableDeclarator" && parent.id === node) return;
if (parent.type === "MemberExpression" && parent.property === node) return;
if (parent.type === "Property" && parent.key === node && parent.computed !== true) return;
// Chained aliases (const v2 = vars) are not followed — flag instead
// of silently missing reads through the second name.
if (classifyValueRead(parent, node, sink)) sink.incomplete();
},
} as any);
}
return { usedIds, scanIncomplete };
}
+9
View File
@@ -84,4 +84,13 @@ export interface PreviewAdapter {
* subscription first. Returns an unsubscribe.
*/
attachSync(comp: Composition): () => void;
/**
* Optional: apply composition-variable values to the preview so it renders
* as `window.__hfVariables` injection would at render time (values must be
* visible to the runtime BEFORE composition scripts run — typically a
* preview reload with injection, not a live poke). Pass null to restore
* declared defaults. Values are ephemeral preview state, never persisted.
*/
setPreviewVariables?(values: Record<string, unknown> | null): void;
}
+4
View File
@@ -597,6 +597,7 @@ function handleSetTiming(
}
// Flush accumulated GSAP script changes as a single patch pair.
// fallow-ignore-next-line code-duplication
if (origScript && currentScript && currentScript !== origScript) {
setGsapScript(parsed.document, currentScript);
const gsapResult = gsapScriptChange(origScript, currentScript);
@@ -666,6 +667,7 @@ function handleRemoveElement(parsed: ParsedDocument, ids: HfId[]): MutationResul
}
}
// fallow-ignore-next-line code-duplication
if (origScript && currentScript && currentScript !== origScript) {
setGsapScript(parsed.document, currentScript);
const gsapResult = gsapScriptChange(origScript, currentScript);
@@ -1629,6 +1631,7 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
case "removeElement": {
const ids = targets(op.target);
if (ids.length === 0) return canErr("E_TARGET_NOT_FOUND", "No target ids provided.");
// fallow-ignore-next-line code-duplication
const missing = ids.filter((id) => resolveScoped(parsed.document, id) === null);
if (missing.length > 0)
return canErr(
@@ -1664,6 +1667,7 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
}
case "reorderElements": {
if (op.entries.length === 0) return CAN_OK;
// fallow-ignore-next-line code-duplication
const missing = op.entries
.map((e) => e.target)
.filter((id) => resolveScoped(parsed.document, id) === null);
+2
View File
@@ -28,7 +28,9 @@ export type {
CompositionVariable,
CompositionVariableType,
VariableValidationIssue,
VariableUsageScan,
} from "@hyperframes/core/variables";
export type { VariableUsageReport } from "./types.js";
export { UnsupportedOpError } from "./engine/mutate.js";
+80 -1
View File
@@ -27,6 +27,7 @@ import type {
PersistErrorEvent,
SelectionProxy,
ElementHandle,
VariableUsageReport,
} from "./types.js";
import { ORIGIN_APPLY_PATCHES, ORIGIN_LOCAL } from "./types.js";
import { buildRoots, flatElements, parsedAnimationIds } from "./document.js";
@@ -39,7 +40,11 @@ import { readVariableDefault, listVariableDecls } from "./engine/variableModel.j
import { extractGsapLabels } from "@hyperframes/core/gsap-parser-acorn";
import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler/html-document";
import { parseStartExpression } from "@hyperframes/core/runtime/start-expression";
import { readDeclaredDefaults, validateVariables } from "@hyperframes/core/variables";
import {
readDeclaredDefaults,
validateVariables,
scanVariableUsage,
} from "@hyperframes/core/variables";
import type { CompositionVariable, VariableValidationIssue } from "@hyperframes/core/variables";
import { readVariableDeclarations } from "./engine/variableModel.js";
import { serializeDocument } from "./engine/serialize.js";
@@ -71,6 +76,11 @@ export interface OpenCompositionOptions {
// ─── Implementation ───────────────────────────────────────────────────────────
/** Escape a string for literal use inside a RegExp. */
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
class CompositionImpl implements Composition {
private readonly parsed: ParsedDocument;
private readonly persist: PersistAdapter | undefined;
@@ -220,6 +230,75 @@ class CompositionImpl implements Composition {
return validateVariables(values, this.getVariableDeclarations());
}
/**
* Script scans are content-keyed (same rationale as _gsapLabelCache): the
* panel recomputes usage on every preview reload, and unchanged script text
* is the common case never pay a second acorn parse for identical input.
*/
private _variableUsageScanCache = new Map<string, ReturnType<typeof scanVariableUsage>>();
// Scan/merge dispatcher — same complexity class as the suppressed
// variableUsage.ts classifiers it drives.
// fallow-ignore-next-line complexity
getVariableUsage(): VariableUsageReport {
const usedIds: string[] = [];
const seen = new Set<string>();
let scanIncomplete = false;
const freshCache = new Map<string, ReturnType<typeof scanVariableUsage>>();
// Inline scripts only — external src scripts aren't part of the document model.
for (const script of Array.from(this.parsed.document.querySelectorAll("script"))) {
if (script.getAttribute("src")) continue;
const text = script.textContent ?? "";
// Direct global reads (window.__hfVariables / __hfVariablesByComp) are
// invisible to the getVariables() scanner — the report must degrade to
// a lower bound instead of confidently claiming declarations unused.
if (text.includes("__hfVariables")) scanIncomplete = true;
if (!text.includes("getVariables")) continue; // cheap pre-filter before an acorn parse
const scan = this._variableUsageScanCache.get(text) ?? scanVariableUsage(text);
freshCache.set(text, scan);
scanIncomplete = scanIncomplete || scan.scanIncomplete;
for (const id of scan.usedIds) {
if (!seen.has(id)) {
seen.add(id);
usedIds.push(id);
}
}
}
this._variableUsageScanCache = freshCache;
const declaredIds = this.getVariableDeclarations().map((d) => d.id);
// The CSS compat channel counts as usage: a variable consumed only via
// var(--id) in stylesheets or inline styles must not be badged unused
// (removing it also removes the --{id} root prop and breaks the binding).
const cssParts: string[] = [];
for (const styleEl of Array.from(this.parsed.document.querySelectorAll("style"))) {
cssParts.push(styleEl.textContent ?? "");
}
for (const el of Array.from(this.parsed.document.querySelectorAll("[style]"))) {
cssParts.push(el.getAttribute("style") ?? "");
}
const cssText = cssParts.join("\n");
// Match var(--id) only at a custom-property-name boundary: the id must be
// followed by whitespace, a comma (fallback), or the closing paren — so id
// "foo" is NOT counted as used by an unrelated var(--foo-header). Ids are
// regex-escaped because a value read from disk may predate can()'s
// /^[A-Za-z_][A-Za-z0-9_-]*$/ enforcement and carry metacharacters.
const cssUsed = (id: string) =>
new RegExp(`var\\(\\s*--${escapeRegExp(id)}[\\s,)]`).test(cssText);
const declaredSet = new Set(declaredIds);
return {
usedIds,
unusedDeclarations: declaredIds.filter((id) => !seen.has(id) && !cssUsed(id)),
undeclaredReads: usedIds.filter((id) => !declaredSet.has(id)),
scanIncomplete,
};
}
setPreviewVariables(values: Record<string, unknown> | null): boolean {
if (!this.preview?.setPreviewVariables) return false;
this.preview.setPreviewVariables(values);
return true;
}
// ── WS-C: timing accessors + typed setHold ───────────────────────────────────
/**
@@ -0,0 +1,128 @@
/**
* getVariableUsage (declaration script-scan cross-reference) and
* setPreviewVariables (preview adapter delegation).
*/
import { describe, it, expect } from "vitest";
import { openComposition } from "./session.js";
import type { PreviewAdapter } from "./adapters/types.js";
const DECLS = JSON.stringify([
{ id: "title", type: "string", label: "Title", default: "Hello" },
{ id: "accent", type: "color", label: "Accent", default: "#00C3FF" },
{ id: "orphan", type: "string", label: "Never read", default: "x" },
]);
function doc(script: string, decls: string | null = DECLS): string {
const attr = decls ? ` data-composition-variables='${decls}'` : "";
return `<!DOCTYPE html>
<html${attr}>
<body>
<div data-hf-id="hf-stage" data-hf-root data-duration="5">
<h1 data-hf-id="hf-title">Hello</h1>
</div>
<script>${script}</script>
</body>
</html>`;
}
describe("getVariableUsage", () => {
it("cross-references used, unused, and undeclared ids", async () => {
const comp = await openComposition(
doc(`
const { title, ghost } = __hyperframes.getVariables();
document.querySelector("h1").textContent = title;
const vars = __hyperframes.getVariables();
el.style.color = vars.accent;
`),
);
const usage = comp.getVariableUsage();
expect(usage.usedIds).toEqual(["title", "ghost", "accent"]);
expect(usage.unusedDeclarations).toEqual(["orphan"]);
expect(usage.undeclaredReads).toEqual(["ghost"]);
expect(usage.scanIncomplete).toBe(false);
});
it("reports all declarations unused when no script reads variables", async () => {
const comp = await openComposition(doc(`gsap.timeline({ paused: true });`));
const usage = comp.getVariableUsage();
expect(usage.usedIds).toEqual([]);
expect(usage.unusedDeclarations).toEqual(["title", "accent", "orphan"]);
expect(usage.scanIncomplete).toBe(false);
});
it("propagates scanIncomplete from opaque access", async () => {
const comp = await openComposition(
doc(`const vars = getVariables(); const v = vars[pickKey()];`),
);
expect(comp.getVariableUsage().scanIncomplete).toBe(true);
});
it("counts a variable used only via var(--id) in a <style> block as used", async () => {
const comp = await openComposition(
doc(`gsap.timeline({ paused: true });`).replace(
"<body>",
`<body><style>.stage { background: var(--accent); }</style>`,
),
);
const usage = comp.getVariableUsage();
// accent is CSS-consumed, so it must NOT be badged unused.
expect(usage.unusedDeclarations).toEqual(["title", "orphan"]);
});
it("does not count var(--id) as usage of a prefix-extended custom property", async () => {
// `accent` must not be marked used by an unrelated `var(--accent-shadow)`.
const comp = await openComposition(
doc(`gsap.timeline({ paused: true });`).replace(
"<body>",
`<body><style>.stage { box-shadow: 0 0 4px var(--accent-shadow); }</style>`,
),
);
const usage = comp.getVariableUsage();
expect(usage.unusedDeclarations).toContain("accent");
});
it("handles compositions with no declarations and no scripts", async () => {
const comp = await openComposition(
`<!DOCTYPE html><html><body><div data-hf-id="hf-stage" data-hf-root data-duration="5"><p data-hf-id="hf-p">x</p></div></body></html>`,
);
expect(comp.getVariableUsage()).toEqual({
usedIds: [],
unusedDeclarations: [],
undeclaredReads: [],
scanIncomplete: false,
});
});
});
describe("setPreviewVariables", () => {
function makeAdapter(calls: Array<Record<string, unknown> | null>): PreviewAdapter {
return {
elementAtPoint: () => null,
applyDraft: () => {},
commitPreview: () => {},
cancelPreview: () => {},
select: () => {},
on: () => () => {},
setPreviewVariables: (values) => calls.push(values),
};
}
it("delegates to the preview adapter and reports handling", async () => {
const calls: Array<Record<string, unknown> | null> = [];
const comp = await openComposition(doc(""), { preview: makeAdapter(calls) });
expect(comp.setPreviewVariables({ title: "Custom" })).toBe(true);
expect(comp.setPreviewVariables(null)).toBe(true);
expect(calls).toEqual([{ title: "Custom" }, null]);
});
it("returns false without an adapter or without adapter support", async () => {
const noAdapter = await openComposition(doc(""));
expect(noAdapter.setPreviewVariables({ a: 1 })).toBe(false);
const bare = makeAdapter([]);
delete bare.setPreviewVariables;
const unsupported = await openComposition(doc(""), { preview: bare });
expect(unsupported.setPreviewVariables({ a: 1 })).toBe(false);
});
});
+31
View File
@@ -1,5 +1,24 @@
import type { CompositionVariable, VariableValidationIssue } from "@hyperframes/core/variables";
/**
* Cross-referenced variable usage for a whole composition: the per-script
* static scans merged and compared against the declared schema.
*/
export interface VariableUsageReport {
/** Variable ids read by composition scripts (static analysis, first-seen order). */
usedIds: string[];
/** Declared ids never read by any script. */
unusedDeclarations: string[];
/** Ids read by scripts but missing from data-composition-variables. */
undeclaredReads: string[];
/**
* True when any script accesses variables opaquely (computed keys, escaping
* values object) usedIds is then a lower bound and unusedDeclarations
* may be false positives.
*/
scanIncomplete: boolean;
}
// ─── Document model ───────────────────────────────────────────────────────────
/** Full DOM-level view of one editable element. Built by the SDK adaptation layer. */
@@ -469,6 +488,18 @@ export interface Composition {
* `--strict-variables`). Read-only does not dispatch.
*/
validateVariableValues(values: Record<string, unknown>): VariableValidationIssue[];
/**
* Cross-reference the declared schema against a static scan of every inline
* composition script (getVariables() reads). Read-only does not dispatch.
*/
getVariableUsage(): VariableUsageReport;
/**
* Apply variable values to the preview surface (ephemeral never written
* to the document; use setVariableValue to persist a default). Pass null to
* restore declared defaults. No-op when the preview adapter doesn't
* implement setPreviewVariables; returns whether the adapter handled it.
*/
setPreviewVariables(values: Record<string, unknown> | null): boolean;
/**
* Read enter/exit times and GSAP labels for every timed element (WS-C).
* Derives enterAt/exitAt using the same data-duration vs data-end preference