nomen-lang 0.6.9 → 0.6.11

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.6.9",
3
+ "version": "0.6.11",
4
4
  "description": "The CLI for the Nomen programming language.",
5
5
  "keywords": [],
6
6
  "license": "ISC",
package/src/index.ts CHANGED
@@ -409,7 +409,14 @@ function processFile(filename: string, config: Config, mode: Mode, program_args:
409
409
  // `build` links the executable but does not run it; `run` links and runs.
410
410
  startTime = performance.now();
411
411
 
412
- const audit_obj = config.audit ? compile_audit_runtime(config, resolved, buildDir) : undefined;
412
+ // The audit runtime is linked under --audit, and also whenever the
413
+ // program references it: `Tester.audit_count` (System::Test) has a raw
414
+ // body calling `nomen_audit_count`, so a Tester-using program run without
415
+ // --audit still needs the (inert) counter object at link time.
416
+ const needs_audit_runtime = config.audit || result.code.includes("nomen_audit_count");
417
+ const audit_obj = needs_audit_runtime
418
+ ? compile_audit_runtime(config, resolved, buildDir)
419
+ : undefined;
413
420
  let link_inputs = codefile;
414
421
  if (companionfile) link_inputs += ` ${companionfile}`;
415
422
  if (audit_obj) link_inputs += ` ${audit_obj}`;
package/src/test.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { execFile } from "node:child_process";
1
+ import { execFile, spawn } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
@@ -49,6 +49,10 @@ export interface TestFileResult {
49
49
  // "LEAK: N allocation(s)" lines the audits runtime printed at exit. Empty
50
50
  // unless the file was run with --audit; a non-empty list fails the file.
51
51
  leaks: string[];
52
+ // Per-test leak attribution (from the harness's `\nomen|leaks` records):
53
+ // the tests whose allocation balance grew while they ran. Collected in
54
+ // every run; only --audit fails the file on them.
55
+ leaking_tests: LeakRecord[];
52
56
  // Set when the compiled binary crashed before finishing (e.g. a segfault
53
57
  // in a test). We still surface whatever records it managed to emit.
54
58
  crashed?: string;
@@ -83,6 +87,13 @@ interface BenchRecord {
83
87
  stddev: number;
84
88
  }
85
89
 
90
+ // A `\nomen|leaks|<test>|<n>` record: the test's allocation balance grew by
91
+ // `n` between the harness's counter snapshot and the test's end.
92
+ interface LeakRecord {
93
+ test: string;
94
+ count: number;
95
+ }
96
+
86
97
  /** Recursively collect `*.test.nm` files under `root`, skipping build output. */
87
98
  export function collect_test_files(root: string): string[] {
88
99
  const out: string[] = [];
@@ -201,11 +212,30 @@ export function generate_harness(tests: TestFunction[], benches: BenchFunction[]
201
212
  })
202
213
  .join("\n");
203
214
 
204
- let main = "\nimport System\nimport System::Test\n\npub func main = () {\n";
215
+ let main = "\nimport System\nimport System::Test\n";
216
+ // Per-test leak attribution: the harness snapshots the audit counter
217
+ // (Tester.audit_count — the audit runtime is linked into every test
218
+ // build, see run_test_file) before every test and diffs it after. A
219
+ // positive delta emits a `\nomen|leaks|<test>|<n>` record, which the CLI
220
+ // reports as a leaking test instead of leaving the file's exit-time
221
+ // total unattributed.
222
+ main += "\npub func main = () {\n";
223
+ // Unbuffered stdout: a binary that crashes mid-suite still leaves every
224
+ // record it printed (the CLI reads them from the run's .out file), and a
225
+ // crash is diagnosed with the tests that PASSED before it instead of
226
+ // "(crashed before records)".
227
+ main += `\tConsole.unbuffered()\n`;
205
228
  main += "\tvar Tester t = Tester()\n";
229
+ main += "\tvar int64 audit_mark = 0\n";
230
+ main += "\tvar int64 audit_leaked = 0\n";
206
231
  for (const test of tests) {
232
+ main += `\taudit_mark = Tester.audit_count()\n`;
207
233
  main += `\tt.begin_test("${escape_nm_string(test.name)}")\n`;
208
234
  main += `\t${test.name}(ref t)\n`;
235
+ main += `\taudit_leaked = Tester.audit_count() - audit_mark\n`;
236
+ main += `\tif audit_leaked > 0 {\n`;
237
+ main += `\t\tConsole.write_line("\\\\nomen|leaks|" + "${escape_nm_string(test.name)}" + "|\\{audit_leaked}")\n`;
238
+ main += `\t}\n`;
209
239
  main += `\tt.end_test()\n`;
210
240
  }
211
241
  for (const bench of benches) {
@@ -227,6 +257,7 @@ interface RunRecord {
227
257
  tests: TestRecord[];
228
258
  fails: FailRecord[];
229
259
  benches: BenchRecord[];
260
+ leaks: LeakRecord[];
230
261
  other: string[];
231
262
  }
232
263
 
@@ -235,6 +266,7 @@ export function parse_records(stdout: string): RunRecord {
235
266
  const tests: TestRecord[] = [];
236
267
  const fails: FailRecord[] = [];
237
268
  const benches: BenchRecord[] = [];
269
+ const leaks: LeakRecord[] = [];
238
270
  const other: string[] = [];
239
271
  for (const line of stdout.split("\n")) {
240
272
  if (!line.startsWith(RECORD_PREFIX)) {
@@ -254,6 +286,8 @@ export function parse_records(stdout: string): RunRecord {
254
286
  });
255
287
  } else if (kind === "fail") {
256
288
  fails.push({ test: parts[1], message: parts.slice(2).join("|") });
289
+ } else if (kind === "leaks") {
290
+ leaks.push({ test: parts[1], count: parseInt(parts[2] || "0", 10) });
257
291
  } else if (kind === "bench") {
258
292
  benches.push({
259
293
  label: parts[1],
@@ -266,15 +300,21 @@ export function parse_records(stdout: string): RunRecord {
266
300
  });
267
301
  }
268
302
  }
269
- return { tests, fails, benches, other };
303
+ return { tests, fails, benches, leaks, other };
270
304
  }
271
305
 
272
- /** Pull the exit-time audit "LEAK: N allocation(s)" lines out of stdout. */
306
+ /**
307
+ * Pull the exit-time audit lines out of stdout: "LEAK: N allocation(s)" (a
308
+ * nonzero malloc/free balance) and "AUDIT-STALE-FREE: N pointer(s) freed that
309
+ * were not live audit allocations" (a free that matched no live block — a
310
+ * foreign pointer or a double free; the runtime no longer passes those to
311
+ * libc free, so this is the only record they leave). Both fail the file.
312
+ */
273
313
  export function extract_leaks(stdout: string): string[] {
274
314
  return stdout
275
315
  .split("\n")
276
316
  .map((l) => l.trim())
277
- .filter((l) => l.startsWith("LEAK:"));
317
+ .filter((l) => l.startsWith("LEAK:") || l.startsWith("AUDIT-STALE-FREE:"));
278
318
  }
279
319
 
280
320
  /**
@@ -302,8 +342,9 @@ export async function run_test_file(
302
342
  tests: [],
303
343
  fails: [],
304
344
  benches: [],
305
- other: [],
306
345
  leaks: [],
346
+ leaking_tests: [],
347
+ other: [],
307
348
  ms: 0,
308
349
  };
309
350
 
@@ -343,29 +384,31 @@ export async function run_test_file(
343
384
 
344
385
  const buildDir = build_dir_for(resolved, true);
345
386
  if (!fs.existsSync(buildDir)) fs.mkdirSync(buildDir, { recursive: true });
346
- // With --audit, compile the audit runtime (malloc/free counting) and link
347
- // it in; the generated main calls nomen_audit_check() at exit, printing
348
- // "LEAK: N allocation(s)" when the balance is nonzero.
349
- let audit_obj: string | undefined;
350
- if (audit) {
351
- // Explicit flag wins; else walk up from the test file (a nomen
352
- // checkout's src/audit_runtime.c); else the runtime bundled with the
353
- // CLI (live repo copy in the dev tree, shipped copy when published)
354
- // so `--audit` works outside the nomen checkout with no flag.
355
- const runtime_src = audit_runtime
356
- ? path.resolve(audit_runtime)
357
- : (find_audit_runtime(path.dirname(resolved)) ?? find_bundled_audit_runtime());
358
- if (!runtime_src || !fs.existsSync(runtime_src)) {
359
- result.ok = false;
360
- result.phase = "setup";
361
- result.crashed =
362
- "Audit enabled but audit_runtime.c was not found. Pass --audit-runtime <path>.";
363
- result.ms = performance.now() - start;
364
- return result;
365
- }
366
- audit_obj = path.join(buildDir, "audit_runtime.o");
367
- await execFile_async("clang", ["-c", runtime_src, "-o", audit_obj]);
387
+ // The audit runtime is linked into EVERY test build: the generated
388
+ // harness polls nomen_audit_count() around every test for per-test leak
389
+ // attribution, and under --audit the generated main additionally calls
390
+ // nomen_audit_check() at exit, printing "LEAK: N allocation(s)" when the
391
+ // balance is nonzero.
392
+ // Runtime discovery: explicit flag wins; else walk up from the test file
393
+ // (a nomen checkout's src/audit_runtime.c); else the runtime bundled with
394
+ // the CLI (live repo copy in the dev tree, shipped copy when published)
395
+ // so it works outside the nomen checkout with no flag.
396
+ const runtime_src = audit_runtime
397
+ ? path.resolve(audit_runtime)
398
+ : (find_audit_runtime(path.dirname(resolved)) ?? find_bundled_audit_runtime());
399
+ if (!runtime_src || !fs.existsSync(runtime_src)) {
400
+ result.ok = false;
401
+ result.phase = "setup";
402
+ result.crashed = "audit_runtime.c was not found. Pass --audit-runtime <path>.";
403
+ result.ms = performance.now() - start;
404
+ return result;
368
405
  }
406
+ // Per-file audit object: two concurrent workers compiling to the SHARED
407
+ // `audit_runtime.o` raced — a link could read the object mid-rewrite
408
+ // (concurrent `clang -c` open the output with O_TRUNC) and fail, or link
409
+ // a stale slab. The runtime is tiny; recompiling per file costs ~0.1s.
410
+ const audit_obj = path.join(buildDir, path.basename(entry_path, ".nm") + "_audit.o");
411
+ await execFile_async("clang", ["-c", runtime_src, "-o", audit_obj]);
369
412
  const platform = default_platform();
370
413
  // Match the run/build command's emission: ObjC-bearing sources (GUI code
371
414
  // pulled in through the library graph) must land in a `.m` TU on Apple
@@ -374,10 +417,19 @@ export async function run_test_file(
374
417
  const ext = arch === "aarch64" ? ".s" : platform === "macos" || platform === "ios" ? ".m" : ".c";
375
418
  const codefile = path.join(buildDir, path.basename(entry_path, ".nm") + ext);
376
419
  const outfile = path.join(buildDir, path.basename(entry_path, ".nm"));
377
- // The C backend's generated source does `#include "main.h"`, so write the
378
- // header next to the code. (aarch64 inlines everything into the .s file.)
379
- fs.writeFileSync(path.join(buildDir, "main.h"), buildResult.headers ?? "");
380
- fs.writeFileSync(codefile, buildResult.code);
420
+ // The C backend's generated source does `#include "main.h"`, so the
421
+ // header lands next to the code. (aarch64 inlines everything into the
422
+ // .s file and ignores it.) Per-file header name: every test file shares
423
+ // one build folder, and two concurrent C builds writing the SAME
424
+ // `main.h` raced — a compile could read another file's header between
425
+ // the write and the clang read and fail (or mis-declare) spuriously.
426
+ const header_name = path.basename(entry_path, ".nm") + ".main.h";
427
+ fs.writeFileSync(path.join(buildDir, header_name), buildResult.headers ?? "");
428
+ let code_text: string = buildResult.code;
429
+ if (arch !== "aarch64") {
430
+ code_text = code_text.replace('#include "main.h"', `#include "${header_name}"`);
431
+ }
432
+ fs.writeFileSync(codefile, code_text);
381
433
 
382
434
  // Companion C (raw-block functions + the pool/fiber/file-scope C infra):
383
435
  // `run`/`build` compile and link it next to the main TU. The aarch64 .s
@@ -424,45 +476,85 @@ export async function run_test_file(
424
476
  return result;
425
477
  }
426
478
 
427
- // Run the binary and collect the records it streams over stdout. A
479
+ // Run the binary with its stdout/stderr redirected to per-file FILES
480
+ // (next to the binary, doubling as debugging artifacts) instead of node
481
+ // pipes, then read them. (Why not pipes: this runner's parse/build
482
+ // phases block the event loop for tens of seconds at a time. A binary
483
+ // that finished inside such a block exited 0 long before its records
484
+ // were read, but the 30s execFile timeout that became due during the
485
+ // block still fired at loop-resume — and the kill path destroyed the
486
+ // child's unread pipe, silently dropping EVERY record: the file
487
+ // reported "(0 tests)" and passed. Records on disk are immune to
488
+ // anything the parent does after the fact.) The documented CWD
489
+ // contract: the binary runs in the `--in` root (the folder `nomen test`
490
+ // scanned for *.test.nm files), so fixture paths in tests resolve
491
+ // against that folder regardless of where the CLI was invoked from. A
428
492
  // non-zero exit (crash, abort) still surfaces whatever records were
429
- // emitted before the crash; a timeout is treated as a crash. The
430
- // documented CWD contract: the binary runs in the `--in` root (the
431
- // folder `nomen test` scanned for *.test.nm files), so fixture paths in
432
- // tests resolve against that folder regardless of where the CLI was
433
- // invoked from.
493
+ // emitted before the crash (the harness runs its stdout unbuffered, so
494
+ // a crashing binary keeps the records it printed); a timeout is treated
495
+ // as a crash.
496
+ const run_out = outfile + ".out";
497
+ const run_err = outfile + ".err";
434
498
  let runStdout = "";
499
+ let runStderr = "";
435
500
  let crashed: string | undefined;
501
+ const out_fd = fs.openSync(run_out, "w");
502
+ const err_fd = fs.openSync(run_err, "w");
436
503
  try {
437
- const run = await execFile_async(outfile, [], {
438
- encoding: "utf8",
504
+ const child = spawn(outfile, [], {
439
505
  timeout: 30_000,
440
- maxBuffer: 16 * 1024 * 1024,
506
+ stdio: ["ignore", out_fd, err_fd],
441
507
  cwd: cwd ?? process.cwd(),
442
508
  });
443
- runStdout = run.stdout;
444
- } catch (err: any) {
445
- runStdout = err.stdout ? err.stdout.toString() : "";
446
- if (err.signal === "SIGTERM") {
509
+ const run = await new Promise<{ code: number | null; signal: string | null }>((resolve) => {
510
+ child.on("error", () => resolve({ code: null, signal: null }));
511
+ child.on("close", (code, signal) => resolve({ code, signal }));
512
+ });
513
+ if (run.signal === "SIGTERM") {
447
514
  crashed = "test binary timed out after 30s";
448
- } else {
449
- crashed = `test binary exited abnormally (signal ${err.signal ?? err.code})`;
515
+ } else if (run.signal !== null) {
516
+ crashed = `test binary exited abnormally (signal ${run.signal})`;
517
+ } else if (run.code === null) {
518
+ crashed = "test binary could not be run";
519
+ } else if (run.code !== 0) {
520
+ crashed = `test binary exited abnormally (signal ${run.code})`;
450
521
  }
522
+ } finally {
523
+ fs.closeSync(out_fd);
524
+ fs.closeSync(err_fd);
525
+ }
526
+ runStdout = fs.readFileSync(run_out, "utf8");
527
+ try {
528
+ runStderr = fs.readFileSync(run_err, "utf8");
529
+ } catch {
530
+ runStderr = "";
531
+ }
532
+ if (crashed && runStderr.trim()) {
533
+ // A crashed binary's own diagnosis (a libmalloc abort message, a
534
+ // panic dump) goes to stderr — surface it with the crash record.
535
+ crashed += `\n${runStderr.trim()}`;
451
536
  }
452
537
 
453
538
  const records = parse_records(runStdout);
454
539
  result.tests = records.tests;
455
540
  result.fails = records.fails;
456
541
  result.benches = records.benches;
542
+ result.leaking_tests = records.leaks;
457
543
  result.other = records.other;
458
544
  // Under --audit the exit-time audit check prints "LEAK: N allocation(s)"
459
- // to stdout when the malloc/free balance is nonzero. Treat any such line
460
- // as a failure so a leaked test file cannot stay green.
545
+ // to stdout when the malloc/free balance is nonzero, and
546
+ // "AUDIT-STALE-FREE: N ..." for frees that matched no live block. Treat
547
+ // any such line as a failure so a leaked (or double-freeing) test file
548
+ // cannot stay green. (The per-test `leaks` records are collected always —
549
+ // they name the leaking tests — but only --audit makes them fail the
550
+ // file.)
461
551
  result.leaks = audit ? extract_leaks(runStdout) : [];
462
- // Leak lines are surfaced explicitly above; drop them from the generic
552
+ // Audit lines are surfaced explicitly above; drop them from the generic
463
553
  // stdout dump so they are not printed twice.
464
554
  if (result.leaks.length) {
465
- result.other = result.other.filter((l) => !l.trim().startsWith("LEAK:"));
555
+ result.other = result.other.filter(
556
+ (l) => !l.trim().startsWith("LEAK:") && !l.trim().startsWith("AUDIT-STALE-FREE:"),
557
+ );
466
558
  }
467
559
  result.crashed = crashed;
468
560
  if (crashed) result.phase = "run";
@@ -535,6 +627,20 @@ export function report_file(result: TestFileResult): void {
535
627
  for (const leak of result.leaks) {
536
628
  console.log(` ${C.yellow("⚠")} ${C.yellow(leak)}`);
537
629
  }
630
+ // Per-test attribution: which tests leaked (the exit-time LEAK line above
631
+ // is the file total; these name the culprits). Shown under --audit only —
632
+ // without it the file cannot fail on leaks, so the warning would be noise
633
+ // on every run.
634
+ if (result.leaks.length) {
635
+ for (const lt of result.leaking_tests) {
636
+ console.log(` ${C.yellow("⚠")} ${lt.test} ${C.dim(`leaked ${lt.count} allocation(s)`)}`);
637
+ }
638
+ if (!result.leaking_tests.length) {
639
+ console.log(
640
+ ` ${C.yellow("⚠")} ${C.yellow("LEAK: no per-test attribution — leaked outside a test (harness/setup/teardown) or crashed mid-test")}`,
641
+ );
642
+ }
643
+ }
538
644
  for (const b of result.benches) {
539
645
  console.log(
540
646
  ` ${C.cyan("⏱")} ${b.label} ${C.dim(`(n=${b.n})`)} ` +
@@ -641,6 +747,12 @@ export async function runTests(root: string, options: RunTestsOptions = {}): Pro
641
747
  const totalFailed = results.reduce((a, r) => a + r.tests.reduce((x, t) => x + t.failed, 0), 0);
642
748
  const totalPassed = totalTests - totalFailed;
643
749
  const totalLeaks = results.reduce((a, r) => a + r.leaks.length, 0);
750
+ // Leak attribution: the count of TESTS that leaked (the old summary
751
+ // counted FILES — one aggregate LEAK line per file told you nothing about
752
+ // which test to blame). The per-file report lists the leaking tests; the
753
+ // files' exit-time totals can exceed the per-test sum when allocations
754
+ // leak outside any test (harness/setup/teardown).
755
+ const totalLeakingTests = results.reduce((a, r) => a + r.leaking_tests.length, 0);
644
756
  const anyFailed = results.some((r) => !r.ok);
645
757
 
646
758
  console.log("");
@@ -659,8 +771,9 @@ export async function runTests(root: string, options: RunTestsOptions = {}): Pro
659
771
  );
660
772
  if (totalLeaks) {
661
773
  console.log(
662
- ` ${C.dim("Leaks ")} ${C.yellow(`${totalLeaks} reported`)}` +
663
- C.dim(" (--audit; leaked files count as failures)"),
774
+ ` ${C.dim("Leaks ")} ${C.yellow(`${totalLeakingTests} leaking test(s)`)}` +
775
+ (totalLeakingTests !== totalLeaks ? C.dim(` (${totalLeaks} leaked file(s))`) : "") +
776
+ C.dim(" (--audit; leaking tests fail their file)"),
664
777
  );
665
778
  }
666
779
  console.log(` ${C.dim(" Time ")} ${format_duration(elapsed)}`);