mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(core,cli): variable schema validation + lint rules
Two lint rules + render-time validation built on top of the existing
data-composition-variables schema.
Lint rules (packages/core/src/lint/rules/composition.ts):
- invalid_variable_values_json — host's data-variable-values must parse as
a JSON object. Today the runtime swallows parse failures silently and
falls back to declared defaults, masking typos.
- invalid_composition_variables_declaration — root <html>'s
data-composition-variables must parse as an array of objects with
`id` (string), `type` (one of string/number/color/boolean/enum), `label`
(string), and `default`. Per-entry findings report which fields are
missing or invalid.
Both rules read attributes via a new `readJsonAttr` helper in lint/utils.ts.
The existing `readAttr` regex `["']([^"']+)["']` truncates JSON-in-attribute
values at the first internal quote (e.g. `data-variable-values='{"x":"y"}'`
captures only `{`); `readJsonAttr` alternates double-vs-single-quoted
branches with quote-specific char classes so JSON values round-trip cleanly.
A second helper `findHtmlTag` returns the actual <html> open tag (where
data-composition-variables lives) — distinct from `findRootTag` which
returns the first in-body composition element.
Render-time validation (packages/core/src/runtime/validateVariables.ts):
- validateVariables(values, declarations) returns a structured array of
issues: undeclared keys, type mismatches, enum-out-of-range values.
Pure / sync; works in any environment.
- formatVariableValidationIssue(issue) renders a one-line user-facing
string for CLI output.
- Both exported from @hyperframes/core for studio/tooling reuse.
CLI integration (packages/cli/src/commands/render.ts):
- New --strict-variables flag. Default behavior: print warnings and
continue. With --strict-variables: print warnings then exit 1.
- New `validateVariablesAgainstProject(indexPath, values)` helper:
reads the project's index.html, runs extractCompositionMetadata to
pull the declared schema, validates the CLI's --variables payload
against it. ensureDOMParser polyfill for Node-side parsing (same
pattern as compositions.ts).
Tests:
- 11 new validateVariables unit tests covering happy path, undeclared
keys, type mismatches (string/number/boolean/color/enum), enum range,
multiple-issue aggregation, and formatter output.
- 11 new composition.test.ts cases for both lint rules: parse errors,
shape errors, per-entry validation, unknown types, missing fields,
positive cases.
- 5 new render.test.ts cases for validateVariablesAgainstProject:
no-declarations, happy path, undeclared, type-mismatch, missing-file.
- All 646 core tests + 213 cli tests still green.
Docs:
- docs/packages/cli.mdx — added --strict-variables flag row.
This is PR 3 of a 4-PR stack. PR 4 ships skill/scaffold distribution.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
James Russo
co-authored by
Claude Opus 4.7
parent
da92a17754
commit
c1b6efd9c5
@@ -205,3 +205,70 @@ describe("parseVariablesArg", () => {
|
||||
expect(err.cause).toMatch(/ENOENT/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateVariablesAgainstProject", () => {
|
||||
let validateVariablesAgainstProject: typeof import("./render.js").validateVariablesAgainstProject;
|
||||
let tmpDir: string;
|
||||
let mkdtempSync: typeof import("node:fs").mkdtempSync;
|
||||
let writeFileSync: typeof import("node:fs").writeFileSync;
|
||||
let rmSync: typeof import("node:fs").rmSync;
|
||||
let join: typeof import("node:path").join;
|
||||
let tmpdir: typeof import("node:os").tmpdir;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ validateVariablesAgainstProject } = await import("./render.js"));
|
||||
({ mkdtempSync, writeFileSync, rmSync } = await import("node:fs"));
|
||||
({ join } = await import("node:path"));
|
||||
({ tmpdir } = await import("node:os"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "hf-validate-vars-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeIndex(html: string): string {
|
||||
const path = join(tmpDir, "index.html");
|
||||
writeFileSync(path, html);
|
||||
return path;
|
||||
}
|
||||
|
||||
it("returns [] when the project has no data-composition-variables declarations", () => {
|
||||
const indexPath = writeIndex(`<html><body><div data-composition-id="x"></div></body></html>`);
|
||||
expect(validateVariablesAgainstProject(indexPath, { title: "Hello" })).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] when every value matches its declaration", () => {
|
||||
const indexPath = writeIndex(
|
||||
`<html data-composition-variables='[{"id":"title","type":"string","label":"Title","default":"x"}]'><body><div data-composition-id="root"></div></body></html>`,
|
||||
);
|
||||
expect(validateVariablesAgainstProject(indexPath, { title: "Hello" })).toEqual([]);
|
||||
});
|
||||
|
||||
it("flags undeclared keys", () => {
|
||||
const indexPath = writeIndex(
|
||||
`<html data-composition-variables='[{"id":"title","type":"string","label":"Title","default":"x"}]'><body><div data-composition-id="root"></div></body></html>`,
|
||||
);
|
||||
expect(validateVariablesAgainstProject(indexPath, { title: "Hello", extra: 1 })).toEqual([
|
||||
{ kind: "undeclared", variableId: "extra" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("flags type mismatches", () => {
|
||||
const indexPath = writeIndex(
|
||||
`<html data-composition-variables='[{"id":"count","type":"number","label":"Count","default":0}]'><body><div data-composition-id="root"></div></body></html>`,
|
||||
);
|
||||
expect(validateVariablesAgainstProject(indexPath, { count: "three" })).toEqual([
|
||||
{ kind: "type-mismatch", variableId: "count", expected: "number", actual: "string" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns [] when the index file cannot be read (lint owns that diagnostic)", () => {
|
||||
expect(
|
||||
validateVariablesAgainstProject(join(tmpDir, "missing.html"), { title: "Hello" }),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,7 +35,14 @@ import { bytesToMb } from "../telemetry/system.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { isDevMode } from "../utils/env.js";
|
||||
import { buildDockerRunArgs } from "../utils/dockerRunArgs.js";
|
||||
import { ensureDOMParser } from "../utils/dom.js";
|
||||
import type { RenderJob } from "@hyperframes/producer";
|
||||
import {
|
||||
extractCompositionMetadata,
|
||||
validateVariables,
|
||||
formatVariableValidationIssue,
|
||||
type VariableValidationIssue,
|
||||
} from "@hyperframes/core";
|
||||
|
||||
const VALID_FPS = new Set([24, 30, 60]);
|
||||
const VALID_QUALITY = new Set(["draft", "standard", "high"]);
|
||||
@@ -142,6 +149,12 @@ export default defineCommand({
|
||||
description:
|
||||
"Path to a JSON file with variable values (alternative to --variables). The file must contain a single JSON object.",
|
||||
},
|
||||
"strict-variables": {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Fail render if any --variables key is undeclared or has a wrong type vs the composition's data-composition-variables. Without this flag, mismatches are warnings.",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
// ── Resolve project ────────────────────────────────────────────────────
|
||||
@@ -349,6 +362,33 @@ export default defineCommand({
|
||||
// ── Resolve --variables / --variables-file ──────────────────────────
|
||||
const variables = resolveVariablesArg(args.variables, args["variables-file"]);
|
||||
|
||||
// ── Validate --variables against data-composition-variables ─────────
|
||||
const strictVariables = args["strict-variables"] ?? false;
|
||||
if (variables && Object.keys(variables).length > 0) {
|
||||
const issues = validateVariablesAgainstProject(project.indexPath, variables);
|
||||
if (issues.length > 0) {
|
||||
if (!quiet) {
|
||||
console.log("");
|
||||
console.log(
|
||||
c.warn(
|
||||
`Variable ${issues.length === 1 ? "issue" : "issues"} (${issues.length}) — values may not render as expected:`,
|
||||
),
|
||||
);
|
||||
for (const issue of issues) {
|
||||
console.log(" " + c.dim(formatVariableValidationIssue(issue)));
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
if (strictVariables) {
|
||||
console.log(
|
||||
c.error(" Aborting render due to variable issues (--strict-variables mode)."),
|
||||
);
|
||||
console.log("");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────
|
||||
if (useDocker) {
|
||||
await renderDocker(project.dir, outputPath, {
|
||||
@@ -509,6 +549,33 @@ export function resolveVariablesArg(
|
||||
return result.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate `--variables` values against the project's top-level
|
||||
* `data-composition-variables` declarations. Returns an empty array when
|
||||
* the index has no declarations or when every key is declared with a
|
||||
* matching type. Errors reading the index are silently treated as "no
|
||||
* declarations" — the lint pass owns malformed-HTML diagnostics, render
|
||||
* shouldn't fail just because the schema is unreadable.
|
||||
*/
|
||||
export function validateVariablesAgainstProject(
|
||||
indexPath: string,
|
||||
values: Record<string, unknown>,
|
||||
): VariableValidationIssue[] {
|
||||
let html: string;
|
||||
try {
|
||||
html = readFileSync(indexPath, "utf8");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
// extractCompositionMetadata uses DOMParser, which Node doesn't ship.
|
||||
// Same pattern as `compositions.ts` and other CLI commands that touch
|
||||
// @hyperframes/core's HTML parsers.
|
||||
ensureDOMParser();
|
||||
const meta = extractCompositionMetadata(html);
|
||||
if (meta.variables.length === 0) return [];
|
||||
return validateVariables(values, meta.variables);
|
||||
}
|
||||
|
||||
export function resolveBrowserGpuForCli(
|
||||
useDocker: boolean,
|
||||
browserGpuArg: boolean | undefined,
|
||||
|
||||
Reference in New Issue
Block a user