volaro 0.1.0-alpha.4 → 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,8 +1,7 @@
1
1
  # Volaro
2
2
 
3
- **Limited alpha, published under the `alpha` dist-tag** (this is `0.1.0-alpha.4`) —
4
- `npm install volaro@alpha`. Not `latest` (that still resolves to an earlier
5
- `0.0.x` placeholder) — always specify `@alpha` or the exact version.
3
+ **Limited alpha** (this is `0.1.0-alpha.6`), published under both the
4
+ `latest` and `alpha` dist-tags: `npm install volaro`.
6
5
 
7
6
  Volaro is an experimental application language intended for AI authoring and
8
7
  human review. This package ships the language reference **and a working
@@ -113,11 +112,11 @@ dynamic route), so `volaro dev` on a project directory runs that server.
113
112
  ## Scaffold a project
114
113
 
115
114
  ```bash
116
- npm create volaro@alpha my-app # the separate create-volaro package
115
+ npm create volaro my-app # the separate create-volaro package
117
116
  ```
118
117
 
119
- (`@alpha` matters — `npm create volaro` with no tag resolves `latest`, which
120
- is still an earlier `0.0.x` placeholder, not this build.)
118
+ (If `npm create volaro` prints "This release is a placeholder", npm is
119
+ reusing a cached copy of the old `0.0.1`: run `npm cache clean --force`.)
121
120
 
122
121
  It writes one single-page starter, a `volaro.json`, and `package.json` scripts
123
122
  (`dev` / `build` / `check`) that call `volaro`.
@@ -155,9 +154,9 @@ use them.
155
154
  **Form controls:** `input` (a full type matrix, including `checkbox`/`radio`
156
155
  binding a `checked` state), `textarea`, `select`/`option`/`optgroup`,
157
156
  `fieldset`/`legend`, `datalist`, `button`, `link`. `disabled:` works on
158
- `button input textarea select fieldset optgroup option`. There is no
159
- standalone `label` element and no semantic `switch`/`aria-pressed` toggle —
160
- 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
161
160
  style name, not a computed expression. `if` used as an expression is binary
162
161
  (`if c a else b`); `match` and block `if` are statements only. A module-level
163
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": "0d5741da1385dd81e277b16715a93592744d8c87",
2
+ "revision": "78304efecb7e728025c959e1fdf207749b3652ed",
3
3
  "source": "git",
4
4
  "dirty": false,
5
- "content_sha256": "611292080bf797022be28aea42376ca55bbeba3e680c2ec638ae4a1417981ff0"
5
+ "content_sha256": "1747ce8d53c4ae2caed129b014f01f7e4af5619ab5358a833e0c888e940c830e"
6
6
  }
@@ -1 +1 @@
1
- 0d5741da1385dd81e277b16715a93592744d8c87
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
@@ -488,13 +507,14 @@ class Checker:
488
507
  if not valid:
489
508
  self._d("E-THEME-SHAPE", Severity.ERROR,
490
509
  "malformed %s token row" % category.head, category)
491
- allowed = {"button", "input", "card"}
510
+ allowed = {"button", "input", "card", "select", "textarea", "checkbox",
511
+ "radio", "link", "fieldset", "table", "details"}
492
512
  seen_recipes: set[str] = set()
493
513
  for recipe in (x for x in mod.items if isinstance(x, A.RecipeDecl)):
494
514
  if recipe.primitive not in allowed:
495
515
  self._d("E-RECIPE-UNKNOWN-PRIMITIVE", Severity.ERROR,
496
- "Slice 1 recipes support button, input, and card; got %r"
497
- % recipe.primitive, recipe)
516
+ "recipes support %s; got %r"
517
+ % (", ".join(sorted(allowed)), recipe.primitive), recipe)
498
518
  continue
499
519
  if recipe.primitive in seen_recipes:
500
520
  self._d("E-RECIPE-SHAPE", Severity.ERROR,
@@ -541,7 +561,8 @@ class Checker:
541
561
  if value not in axes.get(axis, set()):
542
562
  self._d("E-RECIPE-SHAPE", Severity.ERROR,
543
563
  "default %s:%s is not declared" % (axis, value), recipe)
544
- if recipe.primitive in ("button", "input"):
564
+ if recipe.primitive in ("button", "input", "select", "textarea",
565
+ "checkbox", "radio", "link"):
545
566
  all_tokens = " ".join(" ".join(r.tokens) for r in recipe.body)
546
567
  if "focus-visible:" not in all_tokens:
547
568
  self._d("E-RECIPE-FOCUS", Severity.ERROR,
@@ -583,7 +604,10 @@ class Checker:
583
604
  if any("[" in c and "]" in c for c in classes):
584
605
  self._d("W-CLASS-ARBITRARY", Severity.WARNING,
585
606
  "arbitrary values bypass the accessibility denylist", node)
586
- 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"}
587
611
  and any(c in {"outline-none", "outline-0", "ring-0"} for c in utilities)
588
612
  and not any("focus-visible:" in c and c.rsplit(":", 1)[-1]
589
613
  not in {"outline-none", "outline-0", "ring-0"}
@@ -1050,6 +1074,17 @@ class Checker:
1050
1074
  elif k in ("start", "reversed") and name != "ol":
1051
1075
  self._d("E-ATTR-TARGET", Severity.ERROR,
1052
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")
1053
1088
  elif k == "cite" and name not in ("blockquote", "q"):
1054
1089
  self._d("E-ATTR-TARGET", Severity.ERROR,
1055
1090
  "'cite:' is only supported on 'blockquote' and 'q'",
@@ -1059,6 +1094,85 @@ class Checker:
1059
1094
  self._table_element(node, parent_el)
1060
1095
  self._media_element(node, parent_el)
1061
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
1062
1176
 
1063
1177
  # -- inline text and links (action-45.4) ---------------------------------
1064
1178
  def _inline_element(self, node) -> None:
@@ -1965,10 +2079,13 @@ class Checker:
1965
2079
  "'spellcheck:' is the literal 'true' or 'false'",
1966
2080
  node, "spellcheck:false")
1967
2081
 
1968
- if "title" in attrs and name not in TITLE_HOME:
2082
+ if "title" in attrs and name not in TITLE_HOME and name != "dialog":
1969
2083
  # A warning, not an error. It is genuinely harmless as
1970
2084
  # supplementary text and 45.3 already documented it in the global
1971
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.
1972
2089
  self._d("W-TITLE-TOOLTIP", Severity.WARNING,
1973
2090
  "'title:' on '%s' is a tooltip: it is not shown on a touch "
1974
2091
  "device at all, is not reachable by keyboard, and most "
@@ -2535,6 +2652,43 @@ class Checker:
2535
2652
  "to carry it", ep,
2536
2653
  f"add '{p.name}' to the 'query' line, or '{{{p.name}}}' to the path")
2537
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
+
2538
2692
  # -- page.server.vl (spec section 10.2) ----------------------------------
2539
2693
  _PS_WRITE_METHODS = ("post", "put", "patch", "delete")
2540
2694
 
@@ -2624,13 +2778,18 @@ class Checker:
2624
2778
  # -- semantic (scoped) ---------------------------------------------
2625
2779
  def semantic(self, mod: A.Module) -> None:
2626
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()
2627
2784
  for it in mod.items:
2628
2785
  nm = getattr(it, "name", None)
2629
2786
  if nm:
2630
2787
  top.add(nm)
2788
+ self._module_names.add(nm)
2631
2789
  if isinstance(it, A.Use):
2632
2790
  for n in it.names:
2633
2791
  top.add(n)
2792
+ self._module_names.add(n)
2634
2793
  base = it.path.replace("./", "").replace("../", "")
2635
2794
  base = base.split("/")[-1].split(".")[-1]
2636
2795
  if base:
@@ -2646,20 +2805,28 @@ class Checker:
2646
2805
 
2647
2806
  for it in mod.items:
2648
2807
  if isinstance(it, A.FnDecl):
2649
- scope = {p.name for p in it.params}
2808
+ scope = _Scope((p.name for p in it.params), "function parameter")
2809
+ self._emitted = False
2650
2810
  self._scoped(it.body, [top, scope], fallible=_fallible(it.ret))
2811
+ self._emitted = True
2651
2812
  elif isinstance(it, A.ViewDecl):
2652
- 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")
2653
2815
  for s in it.body:
2654
- if isinstance(s, (A.StateDecl, A.DeriveDecl, A.LoadDecl)):
2655
- 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")
2656
2822
  self._scoped(it.body, [top, scope], fallible=True)
2657
2823
  elif isinstance(it, A.ServiceDecl):
2658
2824
  for r in it.routes:
2659
- 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")
2660
2827
  for seg in r.path.split("/"):
2661
2828
  if seg.startswith(":"):
2662
- scope.add(seg[1:])
2829
+ scope.bind(seg[1:], "route parameter")
2663
2830
  self._scoped(r.body, [top, scope], fallible=_fallible(r.ret))
2664
2831
 
2665
2832
  def _resolve(self, name: str, scopes: list[set]) -> bool:
@@ -2686,9 +2853,12 @@ class Checker:
2686
2853
  if isinstance(st, A.Let):
2687
2854
  self._expr(st.init, scopes, fallible)
2688
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"
2689
2858
  elif isinstance(st, A.Assign):
2690
2859
  self._expr(st.target, scopes, fallible)
2691
2860
  self._expr(st.value, scopes, fallible)
2861
+ self._check_assign(st, scopes)
2692
2862
  elif isinstance(st, A.ExprStmt):
2693
2863
  self._expr(st.expr, scopes, fallible)
2694
2864
  elif isinstance(st, A.Return):
@@ -2703,13 +2873,13 @@ class Checker:
2703
2873
  elif isinstance(st, A.Match):
2704
2874
  self._expr(st.subject, scopes, fallible)
2705
2875
  for pat, guard, body in st.arms:
2706
- inner = scopes + [set(getattr(pat, "binds", []))]
2876
+ inner = scopes + [_Scope(getattr(pat, "binds", []), "match binding")]
2707
2877
  if guard is not None:
2708
2878
  self._expr(guard, inner, fallible)
2709
2879
  self._block(body, inner, fallible, in_loop)
2710
2880
  elif isinstance(st, A.For):
2711
2881
  self._expr(st.iter, scopes, fallible)
2712
- inner = scopes + [set(st.vars)]
2882
+ inner = scopes + [_Scope(st.vars, "loop variable")]
2713
2883
  self._scoped(st.body, inner, fallible, in_loop=True)
2714
2884
  elif isinstance(st, A.While):
2715
2885
  self._expr(st.cond, scopes, fallible)
@@ -2721,7 +2891,7 @@ class Checker:
2721
2891
  elif isinstance(st, A.Try):
2722
2892
  self._expr(st.expr, scopes, fallible)
2723
2893
  for pat, body in st.catches:
2724
- inner = scopes + [set(getattr(pat, "binds", []))]
2894
+ inner = scopes + [_Scope(getattr(pat, "binds", []), "catch binding")]
2725
2895
  self._scoped(body, inner, fallible, in_loop)
2726
2896
  elif isinstance(st, (A.Break, A.Skip)):
2727
2897
  if not in_loop:
@@ -2737,14 +2907,15 @@ class Checker:
2737
2907
  elif isinstance(st, A.LoadDecl):
2738
2908
  self._expr(st.expr, scopes, fallible)
2739
2909
  self._scoped(st.pending, scopes, fallible)
2740
- 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)
2741
2912
  self._scoped(st.error_body, inner, fallible)
2742
2913
  elif isinstance(st, A.OnBlock):
2743
- inner = scopes + [set(st.args)]
2914
+ inner = scopes + [_Scope(st.args, "handler argument")]
2744
2915
  self._scoped(st.body, inner, fallible, in_loop=False)
2745
2916
  elif isinstance(st, A.ListBlock):
2746
2917
  self._expr(st.src, scopes, fallible)
2747
- inner = scopes + [{st.var}]
2918
+ inner = scopes + [_Scope([st.var], "list variable")]
2748
2919
  if st.key is not None:
2749
2920
  self._expr(st.key, inner, fallible)
2750
2921
  self._scoped(st.body, inner, fallible, in_loop=False)
@@ -2760,8 +2931,60 @@ class Checker:
2760
2931
  if isinstance(node, A.Name):
2761
2932
  self._use(node, scopes)
2762
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
+
2763
2986
  def _block(self, stmts, scopes: list[set], fallible: bool, in_loop: bool = False) -> None:
2764
- self._scoped(stmts, scopes + [set()], fallible, in_loop)
2987
+ self._scoped(stmts, scopes + [_Scope()], fallible, in_loop)
2765
2988
 
2766
2989
  def _expr(self, ex, scopes: list[set], fallible: bool) -> None:
2767
2990
  if ex is None:
@@ -2769,6 +2992,10 @@ class Checker:
2769
2992
  if isinstance(ex, A.Name):
2770
2993
  self._use(ex, scopes)
2771
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)
2772
2999
  if self.strict_names:
2773
3000
  for n in ex.interp_names:
2774
3001
  if not self._resolve(n, scopes):
@@ -2781,6 +3008,7 @@ class Checker:
2781
3008
  self._expr(ex.obj, scopes, fallible)
2782
3009
  self._expr(ex.index, scopes, fallible)
2783
3010
  elif isinstance(ex, A.Call):
3011
+ self._check_builtin_call(ex, scopes)
2784
3012
  self._expr(ex.callee, scopes, fallible)
2785
3013
  for a in ex.args:
2786
3014
  self._expr(a, scopes, fallible)
@@ -2807,12 +3035,12 @@ class Checker:
2807
3035
  elif isinstance(ex, A.MatchExpr):
2808
3036
  self._expr(ex.subject, scopes, fallible)
2809
3037
  for pat, guard, body in ex.arms:
2810
- inner = scopes + [set(getattr(pat, "binds", []))]
3038
+ inner = scopes + [_Scope(getattr(pat, "binds", []), "match binding")]
2811
3039
  if guard is not None:
2812
3040
  self._expr(guard, inner, fallible)
2813
3041
  self._scoped(body, inner, fallible)
2814
3042
  elif isinstance(ex, A.Closure):
2815
- inner = scopes + [{p.name for p in ex.params}]
3043
+ inner = scopes + [_Scope((p.name for p in ex.params), "closure parameter")]
2816
3044
  if isinstance(ex.body, list):
2817
3045
  self._scoped(ex.body, inner, fallible)
2818
3046
  else:
@@ -2837,6 +3065,25 @@ class Checker:
2837
3065
  "check the name, or add the binding / import")
2838
3066
 
2839
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
+
2840
3087
  def _fallible(tref) -> bool:
2841
3088
  return isinstance(tref, A.TypeRef) and tref.fallible
2842
3089