Skip to content

Commit 9665b2c

Browse files
committed
feat(ns): D-554 — ns attr-map + name-symbol meta merge into ns meta
Smell-audited: 1: the row's original barrier (a Value in the Node needs new rooting machinery) DISSOLVED with D-556 — the lifted map rides pushAnalysisRoot -> persisted_analysis_roots, exactly the arena lifetime the Node already has; no new mechanism was invented. `(ns ^{:k 1} name "doc" {:author "a"})` now merges the name-symbol reader meta + the attr-map into the ns meta (attr wins over name-meta; an explicit docstring wins over an attr :doc — clj's ns-macro order). Both backends per ADR-0036: tree_walk evalNs -> meta.mergeNsMeta (over the new map.mergeInto); the VM rides the chunk literal pool (NsFilterEntry attr_const index + NO_ATTR sentinel; bytecode v5 with the format-coverage guard, round-trip asserts). e2e phase15_ns_docstring +2 cases; diff case diff-ns-attr; clj-oracle-verified. D-554 discharged.
1 parent a1af90a commit 9665b2c

12 files changed

Lines changed: 106 additions & 17 deletions

File tree

‎.dev/debt.yaml‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,6 @@
2424
# md-table-align no longer pads it to ~800 KB of whitespace.
2525

2626
active:
27-
- id: "D-554"
28-
status: "open (2026-07-06, split from D-239's discharge). The `(ns name {:attr-map} …)` ATTR-MAP and `(ns ^{:m 1} name)` name-symbol meta are still SKIPPED at analysis (special_forms.zig analyzeNs) — only the docstring lands as {:doc} ns meta. clj merges name-sym meta + attr-map + docstring into the ns meta. Rare vs the docstring (libs mostly use :author/:added attr-maps); needs a Form->Value map conversion at analysis + ADR-0169-style rooting OR an arena-thread of the literal into both backends (the doc rode NsFilterEntry as a plain string; a map needs a Value). Mechanise via phase15_ns_docstring e2e when landed."
29-
category: "clj-parity"
30-
barrier: "none hard — design choice: formToValue at analysis (needs AnalysisFrame rooting for the map Value in NsNode) vs re-analyzing the attr-map Form at execution. Take up easiest-first or on a lib whose ns attr-map meta is read programmatically."
31-
last_reviewed: "2026-07-06"
3227
- id: "D-547"
3328
status: |-
3429
open (2026-07-01, NARROWED same day). Enforcement-honesty sweep — surfaced by the
@@ -6473,6 +6468,12 @@ discharged:
64736468
barrier: "DISCHARGED (was: static-member gap catalog+fill, ADR-0166 row 5)."
64746469
last_reviewed: "2026-07-07"
64756470

6471+
- id: "D-554"
6472+
status: "DISCHARGED 2026-07-07. The `(ns ^{…} name {:attr …})` ATTR-MAP and name-symbol reader meta now MERGE into the ns meta (attr wins over name-meta; an explicit docstring wins over an attr :doc — clj ns-macro merge order). The design barrier dissolved with D-556: the attr map is lifted at analysis via formToValue + pushAnalysisRoot, and production seams persist their AnalysisFrame roots (persisted_analysis_roots) for the arena's lifetime — so embedding the Value in NsNode is GC-safe with NO new rooting machinery. Both backends per ADR-0036: tree_walk evalNs -> meta.mergeNsMeta (new; over the new map.mergeInto); the VM rides the chunk literal pool (NsFilterEntry.attr_const u32 index, NO_ATTR sentinel; bytecode v5 with coverage-guard + round-trip asserts). e2e phase15_ns_docstring (7 cases incl. ns-attr-meta / ns-name-sym-meta); diff case diff-ns-attr; clj-oracle-verified ({:doc :author :added} + ^{:k 1} -> :k 1). ORIGINAL: split from D-239's discharge as the needs-a-Value-in-node residual."
6473+
category: "clj-parity"
6474+
barrier: "DISCHARGED (was: ns attr-map skipped at analysis)."
6475+
last_reviewed: "2026-07-07"
6476+
64766477
conventions: |-
64776478
- Structure: two top-level lists — `active:` and `discharged:`. Active entries carry id / status / category / barrier (+ optional quality_floor / last_reviewed); discharged entries carry id / discharged_at / resolution. Edit as YAML (no Markdown table to align).
64786479
- New debt: grep `.dev/debt.yaml` for class-overlap before adding (per `.claude/rules/debt_dedup.md`).

‎src/eval/analyzer/special_forms.zig‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1178,13 +1178,29 @@ pub fn analyzeNs(
11781178
var i: usize = 2;
11791179
// Optional docstring + attr-map after the name (clj: `(ns name doc? attrs?
11801180
// refs*)`). The docstring lands as `{:doc …}` on the ns meta at execution
1181-
// (D-239 sibling); the attr-map is still skipped (D-554).
1181+
// (D-239 sibling); the attr-map and the name-symbol's `^{…}` reader meta
1182+
// merge into the ns meta too (D-554). The lifted map Values ride the
1183+
// AnalysisFrame → persisted_analysis_roots (D-556), matching the Node's
1184+
// arena lifetime, so embedding them in the NsNode is GC-safe.
11821185
var doc: ?[]const u8 = null;
1186+
var attr_meta: Value = Value.nil_val;
1187+
if (name_form.meta) |meta_form| {
1188+
if (meta_form.data == .map) {
1189+
attr_meta = try analyzer_mod.formToValue(rt, env, meta_form.*);
1190+
try root_set.pushAnalysisRoot(attr_meta);
1191+
}
1192+
}
11831193
if (i < items.len and items[i].data == .string) {
11841194
doc = try arena.dupe(u8, items[i].data.string);
11851195
i += 1;
11861196
}
1187-
if (i < items.len and items[i].data == .map) i += 1;
1197+
if (i < items.len and items[i].data == .map) {
1198+
const m = try analyzer_mod.formToValue(rt, env, items[i]);
1199+
try root_set.pushAnalysisRoot(m);
1200+
// clj merges the attr-map OVER the name-symbol meta.
1201+
attr_meta = if (attr_meta.isNil()) m else map_collection.mergeInto(rt, attr_meta, m) catch return error.OutOfMemory;
1202+
i += 1;
1203+
}
11881204
while (i < items.len) : (i += 1) {
11891205
const directive = items[i];
11901206
// D-299: clj accepts a list `(:require …)` OR a vector `[:require …]`
@@ -1229,6 +1245,7 @@ pub fn analyzeNs(
12291245
n.* = .{ .ns_node = .{
12301246
.name = ns_name,
12311247
.doc = doc,
1248+
.attr_meta = attr_meta,
12321249
.refer_clojure = refer_clojure,
12331250
.refer_clojure_exclude = refer_clojure_exclude,
12341251
.refer_clojure_only = refer_clojure_only,

‎src/eval/backend/tree_walk.zig‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -567,7 +567,9 @@ fn evalInNs(env: *Env, n: node_mod.InNsNode) !Value {
567567
/// remains in bootstrap.zig + primitive.zig + macro_transforms.zig).
568568
fn evalNs(rt: *Runtime, env: *Env, n: node_mod.NsNode) !Value {
569569
env.setCurrentNs(try env.findOrCreateNs(n.name));
570-
// `(ns name "docstring" …)` → {:doc "…"} on the ns meta (D-239 sibling).
570+
// `(ns ^{…} name {:attr …} "doc" …)` → ns meta: attr merge first, then
571+
// the docstring (an explicit doc wins over an attr :doc) — D-239/D-554.
572+
if (!n.attr_meta.isNil()) try meta_mod.mergeNsMeta(rt, env.current_ns.?, n.attr_meta);
571573
if (n.doc) |d| try meta_mod.setNsDoc(rt, env.current_ns.?, d);
572574
if (n.refer_clojure) {
573575
// Row 14.7 (D-098): filters apply to both rt/ and clojure.core

‎src/eval/backend/vm.zig‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1233,6 +1233,12 @@ inline fn stepOnce(
12331233
return raiseInternal("vm: op_ns_with_filter index out of range");
12341234
const f = chunk.ns_filters[instr.operand];
12351235
env.setCurrentNs(try env.findOrCreateNs(f.name));
1236+
// D-554: attr merge first, then the docstring (doc wins on :doc).
1237+
if (f.attr_const != opcode_mod.NsFilterEntry.NO_ATTR) {
1238+
if (f.attr_const >= chunk.constants.len)
1239+
return raiseInternal("vm: op_ns_with_filter attr_const out of range");
1240+
try meta_mod.mergeNsMeta(rt, env.current_ns.?, chunk.constants[f.attr_const]);
1241+
}
12361242
if (f.doc) |d| try meta_mod.setNsDoc(rt, env.current_ns.?, d);
12371243
if (f.refer_clojure) {
12381244
if (env.findNs("rt")) |rt_ns| {

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -782,8 +782,9 @@ const Compiler = struct {
782782
// trailing op_pop drops the prior nil before the next push, leaving
783783
// exactly one nil (the ns form's value).
784784
const has_filter = n.refer_clojure_exclude.len > 0 or n.refer_clojure_only != null;
785-
if (has_filter or n.libspecs.len > 0 or n.imports.len > 0 or n.doc != null) {
786-
if (n.refer_clojure or n.doc != null) {
785+
const has_attr = !n.attr_meta.isNil();
786+
if (has_filter or n.libspecs.len > 0 or n.imports.len > 0 or n.doc != null or has_attr) {
787+
if (n.refer_clojure or n.doc != null or has_attr) {
787788
// A docstring rides the side-table entry too (D-239 sibling);
788789
// the entry's `refer_clojure` keeps the no-refer shape honest.
789790
if (self.ns_filters.items.len > std.math.maxInt(u16)) return Error.TooManyConstants;
@@ -797,7 +798,10 @@ const Compiler = struct {
797798
break :blk od;
798799
} else null;
799800
const doc_dup: ?[]const u8 = if (n.doc) |d| try self.arena.dupe(u8, d) else null;
800-
try self.ns_filters.append(self.arena, .{ .name = name_dup, .exclude = exclude_dup, .only = only_dup, .doc = doc_dup, .refer_clojure = n.refer_clojure });
801+
// D-554: the lifted attr map rides the literal pool (index in
802+
// the side-table entry) so the chunk stays serializable.
803+
const attr_idx: u32 = if (has_attr) try self.addConstant(n.attr_meta) else NsFilterEntry.NO_ATTR;
804+
try self.ns_filters.append(self.arena, .{ .name = name_dup, .exclude = exclude_dup, .only = only_dup, .doc = doc_dup, .refer_clojure = n.refer_clojure, .attr_const = attr_idx });
801805
try self.emit(.op_ns_with_filter, filter_idx);
802806
} else {
803807
const name_val = try string_mod.alloc(self.rt, n.name);

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,12 @@ pub const NsFilterEntry = struct {
647647
/// False = the ns form had no refer-clojure step (rare); the op skips
648648
/// the rt/clojure.core refers but still applies name + doc.
649649
refer_clojure: bool = true,
650+
/// D-554: chunk-constants index of the lifted `(ns ^{…} name {:attr …})`
651+
/// meta map, or `NO_ATTR` when the form carried none. Riding the literal
652+
/// pool keeps the entry serializable (a Value cannot live here).
653+
attr_const: u32 = NO_ATTR,
654+
655+
pub const NO_ATTR: u32 = std.math.maxInt(u32);
650656
};
651657

652658
/// Compiled bytecode for a single function or top-level form.

‎src/eval/bytecode/serialize.zig‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ const tree_walk = @import("../backend/tree_walk.zig");
7575
const Function = tree_walk.Function;
7676

7777
pub const MAGIC: [4]u8 = .{ 'C', 'L', 'J', 'W' };
78-
pub const VERSION: u16 = 4; // v4: NsFilterEntry doc + refer_clojure (D-239 sibling)
78+
pub const VERSION: u16 = 5; // v5: NsFilterEntry attr_const (D-554); v4: doc + refer_clojure
7979

8080
pub const SerializeError = error{
8181
OutOfMemory,
@@ -620,6 +620,8 @@ pub fn serializeChunk(allocator: std.mem.Allocator, chunk: BytecodeChunk) ![]u8
620620
try writeU8(w, 0);
621621
}
622622
try writeU8(w, if (nf.refer_clojure) 1 else 0);
623+
// v5: attr-map constants index (D-554; NO_ATTR when absent).
624+
try writeU32(w, nf.attr_const);
623625
}
624626
try writeU32(w, @intCast(chunk.ctor_sites.len));
625627
for (chunk.ctor_sites) |ct| {
@@ -754,7 +756,9 @@ pub fn deserializeChunk(allocator: std.mem.Allocator, rt: *Runtime, env: *@impor
754756
doc = try allocator.dupe(u8, db);
755757
}
756758
const refer_clojure = (try r.readU8()) != 0;
757-
ns_filters[i] = .{ .name = name_dup, .exclude = exclude, .only = only, .doc = doc, .refer_clojure = refer_clojure };
759+
// v5: attr-map constants index (D-554).
760+
const attr_const = try r.readU32();
761+
ns_filters[i] = .{ .name = name_dup, .exclude = exclude, .only = only, .doc = doc, .refer_clojure = refer_clojure, .attr_const = attr_const };
758762
}
759763

760764
const ctor_count = try r.readU32();
@@ -1562,7 +1566,7 @@ test "chunk completeness gate: every side-table + entry field round-trips (D-365
15621566
}
15631567
inline for (std.meta.fields(NsFilterEntry)) |f| {
15641568
const classified: bool = switch (@field(std.meta.FieldEnum(NsFilterEntry), f.name)) {
1565-
.name, .exclude, .only, .doc, .refer_clojure => true,
1569+
.name, .exclude, .only, .doc, .refer_clojure, .attr_const => true,
15661570
};
15671571
try testing.expect(classified);
15681572
}
@@ -1588,7 +1592,7 @@ test "chunk completeness gate: every side-table + entry field round-trips (D-365
15881592
const nf_exclude = [_][]const u8{"reduce"};
15891593
const nf_only = [_][]const u8{"inc"};
15901594
const ns_filters = [_]NsFilterEntry{
1591-
.{ .name = "app.core", .exclude = &nf_exclude, .only = &nf_only, .doc = "the ns doc", .refer_clojure = false },
1595+
.{ .name = "app.core", .exclude = &nf_exclude, .only = &nf_only, .doc = "the ns doc", .refer_clojure = false, .attr_const = 7 },
15921596
};
15931597
const ctor_sites = [_]CtorEntry{
15941598
.{ .type_name = "java.io.File", .arg_count = 1 },
@@ -1649,6 +1653,7 @@ test "chunk completeness gate: every side-table + entry field round-trips (D-365
16491653
try testing.expectEqualStrings("inc", round.ns_filters[0].only.?[0]);
16501654
try testing.expectEqualStrings("the ns doc", round.ns_filters[0].doc.?);
16511655
try testing.expect(!round.ns_filters[0].refer_clojure);
1656+
try testing.expectEqual(@as(u32, 7), round.ns_filters[0].attr_const);
16521657

16531658
// ctor_sites
16541659
try testing.expectEqual(@as(usize, 1), round.ctor_sites.len);
@@ -1819,6 +1824,7 @@ test "round-trips ns_filters with no :only (null) — bare (ns x (:require …))
18191824
try testing.expect(round.ns_filters[0].only == null);
18201825
try testing.expect(round.ns_filters[0].doc == null);
18211826
try testing.expect(round.ns_filters[0].refer_clojure);
1827+
try testing.expectEqual(NsFilterEntry.NO_ATTR, round.ns_filters[0].attr_const);
18221828
}
18231829

18241830
test "round-trips instructions" {

‎src/eval/node.zig‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -479,8 +479,12 @@ pub const NsNode = struct {
479479
name: []const u8,
480480
/// `(ns name "docstring" …)` — lands as `{:doc "…"}` on the namespace's
481481
/// meta at execution (clj parity, D-239 sibling). Null = no docstring.
482-
/// The attr-map form `(ns name {:author …})` is NOT yet captured (D-554).
483482
doc: ?[]const u8 = null,
483+
/// `(ns ^{…} name {:attr …})` — the name-symbol reader meta merged with
484+
/// the attr-map (attr wins), lifted to a map Value at analysis (D-554).
485+
/// GC-safe in the node: the lift is pushed to the AnalysisFrame, which
486+
/// production seams persist for the arena's lifetime (D-556). nil = none.
487+
attr_meta: Value = Value.nil_val,
484488
/// User wrote `(:refer-clojure)`. When false the auto-refer step is
485489
/// skipped entirely (cljw-shell-only mode).
486490
refer_clojure: bool = true,

‎src/lang/diff_test.zig‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -875,6 +875,16 @@ test "diff: ns_node :refer-clojure :only filter (D-098 VM op_ns_with_filter)" {
875875
try f.check("(do (ns diff-ns-only (:refer-clojure :only [+])) (+ 1 2))", 3);
876876
}
877877

878+
test "diff: ns_node attr-map merges into ns meta (D-554)" {
879+
var f: Fixture = undefined;
880+
try Fixture.init(&f, testing.allocator);
881+
defer f.deinit();
882+
// `(ns x {:author "a"})` merges the attr-map into the ns meta in BOTH
883+
// backends (tree_walk evalNs mergeNsMeta + the VM's attr_const literal-
884+
// pool ride). Count keeps the assertion Value-comparable.
885+
try f.check("(do (ns diff-ns-attr {:author \"a\" :x 1} (:refer-clojure)) (count (meta (find-ns 'diff-ns-attr))))", 2);
886+
}
887+
878888
test "diff: ns_node docstring lands as {:doc} ns meta (D-239 sibling)" {
879889
var f: Fixture = undefined;
880890
try Fixture.init(&f, testing.allocator);

‎src/runtime/collection/map.zig‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,22 @@ pub fn count(v: Value) u32 {
132132
/// safe to call from a non-reentrant primitive walk. A non-map `v` is a
133133
/// programming error (callers guard the tag). `.sorted_map` is NOT handled
134134
/// here (different backing structure); callers needing it dispatch separately.
135+
/// `(merge base overlay)` for two maps: every entry of `overlay` assoc'd
136+
/// onto `base` (overlay wins on key collision — clj merge's right-bias).
137+
/// Either side nil ⇒ the other. Non-sorted maps only (forEachEntry's scope).
138+
pub fn mergeInto(rt: *Runtime, base: Value, overlay: Value) !Value {
139+
if (overlay.isNil()) return base;
140+
if (base.isNil()) return overlay;
141+
const Ctx = struct { rt: *Runtime, acc: Value };
142+
var ctx: Ctx = .{ .rt = rt, .acc = base };
143+
try forEachEntry(overlay, &ctx, struct {
144+
fn put(c: *Ctx, k: Value, v: Value) anyerror!void {
145+
c.acc = try assoc(c.rt, c.acc, k, v);
146+
}
147+
}.put);
148+
return ctx.acc;
149+
}
150+
135151
pub fn forEachEntry(
136152
v: Value,
137153
ctx: anytype,

0 commit comments

Comments
 (0)