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,1227 @@
|
|
|
1
|
+
"""Structural and heuristic-semantic checks over the parsed AST.
|
|
2
|
+
|
|
3
|
+
None of this is a type checker. `E-PROP-FALLIBLE` and `W-UNDEF-NAME` are
|
|
4
|
+
deliberately conservative heuristics; the real thing needs the spec's
|
|
5
|
+
Hindley-Milner pass (section 17.5). See PROJECT.md section 7.2 for the non-goals.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
from . import ast_nodes as A
|
|
13
|
+
from .diagnostics import Diagnostic, LineIndex, Severity
|
|
14
|
+
|
|
15
|
+
# Generous allowlist so W-UNDEF-NAME stays low-noise. NOT the real stdlib surface.
|
|
16
|
+
BUILTINS = {
|
|
17
|
+
# namespaces / ambient values commonly referenced in the spec examples
|
|
18
|
+
"env", "session", "route", "auth", "db", "log", "analytics", "events", "ws",
|
|
19
|
+
"mailer", "billing", "now", "api",
|
|
20
|
+
# std free functions shown across the spec
|
|
21
|
+
"filter", "map", "sort_by", "sorted", "take", "join", "split", "first",
|
|
22
|
+
"first_char", "group_by", "fold", "chunk", "keys", "values", "merge",
|
|
23
|
+
"get_or", "to_int", "int_to_float", "len", "format", "reload", "loading",
|
|
24
|
+
"ok", "err",
|
|
25
|
+
# list growth (spec section 20.9): the only way to combine or grow a
|
|
26
|
+
# list -- no `+` overload, no spread syntax, see the open question
|
|
27
|
+
"concat", "append",
|
|
28
|
+
# error-code constructors used in `err(...)` / match arms in the spec
|
|
29
|
+
"not_found", "forbidden", "conflict", "rejected", "unexpected", "out_of_range",
|
|
30
|
+
"decode", "invalid_credentials", "unverified_email", "totp_required",
|
|
31
|
+
"rate_limited", "create_guest", "guest_user",
|
|
32
|
+
# std.ui elements (auto-imported into views per spec section 8.2)
|
|
33
|
+
"col", "row", "stack", "grid", "text", "img", "button", "input", "link",
|
|
34
|
+
"spacer", "status", "card",
|
|
35
|
+
# semantic structure (spec section 8.11)
|
|
36
|
+
"h1", "h2", "h3", "h4", "h5", "h6", "main", "nav",
|
|
37
|
+
# `where(...)` comparison-predicate constructors (spec section 13.1)
|
|
38
|
+
"lt", "lte", "gt", "gte", "ne", "between", "any_of",
|
|
39
|
+
}
|
|
40
|
+
_PRIM_NAMES = {"int", "float", "str", "bool", "bytes", "time", "dur", "uuid", "json", "nil"}
|
|
41
|
+
|
|
42
|
+
# `use std.X { name, ... }` names `vlbuild` actually lowers to a working call
|
|
43
|
+
# -- NOT the same list as `BUILTINS` above (that one is a generous, deliberately
|
|
44
|
+
# loose allowlist for the W-UNDEF-NAME heuristic, and includes several names,
|
|
45
|
+
# e.g. `merge`/`fold`/`first`/`to_int`, the spec shows but this build's emitter
|
|
46
|
+
# has no lowering for). Duplicated from `vlbuild/emit.py`'s `STD_FNS` (plus
|
|
47
|
+
# `now`, whose one working lowering — a `model` field default — lives in
|
|
48
|
+
# `server_emit.py`'s `_sql_default`) rather than imported: `vlcheck` has no
|
|
49
|
+
# dependency on `vlbuild` (the dependency runs the other way), so the two
|
|
50
|
+
# packages cannot share this table directly. Keep in sync by hand; a mismatch
|
|
51
|
+
# only makes this check too strict or too loose, never silently wrong, since
|
|
52
|
+
# `vlbuild` is the actual authority on what it lowers.
|
|
53
|
+
#
|
|
54
|
+
# Found reproducing the login-page benchmark's Bug 3 (`test-only`
|
|
55
|
+
# `login-page/volaro/NOTES.md`): `use std.http { post }` passes `vlcheck`
|
|
56
|
+
# clean and compiles to a bare, undefined `post(...)` call in the emitted JS
|
|
57
|
+
# (`emit.py`'s `_ex_Call` has no special case for an unrecognized `A.Name`
|
|
58
|
+
# callee, so it falls through to emitting the call verbatim) -- a silent
|
|
59
|
+
# ReferenceError at runtime, not a build-time diagnostic. `std.http` has no
|
|
60
|
+
# member in either list below; nothing under it can pass this check.
|
|
61
|
+
_STD_SUPPORTED_NAMES = {
|
|
62
|
+
"filter", "map", "take", "count", "sum", "sort_by", "group_by", "chunk",
|
|
63
|
+
"join", "split", "first_char", "int_to_float", "float_to_int",
|
|
64
|
+
"trim", "upper", "lower", "keys", "values", "get_or", "concat", "append",
|
|
65
|
+
"now",
|
|
66
|
+
# std.str's `regex` (spec section 13): `matches(s, pattern)`, added
|
|
67
|
+
# alongside its `vlbuild/emit.py` `STD_FNS` entry and `VL.std.matches`
|
|
68
|
+
# (assets/vlrt.js) -- keep all three in sync by hand.
|
|
69
|
+
"matches",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _lit_text(v):
|
|
74
|
+
"""The literal text of a plain string literal, or None when `v` is not one
|
|
75
|
+
(a bare word, an expression, or an *interpolated* string -- whose runtime
|
|
76
|
+
value the compiler cannot judge statically). Used to catch a statically
|
|
77
|
+
empty accessible name (`label:""`, `aria_label:" "`)."""
|
|
78
|
+
if not isinstance(v, A.Literal) or v.kind != "str":
|
|
79
|
+
return None
|
|
80
|
+
if getattr(v, "interp_names", None):
|
|
81
|
+
return None
|
|
82
|
+
raw = v.raw or ""
|
|
83
|
+
if len(raw) >= 6 and raw[:3] == '"""' and raw[-3:] == '"""':
|
|
84
|
+
return raw[3:-3]
|
|
85
|
+
if len(raw) >= 2 and raw[0] == '"' and raw[-1] == '"':
|
|
86
|
+
return raw[1:-1]
|
|
87
|
+
return raw
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _img_alt_kinds(node):
|
|
91
|
+
"""Classify an `img`'s alt declarations (spec §8.10). Returns
|
|
92
|
+
(kinds, alt_value) -- `kinds` is a list of "meaningful" / "decorative" /
|
|
93
|
+
"todo", one per declaration found, so 0 = none and >1 = conflicting. The
|
|
94
|
+
name form is a single `alt:` attribute: real text, or the bare word
|
|
95
|
+
`decorative` (presentational) / `todo` (placeholder); `alt_todo:<text>`
|
|
96
|
+
is the placeholder-with-a-note variant."""
|
|
97
|
+
kinds: list[str] = []
|
|
98
|
+
alt_value = None
|
|
99
|
+
for k, v in node.attrs:
|
|
100
|
+
if k == "alt":
|
|
101
|
+
alt_value = v
|
|
102
|
+
if isinstance(v, A.Name) and v.id in ("decorative", "todo"):
|
|
103
|
+
kinds.append(v.id if v.id == "decorative" else "todo")
|
|
104
|
+
else:
|
|
105
|
+
kinds.append("meaningful")
|
|
106
|
+
elif k == "alt_todo":
|
|
107
|
+
alt_value = v
|
|
108
|
+
kinds.append("todo")
|
|
109
|
+
return kinds, alt_value
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _is_dc(x) -> bool:
|
|
113
|
+
return hasattr(x, "__dataclass_fields__")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _walk(node):
|
|
117
|
+
"""Yield every dataclass node reachable from `node` (through lists and tuples)."""
|
|
118
|
+
if node is None:
|
|
119
|
+
return
|
|
120
|
+
if isinstance(node, (list, tuple)):
|
|
121
|
+
for x in node:
|
|
122
|
+
yield from _walk(x)
|
|
123
|
+
return
|
|
124
|
+
if not _is_dc(node):
|
|
125
|
+
return
|
|
126
|
+
yield node
|
|
127
|
+
for f in node.__dataclass_fields__:
|
|
128
|
+
if f in ("start", "end"):
|
|
129
|
+
continue
|
|
130
|
+
yield from _walk(getattr(node, f))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class Checker:
|
|
134
|
+
def __init__(self, filename: str, li: LineIndex, strict_names: bool = False,
|
|
135
|
+
release: bool = False) -> None:
|
|
136
|
+
self.filename = filename
|
|
137
|
+
self.li = li
|
|
138
|
+
self.strict_names = strict_names
|
|
139
|
+
# `release` (vlcheck --release / vlbuild --release): promote the
|
|
140
|
+
# "unresolved dev placeholder" warnings to errors -- the gate that
|
|
141
|
+
# keeps a `alt_todo:` / other TODO marker out of a shipped build.
|
|
142
|
+
self.release = release
|
|
143
|
+
self.diags: list[Diagnostic] = []
|
|
144
|
+
|
|
145
|
+
# -- emit ----------------------------------------------------------
|
|
146
|
+
def _d(self, code: str, sev: Severity, msg: str, node, fix: str | None = None) -> None:
|
|
147
|
+
s = getattr(node, "start", 0)
|
|
148
|
+
e = getattr(node, "end", s + 1)
|
|
149
|
+
self.diags.append(Diagnostic(code, sev, msg, s, max(e, s + 1), fix, self.filename))
|
|
150
|
+
|
|
151
|
+
def run(self, mod: A.Module) -> list[Diagnostic]:
|
|
152
|
+
from .test_ids import check_test_ids
|
|
153
|
+
self.diags.extend(check_test_ids(mod, self.filename))
|
|
154
|
+
self.structural(mod)
|
|
155
|
+
self.styling(mod)
|
|
156
|
+
self.accessible_primitives(mod)
|
|
157
|
+
self.view_bindings(mod)
|
|
158
|
+
self.callback_props(mod)
|
|
159
|
+
self.document_structure(mod)
|
|
160
|
+
self.composed_document(mod)
|
|
161
|
+
self.services(mod)
|
|
162
|
+
self.api_params(mod)
|
|
163
|
+
self.imports(mod)
|
|
164
|
+
self.semantic(mod)
|
|
165
|
+
return self.diags
|
|
166
|
+
|
|
167
|
+
# -- std imports: only names vlbuild actually lowers are usable --------
|
|
168
|
+
def imports(self, mod: A.Module) -> None:
|
|
169
|
+
"""`use std.X { name }` where `name` is not one `vlbuild` lowers
|
|
170
|
+
compiles clean today and fails at runtime instead (see
|
|
171
|
+
`_STD_SUPPORTED_NAMES`'s docstring above) -- the same silent-wrong
|
|
172
|
+
shape `E-VARIANT-LITERAL` / `E-ASSIGN-READONLY` exist to catch
|
|
173
|
+
elsewhere in this file, just for the standard library's surface
|
|
174
|
+
rather than a view's. A relative (`./`/`../`) or third-party `use` is
|
|
175
|
+
untouched here -- `vlcheck --resolve` (resolve.py) is the pass that
|
|
176
|
+
follows a project-relative path; this only judges `std.*`, the one
|
|
177
|
+
namespace with a closed, compiler-owned surface."""
|
|
178
|
+
for it in mod.items:
|
|
179
|
+
if not (isinstance(it, A.Use) and it.path.startswith("std.")):
|
|
180
|
+
continue
|
|
181
|
+
for name in it.names:
|
|
182
|
+
if name not in _STD_SUPPORTED_NAMES:
|
|
183
|
+
self._d("E-UNKNOWN-STD-IMPORT", Severity.ERROR,
|
|
184
|
+
f"'{name}' is not a function this build of Volaro "
|
|
185
|
+
f"implements -- '{it.path}' has no working '{name}'; "
|
|
186
|
+
"it would compile with 0 errors and fail at runtime "
|
|
187
|
+
"calling an undefined function", it,
|
|
188
|
+
"supported std functions: "
|
|
189
|
+
+ ", ".join(sorted(_STD_SUPPORTED_NAMES)))
|
|
190
|
+
|
|
191
|
+
# -- view bindings: what may be assigned to, and literal-only attrs ----
|
|
192
|
+
def view_bindings(self, mod: A.Module) -> None:
|
|
193
|
+
"""Per view: (1) a `derive` / `load` value and a parameter are
|
|
194
|
+
read-only -- assigning to one lowers to a dead JS statement, so reject
|
|
195
|
+
it; (2) `variant:` takes a bare *style name*, never a value -- a
|
|
196
|
+
computed expression is dropped by the emitter and a bare identifier
|
|
197
|
+
that is really a `state` / loop var is emitted as the identifier text,
|
|
198
|
+
not its value. A silent no-op / silent-wrong-token is the worst
|
|
199
|
+
failure mode for a language whose errors are the documentation."""
|
|
200
|
+
for it in mod.items:
|
|
201
|
+
if not isinstance(it, A.ViewDecl):
|
|
202
|
+
continue
|
|
203
|
+
readonly: dict[str, str] = {}
|
|
204
|
+
for p in it.params:
|
|
205
|
+
readonly[p.name] = "parameter"
|
|
206
|
+
for st in it.body:
|
|
207
|
+
if isinstance(st, A.DeriveDecl):
|
|
208
|
+
readonly[st.name] = "derive"
|
|
209
|
+
elif isinstance(st, A.LoadDecl):
|
|
210
|
+
readonly[st.name] = "load result"
|
|
211
|
+
# every name that names a *value* in this view (mutable or not) --
|
|
212
|
+
# used to tell `variant:primary` (a style token) from
|
|
213
|
+
# `variant:mode` (a variable read)
|
|
214
|
+
value_names: set[str] = set(readonly)
|
|
215
|
+
for st in it.body:
|
|
216
|
+
if isinstance(st, A.StateDecl):
|
|
217
|
+
value_names.add(st.name)
|
|
218
|
+
for node in _walk(it.body):
|
|
219
|
+
if isinstance(node, A.ListBlock) and node.var:
|
|
220
|
+
value_names.add(node.var)
|
|
221
|
+
elif isinstance(node, A.For):
|
|
222
|
+
value_names.update(getattr(node, "vars", []) or [])
|
|
223
|
+
|
|
224
|
+
for node in _walk(it.body):
|
|
225
|
+
if isinstance(node, A.Assign):
|
|
226
|
+
tgt = getattr(node, "target", None)
|
|
227
|
+
nm = tgt.id if isinstance(tgt, A.Name) else None
|
|
228
|
+
if nm in readonly:
|
|
229
|
+
self._d("E-ASSIGN-READONLY", Severity.ERROR,
|
|
230
|
+
"'%s' is a %s and cannot be assigned to -- it is "
|
|
231
|
+
"recomputed, not stored. Declare a `state` for a "
|
|
232
|
+
"value you change." % (nm, readonly[nm]),
|
|
233
|
+
node, None)
|
|
234
|
+
elif isinstance(node, A.Element):
|
|
235
|
+
for k, v in node.attrs:
|
|
236
|
+
if k != "variant":
|
|
237
|
+
continue
|
|
238
|
+
if not isinstance(v, A.Name):
|
|
239
|
+
self._d("E-VARIANT-LITERAL", Severity.ERROR,
|
|
240
|
+
"variant: takes a bare style name "
|
|
241
|
+
"(variant:primary), not an expression -- a "
|
|
242
|
+
"computed value is silently ignored by the "
|
|
243
|
+
"build. Put the choice in the positional "
|
|
244
|
+
"label, or split into two elements.",
|
|
245
|
+
node, "variant:primary")
|
|
246
|
+
elif v.id in value_names:
|
|
247
|
+
self._d("E-VARIANT-LITERAL", Severity.ERROR,
|
|
248
|
+
"variant:%s reads '%s' as a literal style "
|
|
249
|
+
"token, not its value -- `variant:` is a "
|
|
250
|
+
"bare style name only. Vary the positional "
|
|
251
|
+
"label instead." % (v.id, v.id),
|
|
252
|
+
node, "variant:primary")
|
|
253
|
+
|
|
254
|
+
# -- callback props (DESIGN-01: child->parent data flow) -----------------
|
|
255
|
+
def callback_props(self, mod: A.Module) -> None:
|
|
256
|
+
"""A `fn(...)`-typed view param is a callback prop (the decided
|
|
257
|
+
parent->child mutation idiom --
|
|
258
|
+
`documentation/language-design/CHILD-PARENT-DATA-FLOW-DECISION.md`):
|
|
259
|
+
the parent hands the child a closure that mutates the parent's own
|
|
260
|
+
`state`, never a raw setter. Two things this checker CAN decide
|
|
261
|
+
without real type inference (see the module docstring):
|
|
262
|
+
|
|
263
|
+
(1) at every call site that instantiates the view, the value given
|
|
264
|
+
for that prop must be a plausible callable -- a closure literal
|
|
265
|
+
(whose own arity is checked against the declared signature), or a
|
|
266
|
+
bare name (assumed to resolve to a callable somewhere in scope --
|
|
267
|
+
this prototype has no type inference to confirm it, matching how
|
|
268
|
+
`_resolve` / `R-UNDEF` elsewhere stay conservative rather than
|
|
269
|
+
guess). A literal of any other shape can never be called; the
|
|
270
|
+
emitter would still produce `<literal>(...)`, a guaranteed runtime
|
|
271
|
+
TypeError caught here at check time instead.
|
|
272
|
+
|
|
273
|
+
(2) inside the child's own body, calling that prop with the wrong
|
|
274
|
+
number of arguments -- the same arity mistake an ordinary function
|
|
275
|
+
call already gets, except no existing check reaches a `fn`-typed
|
|
276
|
+
param (it is a local binding, not a module-level declaration
|
|
277
|
+
`resolve.py`'s symbol table would recognise)."""
|
|
278
|
+
views = {it.name: it for it in mod.items if isinstance(it, A.ViewDecl)}
|
|
279
|
+
fn_params: dict[str, dict[str, A.TypeRef]] = {}
|
|
280
|
+
for name, v in views.items():
|
|
281
|
+
d = {p.name: p.type for p in v.params
|
|
282
|
+
if isinstance(p.type, A.TypeRef) and p.type.is_fn}
|
|
283
|
+
if d:
|
|
284
|
+
fn_params[name] = d
|
|
285
|
+
if not fn_params:
|
|
286
|
+
return
|
|
287
|
+
|
|
288
|
+
# (1) every call site that passes one of these props.
|
|
289
|
+
for it in mod.items:
|
|
290
|
+
if not isinstance(it, A.ViewDecl):
|
|
291
|
+
continue
|
|
292
|
+
for node in _walk(it.body):
|
|
293
|
+
if not isinstance(node, A.Element) or node.name not in fn_params:
|
|
294
|
+
continue
|
|
295
|
+
wanted = fn_params[node.name]
|
|
296
|
+
for k, v in node.attrs:
|
|
297
|
+
sig = wanted.get(k)
|
|
298
|
+
if sig is None:
|
|
299
|
+
continue
|
|
300
|
+
if isinstance(v, A.Closure):
|
|
301
|
+
want_n, got_n = len(sig.fn_params), len(v.params)
|
|
302
|
+
if got_n != want_n:
|
|
303
|
+
self._d("E-CALLBACK-PROP-ARITY", Severity.ERROR,
|
|
304
|
+
"'%s:' on <%s> is %s -- the closure passed "
|
|
305
|
+
"has %d parameter%s, not %d"
|
|
306
|
+
% (k, node.name, _fn_sig(sig), got_n,
|
|
307
|
+
"" if got_n == 1 else "s", want_n),
|
|
308
|
+
v, None)
|
|
309
|
+
continue
|
|
310
|
+
if isinstance(v, A.Name):
|
|
311
|
+
continue # assumed callable; no type inference here
|
|
312
|
+
self._d("E-CALLBACK-PROP-TYPE", Severity.ERROR,
|
|
313
|
+
"'%s:' on <%s> is a callback prop (%s) and needs "
|
|
314
|
+
"a closure or a callable value -- a literal can "
|
|
315
|
+
"never be called" % (k, node.name, _fn_sig(sig)),
|
|
316
|
+
v,
|
|
317
|
+
"%s: fn(%s) ..." % (
|
|
318
|
+
k, ", ".join("_" for _ in sig.fn_params)))
|
|
319
|
+
|
|
320
|
+
# (2) arity at each call *inside* the child that owns the prop.
|
|
321
|
+
for name, params in fn_params.items():
|
|
322
|
+
for node in _walk(views[name].body):
|
|
323
|
+
if not isinstance(node, A.Call) or not isinstance(node.callee, A.Name):
|
|
324
|
+
continue
|
|
325
|
+
sig = params.get(node.callee.id)
|
|
326
|
+
if sig is None:
|
|
327
|
+
continue
|
|
328
|
+
want, got = len(sig.fn_params), len(node.args)
|
|
329
|
+
if got != want:
|
|
330
|
+
self._d("E-CALLBACK-PROP-ARITY", Severity.ERROR,
|
|
331
|
+
"'%s' takes %d argument%s (%s) -- called here "
|
|
332
|
+
"with %d" % (node.callee.id, want,
|
|
333
|
+
"" if want == 1 else "s", _fn_sig(sig), got),
|
|
334
|
+
node, None)
|
|
335
|
+
|
|
336
|
+
def styling(self, mod: A.Module) -> None:
|
|
337
|
+
"""Checks that are decidable within one source file. Project-wide
|
|
338
|
+
recipe selection/conflicts are checked by vlbuild after discovery."""
|
|
339
|
+
themes = [x for x in mod.items
|
|
340
|
+
if isinstance(x, A.Directive) and x.head == "theme"]
|
|
341
|
+
for extra in themes[1:]:
|
|
342
|
+
self._d("E-THEME-DUP", Severity.ERROR,
|
|
343
|
+
"a project may declare only one theme", extra)
|
|
344
|
+
for theme in themes[:1]:
|
|
345
|
+
categories: set[str] = set()
|
|
346
|
+
for category in theme.body:
|
|
347
|
+
if category.head not in {"color", "space", "radius", "font", "screen", "motion"}:
|
|
348
|
+
self._d("E-THEME-SHAPE", Severity.ERROR,
|
|
349
|
+
"unknown theme category %r" % category.head, category)
|
|
350
|
+
continue
|
|
351
|
+
if category.head in categories:
|
|
352
|
+
self._d("E-THEME-SHAPE", Severity.ERROR,
|
|
353
|
+
"theme category %r is repeated" % category.head, category)
|
|
354
|
+
categories.add(category.head)
|
|
355
|
+
rows = [r.tokens for r in category.body]
|
|
356
|
+
if not rows:
|
|
357
|
+
flat = category.tokens[1:]
|
|
358
|
+
if not flat or len(flat) % 2:
|
|
359
|
+
self._d("E-THEME-SHAPE", Severity.ERROR,
|
|
360
|
+
"%s needs name/value pairs" % category.head, category)
|
|
361
|
+
continue
|
|
362
|
+
rows = [flat[i:i + 2] for i in range(0, len(flat), 2)]
|
|
363
|
+
names: set[str] = set()
|
|
364
|
+
for row in rows:
|
|
365
|
+
name = row[0] if row else "?"
|
|
366
|
+
if name in names or (category.head == "color" and name in
|
|
367
|
+
{"white", "black", "transparent", "current"}):
|
|
368
|
+
self._d("E-TOKEN-DUP", Severity.ERROR,
|
|
369
|
+
"duplicate theme token %s.%s" % (category.head, name), category)
|
|
370
|
+
names.add(name)
|
|
371
|
+
if name in {"container", "columns", "break", "box", "object", "overflow"}:
|
|
372
|
+
self._d("E-TOKEN-RESERVED", Severity.ERROR,
|
|
373
|
+
"theme token %r shadows a utility root" % name, category)
|
|
374
|
+
valid = len(row) >= 2
|
|
375
|
+
if category.head in {"space", "radius", "screen"}:
|
|
376
|
+
valid = len(row) == 2 and bool(re.fullmatch(r"\d+(?:\.\d+)?", row[1]))
|
|
377
|
+
elif category.head == "color":
|
|
378
|
+
valid = (len(row) == 2 or
|
|
379
|
+
(len(row) == 4 and row[2] == "dark"))
|
|
380
|
+
elif category.head == "motion":
|
|
381
|
+
valid = row == ["reduce", "respect"]
|
|
382
|
+
if not valid:
|
|
383
|
+
self._d("E-THEME-SHAPE", Severity.ERROR,
|
|
384
|
+
"malformed %s token row" % category.head, category)
|
|
385
|
+
allowed = {"button", "input", "card"}
|
|
386
|
+
seen_recipes: set[str] = set()
|
|
387
|
+
for recipe in (x for x in mod.items if isinstance(x, A.RecipeDecl)):
|
|
388
|
+
if recipe.primitive not in allowed:
|
|
389
|
+
self._d("E-RECIPE-UNKNOWN-PRIMITIVE", Severity.ERROR,
|
|
390
|
+
"Slice 1 recipes support button, input, and card; got %r"
|
|
391
|
+
% recipe.primitive, recipe)
|
|
392
|
+
continue
|
|
393
|
+
if recipe.primitive in seen_recipes:
|
|
394
|
+
self._d("E-RECIPE-SHAPE", Severity.ERROR,
|
|
395
|
+
"recipe %r is declared more than once" % recipe.primitive,
|
|
396
|
+
recipe)
|
|
397
|
+
seen_recipes.add(recipe.primitive)
|
|
398
|
+
bases = [r for r in recipe.body
|
|
399
|
+
if isinstance(r, A.Directive) and r.head == "base"]
|
|
400
|
+
if len(bases) != 1 or len(bases[0].tokens) != 2:
|
|
401
|
+
self._d("E-RECIPE-SHAPE", Severity.ERROR,
|
|
402
|
+
"recipe %r needs exactly one literal-string base row"
|
|
403
|
+
% recipe.primitive, recipe,
|
|
404
|
+
'base "classes"')
|
|
405
|
+
continue
|
|
406
|
+
classes = bases[0].tokens[1]
|
|
407
|
+
axes: dict[str, set[str]] = {}
|
|
408
|
+
defaults: list[tuple[str, str]] = []
|
|
409
|
+
for row in recipe.body:
|
|
410
|
+
if row.head == "base":
|
|
411
|
+
continue
|
|
412
|
+
if row.head == "default":
|
|
413
|
+
rest = row.tokens[1:]
|
|
414
|
+
if len(rest) % 3 or any(rest[i + 1] != ":" for i in range(0, len(rest), 3)):
|
|
415
|
+
self._d("E-RECIPE-SHAPE", Severity.ERROR,
|
|
416
|
+
"default entries use axis:value", row)
|
|
417
|
+
else:
|
|
418
|
+
defaults.extend((rest[i], rest[i + 2]) for i in range(0, len(rest), 3))
|
|
419
|
+
continue
|
|
420
|
+
if len(row.tokens) != 3:
|
|
421
|
+
self._d("E-RECIPE-SHAPE", Severity.ERROR,
|
|
422
|
+
"axis row is `axis value \"classes\"`", row)
|
|
423
|
+
continue
|
|
424
|
+
axis, value = row.tokens[:2]
|
|
425
|
+
if value in axes.setdefault(axis, set()):
|
|
426
|
+
self._d("E-RECIPE-SHAPE", Severity.ERROR,
|
|
427
|
+
"duplicate recipe value %s.%s" % (axis, value), row)
|
|
428
|
+
axes[axis].add(value)
|
|
429
|
+
seen_defaults: set[str] = set()
|
|
430
|
+
for axis, value in defaults:
|
|
431
|
+
if axis in seen_defaults:
|
|
432
|
+
self._d("E-RECIPE-AXIS-DUP", Severity.ERROR,
|
|
433
|
+
"default repeats axis %r" % axis, recipe)
|
|
434
|
+
seen_defaults.add(axis)
|
|
435
|
+
if value not in axes.get(axis, set()):
|
|
436
|
+
self._d("E-RECIPE-SHAPE", Severity.ERROR,
|
|
437
|
+
"default %s:%s is not declared" % (axis, value), recipe)
|
|
438
|
+
if recipe.primitive in ("button", "input"):
|
|
439
|
+
all_tokens = " ".join(" ".join(r.tokens) for r in recipe.body)
|
|
440
|
+
if "focus-visible:" not in all_tokens:
|
|
441
|
+
self._d("E-RECIPE-FOCUS", Severity.ERROR,
|
|
442
|
+
"interactive recipe %r needs a focus-visible: treatment"
|
|
443
|
+
% recipe.primitive, recipe)
|
|
444
|
+
if (("transition" in all_tokens or "animate-" in all_tokens)
|
|
445
|
+
and "motion-reduce:" not in all_tokens):
|
|
446
|
+
self._d("E-RECIPE-MOTION", Severity.ERROR,
|
|
447
|
+
"recipe %r uses motion without a motion-reduce: counterpart"
|
|
448
|
+
% recipe.primitive, recipe)
|
|
449
|
+
|
|
450
|
+
for node in _walk(mod):
|
|
451
|
+
if not isinstance(node, A.Element):
|
|
452
|
+
continue
|
|
453
|
+
class_values = [v for k, v in node.attrs if k == "class"]
|
|
454
|
+
if len(class_values) > 1:
|
|
455
|
+
self._d("E-RECIPE-AXIS-DUP", Severity.ERROR,
|
|
456
|
+
"class: may occur only once on an element", node)
|
|
457
|
+
for value in class_values:
|
|
458
|
+
if (not isinstance(value, A.Literal) or value.kind != "str"
|
|
459
|
+
or getattr(value, "interp_names", None)):
|
|
460
|
+
self._d("E-CLASS-DYNAMIC", Severity.ERROR,
|
|
461
|
+
"class: must be a complete, non-interpolated string literal",
|
|
462
|
+
node, 'class:"w-full sm:w-auto"')
|
|
463
|
+
continue
|
|
464
|
+
text = _lit_text(value) or ""
|
|
465
|
+
classes = text.split()
|
|
466
|
+
if any(c.rsplit(":", 1)[-1].startswith("!")
|
|
467
|
+
or c.rsplit(":", 1)[-1].endswith("!") for c in classes):
|
|
468
|
+
self._d("E-CLASS-IMPORTANT", Severity.ERROR,
|
|
469
|
+
"class: cannot use !important to force precedence", node)
|
|
470
|
+
utilities = [c.rsplit(":", 1)[-1] for c in classes]
|
|
471
|
+
if node.name == "status" and any(c in {"hidden", "invisible"} for c in utilities):
|
|
472
|
+
self._d("E-CLASS-LIVE-HIDDEN", Severity.ERROR,
|
|
473
|
+
"a compiler-owned live region must remain exposed", node)
|
|
474
|
+
if node.name == "status" and "sr-only" in utilities:
|
|
475
|
+
self._d("W-CLASS-LIVE-SR-ONLY", Severity.WARNING,
|
|
476
|
+
"status feedback remains audible but should also be visually present", node)
|
|
477
|
+
if any("[" in c and "]" in c for c in classes):
|
|
478
|
+
self._d("W-CLASS-ARBITRARY", Severity.WARNING,
|
|
479
|
+
"arbitrary values bypass the accessibility denylist", node)
|
|
480
|
+
if (node.name in {"button", "input", "link"}
|
|
481
|
+
and any(c in {"outline-none", "outline-0", "ring-0"} for c in utilities)
|
|
482
|
+
and not any("focus-visible:" in c and c.rsplit(":", 1)[-1]
|
|
483
|
+
not in {"outline-none", "outline-0", "ring-0"}
|
|
484
|
+
for c in classes)):
|
|
485
|
+
self._d("E-CLASS-FOCUS-REMOVED", Severity.ERROR,
|
|
486
|
+
"removed focus needs a focus-visible replacement", node)
|
|
487
|
+
# `variant:` literal-only enforcement lives in `view_bindings`, which
|
|
488
|
+
# has the per-view name set needed to tell a style token from a
|
|
489
|
+
# variable read.
|
|
490
|
+
|
|
491
|
+
# -- accessible primitives: dialog, tabs / tab (spec section 8.8) --------
|
|
492
|
+
def accessible_primitives(self, mod: A.Module) -> None:
|
|
493
|
+
def has_on(el, event):
|
|
494
|
+
return any(isinstance(b, A.OnBlock) and b.event == event for b in el.body)
|
|
495
|
+
|
|
496
|
+
def visit(node, parent_el):
|
|
497
|
+
if isinstance(node, A.Element):
|
|
498
|
+
attrs = {k for k, _ in node.attrs}
|
|
499
|
+
|
|
500
|
+
if node.name == "tab" and parent_el != "tabs":
|
|
501
|
+
self._d("E-TAB-PARENT", Severity.ERROR,
|
|
502
|
+
"'tab' is only valid as a direct child of 'tabs'", node,
|
|
503
|
+
"put this 'tab' inside a 'tabs' element")
|
|
504
|
+
|
|
505
|
+
if node.name == "tabs":
|
|
506
|
+
if "bind" not in attrs and "selected" not in attrs:
|
|
507
|
+
self._d("E-TABS-BIND", Severity.ERROR,
|
|
508
|
+
"'tabs' needs a selection: 'bind:<state>', or "
|
|
509
|
+
"'selected:<id>' with 'on select'", node,
|
|
510
|
+
"tabs bind:tab")
|
|
511
|
+
if "selected" in attrs and not has_on(node, "select"):
|
|
512
|
+
self._d("E-TABS-SELECT", Severity.ERROR,
|
|
513
|
+
"'tabs selected:<id>' needs 'on select t: ...' to "
|
|
514
|
+
"write the choice back", node,
|
|
515
|
+
"add 'on select t:' or use 'bind:tab' instead")
|
|
516
|
+
tab_kids = [b for b in node.body
|
|
517
|
+
if isinstance(b, A.Element) and b.name == "tab"]
|
|
518
|
+
if not tab_kids:
|
|
519
|
+
self._d("E-TABS-EMPTY", Severity.ERROR,
|
|
520
|
+
"'tabs' has no 'tab' children", node,
|
|
521
|
+
'tab "id" label:"Label"')
|
|
522
|
+
for b in node.body:
|
|
523
|
+
if isinstance(b, A.Element) and b.name != "tab":
|
|
524
|
+
self._d("E-TABS-CHILD", Severity.ERROR,
|
|
525
|
+
"'tabs' takes only 'tab' children; put other "
|
|
526
|
+
"content inside a 'tab'", b, None)
|
|
527
|
+
|
|
528
|
+
if node.name == "tab":
|
|
529
|
+
if not node.args:
|
|
530
|
+
self._d("E-TAB-ID", Severity.ERROR,
|
|
531
|
+
"'tab' needs an id as its first value", node,
|
|
532
|
+
'tab "overview" label:"Overview"')
|
|
533
|
+
if "label" not in attrs:
|
|
534
|
+
self._d("E-TAB-LABEL", Severity.ERROR,
|
|
535
|
+
"'tab' needs a 'label'", node,
|
|
536
|
+
'tab "overview" label:"Overview"')
|
|
537
|
+
|
|
538
|
+
if node.name == "status":
|
|
539
|
+
if (len(node.args) != 1 or node.body
|
|
540
|
+
or any(k not in ("class", "test_id", "test_scope") for k, _ in node.attrs)):
|
|
541
|
+
self._d("E-STATUS-SHAPE", Severity.ERROR,
|
|
542
|
+
"'status' takes one POSITIONAL expression (not an "
|
|
543
|
+
"attribute), optional class:, and no children",
|
|
544
|
+
node, 'status outcome_message — a bare expression, '
|
|
545
|
+
'never `status message:outcome_message`; keep it '
|
|
546
|
+
'mounted, use nil or "" while idle')
|
|
547
|
+
|
|
548
|
+
if node.name == "input":
|
|
549
|
+
# spec §8.9: every input carries a programmatic name,
|
|
550
|
+
# through *exactly one* of `label:` / `aria_label:`. A
|
|
551
|
+
# placeholder is not a label (it vanishes on input and
|
|
552
|
+
# some assistive tech skips it), so it does not count;
|
|
553
|
+
# neither does an empty or whitespace-only string.
|
|
554
|
+
name_kinds = [k for k, _ in node.attrs
|
|
555
|
+
if k in ("label", "aria_label")]
|
|
556
|
+
if not name_kinds:
|
|
557
|
+
hint = (" ('placeholder' is not a label)"
|
|
558
|
+
if "placeholder" in attrs else "")
|
|
559
|
+
self._d("E-INPUT-NAME", Severity.ERROR,
|
|
560
|
+
"'input' needs an accessible name: 'label:' for a "
|
|
561
|
+
"visible label, or 'aria_label:' when the purpose is "
|
|
562
|
+
"clear from nearby content" + hint, node,
|
|
563
|
+
'input label:"Email" bind:email')
|
|
564
|
+
elif len(name_kinds) > 1:
|
|
565
|
+
self._d("E-INPUT-NAME-DUP", Severity.ERROR,
|
|
566
|
+
"'input' takes exactly one accessible name -- "
|
|
567
|
+
"'label:' or 'aria_label:', not both", node,
|
|
568
|
+
'keep the visible one: input label:"Email" bind:email')
|
|
569
|
+
for k, v in node.attrs:
|
|
570
|
+
if k in ("label", "aria_label") and (
|
|
571
|
+
(t := _lit_text(v)) is not None and t.strip() == ""):
|
|
572
|
+
self._d("E-INPUT-NAME", Severity.ERROR,
|
|
573
|
+
"'input' %s is empty -- an accessible name "
|
|
574
|
+
"cannot be blank or whitespace" % k, node,
|
|
575
|
+
'input label:"Email" bind:email')
|
|
576
|
+
|
|
577
|
+
if node.name == "img":
|
|
578
|
+
# spec §8.10: every image declares its alternative text
|
|
579
|
+
# through exactly one of `alt:"…"` (meaningful),
|
|
580
|
+
# `alt:decorative` (presentational -> alt=""), or
|
|
581
|
+
# `alt:todo` / `alt_todo:"…"` (an unfinished placeholder
|
|
582
|
+
# the release check flags).
|
|
583
|
+
kinds, alt_v = _img_alt_kinds(node)
|
|
584
|
+
if not kinds:
|
|
585
|
+
self._d("E-IMG-ALT", Severity.ERROR,
|
|
586
|
+
"'img' needs alternative text: 'alt:\"…\"' for a "
|
|
587
|
+
"meaningful image, 'alt:decorative' for a "
|
|
588
|
+
"presentational one, or 'alt:todo' / "
|
|
589
|
+
"'alt_todo:\"…\"' to mark it unfinished", node,
|
|
590
|
+
'img src:avatar alt:"{user.name} avatar"')
|
|
591
|
+
elif len(kinds) > 1:
|
|
592
|
+
self._d("E-IMG-ALT-ONE", Severity.ERROR,
|
|
593
|
+
"'img' takes exactly one alt form: 'alt:' or "
|
|
594
|
+
"'alt_todo:', not both", node,
|
|
595
|
+
'img src:hero alt:"Team on stage"')
|
|
596
|
+
elif kinds == ["meaningful"] and (
|
|
597
|
+
(t := _lit_text(alt_v)) is not None and t.strip() == ""):
|
|
598
|
+
self._d("E-IMG-ALT", Severity.ERROR,
|
|
599
|
+
"'img alt:' is empty -- an empty alt means "
|
|
600
|
+
"decorative; write 'alt:decorative' if that is "
|
|
601
|
+
"the intent", node, "img src:divider alt:decorative")
|
|
602
|
+
elif kinds == ["todo"] and self.release:
|
|
603
|
+
self._d("E-IMG-ALT-TODO", Severity.ERROR,
|
|
604
|
+
"'img' alt text is still a placeholder -- a "
|
|
605
|
+
"release build needs real 'alt:' text or "
|
|
606
|
+
"'alt:decorative'", node,
|
|
607
|
+
'alt:"a clear description of what the image shows"')
|
|
608
|
+
elif kinds == ["todo"]:
|
|
609
|
+
self._d("W-IMG-ALT-TODO", Severity.WARNING,
|
|
610
|
+
"'img' alt text is an unfinished placeholder -- "
|
|
611
|
+
"replace it with real 'alt:' text or "
|
|
612
|
+
"'alt:decorative' before release", node,
|
|
613
|
+
'alt:"a clear description of what the image shows"')
|
|
614
|
+
|
|
615
|
+
if node.name == "dialog":
|
|
616
|
+
if "title" not in attrs:
|
|
617
|
+
self._d("E-DIALOG-TITLE", Severity.ERROR,
|
|
618
|
+
"'dialog' needs a 'title' — it is the accessible name",
|
|
619
|
+
node, 'dialog title:"Discard changes?"')
|
|
620
|
+
if not has_on(node, "dismiss"):
|
|
621
|
+
self._d("E-DIALOG-DISMISS", Severity.ERROR,
|
|
622
|
+
"'dialog' needs 'on dismiss:' — it runs on Escape, a "
|
|
623
|
+
"backdrop click, and the close control", node,
|
|
624
|
+
"on dismiss: confirming = false")
|
|
625
|
+
|
|
626
|
+
for b in node.body:
|
|
627
|
+
visit(b, node.name)
|
|
628
|
+
return
|
|
629
|
+
|
|
630
|
+
if isinstance(node, (list, tuple)):
|
|
631
|
+
for x in node:
|
|
632
|
+
visit(x, parent_el)
|
|
633
|
+
elif _is_dc(node):
|
|
634
|
+
for f in node.__dataclass_fields__:
|
|
635
|
+
visit(getattr(node, f), parent_el)
|
|
636
|
+
|
|
637
|
+
for it in mod.items:
|
|
638
|
+
if isinstance(it, A.ViewDecl):
|
|
639
|
+
for st in it.body:
|
|
640
|
+
visit(st, None)
|
|
641
|
+
|
|
642
|
+
# -- page title / headings / landmarks (spec section 8.11) ---------------
|
|
643
|
+
def document_structure(self, mod: A.Module) -> None:
|
|
644
|
+
pages = [it for it in mod.items if isinstance(it, A.PageDecl)]
|
|
645
|
+
if len(pages) > 1:
|
|
646
|
+
self._d("E-PAGE-DUP", Severity.ERROR,
|
|
647
|
+
"only one `page` declaration per module", pages[1], None)
|
|
648
|
+
for pg in pages:
|
|
649
|
+
attrs = {k: v for k, v in pg.attrs}
|
|
650
|
+
if "title" not in attrs:
|
|
651
|
+
self._d("E-PAGE-TITLE", Severity.ERROR,
|
|
652
|
+
"`page` needs a `title:` -- the document's accessible name",
|
|
653
|
+
pg, 'page title:"Weekly Report"')
|
|
654
|
+
else:
|
|
655
|
+
t = _lit_text(attrs["title"])
|
|
656
|
+
if t is not None and t.strip() == "":
|
|
657
|
+
self._d("E-PAGE-TITLE", Severity.ERROR,
|
|
658
|
+
"`page title:` is empty -- give the document a real "
|
|
659
|
+
"name (a name that only goes empty at runtime is "
|
|
660
|
+
"`render.empty_page_title`)", pg,
|
|
661
|
+
'page title:"Weekly Report"')
|
|
662
|
+
for k in attrs:
|
|
663
|
+
if k != "title":
|
|
664
|
+
self._d("W-PAGE-ATTR", Severity.WARNING,
|
|
665
|
+
"`page` setting '%s' is not recognised (only "
|
|
666
|
+
"`title:` so far)" % k, pg, None)
|
|
667
|
+
|
|
668
|
+
# Per view, in source order: heading-level skips and landmark sanity.
|
|
669
|
+
# Deliberately per-view -- a reusable component that starts at `h2`
|
|
670
|
+
# because it will be dropped under some page's `h1` is legitimate and
|
|
671
|
+
# must not be flagged. `_walk` yields depth-first in field order, which
|
|
672
|
+
# tracks source order closely enough for a warning.
|
|
673
|
+
HEAD = {"h1": 1, "h2": 2, "h3": 3, "h4": 4, "h5": 5, "h6": 6}
|
|
674
|
+
for it in mod.items:
|
|
675
|
+
if not isinstance(it, A.ViewDecl):
|
|
676
|
+
continue
|
|
677
|
+
prev = 0
|
|
678
|
+
first_h1 = None
|
|
679
|
+
navs: list = []
|
|
680
|
+
mains: list = []
|
|
681
|
+
for node in _walk(it.body):
|
|
682
|
+
if not isinstance(node, A.Element):
|
|
683
|
+
continue
|
|
684
|
+
lvl = HEAD.get(node.name)
|
|
685
|
+
if lvl is not None:
|
|
686
|
+
if prev and lvl > prev + 1:
|
|
687
|
+
self._d("W-HEADING-SKIP", Severity.WARNING,
|
|
688
|
+
"heading jumps from h%d to h%d in one view -- "
|
|
689
|
+
"screen-reader users navigate by level; don't "
|
|
690
|
+
"skip one" % (prev, lvl), node,
|
|
691
|
+
"use h%d here, or restructure the section" % (prev + 1))
|
|
692
|
+
if lvl == 1:
|
|
693
|
+
if first_h1 is not None:
|
|
694
|
+
self._d("W-HEADING-MULTI-H1", Severity.WARNING,
|
|
695
|
+
"a view renders more than one `h1`; a page "
|
|
696
|
+
"usually has exactly one top-level heading",
|
|
697
|
+
node, "make the later one `h2`")
|
|
698
|
+
else:
|
|
699
|
+
first_h1 = node
|
|
700
|
+
prev = lvl
|
|
701
|
+
elif node.name == "nav":
|
|
702
|
+
navs.append(node)
|
|
703
|
+
elif node.name == "main":
|
|
704
|
+
mains.append(node)
|
|
705
|
+
if len(navs) > 1:
|
|
706
|
+
for n in navs:
|
|
707
|
+
if not any(k == "aria_label" for k, _ in n.attrs):
|
|
708
|
+
self._d("E-NAV-NAME", Severity.ERROR,
|
|
709
|
+
"with more than one `nav` in a view, each needs "
|
|
710
|
+
"`aria_label:` so assistive tech can tell them "
|
|
711
|
+
"apart", n, 'nav aria_label:"Primary"')
|
|
712
|
+
if len(mains) > 1:
|
|
713
|
+
self._d("E-MAIN-DUP", Severity.ERROR,
|
|
714
|
+
"a view renders more than one `main` landmark -- there "
|
|
715
|
+
"is one main region per page", mains[1], None)
|
|
716
|
+
|
|
717
|
+
# -- composed-page landmarks / ids (spec section 8.11) ------------------
|
|
718
|
+
# `document_structure` above never crosses a view boundary -- by design,
|
|
719
|
+
# so a reusable component that starts at `h2` or carries its own `nav`
|
|
720
|
+
# is not punished for a page it cannot see. But the runtime builds ONE
|
|
721
|
+
# document from a page-root view that renders child views (an `Element`
|
|
722
|
+
# whose name is another `view`) and may repeat them -- directly, or in a
|
|
723
|
+
# `list`. On that composed page a second `<main>` (every one emits
|
|
724
|
+
# `id="vl-main"`, so also a duplicate id and an ambiguous skip target),
|
|
725
|
+
# two `<nav>`s assistive tech cannot tell apart (both unnamed, or
|
|
726
|
+
# sharing one `aria_label:`), or one static `id:` rendered by more than
|
|
727
|
+
# one element are real defects no per-view pass can catch. This walks
|
|
728
|
+
# the component graph from the page root and tallies them.
|
|
729
|
+
#
|
|
730
|
+
# A subtree's tally is a *summary*, not a node list: per `<main>` /
|
|
731
|
+
# unnamed-`nav` and per named-`nav` label / static id, a worst-case
|
|
732
|
+
# occurrence `count`, a `repeat` flag (reached through a `list` -> 0..N
|
|
733
|
+
# instances) and, for landmarks, a `crosses` flag (reached through a
|
|
734
|
+
# component / `list` boundary). Siblings ADD counts and OR flags;
|
|
735
|
+
# branches of an `if` are mutually exclusive, so per key they take the
|
|
736
|
+
# MAX count and OR the flags -- a landmark in one branch cannot cancel a
|
|
737
|
+
# collision the other branch (or content outside the `if`) creates.
|
|
738
|
+
# `document_structure` still owns the pure single-view case (all at
|
|
739
|
+
# depth 0, no repeat), so `E-MAIN-COMPOSED` / the unnamed-`nav` form of
|
|
740
|
+
# `E-NAV-NAME` require `crosses` to avoid a duplicate diagnostic.
|
|
741
|
+
#
|
|
742
|
+
# Runs only when the module declares a `page` -- the same "this file
|
|
743
|
+
# builds a page" signal `vlbuild` uses. Cross-file `use` composition is
|
|
744
|
+
# out of scope here (the resolver would be the place); a component
|
|
745
|
+
# reused within one module -- the common case -- is covered. This is a
|
|
746
|
+
# structural over-approximation: conditions are not evaluated, so two
|
|
747
|
+
# separate `if`s that happen to be mutually exclusive by data are still
|
|
748
|
+
# treated as independently reachable.
|
|
749
|
+
def composed_document(self, mod: A.Module) -> None:
|
|
750
|
+
if not any(isinstance(it, A.PageDecl) for it in mod.items):
|
|
751
|
+
return
|
|
752
|
+
views = {it.name: it for it in mod.items if isinstance(it, A.ViewDecl)}
|
|
753
|
+
if not views:
|
|
754
|
+
return
|
|
755
|
+
|
|
756
|
+
# Same root pick as vlbuild Emitter._root_view_name: a preferred name
|
|
757
|
+
# if present, else the last view that needs no arguments.
|
|
758
|
+
ROOT_PREFERENCE = ("Dashboard", "Main", "App", "Page", "Root", "Index")
|
|
759
|
+
rootable = [n for n, v in views.items()
|
|
760
|
+
if not v.params or all(p.default is not None for p in v.params)]
|
|
761
|
+
root = next((n for n in ROOT_PREFERENCE if n in rootable),
|
|
762
|
+
rootable[-1] if rootable else None)
|
|
763
|
+
if root is None:
|
|
764
|
+
return
|
|
765
|
+
|
|
766
|
+
def land():
|
|
767
|
+
return {"count": 0, "repeat": False, "crosses": False, "nodes": []}
|
|
768
|
+
|
|
769
|
+
def empty():
|
|
770
|
+
return {"mains": land(), "unnamed_navs": land(),
|
|
771
|
+
"named_navs": {}, "ids": {}} # label / id -> land()-ish
|
|
772
|
+
|
|
773
|
+
def land_merge(a, b, add):
|
|
774
|
+
return {"count": (a["count"] + b["count"]) if add
|
|
775
|
+
else max(a["count"], b["count"]),
|
|
776
|
+
"repeat": a["repeat"] or b["repeat"],
|
|
777
|
+
"crosses": a["crosses"] or b["crosses"],
|
|
778
|
+
"nodes": a["nodes"] + b["nodes"]}
|
|
779
|
+
|
|
780
|
+
def keyed_merge(a, b, add):
|
|
781
|
+
out = {k: dict(v) for k, v in a.items()}
|
|
782
|
+
for k, v in b.items():
|
|
783
|
+
if k in out:
|
|
784
|
+
out[k] = land_merge(out[k], v, add)
|
|
785
|
+
else:
|
|
786
|
+
out[k] = dict(v)
|
|
787
|
+
return out
|
|
788
|
+
|
|
789
|
+
def combine(parts, add):
|
|
790
|
+
out = empty()
|
|
791
|
+
for p in parts:
|
|
792
|
+
out = {"mains": land_merge(out["mains"], p["mains"], add),
|
|
793
|
+
"unnamed_navs": land_merge(out["unnamed_navs"],
|
|
794
|
+
p["unnamed_navs"], add),
|
|
795
|
+
"named_navs": keyed_merge(out["named_navs"],
|
|
796
|
+
p["named_navs"], add),
|
|
797
|
+
"ids": keyed_merge(out["ids"], p["ids"], add)}
|
|
798
|
+
return out
|
|
799
|
+
|
|
800
|
+
def walk(nodes, cur, depth, repeat, stack):
|
|
801
|
+
return combine([node_tally(n, cur, depth, repeat, stack)
|
|
802
|
+
for n in (nodes or ())], add=True)
|
|
803
|
+
|
|
804
|
+
def node_tally(node, cur, depth, repeat, stack):
|
|
805
|
+
if isinstance(node, A.Element):
|
|
806
|
+
nm = node.name
|
|
807
|
+
here = empty()
|
|
808
|
+
crosses = depth >= 1 or repeat
|
|
809
|
+
if nm == "main":
|
|
810
|
+
here["mains"] = {"count": 1, "repeat": repeat,
|
|
811
|
+
"crosses": crosses, "nodes": [node]}
|
|
812
|
+
elif nm == "nav":
|
|
813
|
+
has_attr = any(k == "aria_label" for k, _ in node.attrs)
|
|
814
|
+
lbl = next((_lit_text(v) for k, v in node.attrs
|
|
815
|
+
if k == "aria_label"), None)
|
|
816
|
+
if not has_attr or (lbl is not None and lbl.strip() == ""):
|
|
817
|
+
here["unnamed_navs"] = {"count": 1, "repeat": repeat,
|
|
818
|
+
"crosses": crosses, "nodes": [node]}
|
|
819
|
+
elif lbl is not None: # static, non-empty name
|
|
820
|
+
here["named_navs"] = {lbl.strip(): {
|
|
821
|
+
"count": 1, "repeat": repeat, "crosses": crosses,
|
|
822
|
+
"nodes": [node]}}
|
|
823
|
+
# a dynamic aria_label: is "named" but uncheckable -> skip
|
|
824
|
+
for k, v in node.attrs:
|
|
825
|
+
if k == "id":
|
|
826
|
+
t = _lit_text(v)
|
|
827
|
+
if t is not None and t.strip():
|
|
828
|
+
cur_id = here["ids"].get(t.strip())
|
|
829
|
+
if cur_id:
|
|
830
|
+
cur_id["count"] += 1
|
|
831
|
+
cur_id["nodes"].append(node)
|
|
832
|
+
else:
|
|
833
|
+
here["ids"][t.strip()] = {
|
|
834
|
+
"count": 1, "repeat": repeat,
|
|
835
|
+
"crosses": crosses, "nodes": [node]}
|
|
836
|
+
parts = [here, walk(node.body, cur, depth, repeat, stack)]
|
|
837
|
+
if nm in views and nm not in stack:
|
|
838
|
+
parts.append(walk(views[nm].body, nm, depth + 1, repeat,
|
|
839
|
+
stack | {nm}))
|
|
840
|
+
return combine(parts, add=True)
|
|
841
|
+
if isinstance(node, A.ListBlock):
|
|
842
|
+
return walk(node.body, cur, depth + 1, True, stack)
|
|
843
|
+
if isinstance(node, A.If):
|
|
844
|
+
branches = [walk(node.then, cur, depth, repeat, stack)]
|
|
845
|
+
for _c, body in node.elifs:
|
|
846
|
+
branches.append(walk(body, cur, depth, repeat, stack))
|
|
847
|
+
branches.append(walk(node.orelse, cur, depth, repeat, stack))
|
|
848
|
+
return combine(branches, add=False) # mutually exclusive
|
|
849
|
+
if isinstance(node, A.LoadDecl):
|
|
850
|
+
# pending / error bodies are alternatives to the loaded view.
|
|
851
|
+
return combine([walk(node.pending, cur, depth, repeat, stack),
|
|
852
|
+
walk(node.error_body, cur, depth, repeat, stack)],
|
|
853
|
+
add=False)
|
|
854
|
+
return empty()
|
|
855
|
+
|
|
856
|
+
t = walk(views[root].body, root, 0, False, {root})
|
|
857
|
+
|
|
858
|
+
# More than one <main> can reach the page, and at least one comes
|
|
859
|
+
# through a component / `list` boundary (the pure single-view case
|
|
860
|
+
# is `document_structure`'s `E-MAIN-DUP`).
|
|
861
|
+
m = t["mains"]
|
|
862
|
+
if m["nodes"] and m["crosses"] and (m["count"] >= 2 or m["repeat"]):
|
|
863
|
+
nodes = m["nodes"]
|
|
864
|
+
offender = nodes[1] if len(nodes) >= 2 else nodes[0]
|
|
865
|
+
self._d("E-MAIN-COMPOSED", Severity.ERROR,
|
|
866
|
+
"the composed page renders more than one `main` landmark "
|
|
867
|
+
"-- a `main` in the page-root view and in a view it "
|
|
868
|
+
"renders, a view whose `main` is reused, or a `main` "
|
|
869
|
+
"inside a `list` (mutually exclusive `if` / `else` "
|
|
870
|
+
'branches count once). Every `main` emits id="vl-main", '
|
|
871
|
+
"so this is also a duplicate id and an ambiguous "
|
|
872
|
+
"skip-link target. Keep the single `main` in the "
|
|
873
|
+
"page-root view.", offender, None)
|
|
874
|
+
|
|
875
|
+
# Two or more unnamed <nav>s can reach the page, at least one across
|
|
876
|
+
# a view / `list` boundary (single-view case is per-view `E-NAV-NAME`).
|
|
877
|
+
un = t["unnamed_navs"]
|
|
878
|
+
if un["nodes"] and un["crosses"] and (un["count"] >= 2 or un["repeat"]):
|
|
879
|
+
for n in un["nodes"]:
|
|
880
|
+
self._d("E-NAV-NAME", Severity.ERROR,
|
|
881
|
+
"the composed page renders more than one unnamed "
|
|
882
|
+
"`nav` landmark across the views it composes -- give "
|
|
883
|
+
"each `nav` a distinct `aria_label:` so assistive tech "
|
|
884
|
+
"can tell them apart", n, 'nav aria_label:"Primary"')
|
|
885
|
+
|
|
886
|
+
# Two or more <nav>s sharing one accessible name -- not caught
|
|
887
|
+
# anywhere else, within a view or across composed views.
|
|
888
|
+
for lbl, info in t["named_navs"].items():
|
|
889
|
+
if info["count"] >= 2 or info["repeat"]:
|
|
890
|
+
for n in info["nodes"]:
|
|
891
|
+
self._d("E-NAV-NAME", Severity.ERROR,
|
|
892
|
+
'more than one `nav` on the composed page is named '
|
|
893
|
+
'"%s" -- sibling and composed navigation regions '
|
|
894
|
+
"need *distinct* `aria_label:` names" % lbl, n,
|
|
895
|
+
'e.g. aria_label:"Primary" and aria_label:"Footer"')
|
|
896
|
+
|
|
897
|
+
# Any id rendered by more than one element on the page.
|
|
898
|
+
for id_str, info in t["ids"].items():
|
|
899
|
+
if info["count"] >= 2 or info["repeat"]:
|
|
900
|
+
for node in (info["nodes"][1:] or info["nodes"]):
|
|
901
|
+
self._d("E-ID-DUP", Severity.ERROR,
|
|
902
|
+
'id "%s" is rendered by more than one element on '
|
|
903
|
+
"the composed page (a reused component, a `list`, "
|
|
904
|
+
"or two views with the same static `id:`) -- an id "
|
|
905
|
+
"must be unique in the document" % id_str, node,
|
|
906
|
+
"use a distinct id, or select by role / text instead")
|
|
907
|
+
|
|
908
|
+
# -- structural (no scope needed) -----------------------------------------
|
|
909
|
+
def structural(self, mod: A.Module) -> None:
|
|
910
|
+
for node in _walk(mod):
|
|
911
|
+
if isinstance(node, A.TypeRef) and node.opt_nested:
|
|
912
|
+
self._d("E-OPT-NEST", Severity.ERROR,
|
|
913
|
+
"optional types do not nest ('T??' is not allowed)", node,
|
|
914
|
+
"use a single '?'")
|
|
915
|
+
elif isinstance(node, A.ListBlock) and not node.has_key:
|
|
916
|
+
v = node.var if node.var and node.var != "?" else "item"
|
|
917
|
+
self._d("E-LIST-KEY", Severity.ERROR,
|
|
918
|
+
"list block requires a stable 'key' expression", node,
|
|
919
|
+
f"list <expr> as {v} key:{v}.id")
|
|
920
|
+
elif isinstance(node, A.Let) and node.empty_literal_no_type:
|
|
921
|
+
self._d("E-EMPTY-INFER", Severity.ERROR,
|
|
922
|
+
"cannot infer the element type of an empty collection literal", node,
|
|
923
|
+
"add a type annotation, e.g. 'let x list<str> = []'")
|
|
924
|
+
elif isinstance(node, A.RecordLit) and node.type_name == "Err":
|
|
925
|
+
self._d("E-ERR-LITERAL", Severity.ERROR,
|
|
926
|
+
"'Err' is never constructed with record-literal syntax (spec section 6.3)",
|
|
927
|
+
node, 'use err(...), e.g. err(not_found("no such post"))')
|
|
928
|
+
|
|
929
|
+
# -- services ----------------------------------------------------
|
|
930
|
+
def services(self, mod: A.Module) -> None:
|
|
931
|
+
for it in mod.items:
|
|
932
|
+
if not isinstance(it, A.ServiceDecl):
|
|
933
|
+
continue
|
|
934
|
+
routes = it.routes
|
|
935
|
+
svc_public = getattr(it, "is_public", False)
|
|
936
|
+
|
|
937
|
+
def _explicit_none(r):
|
|
938
|
+
return r.guard is not None and getattr(r.guard, "kind", "") == "none"
|
|
939
|
+
|
|
940
|
+
# `guard is None` = no line at all; `auth none` = deliberate. Both are
|
|
941
|
+
# "public" for the majority count; only the first is "maybe forgotten".
|
|
942
|
+
public = [r for r in routes if r.guard is None or _explicit_none(r)]
|
|
943
|
+
|
|
944
|
+
if not svc_public:
|
|
945
|
+
for r in routes:
|
|
946
|
+
if r.guard is None:
|
|
947
|
+
self._d("W-ROUTE-NO-AUTH", Severity.WARNING,
|
|
948
|
+
f"route '{r.method} {r.path}' has no 'auth' guard (public)", r,
|
|
949
|
+
"add an 'auth ...' line, 'auth none' to mark it deliberate, "
|
|
950
|
+
"or 'service ... public'")
|
|
951
|
+
if routes and len(public) * 2 > len(routes):
|
|
952
|
+
self._d("W-PUBLIC-MAJORITY", Severity.WARNING,
|
|
953
|
+
f"{len(public)} of {len(routes)} routes in service '{it.name}' are public",
|
|
954
|
+
it,
|
|
955
|
+
"guards may have been omitted entirely; review each public route, "
|
|
956
|
+
"or declare 'service ... public'")
|
|
957
|
+
|
|
958
|
+
# An unauthenticated write is a distinct claim: warn even under
|
|
959
|
+
# `service ... public` and even with an explicit `auth none` (spec section 10).
|
|
960
|
+
for r in routes:
|
|
961
|
+
if r.method in ("post", "patch", "put", "delete") and (
|
|
962
|
+
r.guard is None or _explicit_none(r)):
|
|
963
|
+
self._d("W-WRITE-NO-AUTH", Severity.WARNING,
|
|
964
|
+
f"state-changing route '{r.method} {r.path}' has no 'auth' guard", r,
|
|
965
|
+
"add 'auth role:...' or 'auth owner:...'")
|
|
966
|
+
|
|
967
|
+
# -- outbound api param routing (spec section 9) --------------------
|
|
968
|
+
def api_params(self, mod: A.Module) -> None:
|
|
969
|
+
# For a body-less method every parameter must ride in the path or the
|
|
970
|
+
# query string; there is nowhere else to put it. `post`/`put`/`patch`
|
|
971
|
+
# parameters that are neither become the request body, which is fine.
|
|
972
|
+
for it in mod.items:
|
|
973
|
+
if not isinstance(it, A.ApiDecl):
|
|
974
|
+
continue
|
|
975
|
+
for ep in it.endpoints:
|
|
976
|
+
if ep.method not in ("get", "delete", "head"):
|
|
977
|
+
continue
|
|
978
|
+
routed: set[str] = set()
|
|
979
|
+
for d in ep.settings:
|
|
980
|
+
if not isinstance(d, A.Directive):
|
|
981
|
+
continue
|
|
982
|
+
if d.head == "path":
|
|
983
|
+
routed.update(re.findall(r"\{(\w+)\}", " ".join(d.tokens)))
|
|
984
|
+
elif d.head == "query":
|
|
985
|
+
routed.update(t for t in d.tokens if t.isidentifier())
|
|
986
|
+
for p in ep.params:
|
|
987
|
+
if p.name and p.name != "?" and p.name not in routed:
|
|
988
|
+
self._d("E-API-PARAM-UNROUTED", Severity.ERROR,
|
|
989
|
+
f"parameter '{p.name}' of '{ep.method} {ep.name}' is in neither "
|
|
990
|
+
f"the path nor 'query', and a '{ep.method}' has no request body "
|
|
991
|
+
"to carry it", ep,
|
|
992
|
+
f"add '{p.name}' to the 'query' line, or '{{{p.name}}}' to the path")
|
|
993
|
+
|
|
994
|
+
# -- semantic (scoped) ---------------------------------------------
|
|
995
|
+
def semantic(self, mod: A.Module) -> None:
|
|
996
|
+
top = set(BUILTINS) | set(_PRIM_NAMES)
|
|
997
|
+
for it in mod.items:
|
|
998
|
+
nm = getattr(it, "name", None)
|
|
999
|
+
if nm:
|
|
1000
|
+
top.add(nm)
|
|
1001
|
+
if isinstance(it, A.Use):
|
|
1002
|
+
for n in it.names:
|
|
1003
|
+
top.add(n)
|
|
1004
|
+
base = it.path.replace("./", "").replace("../", "")
|
|
1005
|
+
base = base.split("/")[-1].split(".")[-1]
|
|
1006
|
+
if base:
|
|
1007
|
+
top.add(base)
|
|
1008
|
+
elif isinstance(it, A.ApiDecl):
|
|
1009
|
+
for ep in it.endpoints:
|
|
1010
|
+
top.add(ep.name)
|
|
1011
|
+
elif isinstance(it, A.TypeDecl) and it.kind == "union":
|
|
1012
|
+
for v in it.variants:
|
|
1013
|
+
top.add(v.name)
|
|
1014
|
+
elif isinstance(it, (A.TypeDecl, A.ModelDecl)):
|
|
1015
|
+
pass
|
|
1016
|
+
|
|
1017
|
+
for it in mod.items:
|
|
1018
|
+
if isinstance(it, A.FnDecl):
|
|
1019
|
+
scope = {p.name for p in it.params}
|
|
1020
|
+
self._scoped(it.body, [top, scope], fallible=_fallible(it.ret))
|
|
1021
|
+
elif isinstance(it, A.ViewDecl):
|
|
1022
|
+
scope = {p.name for p in it.params} | {"session"}
|
|
1023
|
+
for s in it.body:
|
|
1024
|
+
if isinstance(s, (A.StateDecl, A.DeriveDecl, A.LoadDecl)):
|
|
1025
|
+
scope.add(s.name)
|
|
1026
|
+
self._scoped(it.body, [top, scope], fallible=True)
|
|
1027
|
+
elif isinstance(it, A.ServiceDecl):
|
|
1028
|
+
for r in it.routes:
|
|
1029
|
+
scope = {p.name for p in r.params} | {"session"}
|
|
1030
|
+
for seg in r.path.split("/"):
|
|
1031
|
+
if seg.startswith(":"):
|
|
1032
|
+
scope.add(seg[1:])
|
|
1033
|
+
self._scoped(r.body, [top, scope], fallible=_fallible(r.ret))
|
|
1034
|
+
|
|
1035
|
+
def _resolve(self, name: str, scopes: list[set]) -> bool:
|
|
1036
|
+
if name in ("?", "_", "self"):
|
|
1037
|
+
return True
|
|
1038
|
+
return any(name in s for s in scopes)
|
|
1039
|
+
|
|
1040
|
+
def _declare(self, name: str, scopes: list[set], at) -> None:
|
|
1041
|
+
if not name or name == "?":
|
|
1042
|
+
return
|
|
1043
|
+
if name in scopes[-1]:
|
|
1044
|
+
self._d("E-SHADOW", Severity.ERROR,
|
|
1045
|
+
f"'{name}' shadows a binding already in this scope", at,
|
|
1046
|
+
f"rename this binding; shadowing '{name}' is not allowed")
|
|
1047
|
+
scopes[-1].add(name)
|
|
1048
|
+
|
|
1049
|
+
def _scoped(self, stmts, scopes: list[set], fallible: bool, in_loop: bool = False) -> None:
|
|
1050
|
+
for st in stmts or []:
|
|
1051
|
+
self._stmt(st, scopes, fallible, in_loop)
|
|
1052
|
+
|
|
1053
|
+
def _stmt(self, st, scopes: list[set], fallible: bool, in_loop: bool = False) -> None:
|
|
1054
|
+
if st is None:
|
|
1055
|
+
return
|
|
1056
|
+
if isinstance(st, A.Let):
|
|
1057
|
+
self._expr(st.init, scopes, fallible)
|
|
1058
|
+
self._declare(st.name, scopes, st)
|
|
1059
|
+
elif isinstance(st, A.Assign):
|
|
1060
|
+
self._expr(st.target, scopes, fallible)
|
|
1061
|
+
self._expr(st.value, scopes, fallible)
|
|
1062
|
+
elif isinstance(st, A.ExprStmt):
|
|
1063
|
+
self._expr(st.expr, scopes, fallible)
|
|
1064
|
+
elif isinstance(st, A.Return):
|
|
1065
|
+
self._expr(st.value, scopes, fallible)
|
|
1066
|
+
elif isinstance(st, A.If):
|
|
1067
|
+
self._expr(st.cond, scopes, fallible)
|
|
1068
|
+
self._block(st.then, scopes, fallible, in_loop)
|
|
1069
|
+
for c, b in st.elifs:
|
|
1070
|
+
self._expr(c, scopes, fallible)
|
|
1071
|
+
self._block(b, scopes, fallible, in_loop)
|
|
1072
|
+
self._block(st.orelse, scopes, fallible, in_loop)
|
|
1073
|
+
elif isinstance(st, A.Match):
|
|
1074
|
+
self._expr(st.subject, scopes, fallible)
|
|
1075
|
+
for pat, guard, body in st.arms:
|
|
1076
|
+
inner = scopes + [set(getattr(pat, "binds", []))]
|
|
1077
|
+
if guard is not None:
|
|
1078
|
+
self._expr(guard, inner, fallible)
|
|
1079
|
+
self._block(body, inner, fallible, in_loop)
|
|
1080
|
+
elif isinstance(st, A.For):
|
|
1081
|
+
self._expr(st.iter, scopes, fallible)
|
|
1082
|
+
inner = scopes + [set(st.vars)]
|
|
1083
|
+
self._scoped(st.body, inner, fallible, in_loop=True)
|
|
1084
|
+
elif isinstance(st, A.While):
|
|
1085
|
+
self._expr(st.cond, scopes, fallible)
|
|
1086
|
+
self._block(st.body, scopes, fallible, in_loop=True)
|
|
1087
|
+
elif isinstance(st, A.Par):
|
|
1088
|
+
self._block(st.lets, scopes, fallible, in_loop)
|
|
1089
|
+
elif isinstance(st, A.Spawn):
|
|
1090
|
+
self._block(st.body, scopes, fallible, in_loop=False)
|
|
1091
|
+
elif isinstance(st, A.Try):
|
|
1092
|
+
self._expr(st.expr, scopes, fallible)
|
|
1093
|
+
for pat, body in st.catches:
|
|
1094
|
+
inner = scopes + [set(getattr(pat, "binds", []))]
|
|
1095
|
+
self._scoped(body, inner, fallible, in_loop)
|
|
1096
|
+
elif isinstance(st, (A.Break, A.Skip)):
|
|
1097
|
+
if not in_loop:
|
|
1098
|
+
kw = "break" if isinstance(st, A.Break) else "skip"
|
|
1099
|
+
self._d("E-LOOP-CONTROL", Severity.ERROR,
|
|
1100
|
+
f"'{kw}' is only valid inside a 'for' or 'while' loop", st,
|
|
1101
|
+
f"remove '{kw}', or move it into a loop body")
|
|
1102
|
+
# view statements
|
|
1103
|
+
elif isinstance(st, A.StateDecl):
|
|
1104
|
+
self._expr(st.init, scopes, fallible)
|
|
1105
|
+
elif isinstance(st, A.DeriveDecl):
|
|
1106
|
+
self._expr(st.expr, scopes, fallible)
|
|
1107
|
+
elif isinstance(st, A.LoadDecl):
|
|
1108
|
+
self._expr(st.expr, scopes, fallible)
|
|
1109
|
+
self._scoped(st.pending, scopes, fallible)
|
|
1110
|
+
inner = scopes + [{st.error_name}] if st.error_name else scopes
|
|
1111
|
+
self._scoped(st.error_body, inner, fallible)
|
|
1112
|
+
elif isinstance(st, A.OnBlock):
|
|
1113
|
+
inner = scopes + [set(st.args)]
|
|
1114
|
+
self._scoped(st.body, inner, fallible, in_loop=False)
|
|
1115
|
+
elif isinstance(st, A.ListBlock):
|
|
1116
|
+
self._expr(st.src, scopes, fallible)
|
|
1117
|
+
inner = scopes + [{st.var}]
|
|
1118
|
+
if st.key is not None:
|
|
1119
|
+
self._expr(st.key, inner, fallible)
|
|
1120
|
+
self._scoped(st.body, inner, fallible, in_loop=False)
|
|
1121
|
+
elif isinstance(st, A.Element):
|
|
1122
|
+
for a in st.args:
|
|
1123
|
+
self._expr(a, scopes, fallible)
|
|
1124
|
+
for _, v in st.attrs:
|
|
1125
|
+
self._expr(v, scopes, fallible)
|
|
1126
|
+
self._scoped(st.body, scopes, fallible, in_loop)
|
|
1127
|
+
else:
|
|
1128
|
+
# unknown statement shape: still walk any expressions inside it
|
|
1129
|
+
for node in _walk(st):
|
|
1130
|
+
if isinstance(node, A.Name):
|
|
1131
|
+
self._use(node, scopes)
|
|
1132
|
+
|
|
1133
|
+
def _block(self, stmts, scopes: list[set], fallible: bool, in_loop: bool = False) -> None:
|
|
1134
|
+
self._scoped(stmts, scopes + [set()], fallible, in_loop)
|
|
1135
|
+
|
|
1136
|
+
def _expr(self, ex, scopes: list[set], fallible: bool) -> None:
|
|
1137
|
+
if ex is None:
|
|
1138
|
+
return
|
|
1139
|
+
if isinstance(ex, A.Name):
|
|
1140
|
+
self._use(ex, scopes)
|
|
1141
|
+
elif isinstance(ex, A.Literal):
|
|
1142
|
+
if self.strict_names:
|
|
1143
|
+
for n in ex.interp_names:
|
|
1144
|
+
if not self._resolve(n, scopes):
|
|
1145
|
+
self._d("W-UNDEF-NAME", Severity.WARNING,
|
|
1146
|
+
f"'{n}' in string interpolation is not defined in this scope", ex,
|
|
1147
|
+
"check the name, or add the binding / import")
|
|
1148
|
+
elif isinstance(ex, A.Member):
|
|
1149
|
+
self._expr(ex.obj, scopes, fallible)
|
|
1150
|
+
elif isinstance(ex, A.Index):
|
|
1151
|
+
self._expr(ex.obj, scopes, fallible)
|
|
1152
|
+
self._expr(ex.index, scopes, fallible)
|
|
1153
|
+
elif isinstance(ex, A.Call):
|
|
1154
|
+
self._expr(ex.callee, scopes, fallible)
|
|
1155
|
+
for a in ex.args:
|
|
1156
|
+
self._expr(a, scopes, fallible)
|
|
1157
|
+
for _, v in ex.named:
|
|
1158
|
+
self._expr(v, scopes, fallible)
|
|
1159
|
+
elif isinstance(ex, A.Unary):
|
|
1160
|
+
self._expr(ex.operand, scopes, fallible)
|
|
1161
|
+
elif isinstance(ex, A.Binary):
|
|
1162
|
+
self._expr(ex.left, scopes, fallible)
|
|
1163
|
+
self._expr(ex.right, scopes, fallible)
|
|
1164
|
+
elif isinstance(ex, A.Range):
|
|
1165
|
+
self._expr(ex.lo, scopes, fallible)
|
|
1166
|
+
self._expr(ex.hi, scopes, fallible)
|
|
1167
|
+
elif isinstance(ex, A.TryOp):
|
|
1168
|
+
if not fallible:
|
|
1169
|
+
self._d("E-PROP-FALLIBLE", Severity.ERROR,
|
|
1170
|
+
"'?' propagates an error but the enclosing function is not fallible", ex,
|
|
1171
|
+
"declare the return type as fallible (T!), or handle with try/catch or ??")
|
|
1172
|
+
self._expr(ex.operand, scopes, fallible)
|
|
1173
|
+
elif isinstance(ex, A.IfExpr):
|
|
1174
|
+
self._expr(ex.cond, scopes, fallible)
|
|
1175
|
+
self._expr(ex.then, scopes, fallible)
|
|
1176
|
+
self._expr(ex.orelse, scopes, fallible)
|
|
1177
|
+
elif isinstance(ex, A.MatchExpr):
|
|
1178
|
+
self._expr(ex.subject, scopes, fallible)
|
|
1179
|
+
for pat, guard, body in ex.arms:
|
|
1180
|
+
inner = scopes + [set(getattr(pat, "binds", []))]
|
|
1181
|
+
if guard is not None:
|
|
1182
|
+
self._expr(guard, inner, fallible)
|
|
1183
|
+
self._scoped(body, inner, fallible)
|
|
1184
|
+
elif isinstance(ex, A.Closure):
|
|
1185
|
+
inner = scopes + [{p.name for p in ex.params}]
|
|
1186
|
+
if isinstance(ex.body, list):
|
|
1187
|
+
self._scoped(ex.body, inner, fallible)
|
|
1188
|
+
else:
|
|
1189
|
+
self._expr(ex.body, inner, fallible)
|
|
1190
|
+
elif isinstance(ex, A.ListLit):
|
|
1191
|
+
for x in ex.items:
|
|
1192
|
+
self._expr(x, scopes, fallible)
|
|
1193
|
+
elif isinstance(ex, A.RecordLit):
|
|
1194
|
+
for _, v in ex.fields:
|
|
1195
|
+
self._expr(v, scopes, fallible)
|
|
1196
|
+
|
|
1197
|
+
def _use(self, node: A.Name, scopes: list[set]) -> None:
|
|
1198
|
+
if not self.strict_names:
|
|
1199
|
+
return
|
|
1200
|
+
n = node.id
|
|
1201
|
+
if not n or n == "?" or n[:1].isupper():
|
|
1202
|
+
return # type / constructor names are not resolved in this prototype
|
|
1203
|
+
if self._resolve(n, scopes):
|
|
1204
|
+
return
|
|
1205
|
+
self._d("W-UNDEF-NAME", Severity.WARNING,
|
|
1206
|
+
f"'{n}' is not defined in this scope", node,
|
|
1207
|
+
"check the name, or add the binding / import")
|
|
1208
|
+
|
|
1209
|
+
|
|
1210
|
+
def _fallible(tref) -> bool:
|
|
1211
|
+
return isinstance(tref, A.TypeRef) and tref.fallible
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def _fn_sig(tref: A.TypeRef) -> str:
|
|
1215
|
+
"""Render a `fn(...)`-typed prop's signature for a diagnostic, e.g.
|
|
1216
|
+
'fn(int, str) nil'."""
|
|
1217
|
+
def name(t):
|
|
1218
|
+
if t is None:
|
|
1219
|
+
return "nil"
|
|
1220
|
+
return "fn(...)" if getattr(t, "is_fn", False) else (t.name or "?")
|
|
1221
|
+
return "fn(%s) %s" % (", ".join(name(p) for p in tref.fn_params),
|
|
1222
|
+
name(tref.fn_ret))
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
def check(mod: A.Module, filename: str, li: LineIndex,
|
|
1226
|
+
strict_names: bool = False, release: bool = False) -> list[Diagnostic]:
|
|
1227
|
+
return Checker(filename, li, strict_names, release).run(mod)
|