nomen-lang 0.6.9 → 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/audit_runtime.c CHANGED
@@ -2,44 +2,164 @@
2
2
  #include <stdlib.h>
3
3
  #include <string.h>
4
4
 
5
+ // The allocation ledger behind `--audit` (`nomen test --audit`,
6
+ // `Tester.audit_count`, and the generated harness's per-test attribution).
7
+ //
8
+ // Every wrapped block carries a 16-byte header (magic, size, state) in front of
9
+ // the payload the program sees, so every wrapped free can be matched against a
10
+ // LIVE block. The header is the whole point: a bare counter cannot tell a
11
+ // correct free from an incorrect one, and both failures it used to absorb are
12
+ // ownership bugs the generated code cannot prove away.
13
+ //
14
+ // - free of a pointer this runtime never handed out (a pool/BSS/rodata
15
+ // address, a libc allocation, a stale pointer kept across a realloc) used
16
+ // to decrement the counter like any other free. That silently hid the exact
17
+ // failure the allmark aarch64 sanitize runs hit — `free()` of a non-heap
18
+ // address aborting in libmalloc with no audit-level diagnosis — and it let
19
+ // the reported count drift away from the allocator's real state, so a
20
+ // genuine leak count and a phantom one were indistinguishable.
21
+ // - a second free of the SAME block (a double free, or a stale pointer whose
22
+ // memory the allocator already recycled) decremented a second time.
23
+ // - `realloc(p, 0)` frees `p`; the old counter kept it live, so every such
24
+ // call read as one phantom allocation.
25
+ //
26
+ // A stale free no longer decrements and no longer reaches libc `free` (it may
27
+ // not be ours to free at all): it is counted in `nomen_stale_frees` and
28
+ // reported by `nomen_audit_check`, so the run fails with a diagnosis instead of
29
+ // dying inside the allocator. The allocation count itself is unchanged — one
30
+ // increment per wrapped block, one decrement per reclaimed block — so it stays
31
+ // exactly as before: the audit test contract.
32
+
5
33
  // Atomic: worker threads from the pool malloc/free concurrently, and a
6
34
  // plain `long` would lose updates under that contention (showing up as
7
35
  // spurious LEAK/negative-count failures in tests that use spawns).
8
36
  static volatile long nomen_malloc_count = 0;
9
37
 
10
- void *nomen_malloc_wrap(unsigned long size) {
11
- void *ptr = malloc(size);
38
+ // Frees that did not match a live wrapped block (foreign pointer, double
39
+ // free, realloc of a stale pointer). Reported at exit; never affects the
40
+ // allocation count.
41
+ static volatile long nomen_stale_frees = 0;
42
+
43
+ #define NOMEN_AUDIT_MAGIC 0x6e6f6d656e415531UL // "nomenAU1"
44
+ #define NOMEN_AUDIT_LIVE 1UL
45
+ #define NOMEN_AUDIT_DEAD 2UL
46
+
47
+ typedef struct {
48
+ unsigned long magic;
49
+ unsigned long size;
50
+ unsigned long state;
51
+ unsigned long pad; // keeps the payload 16-byte aligned
52
+ } nomen_audit_header;
53
+
54
+ static void *audit_alloc(unsigned long size) {
55
+ nomen_audit_header *header = (nomen_audit_header *)malloc(size + sizeof(nomen_audit_header));
56
+ if (!header) return 0;
57
+ header->magic = NOMEN_AUDIT_MAGIC;
58
+ header->size = size;
59
+ header->state = NOMEN_AUDIT_LIVE;
12
60
  __atomic_add_fetch(&nomen_malloc_count, 1, __ATOMIC_SEQ_CST);
13
- return ptr;
61
+ return (void *)(header + 1);
62
+ }
63
+
64
+ // The header in front of `ptr`, or 0 when `ptr` is not one of ours (a libc
65
+ // allocation, a static/BSS address, or already-freed memory whose header was
66
+ // cleared).
67
+ static nomen_audit_header *audit_header(void *ptr) {
68
+ nomen_audit_header *header = (nomen_audit_header *)ptr - 1;
69
+ if (header->magic != NOMEN_AUDIT_MAGIC) return 0;
70
+ return header;
71
+ }
72
+
73
+ // Retire a block: poison the header first so a second free of the same pointer
74
+ // is recognized as stale, then hand the memory back and drop the count.
75
+ static void audit_release(nomen_audit_header *header) {
76
+ header->magic = 0;
77
+ header->state = NOMEN_AUDIT_DEAD;
78
+ __atomic_sub_fetch(&nomen_malloc_count, 1, __ATOMIC_SEQ_CST);
79
+ free(header);
80
+ }
81
+
82
+ static long audit_record_stale(void) {
83
+ return __atomic_add_fetch(&nomen_stale_frees, 1, __ATOMIC_SEQ_CST);
84
+ }
85
+
86
+ // Diagnose each KIND once: a shape that double-frees per iteration would
87
+ // otherwise print thousands of identical lines. The exit-time
88
+ // AUDIT-STALE-FREE count carries the total.
89
+ static void audit_report_stale(int kind) {
90
+ static volatile int reported[2] = {0, 0};
91
+ static const char *const messages[2] = {
92
+ "audit: free of a pointer that is not a live audit allocation "
93
+ "(a foreign address, or one already freed)",
94
+ "audit: realloc of a pointer that is not a live audit allocation",
95
+ };
96
+ if (kind < 0 || kind > 1) return;
97
+ if (__atomic_exchange_n(&reported[kind], 1, __ATOMIC_SEQ_CST)) return;
98
+ fprintf(stderr, "%s\n", messages[kind]);
99
+ }
100
+
101
+ void *nomen_malloc_wrap(unsigned long size) {
102
+ return audit_alloc(size);
14
103
  }
15
104
 
16
105
  void *nomen_calloc_wrap(unsigned long count, unsigned long size) {
17
- void *ptr = calloc(count, size);
18
- __atomic_add_fetch(&nomen_malloc_count, 1, __ATOMIC_SEQ_CST);
106
+ unsigned long total = count * size;
107
+ void *ptr = audit_alloc(total);
108
+ if (ptr) memset(ptr, 0, total);
19
109
  return ptr;
20
110
  }
21
111
 
112
+ void *nomen_strdup_wrap(const char *s) {
113
+ unsigned long len = strlen(s) + 1;
114
+ void *ptr = audit_alloc(len);
115
+ if (ptr) memcpy(ptr, s, len);
116
+ return ptr;
117
+ }
118
+
119
+ // realloc: the count is unchanged (one block in, one block out) — the block is
120
+ // only RETIRED, which is what fixes `realloc(p, 0)`: C lets it free `p` and
121
+ // return NULL, and the old counter left `p` live, reading as a phantom
122
+ // allocation. This implementation always returns the fresh block (a valid
123
+ // unique pointer, the other conforming option), so callers that treat a NULL
124
+ // result as "the buffer is gone" keep working.
125
+ //
126
+ // A stale or foreign `old_ptr` reallocates to NULL (realloc's failure mode)
127
+ // and is counted as a stale free: the pointer is left untouched rather than
128
+ // memcpy'd out of memory that isn't ours.
22
129
  void *nomen_realloc_wrap(void *old_ptr, unsigned long size) {
23
- if (!old_ptr) {
24
- __atomic_add_fetch(&nomen_malloc_count, 1, __ATOMIC_SEQ_CST);
130
+ if (!old_ptr) return audit_alloc(size);
131
+ nomen_audit_header *header = audit_header(old_ptr);
132
+ if (!header || header->state != NOMEN_AUDIT_LIVE) {
133
+ audit_record_stale();
134
+ audit_report_stale(1);
135
+ return 0;
25
136
  }
26
- void *ptr = realloc(old_ptr, size);
137
+ unsigned long old_size = header->size;
138
+ void *ptr = audit_alloc(size);
139
+ // An allocation failure keeps the old block live (realloc's contract).
140
+ if (!ptr) return 0;
141
+ memcpy(ptr, old_ptr, old_size < size ? old_size : size);
142
+ audit_release(header);
27
143
  return ptr;
28
144
  }
29
145
 
30
146
  void nomen_free_wrap(void *ptr) {
31
- if (ptr) {
32
- free(ptr);
33
- __atomic_sub_fetch(&nomen_malloc_count, 1, __ATOMIC_SEQ_CST);
147
+ if (!ptr) return;
148
+ nomen_audit_header *header = audit_header(ptr);
149
+ // Not a live wrapped block: a libc allocation, a static/BSS/rodata
150
+ // address, a pointer the allocator already recycled, or a block this
151
+ // runtime already freed (the double-free case — the header is poisoned on
152
+ // release, so a stale pointer can never look live). Freeing it would be an
153
+ // invalid free, and libmalloc aborts INSIDE free() with no context, which
154
+ // is exactly how the allmark aarch64 sanitize run died. Report it instead
155
+ // of passing it on, and leave the count alone: an unmatched free is not
156
+ // evidence that a live block was reclaimed.
157
+ if (!header || header->state != NOMEN_AUDIT_LIVE) {
158
+ audit_record_stale();
159
+ audit_report_stale(0);
160
+ return;
34
161
  }
35
- }
36
-
37
- void *nomen_strdup_wrap(const char *s) {
38
- unsigned long len = strlen(s) + 1;
39
- void *ptr = malloc(len);
40
- if (ptr) memcpy(ptr, s, len);
41
- __atomic_add_fetch(&nomen_malloc_count, 1, __ATOMIC_SEQ_CST);
42
- return ptr;
162
+ audit_release(header);
43
163
  }
44
164
 
45
165
  void nomen_audit_check(void) {
@@ -47,4 +167,22 @@ void nomen_audit_check(void) {
47
167
  if (count != 0) {
48
168
  printf("LEAK: %ld allocation(s)\n", count);
49
169
  }
170
+ long stale = __atomic_load_n(&nomen_stale_frees, __ATOMIC_SEQ_CST);
171
+ if (stale != 0) {
172
+ printf("AUDIT-STALE-FREE: %ld pointer(s) freed that were not live audit allocations\n",
173
+ stale);
174
+ }
175
+ }
176
+
177
+ // Per-test leak attribution (`nomen test`): the generated harness snapshots
178
+ // the counter before every test and diffs it after, so a leaking test is
179
+ // named instead of a whole file aggregating into one exit-time count.
180
+ long nomen_audit_count(void) {
181
+ return __atomic_load_n(&nomen_malloc_count, __ATOMIC_SEQ_CST);
50
182
  }
183
+
184
+ // Frees that matched no live block (foreign pointer / double free). Polled the
185
+ // same way as the count so a run can attribute them like leaks.
186
+ long nomen_audit_stale_frees(void) {
187
+ return __atomic_load_n(&nomen_stale_frees, __ATOMIC_SEQ_CST);
188
+ }
@@ -11,6 +11,7 @@
11
11
  //
12
12
  // \nomen|start|<test>
13
13
  // \nomen|fail|<test>|<message>
14
+ // \nomen|leaks|<test>|<n>
14
15
  // \nomen|done|<test>|<passed>|<failed>|<ns>
15
16
  // \nomen|bench|<label>|<n>|<min>|<median>|<max>|<mean>|<stddev>
16
17
  //
@@ -70,6 +71,23 @@ pub class Tester {
70
71
  var bench_label = ""
71
72
  var bench_samples = 1000
72
73
 
74
+ // (audit) The process's current malloc/free balance — allocations made
75
+ // but not yet freed. `nomen test --audit` snapshots this around every
76
+ // test so a leak is attributed to the test that leaked instead of one
77
+ // file-level exit-time count. The audit runtime is linked into every
78
+ // test build; in other binaries the counter simply never moves.
79
+ pub func audit_count = (out int64) {
80
+ ```
81
+ #arch: c
82
+ extern long nomen_audit_count(void);
83
+ return (long long)nomen_audit_count();
84
+ ```
85
+ ```
86
+ #arch: aarch64
87
+ bl _nomen_audit_count
88
+ ```
89
+ }
90
+
73
91
  // (harness) Start timing `name` and clear the per-test state. Called by
74
92
  // the `main` that `nomen test` generates — not by test code.
75
93
  pub func begin_test = (ref self, string name) {
package/dist/index.mjs CHANGED
@@ -2122,8 +2122,40 @@ function try_parse_operands(parts) {
2122
2122
  * is not an instruction — label, directive, comment, blank, OR an
2123
2123
  * instruction outside the contract table). Shared by the validator and the
2124
2124
  * phase-2 optimizer so both see identical shapes.
2125
- */
2125
+ *
2126
+ * Memoized by raw text: the post-processing pipeline (frame slots, coalesce,
2127
+ * loop promote, if-convert, float forwarding, widen masks, …) plus the
2128
+ * validators each re-parse the SAME instruction texts — prologue/epilogue
2129
+ * lines repeat across every function, so the hit rate is very high. Every
2130
+ * call returns a PRIVATE deep copy (the passes treat parsed instructions as
2131
+ * mutable IR — coalesce renames operands in place — so sharing a parsed
2132
+ * object would corrupt the memo and cross-pass state).
2133
+ */
2134
+ const parse_memo = /* @__PURE__ */ new Map();
2135
+ const PARSE_MEMO_CAP = 2e5;
2136
+ function clone_parsed(c, line) {
2137
+ return {
2138
+ text: c.text,
2139
+ op: c.op,
2140
+ setsFlags: c.setsFlags,
2141
+ readsFlags: c.readsFlags,
2142
+ operands: c.operands.map((o) => {
2143
+ const copy = { ...o };
2144
+ if (copy.kind === "mem" && copy.offset) copy.offset = { ...copy.offset };
2145
+ return copy;
2146
+ }),
2147
+ line
2148
+ };
2149
+ }
2126
2150
  function parse_asm_instruction(text, line) {
2151
+ const cached = parse_memo.get(text);
2152
+ if (cached !== void 0) return cached === null ? null : clone_parsed(cached, line);
2153
+ const parsed = parse_asm_instruction_uncached(text, line);
2154
+ if (parse_memo.size >= PARSE_MEMO_CAP) parse_memo.clear();
2155
+ parse_memo.set(text, parsed ? clone_parsed(parsed, 0) : null);
2156
+ return parsed;
2157
+ }
2158
+ function parse_asm_instruction_uncached(text, line) {
2127
2159
  const t = strip_comment$1(text).trim();
2128
2160
  const m = /^([a-z][a-z0-9.]*)\s*(.*)$/.exec(t);
2129
2161
  if (!m) return null;
@@ -2407,34 +2439,13 @@ function validate_asm(code) {
2407
2439
  }
2408
2440
  return errors;
2409
2441
  }
2410
- /**
2411
- * The deferred phase-1 check: every function's sp must return to its entry
2412
- * value at each `ret`. A LINEAR scan double-counts across diamond control
2413
- * flow (`b .epilogue` skipping the sibling path's `add sp` — the classic
2414
- * false positive on bool_to_string), so this runs a per-block dataflow over
2415
- * the lifted function bodies:
2416
- *
2417
- * - Block splitting: labels and branch/terminator instructions bound blocks,
2418
- * exactly like the CFG the emitters produce.
2419
- * - Per-instruction sp effects: pre-indexed `stp/str [sp, #-N]!` subtracts,
2420
- * post-indexed `ldp/ldr [sp], #N` adds, `sub/add sp, sp, #imm` adjusts;
2421
- * any other sp write (`mov sp, xN`, register-scaled adds, `ldr sp, …`)
2422
- * is UNKNOWN and conservatively poisons downstream deltas.
2423
- * - Join = require equal deltas or unknown: a label reached by paths with
2424
- * differing deltas becomes unknown; only a KNOWN non-zero delta at a
2425
- * `ret` is reported. Unknown never errors — the check is conservative by
2426
- * construction (no false positives, only silence).
2427
- * - `bl`/`blr` preserve sp (the callee rebalances under the ABI) and do not
2428
- * terminate blocks; `br xN` (tail-jump) ends propagation with no check;
2429
- * `svc` continues (the kernel preserves sp).
2430
- * - Branch targets resolve within the function, including the numeric local
2431
- * form raw `#arch` blocks use (`1:` / `b.hs 1f` — forward/backward by
2432
- * suffix); an unresolvable target makes downstream deltas unknown.
2433
- */
2434
- function validate_stack_balance(code) {
2435
- const { result } = lift_functions(code);
2436
- if (!result.ok) return [];
2442
+ /** Stack-balance check over an ALREADY-lifted result — lets the build
2443
+ * pipeline lift+validate once and share the structured functions between
2444
+ * the shape/branch validation and the balance pass (lift_functions runs
2445
+ * validate_asm internally, so re-lifting per check doubled the cost). */
2446
+ function stack_balance_errors_from_lift(result) {
2437
2447
  const errors = [];
2448
+ if (!result.ok) return [];
2438
2449
  for (const fn of result.functions) check_function_balance(fn, errors);
2439
2450
  return errors;
2440
2451
  }
@@ -2562,37 +2573,37 @@ function line_effect(text) {
2562
2573
  };
2563
2574
  }
2564
2575
  /** Resolve a branch target token to a body line index, or null when the
2565
- * target is outside this function / unresolved (numeric f/b forms scan
2566
- * linearly, matching the assembler's semantics). */
2567
- function resolve_target(token, from, body) {
2576
+ * target is outside this function. Backed by the per-function label maps
2577
+ * built once in check_function_balance — a linear rescan of the body per
2578
+ * pending branch used to be the single hottest spot in large builds
2579
+ * (branches × body-length re-stripping + regex). Numeric `Nf`/`Nb` forms
2580
+ * resolve directionally against the (possibly redefined) definitions,
2581
+ * matching the assembler's semantics; a named label resolves to its FIRST
2582
+ * definition (the old body scan returned the first match too). */
2583
+ function resolve_target(token, from, named_labels, numeric_labels) {
2568
2584
  const numeric = /^(\d+)([fb])$/.exec(token);
2569
2585
  if (numeric) {
2570
- const n = numeric[1];
2586
+ const defs = numeric_labels.get(numeric[1]);
2587
+ if (!defs) return null;
2571
2588
  if (numeric[2] === "f") {
2572
- for (let i = from + 1; i < body.length; i++) {
2573
- const lm = /^(\d+):/.exec(strip_comment$1(body[i].text).trim());
2574
- if (lm && lm[1] === n) return i;
2575
- }
2589
+ for (let k = 0; k < defs.length; k++) if (defs[k] > from) return defs[k];
2576
2590
  return null;
2577
2591
  }
2578
- for (let i = from - 1; i >= 0; i--) {
2579
- const lm = /^(\d+):/.exec(strip_comment$1(body[i].text).trim());
2580
- if (lm && lm[1] === n) return i;
2581
- }
2592
+ for (let k = defs.length - 1; k >= 0; k--) if (defs[k] < from) return defs[k];
2582
2593
  return null;
2583
2594
  }
2584
- for (let i = 0; i < body.length; i++) {
2585
- const lm = LABEL_RE.exec(strip_comment$1(body[i].text).trim());
2586
- if (lm && lm[1] === token) return i;
2587
- }
2588
- return null;
2595
+ return named_labels.get(token) ?? null;
2589
2596
  }
2590
2597
  function check_function_balance(fn, errors) {
2591
2598
  const body = fn.body;
2599
+ const stripped_text = [];
2592
2600
  const effects = [];
2593
2601
  const is_code = [];
2602
+ const named_labels = /* @__PURE__ */ new Map();
2603
+ const numeric_labels = /* @__PURE__ */ new Map();
2594
2604
  for (let i = 0; i < body.length; i++) {
2595
2605
  const t = strip_comment$1(body[i].text).trim();
2606
+ stripped_text.push(t);
2596
2607
  effects.push({
2597
2608
  delta: null,
2598
2609
  branch: null,
@@ -2601,8 +2612,15 @@ function check_function_balance(fn, errors) {
2601
2612
  unknown_jump: false
2602
2613
  });
2603
2614
  is_code.push(false);
2604
- if (!t) continue;
2605
2615
  const lm = LABEL_RE.exec(t);
2616
+ if (lm) {
2617
+ if (/^\d+$/.test(lm[1])) {
2618
+ const defs = numeric_labels.get(lm[1]);
2619
+ if (defs) defs.push(i);
2620
+ else numeric_labels.set(lm[1], [i]);
2621
+ } else if (!named_labels.has(lm[1])) named_labels.set(lm[1], i);
2622
+ }
2623
+ if (!t) continue;
2606
2624
  const rest = lm ? lm[2].trim() : t;
2607
2625
  if (rest.startsWith(".") || /^[\w.$]+\s*=\s*[\w.$]+$/.test(rest) || !rest) continue;
2608
2626
  is_code[i] = true;
@@ -2614,7 +2632,7 @@ function check_function_balance(fn, errors) {
2614
2632
  let cur = -1;
2615
2633
  let terminated = true;
2616
2634
  for (let i = 0; i < body.length; i++) {
2617
- const t = strip_comment$1(body[i].text).trim();
2635
+ const t = stripped_text[i];
2618
2636
  if (!t) continue;
2619
2637
  if (LABEL_RE.test(t)) {
2620
2638
  cur++;
@@ -2662,7 +2680,7 @@ function check_function_balance(fn, errors) {
2662
2680
  }
2663
2681
  }
2664
2682
  for (const p of pending) {
2665
- const line = resolve_target(p.token, p.from, body);
2683
+ const line = resolve_target(p.token, p.from, named_labels, numeric_labels);
2666
2684
  blocks[p.block].targets.push(line === null ? null : line_block.get(line) ?? null);
2667
2685
  }
2668
2686
  const preds = Array.from({ length: blocks.length }, () => []);
@@ -8972,6 +8990,85 @@ function record_ctor_heap_default_fields(var_name, struct_node, status, is_owned
8972
8990
  }
8973
8991
  }
8974
8992
  //#endregion
8993
+ //#region ../src/build_common/destroy_analysis.ts
8994
+ /**
8995
+ * Whether any field of the struct is an enum-with-data value owning heap
8996
+ * payloads (a string payload — always heap, case construction strdups it —
8997
+ * or a class/trait reference payload — construction transfers ownership).
8998
+ * Such a field must be reclaimed when the struct dies.
8999
+ */
9000
+ function struct_has_owning_enum_field(struct, table) {
9001
+ for (const field of struct.fields) {
9002
+ if (field.type.is_ref) continue;
9003
+ const en = table.enums?.find((e) => e.name === field.type.name);
9004
+ if (!en?.has_associated_data) continue;
9005
+ for (const c of en.cases) for (const p of c.params) {
9006
+ if (p.type.name === "string" && !p.type.is_array) return true;
9007
+ if (table.traits?.some((t) => t.name === p.type.name)) return true;
9008
+ if (table.structs.find((s) => s.name === p.type.name)?.is_class) return true;
9009
+ }
9010
+ }
9011
+ return false;
9012
+ }
9013
+ /**
9014
+ * Whether a struct declares its own `#destroy` (user side effects /
9015
+ * resource release).
9016
+ */
9017
+ function has_destroy(struct) {
9018
+ return !!struct.functions.find((f) => f.name === "#destroy");
9019
+ }
9020
+ /**
9021
+ * Whether any field (recursively) requires destroy handling — a class
9022
+ * field (heap instance to reclaim) or a nested struct with its own
9023
+ * `#destroy` (or nested fields that do). Deliberately excludes `string`
9024
+ * fields: a struct local whose only owning fields are strings is NOT
9025
+ * auto-destroyed at scope exit (the strings may be raw rodata arg
9026
+ * pointers, not heap) — see `struct_needs_auto_destroy` for the broader
9027
+ * form used to decide whether a `<Struct>_destroy` function is generated.
9028
+ */
9029
+ function has_struct_fields_with_destroy(struct, table) {
9030
+ for (const field of struct.fields) {
9031
+ if (field.type.is_ref) continue;
9032
+ const field_struct = resolve_struct_type(field.type, table);
9033
+ if (!field_struct) continue;
9034
+ if (field_struct.is_class) return true;
9035
+ if (has_destroy(field_struct)) return true;
9036
+ if (has_struct_fields_with_destroy(field_struct, table)) return true;
9037
+ }
9038
+ return false;
9039
+ }
9040
+ /**
9041
+ * Whether a struct (or any embedded struct field, recursively) needs a
9042
+ * destroy call at scope exit — it has a `#destroy`, a class-typed field,
9043
+ * or a nested struct field that itself needs destroying. This does NOT
9044
+ * count `string` fields (see `has_struct_fields_with_destroy`).
9045
+ */
9046
+ function struct_needs_destroy(struct, table) {
9047
+ return has_destroy(struct) || has_struct_fields_with_destroy(struct, table) || struct_has_owning_enum_field(struct, table);
9048
+ }
9049
+ /**
9050
+ * Whether a struct needs an auto-generated `<Struct>_destroy` function.
9051
+ * Broader than `struct_needs_destroy`: also returns true for structs with
9052
+ * `string` fields, because a Buffer<T> for such a struct deep-copies the
9053
+ * strings into slots (strdup on store) and the per-element destroy must
9054
+ * free them. The generated destroy frees each string field; it is called
9055
+ * from Buffer's specialized #destroy / replace_T, NOT from struct local
9056
+ * scope exit (`struct_needs_destroy` governs that path and excludes
9057
+ * strings).
9058
+ */
9059
+ function struct_needs_auto_destroy(struct, table) {
9060
+ if (struct_needs_destroy(struct, table)) return true;
9061
+ if (struct_has_owning_enum_field(struct, table)) return true;
9062
+ for (const field of struct.fields) {
9063
+ if (field.type.is_ref) continue;
9064
+ if (field.type.is_view) continue;
9065
+ if (field.type.name === "string" && !field.type.is_array) return true;
9066
+ const field_struct = resolve_struct_type(field.type, table);
9067
+ if (field_struct && !field_struct.is_class && struct_needs_auto_destroy(field_struct, table)) return true;
9068
+ }
9069
+ return false;
9070
+ }
9071
+ //#endregion
8975
9072
  //#region ../src/nodes/child_nodes.ts
8976
9073
  /** Whether `value` is an AST node (carries a string `node_type`). */
8977
9074
  function is_ast_node(value) {
@@ -11648,85 +11745,6 @@ function array_struct_name(type, status) {
11648
11745
  return status.structs.find((s) => s.name === mono && !s.is_generic) ? mono : void 0;
11649
11746
  }
11650
11747
  //#endregion
11651
- //#region ../src/build_common/destroy_analysis.ts
11652
- /**
11653
- * Whether any field of the struct is an enum-with-data value owning heap
11654
- * payloads (a string payload — always heap, case construction strdups it —
11655
- * or a class/trait reference payload — construction transfers ownership).
11656
- * Such a field must be reclaimed when the struct dies.
11657
- */
11658
- function struct_has_owning_enum_field(struct, table) {
11659
- for (const field of struct.fields) {
11660
- if (field.type.is_ref) continue;
11661
- const en = table.enums?.find((e) => e.name === field.type.name);
11662
- if (!en?.has_associated_data) continue;
11663
- for (const c of en.cases) for (const p of c.params) {
11664
- if (p.type.name === "string" && !p.type.is_array) return true;
11665
- if (table.traits?.some((t) => t.name === p.type.name)) return true;
11666
- if (table.structs.find((s) => s.name === p.type.name)?.is_class) return true;
11667
- }
11668
- }
11669
- return false;
11670
- }
11671
- /**
11672
- * Whether a struct declares its own `#destroy` (user side effects /
11673
- * resource release).
11674
- */
11675
- function has_destroy(struct) {
11676
- return !!struct.functions.find((f) => f.name === "#destroy");
11677
- }
11678
- /**
11679
- * Whether any field (recursively) requires destroy handling — a class
11680
- * field (heap instance to reclaim) or a nested struct with its own
11681
- * `#destroy` (or nested fields that do). Deliberately excludes `string`
11682
- * fields: a struct local whose only owning fields are strings is NOT
11683
- * auto-destroyed at scope exit (the strings may be raw rodata arg
11684
- * pointers, not heap) — see `struct_needs_auto_destroy` for the broader
11685
- * form used to decide whether a `<Struct>_destroy` function is generated.
11686
- */
11687
- function has_struct_fields_with_destroy(struct, table) {
11688
- for (const field of struct.fields) {
11689
- if (field.type.is_ref) continue;
11690
- const field_struct = resolve_struct_type(field.type, table);
11691
- if (!field_struct) continue;
11692
- if (field_struct.is_class) return true;
11693
- if (has_destroy(field_struct)) return true;
11694
- if (has_struct_fields_with_destroy(field_struct, table)) return true;
11695
- }
11696
- return false;
11697
- }
11698
- /**
11699
- * Whether a struct (or any embedded struct field, recursively) needs a
11700
- * destroy call at scope exit — it has a `#destroy`, a class-typed field,
11701
- * or a nested struct field that itself needs destroying. This does NOT
11702
- * count `string` fields (see `has_struct_fields_with_destroy`).
11703
- */
11704
- function struct_needs_destroy(struct, table) {
11705
- return has_destroy(struct) || has_struct_fields_with_destroy(struct, table) || struct_has_owning_enum_field(struct, table);
11706
- }
11707
- /**
11708
- * Whether a struct needs an auto-generated `<Struct>_destroy` function.
11709
- * Broader than `struct_needs_destroy`: also returns true for structs with
11710
- * `string` fields, because a Buffer<T> for such a struct deep-copies the
11711
- * strings into slots (strdup on store) and the per-element destroy must
11712
- * free them. The generated destroy frees each string field; it is called
11713
- * from Buffer's specialized #destroy / replace_T, NOT from struct local
11714
- * scope exit (`struct_needs_destroy` governs that path and excludes
11715
- * strings).
11716
- */
11717
- function struct_needs_auto_destroy(struct, table) {
11718
- if (struct_needs_destroy(struct, table)) return true;
11719
- if (struct_has_owning_enum_field(struct, table)) return true;
11720
- for (const field of struct.fields) {
11721
- if (field.type.is_ref) continue;
11722
- if (field.type.is_view) continue;
11723
- if (field.type.name === "string" && !field.type.is_array) return true;
11724
- const field_struct = resolve_struct_type(field.type, table);
11725
- if (field_struct && !field_struct.is_class && struct_needs_auto_destroy(field_struct, table)) return true;
11726
- }
11727
- return false;
11728
- }
11729
- //#endregion
11730
11748
  //#region ../src/build_common/scan_force_heap_strings.ts
11731
11749
  function scan_force_heap_strings(statements, structs) {
11732
11750
  const result = /* @__PURE__ */ new Set();
@@ -14909,7 +14927,33 @@ function emit_base_ptr(status, decl_name, is_class_parent) {
14909
14927
  if (is_class_parent) emit_var_load(status, "x0", decl_name, 8);
14910
14928
  else emit_var_address(status, "x0", decl_name);
14911
14929
  }
14912
- function emit_field_destroys(status, struct_type, decl_name, base_offset, is_class_parent, free_strings = true) {
14930
+ /**
14931
+ * Reclaim a struct's owning fields in place (base + every nested owning field).
14932
+ *
14933
+ * `base_offset` (added to each field offset) and `is_class_parent` (a class's
14934
+ * base is a pointer LOADED from the slot; a value struct is inline AT it)
14935
+ * locate `decl_name`'s storage.
14936
+ *
14937
+ * `free_strings` is REQUIRED — it is a per-call-site OWNOWNITY fact, not a
14938
+ * per-type one, so it deliberately has no default:
14939
+ * - `true` from a DESTROY function (`<T>_destroy`), which only ever runs on
14940
+ * heap-owned storage: a class's plain string fields are always heap-owned
14941
+ * (`_init` strdup's defaults, assignments strdup a non-heap RHS), and a
14942
+ * VALUE struct arrives with the per-slot copies `store_T`/`_copy` strdup'd
14943
+ * (Buffer per-element teardown, the displaced-field reclaim, the
14944
+ * anchor-slot walk). Matches C's build_auto_destroy, which frees string
14945
+ * fields unconditionally.
14946
+ * - `false` from struct-LOCAL scope exit, where a string field may still hold
14947
+ * a rodata literal (aarch64 constructors don't strdup) and freeing it would
14948
+ * `free` rodata (SIGABRT).
14949
+ *
14950
+ * A default (`= true`) previously stood in for the `true` arm while
14951
+ * `node.is_class` (undefined on plain parsed structs) was passed as if it
14952
+ * selected between the arms — so the day `is_class` becomes explicitly false,
14953
+ * value-struct slot strings would silently stop being reclaimed. Passing the
14954
+ * boolean at every call site makes both arms visible.
14955
+ */
14956
+ function emit_field_destroys(status, struct_type, decl_name, base_offset, is_class_parent, free_strings) {
14913
14957
  const destroy_fields = struct_type.is_class ? layout_fields(struct_type, status) : struct_type.fields;
14914
14958
  for (const field of destroy_fields) {
14915
14959
  const offset = get_field_offset_of_fields(destroy_fields, field.name, status);
@@ -21215,7 +21259,7 @@ function build_destroy_function(node, func, status) {
21215
21259
  status.code = status.code.slice(0, after_prologue) + saves + status.code.slice(after_prologue);
21216
21260
  }
21217
21261
  }
21218
- if (node.is_class) emit_field_destroys(status, node, "self", void 0, false);
21262
+ if (node.is_class) emit_field_destroys(status, node, "self", void 0, false, true);
21219
21263
  emit_asm(status, `${return_label}:\n`);
21220
21264
  const total_stack = Math.ceil((status.stack_size || 0) / 16) * 16;
21221
21265
  status.code = status.code.replace(`sub sp, sp, #${stack_placeholder}`, total_stack > 0 ? `sub sp, sp, #${total_stack}` : `// no stack needed`);
@@ -21272,7 +21316,7 @@ function build_auto_destroy_function(node, status) {
21272
21316
  status.function_param_regs.set("self", "x19");
21273
21317
  emit_asm(status, `sub sp, sp, #${stack_placeholder}\n`);
21274
21318
  emit_asm(status, `mov x29, sp\n`);
21275
- emit_field_destroys(status, node, "self", void 0, false, node.is_class);
21319
+ emit_field_destroys(status, node, "self", void 0, false, true);
21276
21320
  emit_asm(status, `${return_label}:\n`);
21277
21321
  const total_stack = Math.ceil((status.stack_size || 0) / 16) * 16;
21278
21322
  status.code = status.code.replace(`sub sp, sp, #${stack_placeholder}`, total_stack > 0 ? `sub sp, sp, #${total_stack}` : `// no stack needed`);
@@ -24988,6 +25032,17 @@ function build_declaration_node$1(node, status, nir_init, nir_swap) {
24988
25032
  check_heap();
24989
25033
  return;
24990
25034
  }
25035
+ const decl_string_field_read = node.type.name === "string" && !node.type.is_view && !node.type.is_array && size === 16 && node.value.node_type === "access" && node.value.access.node_type === "access_field";
25036
+ if (decl_string_field_read && !!status.force_heap_strings?.has(node.name)) {
25037
+ emit_init_value$1(node.value, nir_init, status);
25038
+ ensure_newline(status);
25039
+ emit_strdup_string(status);
25040
+ emit_var_store(status, "x0", node.name, size);
25041
+ mark_heap_string(status, node.name);
25042
+ status.last_result_is_heap = false;
25043
+ return;
25044
+ }
25045
+ if (decl_string_field_read) status.last_result_is_heap = false;
24991
25046
  emit_init_value$1(node.value, nir_init, status);
24992
25047
  emit_var_store(status, "x0", node.name, size);
24993
25048
  check_heap();
@@ -41684,7 +41739,9 @@ function build_assignment_node(node, status, nir_rhs, nir_swap) {
41684
41739
  const rhs_is_string_var = !node.swap && !!rhs_value && typeof rhs_value.value === "string" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(rhs_value.value) && rhs_value.value !== "true" && rhs_value.value !== "false" && rhs_value.value !== "null" && !rhs_value.is_moved && size === 16 && rhs_value.type?.name === "string" && !rhs_value.type?.is_view;
41685
41740
  const move_source = rhs_is_string_var && node.last_use_move && move_on_last_use_enabled() ? rhs_value.value : void 0;
41686
41741
  const explicit_move_source = !node.swap && !!rhs_value && rhs_value.is_moved && size === 16 && rhs_value.type?.name === "string" && !rhs_value.type?.is_view ? rhs_value.value : void 0;
41687
- const borrow_needs_dup = !node.swap && size === 16 && is_string_borrow(node.right_value) && !!status.force_heap_strings?.has(name);
41742
+ const rhs_is_borrow_reception = !node.swap && size === 16 && is_string_borrow(node.right_value);
41743
+ const rhs_is_field_read = !node.swap && size === 16 && node.right_value.node_type === "access" && node.right_value.access.node_type === "access_field";
41744
+ const borrow_needs_dup = (rhs_is_borrow_reception || rhs_is_field_read) && !!status.force_heap_strings?.has(name);
41688
41745
  const move_source_owns_heap = move_source !== void 0 && !!status.heap_strings?.has(move_source);
41689
41746
  if (rhs_is_view_into_owned) {
41690
41747
  if (!status.heap_strings) status.heap_strings = /* @__PURE__ */ new Set();
@@ -41694,7 +41751,8 @@ function build_assignment_node(node, status, nir_rhs, nir_swap) {
41694
41751
  if (move_source !== void 0 && move_source_owns_heap) status.heap_strings?.delete(move_source);
41695
41752
  if (!status.heap_strings) status.heap_strings = /* @__PURE__ */ new Set();
41696
41753
  status.heap_strings.add(name);
41697
- } else if (status.last_result_is_heap) {
41754
+ } else if (rhs_is_field_read) status.last_result_is_heap = false;
41755
+ else if (status.last_result_is_heap) {
41698
41756
  if (!status.heap_strings) status.heap_strings = /* @__PURE__ */ new Set();
41699
41757
  status.heap_strings.add(name);
41700
41758
  } else if (explicit_move_source !== void 0 && status.heap_strings?.delete(explicit_move_source)) status.heap_strings.add(name);
@@ -41911,6 +41969,22 @@ function build_assignment_node(node, status, nir_rhs, nir_swap) {
41911
41969
  get_base_address(access, status, "x0");
41912
41970
  emit_asm(status, `str x0, [sp, #-16]!\n`);
41913
41971
  }
41972
+ if (!node.operator && node.right_value.node_type === "value" && node.right_value.is_moved) {
41973
+ const field_resolved = resolve_struct_type(field_type, status);
41974
+ const field_target_var = access.target.node_type === "value" ? access.target.value : "";
41975
+ const first_init_write = is_first_init_field_write(field_target_var, field_name, status);
41976
+ mark_init_field_write(field_target_var, field_name, status);
41977
+ if (field_resolved && !field_resolved.is_class && !first_init_write && struct_needs_auto_destroy(field_resolved, status)) {
41978
+ if (base_reg !== void 0) {
41979
+ if (offset !== 0) emit_asm(status, `add x0, ${base_reg}, #${offset}\n`);
41980
+ else emit_asm(status, `mov x0, ${base_reg}\n`);
41981
+ } else {
41982
+ emit_asm(status, `ldr x0, [sp]\n`);
41983
+ if (offset !== 0) emit_asm(status, `add x0, x0, #${offset}\n`);
41984
+ }
41985
+ emit_asm(status, `bl ${field_resolved.name}_destroy\n`);
41986
+ }
41987
+ }
41914
41988
  get_source_address(node.right_value, status, nir_rhs);
41915
41989
  ensure_newline(status);
41916
41990
  emit_asm(status, `mov x1, x0\n`);
@@ -47312,7 +47386,8 @@ function build(root, options = {}) {
47312
47386
  status.code = status.code.replaceAll("bl _strdup\n", "bl _nomen_strdup_wrap\n");
47313
47387
  status.code = status.code.replaceAll("bl _free\n", "bl _nomen_free_wrap\n");
47314
47388
  }
47315
- const lift_errors = validate_asm(status.code);
47389
+ const { result: lifted } = lift_functions(status.code);
47390
+ const lift_errors = lifted.errors;
47316
47391
  if (lift_errors.length > 0) {
47317
47392
  if (!status.build_errors) status.build_errors = [];
47318
47393
  for (const e of lift_errors.slice(0, 20)) status.build_errors.push({
@@ -47320,7 +47395,7 @@ function build(root, options = {}) {
47320
47395
  start: 0
47321
47396
  });
47322
47397
  }
47323
- const balance_errors = validate_stack_balance(status.code);
47398
+ const balance_errors = stack_balance_errors_from_lift(lifted);
47324
47399
  if (balance_errors.length > 0) {
47325
47400
  if (!status.build_errors) status.build_errors = [];
47326
47401
  for (const e of balance_errors.slice(0, 20)) status.build_errors.push({
@@ -63125,11 +63200,19 @@ function generate_harness(tests, benches) {
63125
63200
  const n = Math.min(raw, 4096);
63126
63201
  return template.replace(/__NAME__/g, b.name).replace(/__TARGET__/g, b.target).replace(/__N__/g, String(n));
63127
63202
  }).join("\n");
63128
- let main = "\nimport System\nimport System::Test\n\npub func main = () {\n";
63203
+ let main = "\nimport System\nimport System::Test\n";
63204
+ main += "\npub func main = () {\n";
63129
63205
  main += " var Tester t = Tester()\n";
63206
+ main += " var int64 audit_mark = 0\n";
63207
+ main += " var int64 audit_leaked = 0\n";
63130
63208
  for (const test of tests) {
63209
+ main += `\taudit_mark = Tester.audit_count()\n`;
63131
63210
  main += `\tt.begin_test("${escape_nm_string(test.name)}")\n`;
63132
63211
  main += `\t${test.name}(ref t)\n`;
63212
+ main += `\taudit_leaked = Tester.audit_count() - audit_mark\n`;
63213
+ main += `\tif audit_leaked > 0 {\n`;
63214
+ main += `\t\tConsole.write_line("\\\\nomen|leaks|" + "${escape_nm_string(test.name)}" + "|\\{audit_leaked}")\n`;
63215
+ main += `\t}\n`;
63133
63216
  main += `\tt.end_test()\n`;
63134
63217
  }
63135
63218
  for (const bench of benches) {
@@ -63148,6 +63231,7 @@ function parse_records(stdout) {
63148
63231
  const tests = [];
63149
63232
  const fails = [];
63150
63233
  const benches = [];
63234
+ const leaks = [];
63151
63235
  const other = [];
63152
63236
  for (const line of stdout.split("\n")) {
63153
63237
  if (!line.startsWith(RECORD_PREFIX)) {
@@ -63166,6 +63250,10 @@ function parse_records(stdout) {
63166
63250
  test: parts[1],
63167
63251
  message: parts.slice(2).join("|")
63168
63252
  });
63253
+ else if (kind === "leaks") leaks.push({
63254
+ test: parts[1],
63255
+ count: parseInt(parts[2] || "0", 10)
63256
+ });
63169
63257
  else if (kind === "bench") benches.push({
63170
63258
  label: parts[1],
63171
63259
  n: parseInt(parts[2] || "0", 10),
@@ -63180,12 +63268,19 @@ function parse_records(stdout) {
63180
63268
  tests,
63181
63269
  fails,
63182
63270
  benches,
63271
+ leaks,
63183
63272
  other
63184
63273
  };
63185
63274
  }
63186
- /** Pull the exit-time audit "LEAK: N allocation(s)" lines out of stdout. */
63275
+ /**
63276
+ * Pull the exit-time audit lines out of stdout: "LEAK: N allocation(s)" (a
63277
+ * nonzero malloc/free balance) and "AUDIT-STALE-FREE: N pointer(s) freed that
63278
+ * were not live audit allocations" (a free that matched no live block — a
63279
+ * foreign pointer or a double free; the runtime no longer passes those to
63280
+ * libc free, so this is the only record they leave). Both fail the file.
63281
+ */
63187
63282
  function extract_leaks(stdout) {
63188
- return stdout.split("\n").map((l) => l.trim()).filter((l) => l.startsWith("LEAK:"));
63283
+ return stdout.split("\n").map((l) => l.trim()).filter((l) => l.startsWith("LEAK:") || l.startsWith("AUDIT-STALE-FREE:"));
63189
63284
  }
63190
63285
  /**
63191
63286
  * Compile one `*.test.nm` file (with the generated harness) and run it,
@@ -63201,8 +63296,9 @@ async function run_test_file(entry_path, lib_path, arch, audit = false, audit_ru
63201
63296
  tests: [],
63202
63297
  fails: [],
63203
63298
  benches: [],
63204
- other: [],
63205
63299
  leaks: [],
63300
+ leaking_tests: [],
63301
+ other: [],
63206
63302
  ms: 0
63207
63303
  };
63208
63304
  const resolved = path.resolve(entry_path);
@@ -63230,24 +63326,21 @@ async function run_test_file(entry_path, lib_path, arch, audit = false, audit_ru
63230
63326
  }
63231
63327
  const buildDir = build_dir_for(resolved, true);
63232
63328
  if (!fs.existsSync(buildDir)) fs.mkdirSync(buildDir, { recursive: true });
63233
- let audit_obj;
63234
- if (audit) {
63235
- const runtime_src = audit_runtime ? path.resolve(audit_runtime) : find_audit_runtime(path.dirname(resolved)) ?? find_bundled_audit_runtime();
63236
- if (!runtime_src || !fs.existsSync(runtime_src)) {
63237
- result.ok = false;
63238
- result.phase = "setup";
63239
- result.crashed = "Audit enabled but audit_runtime.c was not found. Pass --audit-runtime <path>.";
63240
- result.ms = performance.now() - start;
63241
- return result;
63242
- }
63243
- audit_obj = path.join(buildDir, "audit_runtime.o");
63244
- await execFile_async("clang", [
63245
- "-c",
63246
- runtime_src,
63247
- "-o",
63248
- audit_obj
63249
- ]);
63329
+ const runtime_src = audit_runtime ? path.resolve(audit_runtime) : find_audit_runtime(path.dirname(resolved)) ?? find_bundled_audit_runtime();
63330
+ if (!runtime_src || !fs.existsSync(runtime_src)) {
63331
+ result.ok = false;
63332
+ result.phase = "setup";
63333
+ result.crashed = "audit_runtime.c was not found. Pass --audit-runtime <path>.";
63334
+ result.ms = performance.now() - start;
63335
+ return result;
63250
63336
  }
63337
+ const audit_obj = path.join(buildDir, "audit_runtime.o");
63338
+ await execFile_async("clang", [
63339
+ "-c",
63340
+ runtime_src,
63341
+ "-o",
63342
+ audit_obj
63343
+ ]);
63251
63344
  const platform = default_platform();
63252
63345
  const ext = arch === "aarch64" ? ".s" : platform === "macos" || platform === "ios" ? ".m" : ".c";
63253
63346
  const codefile = path.join(buildDir, path.basename(entry_path, ".nm") + ext);
@@ -63297,9 +63390,10 @@ async function run_test_file(entry_path, lib_path, arch, audit = false, audit_ru
63297
63390
  result.tests = records.tests;
63298
63391
  result.fails = records.fails;
63299
63392
  result.benches = records.benches;
63393
+ result.leaking_tests = records.leaks;
63300
63394
  result.other = records.other;
63301
63395
  result.leaks = audit ? extract_leaks(runStdout) : [];
63302
- if (result.leaks.length) result.other = result.other.filter((l) => !l.trim().startsWith("LEAK:"));
63396
+ if (result.leaks.length) result.other = result.other.filter((l) => !l.trim().startsWith("LEAK:") && !l.trim().startsWith("AUDIT-STALE-FREE:"));
63303
63397
  result.crashed = crashed;
63304
63398
  if (crashed) result.phase = "run";
63305
63399
  result.ok = !(result.fails.length > 0 || result.tests.some((t) => t.failed > 0)) && crashed === void 0 && result.leaks.length === 0;
@@ -63340,6 +63434,10 @@ function report_file(result) {
63340
63434
  if (result.crashed) console.log(C.red(` ${result.crashed}`));
63341
63435
  for (const f of result.fails) console.log(` ${C.red("✗")} ${f.test} ${C.dim(">")} ${f.message}`);
63342
63436
  for (const leak of result.leaks) console.log(` ${C.yellow("⚠")} ${C.yellow(leak)}`);
63437
+ if (result.leaks.length) {
63438
+ for (const lt of result.leaking_tests) console.log(` ${C.yellow("⚠")} ${lt.test} ${C.dim(`leaked ${lt.count} allocation(s)`)}`);
63439
+ if (!result.leaking_tests.length) console.log(` ${C.yellow("⚠")} ${C.yellow("LEAK: no per-test attribution — leaked outside a test (harness/setup/teardown) or crashed mid-test")}`);
63440
+ }
63343
63441
  for (const b of result.benches) console.log(` ${C.cyan("⏱")} ${b.label} ${C.dim(`(n=${b.n})`)} ${C.dim("min")} ${fmt_ns(b.min)} ${C.dim("median")} ${fmt_ns(b.median)} ${C.dim("mean")} ${fmt_ns(Math.round(b.mean))} ${C.dim("max")} ${fmt_ns(b.max)} ${C.dim("±")} ${fmt_ns(Math.round(b.stddev))}`);
63344
63442
  if (result.other.length) {
63345
63443
  console.log(C.dim(" --- test stdout ---"));
@@ -63395,11 +63493,12 @@ async function runTests(root, options = {}) {
63395
63493
  const totalFailed = results.reduce((a, r) => a + r.tests.reduce((x, t) => x + t.failed, 0), 0);
63396
63494
  const totalPassed = totalTests - totalFailed;
63397
63495
  const totalLeaks = results.reduce((a, r) => a + r.leaks.length, 0);
63496
+ const totalLeakingTests = results.reduce((a, r) => a + r.leaking_tests.length, 0);
63398
63497
  const anyFailed = results.some((r) => !r.ok);
63399
63498
  console.log("");
63400
63499
  console.log(` ${C.dim("Files ")} ${C.green(`${totalFilesPassed} passed`)}` + (totalFilesFailed ? ` ${C.dim("|")} ${C.red(`${totalFilesFailed} failed`)}` : "") + C.dim(` (${totalFiles})`));
63401
63500
  console.log(` ${C.dim("Tests ")} ${C.green(`${totalPassed} passed`)}` + (totalFailed ? ` ${C.dim("|")} ${C.red(`${totalFailed} failed`)}` : "") + C.dim(` (${totalTests})`));
63402
- if (totalLeaks) console.log(` ${C.dim("Leaks ")} ${C.yellow(`${totalLeaks} reported`)}` + C.dim(" (--audit; leaked files count as failures)"));
63501
+ if (totalLeaks) console.log(` ${C.dim("Leaks ")} ${C.yellow(`${totalLeakingTests} leaking test(s)`)}` + (totalLeakingTests !== totalLeaks ? C.dim(` (${totalLeaks} leaked file(s))`) : "") + C.dim(" (--audit; leaking tests fail their file)"));
63403
63502
  console.log(` ${C.dim(" Time ")} ${format_duration(elapsed)}`);
63404
63503
  console.log("");
63405
63504
  return !anyFailed;
@@ -63681,7 +63780,7 @@ function processFile(filename, config, mode, program_args) {
63681
63780
  console.log(`Created ${codefile} in ${(compileTime - startTime).toFixed(2)}ms`);
63682
63781
  console.log("");
63683
63782
  startTime = performance.now();
63684
- const audit_obj = config.audit ? compile_audit_runtime(config, resolved, buildDir) : void 0;
63783
+ const audit_obj = config.audit || result.code.includes("nomen_audit_count") ? compile_audit_runtime(config, resolved, buildDir) : void 0;
63685
63784
  let link_inputs = codefile;
63686
63785
  if (companionfile) link_inputs += ` ${companionfile}`;
63687
63786
  if (audit_obj) link_inputs += ` ${audit_obj}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nomen-lang",
3
- "version": "0.6.9",
3
+ "version": "0.6.10",
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
@@ -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,25 @@ 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";
205
223
  main += "\tvar Tester t = Tester()\n";
224
+ main += "\tvar int64 audit_mark = 0\n";
225
+ main += "\tvar int64 audit_leaked = 0\n";
206
226
  for (const test of tests) {
227
+ main += `\taudit_mark = Tester.audit_count()\n`;
207
228
  main += `\tt.begin_test("${escape_nm_string(test.name)}")\n`;
208
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`;
209
234
  main += `\tt.end_test()\n`;
210
235
  }
211
236
  for (const bench of benches) {
@@ -227,6 +252,7 @@ interface RunRecord {
227
252
  tests: TestRecord[];
228
253
  fails: FailRecord[];
229
254
  benches: BenchRecord[];
255
+ leaks: LeakRecord[];
230
256
  other: string[];
231
257
  }
232
258
 
@@ -235,6 +261,7 @@ export function parse_records(stdout: string): RunRecord {
235
261
  const tests: TestRecord[] = [];
236
262
  const fails: FailRecord[] = [];
237
263
  const benches: BenchRecord[] = [];
264
+ const leaks: LeakRecord[] = [];
238
265
  const other: string[] = [];
239
266
  for (const line of stdout.split("\n")) {
240
267
  if (!line.startsWith(RECORD_PREFIX)) {
@@ -254,6 +281,8 @@ export function parse_records(stdout: string): RunRecord {
254
281
  });
255
282
  } else if (kind === "fail") {
256
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) });
257
286
  } else if (kind === "bench") {
258
287
  benches.push({
259
288
  label: parts[1],
@@ -266,15 +295,21 @@ export function parse_records(stdout: string): RunRecord {
266
295
  });
267
296
  }
268
297
  }
269
- return { tests, fails, benches, other };
298
+ return { tests, fails, benches, leaks, other };
270
299
  }
271
300
 
272
- /** 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
+ */
273
308
  export function extract_leaks(stdout: string): string[] {
274
309
  return stdout
275
310
  .split("\n")
276
311
  .map((l) => l.trim())
277
- .filter((l) => l.startsWith("LEAK:"));
312
+ .filter((l) => l.startsWith("LEAK:") || l.startsWith("AUDIT-STALE-FREE:"));
278
313
  }
279
314
 
280
315
  /**
@@ -302,8 +337,9 @@ export async function run_test_file(
302
337
  tests: [],
303
338
  fails: [],
304
339
  benches: [],
305
- other: [],
306
340
  leaks: [],
341
+ leaking_tests: [],
342
+ other: [],
307
343
  ms: 0,
308
344
  };
309
345
 
@@ -343,29 +379,27 @@ export async function run_test_file(
343
379
 
344
380
  const buildDir = build_dir_for(resolved, true);
345
381
  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]);
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;
368
400
  }
401
+ const audit_obj = path.join(buildDir, "audit_runtime.o");
402
+ await execFile_async("clang", ["-c", runtime_src, "-o", audit_obj]);
369
403
  const platform = default_platform();
370
404
  // Match the run/build command's emission: ObjC-bearing sources (GUI code
371
405
  // pulled in through the library graph) must land in a `.m` TU on Apple
@@ -454,15 +488,22 @@ export async function run_test_file(
454
488
  result.tests = records.tests;
455
489
  result.fails = records.fails;
456
490
  result.benches = records.benches;
491
+ result.leaking_tests = records.leaks;
457
492
  result.other = records.other;
458
493
  // 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.
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.)
461
500
  result.leaks = audit ? extract_leaks(runStdout) : [];
462
- // Leak lines are surfaced explicitly above; drop them from the generic
501
+ // Audit lines are surfaced explicitly above; drop them from the generic
463
502
  // stdout dump so they are not printed twice.
464
503
  if (result.leaks.length) {
465
- 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
+ );
466
507
  }
467
508
  result.crashed = crashed;
468
509
  if (crashed) result.phase = "run";
@@ -535,6 +576,20 @@ export function report_file(result: TestFileResult): void {
535
576
  for (const leak of result.leaks) {
536
577
  console.log(` ${C.yellow("⚠")} ${C.yellow(leak)}`);
537
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
+ }
538
593
  for (const b of result.benches) {
539
594
  console.log(
540
595
  ` ${C.cyan("⏱")} ${b.label} ${C.dim(`(n=${b.n})`)} ` +
@@ -641,6 +696,12 @@ export async function runTests(root: string, options: RunTestsOptions = {}): Pro
641
696
  const totalFailed = results.reduce((a, r) => a + r.tests.reduce((x, t) => x + t.failed, 0), 0);
642
697
  const totalPassed = totalTests - totalFailed;
643
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);
644
705
  const anyFailed = results.some((r) => !r.ok);
645
706
 
646
707
  console.log("");
@@ -659,8 +720,9 @@ export async function runTests(root: string, options: RunTestsOptions = {}): Pro
659
720
  );
660
721
  if (totalLeaks) {
661
722
  console.log(
662
- ` ${C.dim("Leaks ")} ${C.yellow(`${totalLeaks} reported`)}` +
663
- 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)"),
664
726
  );
665
727
  }
666
728
  console.log(` ${C.dim(" Time ")} ${format_duration(elapsed)}`);