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.
- package/README.md +114 -22
- package/bin/vl.js +466 -28
- package/compiler/SOURCE_INFO.json +6 -0
- package/compiler/SOURCE_REV +1 -0
- package/compiler/validator/vlcheck/__init__.py +10 -0
- package/compiler/validator/vlcheck/__main__.py +128 -0
- package/compiler/validator/vlcheck/ast_nodes.py +397 -0
- package/compiler/validator/vlcheck/checks.py +1227 -0
- package/compiler/validator/vlcheck/diagnostics.py +88 -0
- package/compiler/validator/vlcheck/lexer.py +343 -0
- package/compiler/validator/vlcheck/parser.py +1638 -0
- package/compiler/validator/vlcheck/project_config.py +97 -0
- package/compiler/validator/vlcheck/resolve.py +849 -0
- package/compiler/validator/vlcheck/test_ids.py +98 -0
- package/compiler/vlbuild/styling/README.md +39 -0
- package/compiler/vlbuild/styling/build-css.mjs +103 -0
- package/compiler/vlbuild/styling/package-lock.json +1254 -0
- package/compiler/vlbuild/styling/package.json +15 -0
- package/compiler/vlbuild/styling/test-build-css.mjs +69 -0
- package/compiler/vlbuild/vlbuild/__init__.py +16 -0
- package/compiler/vlbuild/vlbuild/__main__.py +246 -0
- package/compiler/vlbuild/vlbuild/assets/vlrt.css +165 -0
- package/compiler/vlbuild/vlbuild/assets/vlrt.js +1291 -0
- package/compiler/vlbuild/vlbuild/emit.py +2023 -0
- package/compiler/vlbuild/vlbuild/server_emit.py +1551 -0
- package/compiler/vlbuild/vlbuild/static_assets.py +83 -0
- package/compiler/vlbuild/vlbuild/style_config.py +347 -0
- package/compiler/vlbuild/vlbuild/styling.py +39 -0
- package/examples/station.vl +2 -2
- package/language/crib.md +131 -10
- package/language/spec.md +209 -7
- package/language/supported.md +185 -0
- package/lib/env.js +107 -0
- package/package.json +19 -2
- package/scripts/record-provenance.mjs +51 -0
- package/scripts/selftest.mjs +54 -0
- 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)
|