Skip to content

Commit 2828448

Browse files
committed
feat(meta): D-563(b) — Var :line/:column/:file source meta; def meta rides the AOT wire (envelope v9)
Smell-audited: 2: the meta-as-code design pivoted twice in-cycle (analyze raw → NameError on defn's raw :arglists lists; measured that quoted composites DO round-trip the wire) and settled on wholesale-quote = ONE composite constant preserving cljw's static-data meta semantics — the measurement-first pivot is recorded here + in the debt row. defmacro's def_node meta_expr is a named residual (D-563 row). - analyzeDef mints {:line :column :file} under user ^meta/:doc (compiler wins on collision — clj-oracle: ^{:line 999} yields the real line). - DefNode.meta_expr evaluated at def time by BOTH backends: tree_walk evalDef + new op_var_meta (0x56; pops meta, sets Var.meta, var stays). Dual-backend contract: TreeWalk arm + compile arm + dispatch arm + diff case in this commit. - fix(aot): def meta now survives `cljw build` artifacts — docstrings were silently LOST in built apps ((:doc (meta #'f)) → nil) while the lazy-source bootstrap kept them; envelope v8 → v9 (new opcode). - clojure.test FAIL/ERROR lines regain the ` (file:line)` suffix from the deftest var's meta; AD-041 NARROWED (cljw points at the deftest line, clj at the assertion's stack line — no JVM stack to read). e2e phase15_var_source_meta (8 cases incl. AOT round-trip + fixture file line/path) + phase15_clojure_test_output pins updated.
1 parent 83fba73 commit 2828448

14 files changed

Lines changed: 225 additions & 31 deletions

File tree

‎.dev/accepted_divergences.yaml‎

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -599,20 +599,21 @@ accepted:
599599
derives_from: "F-011 (behavioural equivalence is the contract; the macros run identically — only the intermediate macroexpand-1 FORM differs; cljw's Zig transforms lower eagerly in one step). Functionally-equivalent expansion form, like ClojureScript's macroexpansions vs JVM clj."
600600
pin: "test/e2e/phase14_when_if_not.sh — `cond_expand_full` locks (macroexpand-1 '(cond a 1 :else 2)) => (if a 1 (if :else 2 nil)) (cljw's full form), and `when_expand_multi`/`when_expand_single` lock when's byte-match with clj"
601601
- id: "AD-041"
602-
summary: "clojure.test's FAIL/ERROR report line omits clj's ` (file:line)` source-location suffix, and `report :error` prints the exception's value instead of clj's JVM cause trace. The test name, expected/actual, context line, and counts all match clj."
603-
example: "(deftest a (is (= 1 2))) (run-tests) => cljw `FAIL in (a)` / clj `FAIL in (a) (NO_SOURCE_FILE:1)`. An erroring test: cljw ` actual: #error {…}` (the ex value) / clj a multi-line `clojure.lang.ExceptionInfo … at …` cause trace."
604-
why: |-
605-
cljw Vars carry no source location — `(meta #'a)` has `:name`/`:ns` but no
606-
`:line`/`:file` (clj attaches both at compile time). So `testing-vars-str`
607-
cannot emit the ` (file:line)` suffix clj appends, and there is no JVM
608-
stack to print for an errored assertion. Everything that does NOT depend on
609-
a source location — the `(test-name)` list, `expected:`/` actual:` (incl.
610-
the clj `(not (= …))` actual form), the joined `testing` context line, the
611-
`Testing <ns>` begin line, and the `Ran … / failures, errors.` summary —
612-
matches clj byte-for-line. Reproducing the suffix/trace would require a
613-
source-location + JVM-stack model cljw does not have (ADR-0059 no-JVM).
602+
summary: "clojure.test's FAIL/ERROR ` (file:line)` suffix points at the DEFTEST var's line (from Var source meta, D-563b) where clj points at the failing assertion's line (JVM stack frame); and `report :error` prints the exception's value instead of clj's JVM cause trace. Names, expected/actual, context, and counts all match clj."
603+
example: "(deftest a (is (= 1 2))) in f.clj: cljw `FAIL in (a) (f.clj:LINE-OF-DEFTEST)` / clj `FAIL in (a) (f.clj:LINE-OF-IS)`. An erroring test: cljw ` actual: #error {…}` (the ex value) / clj a multi-line `clojure.lang.ExceptionInfo … at …` cause trace."
604+
why: |-
605+
Since D-563(b) cljw Vars DO carry `:line`/`:column`/`:file` source meta
606+
(compiler-minted, AOT-wire-carried), so `testing-vars-str` emits the
607+
` (file:line)` suffix — but from the deftest VAR's meta. clj derives it
608+
from the failing ASSERTION's stack frame, and cljw has no JVM stack to
609+
read (ADR-0059), so cljw points at the deftest line where clj points at
610+
the `is` line. The `report :error` cause-trace half is unchanged (no JVM
611+
stack to print). Everything else — the `(test-name)` list,
612+
`expected:`/` actual:` (incl. the clj `(not (= …))` form), the joined
613+
`testing` context line, `Testing <ns>`, and the summary — matches clj
614+
byte-for-line.
614615
derives_from: "ADR-0059 (no-JVM: no JVM stack / class-file source positions) + F-011 (behavioural equivalence — pass/fail/error outcomes + counts match; only the unreproducible location/trace text differs)."
615-
pin: "test/e2e/phase15_clojure_test_output.sh — asserts the clj-matching lines (`FAIL in (eqfail)`, ` actual: (not (= 1 2))`, context, `Testing demo`, summary) are present and that `*test-out*` is re-enabled; the absent ` (file:line)` suffix is the locked divergence."
616+
pin: "test/e2e/phase15_clojure_test_output.sh (suffix present, deftest-var-sourced) + test/e2e/phase15_var_source_meta.sh (the Var meta channel + AOT round-trip)."
616617
- id: "AD-042"
617618
summary: "`(pr-str v)` / `(str v)` print the bare ISO toString for a `java.time.*` value (Instant, Duration, …), where clj's `pr` emits the opaque `#object[java.time.X 0x.. \"ISO\"]` identity form. cljw makes both str and pr the toString; the value's `(str …)`, method results, and `=` all match clj."
618619
example: "(pr-str (java.time.Instant/ofEpochSecond 0)) => cljw \"1970-01-01T00:00:00Z\" / clj \"#object[java.time.Instant 0x5d3411d \\\"1970-01-01T00:00:00Z\\\"]\". (str (java.time.Duration/ofSeconds 90)) => both \"PT1M30S\"."

‎.dev/debt.yaml‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,19 @@ active:
6969
the value; call heads fall through to analyzeCall. e2e: 6 cases on
7070
phase14_host_class_identity (map String/.length, Thread/new,
7171
StringBuilder chain, Date/new higher-order), clj-1.12-oracle-matched.
72-
(b) **Var source meta** — clj vars carry :line/:file/:column from
73-
the compiler; cljw def sites know their SourceLocation but drop it.
74-
Adding it converges `(meta #'x)` and clojure.test's
75-
` (file:line)` FAIL-line suffix (AD-041's divergence dissolves).
72+
(b) DISCHARGED 2026-07-17 — Vars carry :line/:column/:file (analyzeDef
73+
mints them under user ^meta/:doc, compiler-wins on collision per clj
74+
oracle) AND def meta now RIDES THE AOT WIRE: DefNode.meta_expr (the
75+
merged map, wholesale-quoted = cljw's static-data meta semantics) is
76+
evaluated at def time by both backends — tree_walk evalDef + the new
77+
op_var_meta opcode (envelope v9). This also FIXED the pre-existing
78+
user-AOT docstring loss ((:doc (meta #'f)) was nil in `cljw build`
79+
artifacts while lazy-source bootstrap kept it). clojure.test's
80+
` (file:line)` suffix restored from the deftest var's meta — AD-041
81+
NARROWED (deftest-line vs clj's assertion-stack-line residual).
82+
e2e phase15_var_source_meta (8) + diff case + the narrowed pin.
83+
Residual: defmacro's def_node has no meta_expr yet (macro vars keep
84+
analyze-time meta only; small follow-up on the same channel).
7685
(c) **var-existence gaps mainline completion surfaced**:
7786
`default-data-readers` (needs reader fns exposed as vars) and
7887
`defstruct`/`create-struct`/`struct-map`/`accessor` (legacy

‎src/eval/analyzer/special_forms.zig‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,47 @@ pub fn analyzeDef(
607607
const meta_map: Form = .{ .data = .{ .map = try arena.dupe(Form, meta_items.items) }, .location = form.location };
608608
var_ptr.meta = try analyzer_mod.formToValue(rt, env, try unquoteMetaValues(arena, meta_map));
609609
}
610+
// D-563(b): the full Var-meta EXPRESSION — user `^meta` + `:doc`, with the
611+
// compiler-minted `:line`/`:column`/`:file` appended AFTER them (a map
612+
// literal is last-key-wins, so the source location overrides a user
613+
// `^{:line …}` — clj parity). Both backends evaluate it at def time and
614+
// set `Var.meta`, so def meta rides the AOT wire (the analyze-time lift
615+
// above only covers the pre-eval window). Skipped for locationless
616+
// internal defs (line 0 = no real source position).
617+
const def_meta_expr: ?*const Node = blk: {
618+
const src_loc = items[1].location;
619+
if (src_loc.line == 0) break :blk null;
620+
var meta_items: std.ArrayList(Form) = .empty;
621+
if (items[1].meta) |mf| {
622+
if (mf.data == .map) try meta_items.appendSlice(arena, mf.data.map);
623+
}
624+
if (doc_form) |d| {
625+
try meta_items.append(arena, .{ .data = .{ .keyword = .{ .name = "doc" } }, .location = form.location });
626+
try meta_items.append(arena, d);
627+
}
628+
const kws = [_]struct { name: []const u8, v: i64 }{
629+
.{ .name = "line", .v = @intCast(src_loc.line) },
630+
.{ .name = "column", .v = @intCast(src_loc.column) },
631+
};
632+
for (kws) |kv| {
633+
try meta_items.append(arena, .{ .data = .{ .keyword = .{ .name = kv.name } }, .location = form.location });
634+
try meta_items.append(arena, .{ .data = .{ .integer = kv.v }, .location = form.location });
635+
}
636+
try meta_items.append(arena, .{ .data = .{ .keyword = .{ .name = "file" } }, .location = form.location });
637+
try meta_items.append(arena, .{ .data = .{ .string = src_loc.file }, .location = form.location });
638+
const meta_map: Form = .{ .data = .{ .map = try arena.dupe(Form, meta_items.items) }, .location = form.location };
639+
// The whole map is wrapped in (quote …): def-meta is STATIC DATA in
640+
// cljw (same semantics as the formToValue lift above — defn's raw
641+
// `:arglists ([s] …)` lists must never evaluate; clj's
642+
// evaluate-meta-exprs nuance is out of scope, AD candidate if ever
643+
// hit). The quoted map analyzes to ONE composite constant, which the
644+
// AOT wire carries (quoted literals round-trip `cljw build` today).
645+
const quote_items = try arena.alloc(Form, 2);
646+
quote_items[0] = macro_dispatch.makeSymbol("quote", form.location);
647+
quote_items[1] = try unquoteMetaValues(arena, meta_map);
648+
const quoted: Form = .{ .data = .{ .list = quote_items }, .location = form.location };
649+
break :blk try analyzer_mod.analyze(arena, rt, env, scope, quoted, macro_table);
650+
};
610651
// `^:dynamic` / `^:private` on the def target set the Var flags (evalDef /
611652
// op_def copy DefNode flags onto the Var). Without this the metadata was
612653
// lifted into Var.meta but the flags stayed false, so `(def ^:dynamic *x*)`
@@ -634,6 +675,7 @@ pub fn analyzeDef(
634675
.is_dynamic = is_dynamic,
635676
.is_private = is_private,
636677
.has_init = init_idx != null,
678+
.meta_expr = def_meta_expr,
637679
.loc = form.location,
638680
} };
639681
return n;

‎src/eval/backend/tree_walk.zig‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -875,6 +875,12 @@ fn evalDef(rt: *Runtime, env: *Env, locals: []Value, n: node_mod.DefNode) !Value
875875
var_ptr.flags.dynamic = n.is_dynamic;
876876
var_ptr.flags.macro_ = n.is_macro;
877877
var_ptr.flags.private = n.is_private;
878+
// D-563(b): the merged def-meta (user ^meta/:doc under the compiler's
879+
// :line/:column/:file) is set at DEF time so it also rides the AOT wire
880+
// (the VM twin is op_var_meta).
881+
if (n.meta_expr) |me| {
882+
var_ptr.meta = try eval(rt, env, locals, me);
883+
}
878884
return Value.encodeHeapPtr(.var_ref, var_ptr);
879885
}
880886

‎src/eval/backend/vm.zig‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -550,6 +550,19 @@ inline fn stepOnce(
550550
stack[sp] = Value.encodeHeapPtr(.var_ref, var_ptr);
551551
sp += 1;
552552
},
553+
.op_var_meta => {
554+
// D-563(b): stack is [.., var, meta] — pop the meta map, set it as
555+
// the Var's meta (the merged user ^meta/:doc + :line/:column/:file
556+
// the analyzer minted); the var stays as def's result value.
557+
if (sp < 2) return raiseInternal("vm: op_var_meta needs var + meta on the stack");
558+
sp -= 1;
559+
const meta_val = stack[sp];
560+
const var_val = stack[sp - 1];
561+
if (var_val.tag() != .var_ref)
562+
return raiseInternal("vm: op_var_meta target is not a Var");
563+
const var_ptr = var_val.decodePtr(*env_mod.Var);
564+
var_ptr.meta = meta_val;
565+
},
553566
.op_def_unbound => {
554567
// No-init `(def x)`: intern an UNBOUND placeholder (no stack value,
555568
// does not clobber an existing root, Var.bound stays false).

‎src/eval/backend/vm/compiler.zig‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -886,6 +886,14 @@ const Compiler = struct {
886886
if (n.is_macro) packed_operand |= opcode_mod.DEF_FLAG_MACRO;
887887
if (n.is_private) packed_operand |= opcode_mod.DEF_FLAG_PRIVATE;
888888
try self.emit(if (n.has_init) .op_def else .op_def_unbound, packed_operand);
889+
// D-563(b): def meta — the map is BUILT by ordinary ops (the wire
890+
// constant pool has no map kind) on top of the Var op_def left on
891+
// the stack; op_var_meta pops it and sets Var.meta. Meta thereby
892+
// rides any AOT artifact.
893+
if (n.meta_expr) |me| {
894+
try self.compileNode(me);
895+
try self.emit(.op_var_meta, 0);
896+
}
889897
}
890898

891899
/// Emit a jump opcode with a placeholder operand and return the

‎src/eval/backend/vm/opcode.zig‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,15 @@ pub const Opcode = enum(u8) {
353353
/// vector index; every error case (OOB / negative / non-vector / nil, which
354354
/// 2-arg `nth` RAISES) defers to the cached `nth` Var for the correct error.
355355
op_nth2 = 0x55,
356+
/// Pops a meta map; sets it as `Var.meta` of the `.var_ref` left at the
357+
/// stack top (the var STAYS — def's result value). Emitted right after
358+
/// `op_def` / `op_def_unbound` when the def carries meta — since D-563(b)
359+
/// the analyzer mints source meta (:line/:column/:file) for every def, so
360+
/// effectively always. The meta map is BUILT by ordinary ops on the stack
361+
/// (the wire constant pool has no map kind), so def meta rides any AOT
362+
/// artifact — fixing the pre-existing loss where a `cljw build` app's
363+
/// docstrings vanished while the lazy-source bootstrap kept them.
364+
op_var_meta = 0x56,
356365

357366
/// True when this opcode carries a **signed-i16 instruction-position
358367
/// offset** in `operand`, relative to the instruction after itself
@@ -368,6 +377,7 @@ pub const Opcode = enum(u8) {
368377
.op_const,
369378
.op_load_local,
370379
.op_store_local,
380+
.op_var_meta,
371381
.op_def,
372382
.op_def_unbound,
373383
.op_get_var,
@@ -462,6 +472,7 @@ pub const Opcode = enum(u8) {
462472
return switch (self) {
463473
.op_const, .op_load_local => true,
464474
.op_store_local,
475+
.op_var_meta,
465476
.op_def,
466477
.op_def_unbound,
467478
.op_get_var,

‎src/eval/bytecode/serialize.zig‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ const tree_walk = @import("../backend/tree_walk.zig");
7676
const Function = tree_walk.Function;
7777

7878
pub const MAGIC: [4]u8 = .{ 'C', 'L', 'J', 'W' };
79-
pub const VERSION: u16 = 8; // v8 (ADR-0174): host-class fqcn identity unification — baked class-value constants carry JVM FQCNs ("java.util.Date", was "Date"/"cljw.java.*"), so ≤v7 artifacts are version-rejected; v7 (ADR-0173 C2'): 4B WireInstr wire + 4B-aligned instr sections + source_file/has_handlers in the chunk + interned-name constant pool (pool_ref) + headerless nested fn-method chunks; v6: rt ns merged into clojure.core (ADR-0171)
79+
pub const VERSION: u16 = 9; // v9 (D-563b): op_var_meta — def meta (:doc + :line/:column/:file) rides the wire, fixing the user-AOT docstring loss; v8 (ADR-0174): host-class fqcn identity unification — baked class-value constants carry JVM FQCNs ("java.util.Date", was "Date"/"cljw.java.*"), so ≤v7 artifacts are version-rejected; v7 (ADR-0173 C2'): 4B WireInstr wire + 4B-aligned instr sections + source_file/has_handlers in the chunk + interned-name constant pool (pool_ref) + headerless nested fn-method chunks; v6: rt ns merged into clojure.core (ADR-0171)
8080

8181
pub const SerializeError = error{
8282
OutOfMemory,

‎src/eval/node.zig‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,12 @@ pub const DefNode = struct {
160160
/// interns an UNBOUND placeholder (`internDeclare`) — it must not clobber
161161
/// an existing root (clj parity) and leaves `Var.bound` false for `bound?`.
162162
has_init: bool = true,
163+
/// The Var-meta map EXPRESSION (D-563b): user `^meta` + `:doc` merged
164+
/// under the analyzer-minted `:line`/`:column`/`:file` (compiler wins on
165+
/// collision, clj parity). Evaluated at def time and set as `Var.meta`
166+
/// by both backends (tree_walk evalDef / VM op_var_meta), so def meta
167+
/// survives the AOT wire. Null only for internal defs with no location.
168+
meta_expr: ?*const Node = null,
163169
loc: SourceLocation = .{},
164170
};
165171

‎src/lang/clj/clojure/test.clj‎

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@
1515
;; multimethod (`=` / `thrown?` / default), a `report` multimethod keyed on
1616
;; `:type`, and `*report-counters*` as a dynamic var holding an atom.
1717
;;
18-
;; cljw adaptations (no JVM): vars carry no source location, so the FAIL/ERROR
19-
;; line omits clj's ` (file:line)` suffix and `report :error` cannot print a JVM
20-
;; cause trace — both are AD-041. Deferred: per-var lifecycle events
21-
;; (begin/end-test-var, end-test-ns), use-fixtures, with-test.
18+
;; cljw adaptations (no JVM): the FAIL/ERROR ` (file:line)` suffix reads the
19+
;; deftest VAR's :file/:line meta (D-563b) where clj reads the failing
20+
;; assertion's stack frame (deftest-line vs is-line — the narrowed AD-041);
21+
;; `report :error` cannot print a JVM cause trace (also AD-041). Deferred:
22+
;; per-var lifecycle events (begin/end-test-var, end-test-ns), use-fixtures,
23+
;; with-test.
2224

2325
(ns clojure.test
2426
(:refer-clojure)
@@ -65,10 +67,19 @@
6567
`(binding [*out* *test-out*] ~@body))
6668

6769
;; A string naming the test(s) currently running, for a reporter's pass/fail
68-
;; line: the simple test name(s) in a list, e.g. "(my-test)" (clj form, minus
69-
;; the ` (file:line)` suffix — cljw has no source location on Vars, AD-041).
70+
;; line: "(my-test) (file.clj:12)". The suffix reads the deftest VAR's
71+
;; :file/:line source meta (D-563b); clj derives it from the failing
72+
;; ASSERTION's stack frame instead, so clj points at the `is` line where
73+
;; cljw points at the deftest line — the narrowed AD-041 residual (no JVM
74+
;; stack frames to read).
7075
(defn testing-vars-str [_m]
71-
(str "(" (clojure.string/join " " (reverse (map #(:name (meta %)) *testing-vars*))) ")"))
76+
(let [names (str "(" (clojure.string/join " " (reverse (map #(:name (meta %)) *testing-vars*))) ")")
77+
vm (meta (first *testing-vars*))
78+
file (:file vm)
79+
line (:line vm)]
80+
(if (and file line)
81+
(str names " (" (last (clojure.string/split (str file) #"/")) ":" line ")")
82+
names)))
7283

7384
;; A string of the active `testing` context strings (outermost first).
7485
(defn testing-contexts-str []

0 commit comments

Comments
 (0)