Skip to content

Commit ab152f1

Browse files
committed
feat(parity): Clojure 1.12 static method values + defrecord ns-qualified identity (D-563(a))
Smell-audited: 2: the method-value branch reuses the SAME resolveJavaSurface + lookupMethod pair the call path uses (a value completes/resolves exactly when a call would — no second resolution rule); recordFromMap moved to the runtime zone so the reader and the __map->record primitive share one construction core; the brew-user regression class is corpus-locked cross-class (26 golden pairs). Method values (the brew report `(every? Character/isWhitespace s)` — a Name error on 1.3.0): a bare `Class/method` in VALUE position lifts to the static method's builtin fn via the analyzer's Java-surface branch, generally (Character/Math/Long/Integer/Double/String/Boolean corpus). The 1.12 `Class/.instance` + `Class/new` forms are recorded in D-563(a2). defrecord ns-qualified identity: TypeDescriptor gains defining_ns + a precomputed record_type_hash; records print `#user.Pt{…}` (clj form), the reader CONSTRUCTS `#ns.Name{…}` back (recordFromMap — extras to the extmap), and record hasheq is clj's (type-hash XOR mapHasheq) — `(hash (->Pt 1 2))` = -1763326040 both. AD-035 retired as parity; AD-009's record residual closed. scripts/nrepl_send.py added (the nREPL debug client the user suggested).
1 parent 25a0f8d commit ab152f1

13 files changed

Lines changed: 290 additions & 79 deletions

File tree

‎.dev/accepted_divergences.yaml‎

Lines changed: 9 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,8 @@ accepted:
105105
derives_from: "F-005 (numeric tower: auto-promote on overflow is the chosen cljw surface)"
106106
pin: "none — clj diverges (throws) by design; cljw's promote result is pinned by the D-165/overflow cycle's e2e (campaign C7, next session)"
107107
- id: "AD-009"
108-
summary: "cljw carries ONE hash system: `(hash x)` (hasheq) is clj-PORTABLE for value types (2026-07-16 leaf-formula alignment), and `.hashCode` / format `%h` DELEGATE to it — where clj/JVM has a SECOND, Java-side `Object.hashCode` system (`(.hashCode \"abc\")` → 96354 ≠ `(hash \"abc\")` → 74834163) that cljw does not reproduce. Residual hasheq divergences: defrecord/deftype instances (per-run descriptor seed) and >i64 ratio/BigDecimal components."
109-
example: '(hash "abc") => 74834163 BOTH ; (hash :user/k) / (hash 1/2) / (hash 1.5M) / (hash [1 2]) all match clj (corpus hash_compare.txt) ; (.hashCode "abc") => cljw 74834163 (= hash) / clj 96354 (Java String.hashCode) ; (hash (->Pt 1 2)) => cljw per-run / clj -1763326040'
108+
summary: "cljw carries ONE hash system: `(hash x)` (hasheq) is clj-PORTABLE for value types incl. defrecords (2026-07-16 alignment), and `.hashCode` / format `%h` DELEGATE to it — where clj/JVM has a SECOND, Java-side `Object.hashCode` system (`(.hashCode \"abc\")` → 96354 ≠ `(hash \"abc\")` → 74834163) that cljw does not reproduce. Residual hasheq divergences: >i64 ratio/BigDecimal components; deftype stays identity-based."
109+
example: '(hash "abc") => 74834163 BOTH ; (hash :user/k) / (hash 1/2) / (hash 1.5M) / (hash [1 2]) all match clj (corpus hash_compare.txt) ; (.hashCode "abc") => cljw 74834163 (= hash) / clj 96354 (Java String.hashCode) ; (hash (->Pt 1 2)) => -1763326040 BOTH (records joined 2026-07-16)'
110110
why: |-
111111
The 2026-07-16 D-562 sweep aligned every leaf hasheq formula with clj:
112112
strings (Murmur3.hashInt over Java String.hashCode), keywords/symbols
@@ -120,11 +120,13 @@ accepted:
120120
JVM's SECOND hash system — `Object.hashCode` (Java 31-fold String
121121
hash, per-class formulas, identity hashes) — `(.hashCode x)` and
122122
format `%h`/`%H` delegate to the single hasheq (D-207/D-216).
123-
Residual hasheq gaps: defrecord/deftype hasheq seeds on the per-run
124-
descriptor pointer (clj XORs the ns-qualified record-name hash — the
125-
AD-035 ns-qualification residual would unlock parity), and ratio /
126-
BigDecimal components beyond i64 keep the cljw-native managed hash.
127-
derives_from: "ADR-0059 (no-JVM: no second Object.hashCode system, no identity hashes) + F-011 (the observable `(hash x)` values now MATCH clj for data; the single-hash delegation is the internal-representation freedom) + AD-028 (the mapHash/mapHasheq sibling) + AD-035 (the record-name residual)."
123+
Residual hasheq gaps: ratio / BigDecimal components beyond i64 keep
124+
the cljw-native managed hash, and a bare-registered descriptor with
125+
no defining ns (unit-test contexts) keeps the descriptor-pointer
126+
seed. defrecord instances joined parity 2026-07-16 (the D-563(a)
127+
ns-qualification: type-hash XOR mapHasheq, corpus
128+
records_method_values.txt).
129+
derives_from: "ADR-0059 (no-JVM: no second Object.hashCode system, no identity hashes) + F-011 (the observable `(hash x)` values now MATCH clj for data; the single-hash delegation is the internal-representation freedom) + AD-028 (the mapHash/mapHasheq sibling)."
128130
pin: "test/diff/clj_corpus/hash_compare.txt — 30+ `(hash …)` golden values locked against real clj across every aligned type; test/diff/clj_corpus/object_methods.txt — `(= (.hashCode \"abc\") (hash \"abc\"))` → true (single-system delegation locked); test/e2e/phase14_format.sh — `%h` lowercase-hex rendering."
129131
- id: "AD-010"
130132
summary: "a Namespace value prints as `#object[Namespace \"<name>\"]` (no JVM class FQN, no `0xADDR` identity hash)"
@@ -511,24 +513,6 @@ accepted:
511513
locks. See ADR-0152 (the conscious election + the DA's seq_cst analysis).
512514
derives_from: "ADR-0152 (uniform acquire/release volatile-grade ordering) — JMM volatile = seq_cst across volatile accesses; cljw's acquire/release omits the IRIW cross-cell total order. Substrate: atom.zig/volatile.zig/iref.zig/agent.zig + the D-444 deftype field path."
513515
pin: "test/e2e/phase14_deftype_mutable.sh — the :volatile-mutable field increments correctly (the by-construction half: the field IS atomic acquire/release; the IRIW gap itself is non-deterministic + untestable)"
514-
- id: "AD-035"
515-
summary: "A defrecord prints with its SIMPLE name (`#R{:x 1, :y 2}`), not clj's namespace-qualified `#user.R{:x 1, :y 2}`."
516-
example: '(pr-str (->R 1 2)) => cljw "#R{:x 1, :y 2}" / clj "#user.R{:x 1, :y 2}" ; with extras: cljw "#R{:x 1, :y 2, :z 9}" / clj "#user.R{:x 1, :y 2, :z 9}"'
517-
why: |-
518-
cljw registers a defrecord/deftype by its SIMPLE name (the descriptor's
519-
`fqcn` is `R`, not `user.R`) — the same no-JVM-Class simple-name surface
520-
AD-003 records for `(class x)`. clj prints a record reader-tag qualified by
521-
its defining namespace because the JVM record class IS `user.R`; cljw has
522-
no defining-ns on the descriptor (ADR-0059, no JVM Class hierarchy), so it
523-
prints the simple name it actually carries. The record CONTENT (declared
524-
fields + extmap entries, in declared-then-extmap order) is byte-identical
525-
to clj modulo this prefix — verified across assoc/dissoc/get/keys/vals/
526-
count/seq/merge/select-keys/find/update/assoc-in/reduce-kv (D-086). Adding
527-
the ns prefix would require threading a defining-ns through every record
528-
descriptor purely for the print fiction, contradicting the AD-003 simple-
529-
name policy for no behavioural gain.
530-
derives_from: "ADR-0059 (no-JVM Class hierarchy) + AD-003 (simple class name, not FQCN); no_jvm_specific_assumption.md. Records carry only a simple `fqcn`, so the printer emits `#<simple>{…}`."
531-
pin: "test/e2e/phase9_record_extmap.sh — record_extmap_print locks cljw's `#R{:x 1, :y 2, :z 9}` (simple name, no `user.` prefix)"
532516
- id: "AD-036"
533517
summary: "The 2-arg array ctors `(byte-array size n)` / `(short-array size n)` / `(char-array size n)` FILL with a number init; clj throws (`IllegalArgumentException`, 'Don't know how to create ISeq from: java.lang.Long') because the JVM `Numbers` overload for those element types has no number-fill branch."
534518
example: "(vec (byte-array 3 65)) => cljw [65 65 65] / clj THROWS ; (int (aget (char-array 3 65) 0)) => cljw 65 / clj THROWS ; matching cases agree: (vec (int-array 5 7)) => [7 7 7 7 7], (vec (byte-array 3 [1 2])) => [1 2 0]"

‎.dev/debt.yaml‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,18 @@ active:
2828
status: |-
2929
open (2026-07-16, the D-562 inventory's enumerated residuals — each
3030
verified against real clj this cycle, deferred with a named reason):
31-
(a) **defrecord ns-qualified identity** — clj prints `#user.Pt{…}`
32-
(readable, reader-constructible) and hashes records as
33-
(ns-qualified-name-hash XOR map-hasheq); cljw's descriptor carries
34-
only the simple name, so print shows `#Pt{…}` (AD-035) and record
35-
hasheq seeds on the per-run descriptor pointer (AD-009 residual).
36-
Discharge = store the defining ns on the deftype/defrecord
37-
TypeDescriptor, print qualified, hash per clj's formula — one
38-
descriptor change unlocks both.
31+
(a) DISCHARGED 2026-07-16 — defrecord ns-qualified identity landed:
32+
TypeDescriptor carries defining_ns + a precomputed record_type_hash;
33+
records print `#user.Pt{…}`, the READER constructs them back
34+
(`#ns.Name{…}` → recordFromMap), and record hasheq matches clj
35+
(type-hash XOR mapHasheq; corpus records_method_values.txt,
36+
AD-035 retired as parity). deftype `(class …)` naming stays simple
37+
(AD-003/044 scope — revisit only with a consumer).
38+
(a2) NEW: Clojure 1.12 METHOD VALUES — the static form
39+
(`(every? Character/isWhitespace s)`, brew-user report 2026-07-16)
40+
landed via the analyzer's Java-surface value-position branch; the
41+
`Class/.instanceMethod` and `Class/new` 1.12 forms are NOT yet
42+
carried (implement when a consumer appears; same branch extends).
3943
(b) **Var source meta** — clj vars carry :line/:file/:column from
4044
the compiler; cljw def sites know their SourceLocation but drop it.
4145
Adding it converges `(meta #'x)` and clojure.test's

‎scripts/nrepl_send.py‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
#!/usr/bin/env python3
2+
"""Send eval(s) to a running cljw/clj nREPL server — the quick debug tool.
3+
4+
Usage:
5+
python3 scripts/nrepl_send.py 7888 "(+ 1 2)"
6+
python3 scripts/nrepl_send.py 7888 - # read code from stdin
7+
python3 scripts/nrepl_send.py 7888 --op describe
8+
python3 scripts/nrepl_send.py 7888 --op completions --prefix "Char" [--ns user]
9+
10+
Prints every response dict the server sends for the request (value / out /
11+
err / status), so a REPL-only failure ("works in CLI, breaks over nREPL")
12+
is reproducible outside the editor. NOTE: "eval" here is the nREPL wire
13+
OP name — evaluation happens in the TARGET nREPL server the caller
14+
already controls; this script never eval()s anything locally. Uses the shared bencode client
15+
(scripts/nrepl_bencode.py); no dependencies beyond python3.
16+
"""
17+
import argparse
18+
import os
19+
import sys
20+
21+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
22+
from nrepl_bencode import NreplClient
23+
24+
25+
def main():
26+
ap = argparse.ArgumentParser()
27+
ap.add_argument("port", type=int)
28+
ap.add_argument("code", nargs="?", help="code to eval; '-' reads stdin")
29+
ap.add_argument("--host", default="127.0.0.1")
30+
ap.add_argument("--op", default="eval", help="nREPL op (default eval)")
31+
ap.add_argument("--prefix", help="completions prefix")
32+
ap.add_argument("--ns", help="ns for the op")
33+
ap.add_argument("--sym", help="lookup/eldoc symbol")
34+
args = ap.parse_args()
35+
36+
cl = NreplClient(args.host, args.port)
37+
try:
38+
cl.clone()
39+
msg = {"op": args.op}
40+
if args.op == "eval":
41+
code = args.code or "-"
42+
msg["code"] = sys.stdin.read() if code == "-" else code
43+
elif args.code:
44+
msg["code"] = args.code
45+
if args.prefix is not None:
46+
msg["prefix"] = args.prefix
47+
if args.ns is not None:
48+
msg["ns"] = args.ns
49+
if args.sym is not None:
50+
msg["sym"] = args.sym
51+
for r in cl.request(msg):
52+
print(r)
53+
finally:
54+
cl.close()
55+
56+
57+
if __name__ == "__main__":
58+
main()

‎src/eval/analyzer/analyzer.zig‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -698,6 +698,14 @@ fn analyzeSymbol(
698698
// per-Runtime value at analyze time (handled inside the helper).
699699
return try makeConstant(arena, try staticFieldValue(env.rt, sf), form);
700700
}
701+
// Clojure 1.12 METHOD VALUES: a bare `Class/method` in value
702+
// position is the static method as a first-class fn —
703+
// `(every? Character/isWhitespace s)`. The host surface's
704+
// method_val already IS a callable builtin Value, so the
705+
// symbol lifts to that constant directly.
706+
if (td.lookupMethod(null, sym.name)) |me| {
707+
return try makeConstant(arena, me.method_val, form);
708+
}
701709
}
702710
// ADR-0113: an unresolved `clojure.lang.*` / `clojure.asm.*` qualified
703711
// reference is a JVM-internal class cljw has no value for (ADR-0059) —
@@ -1246,6 +1254,27 @@ fn vectorFormToValue(rt: *Runtime, env: *Env, items: []const Form) AnalyzeError!
12461254
fn liftTagged(rt: *Runtime, env: *Env, t: TaggedForm, loc: SourceLocation) AnalyzeError!Value {
12471255
const inner = try formToValue(rt, env, t.form.*);
12481256

1257+
// clj reads `#ns.Name{…}` as a RECORD constructor (the qualified
1258+
// record print form round-trips through the reader). A dotted tag is
1259+
// resolved against rt.types by its simple name; the descriptor must
1260+
// be a defrecord whose defining ns matches the tag's prefix.
1261+
if (t.tag.ns == null) {
1262+
if (std.mem.findScalarLast(u8, t.tag.name, '.')) |dot| {
1263+
const simple = t.tag.name[dot + 1 ..];
1264+
if (rt.types.get(simple)) |td| {
1265+
const matches_ns = td.defining_ns != null and
1266+
std.mem.eql(u8, td.defining_ns.?, t.tag.name[0..dot]);
1267+
const map_arg = inner.tag() == .array_map or inner.tag() == .hash_map;
1268+
if (td.kind == .defrecord and matches_ns and map_arg) {
1269+
return type_descriptor.recordFromMap(rt, td, inner) catch |e| switch (e) {
1270+
error.OutOfMemory => error.OutOfMemory,
1271+
else => error_catalog.raise(.reader_tag_unknown, loc, .{ .tag = symFullName(t.tag) }),
1272+
};
1273+
}
1274+
}
1275+
}
1276+
}
1277+
12491278
if (rt.data_readers_var) |dr_opaque| {
12501279
const dr_var: *const env_mod.Var = @ptrCast(@alignCast(dr_opaque));
12511280
const table = dr_var.deref();

‎src/eval/bytecode/serialize.zig‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1419,7 +1419,7 @@ test "every wire ValueTag has BOTH a write and a read arm (symmetry gate)" {
14191419
.fn_val => try tree_walk.allocFunctionFromSerialized(&rt, 0, &[_]tree_walk.SerializedMethod{}, null),
14201420
.type_descriptor => blk: {
14211421
const td_mod = @import("../../runtime/type_descriptor.zig");
1422-
const td = try td_mod.registerType(&rt, "SymGateType", &.{}, &.{}, .deftype);
1422+
const td = try td_mod.registerType(&rt, "SymGateType", null, &.{}, &.{}, .deftype);
14231423
break :blk try td_mod.makeTypeDescriptorRef(&rt, td);
14241424
},
14251425
};
@@ -1455,7 +1455,7 @@ test "type_descriptor constant round-trips by name (ADR-0034 am5; D-452)" {
14551455
const type_descriptor = @import("../../runtime/type_descriptor.zig");
14561456
// The defining chunk has run by load time → the type is registered. Mirror
14571457
// that here by registering BEFORE deserialize.
1458-
const td = try type_descriptor.registerType(&rt, "ZipLoc", &.{}, &.{}, .defrecord);
1458+
const td = try type_descriptor.registerType(&rt, "ZipLoc", null, &.{}, &.{}, .defrecord);
14591459
const ref = try type_descriptor.makeTypeDescriptorRef(&rt, td);
14601460

14611461
const consts = [_]Value{ Value.initInteger(1), ref };
@@ -1575,7 +1575,7 @@ test "chunk completeness gate: every side-table + entry field round-trips (D-365
15751575
// A registered host class so the static-dispatch descriptor re-resolves at
15761576
// deserialize (resolveJavaSurface(rt, env, fqcn) returns this rt.types entry;
15771577
// freeChunk does NOT free `descriptor`, which rt.types owns → no double-free).
1578-
const int_td = try type_descriptor.registerType(&rt, "java.lang.Integer", &.{}, &.{}, .native);
1578+
const int_td = try type_descriptor.registerType(&rt, "java.lang.Integer", null, &.{}, &.{}, .native);
15791579

15801580
const call_sites = [_]CallSiteEntry{
15811581
// instance dispatch (descriptor null, field_only false)

‎src/lang/primitive/protocol.zig‎

Lines changed: 8 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ const protocol_mod = @import("../../runtime/protocol.zig");
2727
const symbol_mod = @import("../../runtime/symbol.zig");
2828
const string_mod = @import("../../runtime/collection/string.zig");
2929
const vector_mod = @import("../../runtime/collection/vector.zig");
30-
const map_mod = @import("../../runtime/collection/map.zig");
3130
const td_mod = @import("../../runtime/type_descriptor.zig");
3231
const keyword_mod = @import("../../runtime/keyword.zig");
3332
const host_interface = @import("../../runtime/host_interface.zig");
@@ -335,8 +334,8 @@ pub fn nativeType(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocat
335334
/// JVM reference: clojure.core/defrecord in clojure/core_deftype.clj L387.
336335
/// cw v1 tier: A (row 7.4 cycle 2 — descriptor kind landed).
337336
pub fn defrecordPrim(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
338-
_ = env;
339-
return registerTypePrim(rt, args, loc, .defrecord, "__defrecord!");
337+
const dns: ?[]const u8 = if (env.current_ns) |ns| ns.name else null;
338+
return registerTypePrim(rt, dns, args, loc, .defrecord, "__defrecord!");
340339
}
341340

342341
/// `(rt/__map->record TypeRef m)` — clj's `Record/create`: build a defrecord
@@ -357,27 +356,8 @@ pub fn mapToRecordPrim(rt: *Runtime, env: *Env, args: []const Value, loc: Source
357356
if (args[0].tag() != .type_descriptor)
358357
return error_catalog.raise(.type_arg_invalid, loc, .{ .fn_name = "map->record", .expected = "record type", .actual = @tagName(args[0].tag()) });
359358
const td = td_mod.asTypeDescriptorRef(args[0]);
360-
const m = args[1];
361-
const layout = td.field_layout orelse &[_]td_mod.TypeDescriptor.FieldEntry{};
362-
// Declared field values, by keyword (nil when `m` lacks the key / is nil).
363-
const fields = try rt.gpa.alloc(Value, layout.len);
364-
defer rt.gpa.free(fields);
365-
for (layout, 0..) |fe, i| {
366-
const kw = try keyword_mod.intern(rt, null, fe.name);
367-
fields[i] = if (!m.isNil() and try map_mod.contains(m, kw)) try map_mod.get(m, kw) else Value.nil_val;
368-
}
369-
// extmap = `m` minus the declared keys, normalized to nil when empty so the
370-
// result `=`/hashes as a plain (no-extras) record would.
371-
var ext: Value = Value.nil_val;
372-
if (!m.isNil()) {
373-
ext = m;
374-
for (layout) |fe| {
375-
const kw = try keyword_mod.intern(rt, null, fe.name);
376-
ext = try map_mod.dissoc(rt, ext, kw);
377-
}
378-
if (map_mod.count(ext) == 0) ext = Value.nil_val;
379-
}
380-
return td_mod.allocInstanceFull(rt, td, fields, Value.nil_val, ext);
359+
// Construction core shared with the record-literal reader (D-563(a)).
360+
return td_mod.recordFromMap(rt, td, args[1]);
381361
}
382362

383363
/// `(rt/__deftype! 'Name ['field-syms...])` — register a fresh
@@ -389,8 +369,8 @@ pub fn mapToRecordPrim(rt: *Runtime, env: *Env, args: []const Value, loc: Source
389369
/// Implements clojure.core/deftype registration (JVM reference:
390370
/// clojure.core/deftype in clojure/core_deftype.clj). cw v1 tier: A.
391371
pub fn deftypePrim(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
392-
_ = env;
393-
return registerTypePrim(rt, args, loc, .deftype, "__deftype!");
372+
const dns: ?[]const u8 = if (env.current_ns) |ns| ns.name else null;
373+
return registerTypePrim(rt, dns, args, loc, .deftype, "__deftype!");
394374
}
395375

396376
/// Shared registration body for `__defrecord!` / `__deftype!` — the two
@@ -400,6 +380,7 @@ pub fn deftypePrim(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLoca
400380
/// + downstream `extend-type` resolve `Name` to a usable Value.
401381
fn registerTypePrim(
402382
rt: *Runtime,
383+
defining_ns: ?[]const u8,
403384
args: []const Value,
404385
loc: SourceLocation,
405386
kind: td_mod.TypeKind,
@@ -462,7 +443,7 @@ fn registerTypePrim(
462443
}
463444
}
464445

465-
const td = try td_mod.registerType(rt, name_sym.name, field_names, field_volatile, kind);
446+
const td = try td_mod.registerType(rt, name_sym.name, defining_ns, field_names, field_volatile, kind);
466447
return td_mod.makeTypeDescriptorRef(rt, td);
467448
}
468449

0 commit comments

Comments
 (0)