volaro 0.1.0-alpha.11 → 0.1.0-alpha.13

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.11`), published under both the
3
+ **Limited alpha** (this is `0.1.0-alpha.13`), 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
@@ -1,6 +1,6 @@
1
1
  {
2
- "revision": "5f9c32d19082250fc91ab3664ae6726aa2cfd0a0",
2
+ "revision": "d6e19b4e656bee01bf90048117fb24b598abdf3b",
3
3
  "source": "git",
4
4
  "dirty": false,
5
- "content_sha256": "53474ecb82ebaf7ff2861f647b7796dc9c84cce73d7061ee64a65c90365a80ae"
5
+ "content_sha256": "64c762006f1fceda3601d6c6b3a6de4ba99590d93aa14d04a749f318a840aa17"
6
6
  }
@@ -1 +1 @@
1
- 5f9c32d19082250fc91ab3664ae6726aa2cfd0a0
1
+ d6e19b4e656bee01bf90048117fb24b598abdf3b
@@ -24,7 +24,7 @@ from .checks import check
24
24
  from .diagnostics import LineIndex, Severity
25
25
  from .lexer import Lexer
26
26
  from .parser import Parser
27
- from .resolve import resolve
27
+ from .resolve import resolve, resolve_page_server
28
28
  from .project_config import load_config, ConfigError
29
29
  from .diagnostics import Diagnostic
30
30
  from .layoutcompose import check_all as check_project_composition
@@ -59,21 +59,25 @@ def check_source(src: str, filename: str, strict_names: bool = False,
59
59
  # (spec §10.2): handlers and an `errors` block, no module items. Every
60
60
  # other basename parses as an ordinary module.
61
61
  base = os.path.basename(filename)
62
+ resolver = resolve
62
63
  if base in ("page.server.vl", "_server.vl"):
63
64
  if base == "page.server.vl":
64
65
  mod = parser.parse_page_server()
66
+ # Handler bodies resolve like service route bodies, against the
67
+ # project's app.vl, the handler's params and the `_server.vl`
68
+ # context providers above the file (resolve.resolve_page_server).
69
+ resolver = resolve_page_server
65
70
  else:
66
71
  mod = parser.parse_server_ctx()
67
- # The resolver walks module items (services, views, use graphs); a
68
- # page-server file has none of those, so skip it here. Folder-param
69
- # matching against the route manifest is a later stage.
70
- do_resolve = False
72
+ # A `_server.vl` holds only `context` providers; the resolver
73
+ # has no pass for them yet.
74
+ do_resolve = False
71
75
  else:
72
76
  mod = parser.parse_module()
73
77
  diags = (list(lx.diags) + list(parser.diags)
74
78
  + check(mod, filename, li, strict_names=strict_names, release=release))
75
79
  if do_resolve:
76
- diags += resolve(mod, filename, li)
80
+ diags += resolver(mod, filename, li)
77
81
  diags.sort(key=lambda d: (d.start, 0 if d.severity == Severity.ERROR else 1, d.code))
78
82
  return li, diags
79
83
 
@@ -105,12 +105,13 @@ _METHOD_FN_FORM = {
105
105
  "matches": "matches", "match": "matches", "test": "matches",
106
106
  "first_char": "first_char", "split": "split", "join": "join",
107
107
  "filter": "filter", "map": "map", "take": "take", "count": "count",
108
+ "some": "any", "every": "all",
108
109
  "sum": "sum", "sort_by": "sort_by", "group_by": "group_by",
109
110
  "chunk": "chunk", "concat": "concat", "append": "append", "push": "append",
110
111
  "keys": "keys", "values": "values", "get_or": "get_or",
111
112
  }
112
113
  _LIST_FNS = {"filter", "map", "take", "count", "sum", "sort_by", "group_by",
113
- "chunk", "concat", "append", "join"}
114
+ "chunk", "concat", "append", "join", "any", "all"}
114
115
  # Other common carry-overs with a Volaro spelling that is not a function call.
115
116
  _METHOD_OTHER_FIX = {
116
117
  "length": "`{r}.len()`", "size": "`{r}.len()`",
@@ -124,64 +125,200 @@ _METHOD_OTHER_FIX = {
124
125
  # Names a page-server / service body has in scope that are ambient objects,
125
126
  # not user values.
126
127
  _AMBIENT_RECEIVERS = {"session", "request"}
127
- # Free functions the spec shows but no emitter lowers as a bare call (not in
128
- # vlbuild's STD_FNS). Only an error where the build emits the call -- a view
129
- # or a service route; a module-level `fn` body is never emitted into either.
128
+ # The std free-function surface, in one place. Every std function name the
129
+ # checker knows is in exactly one of the two tables below:
130
+ #
131
+ # STD_BUILT_FNS -- vlbuild lowers `name(...)` to `VL.std.name(...)`.
132
+ # vlbuild's `emit.STD_FNS` IS this set (imported, not
133
+ # copied), and `VL.std` in `assets/vlrt.js` defines each
134
+ # one; server.js embeds that same `VL.std` block, so a
135
+ # route body gets the identical function.
136
+ # _FN_NOT_BUILT -- a name the spec, the corpus or an agent uses that this
137
+ # build has no function for. A bare call where the build
138
+ # emits it (a view or a service route) is E-FN-NOT-BUILT
139
+ # with this fix line; a module-level `fn` body is never
140
+ # emitted into either, so it is not judged there.
141
+ #
142
+ # resolve.py's known-name list is derived from these two, so a name cannot be
143
+ # "known" without being classified. `test_std_fn_parity` in vlbuild's
144
+ # tests/test_build.py calls every known name through the real checker and
145
+ # requires each accepted one to exist in the emitter, `vlrt.js`, its minified
146
+ # copy and server.js. This replaced five hand-synced tables: `unique` was
147
+ # known to the resolver, missing from the other four, and built to a bare
148
+ # `unique(...)` that crashed every page (Testing Lab search brief, Example
149
+ # Demo 2.3).
150
+ STD_BUILT_FNS = frozenset({
151
+ # std.list
152
+ "filter", "map", "take", "count", "sum", "sort_by", "group_by", "chunk",
153
+ # list growth (spec section 20.9): the only way to combine or grow a list
154
+ "concat", "append",
155
+ # `unique(xs)`: first occurrence of each distinct value, in order
156
+ "unique",
157
+ # `any(xs, f)` / `all(xs, f)`: whether some / every item passes `f`
158
+ "any", "all",
159
+ # std.str (spec section 13: split, join, trim, case, replace, contains,
160
+ # regex -- `matches`)
161
+ "join", "split", "first_char", "trim", "upper", "lower", "replace",
162
+ "contains", "matches",
163
+ # std.map
164
+ "keys", "values", "get_or",
165
+ # numeric conversion
166
+ "int_to_float", "float_to_int",
167
+ })
168
+
169
+ _FOLD_FIX = "a `for` loop over a `let mut` accumulator"
130
170
  _FN_NOT_BUILT = {
131
- "sorted": "sort_by(xs, fn(x) x)", "first": "xs[0] (check xs.len() > 0 first)",
132
- "fold": "a `for` loop over a `let mut` accumulator",
171
+ # in the spec (section 13 or its examples), not built
172
+ "fold": _FOLD_FIX,
133
173
  "merge": "a record literal naming every field",
134
174
  "to_int": "no string-to-int conversion is built yet; keep the value an int",
175
+ "now": ("a `model` field default (`created_at time = now()`); a view or "
176
+ "route cannot read the clock in this build"),
177
+ # names the checker has accepted, not built
178
+ "sorted": "sort_by(xs, fn(x) x)",
179
+ "first": "xs[0] (check xs.len() > 0 first)",
180
+ "reduce": _FOLD_FIX,
181
+ "flatten": _FOLD_FIX + " and `concat(acc, x)`",
182
+ "reverse": _FOLD_FIX + " and `concat([x], acc)`",
183
+ "zip": "a `for i in 0..xs.len()` loop reading `xs[i]` and `ys[i]`",
184
+ "enumerate": "a `for i in 0..xs.len()` loop reading `xs[i]`",
185
+ "index_of": "a `for i in 0..xs.len()` loop that compares `xs[i]`",
186
+ "slice": "take(xs, n) for a prefix, or a `for i in a..b` loop",
187
+ "range": "a `for i in 0..n` loop",
188
+ "none": "not any(xs, f)",
189
+ "avg": "sum(xs) / count(xs) (check count(xs) > 0 first)",
190
+ "min": "if (a < b) a else b",
191
+ "max": "if (a > b) a else b",
192
+ "abs": "if (x < 0) -x else x",
193
+ "round": "float_to_int(x + 0.5) for a non-negative x",
194
+ "floor": "float_to_int(x) for a non-negative x",
195
+ "ceil": "float_to_int(x) + 1 when x has a fraction, for a non-negative x",
196
+ "starts_with": "matches(s, \"^prefix\")",
197
+ "ends_with": "matches(s, \"suffix$\")",
198
+ "pad_start": "string interpolation: `\"{pad}{s}\"`",
199
+ "pad_end": "string interpolation: `\"{s}{pad}\"`",
200
+ "repeat": _FOLD_FIX + " building the string",
201
+ "uuid4": "let the database assign the id (`id uuid pk`)",
202
+ "random": "no random source is built yet",
135
203
  }
136
-
137
- # `use std.X { name, ... }` names `vlbuild` actually lowers to a working call
138
- # -- NOT the same list as `BUILTINS` above (that one is a generous, deliberately
139
- # loose allowlist for the W-UNDEF-NAME heuristic, and includes several names,
140
- # e.g. `merge`/`fold`/`first`/`to_int`, the spec shows but this build's emitter
141
- # has no lowering for). Duplicated from `vlbuild/emit.py`'s `STD_FNS` (plus
142
- # `now`, whose one working lowering — a `model` field default — lives in
143
- # `server_emit.py`'s `_sql_default`) rather than imported: `vlcheck` has no
144
- # dependency on `vlbuild` (the dependency runs the other way), so the two
145
- # packages cannot share this table directly. Keep in sync by hand; a mismatch
146
- # only makes this check too strict or too loose, never silently wrong, since
147
- # `vlbuild` is the actual authority on what it lowers.
204
+ assert not (STD_BUILT_FNS & set(_FN_NOT_BUILT))
205
+ BUILTINS.update(STD_BUILT_FNS, _FN_NOT_BUILT)
206
+ # The names `use std.X { ... }` may import: every built function, plus `now`,
207
+ # whose one working lowering -- a `model` field default -- is server_emit.py's
208
+ # `_sql_default`.
148
209
  #
149
210
  # Found reproducing the login-page benchmark's Bug 3 (`test-only`
150
- # `login-page/volaro/NOTES.md`): `use std.http { post }` passes `vlcheck`
151
- # clean and compiles to a bare, undefined `post(...)` call in the emitted JS
152
- # (`emit.py`'s `_ex_Call` has no special case for an unrecognized `A.Name`
153
- # callee, so it falls through to emitting the call verbatim) -- a silent
154
- # ReferenceError at runtime, not a build-time diagnostic. `std.http` has no
155
- # member in either list below; nothing under it can pass this check.
156
- _STD_SUPPORTED_NAMES = {
157
- "filter", "map", "take", "count", "sum", "sort_by", "group_by", "chunk",
158
- "join", "split", "first_char", "int_to_float", "float_to_int",
159
- "trim", "upper", "lower", "keys", "values", "get_or", "concat", "append",
160
- "now",
161
- # std.str's `regex` (spec section 13): `matches(s, pattern)`, added
162
- # alongside its `vlbuild/emit.py` `STD_FNS` entry and `VL.std.matches`
163
- # (assets/vlrt.js) -- keep all three in sync by hand.
164
- "matches",
165
- # std.str's `replace` (spec section 13's own std.str row already names
166
- # it, alongside split/join/trim/case/regex, as part of the documented
167
- # surface -- this build just had not implemented it yet). Added for
168
- # feature 17 (export): CSV field escaping (RFC 4180 -- double an
169
- # embedded '"') needs a literal substring replace-all; reproduced
170
- # missing first (`use std.str { replace }` failed vlcheck with
171
- # E-UNKNOWN-STD-IMPORT before this entry existed). Replace-ALL,
172
- # literal-substring semantics (not regex, not first-occurrence-only) --
173
- # keep this entry, `vlbuild/emit.py`'s `STD_FNS`, `assets/vlrt.js`'s
174
- # `VL.std.replace`, and `server_emit.py`'s server-side `VL.std.replace`
175
- # shim in sync by hand, same as `matches`/`split` above.
176
- "replace",
177
- # std.str's `contains(s, part) bool` (case-sensitive substring test).
178
- # Also a `where()` predicate name (BUILTINS above); vlbuild lowers a
179
- # `where(col: contains(x))` value as the predicate and every other call
180
- # as `VL.std.contains`. Keep in sync with `vlbuild/emit.py`'s `STD_FNS`.
181
- "contains",
211
+ # `login-page/volaro/NOTES.md`): `use std.http { post }` passed `vlcheck`
212
+ # clean and compiled to a bare, undefined `post(...)` call. `std.http` has no
213
+ # member here; nothing under it can pass this check.
214
+ _STD_SUPPORTED_NAMES = STD_BUILT_FNS | {"now"}
215
+
216
+ # Bare words the spec uses in argument position (`.order(created_at: desc)`,
217
+ # `auth.start_oauth(google)`). They resolve as names; resolve.py reads this
218
+ # set.
219
+ CONTEXTUAL_WORDS = frozenset({"asc", "desc", "google", "github"})
220
+
221
+ # Every other name the checker knows is not a function the build lowers, so
222
+ # a bare call to one compiles to an undefined (or non-function) JavaScript
223
+ # callee and throws when the page or route runs. Each is classified here by
224
+ # the one place it may be called, if any; `_check_builtin_call` rejects the
225
+ # call everywhere else in emitted code. `test_bare_call_parity` (vlbuild
226
+ # tests/test_build.py, gate 2) calls every known name in every emitted
227
+ # context and requires the checker to reject it or the build to lower it,
228
+ # and `_unclassified_names()` must stay empty, so a name added to BUILTINS
229
+ # cannot slip through unjudged. (Testing Lab, Example Demo 2.3: `unique`
230
+ # was the std case; these are the rest of the class.)
231
+ #
232
+ # `where()` predicates (spec section 13.1): a predicate is only a
233
+ # `.where(field: pred(x))` value (or a branch of an `if` that is one). The
234
+ # fix is the operator that tests the same thing on a value. `contains` is
235
+ # also a std.str function, so it is built everywhere.
236
+ _WHERE_PREDICATE_FIX = {
237
+ "lt": "`a < b`", "lte": "`a <= b`", "gt": "`a > b`", "gte": "`a >= b`",
238
+ "ne": "`a != b`", "between": "`lo <= x and x <= hi`",
239
+ "any_of": "`any(xs, fn(v) v == x)`",
240
+ }
241
+ # Error codes (spec section 6): a code is constructed only as the argument of
242
+ # `err(...)` in a service or page-server route body, where the build turns
243
+ # it into the route's error response. `catch not_found` and a match arm name
244
+ # a code without calling it.
245
+ _ERROR_CODES = frozenset({
246
+ "not_found", "forbidden", "conflict", "rejected", "unexpected",
247
+ "out_of_range", "decode", "invalid_credentials", "unverified_email",
248
+ "totp_required", "rate_limited",
249
+ })
250
+ # The codes the compiler-owned auth endpoints fail with (`catch
251
+ # weak_password`); vlbuild's emitter reads this set.
252
+ AUTH_ERROR_CODES = frozenset({
253
+ "email_taken", "weak_password", "breached_password", "rate_limited",
254
+ "invalid_credentials", "unverified_email", "totp_required",
255
+ "provider_error", "invalid_token", "expired_token", "invalid_code",
256
+ "no_totp_enrolled",
257
+ })
258
+ # A route body's `catch <head>` tests the thrown error's code when <head> is
259
+ # one of these (or a declared union variant); any other head binds the
260
+ # caught error itself (`catch e`). resolve.py and vlbuild's `_route_try`
261
+ # both read this set, so `err(e)` means the same thing to check and build.
262
+ ROUTE_CATCH_CODES = (_ERROR_CODES | AUTH_ERROR_CODES
263
+ | frozenset({"bad_request", "unauthorized"}))
264
+ _ERR_ROUTE_FIX = ("`return err(not_found(\"...\"))` in a `service` or "
265
+ "page.server route body; a view reports a failure by "
266
+ "setting its own state")
267
+ # Names that are never called: namespaces and ambient objects (call a member
268
+ # instead), type names (no conversion function by that name), contextual
269
+ # words, and names the spec's examples use that no build provides.
270
+ _NO_BUILD_NAMESPACE = "no `%s` namespace is built yet"
271
+ _NOT_CALLABLE = {
272
+ # namespaces / ambient objects
273
+ "env": "read a member: `env.NAME`",
274
+ "session": "read a member: `session.user`",
275
+ "request": "read a member: `request.headers`",
276
+ "route": "`route.to(\"/path\")`",
277
+ "auth": "a section 11.3 call: `auth.sign_in(email: e, password: p)`",
278
+ "db": "a query: `db.posts.where(...).all()?`",
279
+ "log": "`log.info(\"message\", value)`",
280
+ "api": "an `api` block endpoint: `client.endpoint(...)`",
281
+ "analytics": _NO_BUILD_NAMESPACE % "analytics",
282
+ "events": _NO_BUILD_NAMESPACE % "events",
283
+ "ws": _NO_BUILD_NAMESPACE % "ws",
284
+ "mailer": _NO_BUILD_NAMESPACE % "mailer",
285
+ "billing": _NO_BUILD_NAMESPACE % "billing",
286
+ # result constructor: a `T!` success is the plain value (spec section 6)
287
+ "ok": "return the value itself; a successful `T!` is the plain value",
288
+ # names from the spec's examples, not built-in functions
289
+ "create_guest": "no `create_guest` is built in; build the value inline",
290
+ "guest_user": "no `guest_user` is built in; build the value inline",
291
+ # contextual words
292
+ "asc": "an `.order(field: asc)` value, not a call",
293
+ "desc": "an `.order(field: desc)` value, not a call",
294
+ "google": "an `auth.start_oauth(google)` argument, not a call",
295
+ "github": "an `auth.start_oauth(github)` argument, not a call",
296
+ # type names
297
+ "str": "string interpolation: `\"{x}\"`",
298
+ "int": "float_to_int(x)",
299
+ "float": "int_to_float(x)",
300
+ "bool": "a comparison: `x != 0`, `s != \"\"`",
301
+ "bytes": "no conversion to `bytes` is built",
302
+ "time": "a `time` field value; no conversion to `time` is built",
303
+ "dur": "no conversion to `dur` is built",
304
+ "uuid": "let the database assign the id (`id uuid pk`)",
305
+ "json": "no conversion to `json` is built",
306
+ "nil": "the value `nil`, not a call",
182
307
  }
183
308
 
184
309
 
310
+ def _unclassified_names() -> set[str]:
311
+ """Known names with no bare-call rule: must stay empty (asserted below
312
+ and by test_bare_call_parity)."""
313
+ classified = (set(STD_BUILT_FNS) | set(_FN_NOT_BUILT) | set(_METHOD_ONLY)
314
+ | set(_WHERE_PREDICATE_FIX) | _ERROR_CODES | {"err"}
315
+ | set(_NOT_CALLABLE) | set(ELEMENT_NAMES))
316
+ return (set(BUILTINS) | set(_PRIM_NAMES) | CONTEXTUAL_WORDS) - classified
317
+
318
+
319
+ assert not _unclassified_names(), _unclassified_names()
320
+
321
+
185
322
  def _lit_text(v):
186
323
  """The literal text of a plain string literal, or None when `v` is not one
187
324
  (a bare word, an expression, or an *interpolated* string -- whose runtime
@@ -324,6 +461,17 @@ class Checker:
324
461
  # (module-level fn bodies)
325
462
  self._module_names: set[str] = set()
326
463
  self._emitted = True
464
+ # which emitted code is being walked: "view" (a view's state,
465
+ # derives, render tree and handlers) or "route" (a service route,
466
+ # a page.server handler, a _server.vl provider). `err(...)` builds
467
+ # only in a route.
468
+ self._ctx = "route"
469
+ # payload-carrying union variants this module declares, and the
470
+ # calls whose position makes them legal (a `where()` predicate
471
+ # value, the error constructor inside `err(...)`)
472
+ self._variants: set[str] = set()
473
+ self._views: set[str] = set()
474
+ self._exempt_calls: set[int] = set()
327
475
  # names of `fn`-typed record fields in this module: `x.on_pick(id)`
328
476
  # calls one, which E-METHOD-UNKNOWN must not report
329
477
  self._fn_fields: set[str] = set()
@@ -2973,10 +3121,15 @@ class Checker:
2973
3121
  if nm:
2974
3122
  top.add(nm)
2975
3123
  self._module_names.add(nm)
3124
+ if isinstance(it, A.ViewDecl):
3125
+ self._views.add(nm)
2976
3126
  if isinstance(it, A.Use):
2977
3127
  for n in it.names:
2978
3128
  top.add(n)
2979
- self._module_names.add(n)
3129
+ # `use std.time { now }` names the std function, not an
3130
+ # author's own: its call is still judged below
3131
+ if not it.path.startswith("std."):
3132
+ self._module_names.add(n)
2980
3133
  base = it.path.replace("./", "").replace("../", "")
2981
3134
  base = base.split("/")[-1].split(".")[-1]
2982
3135
  if base:
@@ -2987,6 +3140,7 @@ class Checker:
2987
3140
  elif isinstance(it, A.TypeDecl) and it.kind == "union":
2988
3141
  for v in it.variants:
2989
3142
  top.add(v.name)
3143
+ self._variants.add(v.name)
2990
3144
  elif isinstance(it, (A.TypeDecl, A.ModelDecl)):
2991
3145
  pass
2992
3146
 
@@ -3006,7 +3160,9 @@ class Checker:
3006
3160
  scope.bind(s.name, "derive")
3007
3161
  elif isinstance(s, A.LoadDecl):
3008
3162
  scope.bind(s.name, "load result")
3163
+ self._ctx = "view"
3009
3164
  self._scoped(it.body, [top, scope], fallible=True)
3165
+ self._ctx = "route"
3010
3166
  elif isinstance(it, A.ServiceDecl):
3011
3167
  for r in it.routes:
3012
3168
  scope = _Scope((p.name for p in r.params), "route parameter")
@@ -3121,23 +3277,100 @@ class Checker:
3121
3277
  def _check_builtin_call(self, call, scopes: list[set]) -> None:
3122
3278
  """A bare call to an allowlisted name the build cannot lower compiles
3123
3279
  to an undefined JS function (action-48). Skipped when the name is the
3124
- author's own: a local binding or a module declaration or import."""
3280
+ author's own: a local binding or a module declaration or a
3281
+ non-`std` import."""
3125
3282
  callee = call.callee
3126
- if not isinstance(callee, A.Name):
3283
+ if not isinstance(callee, A.Name) or id(call) in self._exempt_calls:
3127
3284
  return
3128
3285
  nm = callee.id
3129
- if nm in self._module_names or any(nm in s for s in scopes[1:]):
3286
+ if nm in STD_BUILT_FNS:
3287
+ return
3288
+ # `session` / `request` are bound in a view's or handler's scope, but
3289
+ # they are the ambient objects, never the author's own function
3290
+ ambient = nm in _AMBIENT_RECEIVERS
3291
+ throws = "compiles to a call the built page or route cannot make, and throws when it runs"
3292
+ if (nm in self._views and self._emitted
3293
+ and not any(nm in s for s in scopes[1:])):
3294
+ # a view is rendered by a line of its own (an element); as a
3295
+ # value, `Row(1)` calls the emitted render function with no
3296
+ # context and throws
3297
+ self._d("E-NOT-A-FUNCTION", Severity.ERROR,
3298
+ "'%s' is a view, not a function -- as a value `%s(...)` %s"
3299
+ % (nm, nm, throws), call,
3300
+ "render it as its own line in the view body: `%s(...)`" % nm)
3301
+ return
3302
+ if not ambient and (nm in self._module_names
3303
+ or any(nm in s for s in scopes[1:])):
3130
3304
  return
3131
3305
  if nm in _METHOD_ONLY:
3132
3306
  self._d("E-METHOD-AS-FN", Severity.ERROR,
3133
3307
  "'%s' is a method, not a function -- `%s(...)` compiles to "
3134
3308
  "an undefined function and throws when the page runs"
3135
3309
  % (nm, nm), call, "call it on the value: `%s`" % _METHOD_ONLY[nm])
3136
- elif nm in _FN_NOT_BUILT and self._emitted:
3310
+ elif not self._emitted:
3311
+ # a module-level `fn` body: the build refuses module functions
3312
+ # (E-VIEW-MODULE-FN), so nothing here is emitted
3313
+ return
3314
+ elif nm in _FN_NOT_BUILT:
3137
3315
  self._d("E-FN-NOT-BUILT", Severity.ERROR,
3138
- "'%s' is in the spec but this build has no working `%s` -- "
3139
- "the call compiles and throws when it runs" % (nm, nm),
3316
+ "'%s' is a std name this build of Volaro has no function "
3317
+ "for -- the call compiles to an undefined function and "
3318
+ "throws when it runs" % nm,
3140
3319
  call, "use %s" % _FN_NOT_BUILT[nm])
3320
+ elif nm == "err":
3321
+ if self._ctx == "view":
3322
+ self._d("E-ERR-CONTEXT", Severity.ERROR,
3323
+ "`err(...)` fails a route; a view has no route to fail "
3324
+ "-- in a view it %s" % throws, call,
3325
+ "use " + _ERR_ROUTE_FIX)
3326
+ elif nm in _ERROR_CODES or nm in self._variants:
3327
+ what = ("the error code '%s'" % nm if nm in _ERROR_CODES
3328
+ and nm not in self._variants
3329
+ else "the union variant '%s'" % nm)
3330
+ self._d("E-ERR-CONTEXT", Severity.ERROR,
3331
+ "%s is built only as the argument of `err(...)` in a route "
3332
+ "body -- here `%s(...)` %s" % (what, nm, throws), call,
3333
+ "use " + _ERR_ROUTE_FIX + ("" if nm in _ERROR_CODES else
3334
+ "; for data, a record type with the same fields"))
3335
+ elif nm in _WHERE_PREDICATE_FIX:
3336
+ self._d("E-WHERE-PREDICATE", Severity.ERROR,
3337
+ "'%s' is a `where()` predicate (spec section 13.1), built "
3338
+ "only as a `.where(field: %s(...))` value -- here it %s"
3339
+ % (nm, nm, throws), call,
3340
+ "compare the values with %s, or move it into "
3341
+ "`.where(field: %s(x))`" % (_WHERE_PREDICATE_FIX[nm], nm))
3342
+ elif nm in _NOT_CALLABLE:
3343
+ self._d("E-NOT-A-FUNCTION", Severity.ERROR,
3344
+ "'%s' is not a function -- `%s(...)` %s" % (nm, nm, throws),
3345
+ call, "use %s" % _NOT_CALLABLE[nm])
3346
+ elif nm in ELEMENT_NAMES:
3347
+ self._d("E-NOT-A-FUNCTION", Severity.ERROR,
3348
+ "'%s' is an element, not a function -- as a value "
3349
+ "`%s(...)` %s" % (nm, nm, throws), call,
3350
+ "write the element as its own line in the view body: `%s ...`"
3351
+ % nm)
3352
+
3353
+ def _mark_positional_calls(self, call) -> None:
3354
+ """Exempt the calls whose position gives them meaning: a predicate as
3355
+ a `.where(field: ...)` value (either branch of an `if` value too), and
3356
+ the error constructor that is the argument of `err(...)`."""
3357
+ callee = call.callee
3358
+ if isinstance(callee, A.Member) and callee.name == "where":
3359
+ stack = [v for _, v in call.named]
3360
+ while stack:
3361
+ v = stack.pop()
3362
+ if isinstance(v, A.IfExpr):
3363
+ stack += [v.then, v.orelse]
3364
+ elif (isinstance(v, A.Call) and isinstance(v.callee, A.Name)
3365
+ and v.callee.id in _WHERE_PREDICATE_FIX):
3366
+ self._exempt_calls.add(id(v))
3367
+ elif isinstance(callee, A.Name) and callee.id == "err":
3368
+ # in a view the `err(...)` itself is the one error reported
3369
+ for a in call.args:
3370
+ if (isinstance(a, A.Call) and isinstance(a.callee, A.Name)
3371
+ and (a.callee.id in _ERROR_CODES
3372
+ or a.callee.id in self._variants)):
3373
+ self._exempt_calls.add(id(a))
3141
3374
 
3142
3375
  @staticmethod
3143
3376
  def _value_receiver(obj, scopes: list[set]) -> bool:
@@ -3331,6 +3564,7 @@ class Checker:
3331
3564
  self._expr(ex.obj, scopes, fallible)
3332
3565
  self._expr(ex.index, scopes, fallible)
3333
3566
  elif isinstance(ex, A.Call):
3567
+ self._mark_positional_calls(ex)
3334
3568
  self._check_builtin_call(ex, scopes)
3335
3569
  self._check_value_method(ex, scopes)
3336
3570
  callee = ex.callee