volaro 0.1.0-alpha.1 → 0.1.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # Volaro
2
2
 
3
- **Limited alpha (`0.1.0-alpha.1`). Not yet published to npm.** Install from a
4
- local `npm pack` tarball to try it.
3
+ **Limited alpha, published as `0.1.0-alpha.1` under the `alpha` dist-tag** —
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.
5
6
 
6
7
  Volaro is an experimental application language intended for AI authoring and
7
8
  human review. This package ships the language reference **and a working
@@ -65,9 +66,12 @@ Node 22.5+ for `node:sqlite`.
65
66
  ## Scaffold a project
66
67
 
67
68
  ```bash
68
- npm create volaro my-app # the separate create-volaro package
69
+ npm create volaro@alpha my-app # the separate create-volaro package
69
70
  ```
70
71
 
72
+ (`@alpha` matters — `npm create volaro` with no tag resolves `latest`, which
73
+ is still an earlier `0.0.x` placeholder, not this build.)
74
+
71
75
  It writes one single-page starter, a `volaro.json`, and `package.json` scripts
72
76
  (`dev` / `build` / `check`) that call `volaro`.
73
77
 
@@ -110,3 +114,25 @@ The legacy `volara` npm package is unrelated to this release.
110
114
  ## License
111
115
 
112
116
  MIT
117
+
118
+
119
+ ## Local assets
120
+
121
+ Put public files in `assets/` beside your entry `.vl` file and use
122
+ `img src:"assets/logo.svg" alt:"Logo"`. Build and dev copy them to the browser
123
+ bundle. Dev watches changes; refresh the browser after a rebuild. Missing
124
+ literal asset references fail the build. Output `assets/` is compiler-owned.
125
+
126
+ ## Package provenance
127
+
128
+ `compiler/SOURCE_REV` records the source commit. `compiler/SOURCE_INFO.json`
129
+ adds its origin, Git dirty status where available, and a SHA-256 digest of
130
+ compiler and CLI/documentation content. A package version alone does not
131
+ identify a local candidate; preserve the tarball hash as well.
132
+
133
+ Packaging a Git export uses `.volaro-source-rev`, expanded by `git archive`.
134
+ For other exported source, set `VOLARO_SOURCE_REV` to the full source commit
135
+ hash before `npm pack`. Missing/invalid revisions fail packaging; an explicit
136
+ revision cannot override a different checkout HEAD. Export metadata identifies
137
+ the base revision, not an assurance that someone has not modified the export;
138
+ the content hash distinguishes modified candidates. Nothing is published by packing.
package/bin/vl.js CHANGED
@@ -12,7 +12,7 @@
12
12
  // Python is located and version-checked up front with an actionable message
13
13
  // (see ../lib/env.js). Nothing here depends on the Volaro repository layout.
14
14
 
15
- import { readFileSync, existsSync, statSync, readdirSync } from "node:fs";
15
+ import { readFileSync, existsSync, statSync, lstatSync, readdirSync } from "node:fs";
16
16
  import { fileURLToPath } from "node:url";
17
17
  import { dirname, join, resolve as resolvePath, extname, relative, sep, delimiter } from "node:path";
18
18
  import { spawn, spawnSync } from "node:child_process";
@@ -148,6 +148,13 @@ const MIME = {
148
148
  ".css": "text/css; charset=utf-8",
149
149
  ".json": "application/json; charset=utf-8",
150
150
  ".svg": "image/svg+xml",
151
+ ".png": "image/png",
152
+ ".jpg": "image/jpeg",
153
+ ".jpeg": "image/jpeg",
154
+ ".webp": "image/webp",
155
+ ".gif": "image/gif",
156
+ ".ico": "image/x-icon",
157
+ ".woff2": "font/woff2",
151
158
  ".map": "application/json",
152
159
  };
153
160
 
@@ -184,7 +191,7 @@ function staticServer(dir, port) {
184
191
  // Polling beats fs.watch here: it is identical across platforms and editors
185
192
  // (rename-replace, truncate-write and append all show up), which fs.watch is
186
193
  // not, and the cost is trivial for a project-sized tree.
187
- function scanVl(dir, depth = 6, acc = new Map()) {
194
+ function scanVl(dir, depth = 6, acc = new Map(), excluded = null) {
188
195
  let entries;
189
196
  try {
190
197
  entries = readdirSync(dir, { withFileTypes: true });
@@ -194,8 +201,9 @@ function scanVl(dir, depth = 6, acc = new Map()) {
194
201
  for (const e of entries) {
195
202
  if (e.name === "node_modules" || e.name === ".git" || e.name.startsWith(".")) continue;
196
203
  const p = join(dir, e.name);
204
+ if (resolvePath(p) === excluded) continue;
197
205
  if (e.isDirectory()) {
198
- if (depth > 0) scanVl(p, depth - 1, acc);
206
+ if (depth > 0) scanVl(p, depth - 1, acc, excluded);
199
207
  } else if (extname(e.name) === ".vl") {
200
208
  try {
201
209
  const s = statSync(p);
@@ -214,10 +222,24 @@ function snapEqual(a, b) {
214
222
 
215
223
  // Poll watchDir for .vl changes and call onChange (debounced by the interval).
216
224
  // Returns a close() that stops polling.
217
- function watchVl(watchDir, onChange) {
218
- let prev = scanVl(watchDir);
225
+ function watchVl(watchDir, onChange, outDir) {
226
+ const snapshot = () => {
227
+ const acc = scanVl(watchDir, 6, new Map(), resolvePath(outDir));
228
+ const scanAssets = (dir) => {
229
+ try {
230
+ const s = lstatSync(dir);
231
+ acc.set(dir, `${s.mtimeMs}:${s.ctimeMs}:${s.size}`);
232
+ if (s.isDirectory() && !s.isSymbolicLink()) {
233
+ for (const name of readdirSync(dir)) scanAssets(join(dir, name));
234
+ }
235
+ } catch {}
236
+ };
237
+ scanAssets(join(watchDir, "assets"));
238
+ return acc;
239
+ };
240
+ let prev = snapshot();
219
241
  const iv = setInterval(() => {
220
- const now = scanVl(watchDir);
242
+ const now = snapshot();
221
243
  if (!snapEqual(prev, now)) {
222
244
  prev = now;
223
245
  onChange();
@@ -299,7 +321,7 @@ function cmdDev(args) {
299
321
  await restartServer();
300
322
  startServer();
301
323
  process.stdout.write("volaro dev: server restarted\n");
302
- });
324
+ }, out);
303
325
 
304
326
  const bye = () => {
305
327
  if (shuttingDown) return;
@@ -326,7 +348,7 @@ function cmdDev(args) {
326
348
  process.stdout.write("volaro dev: change detected — rebuilding\n");
327
349
  const r = buildOnce(entry, out);
328
350
  process.stdout.write(r.ok ? "volaro dev: rebuilt\n" : "volaro dev: build failed — keeping last good bundle\n");
329
- });
351
+ }, out);
330
352
 
331
353
  const bye = () => {
332
354
  if (shuttingDown) return;
@@ -351,7 +373,14 @@ function cmdReference(which) {
351
373
  );
352
374
  }
353
375
  const file = which === "supported" ? "supported" : which;
354
- process.stdout.write(read(`language/${file}.md`));
376
+ // `supported.md`'s own title carries this build's exact version as a
377
+ // `{{VERSION}}` placeholder rather than a hardcoded string -- found stale
378
+ // (still reading a prior release's version after a version bump, since
379
+ // nothing kept it in sync) in the Test_003 benchmark's packaged-docs
380
+ // finding. Substituted from `package.json` at print time so it can never
381
+ // drift again, the same way the "supported"-vs-"spec" note above already
382
+ // reads `pkg.version` live rather than a copy-pasted string.
383
+ process.stdout.write(read(`language/${file}.md`).replace("{{VERSION}}", pkg.version));
355
384
  }
356
385
 
357
386
  function cmdExample(name) {
@@ -0,0 +1,6 @@
1
+ {
2
+ "revision": "a450e6fe6549b8c7df43ba343793ab2e52758a78",
3
+ "source": "git",
4
+ "dirty": false,
5
+ "content_sha256": "095f3d5b55826d6aa0c560ce5570c91752d8f03cbd0e869ce61df1fcf53e2593"
6
+ }
@@ -1 +1 @@
1
- e1d25fea4407ecd621dc6a0982a2023dfb5c5ba1
1
+ a450e6fe6549b8c7df43ba343793ab2e52758a78
@@ -39,6 +39,36 @@ BUILTINS = {
39
39
  }
40
40
  _PRIM_NAMES = {"int", "float", "str", "bool", "bytes", "time", "dur", "uuid", "json", "nil"}
41
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
+
42
72
 
43
73
  def _lit_text(v):
44
74
  """The literal text of a plain string literal, or None when `v` is not one
@@ -125,13 +155,39 @@ class Checker:
125
155
  self.styling(mod)
126
156
  self.accessible_primitives(mod)
127
157
  self.view_bindings(mod)
158
+ self.callback_props(mod)
128
159
  self.document_structure(mod)
129
160
  self.composed_document(mod)
130
161
  self.services(mod)
131
162
  self.api_params(mod)
163
+ self.imports(mod)
132
164
  self.semantic(mod)
133
165
  return self.diags
134
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
+
135
191
  # -- view bindings: what may be assigned to, and literal-only attrs ----
136
192
  def view_bindings(self, mod: A.Module) -> None:
137
193
  """Per view: (1) a `derive` / `load` value and a parameter are
@@ -195,6 +251,88 @@ class Checker:
195
251
  "label instead." % (v.id, v.id),
196
252
  node, "variant:primary")
197
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
+
198
336
  def styling(self, mod: A.Module) -> None:
199
337
  """Checks that are decidable within one source file. Project-wide
200
338
  recipe selection/conflicts are checked by vlbuild after discovery."""
@@ -401,8 +539,11 @@ class Checker:
401
539
  if (len(node.args) != 1 or node.body
402
540
  or any(k not in ("class", "test_id", "test_scope") for k, _ in node.attrs)):
403
541
  self._d("E-STATUS-SHAPE", Severity.ERROR,
404
- "'status' takes one message, optional class:, and no children",
405
- node, 'status message — keep it mounted; use nil or "" while idle')
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')
406
547
 
407
548
  if node.name == "input":
408
549
  # spec §8.9: every input carries a programmatic name,
@@ -1070,6 +1211,17 @@ def _fallible(tref) -> bool:
1070
1211
  return isinstance(tref, A.TypeRef) and tref.fallible
1071
1212
 
1072
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
+
1073
1225
  def check(mod: A.Module, filename: str, li: LineIndex,
1074
1226
  strict_names: bool = False, release: bool = False) -> list[Diagnostic]:
1075
1227
  return Checker(filename, li, strict_names, release).run(mod)
@@ -191,7 +191,10 @@ class Lexer:
191
191
  if src[self.i:self.i + 3] == '"""':
192
192
  self.i += 3
193
193
  while self.i < self.n and src[self.i:self.i + 3] != '"""':
194
- self.i += 1
194
+ if src[self.i] == "\\":
195
+ self._lex_escape()
196
+ else:
197
+ self.i += 1
195
198
  if src[self.i:self.i + 3] == '"""':
196
199
  self.i += 3
197
200
  else:
@@ -204,7 +207,7 @@ class Lexer:
204
207
  while self.i < self.n:
205
208
  c = src[self.i]
206
209
  if c == "\\":
207
- self.i += 2
210
+ self._lex_escape()
208
211
  continue
209
212
  if c == "\n":
210
213
  self._err("E-STRING", "unterminated string literal", s, self.i,
@@ -236,6 +239,35 @@ class Lexer:
236
239
  tok = self._add("STRING", src[s:self.i], s, self.i)
237
240
  tok.interps = interps
238
241
 
242
+ def _lex_escape(self) -> None:
243
+ """Validate escapes before emission can silently change their meaning."""
244
+ start = self.i
245
+ self.i += 1
246
+ if self.i < self.n and self.src[self.i] in 'nt"\\{}':
247
+ self.i += 1
248
+ return
249
+ if self.src[self.i:self.i + 2] == "u{":
250
+ end = self.src.find("}", self.i + 2)
251
+ digits = self.src[self.i + 2:end] if end != -1 else ""
252
+ if (1 <= len(digits) <= 6
253
+ and all(c in "0123456789abcdefABCDEF" for c in digits)):
254
+ value = int(digits, 16)
255
+ if value <= 0x10ffff and not 0xd800 <= value <= 0xdfff:
256
+ self.i = end + 1
257
+ return
258
+ self._err("E-STRING-ESCAPE", "invalid Unicode string escape", start,
259
+ min(self.n, end + 1 if end != -1 else self.i + 1),
260
+ r"use \u{HEX} with a Unicode scalar value (no surrogate code points)")
261
+ if end != -1 and not any(c in self.src[self.i:end] for c in '\n"'):
262
+ self.i = end + 1
263
+ return
264
+ self._err("E-STRING-ESCAPE", "unknown string escape", start,
265
+ min(self.n, self.i + 1),
266
+ r'double a literal backslash: "\\s" for regex \s; supported escapes: \n \t \" \\ \{ \} \u{HEX}')
267
+ # Keep a closing quote/newline available for normal string recovery.
268
+ if self.i < self.n and self.src[self.i] not in '\n"':
269
+ self.i += 1
270
+
239
271
  def _lex_number(self) -> None:
240
272
  s = self.i
241
273
  src = self.src