Skip to content

Commit 07b7f10

Browse files
committed
feat(nrepl): completion-parity oracle harness + mainline fixtures
Smell-audited: 1: e2e-first per user directive — mainline (JVM nREPL + cider-nrepl) responses captured as fixtures BEFORE the cljw fixes; the red baseline is 5/18 probes matching. scripts/completion_oracle.py spawns mainline and/or cljw nREPL servers, runs the scenario groups in test/e2e/fixtures/completion/requests.json (core vars / user ns / stdlib ns / classes / static members / keywords / negative), normalizes candidates to the three keys CIDER reads (candidate/type/ns), and either captures the mainline fixture (expected.json), prints the parity diff, or dumps cljw's responses. scripts/nrepl_bencode.py is the shared dependency-free bencode client. Baseline gaps the campaign closes (survey private/notes/gapT-completion-parity-survey.md): ns="rt" leak on Zig builtins, no special-form/class/static-member/keyword sources, __-internal vars leaking, unsorted replies.
1 parent 410b046 commit 07b7f10

4 files changed

Lines changed: 1291 additions & 0 deletions

File tree

‎scripts/completion_oracle.py‎

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
#!/usr/bin/env python3
2+
"""Completion oracle harness: run the SAME completion requests against
3+
mainline Clojure (JVM nREPL + cider-nrepl) and/or cljw's nREPL, then
4+
capture fixtures or show the parity diff.
5+
6+
The completion-parity campaign (user-directed, 2026-07-15) is e2e-first:
7+
mainline's live responses are the oracle. This script is the capture +
8+
audit half; the per-commit regression half is the fixture-driven e2e
9+
(test/e2e/phase14_nrepl_completion.sh), which needs NO JVM.
10+
11+
Usage:
12+
python3 scripts/completion_oracle.py --capture # mainline → fixture
13+
python3 scripts/completion_oracle.py --diff # mainline vs cljw
14+
python3 scripts/completion_oracle.py --cljw # cljw responses only
15+
16+
Requests spec: test/e2e/fixtures/completion/requests.json — a list of
17+
scenario groups {group, setup: [code...], probes: [{prefix, ns}]}.
18+
Fixture: test/e2e/fixtures/completion/expected.json — per "group|prefix"
19+
the mainline candidate dicts normalized to the three keys CIDER reads
20+
(candidate/type/ns; extra wire fields like package/file/priority are
21+
dropped — cider-completion.el reads exactly those three), sorted by
22+
candidate (the nREPL built-in `completions` op is by-name sorted).
23+
24+
Server lifecycle: both servers are spawned as children with explicit
25+
kill in finally; the JVM gets -J-Xmx2g (orphan_prevention.md rule 3).
26+
Wrap invocations in `timeout 600` from the shell (rule 1).
27+
"""
28+
import argparse
29+
import json
30+
import os
31+
import shutil
32+
import signal
33+
import subprocess
34+
import sys
35+
import tempfile
36+
import time
37+
38+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
39+
from nrepl_bencode import NreplClient
40+
41+
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
42+
FIXTURE_DIR = os.path.join(REPO, "test", "e2e", "fixtures", "completion")
43+
REQUESTS = os.path.join(FIXTURE_DIR, "requests.json")
44+
EXPECTED = os.path.join(FIXTURE_DIR, "expected.json")
45+
46+
MAINLINE_DEPS = ('{:deps {nrepl/nrepl {:mvn/version "1.3.1"} '
47+
'cider/cider-nrepl {:mvn/version "0.62.1"}}}')
48+
49+
50+
def wait_port_file(path, seconds):
51+
deadline = time.time() + seconds
52+
while time.time() < deadline:
53+
if os.path.exists(path):
54+
with open(path) as f:
55+
txt = f.read().strip()
56+
if txt:
57+
return int(txt)
58+
time.sleep(0.2)
59+
raise SystemExit(f"no .nrepl-port within {seconds}s at {path}")
60+
61+
62+
def start_mainline(workdir):
63+
"""Headless JVM nREPL + cider-nrepl in `workdir`; returns (proc, port)."""
64+
proc = subprocess.Popen(
65+
["clj", "-J-Xmx2g", "-Sdeps", MAINLINE_DEPS, "-M", "-m", "nrepl.cmdline",
66+
"--middleware", '["cider.nrepl/cider-middleware"]', "--port", "0"],
67+
cwd=workdir, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
68+
port = wait_port_file(os.path.join(workdir, ".nrepl-port"), 120)
69+
return proc, port
70+
71+
72+
def start_cljw(workdir, port):
73+
"""cljw nrepl on an explicit port; returns (proc, port)."""
74+
binary = os.path.join(REPO, "zig-out", "bin", "cljw")
75+
proc = subprocess.Popen([binary, "nrepl", "--port", str(port)],
76+
cwd=workdir, stdout=subprocess.DEVNULL,
77+
stderr=subprocess.STDOUT)
78+
wait_port_file(os.path.join(workdir, ".nrepl-port"), 15)
79+
return proc, port
80+
81+
82+
def normalize(cands):
83+
"""Keep exactly the fields CIDER consumes; sort by candidate."""
84+
out = []
85+
for c in cands or []:
86+
d = {"candidate": c.get("candidate"), "type": c.get("type")}
87+
if "ns" in c:
88+
d["ns"] = c["ns"]
89+
out.append(d)
90+
return sorted(out, key=lambda d: d["candidate"] or "")
91+
92+
93+
def run_probes(port, groups):
94+
"""One fresh session per group (setup isolation); returns
95+
{"group|prefix": [normalized candidates]}."""
96+
result = {}
97+
for g in groups:
98+
cl = NreplClient("127.0.0.1", port)
99+
try:
100+
cl.clone()
101+
for code in g.get("setup", []):
102+
cl.request({"op": "eval", "code": code})
103+
for p in g["probes"]:
104+
res = cl.request({"op": "completions",
105+
"prefix": p["prefix"],
106+
"ns": p.get("ns", "user")})
107+
cands = next((r.get("completions") for r in res
108+
if isinstance(r, dict) and "completions" in r), None)
109+
result[f'{g["group"]}|{p["prefix"]}'] = normalize(cands)
110+
finally:
111+
cl.close()
112+
return result
113+
114+
115+
def stop(proc):
116+
proc.send_signal(signal.SIGTERM)
117+
try:
118+
proc.wait(timeout=5)
119+
except subprocess.TimeoutExpired:
120+
proc.kill()
121+
122+
123+
def main():
124+
ap = argparse.ArgumentParser()
125+
mode = ap.add_mutually_exclusive_group(required=True)
126+
mode.add_argument("--capture", action="store_true",
127+
help="run mainline, write the fixture")
128+
mode.add_argument("--diff", action="store_true",
129+
help="run both servers, print the parity diff")
130+
mode.add_argument("--cljw", action="store_true",
131+
help="run cljw only, print normalized responses")
132+
args = ap.parse_args()
133+
134+
with open(REQUESTS) as f:
135+
groups = json.load(f)
136+
137+
tmp = tempfile.mkdtemp(prefix="completion_oracle_")
138+
try:
139+
if args.capture or args.diff:
140+
m_proc, m_port = start_mainline(tmp)
141+
try:
142+
mainline = run_probes(m_port, groups)
143+
finally:
144+
stop(m_proc)
145+
if args.cljw or args.diff:
146+
cljw_dir = os.path.join(tmp, "cljw")
147+
os.makedirs(cljw_dir, exist_ok=True)
148+
c_proc, c_port = start_cljw(cljw_dir, 17000 + os.getpid() % 1000)
149+
try:
150+
cljw = run_probes(c_port, groups)
151+
finally:
152+
stop(c_proc)
153+
154+
if args.capture:
155+
os.makedirs(FIXTURE_DIR, exist_ok=True)
156+
with open(EXPECTED, "w") as f:
157+
json.dump(mainline, f, indent=1, ensure_ascii=False, sort_keys=True)
158+
f.write("\n")
159+
print(f"captured {len(mainline)} probe responses -> {EXPECTED}")
160+
elif args.cljw:
161+
print(json.dumps(cljw, indent=1, ensure_ascii=False, sort_keys=True))
162+
else:
163+
diffs = 0
164+
for key in sorted(mainline):
165+
m, c = mainline[key], cljw.get(key)
166+
if m == c:
167+
print(f"OK {key} ({len(m)})")
168+
else:
169+
diffs += 1
170+
mset = {json.dumps(d, sort_keys=True) for d in m}
171+
cset = {json.dumps(d, sort_keys=True) for d in (c or [])}
172+
print(f"DIFF {key}: mainline {len(m)} vs cljw {len(c or [])}")
173+
for line in sorted(mset - cset)[:15]:
174+
print(f" -mainline-only {line}")
175+
for line in sorted(cset - mset)[:15]:
176+
print(f" +cljw-only {line}")
177+
print(f"---\n{len(mainline) - diffs}/{len(mainline)} probes match")
178+
sys.exit(1 if diffs else 0)
179+
finally:
180+
shutil.rmtree(tmp, ignore_errors=True)
181+
182+
183+
if __name__ == "__main__":
184+
main()

‎scripts/nrepl_bencode.py‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
#!/usr/bin/env python3
2+
"""Minimal nREPL bencode client, shared by the completion oracle harness
3+
(scripts/completion_oracle.py) and ad-hoc probing.
4+
5+
The encoder/decoder mirrors the inline implementations the nREPL e2e
6+
scripts carry (test/e2e/phase14_nrepl.sh) — kept dependency-free so any
7+
python3 can run it. Responses are plain dicts/lists/strs/ints; byte
8+
strings are decoded as UTF-8 (replacement on error).
9+
"""
10+
import socket
11+
12+
13+
def bencode(x):
14+
if isinstance(x, dict):
15+
return b"d" + b"".join(bencode(k) + bencode(v) for k, v in sorted(x.items())) + b"e"
16+
if isinstance(x, str):
17+
b = x.encode()
18+
return str(len(b)).encode() + b":" + b
19+
if isinstance(x, int):
20+
return b"i" + str(x).encode() + b"e"
21+
if isinstance(x, list):
22+
return b"l" + b"".join(bencode(v) for v in x) + b"e"
23+
raise TypeError(f"bencode: {type(x)}")
24+
25+
26+
def bdecode(buf, i=0):
27+
"""Decode one value at offset i. Returns (value, next_offset).
28+
Raises ValueError/IndexError on a truncated buffer."""
29+
c = buf[i:i + 1]
30+
if c == b"d":
31+
d = {}
32+
i += 1
33+
while buf[i:i + 1] != b"e":
34+
k, i = bdecode(buf, i)
35+
v, i = bdecode(buf, i)
36+
d[k] = v
37+
return d, i + 1
38+
if c == b"l":
39+
out = []
40+
i += 1
41+
while buf[i:i + 1] != b"e":
42+
v, i = bdecode(buf, i)
43+
out.append(v)
44+
return out, i + 1
45+
if c == b"i":
46+
j = buf.index(b"e", i)
47+
return int(buf[i + 1:j]), j + 1
48+
if c == b"":
49+
raise ValueError("empty")
50+
j = buf.index(b":", i)
51+
n = int(buf[i:j])
52+
end = j + 1 + n
53+
if end > len(buf):
54+
raise ValueError("truncated string")
55+
return buf[j + 1:end].decode("utf-8", "replace"), end
56+
57+
58+
class NreplClient:
59+
"""One nREPL connection; request() sends an op and collects response
60+
dicts until a `done` status arrives (or the socket times out)."""
61+
62+
def __init__(self, host, port, timeout=20):
63+
self.sock = socket.create_connection((host, port), timeout=10)
64+
self.sock.settimeout(timeout)
65+
self.buf = b""
66+
self.counter = 0
67+
self.session = None
68+
69+
def request(self, msg):
70+
self.counter += 1
71+
msg = dict(msg)
72+
msg.setdefault("id", f"req{self.counter}")
73+
if self.session and "session" not in msg:
74+
msg["session"] = self.session
75+
self.sock.sendall(bencode(msg))
76+
out = []
77+
while True:
78+
try:
79+
chunk = self.sock.recv(1 << 16)
80+
except socket.timeout:
81+
break
82+
if not chunk:
83+
break
84+
self.buf += chunk
85+
progressed = True
86+
while progressed:
87+
progressed = False
88+
try:
89+
v, n = bdecode(self.buf)
90+
except (ValueError, IndexError):
91+
continue
92+
out.append(v)
93+
self.buf = self.buf[n:]
94+
progressed = True
95+
if any(isinstance(r, dict) and "done" in r.get("status", []) for r in out):
96+
break
97+
return out
98+
99+
def clone(self):
100+
res = self.request({"op": "clone"})
101+
self.session = next(r["new-session"] for r in res if isinstance(r, dict) and "new-session" in r)
102+
return self.session
103+
104+
def close(self):
105+
try:
106+
self.sock.close()
107+
except OSError:
108+
pass

0 commit comments

Comments
 (0)