volaro 0.1.0-alpha.5 → 0.1.0-alpha.6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # Volaro
2
2
 
3
- **Limited alpha** (this is `0.1.0-alpha.5`), published under both the
3
+ **Limited alpha** (this is `0.1.0-alpha.6`), published under both the
4
4
  `latest` and `alpha` dist-tags: `npm install volaro`.
5
5
 
6
6
  Volaro is an experimental application language intended for AI authoring and
@@ -154,9 +154,9 @@ use them.
154
154
  **Form controls:** `input` (a full type matrix, including `checkbox`/`radio`
155
155
  binding a `checked` state), `textarea`, `select`/`option`/`optgroup`,
156
156
  `fieldset`/`legend`, `datalist`, `button`, `link`. `disabled:` works on
157
- `button input textarea select fieldset optgroup option`. There is no
158
- standalone `label` element and no semantic `switch`/`aria-pressed` toggle —
159
- model one as a `button` reading a `state` bool. `variant:` is a literal
157
+ `button input textarea select fieldset optgroup option`. A toggle is
158
+ `button pressed:<bool>` (a toggle button, `aria-pressed`; there is no
159
+ `switch` role). There is no standalone `label` element. `variant:` is a literal
160
160
  style name, not a computed expression. `if` used as an expression is binary
161
161
  (`if c a else b`); `match` and block `if` are statements only. A module-level
162
162
  `fn` is not available to a view. **`volaro supported` and `volaro crib` are
package/bin/vl.js CHANGED
@@ -771,8 +771,8 @@ choose the interpreter if \`python3\` is not the one you want.
771
771
 
772
772
  SHIPPED SUBSET: single page (no router); \`input\` (full type matrix incl.
773
773
  checkbox/radio), \`textarea\`, \`select\`/\`option\`/\`optgroup\`,
774
- \`fieldset\`/\`legend\`, \`datalist\`, \`button\`, \`link\` (no standalone
775
- \`label\`, no semantic switch/aria-pressed toggle); \`if\` as an expression is
774
+ \`fieldset\`/\`legend\`, \`datalist\`, \`button\` (a toggle is \`button pressed:\`,
775
+ aria-pressed; no switch role), \`link\` (no standalone \`label\`); \`if\` as an expression is
776
776
  binary; \`match\` is a statement; a module-level \`fn\` cannot be called from a
777
777
  view. \`volaro crib\` + \`volaro supported\` are authoritative for this build;
778
778
  \`volaro spec\` is the wider design, not what it accepts.
@@ -1,6 +1,6 @@
1
1
  {
2
- "revision": "8c0952905f3449e5dc7c4fe75c2ba4b718b34148",
2
+ "revision": "78304efecb7e728025c959e1fdf207749b3652ed",
3
3
  "source": "git",
4
4
  "dirty": false,
5
- "content_sha256": "24266586ae4ce6a2122f6893b7b44cf1cf721c2e97785afe95751a8109a2cb7a"
5
+ "content_sha256": "1747ce8d53c4ae2caed129b014f01f7e4af5619ab5358a833e0c888e940c830e"
6
6
  }
@@ -1 +1 @@
1
- 8c0952905f3449e5dc7c4fe75c2ba4b718b34148
1
+ 78304efecb7e728025c959e1fdf207749b3652ed
@@ -370,6 +370,7 @@ class Literal(Node):
370
370
  kind: str = "" # int float str bool nil duration
371
371
  raw: str = ""
372
372
  interp_names: list = field(default_factory=list)
373
+ interp_calls: list = field(default_factory=list) # bare names called: "{len(xs)}"
373
374
 
374
375
 
375
376
  @dataclass
@@ -17,7 +17,7 @@ from .elements import (
17
17
  AUTOCOMPLETE_TOKENS, BUTTON_TYPES, ELEMENT_EVENTS, INPUT_TYPE_ATTRS,
18
18
  KNOWN_EVENTS,
19
19
  EXCLUSIVE_CHILDREN, EXCLUSIVE_PARENTS, NAMED_LANDMARKS,
20
- NATIVE_ELEMENT_TAGS, OL_TYPES,
20
+ NATIVE_ELEMENT_TAGS, OL_TYPES, SPECIAL_ELEMENT_NAMES, attr_is_known,
21
21
  PHRASING_ELEMENTS, PHRASING_ONLY_PARENTS, TEXT_ARG_ELEMENTS,
22
22
  VOID_ELEMENTS,
23
23
  BDI_DIRS, BDO_DIRS, INTERACTIVE_ELEMENTS, LINK_RELS, LINK_TARGETS,
@@ -66,6 +66,23 @@ BUILTINS = {
66
66
  BUILTINS.update(ELEMENT_NAMES)
67
67
  _PRIM_NAMES = {"int", "float", "str", "bool", "bytes", "time", "dur", "uuid", "json", "nil"}
68
68
 
69
+ # Names on the BUILTINS allowlist that are methods in the language, never free
70
+ # functions. A bare call (`len(xs)`) passed --resolve and was emitted verbatim,
71
+ # an undefined JS function: ReferenceError at load (action-48).
72
+ _METHOD_ONLY = {
73
+ "len": "xs.len()", "format": "value.format(pattern)",
74
+ "reload": "posts.reload()", "loading": "posts.loading()",
75
+ }
76
+ # Free functions the spec shows but no emitter lowers as a bare call (not in
77
+ # vlbuild's STD_FNS). Only an error where the build emits the call -- a view
78
+ # or a service route; a module-level `fn` body is never emitted into either.
79
+ _FN_NOT_BUILT = {
80
+ "sorted": "sort_by(xs, fn(x) x)", "first": "xs[0] (check xs.len() > 0 first)",
81
+ "fold": "a `for` loop over a `let mut` accumulator",
82
+ "merge": "a record literal naming every field",
83
+ "to_int": "no string-to-int conversion is built yet; keep the value an int",
84
+ }
85
+
69
86
  # `use std.X { name, ... }` names `vlbuild` actually lowers to a working call
70
87
  # -- NOT the same list as `BUILTINS` above (that one is a generous, deliberately
71
88
  # loose allowlist for the W-UNDEF-NAME heuristic, and includes several names,
@@ -246,6 +263,11 @@ class Checker:
246
263
  # keeps a `alt_todo:` / other TODO marker out of a shipped build.
247
264
  self.release = release
248
265
  self.diags: list[Diagnostic] = []
266
+ # semantic(): names the module declares or imports, and whether the
267
+ # code being walked is emitted by the build (views, routes) or not
268
+ # (module-level fn bodies)
269
+ self._module_names: set[str] = set()
270
+ self._emitted = True
249
271
 
250
272
  # -- emit ----------------------------------------------------------
251
273
  def _d(self, code: str, sev: Severity, msg: str, node, fix: str | None = None) -> None:
@@ -255,6 +277,7 @@ class Checker:
255
277
 
256
278
  def run(self, mod: A.Module) -> list[Diagnostic]:
257
279
  from .test_ids import check_test_ids
280
+ self._module = mod
258
281
  self.diags.extend(check_test_ids(mod, self.filename))
259
282
  self.structural(mod)
260
283
  self.styling(mod)
@@ -266,6 +289,7 @@ class Checker:
266
289
  self.children_marker(mod)
267
290
  self.services(mod)
268
291
  self.api_params(mod)
292
+ self.api_base(mod)
269
293
  self.imports(mod)
270
294
  self.semantic(mod)
271
295
  return self.diags
@@ -327,17 +351,12 @@ class Checker:
327
351
  elif isinstance(node, A.For):
328
352
  value_names.update(getattr(node, "vars", []) or [])
329
353
 
354
+ # (1) is checked by semantic(), whose walker scopes names
355
+ # lexically: an assignment to a list/for var, closure or handler
356
+ # parameter, match/catch bind or `let` that shadows a readonly
357
+ # name writes that local, not the view-level value (issue #17).
330
358
  for node in _walk(it.body):
331
- if isinstance(node, A.Assign):
332
- tgt = getattr(node, "target", None)
333
- nm = tgt.id if isinstance(tgt, A.Name) else None
334
- if nm in readonly:
335
- self._d("E-ASSIGN-READONLY", Severity.ERROR,
336
- "'%s' is a %s and cannot be assigned to -- it is "
337
- "recomputed, not stored. Declare a `state` for a "
338
- "value you change." % (nm, readonly[nm]),
339
- node, None)
340
- elif isinstance(node, A.Element):
359
+ if isinstance(node, A.Element):
341
360
  for k, v in node.attrs:
342
361
  if k != "variant":
343
362
  continue
@@ -585,7 +604,10 @@ class Checker:
585
604
  if any("[" in c and "]" in c for c in classes):
586
605
  self._d("W-CLASS-ARBITRARY", Severity.WARNING,
587
606
  "arbitrary values bypass the accessibility denylist", node)
588
- if (node.name in {"button", "input", "link"}
607
+ # The same element set vlbuild's resolve_classes enforces; the
608
+ # two must agree or `vlcheck --resolve` passes source the build
609
+ # then rejects, and the write -> check -> repair loop breaks.
610
+ if (node.name in {"button", "input", "link", "select", "textarea"}
589
611
  and any(c in {"outline-none", "outline-0", "ring-0"} for c in utilities)
590
612
  and not any("focus-visible:" in c and c.rsplit(":", 1)[-1]
591
613
  not in {"outline-none", "outline-0", "ring-0"}
@@ -1052,6 +1074,17 @@ class Checker:
1052
1074
  elif k in ("start", "reversed") and name != "ol":
1053
1075
  self._d("E-ATTR-TARGET", Severity.ERROR,
1054
1076
  "'%s:' is only supported on 'ol'" % k, node, None)
1077
+ elif k == "pressed" and name != "button":
1078
+ # action-40 (Toggle). `pressed:` is a button's toggle state
1079
+ # (aria-pressed); on any other element it contradicts the
1080
+ # element's role. vlbuild refuses it too (E-PRESSED-TARGET);
1081
+ # the checker must agree or the write -> check -> repair
1082
+ # loop passes source the build then rejects.
1083
+ self._d("E-ATTR-TARGET", Severity.ERROR,
1084
+ "'pressed:' is only supported on 'button' -- it is a "
1085
+ "toggle button's on/off state (aria-pressed), and on "
1086
+ "'%s' it would contradict the element's role" % name,
1087
+ node, "use a 'button' with 'pressed:' for a toggle")
1055
1088
  elif k == "cite" and name not in ("blockquote", "q"):
1056
1089
  self._d("E-ATTR-TARGET", Severity.ERROR,
1057
1090
  "'cite:' is only supported on 'blockquote' and 'q'",
@@ -1061,6 +1094,85 @@ class Checker:
1061
1094
  self._table_element(node, parent_el)
1062
1095
  self._media_element(node, parent_el)
1063
1096
  self._global_and_style_surface(node, parent_el)
1097
+ self._unknown_attrs(node)
1098
+
1099
+ def _unknown_attrs(self, node) -> None:
1100
+ """`E-ATTR-UNKNOWN`: an attribute no rule gives a meaning to.
1101
+ vlbuild refuses these (`Emitter._apply_attr`); until this rule the
1102
+ checker passed them, so `row wrap:true` checked clean and then failed
1103
+ the build. `attr_is_known` holds the emitter's name list, and a
1104
+ vlbuild test sweeps it against the real emitter."""
1105
+ name = node.name
1106
+ if name not in NATIVE_ELEMENT_TAGS or name in SPECIAL_ELEMENT_NAMES:
1107
+ return
1108
+ axes = self._recipe_axes_for(node)
1109
+ for k, _v in node.attrs:
1110
+ if attr_is_known(name, k):
1111
+ continue
1112
+ if axes is None or k in axes:
1113
+ # A recipe's style axis, or a recipes.vl this pass cannot
1114
+ # read: the build decides.
1115
+ continue
1116
+ if any(d.severity == Severity.ERROR and d.start == node.start
1117
+ and ("'%s:'" % k) in d.message for d in self.diags):
1118
+ continue # an earlier rule already refused this attribute
1119
+ self._d("E-ATTR-UNKNOWN", Severity.ERROR,
1120
+ "unknown attribute '%s:' on '%s' -- Volaro has no contract "
1121
+ "for it, and the build refuses it" % (k, name), node,
1122
+ "check the spelling, or see the supported attribute list "
1123
+ "for '%s'" % name)
1124
+
1125
+ def _recipe_axes_for(self, node):
1126
+ """The style-axis names of the recipe this element uses, as the
1127
+ build resolves them (vlbuild style_config.recipe_for): recipes in this
1128
+ file, in a recipes.vl beside it, or in the project's root recipes.vl
1129
+ for a file under app/. None when a recipes.vl does not parse."""
1130
+ recipes = self._recipes()
1131
+ if recipes is None:
1132
+ return None
1133
+ key = name = node.name
1134
+ if name == "input":
1135
+ for k, v in node.attrs:
1136
+ if k == "type" and isinstance(v, A.Name) and v.id in ("checkbox", "radio"):
1137
+ key = v.id
1138
+ return recipes.get(key, frozenset())
1139
+
1140
+ def _recipes(self):
1141
+ if hasattr(self, "_recipe_cache"):
1142
+ return self._recipe_cache
1143
+ mods = [self._module]
1144
+ here = os.path.dirname(os.path.abspath(self.filename))
1145
+ dirs = [here]
1146
+ d = here
1147
+ while os.path.dirname(d) != d:
1148
+ if os.path.basename(d) == "app":
1149
+ dirs.append(os.path.dirname(d))
1150
+ break
1151
+ d = os.path.dirname(d)
1152
+ for d in dict.fromkeys(dirs):
1153
+ path = os.path.join(d, "recipes.vl")
1154
+ if os.path.abspath(path) == os.path.abspath(self.filename) \
1155
+ or not os.path.isfile(path):
1156
+ continue
1157
+ from .lexer import Lexer
1158
+ from .parser import Parser
1159
+ with open(path, encoding="utf-8") as fh:
1160
+ src = fh.read()
1161
+ lx = Lexer(src, path)
1162
+ ps = Parser(lx.lex(), src, path)
1163
+ mod = ps.parse_module()
1164
+ if any(x.severity == Severity.ERROR for x in list(lx.diags) + list(ps.diags)):
1165
+ self._recipe_cache = None
1166
+ return None
1167
+ mods.append(mod)
1168
+ out: dict[str, set] = {}
1169
+ for mod in mods:
1170
+ for r in (x for x in mod.items if isinstance(x, A.RecipeDecl)):
1171
+ for row in r.body:
1172
+ if row.head not in ("base", "default") and row.tokens:
1173
+ out.setdefault(r.primitive, set()).add(row.tokens[0])
1174
+ self._recipe_cache = out
1175
+ return out
1064
1176
 
1065
1177
  # -- inline text and links (action-45.4) ---------------------------------
1066
1178
  def _inline_element(self, node) -> None:
@@ -1967,10 +2079,13 @@ class Checker:
1967
2079
  "'spellcheck:' is the literal 'true' or 'false'",
1968
2080
  node, "spellcheck:false")
1969
2081
 
1970
- if "title" in attrs and name not in TITLE_HOME:
2082
+ if "title" in attrs and name not in TITLE_HOME and name != "dialog":
1971
2083
  # A warning, not an error. It is genuinely harmless as
1972
2084
  # supplementary text and 45.3 already documented it in the global
1973
2085
  # set, so an error would break existing source for no safety gain.
2086
+ # `dialog title:` is not a tooltip: spec §8.8 requires it
2087
+ # (E-DIALOG-TITLE) and the build makes it the dialog's heading and
2088
+ # accessible name.
1974
2089
  self._d("W-TITLE-TOOLTIP", Severity.WARNING,
1975
2090
  "'title:' on '%s' is a tooltip: it is not shown on a touch "
1976
2091
  "device at all, is not reachable by keyboard, and most "
@@ -2537,6 +2652,43 @@ class Checker:
2537
2652
  "to carry it", ep,
2538
2653
  f"add '{p.name}' to the 'query' line, or '{{{p.name}}}' to the path")
2539
2654
 
2655
+ def api_base(self, mod: A.Module) -> None:
2656
+ # An outbound `base` is joined to each endpoint's `path` with exactly
2657
+ # one `/` between them (spec section 9). A base that cannot join
2658
+ # safely is refused here, before the build: `base "/"` with
2659
+ # `path "/api/login"` used to produce `//api/login`, which a browser
2660
+ # sends to a host named `api` -- with the login body. A bare host or
2661
+ # a relative path is refused for the same reason: it resolves
2662
+ # against whatever page is open. vlbuild's `normalize_api_base` is
2663
+ # the fail-closed backstop with the same rule.
2664
+ for it in mod.items:
2665
+ if not isinstance(it, A.ApiDecl):
2666
+ continue
2667
+ for d in it.settings:
2668
+ if not isinstance(d, A.Directive) or d.head != "base":
2669
+ continue
2670
+ rest = d.tokens[1:] if d.tokens and d.tokens[0] == "base" else list(d.tokens)
2671
+ raw = rest[0] if rest else ""
2672
+ if len(raw) >= 2 and raw[0] == '"' and raw[-1] == '"':
2673
+ raw = raw[1:-1]
2674
+ b = raw.strip()
2675
+ if b == "":
2676
+ continue
2677
+ if b.startswith("//"):
2678
+ why = ("starts with '//', which a browser reads as a URL on "
2679
+ "another host (protocol-relative), not a path on this origin")
2680
+ elif b.startswith("/") or re.match(r"^https?://[^/?#\s]+", b):
2681
+ if "?" not in b and "#" not in b:
2682
+ continue
2683
+ why = "must not carry a query string or fragment"
2684
+ else:
2685
+ why = ("must be an absolute http(s):// URL or a same-origin path "
2686
+ "starting with '/'")
2687
+ self._d("E-API-BASE", Severity.ERROR,
2688
+ f"'api {it.name}' base \"{b}\" {why}", d,
2689
+ 'base "/" (this origin; joined to each path with one /) '
2690
+ 'or base "https://api.example.com/v1"')
2691
+
2540
2692
  # -- page.server.vl (spec section 10.2) ----------------------------------
2541
2693
  _PS_WRITE_METHODS = ("post", "put", "patch", "delete")
2542
2694
 
@@ -2626,13 +2778,18 @@ class Checker:
2626
2778
  # -- semantic (scoped) ---------------------------------------------
2627
2779
  def semantic(self, mod: A.Module) -> None:
2628
2780
  top = set(BUILTINS) | set(_PRIM_NAMES)
2781
+ # names the module itself declares or imports: a call to one of these
2782
+ # is the author's own function, never the builtin of the same name
2783
+ self._module_names = set()
2629
2784
  for it in mod.items:
2630
2785
  nm = getattr(it, "name", None)
2631
2786
  if nm:
2632
2787
  top.add(nm)
2788
+ self._module_names.add(nm)
2633
2789
  if isinstance(it, A.Use):
2634
2790
  for n in it.names:
2635
2791
  top.add(n)
2792
+ self._module_names.add(n)
2636
2793
  base = it.path.replace("./", "").replace("../", "")
2637
2794
  base = base.split("/")[-1].split(".")[-1]
2638
2795
  if base:
@@ -2648,20 +2805,28 @@ class Checker:
2648
2805
 
2649
2806
  for it in mod.items:
2650
2807
  if isinstance(it, A.FnDecl):
2651
- scope = {p.name for p in it.params}
2808
+ scope = _Scope((p.name for p in it.params), "function parameter")
2809
+ self._emitted = False
2652
2810
  self._scoped(it.body, [top, scope], fallible=_fallible(it.ret))
2811
+ self._emitted = True
2653
2812
  elif isinstance(it, A.ViewDecl):
2654
- scope = {p.name for p in it.params} | {"session"}
2813
+ scope = _Scope((p.name for p in it.params), "parameter")
2814
+ scope.bind("session", "session")
2655
2815
  for s in it.body:
2656
- if isinstance(s, (A.StateDecl, A.DeriveDecl, A.LoadDecl)):
2657
- scope.add(s.name)
2816
+ if isinstance(s, A.StateDecl):
2817
+ scope.bind(s.name, "state")
2818
+ elif isinstance(s, A.DeriveDecl):
2819
+ scope.bind(s.name, "derive")
2820
+ elif isinstance(s, A.LoadDecl):
2821
+ scope.bind(s.name, "load result")
2658
2822
  self._scoped(it.body, [top, scope], fallible=True)
2659
2823
  elif isinstance(it, A.ServiceDecl):
2660
2824
  for r in it.routes:
2661
- scope = {p.name for p in r.params} | {"session"}
2825
+ scope = _Scope((p.name for p in r.params), "route parameter")
2826
+ scope.bind("session", "session")
2662
2827
  for seg in r.path.split("/"):
2663
2828
  if seg.startswith(":"):
2664
- scope.add(seg[1:])
2829
+ scope.bind(seg[1:], "route parameter")
2665
2830
  self._scoped(r.body, [top, scope], fallible=_fallible(r.ret))
2666
2831
 
2667
2832
  def _resolve(self, name: str, scopes: list[set]) -> bool:
@@ -2688,9 +2853,12 @@ class Checker:
2688
2853
  if isinstance(st, A.Let):
2689
2854
  self._expr(st.init, scopes, fallible)
2690
2855
  self._declare(st.name, scopes, st)
2856
+ if st.name and st.name != "?" and isinstance(scopes[-1], _Scope):
2857
+ scopes[-1].kinds[st.name] = "mutable let" if st.mut else "`let` binding"
2691
2858
  elif isinstance(st, A.Assign):
2692
2859
  self._expr(st.target, scopes, fallible)
2693
2860
  self._expr(st.value, scopes, fallible)
2861
+ self._check_assign(st, scopes)
2694
2862
  elif isinstance(st, A.ExprStmt):
2695
2863
  self._expr(st.expr, scopes, fallible)
2696
2864
  elif isinstance(st, A.Return):
@@ -2705,13 +2873,13 @@ class Checker:
2705
2873
  elif isinstance(st, A.Match):
2706
2874
  self._expr(st.subject, scopes, fallible)
2707
2875
  for pat, guard, body in st.arms:
2708
- inner = scopes + [set(getattr(pat, "binds", []))]
2876
+ inner = scopes + [_Scope(getattr(pat, "binds", []), "match binding")]
2709
2877
  if guard is not None:
2710
2878
  self._expr(guard, inner, fallible)
2711
2879
  self._block(body, inner, fallible, in_loop)
2712
2880
  elif isinstance(st, A.For):
2713
2881
  self._expr(st.iter, scopes, fallible)
2714
- inner = scopes + [set(st.vars)]
2882
+ inner = scopes + [_Scope(st.vars, "loop variable")]
2715
2883
  self._scoped(st.body, inner, fallible, in_loop=True)
2716
2884
  elif isinstance(st, A.While):
2717
2885
  self._expr(st.cond, scopes, fallible)
@@ -2723,7 +2891,7 @@ class Checker:
2723
2891
  elif isinstance(st, A.Try):
2724
2892
  self._expr(st.expr, scopes, fallible)
2725
2893
  for pat, body in st.catches:
2726
- inner = scopes + [set(getattr(pat, "binds", []))]
2894
+ inner = scopes + [_Scope(getattr(pat, "binds", []), "catch binding")]
2727
2895
  self._scoped(body, inner, fallible, in_loop)
2728
2896
  elif isinstance(st, (A.Break, A.Skip)):
2729
2897
  if not in_loop:
@@ -2739,14 +2907,15 @@ class Checker:
2739
2907
  elif isinstance(st, A.LoadDecl):
2740
2908
  self._expr(st.expr, scopes, fallible)
2741
2909
  self._scoped(st.pending, scopes, fallible)
2742
- inner = scopes + [{st.error_name}] if st.error_name else scopes
2910
+ inner = (scopes + [_Scope([st.error_name], "load error")]
2911
+ if st.error_name else scopes)
2743
2912
  self._scoped(st.error_body, inner, fallible)
2744
2913
  elif isinstance(st, A.OnBlock):
2745
- inner = scopes + [set(st.args)]
2914
+ inner = scopes + [_Scope(st.args, "handler argument")]
2746
2915
  self._scoped(st.body, inner, fallible, in_loop=False)
2747
2916
  elif isinstance(st, A.ListBlock):
2748
2917
  self._expr(st.src, scopes, fallible)
2749
- inner = scopes + [{st.var}]
2918
+ inner = scopes + [_Scope([st.var], "list variable")]
2750
2919
  if st.key is not None:
2751
2920
  self._expr(st.key, inner, fallible)
2752
2921
  self._scoped(st.body, inner, fallible, in_loop=False)
@@ -2762,8 +2931,60 @@ class Checker:
2762
2931
  if isinstance(node, A.Name):
2763
2932
  self._use(node, scopes)
2764
2933
 
2934
+ def _check_builtin_call(self, call, scopes: list[set]) -> None:
2935
+ """A bare call to an allowlisted name the build cannot lower compiles
2936
+ to an undefined JS function (action-48). Skipped when the name is the
2937
+ author's own: a local binding or a module declaration or import."""
2938
+ callee = call.callee
2939
+ if not isinstance(callee, A.Name):
2940
+ return
2941
+ nm = callee.id
2942
+ if nm in self._module_names or any(nm in s for s in scopes[1:]):
2943
+ return
2944
+ if nm in _METHOD_ONLY:
2945
+ self._d("E-METHOD-AS-FN", Severity.ERROR,
2946
+ "'%s' is a method, not a function -- `%s(...)` compiles to "
2947
+ "an undefined function and throws when the page runs"
2948
+ % (nm, nm), call, "call it on the value: `%s`" % _METHOD_ONLY[nm])
2949
+ elif nm in _FN_NOT_BUILT and self._emitted:
2950
+ self._d("E-FN-NOT-BUILT", Severity.ERROR,
2951
+ "'%s' is in the spec but this build has no working `%s` -- "
2952
+ "the call compiles and throws when it runs" % (nm, nm),
2953
+ call, "use %s" % _FN_NOT_BUILT[nm])
2954
+
2955
+ def _check_assign(self, st, scopes: list[set]) -> None:
2956
+ """Only a view `state` and a `let mut` can be reassigned (spec 5.1:
2957
+ bindings are immutable unless declared `mut`). The binding that counts
2958
+ is the one the name resolves to -- the innermost scope declaring it --
2959
+ so a local that shadows a derive is judged as that local (issue #17).
2960
+ A view `derive` / `load` value or parameter is recomputed, not stored:
2961
+ assigning to one lowers to a dead JS statement. A loop variable lowers
2962
+ to a JS `const`, so assigning to one throws at runtime."""
2963
+ tgt = getattr(st, "target", None)
2964
+ if not isinstance(tgt, A.Name):
2965
+ return
2966
+ nm = tgt.id
2967
+ owner = next((s for s in reversed(scopes) if nm in s), None)
2968
+ kind = getattr(owner, "kinds", {}).get(nm)
2969
+ if kind is None or kind in _ASSIGNABLE:
2970
+ return # module-level / builtin names are not judged here
2971
+ if kind in _READONLY_VIEW:
2972
+ self._d("E-ASSIGN-READONLY", Severity.ERROR,
2973
+ "'%s' is a %s and cannot be assigned to -- it is "
2974
+ "recomputed, not stored. Declare a `state` for a "
2975
+ "value you change." % (nm, kind),
2976
+ st, None)
2977
+ return
2978
+ fix = ("declare it `let mut %s = ...`" % nm if kind == "`let` binding" else
2979
+ "copy it into a new binding first -- `let mut next = %s` -- "
2980
+ "and assign to that" % nm)
2981
+ self._d("E-ASSIGN-IMMUTABLE", Severity.ERROR,
2982
+ "'%s' is a %s and cannot be reassigned -- bindings are "
2983
+ "immutable unless declared `let mut`." % (nm, kind),
2984
+ st, fix)
2985
+
2765
2986
  def _block(self, stmts, scopes: list[set], fallible: bool, in_loop: bool = False) -> None:
2766
- self._scoped(stmts, scopes + [set()], fallible, in_loop)
2987
+ self._scoped(stmts, scopes + [_Scope()], fallible, in_loop)
2767
2988
 
2768
2989
  def _expr(self, ex, scopes: list[set], fallible: bool) -> None:
2769
2990
  if ex is None:
@@ -2771,6 +2992,10 @@ class Checker:
2771
2992
  if isinstance(ex, A.Name):
2772
2993
  self._use(ex, scopes)
2773
2994
  elif isinstance(ex, A.Literal):
2995
+ for nm in ex.interp_calls:
2996
+ self._check_builtin_call(A.Call(start=ex.start, end=ex.end,
2997
+ callee=A.Name(start=ex.start, end=ex.end, id=nm)),
2998
+ scopes)
2774
2999
  if self.strict_names:
2775
3000
  for n in ex.interp_names:
2776
3001
  if not self._resolve(n, scopes):
@@ -2783,6 +3008,7 @@ class Checker:
2783
3008
  self._expr(ex.obj, scopes, fallible)
2784
3009
  self._expr(ex.index, scopes, fallible)
2785
3010
  elif isinstance(ex, A.Call):
3011
+ self._check_builtin_call(ex, scopes)
2786
3012
  self._expr(ex.callee, scopes, fallible)
2787
3013
  for a in ex.args:
2788
3014
  self._expr(a, scopes, fallible)
@@ -2809,12 +3035,12 @@ class Checker:
2809
3035
  elif isinstance(ex, A.MatchExpr):
2810
3036
  self._expr(ex.subject, scopes, fallible)
2811
3037
  for pat, guard, body in ex.arms:
2812
- inner = scopes + [set(getattr(pat, "binds", []))]
3038
+ inner = scopes + [_Scope(getattr(pat, "binds", []), "match binding")]
2813
3039
  if guard is not None:
2814
3040
  self._expr(guard, inner, fallible)
2815
3041
  self._scoped(body, inner, fallible)
2816
3042
  elif isinstance(ex, A.Closure):
2817
- inner = scopes + [{p.name for p in ex.params}]
3043
+ inner = scopes + [_Scope((p.name for p in ex.params), "closure parameter")]
2818
3044
  if isinstance(ex.body, list):
2819
3045
  self._scoped(ex.body, inner, fallible)
2820
3046
  else:
@@ -2839,6 +3065,25 @@ class Checker:
2839
3065
  "check the name, or add the binding / import")
2840
3066
 
2841
3067
 
3068
+ class _Scope(set):
3069
+ """One lexical scope for semantic(): the names it binds, and each name's
3070
+ binding kind (what an assignment to it is judged by)."""
3071
+
3072
+ def __init__(self, names=(), kind: str | None = None) -> None:
3073
+ super().__init__(names)
3074
+ self.kinds: dict[str, str] = {n: kind for n in self} if kind else {}
3075
+
3076
+ def bind(self, name: str, kind: str) -> None:
3077
+ self.add(name)
3078
+ self.kinds[name] = kind
3079
+
3080
+
3081
+ # binding kinds an assignment may target
3082
+ _ASSIGNABLE = {"state", "mutable let"}
3083
+ # view values that are recomputed, not stored (E-ASSIGN-READONLY)
3084
+ _READONLY_VIEW = {"parameter", "derive", "load result"}
3085
+
3086
+
2842
3087
  def _fallible(tref) -> bool:
2843
3088
  return isinstance(tref, A.TypeRef) and tref.fallible
2844
3089
 
@@ -413,7 +413,11 @@ CONTROL_ATTRS = {
413
413
  }),
414
414
  "option": frozenset({"value", "label", "disabled"}),
415
415
  "optgroup": frozenset({"label", "disabled"}),
416
- "button": frozenset({"type", "name", "value", "disabled", "aria_label"}),
416
+ # action-40 (Toggle): `pressed:` makes a button a WAI-ARIA toggle
417
+ # button (`aria-pressed` true/false). The primitive carries the
418
+ # semantics, so it is a contract here, not an aria passthrough.
419
+ "button": frozenset({"type", "name", "value", "disabled", "aria_label",
420
+ "pressed"}),
417
421
  "fieldset": frozenset({"name", "disabled"}),
418
422
  "legend": frozenset(),
419
423
  "datalist": frozenset(),
@@ -1189,3 +1193,68 @@ ELEMENT_NAMES = frozenset(NATIVE_ELEMENT_TAGS) | SPECIAL_ELEMENT_NAMES
1189
1193
  # Hook support follows emission capability, not merely name recognition.
1190
1194
  TEST_ID_ELEMENT_NAMES = frozenset(NATIVE_ELEMENT_TAGS) | {"status"}
1191
1195
  TEST_SCOPE_ELEMENT_NAMES = TEST_ID_ELEMENT_NAMES | {"dialog", "tabs"}
1196
+
1197
+ # ---- The attribute names the emitter lowers ------------------------------
1198
+ # vlbuild's `Emitter._apply_attr` ends in `E-ATTR-UNKNOWN` for any name none
1199
+ # of its branches claims. vlcheck must refuse the same names, or the
1200
+ # write -> check -> repair loop passes source the build then rejects
1201
+ # (`row wrap:true`). This is the list of names those branches claim; the
1202
+ # emitter-parity test in vlbuild/tests/test_build.py drives the real
1203
+ # `_apply_attr` over every element and every name here, so a new branch
1204
+ # there without an entry here fails the gates.
1205
+ #
1206
+ # Names a branch claims on ANY element. Reaching the branch may still be an
1207
+ # error (`gap:` on a `strong` is E-LAYOUT-TARGET), but it is not UNKNOWN.
1208
+ APPLY_ATTR_NAMES = (
1209
+ frozenset(REFUSED_GLOBAL_ATTRS) | LAYOUT_STYLE_ATTRS | CELL_SPAN_ATTRS
1210
+ | frozenset({
1211
+ "hidden", "lang", "spellcheck", "dir", "test_id", "bind", "tabindex",
1212
+ # vlbuild PX_KEYS, `weight`, `color`/`bg`, `align`, `justify`
1213
+ "gap", "pad", "radius", "size", "max_w", "min_w", "w", "h",
1214
+ "pad_x", "pad_y", "weight", "color", "bg", "align", "justify",
1215
+ "to", "href", "target", "rel", "download", "scope", "open",
1216
+ "datetime", "src", "srcset", "poster", "captions", "preload", "kind",
1217
+ "srclang", "shape", "coords", "use_map", "crossorigin", "loading",
1218
+ "decoding", "variant", "aria_label", "disabled", "pressed",
1219
+ "read_only", "required", "multiple", "pre", "start", "reversed",
1220
+ "cite", "autocomplete",
1221
+ "type", "placeholder", "name", "id", "alt", "title", "fallback",
1222
+ "value", "label", "min", "max", "step", "pattern", "accept",
1223
+ "maxlength", "minlength", "list", "cols", "rows",
1224
+ }))
1225
+
1226
+ # Names a branch claims only on some elements; elsewhere they fall through
1227
+ # to E-ATTR-UNKNOWN.
1228
+ APPLY_ATTR_NAMES_ON = {
1229
+ "span": lambda el: el in ("colgroup", "col"),
1230
+ "low": lambda el: el in ("progress", "meter"),
1231
+ "high": lambda el: el in ("progress", "meter"),
1232
+ "optimum": lambda el: el in ("progress", "meter"),
1233
+ "width": lambda el: el in ("img", "video"),
1234
+ "height": lambda el: el in ("img", "video"),
1235
+ "sizes": lambda el: el in ("img", "source"),
1236
+ "media": lambda el: el == "source",
1237
+ }
1238
+ for _flag in MEDIA_FLAGS:
1239
+ APPLY_ATTR_NAMES_ON[_flag] = (
1240
+ lambda el: el in MEDIA_ATTRS or el == "img")
1241
+
1242
+ # The attributes the emitter consumes before `_apply_attr` runs.
1243
+ FIELD_ELEMENTS = frozenset({"input", "textarea", "select"})
1244
+
1245
+
1246
+ def attr_is_known(el_name: str, k: str) -> bool:
1247
+ """True when vlbuild lowers attribute `k` on native element `el_name`
1248
+ (or refuses it with a more specific code), false when the build would
1249
+ stop with E-ATTR-UNKNOWN. Recipe style axes and `class:` are the
1250
+ caller's to exclude: they depend on the project's recipes."""
1251
+ if k in ("class", "test_scope"):
1252
+ return True
1253
+ if el_name == "img" and k in ("alt", "alt_todo"):
1254
+ return True
1255
+ if el_name in FIELD_ELEMENTS and k in FIELD_A11Y_ATTRS:
1256
+ return True
1257
+ if k in APPLY_ATTR_NAMES:
1258
+ return True
1259
+ only = APPLY_ATTR_NAMES_ON.get(k)
1260
+ return only is not None and only(el_name)