Skip to content

Commit 382b42f

Browse files
serpentbladeclaude
andcommitted
test(260602-dv1): cover ROZ123 flag/do-not-flag/malformed + repo sweep + docs
- 25 cases: $computed/$watch-getter/binding/interpolation/r-if/r-show/r-for positives; lifecycle/$watch-callback/@event/listeners/r-model/plain-fn/ top-level/nested-lifecycle negatives; malformed-no-crash; compile() surfaces ROZ123 on solid + lit - Repo-wide sweep over examples/ + packages/ui/ + tests/ asserts ZERO ROZ123 (30s timeout, matching the exposeValidator sweep) - $watch-getter test encodes the EAGER verdict (getter flagged, callback not) - features.md: one-sentence ROZ123 mention in the $refs section; diagnostics.md is auto-generated from codes.ts (untouched) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3fe3ee0 commit 382b42f

2 files changed

Lines changed: 306 additions & 0 deletions

File tree

docs/guide/features.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,8 @@ $onMount(() => {
548548

549549
This is the integration story for component libraries that wrap vanilla-JS engines (focus-trap, popper, downshift-style state machines): one `$refs.x` access, idiomatic per-target ref handling on the emit side.
550550

551+
Because `$refs` are only populated after mount, reading them in an eagerly-evaluated position — inside a `$computed(...)` body or `$watch` getter, or in a template binding / `{{ }}` interpolation / `r-if` / `r-show` / `r-for` iterable expression — is a compile error (`ROZ123`); read `$refs` inside `$onMount` (or any callback that runs after mount) instead.
552+
551553
## `$snapshot()` — crossing into untyped JS
552554

553555
`$snapshot(x)` is the escape hatch for handing a reactive value to a library that mutates the value's property descriptors. The canonical case is Chart.js's data config: Chart.js internally calls `Object.defineProperty(data, ...)` to install reactive getters, and Svelte 5's `$state` Proxy raises `state_descriptors_fixed` rather than allowing the mutation. The other five targets unwrap to plain values at read time and don't have this problem.
Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
// Quick 260602-dv1 — ROZ123 $refs-read-before-mount validator.
2+
//
3+
// Proves: a `$refs.<x>` read in a PRE-MOUNT evaluation position fires exactly
4+
// one ROZ123 (error) — inside a `$computed(...)` body, the `$watch` GETTER, a
5+
// `<template>` binding / `{{ }}` interpolation / `r-if` / `r-show` / `r-for`
6+
// iterable. Proves the DO-NOT-FLAG list produces ZERO ROZ123 — `$onMount` /
7+
// `$onUnmount` / `$onUpdate` bodies, the `$watch` CALLBACK, `@event` handlers,
8+
// `<listeners>` handlers, `r-model` targets, plain function bodies, top-level
9+
// `<script>` reads, and a nested lifecycle inside a `$computed`. Proves
10+
// malformed shapes never crash and never false-positive, and that compile()
11+
// surfaces ROZ123 on solid + lit without throwing. Closes with a repo-wide
12+
// sweep asserting ZERO ROZ123 across every committed .rozie under examples/ +
13+
// packages/ui/ + tests/.
14+
//
15+
// $watch-GETTER VERDICT (encoded by the getter tests below): EAGER → FLAGGED.
16+
// Empirically confirmed via compile(..., { target: 'solid' }) — the getter
17+
// lowers into `createEffect(on(() => getter(), …))` whose deps-fn runs at setup
18+
// BEFORE the `let elRef = null` ref binding is assigned (the eager-read hazard,
19+
// and a literal read-before-declaration of the ref). The CALLBACK is deferred.
20+
import { describe, it, expect } from 'vitest';
21+
import path from 'node:path';
22+
import { fileURLToPath } from 'node:url';
23+
import { readFileSync, readdirSync, type Dirent } from 'node:fs';
24+
import { parse } from '../../../parse.js';
25+
import { analyzeAST } from '../../analyze.js';
26+
import { compile } from '../../../compile.js';
27+
import { renderDiagnostic } from '../../../diagnostics/frame.js';
28+
import type { Diagnostic } from '../../../diagnostics/Diagnostic.js';
29+
30+
function diagnose(source: string, filename = 'RefsProbe.rozie'): Diagnostic[] {
31+
const { ast, diagnostics: parseDiags } = parse(source, { filename });
32+
if (!ast) {
33+
throw new Error(
34+
`parse() returned null AST: ${parseDiags.map((d) => d.message).join(', ')}`,
35+
);
36+
}
37+
return analyzeAST(ast).diagnostics;
38+
}
39+
40+
function byCode(diags: Diagnostic[], code: string): Diagnostic[] {
41+
return diags.filter((d) => d.code === code);
42+
}
43+
44+
/** A self-contained probe with a script + (optional) custom template. The
45+
* default template carries `ref="el"` so $refs.el is a declared ref (keeps
46+
* unknownRefValidator quiet — it is irrelevant to the ROZ123 assertions, which
47+
* all filter byCode). */
48+
const wrap = (
49+
script: string,
50+
template = `<input ref="el" />`,
51+
) => `<rozie name="RefsProbe">
52+
<data>{ v: '' }</data>
53+
<script>
54+
${script}
55+
</script>
56+
<template>${template}</template>
57+
</rozie>`;
58+
59+
/** Template-only probe: no <script>; the ref is declared inline in the template. */
60+
const wrapTemplate = (template: string) => `<rozie name="RefsProbe">
61+
<data>{ v: '' }</data>
62+
<template>${template}</template>
63+
</rozie>`;
64+
65+
/** Probe with a <listeners> block whose handler reads $refs. */
66+
const wrapWithListeners = (listeners: string) => `<rozie name="RefsProbe">
67+
<data>{ v: '' }</data>
68+
<template><input ref="x" /></template>
69+
<listeners>${listeners}</listeners>
70+
</rozie>`;
71+
72+
// ── POSITIVE — flagged contexts ─────────────────────────────────────────────
73+
74+
describe('refsPreMountValidator — POSITIVE flagged positions (ROZ123)', () => {
75+
it('$computed body — the falsified FlatpickrBehaviorDemo shape → one ROZ123 naming rangeEnd', () => {
76+
const src = wrap(
77+
`const plugin = () => ({});\nconst r = $computed(() => [plugin({ input: $refs.rangeEnd })])`,
78+
`<input ref="rangeEnd" />`,
79+
);
80+
const hits = byCode(diagnose(src), 'ROZ123');
81+
expect(hits.length).toBe(1);
82+
expect(hits[0]!.severity).toBe('error');
83+
const frame = renderDiagnostic(hits[0]!, src);
84+
expect(frame).toContain('ROZ123');
85+
expect(frame).toContain('rangeEnd');
86+
});
87+
88+
it('template binding :data-w="$refs.el.offsetWidth" → ROZ123', () => {
89+
const src = wrapTemplate(`<input ref="el" :data-w="$refs.el.offsetWidth" />`);
90+
expect(byCode(diagnose(src), 'ROZ123').length).toBe(1);
91+
});
92+
93+
it('interpolation {{ $refs.el.offsetWidth }} → ROZ123', () => {
94+
const src = wrapTemplate(`<input ref="el" /><span>{{ $refs.el.offsetWidth }}</span>`);
95+
expect(byCode(diagnose(src), 'ROZ123').length).toBe(1);
96+
});
97+
98+
it('r-if="$refs.el" → ROZ123', () => {
99+
const src = wrapTemplate(`<input ref="el" /><div r-if="$refs.el">x</div>`);
100+
expect(byCode(diagnose(src), 'ROZ123').length).toBe(1);
101+
});
102+
103+
it('r-show="$refs.el" → ROZ123', () => {
104+
const src = wrapTemplate(`<input ref="el" /><div r-show="$refs.el">x</div>`);
105+
expect(byCode(diagnose(src), 'ROZ123').length).toBe(1);
106+
});
107+
108+
it('r-for iterable "item in $refs.el.children" → ROZ123 (iterable RHS is render-time)', () => {
109+
// DECISION (Task 1): the r-for iterable IS flagged. The LHS alias `item` is
110+
// not a JS expression and is not parsed; the RHS `$refs.el.children` is
111+
// re-parsed at its computed byte offset and the $refs read is flagged.
112+
const src = wrapTemplate(
113+
`<input ref="el" /><div r-for="item in $refs.el.children" :key="item"><span>{{ item }}</span></div>`,
114+
);
115+
expect(byCode(diagnose(src), 'ROZ123').length).toBe(1);
116+
});
117+
});
118+
119+
// ── $watch GETTER vs CALLBACK ───────────────────────────────────────────────
120+
121+
describe('refsPreMountValidator — $watch getter verdict EAGER → FLAGGED', () => {
122+
it('$watch GETTER reading $refs → ONE ROZ123 (eager verdict)', () => {
123+
// EAGER verdict: solid lowers the getter into createEffect(on(...)) whose
124+
// deps-fn runs at setup before the ref is assigned. So the getter IS flagged.
125+
const src = wrap(
126+
`$watch(() => $refs.el?.offsetWidth, (w) => { $data.v = String(w) })`,
127+
);
128+
expect(byCode(diagnose(src), 'ROZ123').length).toBe(1);
129+
});
130+
131+
it('$watch CALLBACK reading $refs → ZERO ROZ123 (callback is deferred)', () => {
132+
const src = wrap(
133+
`$watch(() => $data.v, () => { foo($refs.el) })`,
134+
);
135+
expect(byCode(diagnose(src), 'ROZ123')).toEqual([]);
136+
});
137+
});
138+
139+
// ── NEGATIVE — do-not-flag positions ────────────────────────────────────────
140+
141+
describe('refsPreMountValidator — NEGATIVE do-not-flag positions (zero ROZ123)', () => {
142+
it('$onMount body reading $refs → zero ROZ123', () => {
143+
const src = wrap(`$onMount(() => { foo($refs.el) })`);
144+
expect(byCode(diagnose(src), 'ROZ123')).toEqual([]);
145+
});
146+
147+
it('$onUnmount body reading $refs → zero ROZ123', () => {
148+
const src = wrap(`$onUnmount(() => { foo($refs.el) })`);
149+
expect(byCode(diagnose(src), 'ROZ123')).toEqual([]);
150+
});
151+
152+
it('$onUpdate body reading $refs → zero ROZ123', () => {
153+
const src = wrap(`$onUpdate(() => { foo($refs.el) })`);
154+
expect(byCode(diagnose(src), 'ROZ123')).toEqual([]);
155+
});
156+
157+
it('event handler @click="$refs.x.focus()" → zero ROZ123', () => {
158+
const src = wrapTemplate(`<input ref="x" /><button @click="$refs.x.focus()">x</button>`);
159+
expect(byCode(diagnose(src), 'ROZ123')).toEqual([]);
160+
});
161+
162+
it('<listeners> handler reading $refs → zero ROZ123', () => {
163+
const src = wrapWithListeners(
164+
`<listener :target="window" @resize="$refs.x.focus()" />`,
165+
);
166+
expect(byCode(diagnose(src), 'ROZ123')).toEqual([]);
167+
});
168+
169+
it('r-model target → zero ROZ123', () => {
170+
const src = wrapTemplate(`<input ref="x" r-model="$data.v" />`);
171+
expect(byCode(diagnose(src), 'ROZ123')).toEqual([]);
172+
});
173+
174+
it('plain function body reading $refs (called post-mount) → zero ROZ123', () => {
175+
const src = wrap(`const reposition = () => { foo($refs.el) }`);
176+
expect(byCode(diagnose(src), 'ROZ123')).toEqual([]);
177+
});
178+
179+
it('top-level $refs read in <script> (not in $computed) → zero ROZ123', () => {
180+
const src = wrap(`const w = $refs.el`);
181+
expect(byCode(diagnose(src), 'ROZ123')).toEqual([]);
182+
});
183+
184+
it('nested $onMount inside $computed re-defers → zero ROZ123 (exotic edge)', () => {
185+
const src = wrap(
186+
`const c = $computed(() => { $onMount(() => foo($refs.el)); return 1 })`,
187+
);
188+
expect(byCode(diagnose(src), 'ROZ123')).toEqual([]);
189+
});
190+
});
191+
192+
// ── MALFORMED — no crash, no false positive ─────────────────────────────────
193+
194+
describe('refsPreMountValidator — MALFORMED inputs never crash (D-08)', () => {
195+
it('$computed() with no arg → no throw, zero ROZ123', () => {
196+
const src = wrap(`const c = $computed()`);
197+
expect(() => diagnose(src)).not.toThrow();
198+
expect(byCode(diagnose(src), 'ROZ123')).toEqual([]);
199+
});
200+
201+
it('$computed(notAFn) → no throw, zero ROZ123', () => {
202+
const src = wrap(`const fn = () => {}\nconst c = $computed(fn)`);
203+
expect(() => diagnose(src)).not.toThrow();
204+
expect(byCode(diagnose(src), 'ROZ123')).toEqual([]);
205+
});
206+
207+
it('bare $refs (no member) → no throw, zero ROZ123', () => {
208+
const src = wrap(`const c = $computed(() => $refs)`);
209+
expect(() => diagnose(src)).not.toThrow();
210+
expect(byCode(diagnose(src), 'ROZ123')).toEqual([]);
211+
});
212+
213+
it('unparseable template binding :x="$refs." → no throw, zero ROZ123', () => {
214+
const src = wrapTemplate(`<input ref="el" :x="$refs." />`);
215+
expect(() => diagnose(src)).not.toThrow();
216+
expect(byCode(diagnose(src), 'ROZ123')).toEqual([]);
217+
});
218+
219+
it('computed $refs["x"] in $computed → zero ROZ123 (ROZ106 owns it, not us)', () => {
220+
const src = wrap(`const c = $computed(() => $refs['x'])`);
221+
expect(() => diagnose(src)).not.toThrow();
222+
expect(byCode(diagnose(src), 'ROZ123')).toEqual([]);
223+
});
224+
});
225+
226+
// ── compile() surfaces ROZ123 on solid + lit, never throws ───────────────────
227+
228+
describe('refsPreMountValidator — compile() surfaces ROZ123 (solid + lit), never throws', () => {
229+
for (const target of ['solid', 'lit'] as const) {
230+
it(`compile() to ${target} never throws on the $computed shape and surfaces ROZ123`, () => {
231+
const src = wrap(
232+
`const plugin = () => ({});\nconst r = $computed(() => [plugin({ input: $refs.rangeEnd })])`,
233+
`<input ref="rangeEnd" />`,
234+
);
235+
expect(() => compile(src, { target })).not.toThrow();
236+
const result = compile(src, { target });
237+
expect(result.diagnostics.some((d) => d.code === 'ROZ123')).toBe(true);
238+
});
239+
}
240+
});
241+
242+
// ── Repo-wide sweep — ZERO ROZ123 across committed .rozie sources ────────────
243+
244+
describe('refsPreMountValidator — repo-wide sweep (ZERO ROZ123)', () => {
245+
// This test file lives at
246+
// packages/core/src/semantic/validators/__tests__/refsPreMountValidator.test.ts
247+
// → six `..` segments reach the repo root.
248+
const repoRoot = path.resolve(
249+
path.dirname(fileURLToPath(import.meta.url)),
250+
'../../../../../../',
251+
);
252+
253+
/** Recursively collect every `.rozie` file under `dir`, skipping node_modules / dist. */
254+
function collectRozieFiles(dir: string): string[] {
255+
const out: string[] = [];
256+
let entries: Dirent[];
257+
try {
258+
entries = readdirSync(dir, { withFileTypes: true, encoding: 'utf8' });
259+
} catch {
260+
return out; // dir doesn't exist — skip.
261+
}
262+
for (const ent of entries) {
263+
if (ent.name === 'node_modules' || ent.name === 'dist') continue;
264+
const full = path.join(dir, ent.name);
265+
if (ent.isDirectory()) {
266+
out.push(...collectRozieFiles(full));
267+
} else if (ent.isFile() && ent.name.endsWith('.rozie')) {
268+
out.push(full);
269+
}
270+
}
271+
return out;
272+
}
273+
274+
function roz123Hits(filePath: string): Diagnostic[] {
275+
const source = readFileSync(filePath, 'utf8');
276+
// ROZ123 is a SEMANTIC diagnostic emitted in analyzeAST (target-independent),
277+
// so one target suffices.
278+
const { diagnostics } = compile(source, { target: 'solid', filename: filePath });
279+
return diagnostics.filter((d) => d.code === 'ROZ123');
280+
}
281+
282+
it('SWEEP: no committed .rozie example/fixture trips ROZ123', () => {
283+
const roots = ['examples', 'packages/ui', 'tests']
284+
.map((r) => path.join(repoRoot, r))
285+
.flatMap((r) => collectRozieFiles(r));
286+
287+
expect(roots.length).toBeGreaterThan(0); // sanity: we actually found files.
288+
289+
const offenders: string[] = [];
290+
for (const file of roots) {
291+
if (roz123Hits(file).length > 0) {
292+
offenders.push(path.relative(repoRoot, file));
293+
}
294+
}
295+
// A non-empty offenders list is a STOP-and-report finding (a real latent
296+
// pre-mount $refs read in a shipped example) — surfaced in the assertion
297+
// message. The post-fix FlatpickrBehaviorDemo uses the safe $onMount pattern,
298+
// so this MUST be empty.
299+
expect(offenders, `latent ROZ123 ($refs-before-mount) in: ${offenders.join(', ')}`).toEqual([]);
300+
// 30s deadline: matches the exposeValidator sweep — @rozie/core's
301+
// vitest.config.ts has no global testTimeout, and under `turbo run test`
302+
// parallel CPU starvation this whole-repo compile races past the 5s default.
303+
}, 30_000);
304+
});

0 commit comments

Comments
 (0)