Skip to content

Commit 3fe3ee0

Browse files
serpentbladeclaude
andcommitted
feat(260602-dv1): add ROZ123 refsPreMountValidator ($refs read before mount)
- Register REFS_READ_BEFORE_MOUNT: 'ROZ123' with doc-quality registry comment - New refsPreMountValidator: flags $refs reads in $computed bodies, $watch getters, and template binding/interpolation/r-if/r-show/r-for-iterable exprs - $watch-getter verdict EAGER (flagged): solid lowers the getter into createEffect(on(...)) whose deps-fn runs at setup before the ref is assigned - Do-not-flag: lifecycle bodies, $watch callbacks, @event handlers, listeners, r-model targets, plain functions, top-level reads; nested defer via path.skip - Wire into analyzeAST after runEmitNameValidator; never throws (D-08) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4084548 commit 3fe3ee0

3 files changed

Lines changed: 360 additions & 0 deletions

File tree

packages/core/src/diagnostics/codes.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,38 @@ export const RozieErrorCode = {
164164
// SCOPE — no false positive. ROZ122 is the next free code after ROZ121 in the
165165
// 100 semantic-binding cluster.
166166
EMIT_EMPTY_EVENT_NAME: 'ROZ122', // $emit('') / $emit(' ') — empty/whitespace-only string-literal event name
167+
// Quick 260602-dv1 — a `$refs.<member>` read in a PRE-MOUNT evaluation position.
168+
// `$refs` are only populated AFTER the component mounts, so reading one in a
169+
// position that is evaluated during setup/render is a silent cross-target
170+
// divergence: on solid the eager memo / `$watch` effect reads `$refs` at setup
171+
// (before the ref callback assigns the element) → TDZ crash / null; on lit and
172+
// the other targets it simply yields null. This exact shape — `$computed(() =>
173+
// [rangePlugin({ input: $refs.rangeEnd })])` — was falsified in debug session
174+
// lit-rangeplugin-shadow-dom (the $computed crashed solid and silently no-op'd
175+
// lit), and Dan blessed turning the divergence into a LOUD compile error.
176+
//
177+
// FLAGGED (the two pre-mount eval contexts), emitted by refsPreMountValidator:
178+
// - inside a `$computed(...)` argument body in <script>;
179+
// - inside the `$watch(getter, cb)` GETTER (argument[0]) in <script> — the
180+
// getter is EAGER on solid: it lowers to `createEffect(on(() => getter(),
181+
// …))`, whose deps-fn runs at setup BEFORE the `let elRef = null` ref
182+
// binding is assigned (empirically confirmed, 260602-dv1 probe), the same
183+
// hazard as a `$computed` body;
184+
// - inside a <template> binding (`:plugins="…"`), a `{{ }}` interpolation,
185+
// or an `r-if` / `r-show` / `r-for`-iterable expression (all render-time).
186+
// DO-NOT-FLAG (all run post-mount when invoked): `$onMount`/`$onUnmount`/
187+
// `$onUpdate` bodies; the `$watch` CALLBACK (argument[1]); `@event` handler
188+
// expressions (`@click="$refs.x.focus()"`); `<listeners>` handlers; r-model
189+
// targets; r-for LHS aliases (not a JS expr); and plain function/method bodies
190+
// (and a `$refs` read at <script> Program top level — a separate concern, not
191+
// this validator's).
192+
// OUT OF SCOPE: computed `$refs['x']` is ROZ106's concern (detectMagicAccess
193+
// returns null for it); non-`$refs` magic accessors are unaffected.
194+
// Error severity; fires once per offending `$refs` read, code-framed at the
195+
// read, naming the member. Never throws (D-08): all template re-parses are
196+
// try/catch-wrapped. ROZ123 is the next free code after ROZ122 in the 100
197+
// semantic-binding cluster.
198+
REFS_READ_BEFORE_MOUNT: 'ROZ123', // $refs.x read in a $computed body / $watch getter / template binding|interpolation|r-if|r-show|r-for-iterable — evaluated before mount
167199

168200
// ---- Compile-time correctness errors (Phase 2 Plan 02) — ROZ200..ROZ299 ----
169201
WRITE_TO_NON_MODEL_PROP: 'ROZ200', // SEM-02: $props.foo = … where foo lacks model: true (Phase 2 success criterion 2)

packages/core/src/semantic/analyze.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { runReservedIdentifierValidator } from './validators/reservedIdentifierV
3737
import { runListenerElementValidator } from './validators/listenerElementValidator.js';
3838
import { runExposeValidator } from './validators/exposeValidator.js';
3939
import { runEmitNameValidator } from './validators/emitNameValidator.js';
40+
import { runRefsPreMountValidator } from './validators/refsPreMountValidator.js';
4041

4142
export interface AnalyzeResult {
4243
bindings: BindingsTable;
@@ -65,6 +66,8 @@ export function analyzeAST(ast: RozieAST): AnalyzeResult {
6566
runExposeValidator(ast, bindings, diagnostics);
6667
// Quick 260601-l2u — ROZ122: reject empty/whitespace-only $emit event names (script + template + listeners). No binding dependency.
6768
runEmitNameValidator(ast, diagnostics);
69+
// Quick 260602-dv1 — ROZ123: $refs read in a pre-mount eval position ($computed body / $watch getter / template binding/interpolation/r-if/r-show/r-for-iterable expr). No binding dependency.
70+
runRefsPreMountValidator(ast, diagnostics);
6871
// Phase 19 (D-08) — final pass: a <listener> placed inside <template> is a
6972
// misplaced element (ROZ206). No binding dependency.
7073
runListenerElementValidator(ast, diagnostics);
Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
/**
2+
* SEM (Quick 260602-dv1) — `$refs`-read-before-mount validator (ROZ123).
3+
*
4+
* `$refs` are only populated AFTER the component mounts. Reading a `$refs.<x>`
5+
* in a position that is evaluated DURING setup/render is a silent cross-target
6+
* divergence — on solid the eager memo / `$watch` effect reads `$refs` at setup
7+
* (before the ref callback assigns the element) → TDZ crash / null; on lit and
8+
* the other targets it simply yields null. The debug session
9+
* lit-rangeplugin-shadow-dom falsified the exact shape
10+
* `$computed(() => [rangePlugin({ input: $refs.rangeEnd })])` (it crashed solid
11+
* and silently no-op'd lit) and Dan blessed turning the divergence into a LOUD
12+
* compile error.
13+
*
14+
* ── FLAGGED (the two pre-mount evaluation contexts) ──────────────────────────
15+
* <script>:
16+
* - inside a `$computed(...)` argument body;
17+
* - inside the `$watch(getter, cb)` GETTER (argument[0]).
18+
* <template>:
19+
* - kind === 'binding' (`:plugins="…"`, `:disabled="…"`);
20+
* - kind === 'directive' for `if` / `show` / `text` / `html`;
21+
* - the ITERABLE (right-hand side) of an `r-for` (render-time);
22+
* - a `{{ ... }}` TemplateInterpolation.
23+
*
24+
* ── DO-NOT-FLAG (all run post-mount when invoked) ────────────────────────────
25+
* - `$onMount` / `$onUnmount` / `$onUpdate` callback bodies;
26+
* - the `$watch` CALLBACK (argument[1]);
27+
* - `@event` handler expressions (`@click="$refs.x.focus()"`);
28+
* - `<listeners>` handlers (this validator never walks <listeners>);
29+
* - `r-model` targets;
30+
* - the `r-for` LHS alias clause (`item in …` — not a JS expression);
31+
* - plain function / method bodies (called later, post-mount);
32+
* - a `$refs` read at <script> Program top level (a separate concern).
33+
*
34+
* ── $watch-GETTER VERDICT: EAGER → FLAGGED ───────────────────────────────────
35+
* Empirically confirmed (260602-dv1 probe, compile(..., { target: 'solid' })):
36+
* `$watch(() => $refs.el?.offsetWidth, cb)` lowers to
37+
* `createEffect(on(() => (() => elRef?.offsetWidth)(), …, { defer: true }));`
38+
* `let elRef: HTMLElement | null = null;` // declared AFTER the effect
39+
* `on`'s deps-function runs the getter at the effect's first run (setup time),
40+
* BEFORE the ref callback assigns `elRef` — the same eager-read hazard as a
41+
* `$computed` body (and a literal read-before-declaration of `elRef`). So the
42+
* getter is FLAGGED. The CALLBACK (argument[1]) fires in response to a change
43+
* (post-mount) and is ALWAYS do-not-flag.
44+
*
45+
* ── OUT OF SCOPE ─────────────────────────────────────────────────────────────
46+
* Computed `$refs['x']` is ROZ106's concern (detectMagicAccess returns null for
47+
* computed access). Non-`$refs` magic accessors are unaffected.
48+
*
49+
* ── Re-parse / byte-offset discipline ────────────────────────────────────────
50+
* Template expression text is re-parsed via @babel/parser.parseExpression inside
51+
* a try/catch (D-08: never throws — the parser layer already diagnosed malformed
52+
* mustache/expression text). Every emitted diagnostic carries an absolute
53+
* byte-offset loc: re-parsed fragments add the fragment's base offset (the
54+
* attribute valueLoc.start, the interpolation loc.start + 2 to skip `{{`, or the
55+
* r-for value offset + the iterable's position within the LHS string).
56+
*
57+
* This validator has NO bindings dependency.
58+
*
59+
* @experimental — shape may change before v1.0
60+
*/
61+
import * as t from '@babel/types';
62+
import _traverse from '@babel/traverse';
63+
import type { NodePath } from '@babel/traverse';
64+
import { parseExpression } from '@babel/parser';
65+
import type { RozieAST, SourceLoc } from '../../ast/types.js';
66+
import type { ScriptAST } from '../../ast/blocks/ScriptAST.js';
67+
import type {
68+
TemplateAST,
69+
TemplateNode,
70+
TemplateElement,
71+
TemplateAttr,
72+
} from '../../ast/blocks/TemplateAST.js';
73+
import type { Diagnostic } from '../../diagnostics/Diagnostic.js';
74+
import { RozieErrorCode } from '../../diagnostics/codes.js';
75+
import { detectMagicAccess } from '../visitors.js';
76+
77+
// Default-export interop: see unknownRefValidator.ts for the same pattern.
78+
type TraverseFn = typeof import('@babel/traverse').default;
79+
const traverse: TraverseFn =
80+
typeof _traverse === 'function'
81+
? _traverse
82+
: (_traverse as unknown as { default: TraverseFn }).default;
83+
84+
/** Callees whose callback argument runs post-mount — a `$refs` read inside one
85+
* is deferred and must NOT be flagged, even when nested inside a flagged
86+
* region (e.g. `$computed(() => { $onMount(() => use($refs.el)); … })`). */
87+
const DEFER_CALLEES = new Set(['$onMount', '$onUnmount', '$onUpdate']);
88+
89+
interface ValidatorContext {
90+
diagnostics: Diagnostic[];
91+
}
92+
93+
/**
94+
* Shift Babel-relative offsets (computed against the parsed expression
95+
* fragment) into absolute offsets in the .rozie file by adding `baseOffset`.
96+
*/
97+
function locFromNodeOffset(node: t.Node, baseOffset: number): SourceLoc {
98+
return {
99+
start: (node.start ?? 0) + baseOffset,
100+
end: (node.end ?? 0) + baseOffset,
101+
};
102+
}
103+
104+
/** A `$refs.<member>` static read (computed `$refs['x']` returns null → ROZ106). */
105+
function refsMember(node: t.Node): string | null {
106+
if (!t.isMemberExpression(node)) return null;
107+
const access = detectMagicAccess(node);
108+
return access && access.scope === 'refs' ? access.member : null;
109+
}
110+
111+
/** Emit ROZ123 for a `$refs.<refName>` read at `loc`. */
112+
function pushRefsPreMount(
113+
ctx: ValidatorContext,
114+
refName: string,
115+
loc: SourceLoc,
116+
): void {
117+
ctx.diagnostics.push({
118+
code: RozieErrorCode.REFS_READ_BEFORE_MOUNT,
119+
severity: 'error',
120+
message: `$refs.${refName} is read before mount — $refs are only populated after the component mounts, but this position is evaluated during setup/render.`,
121+
loc,
122+
hint: 'Read $refs only inside $onMount (or another callback that runs after mount). $computed bodies, $watch getters, and template/binding expressions evaluate too early.',
123+
});
124+
}
125+
126+
// ── <script> walk ────────────────────────────────────────────────────────────
127+
128+
/**
129+
* Traverse a FLAGGED region (a `$computed` body or a `$watch` getter), pushing
130+
* ROZ123 for every `$refs.<x>` read. Nested do-not-flag callbacks re-defer: when
131+
* we hit a `$onMount`/`$onUnmount`/`$onUpdate(...)` call OR a `$watch(...)`
132+
* callback (argument[1]), we `path.skip()` so its subtree is not flagged (a
133+
* `$refs` read in there runs post-mount). Nested `$computed`/`$watch`-getter
134+
* regions remain flagged (they re-enter via the same eager-eval contract). The
135+
* base offset is 0 — `<script>` nodes carry absolute .rozie offsets.
136+
*/
137+
function flagRefsInRegion(region: t.Node, ctx: ValidatorContext): void {
138+
const wrapped = t.file(
139+
t.program([t.expressionStatement(region as t.Expression)]),
140+
);
141+
traverse(wrapped, {
142+
CallExpression(path) {
143+
const callee = path.node.callee;
144+
if (!t.isIdentifier(callee)) return;
145+
if (DEFER_CALLEES.has(callee.name)) {
146+
// The whole call (its callback body) is deferred — do not flag inside.
147+
path.skip();
148+
} else if (callee.name === '$watch') {
149+
// A nested $watch: its CALLBACK (arg[1]) is deferred, but its GETTER
150+
// (arg[0]) is still eager and must remain flagged. Descend into the
151+
// getter explicitly, then skip the rest of this call subtree.
152+
const getter = path.node.arguments[0];
153+
if (
154+
getter &&
155+
(t.isArrowFunctionExpression(getter) || t.isFunctionExpression(getter))
156+
) {
157+
flagRefsInRegion(getter, ctx);
158+
}
159+
path.skip();
160+
}
161+
},
162+
MemberExpression(path) {
163+
const refName = refsMember(path.node);
164+
if (refName !== null) {
165+
pushRefsPreMount(ctx, refName, locFromNodeOffset(path.node, 0));
166+
}
167+
},
168+
});
169+
}
170+
171+
/**
172+
* Walk the `<script>` Program for the pre-mount eval contexts. We scope the flag
173+
* precisely with a CallExpression visitor: for each `$computed(fn)` descend into
174+
* `fn` (arg[0]); for each `$watch(getterFn, cb)` descend into `getterFn`
175+
* (arg[0]) ONLY (the getter is eager on solid — see the $watch-getter verdict
176+
* above; the callback is deferred). Anything outside these regions — top-level
177+
* reads, plain functions, lifecycle bodies — is never flagged.
178+
*/
179+
function validateScript(script: ScriptAST, ctx: ValidatorContext): void {
180+
traverse(script.program, {
181+
CallExpression(path) {
182+
const callee = path.node.callee;
183+
if (!t.isIdentifier(callee)) return;
184+
if (callee.name === '$computed') {
185+
const body = path.node.arguments[0];
186+
if (
187+
body &&
188+
(t.isArrowFunctionExpression(body) || t.isFunctionExpression(body))
189+
) {
190+
flagRefsInRegion(body, ctx);
191+
}
192+
} else if (callee.name === '$watch') {
193+
const getter = path.node.arguments[0];
194+
if (
195+
getter &&
196+
(t.isArrowFunctionExpression(getter) || t.isFunctionExpression(getter))
197+
) {
198+
flagRefsInRegion(getter, ctx);
199+
}
200+
}
201+
},
202+
});
203+
}
204+
205+
// ── <template> walk ──────────────────────────────────────────────────────────
206+
207+
/**
208+
* Re-parse a template-expression fragment and flag `$refs` reads. Returns
209+
* silently on parse failure (parser layer already diagnosed it). NEVER throws.
210+
*/
211+
function parseAndFlag(text: string, baseOffset: number, ctx: ValidatorContext): void {
212+
let expr: t.Expression;
213+
try {
214+
expr = parseExpression(text, { sourceType: 'module' });
215+
} catch {
216+
return; // malformed — parser-layer diagnostics cover it; stay silent (D-08).
217+
}
218+
const wrapped = t.file(t.program([t.expressionStatement(expr)]));
219+
traverse(wrapped, {
220+
MemberExpression(path) {
221+
const refName = refsMember(path.node);
222+
if (refName !== null) {
223+
pushRefsPreMount(ctx, refName, locFromNodeOffset(path.node, baseOffset));
224+
}
225+
},
226+
});
227+
}
228+
229+
// `(item, idx) in iterable` / `item of iterable` — find the keyword split so we
230+
// can re-parse ONLY the iterable RHS (render-time) and skip the alias LHS (not a
231+
// JS expression). Linear-time, bounded; mirrors extractRForAliases's posture.
232+
const R_FOR_KEYWORD = /\s+(?:in|of)\s+/;
233+
234+
/**
235+
* Flag `$refs` reads inside an `r-for` ITERABLE (the RHS of `… in iterable`).
236+
* The LHS alias clause is intentionally NOT parsed (it is `item` / `(item, idx)`
237+
* binding syntax, not a JS expression). baseOffset is the attribute
238+
* valueLoc.start plus the byte offset of the iterable within the raw value.
239+
*/
240+
function validateRForIterable(attr: TemplateAttr, ctx: ValidatorContext): void {
241+
if (attr.value === null || attr.valueLoc === null) return;
242+
const m = R_FOR_KEYWORD.exec(attr.value);
243+
if (!m || m.index === undefined) return; // malformed r-for — rForKeyValidator owns it.
244+
const iterableStart = m.index + m[0].length;
245+
const iterable = attr.value.slice(iterableStart);
246+
parseAndFlag(iterable, attr.valueLoc.start + iterableStart, ctx);
247+
}
248+
249+
/**
250+
* Walk a TemplateAttr's expression value for the render-time positions only.
251+
* SKIP `event` (handlers run on user action, post-mount), `r-model` (binding
252+
* target), and `static`. FLAG `binding` and `directive` `if`/`show`/`text`/
253+
* `html`; the `r-for` iterable RHS is flagged via validateRForIterable.
254+
*/
255+
function validateTemplateAttr(attr: TemplateAttr, ctx: ValidatorContext): void {
256+
if (attr.value === null || attr.valueLoc === null) return;
257+
if (attr.kind === 'event') return; // @click etc. — post-mount.
258+
if (attr.kind === 'binding') {
259+
parseAndFlag(attr.value, attr.valueLoc.start, ctx);
260+
return;
261+
}
262+
if (attr.kind === 'directive') {
263+
if (attr.name === 'for') {
264+
validateRForIterable(attr, ctx);
265+
return;
266+
}
267+
if (attr.name === 'model') return; // r-model target — do-not-flag.
268+
if (
269+
attr.name === 'if' ||
270+
attr.name === 'show' ||
271+
attr.name === 'text' ||
272+
attr.name === 'html'
273+
) {
274+
parseAndFlag(attr.value, attr.valueLoc.start, ctx);
275+
}
276+
// Other directives (e.g. r-key) are not pre-mount eval positions for $refs.
277+
}
278+
}
279+
280+
function isElement(node: TemplateNode): node is TemplateElement {
281+
return node.type === 'TemplateElement';
282+
}
283+
284+
function isInterpolation(
285+
node: TemplateNode,
286+
): node is { type: 'TemplateInterpolation'; rawExpr: string; loc: SourceLoc } {
287+
return node.type === 'TemplateInterpolation';
288+
}
289+
290+
function visitTemplateNode(node: TemplateNode, ctx: ValidatorContext): void {
291+
if (isInterpolation(node)) {
292+
// {{ ... }} — baseOffset = loc.start + 2 (skipping `{{`).
293+
parseAndFlag(node.rawExpr, node.loc.start + 2, ctx);
294+
return;
295+
}
296+
if (!isElement(node)) return;
297+
for (const attr of node.attributes) {
298+
validateTemplateAttr(attr, ctx);
299+
}
300+
for (const child of node.children) {
301+
visitTemplateNode(child, ctx);
302+
}
303+
}
304+
305+
function validateTemplate(template: TemplateAST, ctx: ValidatorContext): void {
306+
for (const child of template.children) {
307+
visitTemplateNode(child, ctx);
308+
}
309+
}
310+
311+
/**
312+
* Run the `$refs`-read-before-mount validator over the given AST. Emits ROZ123
313+
* into `diagnostics`. NEVER throws (D-08). No bindings dependency.
314+
*
315+
* Note: <listeners> is intentionally NOT walked — listener handlers run
316+
* post-mount (do-not-flag).
317+
*/
318+
export function runRefsPreMountValidator(
319+
ast: RozieAST,
320+
diagnostics: Diagnostic[],
321+
): void {
322+
const ctx: ValidatorContext = { diagnostics };
323+
if (ast.script) validateScript(ast.script, ctx);
324+
if (ast.template) validateTemplate(ast.template, ctx);
325+
}

0 commit comments

Comments
 (0)