volaro 0.0.2 → 0.1.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +114 -22
  2. package/bin/vl.js +466 -28
  3. package/compiler/SOURCE_INFO.json +6 -0
  4. package/compiler/SOURCE_REV +1 -0
  5. package/compiler/validator/vlcheck/__init__.py +10 -0
  6. package/compiler/validator/vlcheck/__main__.py +128 -0
  7. package/compiler/validator/vlcheck/ast_nodes.py +397 -0
  8. package/compiler/validator/vlcheck/checks.py +1227 -0
  9. package/compiler/validator/vlcheck/diagnostics.py +88 -0
  10. package/compiler/validator/vlcheck/lexer.py +343 -0
  11. package/compiler/validator/vlcheck/parser.py +1638 -0
  12. package/compiler/validator/vlcheck/project_config.py +97 -0
  13. package/compiler/validator/vlcheck/resolve.py +849 -0
  14. package/compiler/validator/vlcheck/test_ids.py +98 -0
  15. package/compiler/vlbuild/styling/README.md +39 -0
  16. package/compiler/vlbuild/styling/build-css.mjs +103 -0
  17. package/compiler/vlbuild/styling/package-lock.json +1254 -0
  18. package/compiler/vlbuild/styling/package.json +15 -0
  19. package/compiler/vlbuild/styling/test-build-css.mjs +69 -0
  20. package/compiler/vlbuild/vlbuild/__init__.py +16 -0
  21. package/compiler/vlbuild/vlbuild/__main__.py +246 -0
  22. package/compiler/vlbuild/vlbuild/assets/vlrt.css +165 -0
  23. package/compiler/vlbuild/vlbuild/assets/vlrt.js +1291 -0
  24. package/compiler/vlbuild/vlbuild/emit.py +2023 -0
  25. package/compiler/vlbuild/vlbuild/server_emit.py +1551 -0
  26. package/compiler/vlbuild/vlbuild/static_assets.py +83 -0
  27. package/compiler/vlbuild/vlbuild/style_config.py +347 -0
  28. package/compiler/vlbuild/vlbuild/styling.py +39 -0
  29. package/examples/station.vl +2 -2
  30. package/language/crib.md +131 -10
  31. package/language/spec.md +209 -7
  32. package/language/supported.md +185 -0
  33. package/lib/env.js +107 -0
  34. package/package.json +19 -2
  35. package/scripts/record-provenance.mjs +51 -0
  36. package/scripts/selftest.mjs +54 -0
  37. package/scripts/sync-compiler.sh +52 -0
@@ -0,0 +1,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)