nomen-lang 0.0.6 → 0.0.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nomen-lang",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "description": "The CLI for the Nomen programming language.",
5
5
  "keywords": [],
6
6
  "license": "ISC",
@@ -0,0 +1,113 @@
1
+ // Template for one benchmark's timing loop. The CLI substitutes __NAME__,
2
+ // __TARGET__ and __N__ and concatenates this after the test file's source
3
+ // (so it is user code and can call the test file's functions). Kept as a
4
+ // separate .nm file (not a TS template literal) because Nomen's generic syntax
5
+ // (List<int>) is not valid TypeScript and would break the CLI's own build.
6
+ //
7
+ // Placeholders use the leading-AND-trailing `__NAME__` convention so they don't
8
+ // collide with the `__w` / `__i` / `__samples` locals (leading-only).
9
+ //
10
+ // min / max / sum / sum-of-squares are tracked as running aggregates during
11
+ // sampling, so mean and stddev need no `List.at` calls at all. Only the median
12
+ // needs sorted data, so the samples are insertion-sorted afterwards. Every
13
+ // `List.at` / `List.set` is guarded by `idx >= 0 && idx < __samples.length` —
14
+ // the exact bound `List.at`'s constraint requires — because the constraint
15
+ // checker treats an unverifiable index as an out-of-bounds risk (the backends
16
+ // emit unchecked strided loads).
17
+ func bench_loop___NAME__ = (ref Tester t) {
18
+ // Warm up so the first samples don't pay for cold caches / lazy binding.
19
+ var int __w = 0
20
+ while __w < 8 {
21
+ __TARGET__()
22
+ __w += 1
23
+ }
24
+ // Collect __N__ samples, tracking running aggregates for every statistic
25
+ // except the median (which needs sorted data).
26
+ var List<int> __samples = List<int>()
27
+ var int __sum = 0
28
+ var float __sum_sq = 0.0
29
+ var int __min = 0
30
+ var int __max = 0
31
+ var int __i = 0
32
+ while __i < __N__ {
33
+ const uint64 __t0 = Time.now_ns()
34
+ __TARGET__()
35
+ const uint64 __t1 = Time.now_ns()
36
+ const int __dt = (__t1 - __t0) as int
37
+ __samples.push(__dt)
38
+ __sum += __dt
39
+ __sum_sq = __sum_sq + (__dt as float) * (__dt as float)
40
+ if __i == 0 {
41
+ __min = __dt
42
+ __max = __dt
43
+ }
44
+ if __i > 0 {
45
+ if __dt < __min {
46
+ __min = __dt
47
+ }
48
+ if __dt > __max {
49
+ __max = __dt
50
+ }
51
+ }
52
+ __i += 1
53
+ }
54
+ const int __n = __samples.length
55
+ if __n > 0 {
56
+ const float __fn = __n as float
57
+ const float __mean = (__sum as float) / __fn
58
+ var float __variance = __sum_sq / __fn - __mean * __mean
59
+ if __variance < 0.0 {
60
+ __variance = 0.0
61
+ }
62
+ const float __stddev = Math.sqrt(__variance)
63
+ // Insertion sort the samples so we can pick a median. `length` is
64
+ // stable throughout (only `set` mutates), so every access is guarded
65
+ // against `__samples.length` directly.
66
+ var int __s = 1
67
+ while __s < __n {
68
+ if __s >= 0 && __s < __samples.length {
69
+ const int __v = __samples.at(__s)
70
+ var int __j = __s
71
+ var bool __more = true
72
+ while __more {
73
+ var int __pm1 = __j - 1
74
+ if __pm1 >= 0 && __pm1 < __samples.length {
75
+ const int __prev = __samples.at(__pm1)
76
+ if __prev > __v {
77
+ if __j >= 0 && __j < __samples.length {
78
+ __samples.set(__j, __prev)
79
+ }
80
+ __j -= 1
81
+ } else {
82
+ __more = false
83
+ }
84
+ } else {
85
+ __more = false
86
+ }
87
+ }
88
+ if __j >= 0 && __j < __samples.length {
89
+ __samples.set(__j, __v)
90
+ }
91
+ }
92
+ __s += 1
93
+ }
94
+ // Median of the sorted samples. Indices are precomputed into locals so
95
+ // the guards are simple variable bounds.
96
+ var int __median = __min
97
+ var int __hi = __n / 2
98
+ if __hi >= 0 && __hi < __samples.length {
99
+ __median = __samples.at(__hi)
100
+ }
101
+ if __n % 2 == 0 {
102
+ var int __lo = __n / 2 - 1
103
+ if __lo >= 0 && __lo < __samples.length {
104
+ if __hi >= 0 && __hi < __samples.length {
105
+ var int __a = __samples.at(__lo)
106
+ var int __b = __samples.at(__hi)
107
+ __median = (__a + __b) / 2
108
+ }
109
+ }
110
+ }
111
+ t.record_bench(t.bench_label, __n, __min, __median, __max, __mean, __stddev)
112
+ }
113
+ }
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ import { get_library } from "../../src/lib.ts";
14
14
  import parse from "../../src/parse.ts";
15
15
  import { run_docs } from "./docs.ts";
16
16
  import render_errors, { render_warnings } from "./format_errors.ts";
17
+ import { runTests } from "./test.ts";
17
18
  import type Config from "./types/Config.ts";
18
19
 
19
20
  const SUPPORTED_EXTENSION = ".nm";
@@ -86,6 +87,7 @@ const parser = yargs(hideBin(process.argv))
86
87
  .command("check", "Parse and check only")
87
88
  .command("format", "Reformat every .nm file")
88
89
  .command("docs", "Generate markdown documentation")
90
+ .command("test", "Discover and run *.test.nm files with the Tester harness")
89
91
  .option("in", {
90
92
  alias: "i",
91
93
  describe: "Input file or folder",
@@ -106,6 +108,11 @@ const parser = yargs(hideBin(process.argv))
106
108
  describe: "Whether to watch for file changes",
107
109
  type: "boolean",
108
110
  })
111
+ .option("filter", {
112
+ alias: "f",
113
+ describe: "Only run test files whose path matches this regex",
114
+ type: "string",
115
+ })
109
116
  .option("arch", {
110
117
  alias: "a",
111
118
  describe: "Target architecture (aarch64 or c)",
@@ -147,6 +154,16 @@ try {
147
154
  process.exit(0);
148
155
  }
149
156
 
157
+ // `nomen test` discovers and runs every `*.test.nm` file under --in (or
158
+ // the cwd), compiling each into a Tester harness and reporting results.
159
+ if (command === "test") {
160
+ const root = options.in ?? process.cwd();
161
+ const filter = typeof options.filter === "string" ? new RegExp(options.filter) : undefined;
162
+ const arch = (options.arch as string | undefined) ?? "aarch64";
163
+ const ok = runTests(root, { arch, filter });
164
+ process.exit(ok ? 0 : 1);
165
+ }
166
+
150
167
  // `nomen format` re-indents and tidies every .nm file under a folder.
151
168
  if (command === "format") {
152
169
  const root = options.in ?? process.cwd();
package/src/test.ts ADDED
@@ -0,0 +1,518 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ import build from "../../src/build.ts";
7
+ import join from "../../src/join.ts";
8
+ import { get_library } from "../../src/lib.ts";
9
+ import parse from "../../src/parse.ts";
10
+
11
+ const RECORD_PREFIX = "\\nomen|";
12
+
13
+ // A discovered test function: `pub func <name> = (ref Tester t)`.
14
+ export interface TestFunction {
15
+ name: string;
16
+ }
17
+
18
+ // A discovered benchmark: `pub func <name> = (ref Tester t)` whose body calls
19
+ // `t.bench(label, fn)` (or `t.bench_n(...)`). `fn` is the function the harness
20
+ // must time.
21
+ export interface BenchFunction {
22
+ name: string;
23
+ // The function reference passed to `t.bench`/`t.bench_n`, e.g. `add_once`.
24
+ target: string;
25
+ // The label string passed to `t.bench`.
26
+ label: string;
27
+ // Explicit sample count from `bench_n`, or undefined for the default.
28
+ samples?: number;
29
+ }
30
+
31
+ export interface TestFileResult {
32
+ file: string;
33
+ ok: boolean;
34
+ tests: TestRecord[];
35
+ fails: FailRecord[];
36
+ benches: BenchRecord[];
37
+ other: string[];
38
+ // Set when the compiled binary crashed before finishing (e.g. a segfault
39
+ // in a test). We still surface whatever records it managed to emit.
40
+ crashed?: string;
41
+ ms: number;
42
+ }
43
+
44
+ interface TestRecord {
45
+ name: string;
46
+ passed: number;
47
+ failed: number;
48
+ ns: number;
49
+ }
50
+
51
+ interface FailRecord {
52
+ test: string;
53
+ message: string;
54
+ }
55
+
56
+ interface BenchRecord {
57
+ label: string;
58
+ n: number;
59
+ min: number;
60
+ median: number;
61
+ max: number;
62
+ mean: number;
63
+ stddev: number;
64
+ }
65
+
66
+ /** Recursively collect `*.test.nm` files under `root`, skipping build output. */
67
+ export function collect_test_files(root: string): string[] {
68
+ const out: string[] = [];
69
+ const walk = (dir: string) => {
70
+ let entries: fs.Dirent[];
71
+ try {
72
+ entries = fs.readdirSync(dir, { withFileTypes: true });
73
+ } catch {
74
+ return;
75
+ }
76
+ for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
77
+ const full = path.join(dir, entry.name);
78
+ if (entry.isDirectory()) {
79
+ if (entry.name === "build" || entry.name === "node_modules") continue;
80
+ walk(full);
81
+ } else if (entry.name.endsWith(".test.nm")) {
82
+ out.push(full);
83
+ }
84
+ }
85
+ };
86
+ walk(root);
87
+ return out;
88
+ }
89
+
90
+ /** Pull `pub func <name> = (ref Tester t)` declarations out of source text. */
91
+ export function extract_test_functions(source: string): TestFunction[] {
92
+ const tests: TestFunction[] = [];
93
+ // `pub func NAME = (ref Tester t)` — Tester may be passed by value too,
94
+ // but `ref` is required for mutation, so accept either.
95
+ const re = /pub\s+func\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\(\s*(?:ref\s+)?Tester\s+t\s*\)/g;
96
+ let m: RegExpExecArray | null;
97
+ while ((m = re.exec(source))) {
98
+ tests.push({ name: m[1] });
99
+ }
100
+ return tests;
101
+ }
102
+
103
+ /**
104
+ * Find benchmark functions: test functions whose body calls `t.bench(...)` or
105
+ * `t.bench_n(...)`. Returns the target function (the thing to time) and label.
106
+ */
107
+ export function extract_bench_functions(source: string): BenchFunction[] {
108
+ const benches: BenchFunction[] = [];
109
+ const testRe = /pub\s+func\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\(\s*(?:ref\s+)?Tester\s+t\s*\)/g;
110
+ let m: RegExpExecArray | null;
111
+ while ((m = testRe.exec(source))) {
112
+ const name = m[1];
113
+ const bodyStart = testRe.lastIndex;
114
+ // Find the matching closing brace by counting depth.
115
+ let depth = 0;
116
+ let i = bodyStart;
117
+ let inStr: string | null = null;
118
+ let escaped = false;
119
+ for (; i < source.length; i++) {
120
+ const ch = source[i];
121
+ if (inStr) {
122
+ if (escaped) escaped = false;
123
+ else if (ch === "\\") escaped = true;
124
+ else if (ch === inStr) inStr = null;
125
+ continue;
126
+ }
127
+ if (ch === '"' || ch === "'") inStr = ch;
128
+ else if (ch === "{") depth++;
129
+ else if (ch === "}") {
130
+ depth--;
131
+ if (depth === 0) break;
132
+ }
133
+ }
134
+ const body = source.slice(bodyStart, i);
135
+ // `t.bench("label", fn)` or `t.bench_n("label", fn, 123)`
136
+ const benchRe =
137
+ /\bt\.bench(?:_n)?\s*\(\s*"([^"]*)"\s*,\s*([A-Za-z_][A-Za-z0-9_]*)\s*(?:,\s*(\d+)\s*)?\)/;
138
+ const bm = body.match(benchRe);
139
+ if (bm) {
140
+ benches.push({
141
+ name,
142
+ label: bm[1],
143
+ target: bm[2],
144
+ samples: bm[3] ? parseInt(bm[3], 10) : undefined,
145
+ });
146
+ }
147
+ }
148
+ return benches;
149
+ }
150
+
151
+ function escape_nm_string(s: string): string {
152
+ return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
153
+ }
154
+
155
+ /** Build the `main` + per-benchmark timing harness for one test file. */
156
+ export function generate_harness(tests: TestFunction[], benches: BenchFunction[]): string {
157
+ // A function that is also a benchmark must not run twice — exclude bench
158
+ // functions from the plain test list (they're driven by the bench loop).
159
+ const benchNames = new Set(benches.map((b) => b.name));
160
+ tests = tests.filter((t) => !benchNames.has(t.name));
161
+
162
+ const templatePath = path.join(
163
+ path.dirname(fileURLToPath(import.meta.url)),
164
+ "..",
165
+ "src",
166
+ "bench_loop.nm",
167
+ );
168
+ const template = fs.readFileSync(templatePath, "utf8");
169
+
170
+ const benchLoops = benches
171
+ .map((b) => {
172
+ // Clamp the sample count: insertion-sorting the results is O(n²),
173
+ // so an absurd `bench_n` would dominate the suite. 4096 keeps the
174
+ // sort bounded while leaving plenty of samples for stable stats.
175
+ const raw = b.samples && b.samples > 0 ? b.samples : 1000;
176
+ const n = Math.min(raw, 4096);
177
+ return template
178
+ .replace(/__NAME__/g, b.name)
179
+ .replace(/__TARGET__/g, b.target)
180
+ .replace(/__N__/g, String(n));
181
+ })
182
+ .join("\n");
183
+
184
+ let main = "\nimport System\nimport System/Test\n\npub func main = () {\n";
185
+ main += "\tvar Tester t = Tester()\n";
186
+ for (const test of tests) {
187
+ main += `\tt.begin_test("${escape_nm_string(test.name)}")\n`;
188
+ main += `\t${test.name}(ref t)\n`;
189
+ main += `\tt.end_test()\n`;
190
+ }
191
+ for (const bench of benches) {
192
+ // Bench functions run after every test, so reset the per-test failure
193
+ // flag (otherwise a prior test's failure would make `t.bench` a no-op)
194
+ // and any leftover `bench_pending` from an earlier bench.
195
+ main += `\tt.has_failed = false\n`;
196
+ main += `\tt.bench_pending = false\n`;
197
+ main += `\t${bench.name}(ref t)\n`;
198
+ main += `\tif t.bench_pending {\n`;
199
+ main += `\t\tbench_loop_${bench.name}(ref t)\n`;
200
+ main += `\t}\n`;
201
+ }
202
+ main += "}\n";
203
+ return benchLoops + main;
204
+ }
205
+
206
+ interface RunRecord {
207
+ tests: TestRecord[];
208
+ fails: FailRecord[];
209
+ benches: BenchRecord[];
210
+ other: string[];
211
+ }
212
+
213
+ /** Parse the machine-readable records out of a test binary's stdout. */
214
+ export function parse_records(stdout: string): RunRecord {
215
+ const tests: TestRecord[] = [];
216
+ const fails: FailRecord[] = [];
217
+ const benches: BenchRecord[] = [];
218
+ const other: string[] = [];
219
+ for (const line of stdout.split("\n")) {
220
+ if (!line.startsWith(RECORD_PREFIX)) {
221
+ if (line.length) other.push(line);
222
+ continue;
223
+ }
224
+ const parts = line.slice(RECORD_PREFIX.length).split("|");
225
+ const kind = parts[0];
226
+ if (kind === "start") {
227
+ // no-op; the `done` record carries the result
228
+ } else if (kind === "done") {
229
+ tests.push({
230
+ name: parts[1],
231
+ passed: parseInt(parts[2] || "0", 10),
232
+ failed: parseInt(parts[3] || "0", 10),
233
+ ns: parseInt(parts[4] || "0", 10),
234
+ });
235
+ } else if (kind === "fail") {
236
+ fails.push({ test: parts[1], message: parts.slice(2).join("|") });
237
+ } else if (kind === "bench") {
238
+ benches.push({
239
+ label: parts[1],
240
+ n: parseInt(parts[2] || "0", 10),
241
+ min: parseInt(parts[3] || "0", 10),
242
+ median: parseInt(parts[4] || "0", 10),
243
+ max: parseInt(parts[5] || "0", 10),
244
+ mean: parseFloat(parts[6] || "0"),
245
+ stddev: parseFloat(parts[7] || "0"),
246
+ });
247
+ }
248
+ }
249
+ return { tests, fails, benches, other };
250
+ }
251
+
252
+ /**
253
+ * Compile one `*.test.nm` file (with the generated harness) and run it,
254
+ * returning its parsed records and any crash info.
255
+ */
256
+ export function run_test_file(
257
+ entry_path: string,
258
+ lib_path: string | undefined,
259
+ arch: string,
260
+ ): TestFileResult {
261
+ const start = performance.now();
262
+ const source_text = fs.readFileSync(entry_path, "utf8");
263
+ const tests = extract_test_functions(source_text);
264
+ const benches = extract_bench_functions(source_text);
265
+ const harness = generate_harness(tests, benches);
266
+
267
+ const result: TestFileResult = {
268
+ file: entry_path,
269
+ ok: true,
270
+ tests: [],
271
+ fails: [],
272
+ benches: [],
273
+ other: [],
274
+ ms: 0,
275
+ };
276
+
277
+ const resolved = path.resolve(entry_path);
278
+ const input = join(resolved, lib_path);
279
+ const library = lib_path ? get_library(lib_path) : undefined;
280
+ // `harness` carries `import System`, so `parse` will resolve the library
281
+ // types (Tester, Buffer, Time, Math) and append them. The user source is
282
+ // already concatenated into `input` via `join`, which strips its `import`
283
+ // lines — `harness` re-supplies it so library resolution still triggers.
284
+ const source = input + "\n" + harness;
285
+ const parsed = parse(source, library, resolved);
286
+ if (parsed.errors.length) {
287
+ result.ok = false;
288
+ result.crashed = parsed.errors
289
+ .map((e) => `${e.message} (${e.line ?? "?"}:${e.column ?? "?"})`)
290
+ .join("\n");
291
+ result.ms = performance.now() - start;
292
+ return result;
293
+ }
294
+
295
+ const buildResult = build(parsed.root, { arch: arch as "aarch64" | "c", audit: false });
296
+ if (buildResult.errors && buildResult.errors.length) {
297
+ result.ok = false;
298
+ result.crashed = buildResult.errors
299
+ .map((e) => `${e.message} (${e.line ?? "?"}:${e.column ?? "?"})`)
300
+ .join("\n");
301
+ result.ms = performance.now() - start;
302
+ return result;
303
+ }
304
+
305
+ const buildDir = path.join(path.dirname(resolved), "build");
306
+ if (!fs.existsSync(buildDir)) fs.mkdirSync(buildDir, { recursive: true });
307
+ const ext = arch === "aarch64" ? ".s" : ".c";
308
+ const codefile = path.join(buildDir, path.basename(entry_path, ".nm") + ext);
309
+ const outfile = path.join(buildDir, path.basename(entry_path, ".nm"));
310
+ // The C backend's generated source does `#include "main.h"`, so write the
311
+ // header next to the code. (aarch64 inlines everything into the .s file.)
312
+ fs.writeFileSync(path.join(buildDir, "main.h"), buildResult.headers ?? "");
313
+ fs.writeFileSync(codefile, buildResult.code);
314
+
315
+ // Link the harness binary. The harness uses only Console/Time (printf,
316
+ // clock_gettime), so no platform frameworks are required.
317
+ try {
318
+ execFileSync("clang", ["-o", outfile, codefile], {
319
+ encoding: "utf8",
320
+ stdio: ["ignore", "pipe", "pipe"],
321
+ });
322
+ } catch (err: any) {
323
+ const stderr = err.stderr ? err.stderr.toString() : (err.message ?? "");
324
+ result.ok = false;
325
+ result.crashed = `link failed: ${stderr.trim() || "clang error"}`;
326
+ result.ms = performance.now() - start;
327
+ return result;
328
+ }
329
+
330
+ // Run the binary and collect the records it streams over stdout. A
331
+ // non-zero exit (crash, abort) still surfaces whatever records were
332
+ // emitted before the crash; a timeout is treated as a crash.
333
+ let runStdout = "";
334
+ let crashed: string | undefined;
335
+ try {
336
+ runStdout = execFileSync(outfile, [], {
337
+ encoding: "utf8",
338
+ stdio: ["ignore", "pipe", "pipe"],
339
+ timeout: 30_000,
340
+ maxBuffer: 16 * 1024 * 1024,
341
+ });
342
+ } catch (err: any) {
343
+ runStdout = err.stdout ? err.stdout.toString() : "";
344
+ if (err.signal === "SIGTERM") {
345
+ crashed = "test binary timed out after 30s";
346
+ } else {
347
+ crashed = `test binary exited abnormally (signal ${err.signal ?? err.code})`;
348
+ }
349
+ }
350
+
351
+ const records = parse_records(runStdout);
352
+ result.tests = records.tests;
353
+ result.fails = records.fails;
354
+ result.benches = records.benches;
355
+ result.other = records.other;
356
+ result.crashed = crashed;
357
+ // A file "passes" when it reported no failed asserts and didn't crash.
358
+ const failed = result.fails.length > 0 || result.tests.some((t) => t.failed > 0);
359
+ result.ok = !failed && crashed === undefined;
360
+
361
+ result.ms = performance.now() - start;
362
+ return result;
363
+ }
364
+
365
+ // Format a millisecond duration (as returned by `performance.now()` deltas)
366
+ // as a compact human-readable string.
367
+ function format_duration(ms: number): string {
368
+ if (ms < 1) return `${(ms * 1000).toFixed(0)}µs`;
369
+ if (ms < 1000) return `${ms.toFixed(0)}ms`;
370
+ return `${(ms / 1000).toFixed(2)}s`;
371
+ }
372
+
373
+ function fmt_ns(ns: number): string {
374
+ if (ns < 1000) return `${ns}ns`;
375
+ if (ns < 1e6) return `${(ns / 1000).toFixed(1)}µs`;
376
+ if (ns < 1e9) return `${(ns / 1e6).toFixed(1)}ms`;
377
+ return `${(ns / 1e9).toFixed(2)}s`;
378
+ }
379
+
380
+ const C = {
381
+ green: (s: string) => `\x1b[32m${s}\x1b[0m`,
382
+ red: (s: string) => `\x1b[31m${s}\x1b[0m`,
383
+ dim: (s: string) => `\x1b[2m${s}\x1b[0m`,
384
+ bold: (s: string) => `\x1b[1m${s}\x1b[0m`,
385
+ yellow: (s: string) => `\x1b[33m${s}\x1b[0m`,
386
+ cyan: (s: string) => `\x1b[36m${s}\x1b[0m`,
387
+ };
388
+
389
+ /** Print a vitest-style report for one file's results. */
390
+ export function report_file(result: TestFileResult): void {
391
+ const rel = path.relative(process.cwd(), result.file);
392
+ if (result.crashed && result.tests.length === 0 && result.fails.length === 0) {
393
+ console.log(` ${C.red("✗")} ${rel} ${C.red("(failed to build)")}`);
394
+ console.log(C.red(result.crashed));
395
+ return;
396
+ }
397
+ const totalTests = result.tests.length;
398
+ const mark = result.ok ? C.green("✓") : C.red("✗");
399
+ console.log(
400
+ ` ${mark} ${rel} ${C.dim(`(${totalTests} tests)`)} ${C.dim(format_duration(result.ms))}`,
401
+ );
402
+ if (result.crashed) {
403
+ console.log(C.red(` ${result.crashed}`));
404
+ }
405
+ // Each `fail` record already names its test and carries the message; with
406
+ // short-circuiting a test has at most one failure, so the fail lines are
407
+ // the per-test detail and a separate count line would just repeat names.
408
+ for (const f of result.fails) {
409
+ console.log(` ${C.red("✗")} ${C.bold(f.test)} ${C.dim(">")} ${f.message}`);
410
+ }
411
+ for (const b of result.benches) {
412
+ console.log(
413
+ ` ${C.cyan("⏱")} ${C.bold(b.label)} ${C.dim(`(n=${b.n})`)} ` +
414
+ `${C.dim("min")} ${fmt_ns(b.min)} ${C.dim("median")} ${fmt_ns(b.median)} ` +
415
+ `${C.dim("mean")} ${fmt_ns(Math.round(b.mean))} ${C.dim("max")} ${fmt_ns(b.max)} ` +
416
+ `${C.dim("±")} ${fmt_ns(Math.round(b.stddev))}`,
417
+ );
418
+ }
419
+ if (result.other.length) {
420
+ console.log(C.dim(" --- test stdout ---"));
421
+ for (const line of result.other) console.log(C.dim(` ${line}`));
422
+ }
423
+ }
424
+
425
+ export interface RunTestsOptions {
426
+ arch?: string;
427
+ filter?: RegExp;
428
+ }
429
+
430
+ /** Discover, run, and report every `*.test.nm` under `root`. */
431
+ export function runTests(root: string, options: RunTestsOptions = {}): boolean {
432
+ const arch = options.arch ?? "aarch64";
433
+ const lib = resolve_lib_for(root);
434
+ const files = collect_test_files(root).filter((f) => !options.filter || options.filter.test(f));
435
+
436
+ console.log(`\n~ NOMEN TEST ~ ${files.length} file(s)\n`);
437
+ const startTime = performance.now();
438
+
439
+ const results: TestFileResult[] = [];
440
+ for (const file of files) {
441
+ const result = run_test_file(file, lib, arch);
442
+ report_file(result);
443
+ results.push(result);
444
+ }
445
+
446
+ const elapsed = performance.now() - startTime;
447
+ const totalFiles = results.length;
448
+ // `tests[].failed` (from each test's `done` record) is the authoritative
449
+ // failure count; `result.fails` holds the same failures' messages for
450
+ // display, so don't sum both or failures are double-counted.
451
+ const totalTests = results.reduce((a, r) => a + r.tests.length, 0);
452
+ const totalFailed = results.reduce((a, r) => a + r.tests.reduce((x, t) => x + t.failed, 0), 0);
453
+ const totalPassed = totalTests - totalFailed;
454
+ const anyFailed = results.some((r) => !r.ok);
455
+
456
+ console.log("");
457
+ console.log(
458
+ ` ${C.bold("Files ")} ${totalFiles} ${anyFailed ? C.red("failed") : C.green("passed")} (${totalFiles})`,
459
+ );
460
+ console.log(
461
+ ` ${C.bold("Tests ")} ${totalPassed} ${C.green("passed")}` +
462
+ (totalFailed ? ` | ${C.red(`${totalFailed} failed`)}` : "") +
463
+ ` (${totalTests})`,
464
+ );
465
+ console.log(` ${C.bold("Time ")} ${format_duration(elapsed)}`);
466
+ console.log("");
467
+
468
+ return !anyFailed;
469
+ }
470
+
471
+ // Find the System library for a test run rooted at `root`. Mirrors the CLI's
472
+ // resolve_lib but also checks the repo's `core/System` layout directly.
473
+ function resolve_lib_for(root: string): string | undefined {
474
+ let dir = path.resolve(root);
475
+ // Climb to the filesystem root looking for the System library.
476
+ for (;;) {
477
+ const candidates = [
478
+ path.join(dir, "core", "System", "package.jsonc"),
479
+ path.join(dir, "core", "package.jsonc"),
480
+ path.join(dir, "package.jsonc"),
481
+ ];
482
+ for (const c of candidates) {
483
+ if (fs.existsSync(c)) {
484
+ try {
485
+ const json = fs
486
+ .readFileSync(c, "utf8")
487
+ .replace(/\/\/.*$/gm, "")
488
+ .replace(/\/\*[\s\S]*?\*\//g, "");
489
+ const parsed = JSON.parse(json);
490
+ // Return the package directory (where this package.jsonc
491
+ // lives). `get_library` reads package.jsonc from here and
492
+ // finds sources under `<dir>/src` (a symlink to `System`
493
+ // in this repo); resolving `imports.System` to a subdir
494
+ // would point at the source folder instead, which
495
+ // `get_library` cannot use.
496
+ if (parsed.imports?.System) {
497
+ return path.dirname(c);
498
+ }
499
+ if (fs.existsSync(path.join(path.dirname(c), "System"))) {
500
+ return path.dirname(c);
501
+ }
502
+ } catch {
503
+ // ignore
504
+ }
505
+ }
506
+ }
507
+ const systemDir = path.join(dir, "System");
508
+ if (fs.existsSync(systemDir)) return systemDir;
509
+ const globalSystem = path.join(dir, "core", "System");
510
+ if (fs.existsSync(globalSystem)) {
511
+ return path.join(dir, "core");
512
+ }
513
+ const parent = path.dirname(dir);
514
+ if (parent === dir) break;
515
+ dir = parent;
516
+ }
517
+ return undefined;
518
+ }
Binary file