Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 115 additions & 29 deletions cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Map<string, ts.Expression | null>> = [new Map()];
const bindName = (name: ts.BindingName, value: ts.Expression | null): void => {
Expand Down Expand Up @@ -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;
Expand All @@ -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) => {
Expand Down Expand Up @@ -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 <canvas> 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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -2509,24 +2551,70 @@ function validateLoweredTree(project: SourceProject, errors: string[]): void {
}
}

function validateMediaTransportCapability(project: SourceProject, errors: string[]): void {
const projectPrograms = new WeakMap<SourceProject, ts.Program | null>();

// 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 => {
Expand Down Expand Up @@ -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<string>();
for (const classText of classVariants) try {
Expand Down Expand Up @@ -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`);
Expand Down
40 changes: 40 additions & 0 deletions cli/test/cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <panel class={\`w-[\${sessions * 14}px] h-[8px] rounded-full bg-black\`} />;
});
`, "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 <panel class={\`w-[\${width}px] h-[8px] bg-black\`} />;
});
`, /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 <panel class={\`\${utility} h-[8px] bg-black\`} />;
});
`, /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 <panel class={\`w-[\${percent}%] h-[8px] bg-black\`} />;
});
`, /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"';
Expand Down
23 changes: 23 additions & 0 deletions sdk/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<stack class={`w-[${sessions * 14}px] h-full rounded-full bg-[#5eead4]`} />
```

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 `<canvas>` for continuous geometry.
8 changes: 8 additions & 0 deletions test/capture-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
8 changes: 8 additions & 0 deletions test/capture/log-three.actions
Original file line number Diff line number Diff line change
@@ -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" } }
]
}
19 changes: 19 additions & 0 deletions test/fixtures/class-hole/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}
19 changes: 19 additions & 0 deletions test/fixtures/class-hole/widget.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<column class="p-3 gap-2 bg-[#11141c]">
<row class="w-full h-[8px] rounded-full bg-[#ffffff]/10">
<stack class={`w-[${sessions * 14}px] h-full rounded-full bg-[#5eead4]`} />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Class-hole fixture cannot render

The capture fixture that is meant to prove w-[${sessions * 14}px] updates after interaction does not render on the available Linux null backend. Both the baseline capture and the three-click capture fail with UnsupportedViewKind before emitting a frame or accessibility snapshot, so the expected 0px-to-42px width transition is not covered by an executable regression check in this environment.

T-Rex Ran code and verified through T-Rex

</row>
<button accessibilityLabel="Log" class="h-[24px] rounded-[6px] bg-[#5eead4]" onPress={() => setSessions((count) => count + 1)}>
<text class="text-[11px]">Log</text>
</button>
</column>
);
});
Loading