volaro 0.0.2 → 0.1.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +114 -22
  2. package/bin/vl.js +466 -28
  3. package/compiler/SOURCE_INFO.json +6 -0
  4. package/compiler/SOURCE_REV +1 -0
  5. package/compiler/validator/vlcheck/__init__.py +10 -0
  6. package/compiler/validator/vlcheck/__main__.py +128 -0
  7. package/compiler/validator/vlcheck/ast_nodes.py +397 -0
  8. package/compiler/validator/vlcheck/checks.py +1227 -0
  9. package/compiler/validator/vlcheck/diagnostics.py +88 -0
  10. package/compiler/validator/vlcheck/lexer.py +343 -0
  11. package/compiler/validator/vlcheck/parser.py +1638 -0
  12. package/compiler/validator/vlcheck/project_config.py +97 -0
  13. package/compiler/validator/vlcheck/resolve.py +849 -0
  14. package/compiler/validator/vlcheck/test_ids.py +98 -0
  15. package/compiler/vlbuild/styling/README.md +39 -0
  16. package/compiler/vlbuild/styling/build-css.mjs +103 -0
  17. package/compiler/vlbuild/styling/package-lock.json +1254 -0
  18. package/compiler/vlbuild/styling/package.json +15 -0
  19. package/compiler/vlbuild/styling/test-build-css.mjs +69 -0
  20. package/compiler/vlbuild/vlbuild/__init__.py +16 -0
  21. package/compiler/vlbuild/vlbuild/__main__.py +246 -0
  22. package/compiler/vlbuild/vlbuild/assets/vlrt.css +165 -0
  23. package/compiler/vlbuild/vlbuild/assets/vlrt.js +1291 -0
  24. package/compiler/vlbuild/vlbuild/emit.py +2023 -0
  25. package/compiler/vlbuild/vlbuild/server_emit.py +1551 -0
  26. package/compiler/vlbuild/vlbuild/static_assets.py +83 -0
  27. package/compiler/vlbuild/vlbuild/style_config.py +347 -0
  28. package/compiler/vlbuild/vlbuild/styling.py +39 -0
  29. package/examples/station.vl +2 -2
  30. package/language/crib.md +131 -10
  31. package/language/spec.md +209 -7
  32. package/language/supported.md +185 -0
  33. package/lib/env.js +107 -0
  34. package/package.json +19 -2
  35. package/scripts/record-provenance.mjs +51 -0
  36. package/scripts/selftest.mjs +54 -0
  37. package/scripts/sync-compiler.sh +52 -0
@@ -0,0 +1,849 @@
1
+ """L1 resolution pass — `vlcheck --resolve`.
2
+
3
+ The Stage-3 G2 grader for the Volaro arm (`corpus/STAGE3-PROTOCOL.md` §6). Raises
4
+ `vlcheck` from "parses" (L0) to "every referenced name / field / route / argument
5
+ resolves with the right arity" (L1) — the rung `tsc --noEmit` occupies for the
6
+ baseline arm.
7
+
8
+ A resolver, **not** type inference. It builds a symbol table from declarations
9
+ (this file's, plus those reached through `use`), tracks a local name → declared
10
+ type only where the type is written down or trivially known, and reports:
11
+
12
+ R-UNDEF name / component resolves to no declaration / import / builtin / binding
13
+ R-IMPORT `use ./x { A }` where x is found but has no `A`
14
+ R-ROUTE `api.f(...)` / `<svc>.f(...)` with no matching route / endpoint
15
+ R-ARITY call to a resolved fn / view / route / endpoint / variant, wrong # of positional args
16
+ R-NAMED-ARG named argument not in the callee's parameter list
17
+ R-MISSING-ARG required parameter (no default, not optional-typed) not supplied
18
+ R-FIELD `x.f` where x's type is a known record / model and f is neither a field nor a method
19
+ R-MATCH `match` over a value of known union type, not exhaustive, no `_`
20
+ R-AUTH-METHOD `auth.f(...)` where `f` is not one of the eight §11.3 calls
21
+
22
+ Deliberately conservative — an unknown callee, an unknown receiver type, an
23
+ element attribute value, or a `std.*` function (the spec gives no signature, and
24
+ we do not invent one) is skipped rather than guessed. The Stage-3 hand audit
25
+ covers the residue; a *false positive* here would unfairly fail a sample, so the
26
+ bar (protocol §9.1) is: every hand-written reference `.vl` in `corpus/` resolves
27
+ clean.
28
+
29
+ Known spec looseness this pass papers over (each already in `PROJECT.md` §5.3 or
30
+ the findings log):
31
+ * `std.ui` view surface (uppercase, no local `view`) is unlisted — `_UI_STD_VIEWS`;
32
+ the lowercase primitive set is `_UI_PRIMITIVES` (spec §3.4, exact);
33
+ * a handful of bare-word identifiers used as argument values (`asc`, `desc`,
34
+ `google`, …) — `_CONTEXTUAL`.
35
+
36
+ The §10 item-route client name is now settled (method-aware) and implemented
37
+ exactly in `_route_name`.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import os
43
+
44
+ from . import ast_nodes as A
45
+ from .checks import BUILTINS, _PRIM_NAMES
46
+ from .diagnostics import Diagnostic, LineIndex, Severity
47
+ from .lexer import Lexer
48
+ from .parser import Parser
49
+
50
+ # std.list / std.map / std math names referenced bare in the spec + corpus.
51
+ # Names only — no arity is checked for these (no signature to check against).
52
+ _STD_EXTRA = {
53
+ "sum", "count", "min", "max", "abs", "round", "floor", "ceil", "avg",
54
+ "reverse", "sorted", "unique", "flatten", "concat", "zip", "enumerate",
55
+ "range", "any", "all", "none", "contains", "index_of", "reduce", "slice",
56
+ "float_to_int", "trim", "upper", "lower", "starts_with", "ends_with",
57
+ "replace", "pad_start", "pad_end", "repeat", "now", "uuid4", "random",
58
+ "matches",
59
+ }
60
+ # bare-word identifiers the spec uses in argument position (not bindings)
61
+ _CONTEXTUAL = {"asc", "desc", "google", "github"}
62
+ # Spec §3.4: a component's case carries meaning.
63
+ # lowercase -> a std.ui *primitive element*, exactly this set (also in
64
+ # `checks.BUILTINS`, which is how a bare lowercase element resolves);
65
+ # uppercase -> a *view*, either declared with `view` here / imported, or one of
66
+ # the std.ui-provided views listed below.
67
+ _UI_PRIMITIVES = {
68
+ "col", "row", "stack", "grid", "text", "img", "button", "input", "link",
69
+ "spacer", "status",
70
+ }
71
+ _UI_STD_VIEWS = {
72
+ "Spinner", "Avatar", "Icon", "Image", "Portal", "Fragment", "Divider",
73
+ "Skeleton", "Badge",
74
+ }
75
+ # Spec §11.3, exact: the compiler-owned `auth` client surface, eight calls.
76
+ # Unlike `_STD_EXTRA` (names with no signature to check), the spec gives each of
77
+ # these a parameter list, so an unknown method or a wrong argument here is a
78
+ # definite mistake in a closed API, not a guess about an open one.
79
+ #
80
+ # This is *language* validity, deliberately not build-target support: every call
81
+ # the spec defines belongs in this table even when a given target cannot emit it
82
+ # yet. `vlcheck` says whether the program is written correctly; a target that
83
+ # does not implement `verify_totp` or `start_oauth` still fails the build with
84
+ # its own BuildError, which is where "unsupported here" is reported.
85
+ #
86
+ # Each entry is the parameter list in declaration order: (name, required).
87
+ _AUTH_API = {
88
+ "sign_up": (("email", True), ("password", True)),
89
+ "sign_in": (("email", True), ("password", True)),
90
+ "sign_out": (),
91
+ "start_oauth": (("provider", True),),
92
+ "reset_password": (("email", True),),
93
+ "confirm_reset": (("token", True), ("password", True)),
94
+ "confirm_verify": (("token", True),),
95
+ "verify_totp": (("code", True),),
96
+ }
97
+
98
+ # member names that are methods, not record fields — never flag `x.<this>`
99
+ _METHODS = {
100
+ "len", "format", "reload", "loading", "all", "first", "get", "where",
101
+ "order", "limit", "offset", "insert", "update", "delete", "count", "map",
102
+ "filter", "sort_by", "group_by", "take", "chunk", "join", "split", "trim",
103
+ "upper", "lower", "keys", "values", "contains", "has", "to_int", "to_str",
104
+ "to_float", "add", "sub", "abs", "min", "max", "round", "reverse", "unique",
105
+ "slice", "index_of", "starts_with", "ends_with", "replace", "parse", "raw",
106
+ "reload", "cancel", "retry", "emit", "close", "open", "send",
107
+ }
108
+
109
+ _KNOWN = (set(BUILTINS) | set(_PRIM_NAMES) | _STD_EXTRA | _CONTEXTUAL
110
+ | {"true", "false", "nil", "self", "_", "?"})
111
+
112
+
113
+ class Sym:
114
+ __slots__ = ("kind", "params", "fields", "variants", "ret", "routes",
115
+ "endpoints", "target")
116
+
117
+ def __init__(self, kind, *, params=None, fields=None, variants=None,
118
+ ret=None, routes=None, endpoints=None, target=None):
119
+ self.kind = kind # record union alias fn view service api variant endpoint route opaque
120
+ self.params = params # [(name, has_default, is_optional)]
121
+ self.fields = fields # {name: (base, is_list)}
122
+ self.variants = variants # {vname: nargs}
123
+ self.ret = ret # (base, is_list) | None
124
+ self.routes = routes # {clientkey: [Sym(route), ...]}
125
+ self.endpoints = endpoints # {name: Sym(endpoint)}
126
+ self.target = target # alias target base name
127
+
128
+
129
+ def _tref(t):
130
+ """A.TypeRef -> (base_name|None, is_list, is_optional)."""
131
+ if not isinstance(t, A.TypeRef):
132
+ return (None, False, False)
133
+ is_opt = bool(getattr(t, "optional", False))
134
+ if t.name == "list" and getattr(t, "args", None):
135
+ inner = t.args[0]
136
+ return (getattr(inner, "name", None), True, is_opt)
137
+ return (t.name or None, False, is_opt)
138
+
139
+
140
+ def _params(plist):
141
+ out = []
142
+ for p in plist or []:
143
+ _b, _l, opt = _tref(getattr(p, "type", None))
144
+ out.append((p.name, getattr(p, "default", None) is not None, opt))
145
+ return out
146
+
147
+
148
+ def _fields(flist):
149
+ out = {}
150
+ for f in flist or []:
151
+ b, is_list, _o = _tref(getattr(f, "type", None))
152
+ out[f.name] = (b, is_list)
153
+ return out
154
+
155
+
156
+ def _singularise(seg: str) -> str:
157
+ """Spec §10: strip a trailing `es` after s/x/z/ch/sh, then a trailing `s`;
158
+ a name neither rule changes gets the suffix `_item`."""
159
+ for pre in ("s", "x", "z", "ch", "sh"):
160
+ if seg.endswith(pre + "es"):
161
+ return seg[:-2]
162
+ if len(seg) > 1 and seg.endswith("s"):
163
+ return seg[:-1]
164
+ return seg + "_item"
165
+
166
+
167
+ def _route_name(method: str, path: str, alias: str | None = None) -> str:
168
+ """The generated client name for a route (spec §10, method-aware). One name
169
+ per route; a duplicate across a service is R-ROUTE-DUP."""
170
+ if alias:
171
+ return alias
172
+ segs = [s for s in path.split("/") if s]
173
+ bare = [s for s in segs if not s.startswith(":")]
174
+ seg = bare[-1] if bare else "root"
175
+ has_params = any(s.startswith(":") for s in segs)
176
+ ends_in_param = bool(segs) and segs[-1].startswith(":")
177
+
178
+ if has_params and not ends_in_param: # action: /posts/:id/publish
179
+ return seg
180
+ sing = _singularise(seg)
181
+ if ends_in_param: # item: /posts/:id
182
+ return {"post": "create_" + sing, "put": "update_" + sing,
183
+ "patch": "update_" + sing, "delete": "delete_" + sing}.get(method, sing)
184
+ # collection: /posts (get keeps the plural as written)
185
+ return {"post": "create_" + sing, "put": "update_" + sing,
186
+ "patch": "update_" + sing, "delete": "delete_" + sing}.get(method, seg)
187
+
188
+
189
+ def _route_path_params(path: str):
190
+ """Path params become required named params, in path order (spec §10)."""
191
+ return [(s[1:], False, False) for s in path.split("/") if s.startswith(":")]
192
+
193
+
194
+ def _iter_directives(items):
195
+ for d in items or []:
196
+ if isinstance(d, A.Directive):
197
+ yield d
198
+ yield from _iter_directives(getattr(d, "body", None))
199
+
200
+
201
+ def _collect_module(path: str, loading: set[str]) -> dict[str, Sym]:
202
+ try:
203
+ with open(path, "r", encoding="utf-8") as fh:
204
+ src = fh.read()
205
+ except OSError:
206
+ return {}
207
+ toks = Lexer(src, path).lex()
208
+ mod = Parser(toks, src, path).parse_module()
209
+ r = Resolver(path, LineIndex(src), _loading=loading, emit=False)
210
+ r._build_table(mod)
211
+ return r.table
212
+
213
+
214
+ class Resolver:
215
+ def __init__(self, filename: str, li: LineIndex, _loading=None, emit=True):
216
+ self.filename = filename
217
+ self.li = li
218
+ self.emit = emit
219
+ self._loading = set(_loading or ()) | {os.path.abspath(filename)}
220
+ self.table: dict[str, Sym] = {}
221
+ self.diags: list[Diagnostic] = []
222
+ # E-VIEW-MODULE-FN: a view (or a closure / handler inside one) may not
223
+ # call a module-level `fn` -- `vlbuild` does not lower one into view
224
+ # code. Tracked here so the check reuses this walker's lexical scope.
225
+ self._in_view = False
226
+ self._view_name = ""
227
+ self._vmf_seen: set[str] = set()
228
+
229
+ # ------------------------------------------------------------------ api
230
+ def run(self, mod: A.Module) -> list[Diagnostic]:
231
+ self._build_table(mod)
232
+ self._walk_module(mod)
233
+ return self.diags
234
+
235
+ def _d(self, code, msg, node, fix=None):
236
+ if not self.emit:
237
+ return
238
+ s = getattr(node, "start", 0)
239
+ e = getattr(node, "end", s + 1)
240
+ self.diags.append(Diagnostic(code, Severity.ERROR, msg, s, max(e, s + 1),
241
+ fix, self.filename))
242
+
243
+ # ------------------------------------------------------------- symbol table
244
+ def _build_table(self, mod: A.Module) -> None:
245
+ for it in mod.items:
246
+ if isinstance(it, A.Use):
247
+ self._load_use(it)
248
+ elif isinstance(it, A.TypeDecl):
249
+ self._add_typedecl(it)
250
+ elif isinstance(it, A.ModelDecl):
251
+ self.table[it.name] = Sym("record", fields=_fields(it.fields))
252
+ elif isinstance(it, A.FnDecl):
253
+ self.table[it.name] = Sym("fn", params=_params(it.params),
254
+ ret=_tref(it.ret)[:2])
255
+ elif isinstance(it, A.ViewDecl):
256
+ self.table[it.name] = Sym("view", params=_params(it.params))
257
+ elif isinstance(it, A.ServiceDecl):
258
+ routes: dict[str, Sym] = {}
259
+ seen: dict[str, A.Route] = {}
260
+ for r in it.routes:
261
+ name = _route_name(r.method, r.path, getattr(r, "alias", None))
262
+ rs = Sym("route",
263
+ params=_route_path_params(r.path) + _params(r.params),
264
+ ret=_tref(r.ret)[:2])
265
+ if name in seen:
266
+ prev = seen[name]
267
+ self._d("R-ROUTE-DUP",
268
+ f"routes '{prev.method} {prev.path}' and "
269
+ f"'{r.method} {r.path}' both generate client name '{name}'",
270
+ r, "disambiguate one route with 'as <name>'")
271
+ else:
272
+ seen[name] = r
273
+ routes[name] = rs
274
+ self.table[it.name] = Sym("service", routes=routes)
275
+ elif isinstance(it, A.ApiDecl):
276
+ eps = {ep.name: Sym("endpoint", params=_params(ep.params),
277
+ ret=_tref(ep.ret)[:2]) for ep in it.endpoints}
278
+ self.table[it.name] = Sym("api", endpoints=eps)
279
+ elif isinstance(it, A.AuthDecl):
280
+ for d in _iter_directives(it.settings):
281
+ if d.head == "provider":
282
+ for tok in d.tokens:
283
+ if tok and tok.isidentifier():
284
+ self.table.setdefault(tok, Sym("opaque"))
285
+
286
+ def _add_typedecl(self, it: A.TypeDecl) -> None:
287
+ if it.kind == "record":
288
+ self.table[it.name] = Sym("record", fields=_fields(it.fields))
289
+ elif it.kind == "union":
290
+ variants = {}
291
+ for v in it.variants:
292
+ vparams = getattr(v, "params", []) or []
293
+ variants[v.name] = len(vparams)
294
+ self.table[v.name] = Sym("variant", params=_params(vparams),
295
+ ret=(it.name, False))
296
+ self.table[it.name] = Sym("union", variants=variants)
297
+ elif it.kind == "alias":
298
+ self.table[it.name] = Sym("alias", target=_tref(it.alias_of)[0])
299
+
300
+ def _load_use(self, it: A.Use) -> None:
301
+ path, names = it.path, list(it.names or [])
302
+
303
+ def opaque_all():
304
+ for n in names:
305
+ self.table.setdefault(n, Sym("opaque"))
306
+
307
+ if path.startswith("std."):
308
+ opaque_all()
309
+ self.table.setdefault(path.split(".")[-1], Sym("opaque"))
310
+ return
311
+ if not (path.startswith("./") or path.startswith("../")):
312
+ opaque_all()
313
+ self.table.setdefault(path.split("/")[-1], Sym("opaque"))
314
+ return
315
+
316
+ base = os.path.normpath(os.path.join(os.path.dirname(self.filename), path))
317
+ mod_name = os.path.basename(base).split(".")[0]
318
+ target = next((c for c in (base + ".vl", os.path.join(base, "index.vl"), base)
319
+ if os.path.isfile(c)), None)
320
+
321
+ # A project-relative import (./ ../) MUST resolve. Silently degrading to
322
+ # opaque here is a false-pass channel: it disables every field / arity /
323
+ # exhaustiveness check for the "imported" symbols. `std.*` and bare
324
+ # dependency names still degrade opaque above — those are genuinely
325
+ # external (an explicit dependency manifest is a v0.3 item).
326
+ fault = None
327
+ if target is None:
328
+ fault = ("R-USE", f"cannot resolve import '{path}': no file at '{base}.vl'",
329
+ "check the path — './' and '../' are relative to this file")
330
+ elif os.path.abspath(target) in self._loading:
331
+ fault = ("R-USE-CYCLE", f"circular import: '{path}'",
332
+ "move the shared declarations into a third file both can import")
333
+ elif len(self._loading) > 16:
334
+ fault = ("R-USE-DEPTH", f"import chain deeper than 16 files at '{path}'",
335
+ "flatten the module graph")
336
+ if fault is not None:
337
+ self._d(fault[0], fault[1], it, fault[2])
338
+ opaque_all()
339
+ self.table.setdefault(mod_name, Sym("opaque"))
340
+ return
341
+
342
+ imported = _collect_module(target, self._loading)
343
+ take = names or list(imported.keys())
344
+ for n in take:
345
+ s = imported.get(n)
346
+ if s is None:
347
+ if names:
348
+ self._d("R-IMPORT", f"'{n}' is not declared in '{path}'", it,
349
+ "check the name or the imported file")
350
+ self.table.setdefault(n, Sym("opaque"))
351
+ continue
352
+ self.table[n] = s
353
+ if s.kind == "union" and s.variants:
354
+ for vn in s.variants:
355
+ self.table.setdefault(
356
+ vn, imported.get(vn, Sym("variant", ret=(n, False))))
357
+ self.table.setdefault(os.path.basename(base).split(".")[0], Sym("opaque"))
358
+
359
+ # ------------------------------------------------------------------- walk
360
+ def _walk_module(self, mod: A.Module) -> None:
361
+ for it in mod.items:
362
+ if isinstance(it, A.FnDecl):
363
+ sc = {p.name: _tref(p.type)[:2] for p in it.params}
364
+ self._scoped(it.body, [sc])
365
+ elif isinstance(it, A.ViewDecl):
366
+ sc = {p.name: _tref(p.type)[:2] for p in it.params}
367
+ sc["session"] = None
368
+ for s in it.body:
369
+ if isinstance(s, A.StateDecl):
370
+ sc[s.name] = (_tref(s.type)[:2] if s.type
371
+ else self._typeof(s.init, [sc]))
372
+ elif isinstance(s, A.DeriveDecl):
373
+ sc[s.name] = None
374
+ elif isinstance(s, A.LoadDecl):
375
+ sc[s.name] = self._load_type(s, [sc])
376
+ self._in_view = True
377
+ self._view_name = it.name
378
+ self._vmf_seen = set()
379
+ self._scoped(it.body, [sc])
380
+ self._in_view = False
381
+ elif isinstance(it, A.ServiceDecl):
382
+ for r in it.routes:
383
+ sc = {p.name: _tref(p.type)[:2] for p in r.params}
384
+ sc["session"] = None
385
+ for seg in r.path.split("/"):
386
+ if seg.startswith(":"):
387
+ sc[seg[1:]] = None
388
+ self._scoped(r.body, [sc])
389
+
390
+ def _load_type(self, s: A.LoadDecl, scopes):
391
+ if isinstance(s.expr, A.Call):
392
+ sym = self._client_sym(s.expr.callee)
393
+ if sym is not None and sym.ret:
394
+ return sym.ret
395
+ return None
396
+
397
+ # -------------------------------------------------------------- statements
398
+ def _scoped(self, stmts, scopes):
399
+ for st in stmts or []:
400
+ self._stmt(st, scopes)
401
+
402
+ def _block(self, stmts, scopes):
403
+ self._scoped(stmts, scopes + [{}])
404
+
405
+ def _declare(self, name, ti, scopes):
406
+ if name and name != "?":
407
+ scopes[-1][name] = ti
408
+
409
+ def _stmt(self, st, scopes):
410
+ if st is None:
411
+ return
412
+ if isinstance(st, A.Let):
413
+ self._expr(st.init, scopes)
414
+ ti = _tref(st.type)[:2] if st.type else self._typeof(st.init, scopes)
415
+ self._declare(st.name, ti, scopes)
416
+ elif isinstance(st, A.Assign):
417
+ self._expr(st.target, scopes)
418
+ self._expr(st.value, scopes)
419
+ elif isinstance(st, A.ExprStmt):
420
+ self._expr(st.expr, scopes)
421
+ elif isinstance(st, A.Return):
422
+ self._expr(st.value, scopes)
423
+ elif isinstance(st, A.If):
424
+ self._expr(st.cond, scopes)
425
+ self._block(st.then, scopes)
426
+ for c, b in st.elifs:
427
+ self._expr(c, scopes)
428
+ self._block(b, scopes)
429
+ self._block(st.orelse, scopes)
430
+ elif isinstance(st, (A.Match, A.MatchExpr)):
431
+ self._expr(st.subject, scopes)
432
+ self._check_match(st.subject, st.arms, st, scopes)
433
+ for pat, guard, body in st.arms:
434
+ inner = scopes + [{b: None for b in getattr(pat, "binds", [])}]
435
+ if guard is not None:
436
+ self._expr(guard, inner)
437
+ self._scoped(body, inner)
438
+ elif isinstance(st, A.For):
439
+ self._expr(st.iter, scopes)
440
+ it_ti = self._typeof(st.iter, scopes)
441
+ elem = (it_ti[0], False) if it_ti and it_ti[1] else None
442
+ inner = scopes + [{v: elem for v in st.vars}]
443
+ self._scoped(st.body, inner)
444
+ elif isinstance(st, A.While):
445
+ self._expr(st.cond, scopes)
446
+ self._block(st.body, scopes)
447
+ elif isinstance(st, A.Par):
448
+ self._block(st.lets, scopes)
449
+ elif isinstance(st, A.Spawn):
450
+ self._block(st.body, scopes)
451
+ elif isinstance(st, A.Try):
452
+ self._expr(st.expr, scopes)
453
+ for pat, body in st.catches:
454
+ inner = scopes + [{b: None for b in getattr(pat, "binds", [])}]
455
+ self._scoped(body, inner)
456
+ elif isinstance(st, (A.Break, A.Skip)):
457
+ pass
458
+ elif isinstance(st, A.StateDecl):
459
+ self._expr(st.init, scopes)
460
+ elif isinstance(st, A.DeriveDecl):
461
+ self._expr(st.expr, scopes)
462
+ elif isinstance(st, A.LoadDecl):
463
+ self._expr(st.expr, scopes)
464
+ self._scoped(st.pending, scopes)
465
+ inner = scopes + [{st.error_name: None}] if st.error_name else scopes
466
+ self._scoped(st.error_body, inner)
467
+ elif isinstance(st, A.OnBlock):
468
+ self._scoped(st.body, scopes + [{a: None for a in st.args}])
469
+ elif isinstance(st, A.ListBlock):
470
+ self._expr(st.src, scopes)
471
+ src_ti = self._typeof(st.src, scopes)
472
+ elem = (src_ti[0], False) if src_ti and src_ti[1] else None
473
+ inner = scopes + [{st.var: elem}]
474
+ if st.key is not None:
475
+ self._expr(st.key, inner)
476
+ self._scoped(st.body, inner)
477
+ elif isinstance(st, A.Element):
478
+ self._element(st, scopes)
479
+ else:
480
+ for node in _walk_names(st):
481
+ self._use(node, scopes)
482
+
483
+ def _element(self, st: A.Element, scopes):
484
+ name = st.name
485
+ sym = self.table.get(name)
486
+ # §3.4: uppercase element = a view. It must be declared / imported, or one
487
+ # of the std.ui-provided views. Lowercase elements are primitives and
488
+ # resolve through checks.BUILTINS.
489
+ if name[:1].isupper() and name not in _UI_STD_VIEWS and sym is None:
490
+ self._d("R-UNDEF", f"component '{name}' is not defined", st,
491
+ "declare a view with this name, or check the spelling")
492
+ if sym is not None and sym.kind == "view" and sym.params is not None:
493
+ given = {k for k, _ in st.attrs}
494
+ for pn, has_def, is_opt in sym.params:
495
+ if not has_def and not is_opt and pn not in given:
496
+ self._d("R-MISSING-ARG",
497
+ f"component '{name}' is missing required prop '{pn}'",
498
+ st, f"add {pn}:")
499
+ for a in st.args:
500
+ self._expr(a, scopes)
501
+ for _k, v in st.attrs:
502
+ if not isinstance(v, A.Name): # bare-name attr values are tokens
503
+ self._expr(v, scopes)
504
+ self._scoped(st.body, scopes)
505
+
506
+ # -------------------------------------------------------------- expressions
507
+ def _expr(self, ex, scopes):
508
+ if ex is None:
509
+ return
510
+ if isinstance(ex, A.Name):
511
+ self._use(ex, scopes)
512
+ elif isinstance(ex, A.Literal):
513
+ for n in getattr(ex, "interp_names", []):
514
+ if not self._resolves(n, scopes):
515
+ self._d("R-UNDEF",
516
+ f"'{n}' in string interpolation is not defined", ex,
517
+ "check the name, or add the binding / import")
518
+ elif isinstance(ex, A.Member):
519
+ self._expr(ex.obj, scopes)
520
+ self._check_field(ex, scopes)
521
+ elif isinstance(ex, A.Index):
522
+ self._expr(ex.obj, scopes)
523
+ self._expr(ex.index, scopes)
524
+ elif isinstance(ex, A.Call):
525
+ self._expr(ex.callee, scopes)
526
+ for a in ex.args:
527
+ self._expr(a, scopes)
528
+ for _k, v in ex.named:
529
+ self._expr(v, scopes)
530
+ self._check_call(ex, scopes)
531
+ self._check_view_module_fn(ex, scopes)
532
+ elif isinstance(ex, A.Unary):
533
+ self._expr(ex.operand, scopes)
534
+ elif isinstance(ex, A.Binary):
535
+ self._expr(ex.left, scopes)
536
+ self._expr(ex.right, scopes)
537
+ elif isinstance(ex, A.Range):
538
+ self._expr(ex.lo, scopes)
539
+ self._expr(ex.hi, scopes)
540
+ elif isinstance(ex, A.TryOp):
541
+ self._expr(ex.operand, scopes)
542
+ elif isinstance(ex, A.IfExpr):
543
+ self._expr(ex.cond, scopes)
544
+ self._expr(ex.then, scopes)
545
+ self._expr(ex.orelse, scopes)
546
+ elif isinstance(ex, A.MatchExpr):
547
+ self._expr(ex.subject, scopes)
548
+ self._check_match(ex.subject, ex.arms, ex, scopes)
549
+ for pat, guard, body in ex.arms:
550
+ inner = scopes + [{b: None for b in getattr(pat, "binds", [])}]
551
+ if guard is not None:
552
+ self._expr(guard, inner)
553
+ self._scoped(body, inner)
554
+ elif isinstance(ex, A.Closure):
555
+ inner = scopes + [{p.name: _tref(p.type)[:2] for p in ex.params}]
556
+ if isinstance(ex.body, list):
557
+ self._scoped(ex.body, inner)
558
+ else:
559
+ self._expr(ex.body, inner)
560
+ elif isinstance(ex, A.ListLit):
561
+ for x in ex.items:
562
+ self._expr(x, scopes)
563
+ elif isinstance(ex, A.RecordLit):
564
+ self._check_record_lit(ex, scopes)
565
+ for _k, v in ex.fields:
566
+ self._expr(v, scopes)
567
+
568
+ def _use(self, node: A.Name, scopes):
569
+ n = node.id
570
+ if not n or n in ("_", "?"):
571
+ return
572
+ if self._resolves(n, scopes):
573
+ return
574
+ self._d("R-UNDEF", f"'{n}' is not defined", node,
575
+ "check the name, or add a binding / declaration / import")
576
+
577
+ def _resolves(self, name, scopes) -> bool:
578
+ if name in _KNOWN or name in self.table:
579
+ return True
580
+ return any(name in sc for sc in scopes)
581
+
582
+ # --------------------------------------------------------------- type-of
583
+ def _typeof(self, ex, scopes):
584
+ if isinstance(ex, A.Name):
585
+ for sc in reversed(scopes):
586
+ if ex.id in sc:
587
+ return sc[ex.id]
588
+ return None
589
+ if isinstance(ex, A.RecordLit) and ex.type_name:
590
+ return (ex.type_name, False)
591
+ if isinstance(ex, A.TryOp):
592
+ return self._typeof(ex.operand, scopes)
593
+ if isinstance(ex, A.Member):
594
+ rec = self._record(self._typeof(ex.obj, scopes))
595
+ if rec and rec.fields and ex.name in rec.fields:
596
+ return rec.fields[ex.name]
597
+ return None
598
+ if isinstance(ex, A.Index):
599
+ ot = self._typeof(ex.obj, scopes)
600
+ return (ot[0], False) if ot and ot[1] else None
601
+ if isinstance(ex, A.Call):
602
+ if isinstance(ex.callee, A.Member) and isinstance(ex.callee.obj, A.Name) \
603
+ and (ex.callee.obj.id == "api"
604
+ or (self.table.get(ex.callee.obj.id) or Sym("x")).kind in ("service", "api")):
605
+ sym = self._client_sym(ex.callee)
606
+ return sym.ret if sym else None
607
+ if isinstance(ex.callee, A.Name):
608
+ s = self.table.get(ex.callee.id)
609
+ if s and s.kind in ("fn", "variant"):
610
+ return s.ret
611
+ return None
612
+
613
+ def _record(self, ti):
614
+ if not ti or ti[1]:
615
+ return None
616
+ s = self.table.get(ti[0])
617
+ seen = 0
618
+ while s and s.kind == "alias" and s.target and seen < 8:
619
+ s = self.table.get(s.target)
620
+ seen += 1
621
+ return s if s and s.kind == "record" else None
622
+
623
+ # ------------------------------------------------------------------ checks
624
+ def _check_field(self, ex: A.Member, scopes):
625
+ rec = self._record(self._typeof(ex.obj, scopes))
626
+ if rec is None or not rec.fields:
627
+ return
628
+ if ex.name in rec.fields or ex.name in _METHODS:
629
+ return
630
+ obj_t = self._typeof(ex.obj, scopes)
631
+ self._d("R-FIELD",
632
+ f"type '{obj_t[0]}' has no field '{ex.name}'", ex,
633
+ f"fields: {', '.join(sorted(rec.fields)) or '(none)'}")
634
+
635
+ def _has_client(self) -> bool:
636
+ return any(s.kind in ("service", "api") for s in self.table.values())
637
+
638
+ def _client_sym(self, callee):
639
+ """`api.x` / `<svc>.x` -> the route/endpoint Sym, a params=None sentinel
640
+ when the client key is ambiguous, or None when nothing matches.
641
+
642
+ Returns None only when a service/api IS in scope but has no such route;
643
+ when none is in scope the caller must treat it as "defined elsewhere",
644
+ not as an error."""
645
+ if not (isinstance(callee, A.Member) and isinstance(callee.obj, A.Name)):
646
+ return None
647
+ ns, m = callee.obj.id, callee.name
648
+ cands = []
649
+ if ns == "api":
650
+ cands = list(self.table.values())
651
+ elif ns in self.table:
652
+ cands = [self.table[ns]]
653
+ for s in cands:
654
+ if s.kind == "service" and s.routes and m in s.routes:
655
+ return s.routes[m]
656
+ if s.kind == "api" and s.endpoints and m in s.endpoints:
657
+ return s.endpoints[m]
658
+ return None
659
+
660
+ def _auth_is_builtin(self, scopes) -> bool:
661
+ """True when a bare `auth` really is the §11.3 built-in.
662
+
663
+ Name resolution comes before the table: a local binding or a declared
664
+ module-level symbol called `auth` would shadow the built-in, and a
665
+ method call on *that* is an ordinary call this table knows nothing
666
+ about.
667
+
668
+ Today this guard cannot actually fire — `auth` is a hard keyword
669
+ (`lexer.KEYWORDS`), so it binds as nothing: not a `let`, not a
670
+ parameter, not a declaration name. It is kept deliberately, as the
671
+ one place that decides whether this table applies, so that relaxing
672
+ `auth` to a soft keyword later changes the answer here instead of
673
+ silently mis-attributing someone else's method call to the auth API."""
674
+ if any("auth" in sc for sc in scopes):
675
+ return False
676
+ return "auth" not in self.table
677
+
678
+ def _check_auth_call(self, ex: A.Call, scopes) -> None:
679
+ m = ex.callee.name
680
+ if not self._auth_is_builtin(scopes):
681
+ return
682
+ params = _AUTH_API.get(m)
683
+ if params is None:
684
+ self._d("R-AUTH-METHOD",
685
+ f"'auth.{m}' is not part of the auth API", ex,
686
+ "the §11.3 calls are: "
687
+ + ", ".join(sorted(_AUTH_API)))
688
+ return
689
+ # Reuse the ordinary signature check: positional overflow, unknown
690
+ # named argument, and missing required argument all report the same way
691
+ # they do for a fn or a route.
692
+ self._arity(Sym("fn", params=[(n, False, not req) for n, req in params]),
693
+ ex, f"'auth.{m}'")
694
+
695
+ def _check_view_module_fn(self, ex: A.Call, scopes) -> None:
696
+ """A view -- or a closure / `on` handler / block inside one -- that
697
+ calls a module-level `fn` resolves fine, then `vlbuild` refuses it:
698
+ module-level functions are not lowered into view code, so the emitted
699
+ call is a runtime `ReferenceError` (`emit.py`'s `precheck` is the
700
+ fail-closed backstop). Flag the call here so the common
701
+ view-calls-a-`fn` case fails at `volaro check`, not only at build.
702
+ Uses this walker's own lexical `scopes`, so a `let` / `list` var /
703
+ loop var / closure param / handler arg / `catch` bind shadows the
704
+ `fn` exactly where it is really in scope -- and only there.
705
+
706
+ Residual (parked, owner-gated -- RELEASE-CHECKLIST.md 3b): a
707
+ module-level `fn` merely *declared* in a file that has a view is still
708
+ `check`-clean, yet `emit.py` `precheck` rejects any module-level `fn`
709
+ for the view target regardless of calls, so that file builds red. The
710
+ `view_module_fn*_ok.vl` fixtures isolate this checker -- check-clean by
711
+ design, not buildable."""
712
+ if not self._in_view or not isinstance(ex.callee, A.Name):
713
+ return
714
+ n = ex.callee.id
715
+ if not n or n in ("_", "?") or n in self._vmf_seen:
716
+ return
717
+ if any(n in sc for sc in scopes): # a local binding shadows it
718
+ return
719
+ sym = self.table.get(n)
720
+ if not sym or sym.kind != "fn":
721
+ return
722
+ self._vmf_seen.add(n)
723
+ self._d("E-VIEW-MODULE-FN",
724
+ f"view '{self._view_name}' calls module-level fn '{n}' -- "
725
+ f"vlbuild does not lower a module-level `fn` into view code, so "
726
+ f"the emitted call throws ReferenceError at runtime. Inline "
727
+ f"{n}'s body at the call site (write the expression directly); "
728
+ f"a view has no shared helper function yet, so logic used by "
729
+ f"more than one `derive` / `on` handler is repeated in each.",
730
+ ex,
731
+ f"inline {n}'s body here; repeat it in each derive / handler "
732
+ f"that needs it")
733
+
734
+ def _check_call(self, ex: A.Call, scopes):
735
+ callee = ex.callee
736
+ if isinstance(callee, A.Member) and isinstance(callee.obj, A.Name):
737
+ ns = callee.obj.id
738
+ if ns == "auth":
739
+ self._check_auth_call(ex, scopes)
740
+ return
741
+ nssym = self.table.get(ns)
742
+ if ns == "api" or (nssym and nssym.kind in ("service", "api")):
743
+ if not self._has_client():
744
+ return # client for a service defined in another file
745
+ sym = self._client_sym(callee)
746
+ if sym is None:
747
+ self._d("R-ROUTE",
748
+ f"no route or endpoint '{callee.name}' on '{ns}'", ex,
749
+ "check the route path or endpoint name")
750
+ return
751
+ self._arity(sym, ex, f"'{ns}.{callee.name}'")
752
+ return
753
+ if isinstance(callee, A.Name):
754
+ sym = self.table.get(callee.id)
755
+ if sym and sym.kind in ("fn", "view", "variant"):
756
+ self._arity(sym, ex, f"'{callee.id}'")
757
+
758
+ def _arity(self, sym: Sym, ex: A.Call, label: str):
759
+ params = sym.params
760
+ if params is None:
761
+ return
762
+ pnames = [p[0] for p in params]
763
+ npos = len(ex.args)
764
+ if npos > len(params):
765
+ self._d("R-ARITY",
766
+ f"{label} takes {len(params)} argument(s), got {npos} positional",
767
+ ex, "remove the extra argument(s)")
768
+ return
769
+ for k, _v in ex.named:
770
+ if k not in pnames:
771
+ self._d("R-NAMED-ARG",
772
+ f"{label} has no parameter named '{k}'", ex,
773
+ f"parameters: {', '.join(pnames) or '(none)'}")
774
+ filled = set(pnames[:npos]) | {k for k, _v in ex.named}
775
+ for pn, has_def, is_opt in params:
776
+ if not has_def and not is_opt and pn not in filled:
777
+ self._d("R-MISSING-ARG",
778
+ f"{label} is missing required argument '{pn}'", ex,
779
+ f"pass {pn}:")
780
+
781
+ def _check_record_lit(self, ex: A.RecordLit, scopes):
782
+ if not ex.type_name:
783
+ return
784
+ if ex.type_name == "Err":
785
+ return # illegal, but checks.py owns that (E-ERR-LITERAL)
786
+ s = self.table.get(ex.type_name)
787
+ if s is None:
788
+ self._d("R-UNDEF", f"type '{ex.type_name}' is not defined", ex,
789
+ "declare the record / model, or import it")
790
+ return
791
+ if s.kind != "record" or s.fields is None:
792
+ return
793
+ for k, _v in ex.fields:
794
+ if k is not None and k not in s.fields:
795
+ self._d("R-FIELD",
796
+ f"'{ex.type_name}' has no field '{k}'", ex,
797
+ f"fields: {', '.join(sorted(s.fields)) or '(none)'}")
798
+
799
+ def _check_match(self, subject, arms, node, scopes):
800
+ ti = self._typeof(subject, scopes)
801
+ if not ti or ti[1]:
802
+ return
803
+ s = self.table.get(ti[0])
804
+ seen = 0
805
+ while s and s.kind == "alias" and s.target and seen < 8:
806
+ s = self.table.get(s.target)
807
+ seen += 1
808
+ if not s or s.kind != "union" or not s.variants:
809
+ return
810
+ heads = []
811
+ for arm in arms:
812
+ pat, guard = arm[0], arm[1]
813
+ if guard is not None:
814
+ continue
815
+ h = getattr(pat, "head", "")
816
+ binds = getattr(pat, "binds", [])
817
+ if h == "_":
818
+ return
819
+ if h and h not in s.variants and binds == [h]:
820
+ return # bare-name binding = catch-all
821
+ heads.append(h)
822
+ missing = [v for v in s.variants if v not in set(heads)]
823
+ if missing:
824
+ self._d("R-MATCH",
825
+ f"match on '{ti[0]}' is not exhaustive; missing: {', '.join(missing)}",
826
+ node, "add the missing arms, or a '_' catch-all")
827
+
828
+
829
+ def _walk_names(node):
830
+ """Yield A.Name nodes anywhere under an unrecognised statement shape."""
831
+ if node is None or isinstance(node, (str, int, float, bool)):
832
+ return
833
+ if isinstance(node, (list, tuple)):
834
+ for x in node:
835
+ yield from _walk_names(x)
836
+ return
837
+ if isinstance(node, A.Name):
838
+ yield node
839
+ return
840
+ if not hasattr(node, "__dataclass_fields__"):
841
+ return
842
+ for f in node.__dataclass_fields__:
843
+ if f in ("start", "end"):
844
+ continue
845
+ yield from _walk_names(getattr(node, f))
846
+
847
+
848
+ def resolve(mod: A.Module, filename: str, li: LineIndex) -> list[Diagnostic]:
849
+ return Resolver(filename, li).run(mod)