diff --git a/vapor/DESIGN.md b/vapor/DESIGN.md index 22edb3e7..511ebccf 100644 --- a/vapor/DESIGN.md +++ b/vapor/DESIGN.md @@ -70,6 +70,24 @@ graph is a superset, never a subset. This is the one deliberate divergence from Vue's dynamic dependency collection, and the E2E oracle keeps it honest. +Before setup analysis, a sparse conditional constant propagation pass +(`vapor/compiler/sccp.ts`) tightens that superset. Every ref starts at +its seed constant; write sites collected across the component body lower +it, except writes behind a guard that is decidably false under the current +environment. The pass is optimistic and iterates to a fixpoint, so +mutually-gated refs converge (`if (a.value) b.value = true; +if (b.value) a.value = true;` with both seeded `false` keeps both +constant). A ref proven constant folds at every read: it registers no +dependencies, decidable ternaries and `if`s compile only the taken arm, +and the dead arm's refs leave the effect mask — and the ROM — entirely. +Folded refs keep their state slot, seed, and debug-block entry, so the +oracle and device grids stay comparable. Soundness rests on two subset +rules the compiler already enforces: assignments occur only in statement +position (if/ternary conditions are the complete guard vocabulary), and +no closure escapes setup (every function in the component body is assumed +callable). Locals are not tracked — a write whose right side reads a +local lowers the ref to not-a-constant. + ## 3. Memory: arenas, not GC The runtime never calls `malloc` and never frees: diff --git a/vapor/compiler/compile.ts b/vapor/compiler/compile.ts index 9ae59949..c083aee9 100644 --- a/vapor/compiler/compile.ts +++ b/vapor/compiler/compile.ts @@ -18,6 +18,7 @@ import ts from "typescript"; import { FONT8 } from "./font.gen.ts"; +import { sccpRefConstants } from "./sccp.ts"; import { rgb555, rgb565, StyleTable, type StyleIssue } from "./styles.ts"; import { BACKDROP } from "./styles.ts"; @@ -260,6 +261,11 @@ class AppCompiler { private styleErrors: string[] = []; private styleWarnings: string[] = []; + /** SCCP result: refs proven constant (name -> value). Reads of these fold + * through constNum, so they never register dependencies and decidable + * ternary/if branches drop their dead arm from masks and ROM alike. */ + private sccpFolded: Map | null = null; + constructor( private sf: ts.SourceFile, private title: string, @@ -298,6 +304,14 @@ class AppCompiler { } if (!component) this.err(this.sf, "missing `export default () => ...` component"); if (!this.vueRef || !this.vueComputed) this.err(this.sf, 'component must import { ref, computed } from "vue"'); + // SCCP runs before setup analysis so computed/effect dep collection sees + // folded ref reads. foldConst here is module-level only: sccpFolded is + // still null, so constNum cannot consult the result being computed. + this.sccpFolded = sccpRefConstants({ + component, + refLocalName: this.vueRef, + foldConst: (e) => this.constNum(e) ?? this.constBool(e), + }); this.scanSetup(component); return this.emit(); } @@ -821,8 +835,11 @@ class AppCompiler { private viewMaxLen(e: ts.Expression): number { e = this.unparen(e); - if (ts.isConditionalExpression(e)) + if (ts.isConditionalExpression(e)) { + const cf = this.constNum(e.condition) ?? this.constBool(e.condition); + if (cf !== null) return this.viewMaxLen(cf ? e.whenTrue : e.whenFalse); return Math.max(this.viewMaxLen(e.whenTrue), this.viewMaxLen(e.whenFalse)); + } if (ts.isCallExpression(e) && ts.isPropertyAccessExpression(e.expression)) { const inner = this.viewMaxLen(e.expression.expression); if (e.expression.name.text === "filter") return inner; @@ -840,15 +857,30 @@ class AppCompiler { return this.target.poolCap; } - /** Comparisons over compile-time numbers fold to 0/1 (SCREEN.width < 30). */ + /** Comparisons over compile-time numbers fold to 0/1 (SCREEN.width < 30), + * plus !x and short-circuit &&/|| over foldable operands — SCCP-folded + * ref reads arrive through constNum, so `!locked.value` folds too. */ private constBool(e: ts.Expression): number | null { e = this.unparen(e); + const K = ts.SyntaxKind; + if (ts.isPrefixUnaryExpression(e) && e.operator === K.ExclamationToken) { + const v = this.constNum(e.operand) ?? this.constBool(e.operand); + return v === null ? null : v ? 0 : 1; + } if (!ts.isBinaryExpression(e)) return null; + const op = e.operatorToken.kind; + if (op === K.AmpersandAmpersandToken || op === K.BarBarToken) { + const l = this.constNum(e.left) ?? this.constBool(e.left); + if (l === null) return null; // short-circuit needs a decided left + if (op === K.AmpersandAmpersandToken && !l) return 0; + if (op === K.BarBarToken && l) return 1; + const r = this.constNum(e.right) ?? this.constBool(e.right); + return r === null ? null : r ? 1 : 0; + } const l = this.constNum(e.left); const r = this.constNum(e.right); if (l === null || r === null) return null; - const K = ts.SyntaxKind; - switch (e.operatorToken.kind) { + switch (op) { case K.LessThanToken: return l < r ? 1 : 0; case K.GreaterThanToken: return l > r ? 1 : 0; case K.LessThanEqualsToken: return l <= r ? 1 : 0; @@ -875,6 +907,19 @@ class AppCompiler { const sub = this.substProp(e); if (sub) return this.constNum(sub); if (ts.isNumericLiteral(e)) return Number(e.text); + if (ts.isPrefixUnaryExpression(e) && e.operator === ts.SyntaxKind.MinusToken) { + const v = this.constNum(e.operand); + return v === null ? null : -v; + } + // SCCP-folded ref reads: `flag.value` where flag is proven constant. + // Scope check keeps shadowing locals (map/filter params) out. + { + const base = this.valueBase(e); + if (base !== null && this.sccpFolded?.has(base)) { + const b = this.scope.get(base); + if (!b || b.kind === "ref") return this.sccpFolded.get(base)!; + } + } if (ts.isIdentifier(e)) { const b = this.scope.get(e.text); if (b?.kind === "const" && typeof b.value === "number") return b.value; @@ -918,6 +963,11 @@ class AppCompiler { private compileViewInto(e: ts.Expression, target: string, out: string[], ind: string): void { e = this.unparen(e); if (ts.isConditionalExpression(e)) { + const cf = this.constNum(e.condition) ?? this.constBool(e.condition); + if (cf !== null) { + this.compileViewInto(cf ? e.whenTrue : e.whenFalse, target, out, ind); + return; + } const cond = this.compileExpr(e.condition, out, ind); out.push(`${ind}if (${this.condition(cond)}) {`); this.compileViewInto(e.whenTrue, target, out, ind + " "); @@ -1283,6 +1333,14 @@ class AppCompiler { return; } if (ts.isIfStatement(stmt)) { + // decidable condition (SCCP-folded refs, SCREEN geometry): emit only + // the taken branch — the dead arm leaves ROM and the dep set entirely + const cf = this.constNum(stmt.expression) ?? this.constBool(stmt.expression); + if (cf !== null) { + if (cf) this.compileStmt(stmt.thenStatement, out, ind); + else if (stmt.elseStatement) this.compileStmt(stmt.elseStatement, out, ind); + return; + } const cond = this.compileExpr(stmt.expression, out, ind); out.push(`${ind}if (${this.condition(cond)}) {`); this.compileStmt(stmt.thenStatement, out, ind + " "); @@ -1832,6 +1890,13 @@ class AppCompiler { e.whenFalse.kind !== ts.SyntaxKind.NullKeyword ) this.err(e, "conditional children must be {cond ? : null}"); + // decidable condition: the row is unconditionally present (a plain row + // unit, static if its paint has no deps) or not present at all + const cf = this.constNum(e.condition) ?? this.constBool(e.condition); + if (cf !== null) { + if (cf) return this.compileRowUnit(whenTrue); + return { span: [0, 0], deps: new Set(), decls: [], body: [], isStatic: true }; + } const deps = new Set(); const prev = this.curDeps; this.curDeps = deps; @@ -2165,7 +2230,12 @@ class AppCompiler { // ---- reports ---- const graphLines: string[] = []; graphLines.push("refs:"); - for (const r of this.refs) graphLines.push(` bit ${r.index}: ${r.name} (${r.refTy})`); + for (const r of this.refs) { + const folded = this.sccpFolded?.has(r.name) + ? ` = const ${this.sccpFolded.get(r.name)} (sccp: reads folded, never dirty)` + : ""; + graphLines.push(` bit ${r.index}: ${r.name} (${r.refTy})${folded}`); + } graphLines.push("computeds:"); for (const comp of this.computeds) graphLines.push( diff --git a/vapor/compiler/sccp.ts b/vapor/compiler/sccp.ts new file mode 100644 index 00000000..39721c2b --- /dev/null +++ b/vapor/compiler/sccp.ts @@ -0,0 +1,254 @@ +// vapor/compiler/sccp.ts — sparse conditional constant propagation over refs. +// +// The dependency graph the compiler bakes into ROM is a syntactic +// over-approximation: `flag.value ? a.value : b.value` subscribes to all +// three refs. This pass runs before setup analysis and asks a prior +// question: which refs can ever hold more than one value at runtime? +// +// A ref whose every reachable write either (a) sits behind a guard that is +// decidably false, or (b) stores the value the ref already holds, is a +// compile-time constant. Its reads fold, decidable ternaries and ifs pick +// one arm, and the dead arm's dependencies — and ROM code — disappear. +// +// The analysis is optimistic in the classic SCCP sense: every ref starts +// at const(seed), and writes only lower it. Guard reachability is judged +// under the *current* environment, so mutually-gated refs converge to +// const (`if (a.value) b.value = 1; if (b.value) a.value = 1;` with both +// seeded 0 keeps both at 0 — a pessimistic pass could not conclude that). +// Iteration re-examines every write site until the environment stabilizes; +// at fixpoint every pruning decision is consistent with the final env, +// which makes the result sound. +// +// Soundness relies on two subset properties the compiler enforces later: +// assignments only occur in statement position (so if/ternary conditions +// are the complete guard vocabulary), and no function or closure escapes +// setup (so every function-like node in the component body is the whole +// universe of callable code — all are conservatively assumed reachable; +// only statement-level guards prune). +// +// Deliberately conservative (v1): locals are not tracked (a write whose +// RHS reads a local is NAC), helper params are NAC, loop bodies count as +// reachable, and only num/bool refs fold — str/list refs start at NAC. + +import ts from "typescript"; + +export type SccpValue = { kind: "const"; value: number } | { kind: "nac" }; + +const NAC: SccpValue = { kind: "nac" }; +const cval = (v: number): SccpValue => ({ kind: "const", value: v | 0 }); + +function join(a: SccpValue, b: SccpValue): SccpValue { + if (a.kind === "nac" || b.kind === "nac") return NAC; + return a.value === b.value ? a : NAC; +} + +interface Guard { + cond: ts.Expression; + whenTruthy: boolean; +} + +type WriteOp = "=" | "+=" | "-=" | "*=" | "%=" | "++" | "--" | "nac"; + +interface WriteSite { + ref: string; + op: WriteOp; + rhs: ts.Expression | null; // null for ++/-- + guards: Guard[]; +} + +export interface SccpOptions { + component: ts.ArrowFunction; + /** local name of vue's `ref` import */ + refLocalName: string; + /** module-level folding (consts, SCREEN, Button, folded comparisons) */ + foldConst: (e: ts.Expression) => number | null; +} + +/** `name.value` -> name. */ +function valueBase(e: ts.Expression): string | null { + e = unparen(e); + if (ts.isPropertyAccessExpression(e) && e.name.text === "value" && ts.isIdentifier(e.expression)) + return e.expression.text; + return null; +} + +function unparen(e: ts.Expression): ts.Expression { + while (ts.isParenthesizedExpression(e)) e = e.expression; + return e; +} + +/** + * Run the analysis over one component. Returns only the refs proven + * constant: name -> value (booleans as 0/1). Everything absent is NAC. + */ +export function sccpRefConstants(opts: SccpOptions): Map { + const { component, refLocalName, foldConst } = opts; + if (!ts.isBlock(component.body)) return new Map(); + + // -- discover refs (top-level `const x = ref(seed)` only, like scanSetup) -- + const env = new Map(); + for (const stmt of component.body.statements) { + if (!ts.isVariableStatement(stmt)) continue; + for (const decl of stmt.declarationList.declarations) { + if (!ts.isIdentifier(decl.name) || !decl.initializer) continue; + const init = decl.initializer; + if (!ts.isCallExpression(init) || !ts.isIdentifier(init.expression)) continue; + if (init.expression.text !== refLocalName) continue; + const seed = init.arguments[0]; + if (!seed) continue; + env.set(decl.name.text, seedValue(seed, foldConst)); + } + } + if (env.size === 0) return new Map(); + + // -- collect write sites with their statement-level guard chains ---------- + const writes: WriteSite[] = []; + const K = ts.SyntaxKind; + const compound: Partial> = { + [K.EqualsToken]: "=", + [K.PlusEqualsToken]: "+=", + [K.MinusEqualsToken]: "-=", + [K.AsteriskEqualsToken]: "*=", + [K.PercentEqualsToken]: "%=", + }; + + const walk = (node: ts.Node, guards: Guard[]): void => { + if (ts.isIfStatement(node)) { + walk(node.expression, guards); + walk(node.thenStatement, [...guards, { cond: node.expression, whenTruthy: true }]); + if (node.elseStatement) walk(node.elseStatement, [...guards, { cond: node.expression, whenTruthy: false }]); + return; + } + if (ts.isConditionalExpression(node)) { + walk(node.condition, guards); + walk(node.whenTrue, [...guards, { cond: node.condition, whenTruthy: true }]); + walk(node.whenFalse, [...guards, { cond: node.condition, whenTruthy: false }]); + return; + } + if (ts.isBinaryExpression(node)) { + const op = compound[node.operatorToken.kind]; + const isAssign = + op !== undefined || + node.operatorToken.kind >= K.FirstAssignment && node.operatorToken.kind <= K.LastAssignment; + if (isAssign) { + const base = valueBase(node.left); + if (base && env.has(base)) writes.push({ ref: base, op: op ?? "nac", rhs: node.right, guards }); + walk(node.right, guards); + return; + } + } + if ( + (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) && + (node.operator === K.PlusPlusToken || node.operator === K.MinusMinusToken) + ) { + const base = valueBase(node.operand); + if (base && env.has(base)) { + writes.push({ ref: base, op: node.operator === K.PlusPlusToken ? "++" : "--", rhs: null, guards }); + return; + } + } + ts.forEachChild(node, (c) => walk(c, guards)); + }; + walk(component.body, []); + + // -- abstract evaluation under the current environment -------------------- + const evalExpr = (e: ts.Expression): SccpValue => { + e = unparen(e); + const k = foldConst(e); + if (k !== null) return cval(k); + if (e.kind === K.TrueKeyword) return cval(1); + if (e.kind === K.FalseKeyword) return cval(0); + const base = valueBase(e); + if (base) return env.get(base) ?? NAC; + if (ts.isPrefixUnaryExpression(e)) { + const v = evalExpr(e.operand); + if (v.kind !== "const") return NAC; + if (e.operator === K.ExclamationToken) return cval(v.value ? 0 : 1); + if (e.operator === K.MinusToken) return cval(-v.value); + return NAC; + } + if (ts.isBinaryExpression(e)) { + const op = e.operatorToken.kind; + if (op === K.AmpersandAmpersandToken || op === K.BarBarToken) { + const l = evalExpr(e.left); + if (l.kind !== "const") return NAC; // short-circuit needs a decided left + const lTruthy = l.value !== 0; + if (op === K.AmpersandAmpersandToken) return lTruthy ? evalExpr(e.right) : cval(0); + return lTruthy ? cval(1) : evalExpr(e.right); + } + const l = evalExpr(e.left); + const r = evalExpr(e.right); + if (l.kind !== "const" || r.kind !== "const") return NAC; + switch (op) { + case K.PlusToken: return cval(l.value + r.value); + case K.MinusToken: return cval(l.value - r.value); + case K.AsteriskToken: return cval(Math.imul(l.value, r.value)); + case K.LessThanToken: return cval(l.value < r.value ? 1 : 0); + case K.GreaterThanToken: return cval(l.value > r.value ? 1 : 0); + case K.LessThanEqualsToken: return cval(l.value <= r.value ? 1 : 0); + case K.GreaterThanEqualsToken: return cval(l.value >= r.value ? 1 : 0); + case K.EqualsEqualsEqualsToken: return cval(l.value === r.value ? 1 : 0); + case K.ExclamationEqualsEqualsToken: return cval(l.value !== r.value ? 1 : 0); + default: return NAC; // / stays unfolded: device semantics live in codegen + } + } + if (ts.isConditionalExpression(e)) { + const c = evalExpr(e.condition); + if (c.kind === "const") return evalExpr(c.value ? e.whenTrue : e.whenFalse); + return join(evalExpr(e.whenTrue), evalExpr(e.whenFalse)); + } + return NAC; + }; + + const writeValue = (w: WriteSite): SccpValue => { + const cur = env.get(w.ref)!; + if (w.op === "++" || w.op === "--") { + if (cur.kind !== "const") return NAC; + return cval(cur.value + (w.op === "++" ? 1 : -1)); + } + const rhs = w.rhs ? evalExpr(w.rhs) : NAC; + if (w.op === "=") return rhs; + if (cur.kind !== "const" || rhs.kind !== "const") return NAC; + switch (w.op) { + case "+=": return cval(cur.value + rhs.value); + case "-=": return cval(cur.value - rhs.value); + case "*=": return cval(Math.imul(cur.value, rhs.value)); + case "%=": return rhs.value === 0 ? NAC : cval(cur.value % rhs.value); + default: return NAC; + } + }; + + // -- fixpoint: env only descends; pruning re-judged each round ------------ + let changed = true; + let rounds = 0; + while (changed && rounds++ < 64) { + changed = false; + for (const w of writes) { + const cur = env.get(w.ref)!; + if (cur.kind === "nac") continue; + const dead = w.guards.some((g) => { + const v = evalExpr(g.cond); + return v.kind === "const" && (v.value !== 0) !== g.whenTruthy; + }); + if (dead) continue; + const merged = join(cur, writeValue(w)); + if (merged.kind !== cur.kind || (merged.kind === "const" && cur.kind === "const" && merged.value !== cur.value)) { + env.set(w.ref, merged); + changed = true; + } + } + } + + const folded = new Map(); + for (const [name, v] of env) if (v.kind === "const") folded.set(name, v.value); + return folded; +} + +function seedValue(seed: ts.Expression, foldConst: (e: ts.Expression) => number | null): SccpValue { + seed = unparen(seed); + if (seed.kind === ts.SyntaxKind.TrueKeyword) return cval(1); + if (seed.kind === ts.SyntaxKind.FalseKeyword) return cval(0); + const v = foldConst(seed); + if (v !== null) return cval(v); + return NAC; // str/list refs (and anything else) never fold +} diff --git a/vapor/tests/sccp.test.ts b/vapor/tests/sccp.test.ts new file mode 100644 index 00000000..a08023aa --- /dev/null +++ b/vapor/tests/sccp.test.ts @@ -0,0 +1,157 @@ +// vapor/tests/sccp.test.ts — sparse conditional constant propagation. +// +// The dependency graph is normally a syntactic over-approximation: +// `flag.value ? a.value : b.value` subscribes to all three. These tests pin +// the SCCP refinement: refs proven constant fold at compile time, decidable +// branches drop their dead arm from both the effect masks and the ROM. + +import { describe, expect, test } from "bun:test"; +import { compileVaporApp } from "../compiler/compile.ts"; + +const HEADER = ` +import { computed, ref } from "vue"; +import { Button, onButton } from "../../host/input.ts"; +`; + +function app(setup: string, handler: string, jsx: string): string { + return `${HEADER} +export default () => { +${setup} + onButton((b) => { +${handler} + }); + return ( + <> +${jsx} + + ); +}; +`; +} + +describe("sccp ref constant propagation", () => { + test("a never-written ref folds and splits the ternary mask", () => { + const src = app( + ` const flag = ref(false); + const a = ref(1); + const bb = ref(2);`, + ` if (b === Button.A) a.value += 1; + if (b === Button.B) bb.value += 1;`, + ` {flag.value ? a.value : bb.value}`, + ); + const out = compileVaporApp("sccp.tsx", src); + expect(out.graph).toContain("flag (bool) = const 0 (sccp: reads folded, never dirty)"); + // the effect subscribes to bb alone — a and flag are gone from the mask + expect(out.graph).toMatch(/eff_0: rows \[0, 1\) mask 0x4 \{bb\}/); + // and the dead arm never reaches ROM + expect(out.c).not.toContain("g_flag ?"); + expect(out.c).not.toContain("g_a :"); + }); + + test("writes behind a decidably-false guard are pruned (conditional propagation)", () => { + const src = app( + ` const locked = ref(true); + const secret = ref(0); + const count = ref(0);`, + ` if (b === Button.A) count.value += 1; + if (!locked.value) secret.value += 1;`, + ` {secret.value ? 9 : count.value} + {locked.value ? "LOCKED" : "OPEN"}`, + ); + const out = compileVaporApp("sccp.tsx", src); + // locked never written -> const 1; that kills secret's only write -> + // secret const 0; both rows and the handler's dead if fold + expect(out.graph).toContain("locked (bool) = const 1"); + expect(out.graph).toContain("secret (num) = const 0"); + expect(out.graph).toMatch(/eff_0: rows \[0, 1\) mask 0x4 \{count\}/); + // the pruned write's set-gate never reaches ROM (seeding `g_secret = 0;` + // legitimately remains for oracle/debug parity) + expect(out.c).not.toContain("g_secret !="); + expect(out.c).not.toContain("!g_locked"); + // row 1 folded to a static paint: exactly one effect remains + expect(out.graph.match(/eff_\d+:/g)?.length).toBe(1); + expect(out.c).toContain("LOCKED"); + expect(out.c).not.toContain("OPEN"); + }); + + test("optimistic fixpoint: mutually-gated refs converge to const", () => { + const src = app( + ` const a = ref(false); + const b2 = ref(false); + const count = ref(0);`, + ` if (b === Button.A) count.value += 1; + if (a.value) b2.value = true; + if (b2.value) a.value = true;`, + ` {a.value || b2.value ? 1 : count.value}`, + ); + const out = compileVaporApp("sccp.tsx", src); + expect(out.graph).toContain("a (bool) = const 0"); + expect(out.graph).toContain("b2 (bool) = const 0"); + expect(out.graph).toMatch(/mask 0x4 \{count\}/); + }); + + test("a genuinely-mutated ref does not fold; both arms stay subscribed", () => { + const src = app( + ` const flag = ref(false); + const a = ref(1); + const bb = ref(2);`, + ` if (b === Button.Select) flag.value = !flag.value; + if (b === Button.A) a.value += 1; + if (b === Button.B) bb.value += 1;`, + ` {flag.value ? a.value : bb.value}`, + ); + const out = compileVaporApp("sccp.tsx", src); + expect(out.graph).not.toContain("= const"); + expect(out.graph).toMatch(/eff_0: rows \[0, 1\) mask 0x7 \{flag, a, bb\}/); + }); + + test("writes that store the ref's current value do not break folding", () => { + const src = app( + ` const mode = ref(3); + const count = ref(0);`, + ` if (b === Button.A) { count.value += 1; mode.value = 3; } + if (b === Button.B) mode.value = 2 + 1;`, + ` {mode.value === 3 ? count.value : 0}`, + ); + const out = compileVaporApp("sccp.tsx", src); + expect(out.graph).toContain("mode (num) = const 3"); + expect(out.graph).toMatch(/mask 0x2 \{count\}/); + }); + + test("computed over folded refs becomes dep-free and leaves masks", () => { + const src = app( + ` const base = ref(10); + const count = ref(0); + const offset = computed(() => base.value * 2);`, + ` if (b === Button.A) count.value += 1;`, + ` {offset.value + count.value}`, + ); + const out = compileVaporApp("sccp.tsx", src); + expect(out.graph).toContain("base (num) = const 10"); + expect(out.graph).toContain("offset: num <- {}"); + expect(out.graph).toMatch(/mask 0x2 \{count\}/); + }); + + test("folded refs still seed state and keep their debug slots (oracle parity)", () => { + const src = app( + ` const flag = ref(true); + const count = ref(0);`, + ` if (b === Button.A) count.value += 1;`, + ` {flag.value ? count.value : 0}`, + ); + const out = compileVaporApp("sccp.tsx", src); + expect(out.c).toContain("g_flag = 1;"); + expect(out.debugSlots.map((s) => s.name)).toEqual(["flag", "count"]); + }); + + test("negative ref seeds initialize correctly (prefix-minus folding)", () => { + const src = app( + ` const cursor = ref(-1); + const count = ref(0);`, + ` if (b === Button.A) { cursor.value = 0; count.value += 1; }`, + ` {cursor.value + count.value}`, + ); + const out = compileVaporApp("sccp.tsx", src); + expect(out.c).toContain("g_cursor = -1;"); + }); +});