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:
James
2026-05-04 20:05:33 +00:00
committed by James Russo
co-authored by Claude Opus 4.7
parent da92a17754
commit c1b6efd9c5
9 changed files with 639 additions and 1 deletions
@@ -644,4 +644,117 @@ describe("composition rules", () => {
expect(finding).toBeUndefined();
});
});
describe("invalid_variable_values_json", () => {
it("warns when data-variable-values is unparseable JSON", () => {
const html = `<html><body>
<div data-composition-id="card-1" data-composition-src="card.html" data-variable-values='{not json'></div>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "invalid_variable_values_json");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});
it("warns when data-variable-values is a JSON array (must be an object)", () => {
const html = `<html><body>
<div data-composition-src="card.html" data-variable-values='[1,2,3]'></div>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "invalid_variable_values_json");
expect(finding).toBeDefined();
expect(finding?.message).toMatch(/must be a JSON object/);
});
it("warns when data-variable-values is a JSON string (must be an object)", () => {
const html = `<html><body>
<div data-composition-src="card.html" data-variable-values='"hello"'></div>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "invalid_variable_values_json");
expect(finding).toBeDefined();
});
it("does not warn for a valid JSON object", () => {
const html = `<html><body>
<div data-composition-src="card.html" data-variable-values='{"title":"Hello","count":3}'></div>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "invalid_variable_values_json");
expect(finding).toBeUndefined();
});
it("does not warn when data-variable-values is absent", () => {
const html = `<html><body>
<div data-composition-src="card.html"></div>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "invalid_variable_values_json");
expect(finding).toBeUndefined();
});
});
describe("invalid_composition_variables_declaration", () => {
it("warns when data-composition-variables is unparseable JSON", () => {
const html = `<html data-composition-variables='[{not json'><body><div data-composition-id="x"></div></body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "invalid_composition_variables_declaration",
);
expect(finding).toBeDefined();
});
it("warns when data-composition-variables is not an array", () => {
const html = `<html data-composition-variables='{"title":"Hello"}'><body><div data-composition-id="x"></div></body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "invalid_composition_variables_declaration",
);
expect(finding).toBeDefined();
expect(finding?.message).toMatch(/array of variable declarations/);
});
it("warns per-entry when an entry is missing required fields", () => {
const html = `<html data-composition-variables='[{"id":"ok","type":"string","label":"Ok","default":"x"},{"id":"bad"}]'><body><div data-composition-id="x"></div></body></html>`;
const result = lintHyperframeHtml(html);
const findings = result.findings.filter(
(f) => f.code === "invalid_composition_variables_declaration",
);
expect(findings.length).toBe(1);
expect(findings[0]?.message).toMatch(/\[1\]/);
expect(findings[0]?.message).toMatch(/type|label|default/);
});
it("warns when a declaration uses an unknown type", () => {
const html = `<html data-composition-variables='[{"id":"x","type":"date","label":"X","default":"y"}]'><body><div data-composition-id="x"></div></body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "invalid_composition_variables_declaration",
);
expect(finding).toBeDefined();
expect(finding?.message).toMatch(/type/);
});
it("does not warn for a fully valid declarations array", () => {
const html = `<html data-composition-variables='[
{"id":"title","type":"string","label":"Title","default":"Hello"},
{"id":"count","type":"number","label":"Count","default":3},
{"id":"theme","type":"enum","label":"Theme","default":"light","options":[{"value":"light","label":"Light"}]}
]'><body><div data-composition-id="x"></div></body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "invalid_composition_variables_declaration",
);
expect(finding).toBeUndefined();
});
it("does not warn when data-composition-variables is absent", () => {
const html = `<html><body><div data-composition-id="x"></div></body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "invalid_composition_variables_declaration",
);
expect(finding).toBeUndefined();
});
});
});
+117 -1
View File
@@ -1,5 +1,5 @@
import type { LintContext, HyperframeLintFinding } from "../context";
import { readAttr, truncateSnippet } from "../utils";
import { findHtmlTag, readAttr, readJsonAttr, truncateSnippet } from "../utils";
// Agent guidance thresholds: warning-only nudges for files/tracks that become hard
// to inspect and revise reliably in a single composition.
@@ -388,4 +388,120 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
}
return findings;
},
// invalid_variable_values_json
// Host elements (`[data-composition-src]`) carry per-instance values via
// `data-variable-values`. The runtime swallows JSON errors silently and
// falls back to declared defaults, which masks typos. This rule surfaces
// the parse failure so authors notice before render time.
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
for (const tag of tags) {
const raw = readJsonAttr(tag.raw, "data-variable-values");
if (!raw) continue;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (err) {
const reason = err instanceof Error ? err.message : "unknown";
findings.push({
code: "invalid_variable_values_json",
severity: "warning",
message: `data-variable-values is not valid JSON (${reason}).`,
fixHint:
'Wrap the attribute value in single quotes and the JSON keys/values in double quotes, e.g. data-variable-values=\'{"title":"Hello"}\'.',
elementId: readAttr(tag.raw, "id") || undefined,
snippet: truncateSnippet(tag.raw),
});
continue;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
findings.push({
code: "invalid_variable_values_json",
severity: "warning",
message:
'data-variable-values must be a JSON object keyed by variable id (e.g. {"title":"Hello"}).',
fixHint:
"Replace the value with a JSON object whose keys are variable ids declared in the sub-composition's data-composition-variables.",
elementId: readAttr(tag.raw, "id") || undefined,
snippet: truncateSnippet(tag.raw),
});
}
}
return findings;
},
// invalid_composition_variables_declaration
// The runtime parses `data-composition-variables` and silently returns []
// on any structural problem. Surface JSON / shape failures so authors
// catch them at lint time rather than wondering why their `getVariables()`
// defaults aren't applied.
({ source }) => {
const htmlTag = findHtmlTag(source);
if (!htmlTag) return [];
const raw = readJsonAttr(htmlTag.raw, "data-composition-variables");
if (!raw) return [];
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (err) {
const reason = err instanceof Error ? err.message : "unknown";
return [
{
code: "invalid_composition_variables_declaration",
severity: "warning",
message: `data-composition-variables is not valid JSON (${reason}).`,
fixHint:
'Provide a JSON array of variable declarations: data-composition-variables=\'[{"id":"title","type":"string","label":"Title","default":"Hello"}]\'.',
snippet: truncateSnippet(htmlTag.raw),
},
];
}
if (!Array.isArray(parsed)) {
return [
{
code: "invalid_composition_variables_declaration",
severity: "warning",
message: "data-composition-variables must be a JSON array of variable declarations.",
fixHint:
'Wrap declarations in [] and give each an id, type, label, and default: \'[{"id":"title","type":"string","label":"Title","default":"Hello"}]\'.',
snippet: truncateSnippet(htmlTag.raw),
},
];
}
const findings: HyperframeLintFinding[] = [];
const knownTypes = new Set(["string", "number", "color", "boolean", "enum"]);
for (let i = 0; i < parsed.length; i += 1) {
const entry = parsed[i];
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
findings.push({
code: "invalid_composition_variables_declaration",
severity: "warning",
message: `data-composition-variables entry [${i}] must be an object with id, type, label, and default.`,
snippet: truncateSnippet(htmlTag.raw),
});
continue;
}
const e = entry as Record<string, unknown>;
const missing: string[] = [];
if (typeof e.id !== "string") missing.push("id");
if (typeof e.type !== "string" || !knownTypes.has(e.type as string)) missing.push("type");
if (typeof e.label !== "string") missing.push("label");
if (!("default" in e)) missing.push("default");
if (missing.length > 0) {
findings.push({
code: "invalid_composition_variables_declaration",
severity: "warning",
message: `data-composition-variables entry [${i}] is missing or has invalid: ${missing.join(", ")}. Type must be one of string, number, color, boolean, enum.`,
snippet: truncateSnippet(htmlTag.raw),
});
}
}
return findings;
},
];
+37
View File
@@ -58,6 +58,23 @@ export function extractBlocks(source: string, pattern: RegExp): ExtractedBlock[]
return blocks;
}
/**
* Find the `<html>` open tag in the source. Distinct from `findRootTag`,
* which returns the first element inside `<body>` — the latter is "the
* composition's visible root", whereas `<html>` is where document-level
* metadata like `data-composition-variables` lives.
*/
export function findHtmlTag(source: string): OpenTag | null {
const match = /<html\b([^<>]*)>/i.exec(source);
if (!match) return null;
return {
raw: match[0],
name: "html",
attrs: match[1] ?? "",
index: match.index,
};
}
export function findRootTag(source: string): OpenTag | null {
const bodyOpenMatch = /<body\b[^>]*>/i.exec(source);
const bodyCloseMatch = /<\/body>/i.exec(source);
@@ -82,6 +99,26 @@ export function readAttr(tagSource: string, attr: string): string | null {
return match?.[1] || null;
}
/**
* Read an attribute that may legitimately contain the opposite quote
* character. `readAttr` truncates `data-variable-values='{"title":"Hello"}'`
* at the first internal `"` because its `[^"']+` class excludes both quote
* types. This variant alternates: a double-quoted value never contains an
* unescaped `"`, and a single-quoted value never contains an unescaped `'`,
* so each branch can use a quote-specific class.
*
* Use for attributes whose values are JSON or otherwise carry the opposite
* quote character. Existing single-token attributes (`id`, `class`, etc.)
* stick with `readAttr` for consistency with the rest of the lint code.
*/
export function readJsonAttr(tagSource: string, attr: string): string | null {
if (!tagSource) return null;
const escaped = attr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = tagSource.match(new RegExp(`\\b${escaped}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i"));
if (!match) return null;
return match[1] ?? match[2] ?? null;
}
export function collectCompositionIds(tags: OpenTag[]): Set<string> {
const ids = new Set<string>();
for (const tag of tags) {