Skip to content

Commit e42ffac

Browse files
committed
feat(meta): D-239 — namespace meta (alter-meta!/reset-meta!) + ns docstring lands as {:doc}
Smell-audited: 1: the ns ATTR-MAP + name-symbol meta stay skipped — split to D-554 (needs a Value-in-node + ADR-0169 rooting design; the docstring rides as a plain string, no rooting) rather than bundling a second design into this unit. Namespace gained a meta slot (GC-rooted in root_set's ns_vars walk as the walk enters each ns); reset-meta!'s .ns arm + metaOf's .ns arm complete the alter-meta! family (var/atom/agent/ref/ns). Sibling in the same unit: the `(ns name "docstring")` docstring — previously silently skipped — now lands as {:doc ...} ns meta in BOTH backends per the ADR-0036 contract: NsNode.doc -> tree_walk evalNs + VM op_ns_with_filter (NsFilterEntry doc/refer_clojure, bytecode v4 with the format-coverage guard + round-trip asserts + freeChunk updated); shared meta.setNsDoc helper so semantics cannot drift; diff case added. e2e phase15_ns_docstring (5 cases); clj-oracle-verified ({:doc "my doc", :k 1} after alter-meta!).
1 parent 23fd355 commit e42ffac

15 files changed

Lines changed: 127 additions & 40 deletions

File tree

‎.dev/debt.yaml‎

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@
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"
2732
- id: "D-547"
2833
status: |-
2934
open (2026-07-01, NARROWED same day). Enforcement-honesty sweep — surfaced by the
@@ -655,23 +660,6 @@ active:
655660
barrier: |-
656661
`src/lang/primitive/` (math / core / error today, 3 files) will grow with collection / sequence / io / numeric / type / spec primitives across Phase 5-14. ROADMAP A6 ≤ 1000-lines cap will breach on `core.zig` (general primitives bucket) first. Sub-directory restructure (`primitive/core/`, `primitive/sequence/`, etc.) should land when the second primitive file passes ~500 lines. Owner picks exact split shape **within F-NNN envelope** (= F-005 numeric tower primitives organisation must remain coherent across the split). | each Phase 5+ primitive wave
657662
last_reviewed: "2026-05-28"
658-
- id: "D-239"
659-
status: "RE-NARROWED 2026-07-02 (audit): barrier opening ns + ref remain is half-stale — ref meta landed via D-533 (reset-meta! on refs verified). NS meta is the only remainder. ORIGINAL: now (PARTIAL: var + atom + AGENT + REF landed — agent meta via D-441 (agent.zig:110 + metadata.zig dispatch); REF meta via D-533 (2026-06-25: Ref.meta field + `(ref v :meta m)` ctor kwarg + meta/reset-meta! dispatch; `with-meta` on a ref correctly THROWS — clj's Ref is not IObj, so that is clj-parity, not a gap). Only NS meta still deferred. Corrected 2026-06-17 audit (agent) + 2026-06-25 audit (ref).)"
660-
category: "runtime"
661-
quality_floor: "metadata: alter-meta!/reset-meta! on namespace / ref / agent refs"
662-
barrier: |-
663-
**`alter-meta!` / `reset-meta!` cover Var + atom + agent refs; ns + ref
664-
still deferred.** The Zig primitive `reset-meta!` mutates a Var's `.meta`
665-
field, an atom's `.meta` slot, or an agent's meta (agent.zig:110 + the
666-
metadata.zig dispatch, landed via D-441); `alter-meta!` is a core.clj defn
667-
over it. clj also supports these on **namespaces and refs** — cljw has no
668-
meta slot on Namespace (it is not yet a first-class Value-with-meta; see
669-
ADR-0085 namespace-as-value), and ref is a Phase-15 concurrency type. A
670-
`(reset-meta! <ns-or-ref> …)` raises `reset_meta_target_not_ref`. Barrier
671-
dissolves when (a) Namespace gains a meta slot reachable through a namespace
672-
Value (ADR-0085 follow-on), and (b) Phase 15 lands ref. potemkin's
673-
`namespaces.clj` alter-meta! on a namespace is the first real consumer.
674-
corpus alter_meta (8) covers the var/atom surface.
675663
- id: "D-245"
676664
status: "now (ADR-0092, 2026-06-05) — `locking` LANDED as Option A (header spinlock + threadlocal reentrancy held-set, runtime/concurrency/object_monitor.zig). Correct, reentrant, GC-safe (safepoint-polling spin), clj-result-equivalent (mutual-exclusion e2e = 400). The Option C blocking-monitor inflation is this row."
677665
category: "concurrency"
@@ -6534,6 +6522,12 @@ discharged:
65346522
barrier: "DISCHARGED (was: no UTF-16 hasher)."
65356523
last_reviewed: "2026-07-06"
65366524

6525+
- id: "D-239"
6526+
status: "DISCHARGED 2026-07-06 — the LAST remainder (namespace meta) landed; alter-meta!/reset-meta!/meta now cover Var + atom + agent + ref + NAMESPACE. Namespace gained a meta slot (env.zig; GC-rooted in root_set's ns_vars walk as the walk enters each ns); reset-meta!'s .ns arm + metaOf's .ns arm; the stale 'var or atom' catalog template updated. SIBLING also landed same cycle: the `(ns name \"docstring\")` docstring — previously silently SKIPPED — now lands as {:doc …} ns meta in BOTH backends (NsNode.doc; tree_walk evalNs + VM op_ns_with_filter via NsFilterEntry.doc/refer_clojure + bytecode v4 serialization; shared meta.setNsDoc helper so semantics cannot drift; diff case per the ADR-0036 contract). e2e phase15_ns_docstring (5 cases incl. ns-doc-meta/ns-alter-meta); clj-oracle-verified ({:doc \"my doc\", :k 1} after alter-meta!). The ns ATTR-MAP + name-symbol meta remain skipped -> split to D-554 (needs a Value-in-node + rooting design). HISTORY: var (D-183) + atom + agent (D-441) + ref (D-533) landed earlier."
6527+
category: "runtime"
6528+
barrier: "DISCHARGED (was: ns meta slot missing)."
6529+
last_reviewed: "2026-07-06"
6530+
65376531
conventions: |-
65386532
- 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).
65396533
- 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: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1177,8 +1177,13 @@ pub fn analyzeNs(
11771177

11781178
var i: usize = 2;
11791179
// Optional docstring + attr-map after the name (clj: `(ns name doc? attrs?
1180-
// refs*)`) — real libs open with a docstring; skip before the directives.
1181-
if (i < items.len and items[i].data == .string) i += 1;
1180+
// refs*)`). The docstring lands as `{:doc …}` on the ns meta at execution
1181+
// (D-239 sibling); the attr-map is still skipped (D-554).
1182+
var doc: ?[]const u8 = null;
1183+
if (i < items.len and items[i].data == .string) {
1184+
doc = try arena.dupe(u8, items[i].data.string);
1185+
i += 1;
1186+
}
11821187
if (i < items.len and items[i].data == .map) i += 1;
11831188
while (i < items.len) : (i += 1) {
11841189
const directive = items[i];
@@ -1223,6 +1228,7 @@ pub fn analyzeNs(
12231228
const n = try arena.create(Node);
12241229
n.* = .{ .ns_node = .{
12251230
.name = ns_name,
1231+
.doc = doc,
12261232
.refer_clojure = refer_clojure,
12271233
.refer_clojure_exclude = refer_clojure_exclude,
12281234
.refer_clojure_only = refer_clojure_only,

‎src/eval/backend/tree_walk.zig‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,8 @@ fn evalInNs(env: *Env, n: node_mod.InNsNode) !Value {
548548
/// remains in bootstrap.zig + primitive.zig + macro_transforms.zig).
549549
fn evalNs(rt: *Runtime, env: *Env, n: node_mod.NsNode) !Value {
550550
env.setCurrentNs(try env.findOrCreateNs(n.name));
551+
// `(ns name "docstring" …)` → {:doc "…"} on the ns meta (D-239 sibling).
552+
if (n.doc) |d| try meta_mod.setNsDoc(rt, env.current_ns.?, d);
551553
if (n.refer_clojure) {
552554
// Row 14.7 (D-098): filters apply to both rt/ and clojure.core
553555
// (the two auto-refer sources). `rt/` is cw-side primitives;

‎src/eval/backend/vm.zig‎

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const host_class = @import("../../runtime/error/host_class.zig");
3838
const tree_walk = @import("tree_walk.zig");
3939
const ex_info_mod = @import("../../runtime/collection/ex_info.zig");
4040
const keyword_mod = @import("../../runtime/keyword.zig");
41+
const meta_mod = @import("../../runtime/meta.zig");
4142
const td_mod = @import("../../runtime/type_descriptor.zig");
4243
const object_method = @import("object_method.zig");
4344
const clojure_lang_method = @import("clojure_lang_method.zig");
@@ -1222,18 +1223,23 @@ inline fn stepOnce(
12221223
},
12231224
.op_ns_with_filter => {
12241225
// D-098: mirror of tree_walk::evalNs's refer-clojure branch
1225-
// with the `:exclude`/`:only` filter. Enter the ns, then
1226-
// refer rt + clojure.core through referAllWithFilter.
1226+
// with the `:exclude`/`:only` filter. Enter the ns, apply the
1227+
// docstring meta (D-239 sibling), then refer rt + clojure.core
1228+
// through referAllWithFilter (skipped when the ns form had no
1229+
// refer-clojure step — the entry's flag).
12271230
if (instr.operand >= chunk.ns_filters.len)
12281231
return raiseInternal("vm: op_ns_with_filter index out of range");
12291232
const f = chunk.ns_filters[instr.operand];
12301233
env.setCurrentNs(try env.findOrCreateNs(f.name));
1231-
if (env.findNs("rt")) |rt_ns| {
1232-
try env.referAllWithFilter(rt_ns, env.current_ns.?, f.exclude, f.only);
1233-
}
1234-
if (env.findNs("clojure.core")) |clojure_core_ns| {
1235-
// ADR-0035 D9 revision: clojure.core overrides rt on collision.
1236-
try env.referAllOverriding(clojure_core_ns, env.current_ns.?, f.exclude, f.only);
1234+
if (f.doc) |d| try meta_mod.setNsDoc(rt, env.current_ns.?, d);
1235+
if (f.refer_clojure) {
1236+
if (env.findNs("rt")) |rt_ns| {
1237+
try env.referAllWithFilter(rt_ns, env.current_ns.?, f.exclude, f.only);
1238+
}
1239+
if (env.findNs("clojure.core")) |clojure_core_ns| {
1240+
// ADR-0035 D9 revision: clojure.core overrides rt on collision.
1241+
try env.referAllOverriding(clojure_core_ns, env.current_ns.?, f.exclude, f.only);
1242+
}
12371243
}
12381244
if (sp >= stack.len)
12391245
return raiseInternal("vm: operand stack overflow");

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -782,8 +782,10 @@ 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) {
786-
if (n.refer_clojure) {
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) {
787+
// A docstring rides the side-table entry too (D-239 sibling);
788+
// the entry's `refer_clojure` keeps the no-refer shape honest.
787789
if (self.ns_filters.items.len > std.math.maxInt(u16)) return Error.TooManyConstants;
788790
const filter_idx: u16 = @intCast(self.ns_filters.items.len);
789791
const name_dup = try self.arena.dupe(u8, n.name);
@@ -794,7 +796,8 @@ const Compiler = struct {
794796
for (only, 0..) |o, i| od[i] = try self.arena.dupe(u8, o);
795797
break :blk od;
796798
} else null;
797-
try self.ns_filters.append(self.arena, .{ .name = name_dup, .exclude = exclude_dup, .only = only_dup });
799+
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 });
798801
try self.emit(.op_ns_with_filter, filter_idx);
799802
} else {
800803
const name_val = try string_mod.alloc(self.rt, n.name);

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,11 @@ pub const NsFilterEntry = struct {
642642
name: []const u8,
643643
exclude: []const []const u8 = &.{},
644644
only: ?[]const []const u8 = null,
645+
/// `(ns name "docstring" …)` → `{:doc …}` on the ns meta (D-239 sibling).
646+
doc: ?[]const u8 = null,
647+
/// False = the ns form had no refer-clojure step (rare); the op skips
648+
/// the rt/clojure.core refers but still applies name + doc.
649+
refer_clojure: bool = true,
645650
};
646651

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

‎src/eval/bytecode/serialize.zig‎

Lines changed: 26 additions & 5 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 = 3;
78+
pub const VERSION: u16 = 4; // v4: NsFilterEntry doc + refer_clojure (D-239 sibling)
7979

8080
pub const SerializeError = error{
8181
OutOfMemory,
@@ -612,6 +612,14 @@ pub fn serializeChunk(allocator: std.mem.Allocator, chunk: BytecodeChunk) ![]u8
612612
} else {
613613
try writeU8(w, 0);
614614
}
615+
// v4: docstring (has-flag + bytes) + refer_clojure flag (D-239 sibling).
616+
if (nf.doc) |d| {
617+
try writeU8(w, 1);
618+
try writeLenPrefixed(w, d);
619+
} else {
620+
try writeU8(w, 0);
621+
}
622+
try writeU8(w, if (nf.refer_clojure) 1 else 0);
615623
}
616624
try writeU32(w, @intCast(chunk.ctor_sites.len));
617625
for (chunk.ctor_sites) |ct| {
@@ -738,7 +746,15 @@ pub fn deserializeChunk(allocator: std.mem.Allocator, rt: *Runtime, env: *@impor
738746
}
739747
only = only_buf;
740748
}
741-
ns_filters[i] = .{ .name = name_dup, .exclude = exclude, .only = only };
749+
// v4: docstring + refer_clojure flag (D-239 sibling).
750+
const has_doc = try r.readU8();
751+
var doc: ?[]const u8 = null;
752+
if (has_doc != 0) {
753+
const db = try r.readLenPrefixed();
754+
doc = try allocator.dupe(u8, db);
755+
}
756+
const refer_clojure = (try r.readU8()) != 0;
757+
ns_filters[i] = .{ .name = name_dup, .exclude = exclude, .only = only, .doc = doc, .refer_clojure = refer_clojure };
742758
}
743759

744760
const ctor_count = try r.readU32();
@@ -832,6 +848,7 @@ pub fn freeChunk(allocator: std.mem.Allocator, chunk: BytecodeChunk) void {
832848
for (only) |o| allocator.free(o);
833849
allocator.free(only);
834850
}
851+
if (nf.doc) |d| allocator.free(d);
835852
}
836853
allocator.free(chunk.ns_filters);
837854
for (chunk.ctor_sites) |ct| allocator.free(ct.type_name);
@@ -1545,7 +1562,7 @@ test "chunk completeness gate: every side-table + entry field round-trips (D-365
15451562
}
15461563
inline for (std.meta.fields(NsFilterEntry)) |f| {
15471564
const classified: bool = switch (@field(std.meta.FieldEnum(NsFilterEntry), f.name)) {
1548-
.name, .exclude, .only => true,
1565+
.name, .exclude, .only, .doc, .refer_clojure => true,
15491566
};
15501567
try testing.expect(classified);
15511568
}
@@ -1571,7 +1588,7 @@ test "chunk completeness gate: every side-table + entry field round-trips (D-365
15711588
const nf_exclude = [_][]const u8{"reduce"};
15721589
const nf_only = [_][]const u8{"inc"};
15731590
const ns_filters = [_]NsFilterEntry{
1574-
.{ .name = "app.core", .exclude = &nf_exclude, .only = &nf_only },
1591+
.{ .name = "app.core", .exclude = &nf_exclude, .only = &nf_only, .doc = "the ns doc", .refer_clojure = false },
15751592
};
15761593
const ctor_sites = [_]CtorEntry{
15771594
.{ .type_name = "java.io.File", .arg_count = 1 },
@@ -1625,11 +1642,13 @@ test "chunk completeness gate: every side-table + entry field round-trips (D-365
16251642
try testing.expectEqual(@as(usize, 1), round.libspecs[0].exclude.len);
16261643
try testing.expectEqualStrings("map", round.libspecs[0].exclude[0]);
16271644

1628-
// ns_filters — exclude + only
1645+
// ns_filters — exclude + only + doc + refer_clojure (v4)
16291646
try testing.expectEqual(@as(usize, 1), round.ns_filters.len);
16301647
try testing.expectEqualStrings("app.core", round.ns_filters[0].name);
16311648
try testing.expectEqualStrings("reduce", round.ns_filters[0].exclude[0]);
16321649
try testing.expectEqualStrings("inc", round.ns_filters[0].only.?[0]);
1650+
try testing.expectEqualStrings("the ns doc", round.ns_filters[0].doc.?);
1651+
try testing.expect(!round.ns_filters[0].refer_clojure);
16331652

16341653
// ctor_sites
16351654
try testing.expectEqual(@as(usize, 1), round.ctor_sites.len);
@@ -1798,6 +1817,8 @@ test "round-trips ns_filters with no :only (null) — bare (ns x (:require …))
17981817
try testing.expectEqualStrings("mylib.greet", round.ns_filters[0].name);
17991818
try testing.expectEqual(@as(usize, 0), round.ns_filters[0].exclude.len);
18001819
try testing.expect(round.ns_filters[0].only == null);
1820+
try testing.expect(round.ns_filters[0].doc == null);
1821+
try testing.expect(round.ns_filters[0].refer_clojure);
18011822
}
18021823

18031824
test "round-trips instructions" {

‎src/eval/node.zig‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,10 @@ pub const InNsNode = struct {
477477
/// is what `evalNs` passes to `findOrCreateNs`.
478478
pub const NsNode = struct {
479479
name: []const u8,
480+
/// `(ns name "docstring" …)` — lands as `{:doc "…"}` on the namespace's
481+
/// 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).
483+
doc: ?[]const u8 = null,
480484
/// User wrote `(:refer-clojure)`. When false the auto-refer step is
481485
/// skipped entirely (cljw-shell-only mode).
482486
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 docstring lands as {:doc} ns meta (D-239 sibling)" {
879+
var f: Fixture = undefined;
880+
try Fixture.init(&f, testing.allocator);
881+
defer f.deinit();
882+
// `(ns x "d")` sets {:doc "d"} on the ns meta in BOTH backends (tree_walk
883+
// evalNs + VM op_ns_with_filter's side-table doc). Count of the meta map
884+
// (1 entry) keeps the assertion Value-comparable in the minimal fixture.
885+
try f.check("(do (ns diff-ns-doc \"the doc\" (:refer-clojure)) (count (meta (find-ns 'diff-ns-doc))))", 1);
886+
}
887+
878888
// The ADR-0035 D9 refer-override (clojure.core wins over rt on collision)
879889
// is NOT diff-cased here: `compare` reuses one env across both backend runs,
880890
// so a def/in-ns-mutating source diverges by residue, not by backend. The

‎src/lang/primitive/metadata.zig‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
//! collection (sharing internals) with the new meta. `vary-meta` is a
66
//! core.clj defn over these. Same-type ops (assoc/conj/dissoc) already
77
//! thread `.meta` so metadata is preserved. `reset-meta!` (here) +
8-
//! `alter-meta!` (core.clj) mutate a Var's / atom's meta slot; namespace /
9-
//! ref / agent meta and symbol/keyword meta are deferred (D-239).
10-
//! Metadata cycle 2026-05-30; discharges D-075.
8+
//! `alter-meta!` (core.clj) mutate a Var's / atom's / agent's / ref's /
9+
//! namespace's meta slot (D-239 complete); keyword meta stays rejected
10+
//! (clj parity). Metadata cycle 2026-05-30; discharges D-075.
1111

1212
const Value = @import("../../runtime/value/value.zig").Value;
1313
const Runtime = @import("../../runtime/runtime.zig").Runtime;
@@ -58,6 +58,12 @@ pub fn resetMetaFn(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLoca
5858
.atom => atom.setMeta(r, m),
5959
.agent => agent.setMeta(r, m),
6060
.ref => ref_mod.setMeta(r, m),
61+
// Namespace meta (D-239 last remainder): mutate the ns's meta slot
62+
// (GC-rooted by root_set's ns_vars walk).
63+
.ns => {
64+
const ns: *env_mod.Namespace = @constCast(r.decodePtr(*const env_mod.Namespace));
65+
ns.meta = m;
66+
},
6167
else => return error_catalog.raise(.reset_meta_target_not_ref, loc, .{ .actual = @tagName(r.tag()) }),
6268
}
6369
return m;

0 commit comments

Comments
 (0)