volaro 0.0.2 → 0.1.0-alpha.10

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 (50) hide show
  1. package/README.md +175 -22
  2. package/bin/vl.js +829 -28
  3. package/compiler/SOURCE_INFO.json +6 -0
  4. package/compiler/SOURCE_REV +1 -0
  5. package/compiler/validator/vlcheck/__init__.py +10 -0
  6. package/compiler/validator/vlcheck/__main__.py +197 -0
  7. package/compiler/validator/vlcheck/ast_nodes.py +457 -0
  8. package/compiler/validator/vlcheck/benchmark_signal.py +86 -0
  9. package/compiler/validator/vlcheck/checks.py +3193 -0
  10. package/compiler/validator/vlcheck/diagnostics.py +91 -0
  11. package/compiler/validator/vlcheck/elements.py +1260 -0
  12. package/compiler/validator/vlcheck/layoutcompose.py +412 -0
  13. package/compiler/validator/vlcheck/lexer.py +384 -0
  14. package/compiler/validator/vlcheck/pagemanifest.py +389 -0
  15. package/compiler/validator/vlcheck/pageroutes.py +468 -0
  16. package/compiler/validator/vlcheck/parser.py +1842 -0
  17. package/compiler/validator/vlcheck/project_config.py +97 -0
  18. package/compiler/validator/vlcheck/resolve.py +1085 -0
  19. package/compiler/validator/vlcheck/routes_cli.py +172 -0
  20. package/compiler/validator/vlcheck/test_ids.py +99 -0
  21. package/compiler/vlbuild/styling/README.md +48 -0
  22. package/compiler/vlbuild/styling/build-css.mjs +181 -0
  23. package/compiler/vlbuild/styling/package-lock.json +1254 -0
  24. package/compiler/vlbuild/styling/package.json +15 -0
  25. package/compiler/vlbuild/styling/test-build-css.mjs +149 -0
  26. package/compiler/vlbuild/vlbuild/__init__.py +16 -0
  27. package/compiler/vlbuild/vlbuild/__main__.py +376 -0
  28. package/compiler/vlbuild/vlbuild/assets/vlrouter.js +740 -0
  29. package/compiler/vlbuild/vlbuild/assets/vlrouter.min.js +2 -0
  30. package/compiler/vlbuild/vlbuild/assets/vlrt.css +289 -0
  31. package/compiler/vlbuild/vlbuild/assets/vlrt.js +1648 -0
  32. package/compiler/vlbuild/vlbuild/assets/vlrt.min.css +2 -0
  33. package/compiler/vlbuild/vlbuild/assets/vlrt.min.js +2 -0
  34. package/compiler/vlbuild/vlbuild/emit.py +3732 -0
  35. package/compiler/vlbuild/vlbuild/pages_build.py +508 -0
  36. package/compiler/vlbuild/vlbuild/project.py +338 -0
  37. package/compiler/vlbuild/vlbuild/runtime_assets.py +41 -0
  38. package/compiler/vlbuild/vlbuild/server_emit.py +2149 -0
  39. package/compiler/vlbuild/vlbuild/static_assets.py +130 -0
  40. package/compiler/vlbuild/vlbuild/style_config.py +414 -0
  41. package/compiler/vlbuild/vlbuild/styling.py +41 -0
  42. package/examples/station.vl +2 -2
  43. package/language/crib.md +632 -24
  44. package/language/spec.md +722 -19
  45. package/language/supported.md +711 -0
  46. package/lib/env.js +107 -0
  47. package/package.json +19 -2
  48. package/scripts/record-provenance.mjs +51 -0
  49. package/scripts/selftest.mjs +108 -0
  50. package/scripts/sync-compiler.sh +64 -0
package/bin/vl.js CHANGED
@@ -1,34 +1,835 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync } from "node:fs";
2
+ // The `volaro` command (alias: `vl`): a thin Node wrapper around the Volaro compiler, which
3
+ // ships as Python sources under ../compiler and is invoked as a subprocess.
4
+ //
5
+ // volaro check <path>... validate .vl source (lexer / parser / resolver)
6
+ // volaro build <file.vl> transpile to a runnable bundle
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
9
+ // volaro crib | volaro spec print the language reference
10
+ // volaro example <name> print a worked example
11
+ // volaro version | volaro help
12
+ //
13
+ // Python is located and version-checked up front with an actionable message
14
+ // (see ../lib/env.js). Nothing here depends on the Volaro repository layout.
15
+
16
+ import { appendFileSync, readFileSync, existsSync, statSync, lstatSync, readdirSync } from "node:fs";
3
17
  import { fileURLToPath } from "node:url";
4
- import { dirname, join } from "node:path";
18
+ import { dirname, join, resolve as resolvePath, extname, relative, sep, delimiter } from "node:path";
19
+ import { spawn, spawnSync } from "node:child_process";
20
+ import { createServer } from "node:http";
21
+ import { resolvePython, nodeCanRunServer, VolaroEnvError, MIN_NODE_FOR_SERVER } from "../lib/env.js";
5
22
 
6
23
  const root = join(dirname(fileURLToPath(import.meta.url)), "..");
7
24
  const read = (p) => readFileSync(join(root, p), "utf8");
8
- const { version } = JSON.parse(read("package.json"));
9
- const cmd = process.argv[2];
10
-
11
- if (cmd === "crib") process.stdout.write(read("language/crib.md"));
12
- else if (cmd === "spec") process.stdout.write(read("language/spec.md"));
13
- else if (cmd === "example") {
14
- const n = process.argv[3];
15
- if (n === "sensors" || n === "station") process.stdout.write(read(`examples/${n}.vl`));
16
- else console.log("usage: vl example sensors|station");
17
- } else {
18
- console.log(`Volaro ${version} — pre-release.
19
-
20
- An application language written to be authored by an AI agent and read by a
21
- person: views, HTTP endpoints, outbound API clients and user accounts.
22
-
23
- vl crib the one-page language reference (~1,700 tokens)
24
- vl spec the full language specification
25
- vl example sensors a worked example — models, unions, a fallible fn
26
- vl example station a worked example — a guarded service and a view
27
-
28
- TO AN AI AGENT reading this: run \`vl crib\` and load its output. That is the
29
- complete authoring reference — it is designed to be read once and written from.
30
-
31
- The toolchain (vl new / check / build / dev) is not published yet, so Volaro
32
- written today cannot yet be compiled from this package.
33
- `);
25
+ const pkg = JSON.parse(read("package.json"));
26
+
27
+ // The canonical command is `volaro`; `vl` is a compatibility alias. Messages
28
+ // use whichever name this process was invoked as, defaulting to `volaro`.
29
+ const CLI = (() => {
30
+ const b = (process.argv[1] || "").split(/[\\/]/).pop() || "";
31
+ return b === "vl" || b === "vl.js" ? "vl" : "volaro";
32
+ })();
33
+
34
+ const COMPILER = join(root, "compiler");
35
+ const VALIDATOR_PP = join(COMPILER, "validator");
36
+ const VLBUILD_PP = join(COMPILER, "vlbuild");
37
+
38
+ function fail(msg, code = 1) {
39
+ process.stderr.write((msg.endsWith("\n") ? msg : msg + "\n"));
40
+ process.exit(code);
41
+ }
42
+
43
+ function haveCompiler() {
44
+ return existsSync(join(VALIDATOR_PP, "vlcheck", "__main__.py")) &&
45
+ existsSync(join(VLBUILD_PP, "vlbuild", "__main__.py"));
46
+ }
47
+
48
+ function python() {
49
+ if (!haveCompiler()) {
50
+ fail(
51
+ `${CLI}: the bundled compiler is missing from this package.\n` +
52
+ "If you are working inside the Volaro repo, run:\n" +
53
+ " bash packaging/volaro/scripts/sync-compiler.sh\n" +
54
+ "An installed copy from `npm pack` / a published release always includes it.",
55
+ );
56
+ }
57
+ try {
58
+ return resolvePython();
59
+ } catch (err) {
60
+ if (err instanceof VolaroEnvError) fail(`${CLI}: ` + err.message);
61
+ throw err;
62
+ }
63
+ }
64
+
65
+ // Shared Python-invocation setup for both the streaming and capturing paths.
66
+ function pyInvocation(pp, moduleName, moduleArgs) {
67
+ const py = python();
68
+ const env = {
69
+ ...process.env,
70
+ PYTHONPATH: [pp, VALIDATOR_PP, process.env.PYTHONPATH].filter(Boolean).join(delimiter),
71
+ PYTHONDONTWRITEBYTECODE: "1",
72
+ // Diagnostics and build summaries contain non-ASCII (box drawing, arrows).
73
+ // Without this, Windows encodes a redirected stdout with the legacy code
74
+ // page and the compiler dies with UnicodeEncodeError; the capturing path
75
+ // below also decodes the child's output as UTF-8.
76
+ PYTHONIOENCODING: "utf-8",
77
+ };
78
+ return { cmd: py.cmd, argv: ["-B", "-m", moduleName, ...moduleArgs], env };
79
+ }
80
+
81
+ function elapsedMs(start) {
82
+ return Number(process.hrtime.bigint() - start) / 1e6;
83
+ }
84
+
85
+ function roundedMs(value) {
86
+ return Math.round(value * 1000) / 1000;
87
+ }
88
+
89
+ function benchmarkRecords(path, runId) {
90
+ if (!existsSync(path)) return [];
91
+ let raw;
92
+ try {
93
+ const st = statSync(path);
94
+ if (st.isDirectory()) {
95
+ fail(`${CLI}: benchmark log ${path} is a directory, not a file`);
96
+ }
97
+ raw = readFileSync(path, "utf8");
98
+ } catch (err) {
99
+ fail(`${CLI}: cannot read benchmark log ${path}: ${err.message}`);
100
+ }
101
+ return raw
102
+ .split(/\r?\n/)
103
+ .filter(Boolean)
104
+ .map((line, index) => {
105
+ try {
106
+ return JSON.parse(line);
107
+ } catch {
108
+ fail(`${CLI}: invalid JSONL in benchmark log ${path} at line ${index + 1}: ` +
109
+ `${JSON.stringify(line.length > 80 ? line.slice(0, 80) + "…" : line)}`);
110
+ }
111
+ })
112
+ .filter((row) => row.schema === "volaro.compile-timing.v1" && row.run_id === runId);
113
+ }
114
+
115
+ function appendBenchmark(path, record) {
116
+ try {
117
+ appendFileSync(path, JSON.stringify(record) + "\n", { encoding: "utf8", flag: "a" });
118
+ } catch (err) {
119
+ fail(`${CLI}: cannot append benchmark log ${path}: ${err.message}`);
120
+ }
121
+ }
122
+
123
+ function ensureBenchmarkLog(path) {
124
+ try {
125
+ appendFileSync(path, "", { encoding: "utf8", flag: "a" });
126
+ } catch (err) {
127
+ fail(`${CLI}: cannot open benchmark log ${path}: ${err.message}`);
128
+ }
129
+ }
130
+
131
+ function compilerProvenance() {
132
+ try {
133
+ return JSON.parse(readFileSync(join(COMPILER, "SOURCE_INFO.json"), "utf8"));
134
+ } catch {
135
+ return {};
136
+ }
137
+ }
138
+
139
+ function parseBenchmarkArgs(args, command) {
140
+ let log = null;
141
+ let runId = null;
142
+ let logCount = 0;
143
+ let runCount = 0;
144
+ const compilerArgs = [];
145
+ for (let i = 0; i < args.length; i++) {
146
+ const arg = args[i];
147
+ if (arg === "--benchmark-log" || arg === "--benchmark-run") {
148
+ if (i + 1 >= args.length || args[i + 1].startsWith("--")) {
149
+ fail(`${CLI} ${command}: ${arg} requires a value`, 2);
150
+ }
151
+ if (arg === "--benchmark-log") {
152
+ log = args[++i];
153
+ logCount++;
154
+ } else {
155
+ runId = args[++i];
156
+ runCount++;
157
+ }
158
+ } else if (arg.startsWith("--benchmark-log=")) {
159
+ log = arg.slice("--benchmark-log=".length);
160
+ logCount++;
161
+ } else if (arg.startsWith("--benchmark-run=")) {
162
+ runId = arg.slice("--benchmark-run=".length);
163
+ runCount++;
164
+ } else {
165
+ compilerArgs.push(arg);
166
+ }
167
+ }
168
+ if ((log === null) !== (runId === null)) {
169
+ fail(`${CLI} ${command}: --benchmark-log and --benchmark-run must be supplied together`, 2);
170
+ }
171
+ if (logCount > 1 || runCount > 1) {
172
+ fail(`${CLI} ${command}: benchmark options may be supplied only once`, 2);
173
+ }
174
+ if (log !== null && log.trim() === "") {
175
+ fail(`${CLI} ${command}: --benchmark-log must not be empty`, 2);
176
+ }
177
+ if (runId !== null && runId.trim() === "") {
178
+ fail(`${CLI} ${command}: --benchmark-run must not be empty`, 2);
179
+ }
180
+ return {
181
+ args: compilerArgs,
182
+ benchmark: log === null ? null : { log: resolvePath(log), runId },
183
+ };
184
+ }
185
+
186
+ // Four possible outcomes for a FAILED attempt (never called for exitCode 0).
187
+ // This is the entire classification: it never inspects captured stdout/
188
+ // stderr TEXT, only (a) whether the OS delivered a kill signal and (b)
189
+ // the compiler's own structural report of what kind of stop it made (see
190
+ // `vlcheck/benchmark_signal.py`'s module docstring). "Any output happened"
191
+ // is deliberately NOT a diagnostic signal on its own -- an internal
192
+ // traceback is output too.
193
+ function classifyAttempt(signal, benchmarkSignal, firstOutputMs) {
194
+ if (signal) return "operational_failure"; // killed by the OS, never a diagnostic
195
+ if (benchmarkSignal) {
196
+ if (benchmarkSignal.kind === "crash") return "operational_failure";
197
+ if (benchmarkSignal.kind === "diagnostic") {
198
+ // Each module's OWN reserved "diagnostics found in valid input" code
199
+ // is exactly 1 (see vlcheck/vlbuild __main__.py); any other code the
200
+ // compiler intentionally stopped with (a usage/configuration error,
201
+ // for instance) is a real, honest stop but not a SOURCE diagnostic.
202
+ return benchmarkSignal.exit_code === 1 ? "source_diagnostic" : "operational_failure";
203
+ }
204
+ }
205
+ // No structured signal reached us at all: the compiler crashed before
206
+ // reaching its own signal-emission wrapper (an import-time failure, an
207
+ // interpreter that never started, a segfault) or the side channel was
208
+ // lost. Distinguished only by whether anything was captured on stdout/
209
+ // stderr, not by reading it.
210
+ return firstOutputMs === null ? "silent_failure" : "unknown_failure";
211
+ }
212
+
213
+ function writeBenchmarkResult(benchmark, command, compilerArgv, code, signal, startedAt,
214
+ startedMono, firstOutputMs, benchmarkSignal) {
215
+ const finishedEpochMs = Date.now();
216
+ const durationMs = elapsedMs(startedMono);
217
+ const prior = benchmarkRecords(benchmark.log, benchmark.runId);
218
+ if (prior.some((row) => row.record === "summary")) {
219
+ fail(`${CLI}: benchmark run ${JSON.stringify(benchmark.runId)} is already complete`, 2);
220
+ }
221
+
222
+ const attempts = prior.filter((row) => row.record === "attempt");
223
+ const exitCode = signal ? 1 : code ?? 0;
224
+ const provenance = compilerProvenance();
225
+ const classification = exitCode === 0 ? null : classifyAttempt(signal, benchmarkSignal, firstOutputMs);
226
+ const attempt = {
227
+ schema: "volaro.compile-timing.v1",
228
+ record: "attempt",
229
+ run_id: benchmark.runId,
230
+ attempt: attempts.length + 1,
231
+ command,
232
+ compiler_argv: compilerArgv,
233
+ compiler_version: pkg.version,
234
+ compiler_revision: provenance.revision || null,
235
+ compiler_content_sha256: provenance.content_sha256 || null,
236
+ started_at: startedAt,
237
+ finished_at: new Date(finishedEpochMs).toISOString(),
238
+ exit_code: exitCode,
239
+ signal: signal || null,
240
+ outcome: exitCode === 0 ? "success" : "failure",
241
+ classification,
242
+ duration_ms: roundedMs(durationMs),
243
+ // Raw fact, any classification: did output arrive, and when -- kept
244
+ // even for a crash/silent/unknown failure as the "available output
245
+ // metadata" a human investigating an invalid run needs.
246
+ first_output_ms: firstOutputMs === null ? null : roundedMs(firstOutputMs),
247
+ // Only ever populated for a genuine source diagnostic -- T-fail's
248
+ // whole point is "how long until the compiler told you about YOUR
249
+ // bug", which an operational/silent/unknown failure never did.
250
+ first_diagnostic_ms: classification === "source_diagnostic" && firstOutputMs !== null
251
+ ? roundedMs(firstOutputMs) : null,
252
+ crash_exception_type: benchmarkSignal && benchmarkSignal.kind === "crash"
253
+ ? benchmarkSignal.exception_type : null,
254
+ crash_exception_message: benchmarkSignal && benchmarkSignal.kind === "crash"
255
+ ? benchmarkSignal.exception_message : null,
256
+ };
257
+ appendBenchmark(benchmark.log, attempt);
258
+
259
+ if (exitCode !== 0) return;
260
+
261
+ const allAttempts = [...attempts, attempt];
262
+ const failures = allAttempts.filter((row) => row.outcome === "failure");
263
+ if (failures.length === 0) {
264
+ appendBenchmark(benchmark.log, {
265
+ schema: "volaro.compile-timing.v1",
266
+ record: "summary",
267
+ run_id: benchmark.runId,
268
+ command,
269
+ outcome: "clean",
270
+ timing_complete: true,
271
+ timing_incomplete_reason: null,
272
+ clock_anomaly: false,
273
+ attempt_count: 1,
274
+ failed_attempts: 0,
275
+ t_clean_ms: attempt.duration_ms,
276
+ t_fail_ms: null,
277
+ t_repair_ms: null,
278
+ t_recompile_ms: null,
279
+ t_recover_ms: null,
280
+ compiler_overhead_ms: null,
281
+ });
282
+ return;
283
+ }
284
+
285
+ // Wall-clock gaps between separate process invocations, computed from
286
+ // each process's own Date.now()-derived timestamps -- vulnerable to a
287
+ // backward system clock step (an NTP sync) between two attempts. A
288
+ // negative gap is never clamped to zero and reported as if it were a
289
+ // real, small repair time: that would silently corrupt the median/p90
290
+ // aggregation STAGE3-PROTOCOL.md §6.1 defines over this data. Instead
291
+ // the whole run is marked invalid for recovery-time comparison, with
292
+ // every raw timestamp still preserved untouched in the attempts above.
293
+ let repairMs = 0;
294
+ let clockAnomaly = false;
295
+ for (let i = 1; i < allAttempts.length; i++) {
296
+ const previous = allAttempts[i - 1];
297
+ if (previous.outcome === "failure") {
298
+ const gap = Date.parse(allAttempts[i].started_at) - Date.parse(previous.finished_at);
299
+ if (gap < 0) clockAnomaly = true;
300
+ repairMs += gap;
301
+ }
302
+ }
303
+ const first = allAttempts[0];
304
+ const recoverMs = finishedEpochMs - Date.parse(first.started_at);
305
+ if (recoverMs < 0) clockAnomaly = true;
306
+
307
+ const firstIsDiagnostic = first.classification === "source_diagnostic";
308
+ const failMs = firstIsDiagnostic ? first.first_diagnostic_ms : null;
309
+ const timingComplete = firstIsDiagnostic && !clockAnomaly;
310
+ const compilerOverheadMs = timingComplete
311
+ ? recoverMs - failMs - repairMs - attempt.duration_ms
312
+ : null;
313
+
314
+ let timingIncompleteReason = null;
315
+ if (!firstIsDiagnostic) {
316
+ timingIncompleteReason = `the original failing attempt was classified ` +
317
+ `${JSON.stringify(first.classification)}, not "source_diagnostic" -- ` +
318
+ `t_fail_ms and compiler_overhead_ms are not valid recovery-time samples for this run`;
319
+ } else if (clockAnomaly) {
320
+ timingIncompleteReason = "a wall-clock timestamp in this run predates an earlier one " +
321
+ "(a backward system clock step between two compiler invocations) -- " +
322
+ "t_repair_ms/t_recover_ms/compiler_overhead_ms are not valid recovery-time samples for this run";
323
+ }
324
+
325
+ appendBenchmark(benchmark.log, {
326
+ schema: "volaro.compile-timing.v1",
327
+ record: "summary",
328
+ run_id: benchmark.runId,
329
+ command,
330
+ outcome: "recovered",
331
+ timing_complete: timingComplete,
332
+ timing_incomplete_reason: timingIncompleteReason,
333
+ clock_anomaly: clockAnomaly,
334
+ attempt_count: allAttempts.length,
335
+ failed_attempts: failures.length,
336
+ t_clean_ms: null,
337
+ t_fail_ms: failMs,
338
+ t_repair_ms: clockAnomaly ? null : roundedMs(repairMs),
339
+ t_recompile_ms: attempt.duration_ms,
340
+ t_recover_ms: clockAnomaly ? null : roundedMs(recoverMs),
341
+ compiler_overhead_ms: timingComplete ? roundedMs(Math.max(0, compilerOverheadMs)) : null,
342
+ });
343
+ }
344
+
345
+ // Run a Python module, streaming its output, and exit with its status.
346
+ function runPython(pp, moduleName, moduleArgs, benchmark = null, command = moduleName) {
347
+ const { cmd, argv, env } = pyInvocation(pp, moduleName, moduleArgs);
348
+ if (!benchmark) {
349
+ const child = spawn(cmd, argv, { stdio: "inherit", env });
350
+ child.on("error", (e) => fail(`${CLI}: failed to start Python (${cmd}): ${e.message}`));
351
+ child.on("exit", (code, signal) => process.exit(signal ? 1 : code ?? 0));
352
+ return;
353
+ }
354
+
355
+ ensureBenchmarkLog(benchmark.log);
356
+ const existing = benchmarkRecords(benchmark.log, benchmark.runId);
357
+ if (existing.some((row) => row.record === "summary")) {
358
+ fail(`${CLI}: benchmark run ${JSON.stringify(benchmark.runId)} is already complete`, 2);
359
+ }
360
+ if (existing.some((row) => row.record === "attempt" && row.command !== command)) {
361
+ fail(`${CLI}: benchmark run ${JSON.stringify(benchmark.runId)} cannot mix check and build`, 2);
362
+ }
363
+ const compilerArgv = [moduleName, ...moduleArgs];
364
+ if (existing.some((row) => row.record === "attempt" &&
365
+ JSON.stringify(row.compiler_argv) !== JSON.stringify(compilerArgv))) {
366
+ fail(`${CLI}: benchmark run ${JSON.stringify(benchmark.runId)} must reuse the exact compiler command`, 2);
367
+ }
368
+
369
+ const startedEpochMs = Date.now();
370
+ const startedAt = new Date(startedEpochMs).toISOString();
371
+ const startedMono = process.hrtime.bigint();
372
+ let firstOutputMs = null;
373
+ // fd 3: a private side channel the compiler writes its own structural
374
+ // diagnostic/crash signal to (see vlcheck/benchmark_signal.py) -- never
375
+ // stdout/stderr, so ordinary output forwarded to the terminal below is
376
+ // untouched by this. VOLARO_BENCHMARK_SIGNAL_FD tells the compiler that
377
+ // channel exists; it is a documented no-op when unset, which is exactly
378
+ // every non-benchmark invocation.
379
+ const child = spawn(cmd, argv, {
380
+ stdio: ["inherit", "pipe", "pipe", "pipe"],
381
+ env: { ...env, PYTHONUNBUFFERED: "1", VOLARO_BENCHMARK_SIGNAL_FD: "3" },
382
+ });
383
+ const forward = (stream, target) => stream.on("data", (chunk) => {
384
+ if (firstOutputMs === null) firstOutputMs = elapsedMs(startedMono);
385
+ target.write(chunk);
386
+ });
387
+ forward(child.stdout, process.stdout);
388
+ forward(child.stderr, process.stderr);
389
+ const signalChunks = [];
390
+ child.stdio[3].on("data", (chunk) => signalChunks.push(chunk));
391
+ child.on("error", (e) => fail(`${CLI}: failed to start Python (${cmd}): ${e.message}`));
392
+ child.on("close", (code, signal) => {
393
+ let benchmarkSignal = null;
394
+ const signalRaw = Buffer.concat(signalChunks).toString("utf8").trim();
395
+ if (signalRaw) {
396
+ // The compiler writes exactly one JSON line and closes the stream
397
+ // (benchmark_signal.py); take the first line defensively rather than
398
+ // assuming that held.
399
+ try {
400
+ benchmarkSignal = JSON.parse(signalRaw.split(/\r?\n/)[0]);
401
+ } catch {
402
+ benchmarkSignal = null; // malformed signal -> falls through to unknown_failure
403
+ }
404
+ }
405
+ writeBenchmarkResult(benchmark, command, compilerArgv, code, signal, startedAt,
406
+ startedMono, firstOutputMs, benchmarkSignal);
407
+ process.exit(signal ? 1 : code ?? 0);
408
+ });
409
+ }
410
+
411
+ function pyCapture(pp, moduleName, moduleArgs) {
412
+ const { cmd, argv, env } = pyInvocation(pp, moduleName, moduleArgs);
413
+ return spawnSync(cmd, argv, { encoding: "utf8", env });
414
+ }
415
+
416
+ // ---- subcommands -----------------------------------------------------------
417
+
418
+ function cmdCheck(args) {
419
+ const parsed = parseBenchmarkArgs(args, "check");
420
+ if (parsed.args.length === 0) fail("usage: volaro check <file-or-dir>... [--release] [--config PATH]");
421
+ // Always resolve: a bare structural pass is weaker than the real check.
422
+ runPython(VALIDATOR_PP, "vlcheck", ["--resolve", ...parsed.args], parsed.benchmark, "check");
423
+ }
424
+
425
+ function parseBuildArgs(args) {
426
+ const out = { source: null, o: null, data: null, release: false, config: null };
427
+ for (let i = 0; i < args.length; i++) {
428
+ const a = args[i];
429
+ if (a === "-o" || a === "--out") out.o = args[++i];
430
+ else if (a === "--data") out.data = args[++i];
431
+ else if (a === "--config") out.config = args[++i];
432
+ else if (a === "--release") out.release = true;
433
+ else if (a.startsWith("-")) fail(`volaro build: unknown option ${a}`);
434
+ else if (!out.source) out.source = a;
435
+ else fail(`volaro build: unexpected argument ${a}`);
436
+ }
437
+ return out;
438
+ }
439
+
440
+ function resolveEntry(explicit) {
441
+ if (explicit) {
442
+ if (!existsSync(explicit)) fail(`${CLI}: no such file: ${explicit}`);
443
+ return resolvePath(explicit);
444
+ }
445
+ for (const c of ["app.vl", join("src", "app.vl")]) {
446
+ if (existsSync(c)) return resolvePath(c);
447
+ }
448
+ fail(
449
+ "volaro: no entry file given and no app.vl found in this directory.\n" +
450
+ "Pass one explicitly: volaro build path/to/app.vl",
451
+ );
452
+ }
453
+
454
+ function cmdBuild(args) {
455
+ const parsed = parseBenchmarkArgs(args, "build");
456
+ const b = parseBuildArgs(parsed.args);
457
+ const source = resolveEntry(b.source);
458
+ const outDir = b.o ? resolvePath(b.o) : resolvePath("build");
459
+ const pyArgs = [source, "-o", outDir];
460
+ if (b.data) pyArgs.push("--data", resolvePath(b.data));
461
+ if (b.config) pyArgs.push("--config", resolvePath(b.config));
462
+ if (b.release) pyArgs.push("--release");
463
+ runPython(VLBUILD_PP, "vlbuild", pyArgs, parsed.benchmark, "build");
464
+ }
465
+
466
+ // Synchronous single build for `volaro dev`; returns { ok, server } or exits on env error.
467
+ function buildOnce(source, outDir, extra = []) {
468
+ const r = pyCapture(VLBUILD_PP, "vlbuild", [source, "-o", outDir, ...extra]);
469
+ if (r.error) fail(`${CLI}: failed to start Python: ${r.error.message}`);
470
+ if (r.stdout) process.stdout.write(r.stdout);
471
+ if (r.stderr) process.stderr.write(r.stderr);
472
+ return { ok: r.status === 0, server: existsSync(join(outDir, "server.js")) };
473
+ }
474
+
475
+ const MIME = {
476
+ ".html": "text/html; charset=utf-8",
477
+ ".js": "text/javascript; charset=utf-8",
478
+ ".css": "text/css; charset=utf-8",
479
+ ".json": "application/json; charset=utf-8",
480
+ ".svg": "image/svg+xml",
481
+ ".png": "image/png",
482
+ ".jpg": "image/jpeg",
483
+ ".jpeg": "image/jpeg",
484
+ ".webp": "image/webp",
485
+ ".gif": "image/gif",
486
+ ".ico": "image/x-icon",
487
+ ".woff2": "font/woff2",
488
+ ".map": "application/json",
489
+ };
490
+
491
+ function staticServer(dir, port) {
492
+ const base = resolvePath(dir);
493
+ const srv = createServer((req, res) => {
494
+ let rel = decodeURIComponent((req.url || "/").split("?")[0]);
495
+ if (rel.endsWith("/")) rel += "index.html";
496
+ const target = resolvePath(join(base, rel));
497
+ // Contain to `base`: an exact match, or a path that starts with `base` +
498
+ // separator. A bare `startsWith(base)` would also accept a sibling like
499
+ // `<base>-notes/…`.
500
+ if (target !== base && !target.startsWith(base + sep)) {
501
+ res.writeHead(403).end("forbidden");
502
+ return;
503
+ }
504
+ if (!existsSync(target) || statSync(target).isDirectory()) {
505
+ res.writeHead(404, { "content-type": "text/html; charset=utf-8" });
506
+ res.end("<!doctype html><meta charset=utf-8><title>404</title><p>Not found. The dev server serves the built bundle in <code>" + dir + "</code>.");
507
+ return;
508
+ }
509
+ res.writeHead(200, { "content-type": MIME[extname(target)] || "application/octet-stream", "cache-control": "no-store" });
510
+ res.end(readFileSync(target));
511
+ });
512
+ srv.on("error", (e) => {
513
+ if (e.code === "EADDRINUSE") fail(`volaro dev: port ${port} is already in use. Pass --port <n>.`);
514
+ fail(`volaro dev: ${e.message}`);
515
+ });
516
+ srv.listen(port, "127.0.0.1");
517
+ return srv;
518
+ }
519
+
520
+ // Snapshot every .vl file under dir (bounded depth) as path -> "mtime:size".
521
+ // Polling beats fs.watch here: it is identical across platforms and editors
522
+ // (rename-replace, truncate-write and append all show up), which fs.watch is
523
+ // not, and the cost is trivial for a project-sized tree.
524
+ function scanVl(dir, depth = 6, acc = new Map(), excluded = null) {
525
+ let entries;
526
+ try {
527
+ entries = readdirSync(dir, { withFileTypes: true });
528
+ } catch {
529
+ return acc;
530
+ }
531
+ for (const e of entries) {
532
+ if (e.name === "node_modules" || e.name === ".git" || e.name.startsWith(".")) continue;
533
+ const p = join(dir, e.name);
534
+ if (resolvePath(p) === excluded) continue;
535
+ if (e.isDirectory()) {
536
+ if (depth > 0) scanVl(p, depth - 1, acc, excluded);
537
+ } else if (extname(e.name) === ".vl") {
538
+ try {
539
+ const s = statSync(p);
540
+ acc.set(p, `${s.mtimeMs}:${s.size}`);
541
+ } catch {}
542
+ }
543
+ }
544
+ return acc;
545
+ }
546
+
547
+ function snapEqual(a, b) {
548
+ if (a.size !== b.size) return false;
549
+ for (const [k, v] of a) if (b.get(k) !== v) return false;
550
+ return true;
551
+ }
552
+
553
+ // Poll watchDir for .vl changes and call onChange (debounced by the interval).
554
+ // Returns a close() that stops polling.
555
+ function watchVl(watchDir, onChange, outDir) {
556
+ const snapshot = () => {
557
+ const acc = scanVl(watchDir, 6, new Map(), resolvePath(outDir));
558
+ const scanAssets = (dir) => {
559
+ try {
560
+ const s = lstatSync(dir);
561
+ acc.set(dir, `${s.mtimeMs}:${s.ctimeMs}:${s.size}`);
562
+ if (s.isDirectory() && !s.isSymbolicLink()) {
563
+ for (const name of readdirSync(dir)) scanAssets(join(dir, name));
564
+ }
565
+ } catch {}
566
+ };
567
+ scanAssets(join(watchDir, "assets"));
568
+ return acc;
569
+ };
570
+ let prev = snapshot();
571
+ const iv = setInterval(() => {
572
+ const now = snapshot();
573
+ if (!snapEqual(prev, now)) {
574
+ prev = now;
575
+ onChange();
576
+ }
577
+ }, 250);
578
+ iv.unref();
579
+ return () => clearInterval(iv);
580
+ }
581
+
582
+ function cmdDev(args) {
583
+ let source = null,
584
+ port = 5173,
585
+ outDir = null;
586
+ for (let i = 0; i < args.length; i++) {
587
+ const a = args[i];
588
+ if (a === "--port" || a === "-p") port = Number(args[++i]);
589
+ else if (a === "-o" || a === "--out") outDir = args[++i];
590
+ else if (a.startsWith("-")) fail(`volaro dev: unknown option ${a}`);
591
+ else if (!source) source = a;
592
+ }
593
+ if (!Number.isInteger(port) || port < 1 || port > 65535) fail("volaro dev: --port must be 1..65535");
594
+ const entry = resolveEntry(source);
595
+ const out = outDir ? resolvePath(outDir) : resolvePath("build");
596
+ // A project build's entry is the project directory itself (`volaro dev .`);
597
+ // watching its dirname would watch the PARENT directory instead.
598
+ const watchDir = statSync(entry).isDirectory() ? entry : dirname(entry);
599
+
600
+ process.stdout.write(`volaro dev: building ${relative(process.cwd(), entry) || entry}\n`);
601
+ const first = buildOnce(entry, out);
602
+ if (!first.ok) fail("volaro dev: initial build failed. Fix the errors above and re-run.");
603
+
604
+ let shuttingDown = false;
605
+ let stopWatch = () => {};
606
+
607
+ // ---- full-stack: run server.js, restart it on a successful rebuild ----
608
+ if (first.server) {
609
+ if (!nodeCanRunServer()) {
610
+ fail(
611
+ `volaro dev: this app builds a full-stack server (server.js), which needs Node ` +
612
+ `${MIN_NODE_FOR_SERVER.join(".")}+ for node:sqlite. You have ${process.versions.node}.`,
613
+ );
614
+ }
615
+ const serverPath = join(out, "server.js");
616
+ let child = null;
617
+ let restarting = false;
618
+
619
+ const startServer = () => {
620
+ child = spawn(process.execPath, [serverPath], {
621
+ stdio: "inherit",
622
+ env: { ...process.env, PORT: String(port) },
623
+ });
624
+ child.on("exit", (code, signal) => {
625
+ if (shuttingDown || restarting) return;
626
+ // the server died on its own (e.g. a port clash it reported to stderr)
627
+ stopWatch();
628
+ process.exit(signal ? 1 : code ?? 0);
629
+ });
630
+ };
631
+
632
+ const restartServer = () =>
633
+ new Promise((done) => {
634
+ if (!child) return done();
635
+ restarting = true;
636
+ child.once("exit", () => {
637
+ restarting = false;
638
+ done();
639
+ });
640
+ child.kill("SIGTERM");
641
+ });
642
+
643
+ process.stdout.write(`volaro dev: full-stack app — starting ${relative(process.cwd(), serverPath) || serverPath}\n`);
644
+ startServer();
645
+
646
+ stopWatch = watchVl(watchDir, async () => {
647
+ process.stdout.write("volaro dev: change detected — rebuilding\n");
648
+ const r = buildOnce(entry, out);
649
+ if (!r.ok) {
650
+ process.stdout.write("volaro dev: build failed — server left running on the last good build\n");
651
+ return;
652
+ }
653
+ await restartServer();
654
+ startServer();
655
+ process.stdout.write("volaro dev: server restarted\n");
656
+ }, out);
657
+
658
+ const bye = () => {
659
+ if (shuttingDown) return;
660
+ shuttingDown = true;
661
+ stopWatch();
662
+ if (child) child.kill("SIGTERM");
663
+ setTimeout(() => {
664
+ if (child) child.kill("SIGKILL");
665
+ process.exit(0);
666
+ }, 2000).unref();
667
+ if (child) child.once("exit", () => process.exit(0));
668
+ else process.exit(0);
669
+ };
670
+ process.on("SIGINT", bye);
671
+ process.on("SIGTERM", bye);
672
+ return;
673
+ }
674
+
675
+ // ---- static single-page: serve the bundle, rebuild on change ----
676
+ const srv = staticServer(out, port);
677
+ process.stdout.write(`volaro dev: serving http://127.0.0.1:${port} (Ctrl+C to stop)\n`);
678
+
679
+ stopWatch = watchVl(watchDir, () => {
680
+ process.stdout.write("volaro dev: change detected — rebuilding\n");
681
+ const r = buildOnce(entry, out);
682
+ process.stdout.write(r.ok ? "volaro dev: rebuilt\n" : "volaro dev: build failed — keeping last good bundle\n");
683
+ }, out);
684
+
685
+ const bye = () => {
686
+ if (shuttingDown) return;
687
+ shuttingDown = true;
688
+ stopWatch();
689
+ srv.close(() => process.exit(0));
690
+ setTimeout(() => process.exit(0), 1000).unref();
691
+ };
692
+ process.on("SIGINT", bye);
693
+ process.on("SIGTERM", bye);
694
+ }
695
+
696
+ function cmdReference(which) {
697
+ if (which === "spec") {
698
+ // `volaro-language-spec.md` is the whole-language DESIGN — a superset of
699
+ // what this build accepts. `supported.md` is the version-specific
700
+ // shipped-feature guide; `crib.md` is the authoring reference.
701
+ process.stderr.write(
702
+ `note: \`${CLI} spec\` is the full language design — a SUPERSET of this build.\n` +
703
+ ` \`${CLI} supported\` is what version ${pkg.version} actually accepts;\n` +
704
+ ` \`${CLI} crib\` is the authoring reference.\n\n`,
705
+ );
706
+ }
707
+ const file = which === "supported" ? "supported" : which;
708
+ // `supported.md`'s own title carries this build's exact version as a
709
+ // `{{VERSION}}` placeholder rather than a hardcoded string -- found stale
710
+ // (still reading a prior release's version after a version bump, since
711
+ // nothing kept it in sync) in the Test_003 benchmark's packaged-docs
712
+ // finding. Substituted from `package.json` at print time so it can never
713
+ // drift again, the same way the "supported"-vs-"spec" note above already
714
+ // reads `pkg.version` live rather than a copy-pasted string.
715
+ process.stdout.write(read(`language/${file}.md`).replace("{{VERSION}}", pkg.version));
716
+ }
717
+
718
+ function cmdExample(name) {
719
+ if (name === "sensors" || name === "station") process.stdout.write(read(`examples/${name}.vl`));
720
+ else fail("usage: volaro example sensors|station");
721
+ }
722
+
723
+ function cmdRoutes(args) {
724
+ let json = false;
725
+ let project = null;
726
+ for (const a of args) {
727
+ if (a === "--json") json = true;
728
+ else if (a.startsWith("-")) fail(`usage: volaro routes [project] [--json]`, 2);
729
+ else if (!project) project = a;
730
+ else fail(`usage: volaro routes [project] [--json]`, 2);
731
+ }
732
+ const pyArgs = [resolvePath(project || ".")];
733
+ if (json) pyArgs.push("--json");
734
+ runPython(VALIDATOR_PP, "vlcheck.routes_cli", pyArgs);
735
+ }
736
+
737
+ const HELP = `Volaro ${pkg.version} — an application language for AI authoring and human review.
738
+
739
+ USAGE
740
+ volaro <command> [args] (installed as both \`volaro\` and \`vl\`;
741
+ \`volaro\` is the canonical name)
742
+
743
+ COMMANDS
744
+ check <path>... Validate .vl source. Runs the full resolver, not just
745
+ a structural pass. Exit non-zero on any error.
746
+ --benchmark-log <file> append raw timing records as JSONL
747
+ --benchmark-run <id> group attempts into one clean/recovery trial
748
+ build <file.vl> Transpile to a runnable bundle (default: ./build).
749
+ -o <dir> output directory
750
+ --data <file> data.json to bundle
751
+ --release fail on unresolved placeholders (e.g. img alt_todo:)
752
+ and ship the minified runtime
753
+ --benchmark-log <file> append raw timing records as JSONL
754
+ --benchmark-run <id> group attempts into one clean/recovery trial
755
+ dev [file.vl] Build, serve on http://127.0.0.1:5173, and rebuild
756
+ when a .vl file changes. Defaults to ./app.vl. A
757
+ full-stack build runs server.js instead (Node 22.5+)
758
+ and restarts it after each successful rebuild.
759
+ --port <n> dev server port (passed to server.js as $PORT)
760
+ routes [project] Print discovered browser pages (spec §8.15: URL,
761
+ source, layout chain, boundaries) and the
762
+ page-server route manifest (spec §10.2: final URL,
763
+ method, source file, parameter types, generated
764
+ client member, authorization). Project defaults to
765
+ ".". Exits non-zero on any collision or project-
766
+ validity error (page/route or layout-composition).
767
+ --json emit the language-neutral manifest as JSON
768
+ crib The authoring reference (write from this).
769
+ supported What version ${pkg.version} actually accepts.
770
+ spec The full language design (a SUPERSET of this build).
771
+ example <name> Print a worked example: sensors | station.
772
+ version Print the version.
773
+ help This message.
774
+
775
+ The compiler is Python and ships inside this package. Set VOLARO_PYTHON to
776
+ choose the interpreter if \`python3\` is not the one you want.
777
+
778
+ SHIPPED SUBSET: single page (no router); \`input\` (full type matrix incl.
779
+ checkbox/radio), \`textarea\`, \`select\`/\`option\`/\`optgroup\`,
780
+ \`fieldset\`/\`legend\`, \`datalist\`, \`button\` (a toggle is \`button pressed:\`,
781
+ aria-pressed; no switch role), \`link\` (no standalone \`label\`); \`if\` as an expression is
782
+ binary; \`match\` is a statement; a module-level \`fn\` cannot be called from a
783
+ view. \`volaro crib\` + \`volaro supported\` are authoritative for this build;
784
+ \`volaro spec\` is the wider design, not what it accepts.
785
+
786
+ Run commands via your project's \`npm run …\` scripts or \`npx --no-install
787
+ volaro …\` inside the project. A bare \`npx volaro …\` from elsewhere may fetch
788
+ the wrong thing.
789
+
790
+ TO AN AI AGENT: run \`volaro crib\` and load its output — that is the complete
791
+ authoring reference. Then write source, run \`volaro check\`, and repair from the
792
+ diagnostics until it is clean.`;
793
+
794
+ // ---- dispatch ------------------------------------------------------------
795
+
796
+ const [cmd, ...rest] = process.argv.slice(2);
797
+ switch (cmd) {
798
+ case "check":
799
+ cmdCheck(rest);
800
+ break;
801
+ case "build":
802
+ cmdBuild(rest);
803
+ break;
804
+ case "dev":
805
+ cmdDev(rest);
806
+ break;
807
+ case "routes":
808
+ cmdRoutes(rest);
809
+ break;
810
+ case "crib":
811
+ cmdReference("crib");
812
+ break;
813
+ case "spec":
814
+ cmdReference("spec");
815
+ break;
816
+ case "supported":
817
+ cmdReference("supported");
818
+ break;
819
+ case "example":
820
+ cmdExample(rest[0]);
821
+ break;
822
+ case "version":
823
+ case "--version":
824
+ case "-v":
825
+ process.stdout.write(pkg.version + "\n");
826
+ break;
827
+ case undefined:
828
+ case "help":
829
+ case "--help":
830
+ case "-h":
831
+ process.stdout.write(HELP + "\n");
832
+ break;
833
+ default:
834
+ fail(`${CLI}: unknown command "${cmd}". Run \`${CLI} help\`.`, 2);
34
835
  }