nomen-lang 0.6.8 → 0.6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nomen-lang",
3
- "version": "0.6.8",
3
+ "version": "0.6.10",
4
4
  "description": "The CLI for the Nomen programming language.",
5
5
  "keywords": [],
6
6
  "license": "ISC",
@@ -17,7 +17,8 @@
17
17
  "dist",
18
18
  "src",
19
19
  "core",
20
- "NOMEN_AGENTS.md"
20
+ "NOMEN_AGENTS.md",
21
+ "audit_runtime.c"
21
22
  ],
22
23
  "type": "module",
23
24
  "devDependencies": {
package/src/args.ts CHANGED
@@ -13,6 +13,8 @@ export interface Args {
13
13
  audit_runtime?: string;
14
14
  check: boolean;
15
15
  release: boolean;
16
+ /** `nomen test` concurrency (max test files in flight); default = CPU count. */
17
+ jobs?: string;
16
18
  /** Opt in to FP reassociation: float reductions vectorize under NEON
17
19
  * (results may differ in the last ulp vs the scalar loop). */
18
20
  fast_math: boolean;
@@ -39,6 +41,8 @@ const STRING_OPTIONS: Map<string, string> = new Map([
39
41
  ["lib", "lib"],
40
42
  ["l", "lib"],
41
43
  ["audit-runtime", "audit_runtime"],
44
+ ["jobs", "jobs"],
45
+ ["j", "jobs"],
42
46
  ]);
43
47
 
44
48
  const BOOLEAN_OPTIONS: Map<string, string> = new Map([
@@ -174,8 +178,9 @@ export function print_help(): void {
174
178
  " --platform, -p <name> Target platform: macos, ios, linux, android, windows, web",
175
179
  " --lib, -l <path> Path to System library directory (containing package.jsonc)",
176
180
  " --audit Audit the generated program for memory issues",
177
- " --audit-runtime <path> Path to audit_runtime.c, linked in when --audit is set",
178
- " --release, -r Build with optimizations (clang -O2; asm passes on aarch64)",
181
+ " --audit-runtime <path> Path to audit_runtime.c (default: auto-discovered — walk-up or the CLI-bundled copy)",
182
+ " --jobs, -j <n> For `nomen test`: max test files run in parallel (default: CPU count)",
183
+ " --release, -r Build with optimizations (clang -O2; aarch64 asm cleanup passes)",
179
184
  " --fast-math Allow FP reassociation (float loop reductions vectorize on aarch64)",
180
185
  " --check For `nomen format`: report files that would change",
181
186
  " -h, --help Show this help",
package/src/index.ts CHANGED
@@ -13,7 +13,7 @@ import parse from "../../src/parse.ts";
13
13
  import { parse_args, print_help, type Args } from "./args.ts";
14
14
  import { run_docs } from "./docs.ts";
15
15
  import render_errors, { render_warnings } from "./format_errors.ts";
16
- import { find_bundled, run_init } from "./init.ts";
16
+ import { find_bundled, find_bundled_audit_runtime, run_init } from "./init.ts";
17
17
  import { build_dir_for, outfile_for } from "./paths.ts";
18
18
  import { runTests } from "./test.ts";
19
19
  import type Config from "./types/Config.ts";
@@ -104,12 +104,13 @@ try {
104
104
  const root = args.in ?? process.cwd();
105
105
  const filter = args.filter ? new RegExp(args.filter) : undefined;
106
106
  const arch = args.arch ?? "aarch64";
107
- const ok = runTests(root, {
107
+ const ok = await runTests(root, {
108
108
  arch,
109
109
  filter,
110
110
  audit: args.audit,
111
111
  audit_runtime: args.audit_runtime,
112
112
  release: args.release,
113
+ jobs: args.jobs !== undefined ? parseInt(args.jobs, 10) : undefined,
113
114
  });
114
115
  process.exit(ok ? 0 : 1);
115
116
  }
@@ -283,7 +284,10 @@ function resolve_audit_runtime(config: Config, input_path: string): string | und
283
284
  if (parent === dir) break;
284
285
  dir = parent;
285
286
  }
286
- return undefined;
287
+ // Neither the config nor the walk-up found it: fall back to the runtime
288
+ // bundled with the CLI (live repo copy in the dev tree, shipped copy when
289
+ // published) so `--audit` works outside the nomen checkout with no flag.
290
+ return find_bundled_audit_runtime();
287
291
  }
288
292
 
289
293
  function compile_audit_runtime(config: Config, input_path: string, buildDir: string): string {
@@ -405,7 +409,14 @@ function processFile(filename: string, config: Config, mode: Mode, program_args:
405
409
  // `build` links the executable but does not run it; `run` links and runs.
406
410
  startTime = performance.now();
407
411
 
408
- 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;
409
420
  let link_inputs = codefile;
410
421
  if (companionfile) link_inputs += ` ${companionfile}`;
411
422
  if (audit_obj) link_inputs += ` ${audit_obj}`;
package/src/init.ts CHANGED
@@ -85,6 +85,16 @@ export function find_bundled(filename: string): string | undefined {
85
85
  return undefined;
86
86
  }
87
87
 
88
+ // The audit runtime for `--audit` builds: the dev tree's LIVE copy
89
+ // (repo_root/src/audit_runtime.c — a stale bundled snapshot must not shadow
90
+ // it, same posture as the cli/core rule above), else the copy shipped at the
91
+ // package root by `scripts/bundle-assets.mjs` in a published install. Lets
92
+ // `nomen test/run --audit` work outside the nomen checkout with no
93
+ // --audit-runtime flag.
94
+ export function find_bundled_audit_runtime(): string | undefined {
95
+ return find_bundled("src/audit_runtime.c") ?? find_bundled("audit_runtime.c");
96
+ }
97
+
88
98
  /**
89
99
  * `nomen init <name>`: scaffold a new project under `./<name>` with a sensible
90
100
  * starting layout (package.jsonc, src/main.nm, a starter test, README and an
package/src/test.ts CHANGED
@@ -1,17 +1,26 @@
1
- import { execFileSync } from "node:child_process";
1
+ import { execFile } from "node:child_process";
2
2
  import fs from "node:fs";
3
+ import os from "node:os";
3
4
  import path from "node:path";
4
5
  import { fileURLToPath } from "node:url";
6
+ import { promisify } from "node:util";
5
7
 
6
8
  import build, { default_platform } from "../../src/build.ts";
7
9
  import join from "../../src/join.ts";
8
10
  import { get_library } from "../../src/lib.ts";
9
11
  import parse from "../../src/parse.ts";
10
- import { find_bundled } from "./init.ts";
12
+ import { find_bundled, find_bundled_audit_runtime } from "./init.ts";
11
13
  import { build_dir_for } from "./paths.ts";
12
14
 
13
15
  const RECORD_PREFIX = "\\nomen|";
14
16
 
17
+ // Promise-returning `execFile`: `nomen test` builds and runs each `*.test.nm`
18
+ // file in its own build folder and its own process, so the (latency-bound)
19
+ // clang/link/run subprocesses overlap across files instead of serializing.
20
+ // A rejected error carries the same `.stdout` / `.stderr` / `.signal` /
21
+ // `.code` fields the previous synchronous `execFileSync` catch sites read.
22
+ const execFile_async = promisify(execFile);
23
+
15
24
  // A discovered test function: `pub func <name> = (ref Tester t)`.
16
25
  export interface TestFunction {
17
26
  name: string;
@@ -40,6 +49,10 @@ export interface TestFileResult {
40
49
  // "LEAK: N allocation(s)" lines the audits runtime printed at exit. Empty
41
50
  // unless the file was run with --audit; a non-empty list fails the file.
42
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[];
43
56
  // Set when the compiled binary crashed before finishing (e.g. a segfault
44
57
  // in a test). We still surface whatever records it managed to emit.
45
58
  crashed?: string;
@@ -74,6 +87,13 @@ interface BenchRecord {
74
87
  stddev: number;
75
88
  }
76
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
+
77
97
  /** Recursively collect `*.test.nm` files under `root`, skipping build output. */
78
98
  export function collect_test_files(root: string): string[] {
79
99
  const out: string[] = [];
@@ -192,11 +212,25 @@ export function generate_harness(tests: TestFunction[], benches: BenchFunction[]
192
212
  })
193
213
  .join("\n");
194
214
 
195
- 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";
196
223
  main += "\tvar Tester t = Tester()\n";
224
+ main += "\tvar int64 audit_mark = 0\n";
225
+ main += "\tvar int64 audit_leaked = 0\n";
197
226
  for (const test of tests) {
227
+ main += `\taudit_mark = Tester.audit_count()\n`;
198
228
  main += `\tt.begin_test("${escape_nm_string(test.name)}")\n`;
199
229
  main += `\t${test.name}(ref t)\n`;
230
+ main += `\taudit_leaked = Tester.audit_count() - audit_mark\n`;
231
+ main += `\tif audit_leaked > 0 {\n`;
232
+ main += `\t\tConsole.write_line("\\\\nomen|leaks|" + "${escape_nm_string(test.name)}" + "|\\{audit_leaked}")\n`;
233
+ main += `\t}\n`;
200
234
  main += `\tt.end_test()\n`;
201
235
  }
202
236
  for (const bench of benches) {
@@ -218,6 +252,7 @@ interface RunRecord {
218
252
  tests: TestRecord[];
219
253
  fails: FailRecord[];
220
254
  benches: BenchRecord[];
255
+ leaks: LeakRecord[];
221
256
  other: string[];
222
257
  }
223
258
 
@@ -226,6 +261,7 @@ export function parse_records(stdout: string): RunRecord {
226
261
  const tests: TestRecord[] = [];
227
262
  const fails: FailRecord[] = [];
228
263
  const benches: BenchRecord[] = [];
264
+ const leaks: LeakRecord[] = [];
229
265
  const other: string[] = [];
230
266
  for (const line of stdout.split("\n")) {
231
267
  if (!line.startsWith(RECORD_PREFIX)) {
@@ -245,6 +281,8 @@ export function parse_records(stdout: string): RunRecord {
245
281
  });
246
282
  } else if (kind === "fail") {
247
283
  fails.push({ test: parts[1], message: parts.slice(2).join("|") });
284
+ } else if (kind === "leaks") {
285
+ leaks.push({ test: parts[1], count: parseInt(parts[2] || "0", 10) });
248
286
  } else if (kind === "bench") {
249
287
  benches.push({
250
288
  label: parts[1],
@@ -257,22 +295,28 @@ export function parse_records(stdout: string): RunRecord {
257
295
  });
258
296
  }
259
297
  }
260
- return { tests, fails, benches, other };
298
+ return { tests, fails, benches, leaks, other };
261
299
  }
262
300
 
263
- /** Pull the exit-time audit "LEAK: N allocation(s)" lines out of stdout. */
301
+ /**
302
+ * Pull the exit-time audit lines out of stdout: "LEAK: N allocation(s)" (a
303
+ * nonzero malloc/free balance) and "AUDIT-STALE-FREE: N pointer(s) freed that
304
+ * were not live audit allocations" (a free that matched no live block — a
305
+ * foreign pointer or a double free; the runtime no longer passes those to
306
+ * libc free, so this is the only record they leave). Both fail the file.
307
+ */
264
308
  export function extract_leaks(stdout: string): string[] {
265
309
  return stdout
266
310
  .split("\n")
267
311
  .map((l) => l.trim())
268
- .filter((l) => l.startsWith("LEAK:"));
312
+ .filter((l) => l.startsWith("LEAK:") || l.startsWith("AUDIT-STALE-FREE:"));
269
313
  }
270
314
 
271
315
  /**
272
316
  * Compile one `*.test.nm` file (with the generated harness) and run it,
273
317
  * returning its parsed records and any crash info.
274
318
  */
275
- export function run_test_file(
319
+ export async function run_test_file(
276
320
  entry_path: string,
277
321
  lib_path: string | undefined,
278
322
  arch: string,
@@ -280,7 +324,7 @@ export function run_test_file(
280
324
  audit_runtime?: string,
281
325
  release = false,
282
326
  cwd?: string,
283
- ): TestFileResult {
327
+ ): Promise<TestFileResult> {
284
328
  const start = performance.now();
285
329
  const source_text = fs.readFileSync(entry_path, "utf8");
286
330
  const tests = extract_test_functions(source_text);
@@ -293,8 +337,9 @@ export function run_test_file(
293
337
  tests: [],
294
338
  fails: [],
295
339
  benches: [],
296
- other: [],
297
340
  leaks: [],
341
+ leaking_tests: [],
342
+ other: [],
298
343
  ms: 0,
299
344
  };
300
345
 
@@ -334,25 +379,27 @@ export function run_test_file(
334
379
 
335
380
  const buildDir = build_dir_for(resolved, true);
336
381
  if (!fs.existsSync(buildDir)) fs.mkdirSync(buildDir, { recursive: true });
337
- // With --audit, compile the audit runtime (malloc/free counting) and link
338
- // it in; the generated main calls nomen_audit_check() at exit, printing
339
- // "LEAK: N allocation(s)" when the balance is nonzero.
340
- let audit_obj: string | undefined;
341
- if (audit) {
342
- const runtime_src = audit_runtime
343
- ? path.resolve(audit_runtime)
344
- : find_audit_runtime(path.dirname(resolved));
345
- if (!runtime_src || !fs.existsSync(runtime_src)) {
346
- result.ok = false;
347
- result.phase = "setup";
348
- result.crashed =
349
- "Audit enabled but audit_runtime.c was not found. Pass --audit-runtime <path>.";
350
- result.ms = performance.now() - start;
351
- return result;
352
- }
353
- audit_obj = path.join(buildDir, "audit_runtime.o");
354
- execFileSync("clang", ["-c", runtime_src, "-o", audit_obj]);
382
+ // The audit runtime is linked into EVERY test build: the generated
383
+ // harness polls nomen_audit_count() around every test for per-test leak
384
+ // attribution, and under --audit the generated main additionally calls
385
+ // nomen_audit_check() at exit, printing "LEAK: N allocation(s)" when the
386
+ // balance is nonzero.
387
+ // Runtime discovery: explicit flag wins; else walk up from the test file
388
+ // (a nomen checkout's src/audit_runtime.c); else the runtime bundled with
389
+ // the CLI (live repo copy in the dev tree, shipped copy when published)
390
+ // so it works outside the nomen checkout with no flag.
391
+ const runtime_src = audit_runtime
392
+ ? path.resolve(audit_runtime)
393
+ : (find_audit_runtime(path.dirname(resolved)) ?? find_bundled_audit_runtime());
394
+ if (!runtime_src || !fs.existsSync(runtime_src)) {
395
+ result.ok = false;
396
+ result.phase = "setup";
397
+ result.crashed = "audit_runtime.c was not found. Pass --audit-runtime <path>.";
398
+ result.ms = performance.now() - start;
399
+ return result;
355
400
  }
401
+ const audit_obj = path.join(buildDir, "audit_runtime.o");
402
+ await execFile_async("clang", ["-c", runtime_src, "-o", audit_obj]);
356
403
  const platform = default_platform();
357
404
  // Match the run/build command's emission: ObjC-bearing sources (GUI code
358
405
  // pulled in through the library graph) must land in a `.m` TU on Apple
@@ -401,10 +448,7 @@ export function run_test_file(
401
448
  );
402
449
  }
403
450
  try {
404
- execFileSync("clang", link_args, {
405
- encoding: "utf8",
406
- stdio: ["ignore", "pipe", "pipe"],
407
- });
451
+ await execFile_async("clang", link_args, { encoding: "utf8" });
408
452
  } catch (err: any) {
409
453
  const stderr = err.stderr ? err.stderr.toString() : (err.message ?? "");
410
454
  result.ok = false;
@@ -424,13 +468,13 @@ export function run_test_file(
424
468
  let runStdout = "";
425
469
  let crashed: string | undefined;
426
470
  try {
427
- runStdout = execFileSync(outfile, [], {
471
+ const run = await execFile_async(outfile, [], {
428
472
  encoding: "utf8",
429
- stdio: ["ignore", "pipe", "pipe"],
430
473
  timeout: 30_000,
431
474
  maxBuffer: 16 * 1024 * 1024,
432
475
  cwd: cwd ?? process.cwd(),
433
476
  });
477
+ runStdout = run.stdout;
434
478
  } catch (err: any) {
435
479
  runStdout = err.stdout ? err.stdout.toString() : "";
436
480
  if (err.signal === "SIGTERM") {
@@ -444,15 +488,22 @@ export function run_test_file(
444
488
  result.tests = records.tests;
445
489
  result.fails = records.fails;
446
490
  result.benches = records.benches;
491
+ result.leaking_tests = records.leaks;
447
492
  result.other = records.other;
448
493
  // Under --audit the exit-time audit check prints "LEAK: N allocation(s)"
449
- // to stdout when the malloc/free balance is nonzero. Treat any such line
450
- // as a failure so a leaked test file cannot stay green.
494
+ // to stdout when the malloc/free balance is nonzero, and
495
+ // "AUDIT-STALE-FREE: N ..." for frees that matched no live block. Treat
496
+ // any such line as a failure so a leaked (or double-freeing) test file
497
+ // cannot stay green. (The per-test `leaks` records are collected always —
498
+ // they name the leaking tests — but only --audit makes them fail the
499
+ // file.)
451
500
  result.leaks = audit ? extract_leaks(runStdout) : [];
452
- // Leak lines are surfaced explicitly above; drop them from the generic
501
+ // Audit lines are surfaced explicitly above; drop them from the generic
453
502
  // stdout dump so they are not printed twice.
454
503
  if (result.leaks.length) {
455
- result.other = result.other.filter((l) => !l.trim().startsWith("LEAK:"));
504
+ result.other = result.other.filter(
505
+ (l) => !l.trim().startsWith("LEAK:") && !l.trim().startsWith("AUDIT-STALE-FREE:"),
506
+ );
456
507
  }
457
508
  result.crashed = crashed;
458
509
  if (crashed) result.phase = "run";
@@ -525,6 +576,20 @@ export function report_file(result: TestFileResult): void {
525
576
  for (const leak of result.leaks) {
526
577
  console.log(` ${C.yellow("⚠")} ${C.yellow(leak)}`);
527
578
  }
579
+ // Per-test attribution: which tests leaked (the exit-time LEAK line above
580
+ // is the file total; these name the culprits). Shown under --audit only —
581
+ // without it the file cannot fail on leaks, so the warning would be noise
582
+ // on every run.
583
+ if (result.leaks.length) {
584
+ for (const lt of result.leaking_tests) {
585
+ console.log(` ${C.yellow("⚠")} ${lt.test} ${C.dim(`leaked ${lt.count} allocation(s)`)}`);
586
+ }
587
+ if (!result.leaking_tests.length) {
588
+ console.log(
589
+ ` ${C.yellow("⚠")} ${C.yellow("LEAK: no per-test attribution — leaked outside a test (harness/setup/teardown) or crashed mid-test")}`,
590
+ );
591
+ }
592
+ }
528
593
  for (const b of result.benches) {
529
594
  console.log(
530
595
  ` ${C.cyan("⏱")} ${b.label} ${C.dim(`(n=${b.n})`)} ` +
@@ -545,12 +610,27 @@ export interface RunTestsOptions {
545
610
  audit?: boolean;
546
611
  audit_runtime?: string;
547
612
  release?: boolean;
613
+ /**
614
+ * Maximum test files built and run concurrently. Each file compiles into
615
+ * its own build folder and runs in its own process, so the latency-bound
616
+ * clang/link/run steps overlap across files. Defaults to the machine's
617
+ * available parallelism; 1 forces the historical sequential order.
618
+ */
619
+ jobs?: number;
620
+ }
621
+
622
+ /** The default concurrency for `runTests` when no `jobs` is given. */
623
+ export function default_jobs(): number {
624
+ const n =
625
+ typeof os.availableParallelism === "function" ? os.availableParallelism() : os.cpus().length;
626
+ return Math.max(1, n);
548
627
  }
549
628
 
550
629
  /**
551
630
  * Locate audit_runtime.c for an audited test build: an explicit path wins,
552
631
  * otherwise walk up from `dir` looking for a `src/audit_runtime.c` (the same
553
- * convention the run/build commands use).
632
+ * convention the run/build commands use). When both miss, the caller falls
633
+ * back to the CLI-bundled runtime (`find_bundled_audit_runtime`).
554
634
  */
555
635
  function find_audit_runtime(dir: string): string | undefined {
556
636
  let cur = dir;
@@ -565,7 +645,7 @@ function find_audit_runtime(dir: string): string | undefined {
565
645
  }
566
646
 
567
647
  /** Discover, run, and report every `*.test.nm` under `root`. */
568
- export function runTests(root: string, options: RunTestsOptions = {}): boolean {
648
+ export async function runTests(root: string, options: RunTestsOptions = {}): Promise<boolean> {
569
649
  const arch = options.arch ?? "aarch64";
570
650
  const lib = resolve_lib_for(root);
571
651
  const files = collect_test_files(root).filter((f) => !options.filter || options.filter.test(f));
@@ -577,20 +657,33 @@ export function runTests(root: string, options: RunTestsOptions = {}): boolean {
577
657
  // fixture paths resolve there no matter where the CLI was invoked from.
578
658
  const binary_cwd = path.resolve(root);
579
659
 
580
- const results: TestFileResult[] = [];
581
- for (const file of files) {
582
- const result = run_test_file(
583
- file,
584
- lib,
585
- arch,
586
- options.audit,
587
- options.audit_runtime,
588
- options.release,
589
- binary_cwd,
590
- );
591
- report_file(result);
592
- results.push(result);
593
- }
660
+ // Bounded-concurrency pool: at most `jobs` files are in flight. The
661
+ // parse/build halves are synchronous (no internal awaits), so they run
662
+ // atomically on the main thread between the subprocess awaits — the
663
+ // compiler's in-process state never interleaves. Results are collected by
664
+ // file INDEX and reported after the pool drains, so the report stays in
665
+ // discovery order regardless of completion order (and stdout isn't
666
+ // interleaved).
667
+ const jobs = Math.max(1, Math.min(options.jobs ?? default_jobs(), files.length));
668
+ const results: TestFileResult[] = new Array(files.length);
669
+ let next = 0;
670
+ const worker = async (): Promise<void> => {
671
+ for (;;) {
672
+ const i = next++;
673
+ if (i >= files.length) return;
674
+ results[i] = await run_test_file(
675
+ files[i],
676
+ lib,
677
+ arch,
678
+ options.audit,
679
+ options.audit_runtime,
680
+ options.release,
681
+ binary_cwd,
682
+ );
683
+ }
684
+ };
685
+ await Promise.all(Array.from({ length: jobs }, worker));
686
+ for (const result of results) report_file(result);
594
687
 
595
688
  const elapsed = performance.now() - startTime;
596
689
  const totalFiles = results.length;
@@ -603,6 +696,12 @@ export function runTests(root: string, options: RunTestsOptions = {}): boolean {
603
696
  const totalFailed = results.reduce((a, r) => a + r.tests.reduce((x, t) => x + t.failed, 0), 0);
604
697
  const totalPassed = totalTests - totalFailed;
605
698
  const totalLeaks = results.reduce((a, r) => a + r.leaks.length, 0);
699
+ // Leak attribution: the count of TESTS that leaked (the old summary
700
+ // counted FILES — one aggregate LEAK line per file told you nothing about
701
+ // which test to blame). The per-file report lists the leaking tests; the
702
+ // files' exit-time totals can exceed the per-test sum when allocations
703
+ // leak outside any test (harness/setup/teardown).
704
+ const totalLeakingTests = results.reduce((a, r) => a + r.leaking_tests.length, 0);
606
705
  const anyFailed = results.some((r) => !r.ok);
607
706
 
608
707
  console.log("");
@@ -621,8 +720,9 @@ export function runTests(root: string, options: RunTestsOptions = {}): boolean {
621
720
  );
622
721
  if (totalLeaks) {
623
722
  console.log(
624
- ` ${C.dim("Leaks ")} ${C.yellow(`${totalLeaks} reported`)}` +
625
- C.dim(" (--audit; leaked files count as failures)"),
723
+ ` ${C.dim("Leaks ")} ${C.yellow(`${totalLeakingTests} leaking test(s)`)}` +
724
+ (totalLeakingTests !== totalLeaks ? C.dim(` (${totalLeaks} leaked file(s))`) : "") +
725
+ C.dim(" (--audit; leaking tests fail their file)"),
626
726
  );
627
727
  }
628
728
  console.log(` ${C.dim(" Time ")} ${format_duration(elapsed)}`);