Files
hyperframes/packages/core/src/runtime/validateVariables.ts
T
JamesandClaude Opus 4.7 c1b6efd9c5 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>
2026-05-04 20:05:33 +00:00

102 lines
3.2 KiB
TypeScript

import type { CompositionVariable } from "../core.types";
export type VariableValidationIssue =
| { kind: "undeclared"; variableId: string }
| { kind: "type-mismatch"; variableId: string; expected: string; actual: string }
| { kind: "enum-out-of-range"; variableId: string; allowed: string[]; actual: string };
/**
* Compare a flat values map (from `--variables` / `data-variable-values`) to
* the declared schema (`data-composition-variables`). Returns issues for keys
* that aren't declared, plus per-key type mismatches against the declared
* type. Pure / sync — caller decides how to surface them (warning vs render
* failure under `--strict-variables`).
*/
export function validateVariables(
values: Record<string, unknown>,
declarations: readonly CompositionVariable[],
): VariableValidationIssue[] {
const decls = new Map<string, CompositionVariable>();
for (const decl of declarations) decls.set(decl.id, decl);
const issues: VariableValidationIssue[] = [];
for (const [id, value] of Object.entries(values)) {
const decl = decls.get(id);
if (!decl) {
issues.push({ kind: "undeclared", variableId: id });
continue;
}
const mismatch = checkType(value, decl);
if (mismatch) issues.push(mismatch);
}
return issues;
}
function checkType(value: unknown, decl: CompositionVariable): VariableValidationIssue | null {
switch (decl.type) {
case "string":
case "color":
if (typeof value !== "string") {
return {
kind: "type-mismatch",
variableId: decl.id,
expected: decl.type,
actual: jsTypeOf(value),
};
}
return null;
case "number":
if (typeof value !== "number" || !Number.isFinite(value)) {
return {
kind: "type-mismatch",
variableId: decl.id,
expected: "number",
actual: jsTypeOf(value),
};
}
return null;
case "boolean":
if (typeof value !== "boolean") {
return {
kind: "type-mismatch",
variableId: decl.id,
expected: "boolean",
actual: jsTypeOf(value),
};
}
return null;
case "enum": {
if (typeof value !== "string") {
return {
kind: "type-mismatch",
variableId: decl.id,
expected: "enum (string)",
actual: jsTypeOf(value),
};
}
const allowed = decl.options.map((o) => o.value);
if (!allowed.includes(value)) {
return { kind: "enum-out-of-range", variableId: decl.id, allowed, actual: value };
}
return null;
}
}
}
function jsTypeOf(value: unknown): string {
if (value === null) return "null";
if (Array.isArray(value)) return "array";
return typeof value;
}
export function formatVariableValidationIssue(issue: VariableValidationIssue): string {
switch (issue.kind) {
case "undeclared":
return `Variable "${issue.variableId}" is not declared in data-composition-variables.`;
case "type-mismatch":
return `Variable "${issue.variableId}" expected ${issue.expected}, got ${issue.actual}.`;
case "enum-out-of-range":
return `Variable "${issue.variableId}" must be one of ${issue.allowed.map((v) => `"${v}"`).join(", ")} (got "${issue.actual}").`;
}
}