volaro 0.1.0-alpha.3 → 0.1.0-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/README.md +69 -7
  2. package/bin/vl.js +372 -15
  3. package/compiler/SOURCE_INFO.json +2 -2
  4. package/compiler/SOURCE_REV +1 -1
  5. package/compiler/validator/vlcheck/__main__.py +82 -13
  6. package/compiler/validator/vlcheck/ast_nodes.py +59 -0
  7. package/compiler/validator/vlcheck/benchmark_signal.py +86 -0
  8. package/compiler/validator/vlcheck/checks.py +1663 -18
  9. package/compiler/validator/vlcheck/diagnostics.py +1 -1
  10. package/compiler/validator/vlcheck/elements.py +1191 -0
  11. package/compiler/validator/vlcheck/layoutcompose.py +385 -0
  12. package/compiler/validator/vlcheck/lexer.py +41 -0
  13. package/compiler/validator/vlcheck/pagemanifest.py +389 -0
  14. package/compiler/validator/vlcheck/pageroutes.py +468 -0
  15. package/compiler/validator/vlcheck/parser.py +172 -0
  16. package/compiler/validator/vlcheck/resolve.py +69 -8
  17. package/compiler/validator/vlcheck/routes_cli.py +172 -0
  18. package/compiler/validator/vlcheck/test_ids.py +4 -3
  19. package/compiler/vlbuild/vlbuild/__main__.py +135 -9
  20. package/compiler/vlbuild/vlbuild/assets/vlrouter.js +396 -0
  21. package/compiler/vlbuild/vlbuild/assets/vlrt.js +279 -8
  22. package/compiler/vlbuild/vlbuild/emit.py +1547 -125
  23. package/compiler/vlbuild/vlbuild/pages_build.py +368 -0
  24. package/compiler/vlbuild/vlbuild/project.py +338 -0
  25. package/compiler/vlbuild/vlbuild/server_emit.py +595 -49
  26. package/compiler/vlbuild/vlbuild/style_config.py +1 -1
  27. package/language/crib.md +439 -19
  28. package/language/spec.md +495 -3
  29. package/language/supported.md +472 -18
  30. package/package.json +1 -1
  31. package/scripts/selftest.mjs +36 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Volaro
2
2
 
3
- **Limited alpha, published as `0.1.0-alpha.1` under the `alpha` dist-tag** —
3
+ **Limited alpha, published under the `alpha` dist-tag** (this is `0.1.0-alpha.4`) —
4
4
  `npm install volaro@alpha`. Not `latest` (that still resolves to an earlier
5
5
  `0.0.x` placeholder) — always specify `@alpha` or the exact version.
6
6
 
@@ -59,9 +59,56 @@ volaro example sensors # a worked example (also: station)
59
59
  volaro version | volaro help
60
60
  ```
61
61
 
62
+ ### Benchmark timing log
63
+
64
+ `check` and `build` can append machine-readable compile attempts to a JSONL
65
+ file. Use one unique run ID for the first attempt and every repair attempt in a
66
+ single trial:
67
+
68
+ ```bash
69
+ volaro check app.vl --benchmark-log benchmark.jsonl --benchmark-run test-006-v-01
70
+ # If it fails, repair app.vl and repeat the exact command with the same run ID.
71
+ ```
72
+
73
+ A first-pass success writes a `clean` summary with `t_clean_ms`. A failure
74
+ followed by a success writes a `recovered` summary with `t_fail_ms`,
75
+ `t_repair_ms`, `t_recompile_ms`, and `t_recover_ms`. The log also retains every
76
+ raw attempt, the number of failures, and compiler overhead not attributable to
77
+ repair. A completed run ID cannot be reused, and a run cannot mix `check` and
78
+ `build` attempts or change compiler arguments between attempts.
79
+
80
+ The timer measures the compiler subprocess. `T-repair` is the observed wall
81
+ time between a failed compiler exit and the next compiler start. It measures
82
+ the autonomous repair loop, including agent and tool overhead; it is not a
83
+ claim about model-inference time alone.
84
+
85
+ **Every failed attempt is classified, not just counted.** Each raw `attempt`
86
+ record carries a `classification`: `source_diagnostic` (the compiler ran to
87
+ completion and correctly reported a problem with your source — the only case
88
+ `t_fail_ms` is ever populated for), `operational_failure` (the compiler process
89
+ itself crashed, was killed, or stopped on an invocation/configuration problem —
90
+ `crash_exception_type`/`crash_exception_message` are set when it was a crash),
91
+ `silent_failure` (a non-zero exit with no output at all), or `unknown_failure`
92
+ (a non-zero exit with output that carried none of the compiler's own
93
+ diagnostic signal). This is reported by the compiler itself over a private
94
+ side channel — never guessed from whether stdout/stderr happened to have
95
+ anything in it, since an internal crash prints output too. A `recovered`
96
+ summary whose original failing attempt was not `source_diagnostic` has
97
+ `t_fail_ms: null`, `timing_complete: false`, and a `timing_incomplete_reason`
98
+ explaining why — treat it as an invalid recovery-time sample, not a slow one.
99
+
100
+ **A `recovered` summary can also be invalidated by the wall clock.**
101
+ `t_repair_ms`/`t_recover_ms` are computed from separate processes' own
102
+ timestamps; if one of those timestamps predates an earlier one (a system
103
+ clock step between two attempts), the summary has `clock_anomaly: true`,
104
+ `timing_complete: false`, and the affected fields are `null` — never a
105
+ clamped, misleadingly small positive number.
106
+
62
107
  `volaro dev` serves a static bundle for a single-page app. If the app compiles
63
108
  to a full-stack server (`server.js`), it runs that instead and needs
64
- Node 22.5+ for `node:sqlite`.
109
+ Node 22.5+ for `node:sqlite`. A multi-page project build always emits a
110
+ `server.js` (it serves `public/` and answers a direct link or refresh on a
111
+ dynamic route), so `volaro dev` on a project directory runs that server.
65
112
 
66
113
  ## Scaffold a project
67
114
 
@@ -82,8 +129,20 @@ density against selected baselines — not a general productivity result.
82
129
  Security and accessibility checks cover a tested prototype subset, not a
83
130
  universal guarantee. Independent human-readability validation is outstanding.
84
131
 
85
- **MVP scope is single-page.** Routing, nested layouts and multi-page starters
86
- are later work; `volaro build` / `volaro dev` target one entry file.
132
+ **Pages, routing and nested layouts are implemented** (2026-09-23). A project
133
+ directory with any `app/**/index.vl` builds as a real multi-page app: nested
134
+ `_layout.vl` shells placing content with `@children`, `(group)` folders that add
135
+ layout ancestry but no URL segment, typed `[name=type]` dynamic parameters, and
136
+ optional `_loading.vl` / `_error.vl` / `_not-found.vl` boundaries, served by a
137
+ client router with a direct-link/refresh fallback in the generated server. Point
138
+ `volaro build` / `volaro dev` at the project directory; `volaro routes` prints
139
+ what was discovered. A single `.vl` entry file still builds exactly as before.
140
+
141
+ **Still out of scope:** server-side rendering and hydration (the shell is
142
+ route-agnostic and the client does the first render); `theme` / `recipe` styling
143
+ in a multi-page build, so styled output is single-entry only; catch-all routes,
144
+ per-file route prefixes, and inherited authorization. **`npm create volaro`
145
+ scaffolds a single-page starter** — a multi-page starter is later work.
87
146
 
88
147
  ## Not included
89
148
 
@@ -93,9 +152,12 @@ builds (`theme` / `recipe`) additionally need a one-time
93
152
  `npm ci` inside `compiler/vlbuild/styling`; the single-page starter does not
94
153
  use them.
95
154
 
96
- **Form controls:** the element set is `text` inputs plus `button` / `link` —
97
- there is no `checkbox`, `radio`, `select`, `textarea`, or `disabled` attribute.
98
- Model a toggle as a `button` reading a `state` bool. `variant:` is a literal
155
+ **Form controls:** `input` (a full type matrix, including `checkbox`/`radio`
156
+ binding a `checked` state), `textarea`, `select`/`option`/`optgroup`,
157
+ `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
99
161
  style name, not a computed expression. `if` used as an expression is binary
100
162
  (`if c a else b`); `match` and block `if` are statements only. A module-level
101
163
  `fn` is not available to a view. **`volaro supported` and `volaro crib` are
package/bin/vl.js CHANGED
@@ -5,6 +5,7 @@
5
5
  // volaro check <path>... validate .vl source (lexer / parser / resolver)
6
6
  // volaro build <file.vl> transpile to a runnable bundle
7
7
  // volaro dev [file.vl] build, serve on localhost, rebuild on change
8
+ // volaro routes [project] print discovered pages and the page-server route manifest
8
9
  // volaro crib | volaro spec print the language reference
9
10
  // volaro example <name> print a worked example
10
11
  // volaro version | volaro help
@@ -12,7 +13,7 @@
12
13
  // Python is located and version-checked up front with an actionable message
13
14
  // (see ../lib/env.js). Nothing here depends on the Volaro repository layout.
14
15
 
15
- import { readFileSync, existsSync, statSync, lstatSync, readdirSync } from "node:fs";
16
+ import { appendFileSync, readFileSync, existsSync, statSync, lstatSync, readdirSync } from "node:fs";
16
17
  import { fileURLToPath } from "node:url";
17
18
  import { dirname, join, resolve as resolvePath, extname, relative, sep, delimiter } from "node:path";
18
19
  import { spawn, spawnSync } from "node:child_process";
@@ -72,12 +73,334 @@ function pyInvocation(pp, moduleName, moduleArgs) {
72
73
  return { cmd: py.cmd, argv: ["-B", "-m", moduleName, ...moduleArgs], env };
73
74
  }
74
75
 
76
+ function elapsedMs(start) {
77
+ return Number(process.hrtime.bigint() - start) / 1e6;
78
+ }
79
+
80
+ function roundedMs(value) {
81
+ return Math.round(value * 1000) / 1000;
82
+ }
83
+
84
+ function benchmarkRecords(path, runId) {
85
+ if (!existsSync(path)) return [];
86
+ let raw;
87
+ try {
88
+ const st = statSync(path);
89
+ if (st.isDirectory()) {
90
+ fail(`${CLI}: benchmark log ${path} is a directory, not a file`);
91
+ }
92
+ raw = readFileSync(path, "utf8");
93
+ } catch (err) {
94
+ fail(`${CLI}: cannot read benchmark log ${path}: ${err.message}`);
95
+ }
96
+ return raw
97
+ .split(/\r?\n/)
98
+ .filter(Boolean)
99
+ .map((line, index) => {
100
+ try {
101
+ return JSON.parse(line);
102
+ } catch {
103
+ fail(`${CLI}: invalid JSONL in benchmark log ${path} at line ${index + 1}: ` +
104
+ `${JSON.stringify(line.length > 80 ? line.slice(0, 80) + "…" : line)}`);
105
+ }
106
+ })
107
+ .filter((row) => row.schema === "volaro.compile-timing.v1" && row.run_id === runId);
108
+ }
109
+
110
+ function appendBenchmark(path, record) {
111
+ try {
112
+ appendFileSync(path, JSON.stringify(record) + "\n", { encoding: "utf8", flag: "a" });
113
+ } catch (err) {
114
+ fail(`${CLI}: cannot append benchmark log ${path}: ${err.message}`);
115
+ }
116
+ }
117
+
118
+ function ensureBenchmarkLog(path) {
119
+ try {
120
+ appendFileSync(path, "", { encoding: "utf8", flag: "a" });
121
+ } catch (err) {
122
+ fail(`${CLI}: cannot open benchmark log ${path}: ${err.message}`);
123
+ }
124
+ }
125
+
126
+ function compilerProvenance() {
127
+ try {
128
+ return JSON.parse(readFileSync(join(COMPILER, "SOURCE_INFO.json"), "utf8"));
129
+ } catch {
130
+ return {};
131
+ }
132
+ }
133
+
134
+ function parseBenchmarkArgs(args, command) {
135
+ let log = null;
136
+ let runId = null;
137
+ let logCount = 0;
138
+ let runCount = 0;
139
+ const compilerArgs = [];
140
+ for (let i = 0; i < args.length; i++) {
141
+ const arg = args[i];
142
+ if (arg === "--benchmark-log" || arg === "--benchmark-run") {
143
+ if (i + 1 >= args.length || args[i + 1].startsWith("--")) {
144
+ fail(`${CLI} ${command}: ${arg} requires a value`, 2);
145
+ }
146
+ if (arg === "--benchmark-log") {
147
+ log = args[++i];
148
+ logCount++;
149
+ } else {
150
+ runId = args[++i];
151
+ runCount++;
152
+ }
153
+ } else if (arg.startsWith("--benchmark-log=")) {
154
+ log = arg.slice("--benchmark-log=".length);
155
+ logCount++;
156
+ } else if (arg.startsWith("--benchmark-run=")) {
157
+ runId = arg.slice("--benchmark-run=".length);
158
+ runCount++;
159
+ } else {
160
+ compilerArgs.push(arg);
161
+ }
162
+ }
163
+ if ((log === null) !== (runId === null)) {
164
+ fail(`${CLI} ${command}: --benchmark-log and --benchmark-run must be supplied together`, 2);
165
+ }
166
+ if (logCount > 1 || runCount > 1) {
167
+ fail(`${CLI} ${command}: benchmark options may be supplied only once`, 2);
168
+ }
169
+ if (log !== null && log.trim() === "") {
170
+ fail(`${CLI} ${command}: --benchmark-log must not be empty`, 2);
171
+ }
172
+ if (runId !== null && runId.trim() === "") {
173
+ fail(`${CLI} ${command}: --benchmark-run must not be empty`, 2);
174
+ }
175
+ return {
176
+ args: compilerArgs,
177
+ benchmark: log === null ? null : { log: resolvePath(log), runId },
178
+ };
179
+ }
180
+
181
+ // Four possible outcomes for a FAILED attempt (never called for exitCode 0).
182
+ // This is the entire classification: it never inspects captured stdout/
183
+ // stderr TEXT, only (a) whether the OS delivered a kill signal and (b)
184
+ // the compiler's own structural report of what kind of stop it made (see
185
+ // `vlcheck/benchmark_signal.py`'s module docstring). "Any output happened"
186
+ // is deliberately NOT a diagnostic signal on its own -- an internal
187
+ // traceback is output too.
188
+ function classifyAttempt(signal, benchmarkSignal, firstOutputMs) {
189
+ if (signal) return "operational_failure"; // killed by the OS, never a diagnostic
190
+ if (benchmarkSignal) {
191
+ if (benchmarkSignal.kind === "crash") return "operational_failure";
192
+ if (benchmarkSignal.kind === "diagnostic") {
193
+ // Each module's OWN reserved "diagnostics found in valid input" code
194
+ // is exactly 1 (see vlcheck/vlbuild __main__.py); any other code the
195
+ // compiler intentionally stopped with (a usage/configuration error,
196
+ // for instance) is a real, honest stop but not a SOURCE diagnostic.
197
+ return benchmarkSignal.exit_code === 1 ? "source_diagnostic" : "operational_failure";
198
+ }
199
+ }
200
+ // No structured signal reached us at all: the compiler crashed before
201
+ // reaching its own signal-emission wrapper (an import-time failure, an
202
+ // interpreter that never started, a segfault) or the side channel was
203
+ // lost. Distinguished only by whether anything was captured on stdout/
204
+ // stderr, not by reading it.
205
+ return firstOutputMs === null ? "silent_failure" : "unknown_failure";
206
+ }
207
+
208
+ function writeBenchmarkResult(benchmark, command, compilerArgv, code, signal, startedAt,
209
+ startedMono, firstOutputMs, benchmarkSignal) {
210
+ const finishedEpochMs = Date.now();
211
+ const durationMs = elapsedMs(startedMono);
212
+ const prior = benchmarkRecords(benchmark.log, benchmark.runId);
213
+ if (prior.some((row) => row.record === "summary")) {
214
+ fail(`${CLI}: benchmark run ${JSON.stringify(benchmark.runId)} is already complete`, 2);
215
+ }
216
+
217
+ const attempts = prior.filter((row) => row.record === "attempt");
218
+ const exitCode = signal ? 1 : code ?? 0;
219
+ const provenance = compilerProvenance();
220
+ const classification = exitCode === 0 ? null : classifyAttempt(signal, benchmarkSignal, firstOutputMs);
221
+ const attempt = {
222
+ schema: "volaro.compile-timing.v1",
223
+ record: "attempt",
224
+ run_id: benchmark.runId,
225
+ attempt: attempts.length + 1,
226
+ command,
227
+ compiler_argv: compilerArgv,
228
+ compiler_version: pkg.version,
229
+ compiler_revision: provenance.revision || null,
230
+ compiler_content_sha256: provenance.content_sha256 || null,
231
+ started_at: startedAt,
232
+ finished_at: new Date(finishedEpochMs).toISOString(),
233
+ exit_code: exitCode,
234
+ signal: signal || null,
235
+ outcome: exitCode === 0 ? "success" : "failure",
236
+ classification,
237
+ duration_ms: roundedMs(durationMs),
238
+ // Raw fact, any classification: did output arrive, and when -- kept
239
+ // even for a crash/silent/unknown failure as the "available output
240
+ // metadata" a human investigating an invalid run needs.
241
+ first_output_ms: firstOutputMs === null ? null : roundedMs(firstOutputMs),
242
+ // Only ever populated for a genuine source diagnostic -- T-fail's
243
+ // whole point is "how long until the compiler told you about YOUR
244
+ // bug", which an operational/silent/unknown failure never did.
245
+ first_diagnostic_ms: classification === "source_diagnostic" && firstOutputMs !== null
246
+ ? roundedMs(firstOutputMs) : null,
247
+ crash_exception_type: benchmarkSignal && benchmarkSignal.kind === "crash"
248
+ ? benchmarkSignal.exception_type : null,
249
+ crash_exception_message: benchmarkSignal && benchmarkSignal.kind === "crash"
250
+ ? benchmarkSignal.exception_message : null,
251
+ };
252
+ appendBenchmark(benchmark.log, attempt);
253
+
254
+ if (exitCode !== 0) return;
255
+
256
+ const allAttempts = [...attempts, attempt];
257
+ const failures = allAttempts.filter((row) => row.outcome === "failure");
258
+ if (failures.length === 0) {
259
+ appendBenchmark(benchmark.log, {
260
+ schema: "volaro.compile-timing.v1",
261
+ record: "summary",
262
+ run_id: benchmark.runId,
263
+ command,
264
+ outcome: "clean",
265
+ timing_complete: true,
266
+ timing_incomplete_reason: null,
267
+ clock_anomaly: false,
268
+ attempt_count: 1,
269
+ failed_attempts: 0,
270
+ t_clean_ms: attempt.duration_ms,
271
+ t_fail_ms: null,
272
+ t_repair_ms: null,
273
+ t_recompile_ms: null,
274
+ t_recover_ms: null,
275
+ compiler_overhead_ms: null,
276
+ });
277
+ return;
278
+ }
279
+
280
+ // Wall-clock gaps between separate process invocations, computed from
281
+ // each process's own Date.now()-derived timestamps -- vulnerable to a
282
+ // backward system clock step (an NTP sync) between two attempts. A
283
+ // negative gap is never clamped to zero and reported as if it were a
284
+ // real, small repair time: that would silently corrupt the median/p90
285
+ // aggregation STAGE3-PROTOCOL.md §6.1 defines over this data. Instead
286
+ // the whole run is marked invalid for recovery-time comparison, with
287
+ // every raw timestamp still preserved untouched in the attempts above.
288
+ let repairMs = 0;
289
+ let clockAnomaly = false;
290
+ for (let i = 1; i < allAttempts.length; i++) {
291
+ const previous = allAttempts[i - 1];
292
+ if (previous.outcome === "failure") {
293
+ const gap = Date.parse(allAttempts[i].started_at) - Date.parse(previous.finished_at);
294
+ if (gap < 0) clockAnomaly = true;
295
+ repairMs += gap;
296
+ }
297
+ }
298
+ const first = allAttempts[0];
299
+ const recoverMs = finishedEpochMs - Date.parse(first.started_at);
300
+ if (recoverMs < 0) clockAnomaly = true;
301
+
302
+ const firstIsDiagnostic = first.classification === "source_diagnostic";
303
+ const failMs = firstIsDiagnostic ? first.first_diagnostic_ms : null;
304
+ const timingComplete = firstIsDiagnostic && !clockAnomaly;
305
+ const compilerOverheadMs = timingComplete
306
+ ? recoverMs - failMs - repairMs - attempt.duration_ms
307
+ : null;
308
+
309
+ let timingIncompleteReason = null;
310
+ if (!firstIsDiagnostic) {
311
+ timingIncompleteReason = `the original failing attempt was classified ` +
312
+ `${JSON.stringify(first.classification)}, not "source_diagnostic" -- ` +
313
+ `t_fail_ms and compiler_overhead_ms are not valid recovery-time samples for this run`;
314
+ } else if (clockAnomaly) {
315
+ timingIncompleteReason = "a wall-clock timestamp in this run predates an earlier one " +
316
+ "(a backward system clock step between two compiler invocations) -- " +
317
+ "t_repair_ms/t_recover_ms/compiler_overhead_ms are not valid recovery-time samples for this run";
318
+ }
319
+
320
+ appendBenchmark(benchmark.log, {
321
+ schema: "volaro.compile-timing.v1",
322
+ record: "summary",
323
+ run_id: benchmark.runId,
324
+ command,
325
+ outcome: "recovered",
326
+ timing_complete: timingComplete,
327
+ timing_incomplete_reason: timingIncompleteReason,
328
+ clock_anomaly: clockAnomaly,
329
+ attempt_count: allAttempts.length,
330
+ failed_attempts: failures.length,
331
+ t_clean_ms: null,
332
+ t_fail_ms: failMs,
333
+ t_repair_ms: clockAnomaly ? null : roundedMs(repairMs),
334
+ t_recompile_ms: attempt.duration_ms,
335
+ t_recover_ms: clockAnomaly ? null : roundedMs(recoverMs),
336
+ compiler_overhead_ms: timingComplete ? roundedMs(Math.max(0, compilerOverheadMs)) : null,
337
+ });
338
+ }
339
+
75
340
  // Run a Python module, streaming its output, and exit with its status.
76
- function runPython(pp, moduleName, moduleArgs) {
341
+ function runPython(pp, moduleName, moduleArgs, benchmark = null, command = moduleName) {
77
342
  const { cmd, argv, env } = pyInvocation(pp, moduleName, moduleArgs);
78
- const child = spawn(cmd, argv, { stdio: "inherit", env });
343
+ if (!benchmark) {
344
+ const child = spawn(cmd, argv, { stdio: "inherit", env });
345
+ child.on("error", (e) => fail(`${CLI}: failed to start Python (${cmd}): ${e.message}`));
346
+ child.on("exit", (code, signal) => process.exit(signal ? 1 : code ?? 0));
347
+ return;
348
+ }
349
+
350
+ ensureBenchmarkLog(benchmark.log);
351
+ const existing = benchmarkRecords(benchmark.log, benchmark.runId);
352
+ if (existing.some((row) => row.record === "summary")) {
353
+ fail(`${CLI}: benchmark run ${JSON.stringify(benchmark.runId)} is already complete`, 2);
354
+ }
355
+ if (existing.some((row) => row.record === "attempt" && row.command !== command)) {
356
+ fail(`${CLI}: benchmark run ${JSON.stringify(benchmark.runId)} cannot mix check and build`, 2);
357
+ }
358
+ const compilerArgv = [moduleName, ...moduleArgs];
359
+ if (existing.some((row) => row.record === "attempt" &&
360
+ JSON.stringify(row.compiler_argv) !== JSON.stringify(compilerArgv))) {
361
+ fail(`${CLI}: benchmark run ${JSON.stringify(benchmark.runId)} must reuse the exact compiler command`, 2);
362
+ }
363
+
364
+ const startedEpochMs = Date.now();
365
+ const startedAt = new Date(startedEpochMs).toISOString();
366
+ const startedMono = process.hrtime.bigint();
367
+ let firstOutputMs = null;
368
+ // fd 3: a private side channel the compiler writes its own structural
369
+ // diagnostic/crash signal to (see vlcheck/benchmark_signal.py) -- never
370
+ // stdout/stderr, so ordinary output forwarded to the terminal below is
371
+ // untouched by this. VOLARO_BENCHMARK_SIGNAL_FD tells the compiler that
372
+ // channel exists; it is a documented no-op when unset, which is exactly
373
+ // every non-benchmark invocation.
374
+ const child = spawn(cmd, argv, {
375
+ stdio: ["inherit", "pipe", "pipe", "pipe"],
376
+ env: { ...env, PYTHONUNBUFFERED: "1", VOLARO_BENCHMARK_SIGNAL_FD: "3" },
377
+ });
378
+ const forward = (stream, target) => stream.on("data", (chunk) => {
379
+ if (firstOutputMs === null) firstOutputMs = elapsedMs(startedMono);
380
+ target.write(chunk);
381
+ });
382
+ forward(child.stdout, process.stdout);
383
+ forward(child.stderr, process.stderr);
384
+ const signalChunks = [];
385
+ child.stdio[3].on("data", (chunk) => signalChunks.push(chunk));
79
386
  child.on("error", (e) => fail(`${CLI}: failed to start Python (${cmd}): ${e.message}`));
80
- child.on("exit", (code, signal) => process.exit(signal ? 1 : code ?? 0));
387
+ child.on("close", (code, signal) => {
388
+ let benchmarkSignal = null;
389
+ const signalRaw = Buffer.concat(signalChunks).toString("utf8").trim();
390
+ if (signalRaw) {
391
+ // The compiler writes exactly one JSON line and closes the stream
392
+ // (benchmark_signal.py); take the first line defensively rather than
393
+ // assuming that held.
394
+ try {
395
+ benchmarkSignal = JSON.parse(signalRaw.split(/\r?\n/)[0]);
396
+ } catch {
397
+ benchmarkSignal = null; // malformed signal -> falls through to unknown_failure
398
+ }
399
+ }
400
+ writeBenchmarkResult(benchmark, command, compilerArgv, code, signal, startedAt,
401
+ startedMono, firstOutputMs, benchmarkSignal);
402
+ process.exit(signal ? 1 : code ?? 0);
403
+ });
81
404
  }
82
405
 
83
406
  function pyCapture(pp, moduleName, moduleArgs) {
@@ -88,9 +411,10 @@ function pyCapture(pp, moduleName, moduleArgs) {
88
411
  // ---- subcommands -----------------------------------------------------------
89
412
 
90
413
  function cmdCheck(args) {
91
- if (args.length === 0) fail("usage: volaro check <file-or-dir>... [--release] [--config PATH]");
414
+ const parsed = parseBenchmarkArgs(args, "check");
415
+ if (parsed.args.length === 0) fail("usage: volaro check <file-or-dir>... [--release] [--config PATH]");
92
416
  // Always resolve: a bare structural pass is weaker than the real check.
93
- runPython(VALIDATOR_PP, "vlcheck", ["--resolve", ...args]);
417
+ runPython(VALIDATOR_PP, "vlcheck", ["--resolve", ...parsed.args], parsed.benchmark, "check");
94
418
  }
95
419
 
96
420
  function parseBuildArgs(args) {
@@ -123,14 +447,15 @@ function resolveEntry(explicit) {
123
447
  }
124
448
 
125
449
  function cmdBuild(args) {
126
- const b = parseBuildArgs(args);
450
+ const parsed = parseBenchmarkArgs(args, "build");
451
+ const b = parseBuildArgs(parsed.args);
127
452
  const source = resolveEntry(b.source);
128
453
  const outDir = b.o ? resolvePath(b.o) : resolvePath("build");
129
454
  const pyArgs = [source, "-o", outDir];
130
455
  if (b.data) pyArgs.push("--data", resolvePath(b.data));
131
456
  if (b.config) pyArgs.push("--config", resolvePath(b.config));
132
457
  if (b.release) pyArgs.push("--release");
133
- runPython(VLBUILD_PP, "vlbuild", pyArgs);
458
+ runPython(VLBUILD_PP, "vlbuild", pyArgs, parsed.benchmark, "build");
134
459
  }
135
460
 
136
461
  // Synchronous single build for `volaro dev`; returns { ok, server } or exits on env error.
@@ -263,7 +588,9 @@ function cmdDev(args) {
263
588
  if (!Number.isInteger(port) || port < 1 || port > 65535) fail("volaro dev: --port must be 1..65535");
264
589
  const entry = resolveEntry(source);
265
590
  const out = outDir ? resolvePath(outDir) : resolvePath("build");
266
- const watchDir = dirname(entry);
591
+ // A project build's entry is the project directory itself (`volaro dev .`);
592
+ // watching its dirname would watch the PARENT directory instead.
593
+ const watchDir = statSync(entry).isDirectory() ? entry : dirname(entry);
267
594
 
268
595
  process.stdout.write(`volaro dev: building ${relative(process.cwd(), entry) || entry}\n`);
269
596
  const first = buildOnce(entry, out);
@@ -388,6 +715,20 @@ function cmdExample(name) {
388
715
  else fail("usage: volaro example sensors|station");
389
716
  }
390
717
 
718
+ function cmdRoutes(args) {
719
+ let json = false;
720
+ let project = null;
721
+ for (const a of args) {
722
+ if (a === "--json") json = true;
723
+ else if (a.startsWith("-")) fail(`usage: volaro routes [project] [--json]`, 2);
724
+ else if (!project) project = a;
725
+ else fail(`usage: volaro routes [project] [--json]`, 2);
726
+ }
727
+ const pyArgs = [resolvePath(project || ".")];
728
+ if (json) pyArgs.push("--json");
729
+ runPython(VALIDATOR_PP, "vlcheck.routes_cli", pyArgs);
730
+ }
731
+
391
732
  const HELP = `Volaro ${pkg.version} — an application language for AI authoring and human review.
392
733
 
393
734
  USAGE
@@ -397,15 +738,27 @@ USAGE
397
738
  COMMANDS
398
739
  check <path>... Validate .vl source. Runs the full resolver, not just
399
740
  a structural pass. Exit non-zero on any error.
741
+ --benchmark-log <file> append raw timing records as JSONL
742
+ --benchmark-run <id> group attempts into one clean/recovery trial
400
743
  build <file.vl> Transpile to a runnable bundle (default: ./build).
401
744
  -o <dir> output directory
402
745
  --data <file> data.json to bundle
403
746
  --release fail on unresolved placeholders (e.g. img alt_todo:)
747
+ --benchmark-log <file> append raw timing records as JSONL
748
+ --benchmark-run <id> group attempts into one clean/recovery trial
404
749
  dev [file.vl] Build, serve on http://127.0.0.1:5173, and rebuild
405
750
  when a .vl file changes. Defaults to ./app.vl. A
406
751
  full-stack build runs server.js instead (Node 22.5+)
407
752
  and restarts it after each successful rebuild.
408
753
  --port <n> dev server port (passed to server.js as $PORT)
754
+ routes [project] Print discovered browser pages (spec §8.15: URL,
755
+ source, layout chain, boundaries) and the
756
+ page-server route manifest (spec §10.2: final URL,
757
+ method, source file, parameter types, generated
758
+ client member, authorization). Project defaults to
759
+ ".". Exits non-zero on any collision or project-
760
+ validity error (page/route or layout-composition).
761
+ --json emit the language-neutral manifest as JSON
409
762
  crib The authoring reference (write from this).
410
763
  supported What version ${pkg.version} actually accepts.
411
764
  spec The full language design (a SUPERSET of this build).
@@ -416,12 +769,13 @@ COMMANDS
416
769
  The compiler is Python and ships inside this package. Set VOLARO_PYTHON to
417
770
  choose the interpreter if \`python3\` is not the one you want.
418
771
 
419
- SHIPPED SUBSET: single page (no router); text \`input\` / \`button\` / \`link\`
420
- only (no checkbox/select/textarea/disabled — a toggle is a \`button\` + \`state\`,
421
- not a semantic checkbox); \`if\` as an expression is binary; \`match\` is a
422
- statement; a module-level \`fn\` cannot be called from a view. \`volaro crib\` +
423
- \`volaro supported\` are authoritative for this build; \`volaro spec\` is the wider
424
- design, not what it accepts.
772
+ SHIPPED SUBSET: single page (no router); \`input\` (full type matrix incl.
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
776
+ binary; \`match\` is a statement; a module-level \`fn\` cannot be called from a
777
+ view. \`volaro crib\` + \`volaro supported\` are authoritative for this build;
778
+ \`volaro spec\` is the wider design, not what it accepts.
425
779
 
426
780
  Run commands via your project's \`npm run …\` scripts or \`npx --no-install
427
781
  volaro …\` inside the project. A bare \`npx volaro …\` from elsewhere may fetch
@@ -444,6 +798,9 @@ switch (cmd) {
444
798
  case "dev":
445
799
  cmdDev(rest);
446
800
  break;
801
+ case "routes":
802
+ cmdRoutes(rest);
803
+ break;
447
804
  case "crib":
448
805
  cmdReference("crib");
449
806
  break;
@@ -1,6 +1,6 @@
1
1
  {
2
- "revision": "a450e6fe6549b8c7df43ba343793ab2e52758a78",
2
+ "revision": "0d5741da1385dd81e277b16715a93592744d8c87",
3
3
  "source": "git",
4
4
  "dirty": false,
5
- "content_sha256": "095f3d5b55826d6aa0c560ce5570c91752d8f03cbd0e869ce61df1fcf53e2593"
5
+ "content_sha256": "611292080bf797022be28aea42376ca55bbeba3e680c2ec638ae4a1417981ff0"
6
6
  }
@@ -1 +1 @@
1
- a450e6fe6549b8c7df43ba343793ab2e52758a78
1
+ 0d5741da1385dd81e277b16715a93592744d8c87