From ca244027c30e38dddd53e05a3d87c414f34febdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 8 Aug 2026 20:59:54 +0000 Subject: [PATCH] feat(core): warn when a variable value is not a declared option A composition can set a variable to a value outside that variable declared enum options. The value silently falls back, the composition renders something the author did not ask for, and no signal says a choice was ignored. Warns on both paths that resolve a variable, because they are separate. The runtime guard covers a top-level composition. The compile guard covers a sub-composition given instance values: those are baked into the variables table at compile time and the scoped getVariables shim only reads that table, so the runtime guard never runs there. That sub-comp case is the one that motivated this, and it was the silent one. Both call the same helper, so the message and the per-process dedupe set are shared and an author sees one warning either way. A warning rather than an error, for symmetry: the same defect must not carry two severities depending on which mount path an author happened to use. Escalation already has a home in lint, which a project can make blocking. The check is split across four small helpers rather than one function: parsing the declaration, naming the composition, reducing the option set to comparable scalars, and deciding whether a value actually fell back. As one function it audited at 21 cyclomatic and 25 cognitive. Tests were mutation-checked. Five distinct breakages each killed a test, including one that coerces the unknown value to the default instead of warning, which would turn a diagnostic into a silent rewrite. --- .../core/src/compiler/htmlBundler.test.ts | 124 +++++++++++++++- packages/core/src/compiler/htmlBundler.ts | 9 +- .../src/compiler/inlineSubCompositions.ts | 9 ++ .../core/src/runtime/getVariables.test.ts | 138 +++++++++++++++++- packages/core/src/runtime/getVariables.ts | 114 ++++++++++++++- 5 files changed, 389 insertions(+), 5 deletions(-) diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts index b820d536fa..3a4f86da70 100644 --- a/packages/core/src/compiler/htmlBundler.test.ts +++ b/packages/core/src/compiler/htmlBundler.test.ts @@ -3,8 +3,9 @@ import { mkdtempSync, writeFileSync, mkdirSync, symlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { parseHTML } from "linkedom"; -import { describe, it, expect, vi } from "vitest"; +import { afterEach, beforeEach, describe, it, expect, vi } from "vitest"; import { bundleToSingleHtml } from "./htmlBundler"; +import { resetUnknownEnumWarnings } from "../runtime/getVariables"; import { getHyperframeRuntimeScript } from "../generated/runtime-inline"; function makeTempProject(files: Record): string { @@ -1388,3 +1389,124 @@ describe("bundleToSingleHtml", () => { } }); }); + +/** + * A sub-composition given a value outside a declared enum's `options` falls + * back silently. The runtime guard in getVariables.ts cannot see it: the + * bundler bakes the per-instance values into `window.__hfVariablesByComp` at + * compile time and the sub-comp's scoped `getVariables` shim only reads that + * table. Compile time is therefore the only place the defect is observable on + * this path, so the same warning is emitted here. + */ +describe("bundleToSingleHtml unknown enum values", () => { + let warnings: string[]; + + beforeEach(() => { + resetUnknownEnumWarnings(); + warnings = []; + vi.spyOn(console, "warn").mockImplementation((...args: unknown[]) => { + warnings.push(args.map(String).join(" ")); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + resetUnknownEnumWarnings(); + }); + + const enumWarnings = () => warnings.filter((w) => w.includes("runtime_unknown_enum_value")); + + const ACCENT_ENUM = + '[{"id":"accent","type":"enum","label":"Accent","default":"green","options":["green","blue","violet"]}]'; + + function makeSubCompProject(variableValues: string, declaration = ACCENT_ENUM): string { + return makeTempProject({ + "index.html": ` + +
+
+
+ +`, + "compositions/card.html": ` + + +
+ +`, + }); + } + + it("warns when a sub-composition instance value is not a declared option", async () => { + await bundleToSingleHtml(makeSubCompProject('{"accent":"orange"}')); + + expect(enumWarnings()).toEqual([ + '[hyperframes] runtime_unknown_enum_value: card variable "accent" got "orange", ' + + "which is not a declared option (green, blue, violet). " + + 'Rendering "green" instead.', + ]); + }); + + it("is silent when the instance value is a declared option", async () => { + await bundleToSingleHtml(makeSubCompProject('{"accent":"violet"}')); + + expect(enumWarnings()).toEqual([]); + }); + + it("never inspects a variable declared without options", async () => { + const declaration = '[{"id":"accent","type":"string","label":"Accent","default":"green"}]'; + await bundleToSingleHtml(makeSubCompProject('{"accent":"orange"}', declaration)); + + expect(enumWarnings()).toEqual([]); + }); + + it("is silent for a declared enum absent from the instance values", async () => { + await bundleToSingleHtml(makeSubCompProject('{"unrelated":"whatever"}')); + + expect(enumWarnings()).toEqual([]); + }); + + it("passes the unknown value through to the bundle unrewritten", async () => { + const bundled = await bundleToSingleHtml(makeSubCompProject('{"accent":"orange"}')); + + expect(bundled).toContain("window.__hfVariablesByComp = Object.assign({}, "); + expect(bundled).toContain('{ "card": { "accent": "orange" } }'); + expect(bundled).toMatch(/\[data-composition-id="card"\]\s*\{[^}]*--accent:\s*orange/); + expect(bundled).not.toContain("--accent: green"); + }); + + it("warns once for the same composition, variable and value across bundles", async () => { + const dir = makeSubCompProject('{"accent":"orange"}'); + await bundleToSingleHtml(dir); + await bundleToSingleHtml(dir); + + expect(enumWarnings()).toHaveLength(1); + }); + + it("warns for a