From f301c2d33b9000b8724dfa060fce2d0ddcc1fd86 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:58:55 -0400 Subject: [PATCH] feat(check): allow a number-typed template hole as a pixel arbitrary value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every class had to resolve to at most 32 literal strings, so a width computed from data had no route; the error named the cap and nothing else. Twenty agents building one progress bar produced six workarounds, including 21-branch ternary ladders and semantic-less canvases. check now accepts one dynamic shape: a template hole that is the number of a pixel arbitrary value, `w-[${px}px]`, when TypeScript types the hole as a number. The utility prefix and `px]` suffix stay literal, so every utility is still validated statically; the number is validated by the same class compiler when the widget runs (about 6 µs per change, measured), and a bad value fails the widget onto its error surface with the utility named. A hole anywhere else, or a string-typed hole, is rejected with a message that names the rule and the three routes (pixel hole, fraction utility, canvas). The generic cap message now names those routes too. The project program that the media-capability check already built is shared with the class walk, so this adds no second type-check pass. Receipts: cli tests for the accepted shape and three rejected shapes; a new capture smoke fixture whose fill is `w-[${sessions * 14}px]` moves from 0 to 42 logical pixels across three clicks, checked in the snapshot and pixels. npm test 119 passed; capture smoke passed. Co-Authored-By: Claude Fable 5.1 --- cli/src/index.ts | 144 ++++++++++++++++++++----- cli/test/cli.test.mjs | 40 +++++++ sdk/CONTRACT.md | 23 ++++ test/capture-smoke.mjs | 8 ++ test/capture/log-three.actions | 8 ++ test/fixtures/class-hole/tsconfig.json | 19 ++++ test/fixtures/class-hole/widget.tsx | 19 ++++ 7 files changed, 232 insertions(+), 29 deletions(-) create mode 100644 test/capture/log-three.actions create mode 100644 test/fixtures/class-hole/tsconfig.json create mode 100644 test/fixtures/class-hole/widget.tsx diff --git a/cli/src/index.ts b/cli/src/index.ts index f00644a9..0bf73c89 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -845,11 +845,29 @@ interface StaticExpressionContext { unwrap(expression: ts.Expression): ts.Expression; boundExpression(name: string): ts.Expression | null | undefined; stringVariants(expression: ts.Expression): readonly string[] | null; -} + /** + * Why the most recent failed resolution rejected a template hole, so the + * class diagnostic can name the fix instead of the cap. Reset it before a + * resolution whose failure you intend to report. + */ + holeProblem: "position" | "type" | null; +} + +/** + * The one dynamic shape `class` may take: a number-typed template hole as the + * number of a pixel arbitrary value, `w-[${px}px]`. The utility prefix and the + * `px]` suffix are literal, so weaver check still knows every utility; the + * number itself is validated by the same class compiler when the widget runs. + * `numberTyped` is the caller's type oracle; without one, holes are rejected. + */ +const pixelHoleOpens = /(?:^|\s)[a-z][a-z0-9-]*-\[$/; +const pixelHoleCloses = /^px\](?:\s|$)/; +const pixelHolePlaceholder = "1"; function walkSourceWithStaticExpressions( sourceFile: ts.SourceFile, visitor: (node: ts.Node, context: StaticExpressionContext) => boolean, + numberTyped?: (expression: ts.Expression) => boolean, ): boolean { const bindingScopes: Array> = [new Map()]; const bindName = (name: ts.BindingName, value: ts.Expression | null): void => { @@ -931,7 +949,7 @@ function walkSourceWithStaticExpressions( if (ts.isTemplateExpression(current)) { let values: readonly string[] = [current.head.text]; for (const span of current.templateSpans) { - const substitution = stringVariants(span.expression, new Set(seen)); + const substitution = stringVariants(span.expression, new Set(seen)) ?? pixelValueHole(values, span); if (!substitution) return null; const expanded = combine(values, substitution); if (!expanded) return null; @@ -941,7 +959,18 @@ function walkSourceWithStaticExpressions( } return null; }; - const context: StaticExpressionContext = { unwrap, boundExpression, stringVariants }; + const pixelValueHole = (prefixes: readonly string[], span: ts.TemplateSpan): readonly string[] | null => { + if (!prefixes.every((prefix) => pixelHoleOpens.test(prefix)) || !pixelHoleCloses.test(span.literal.text)) { + context.holeProblem = "position"; + return null; + } + if (!numberTyped || !numberTyped(span.expression)) { + context.holeProblem = "type"; + return null; + } + return [pixelHolePlaceholder]; + }; + const context: StaticExpressionContext = { unwrap, boundExpression, stringVariants, holeProblem: null }; const visitChildren = (node: ts.Node): boolean => { let stopped = false; ts.forEachChild(node, (child) => { @@ -986,6 +1015,19 @@ function walkSourceWithStaticExpressions( return walk(sourceFile); } +const classValueRoutes = "Data-driven values have three routes: a number-typed template hole as the number of a pixel arbitrary value (`w-[${px}px]`; the number is validated when the widget runs), a fraction utility for quantized values (`w-3/20`), or a for continuous geometry."; + +function classResolutionMessage(holeProblem: StaticExpressionContext["holeProblem"]): string { + switch (holeProblem) { + case "position": + return `class template hole must be the number of a pixel arbitrary value, like \`w-[\${px}px]\`; a hole anywhere else (a whole utility, a color, a percentage) leaves weaver check unable to validate the utility. ${classValueRoutes}`; + case "type": + return `class template hole must be a number-typed expression; a string hole leaves weaver check unable to validate the utility. ${classValueRoutes}`; + default: + return `class must resolve to at most ${maxStaticClassVariants} literal strings so weaver check can validate every utility. ${classValueRoutes}`; + } +} + function jsxClassVariants(attribute: ts.JsxAttribute, context: StaticExpressionContext): readonly string[] | null { const initializer = attribute.initializer; if (!initializer) return null; @@ -1265,11 +1307,11 @@ function sourceUsesIcon(sourceFile: ts.SourceFile): boolean { return found; } -/// `dist` is the runtime artifact, so local image paths must mean the same -/// thing after install as they did beside widget.tsx. Copy every ordinary -/// widget-owned file recursively while excluding authoring/build outputs; -/// dynamic local `src` expressions then remain valid without a magic asset -/// directory or source-tree dependency. +// `dist` is the runtime artifact, so local image paths must mean the same +// thing after install as they did beside widget.tsx. Copy every ordinary +// widget-owned file recursively while excluding authoring/build outputs; +// dynamic local `src` expressions then remain valid without a magic asset +// directory or source-tree dependency. function copyWidgetAssets(sourceDirectory: string, outputDirectory: string, root = true): void { for (const entry of readdirSync(sourceDirectory, { withFileTypes: true })) { if (!isWeaveSourceEntryIncluded(entry.name, root) || (root && entry.name === "widget.tsx")) continue; @@ -2509,24 +2551,70 @@ function validateLoweredTree(project: SourceProject, errors: string[]): void { } } -function validateMediaTransportCapability(project: SourceProject, errors: string[]): void { +const projectPrograms = new WeakMap(); + +// The typed view of a widget project, built once per check. Bundling owns the +// two SDK specifiers regardless of widget-authored paths, so check compiles +// the same graph the bundle binds; a local look-alike declaration could +// otherwise hide SDK use. Returns null when tsconfig itself is unreadable, +// which the ordinary TypeScript invocation reports. +function projectProgram(project: SourceProject): ts.Program | null { + if (projectPrograms.has(project)) return projectPrograms.get(project) ?? null; const configPath = join(project.directory, "tsconfig.json"); const configRead = ts.readConfigFile(configPath, (path) => readFileSync(path, "utf8")); - if (configRead.error) return; // The ordinary TypeScript invocation reports it. - const parsed = ts.parseJsonConfigFileContent(configRead.config, ts.sys, project.directory, undefined, configPath); - const sdkDirectory = join(repoRoot, "sdk"); - // Bundling owns these two specifiers regardless of widget-authored paths. - // Check must compile the same graph or a local compatible declaration can - // hide capability use that the bundle later binds to Weaver's real SDK. - const options: ts.CompilerOptions = { - ...parsed.options, - paths: { - ...parsed.options.paths, - "@weaver/sdk": [join(sdkDirectory, "index.d.ts")], - "@weaver/sdk/jsx-runtime": [join(sdkDirectory, "jsx-runtime.d.ts")], - }, + let program: ts.Program | null = null; + if (!configRead.error) { + const parsed = ts.parseJsonConfigFileContent(configRead.config, ts.sys, project.directory, undefined, configPath); + const sdkDirectory = join(repoRoot, "sdk"); + const options: ts.CompilerOptions = { + ...parsed.options, + paths: { + ...parsed.options.paths, + "@weaver/sdk": [join(sdkDirectory, "index.d.ts")], + "@weaver/sdk/jsx-runtime": [join(sdkDirectory, "jsx-runtime.d.ts")], + }, + }; + program = ts.createProgram({ rootNames: parsed.fileNames, options }); + } + projectPrograms.set(project, program); + return program; +} + +// Project source files are parsed standalone, so the type checker cannot be +// asked about their nodes directly. Find the same node in the program's copy +// of the file by span and kind. +function programNode(program: ts.Program, node: ts.Node): ts.Node | null { + const path = resolve(node.getSourceFile().fileName); + const programFile = program.getSourceFiles().find((candidate) => pathsEqual(resolve(candidate.fileName), path)); + if (!programFile) return null; + let match: ts.Node | null = null; + const visit = (candidate: ts.Node): void => { + if (match || candidate.pos > node.end || candidate.end < node.pos) return; + if (candidate.pos === node.pos && candidate.end === node.end && candidate.kind === node.kind) { + match = candidate; + return; + } + ts.forEachChild(candidate, visit); }; - const program = ts.createProgram({ rootNames: parsed.fileNames, options }); + visit(programFile); + return match; +} + +function numberTypedOracle(project: SourceProject): ((expression: ts.Expression) => boolean) | undefined { + const program = projectProgram(project); + if (!program) return undefined; + const checker = program.getTypeChecker(); + return (expression) => { + const node = programNode(program, expression); + if (!node) return false; + return (checker.getTypeAtLocation(node).flags & ts.TypeFlags.NumberLike) !== 0; + }; +} + +function validateMediaTransportCapability(project: SourceProject, errors: string[]): void { + const program = projectProgram(project); + if (!program) return; // The ordinary TypeScript invocation reports the tsconfig failure. + const sdkDirectory = join(repoRoot, "sdk"); const checker = program.getTypeChecker(); const sdkHookDeclaration = (declaration: ts.Declaration | undefined): boolean => { @@ -2831,12 +2919,9 @@ function validateSource(project: SourceProject): string[] { if (tag === "button" || tag === "slider") validateAccessibleName(node, tag); const classAttribute = node.attributes.properties.find((attribute): attribute is ts.JsxAttribute => ts.isJsxAttribute(attribute) && attribute.name.getText(sourceFile) === "class"); if (classAttribute) { + context.holeProblem = null; const classVariants = jsxClassVariants(classAttribute, context); - if (classVariants === null) errors.push(locationMessage( - sourceFile, - classAttribute, - `class must resolve to at most ${maxStaticClassVariants} literal strings so weaver check can validate every utility`, - )); + if (classVariants === null) errors.push(locationMessage(sourceFile, classAttribute, classResolutionMessage(context.holeProblem))); else { const classErrors = new Set(); for (const classText of classVariants) try { @@ -2915,7 +3000,8 @@ function validateSource(project: SourceProject): string[] { } return false; }; - for (const sourceFile of project.sourceFiles) walkSourceWithStaticExpressions(sourceFile, visit); + const numberTyped = numberTypedOracle(project); + for (const sourceFile of project.sourceFiles) walkSourceWithStaticExpressions(sourceFile, visit, numberTyped); for (const [provider, hooks] of usedProviders) { if (!project.config.subscribe?.includes(provider)) { for (const hook of hooks) errors.push(`${hook}("${provider}") requires subscribe: ["${provider}"] in the widget config`); diff --git a/cli/test/cli.test.mjs b/cli/test/cli.test.mjs index fadcbbb4..f516cfda 100644 --- a/cli/test/cli.test.mjs +++ b/cli/test/cli.test.mjs @@ -455,6 +455,46 @@ export default widget({ name: "JSX Mutable Class", size: [160, 80] }, () => ( assert.match(checked.stderr, expected, label); } + // The one dynamic class shape: a number-typed hole as the number of a pixel + // arbitrary value. The utility stays statically known; the number is + // validated by the class compiler when the widget runs. + writeFileSync(sourcePath, `import { useState, widget } from "@weaver/sdk"; +export default widget({ name: "JSX Pixel Hole", size: [160, 80] }, () => { + const [sessions] = useState(3); + return ; +}); +`, "utf8"); + const pixelHole = spawnSync(process.execPath, [cli, "check", join(root, "widget")], { encoding: "utf8" }); + assert.equal(pixelHole.status, 0, pixelHole.stderr); + + const rejectedHoles = [ + [`import { useState, widget } from "@weaver/sdk"; +export default widget({ name: "JSX String Hole", size: [160, 80] }, () => { + const [width] = useState("42"); + return ; +}); +`, /class template hole must be a number-typed expression/, "string-typed hole"], + [`import { useState, widget } from "@weaver/sdk"; +export default widget({ name: "JSX Utility Hole", size: [160, 80] }, () => { + const [utility] = useState("w-[42px]"); + return ; +}); +`, /class template hole must be the number of a pixel arbitrary value/, "hole as a whole utility"], + [`import { useState, widget } from "@weaver/sdk"; +export default widget({ name: "JSX Percent Hole", size: [160, 80] }, () => { + const [percent] = useState(15); + return ; +}); +`, /class template hole must be the number of a pixel arbitrary value/, "percent hole"], + ]; + for (const [source, expected, label] of rejectedHoles) { + writeFileSync(sourcePath, source, "utf8"); + const checked = spawnSync(process.execPath, [cli, "check", join(root, "widget")], { encoding: "utf8" }); + assert.equal(checked.status, 1, label); + assert.match(checked.stderr, expected, label); + assert.match(checked.stderr, /three routes/, label); + } + const tooManyVariants = Array.from({ length: 33 }, (_, index) => `choice === ${index} ? "bg-[#${index.toString(16).padStart(6, "0")}]" : `, ).join("") + '"bg-black"'; diff --git a/sdk/CONTRACT.md b/sdk/CONTRACT.md index efa8c600..656540c6 100644 --- a/sdk/CONTRACT.md +++ b/sdk/CONTRACT.md @@ -846,3 +846,26 @@ re-render never shrinks the draw size back to it. `weaver check` no longer requires explicit pixel dimensions on a canvas (`CanvasNeedsExplicitSize` is retired). A capture whose canvas commands were drawn for a different size than the canvas's layout reports `CanvasDrewForStaleLayout` in `warnings`. + +## Amendment: data-driven class values + +`weaver check` validates every class utility before a widget runs, so a +`class` must resolve statically: string literals, `const` bindings, ternaries, +concatenation, and template literals whose holes resolve the same way, up to +32 distinct strings per attribute. One dynamic shape is allowed on top of +that: a **number-typed template hole as the number of a pixel arbitrary +value**. + +```tsx + +``` + +The utility prefix (`w-[`) and the unit suffix (`px]`) are literal, so check +still knows the utility. The hole must be typed `number` by TypeScript; a +string hole, a hole that spans a whole utility, a color, or a percentage is a +check error that names the rule and the routes. The number itself is +validated by the same class compiler when the widget runs, once per change to +the class string, which costs about 6 µs; a negative, non-finite, or +non-numeric value fails the widget onto its error surface with the offending +utility named. Fraction utilities (`w-3/20`) remain the route for quantized +values and `` for continuous geometry. diff --git a/test/capture-smoke.mjs b/test/capture-smoke.mjs index 5ca5cc78..e78fc167 100644 --- a/test/capture-smoke.mjs +++ b/test/capture-smoke.mjs @@ -36,6 +36,14 @@ try { assert.match(snapshotText(canvasGrowTicked), /role=text name="02"/); assert.ok(countPixels(canvasGrowTicked, [0x5e, 0xea, 0xd4]) > 1000, `grow canvas after re-render painted ${countPixels(canvasGrowTicked, [0x5e, 0xea, 0xd4])} accent pixels`); + // A number-typed template hole is the one dynamic class shape check allows; + // prove it round-trips through the runtime class compiler on state change. + const classHole = capture("class-hole", "test/fixtures/class-hole"); + assert.match(snapshotText(classHole), /role=group name="" bounds=\(\d+(?:\.\d+)?,\d+(?:\.\d+)? 0x8\)/, "hole fill starts at 0px"); + const classHoleClicked = capture("class-hole-clicked", "test/fixtures/class-hole", ["--action-file", "test/capture/log-three.actions"]); + assert.match(snapshotText(classHoleClicked), /role=group name="" bounds=\(\d+(?:\.\d+)?,\d+(?:\.\d+)? 42x8\)/, "hole fill is 42px after three clicks"); + assert.ok(countPixels(classHoleClicked, [0x5e, 0xea, 0xd4]) > countPixels(classHole, [0x5e, 0xea, 0xd4]), "hole fill painted more accent after clicks"); + const images = capture("styling-images", "examples/styling-images"); assert.equal(images.renderer.images, 3); assert.equal(images.pending.images, 0); diff --git a/test/capture/log-three.actions b/test/capture/log-three.actions new file mode 100644 index 00000000..3a796050 --- /dev/null +++ b/test/capture/log-three.actions @@ -0,0 +1,8 @@ +{ + "schema": "weaver.capture.actions.v1", + "actions": [ + { "action": "click", "target": { "role": "button", "name": "Log" } }, + { "action": "click", "target": { "role": "button", "name": "Log" } }, + { "action": "click", "target": { "role": "button", "name": "Log" } } + ] +} diff --git a/test/fixtures/class-hole/tsconfig.json b/test/fixtures/class-hole/tsconfig.json new file mode 100644 index 00000000..318a70a8 --- /dev/null +++ b/test/fixtures/class-hole/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "jsxImportSource": "@weaver/sdk", + "types": [], + "baseUrl": ".", + "paths": { + "@weaver/sdk": ["../../../sdk/index.d.ts"], + "@weaver/sdk/jsx-runtime": ["../../../sdk/jsx-runtime.d.ts"] + } + }, + "include": ["widget.tsx"] +} diff --git a/test/fixtures/class-hole/widget.tsx b/test/fixtures/class-hole/widget.tsx new file mode 100644 index 00000000..7245d86b --- /dev/null +++ b/test/fixtures/class-hole/widget.tsx @@ -0,0 +1,19 @@ +import { useState, widget } from "@weaver/sdk"; + +// Capture smoke fixture: a progress fill whose width is a number-typed +// template hole, `w-[${px}px]`. weaver check validates the utility statically; +// the runtime class compiler validates the number on every change. Three +// clicks on "Log" move the fill from 0 to 42 logical pixels. +export default widget({ name: "Class Hole", size: [200, 72] }, () => { + const [sessions, setSessions] = useState(0); + return ( + + + + + + + ); +});