diff --git a/tools/INDEX.md b/tools/INDEX.md index 9e0f8ab..d8e883c 100644 --- a/tools/INDEX.md +++ b/tools/INDEX.md @@ -13,3 +13,4 @@ gets a Soul pass before merge. | `tools/mutation-table.sh` | One verify job that runs a list of hand mutants (file, before, after, test) against a revision and returns a kill table: CAUGHT with the killing test, SURVIVED, UNVIABLE, TIMEOUT, NOAPPLY, ERROR. Refuses a red or zero-test baseline and a red BUILD; rows print live. Isolation is Docker's: the baselines run once in a container that holds the revision, that container is committed to a throwaway image, and every mutant runs in a fresh `--rm` container from it, so nothing a run writes (tracked, untracked or ignored, such as `target/`) reaches the next mutant; the image is removed when the job ends, killed or not. The shared registry and sccache volumes and the network are not isolated. A file-level load failure (vitest `FAIL [ ]`, jest `FAIL ` plus `Test suite failed to run`) reads UNVIABLE. Replaces per-agent runmut/mutrun/strykrun scripts. | Soul, Hands (scoped run) | `references/briefs.md` (Hands verification, Soul reruns) | | `tools/testparse.sh` | Sourced library: failing test names and pass/fail/skip counts from cargo, dotnet, vitest, jest and pytest logs, and test files that failed to load (`LOADFAIL`). Shared by the summary and the kill table. | the two scripts above | not named by a brief; it lives and dies with them | | `tools/test-tools.sh` | Local fixture check of the parsers and the summary over `tools/fixtures/logs` (real cargo and mutation logs, plus hand-written dotnet, vitest, jest, pytest, exit-0-but-failed and no-verdict logs), and of the mutation runner's host driver and container half under a mock docker. Run it before merging a change to the scripts above. | whoever changes those scripts | `SKILL.md` (Shared tools) | +| `tools/context-pack.py` | `py -3 tools/context-pack.py [--since ] [--only [,...]] [--spec-json ] [--max-lines ]`. Carries a cut_spec's `file_changes` anchors from the spec's base to the worktree by `git diff` hunks (the working tree included) and prints one header, a status table (SAME, MOVED, TOUCHED, RENAMED, NEW, GONE, OUT_OF_RANGE, UNRESOLVED) and windows of at most 100 lines numbered in worktree lines, within `--max-lines` (800). Exit 3 when an anchor cannot be carried, 4 when the spec is unreadable (fetched by id through the eureka-state binding, retried once on Unavailable; `--spec-json` takes a saved view). `--since ` re-prints only the windows edited since that commit. Standard library plus git; writes nothing. | Hands, Soul, Imagination | the briefs that cut `context-pack-briefs` edits; not yet named | diff --git a/tools/context-pack.py b/tools/context-pack.py new file mode 100644 index 0000000..5e7fabf --- /dev/null +++ b/tools/context-pack.py @@ -0,0 +1,475 @@ +#!/usr/bin/env python3 +"""Carry a cut_spec's file_changes anchors from the spec's base to a worktree. + +Usage: context-pack.py [--since ] [--only [,...]] + [--spec-json ] [--max-lines ] + +Prints a header, a status table (one line per anchor) and bounded windows of the +code each anchor names, numbered in the worktree's lines. Exit: 0 every anchor +carried; 3 an anchor cannot be carried (OUT_OF_RANGE, UNRESOLVED, GONE outside +the spec's deletes); 2 usage; 4 spec unreadable. Standard library plus git. +Errors name a field and a code, never an input value. +--max-lines bounds the windows, not the table: the table always lists every +anchor, so a budget smaller than the table prints the table and no window. +""" +import argparse +import json +import os +import queue +import re +import subprocess +import sys +import threading + +WINDOW_CAP = 100 +CONTEXT = 3 +DEFAULT_MAX_LINES = 800 +ERROR_CODES = ("Unavailable", "Rejected", "Misconfigured", "TooLarge", + "Unencodable", "InvalidInput", "Internal") +HUNK = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@") +HEX = re.compile(r"^[0-9a-fA-F]{7,40}$") + + +class Fail(Exception): + def __init__(self, code, msg): + super().__init__(msg) + self.code, self.msg = code, msg + + +class Parser(argparse.ArgumentParser): + def error(self, message): # argparse would echo the offending value + sys.stderr.write("context-pack: usage error\n" + self.format_usage()) + sys.exit(2) + + +def git(wt, *args): + """The one git call: stdout bytes, or None when git failed; stderr is always + discarded. Paths are literal pathspecs here, so a spec path holding [ ] * ? + names that file and no sibling.""" + try: + p = subprocess.run(["git", "-C", wt, "--literal-pathspecs", *args], stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL) + except OSError: + return None + return p.stdout if p.returncode == 0 else None + + +def text_lines(raw): + s = raw.decode("utf-8", "replace").replace("\r\n", "\n") + parts = s.split("\n") + if parts[-1] == "": + parts.pop() + return parts + + +# ---- the spec ------------------------------------------------------------ + +def spec_from_json(obj): + if isinstance(obj, dict) and "document" in obj: + obj = obj["document"] + if isinstance(obj, dict) and "kind" in obj and "value" in obj: + obj = obj["value"] + if not isinstance(obj, dict) or "base" not in obj or "file_changes" not in obj: + raise Fail(4, "spec: not a cut_spec value") + return check_spec_shape(obj) + + +def is_int(v): + return isinstance(v, int) and not isinstance(v, bool) + + +def check_spec_shape(spec): + """A wrong-typed field is a typed refusal naming the field and InvalidInput, + never the value.""" + def bad(field): + raise Fail(4, "spec: %s: InvalidInput" % field) + if not isinstance(spec["base"], str): + bad("base") + fcs = spec["file_changes"] + if not isinstance(fcs, list): + bad("file_changes") + for k, fc in enumerate(fcs, 1): + at = "file_changes[%d]" % k + if not isinstance(fc, dict): + bad(at) + loc = fc.get("location") + if not isinstance(loc, dict): + bad(at + ".location") + if not isinstance(loc.get("path"), str): + bad(at + ".location.path") + if not is_int(loc.get("line")): + bad(at + ".location.line") + if loc.get("end_line") is not None and not is_int(loc["end_line"]): + bad(at + ".location.end_line") + if "change" in fc and not isinstance(fc["change"], str): + bad(at + ".change") + dels = spec.get("deletes", []) + if not isinstance(dels, list): + bad("deletes") + for k, d in enumerate(dels, 1): + if not isinstance(d, dict) or not isinstance(d.get("path"), str): + bad("deletes[%d].path" % k) + return spec + + +def mcp_view_once(spec_id): + exe = os.environ.get("EUREKA_STATE_EXE") + env = dict(os.environ) + args = [] + try: + with open(os.path.join(os.path.expanduser("~"), ".claude.json"), encoding="utf-8") as f: + binding = json.load(f)["mcpServers"]["eureka-state"] + exe = exe or binding.get("command") + args = list(binding.get("args", [])) + for k, v in (binding.get("env") or {}).items(): + env.setdefault(k, v) + except (OSError, ValueError, KeyError, TypeError): + if not exe: + raise Fail(4, "binding: eureka-state is not configured") + if not exe: + raise Fail(4, "binding: eureka-state has no command") + try: + proc = subprocess.Popen([exe, *args], stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, env=env) + except OSError: + raise Fail(4, "transport: Unavailable") + lines = queue.Queue() + + def pump(): + for ln in proc.stdout: + lines.put(ln) + lines.put(None) + threading.Thread(target=pump, daemon=True).start() + + def send(obj): + proc.stdin.write((json.dumps(obj) + "\n").encode("utf-8")) + proc.stdin.flush() + + def reply(want): + while True: + try: + ln = lines.get(timeout=30) + except queue.Empty: + raise Fail(4, "transport: Unavailable") + if ln is None: + raise Fail(4, "transport: Unavailable") + try: + msg = json.loads(ln) + except ValueError: + continue + if isinstance(msg, dict) and msg.get("id") == want: + return msg + + try: + send({"jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": "2024-11-05", "capabilities": {}, + "clientInfo": {"name": "context-pack", "version": "1"}}}) + reply(1) + send({"jsonrpc": "2.0", "method": "notifications/initialized"}) + send({"jsonrpc": "2.0", "id": 2, "method": "tools/call", + "params": {"name": "view", "arguments": {"id": {"kind": "CutSpec", "id": spec_id}}}}) + res = reply(2).get("result") or {} + except OSError: + raise Fail(4, "transport: Unavailable") + finally: + try: + proc.stdin.close() + proc.terminate() + except OSError: + pass + body = "".join(c.get("text", "") for c in res.get("content", []) if isinstance(c, dict)) + if res.get("isError"): + code = next((c for c in ERROR_CODES if c in body), "error") + raise Fail(4, "view: " + code) + try: + view = json.loads(body) + except ValueError: + raise Fail(4, "view: unparsable answer") + if view is None: + raise Fail(4, "view: no such cut_spec") + return spec_from_json(view) + + +def mcp_view(spec_id): + if ":cut_spec:" not in spec_id: + raise Fail(2, "spec id: not a cut_spec id") + try: + return mcp_view_once(spec_id) + except Fail as first: + if first.msg.split(": ")[-1] != "Unavailable": + raise + return mcp_view_once(spec_id) + + +def load_spec(spec_id, spec_json): + try: + if spec_json: + with open(spec_json, encoding="utf-8") as f: + return spec_from_json(json.load(f)) + return mcp_view(spec_id) + except Fail as e: + if e.code == 4: + raise Fail(4, e.msg + "; --spec-json reads a saved view") + raise + except (OSError, ValueError): + raise Fail(4, "--spec-json: unreadable; --spec-json reads a saved view") + + +# ---- carrying an anchor -------------------------------------------------- + +def carry(s, e, hunks): + """Map old lines s..e through -U0 hunks (a,b,c,d). Returns (ns, ne, ins, dele, touched).""" + shift = net_in = ins = dele = 0 + touched = False + lo = hi = None + for a, b, c, d in hunks: + if b == 0: # insertion after old line a + if a < s: + shift += d + continue + if a >= e: # at the last line or after: outside + continue + touched = True + ins += d + net_in += d + else: + last = a + b - 1 + if last < s: + shift += d - b + continue + if a > e: + continue + touched = True + ins += d + dele += b + net_in += d - b + if a < s: # the first surviving line: c, or c+1 after a pure deletion + lo = c if d > 0 else c + 1 + if last > e: # the last surviving line: c+d-1, or c after a pure deletion + hi = c + d - 1 if d > 0 else c + ns, ne = s + shift, e + shift + net_in + if lo is not None: + ns = min(ns, lo) + if hi is not None: + ne = max(ne, hi) + return ns, max(ne, ns), ins, dele, touched + + +class Tree: + def __init__(self, wt, base): + self.wt, self.base = wt, base + self.changes = {} + self._hunks, self._basefile, self._wtfile = {}, {}, {} + + def load_changes(self): + out = git(self.wt, "diff", "-M", "--name-status", "-z", "--no-color", "--no-ext-diff", self.base) + if out is None: + return False + parts = out.decode("utf-8", "replace").split("\0") + i = 0 + while i < len(parts) and parts[i]: + code = parts[i] + if code[0] in "RC" and i + 2 < len(parts): + self.changes[parts[i + 1]] = (code[0], parts[i + 2]) + i += 3 + else: + self.changes[parts[i + 1]] = (code[0], None) + i += 2 + return True + + def hunks(self, since, *paths): + key = (since, paths) + if key not in self._hunks: + out = git(self.wt, "diff", "-M", "-U0", "--no-color", "--no-ext-diff", since, "--", *paths) + hs = None + if out is not None: + hs = [] + for ln in out.decode("utf-8", "replace").split("\n"): + m = HUNK.match(ln) + if m: + a, b, c, d = m.groups() + hs.append((int(a), 1 if b is None else int(b), int(c), 1 if d is None else int(d))) + self._hunks[key] = hs + return self._hunks[key] + + def base_lines(self, path): + """None when the path is absent at base.""" + if path not in self._basefile: + raw = git(self.wt, "show", self.base + ":" + path) + self._basefile[path] = None if raw is None else text_lines(raw) + return self._basefile[path] + + def wt_lines(self, path): + if path not in self._wtfile: + try: + with open(os.path.join(self.wt, *path.split("/")), "rb") as f: + self._wtfile[path] = text_lines(f.read()) + except OSError: + self._wtfile[path] = None + return self._wtfile[path] + + +def safe_path(path): + return bool(path) and not path.startswith(("/", "\\")) and ":" not in path \ + and ".." not in path.replace("\\", "/").split("/") + + +def resolve(tree, k, fc, deleted_ok, base_ok, listed): + loc = fc["location"] + path, s = loc["path"], int(loc["line"]) + e = int(loc.get("end_line") or s) + a = {"k": k, "path": path, "s": s, "e": e, "change": str(fc.get("change", "")), + "rank": 1, "exit": 0, "status": "", "now": None, "window": None} + if not base_ok: + a.update(rank=0, exit=3, status="UNRESOLVED base not a commit here: git fetch origin " + tree.base) + return a + if not listed or not safe_path(path): + a.update(rank=0, exit=3, status="UNRESOLVED path or git diff unusable") + return a + base = tree.base_lines(path) + if base is None: + sib = [p for p in tree.siblings() if p.rsplit("/", 1)[-1] == path.rsplit("/", 1)[-1]][:3] + a.update(rank=0, status="NEW" + (" (at base: " + ", ".join(sib) + ")" if sib else "")) + a["now"] = (path, s, e) + if tree.wt_lines(path) is not None: + a["window"] = ("wt", path, s, e) + return a + code, newpath = tree.changes.get(path, ("", None)) + if code == "D": + ok = path in deleted_ok + a.update(rank=0, exit=0 if ok else 3, status="GONE" + ("" if ok else " (not named by the spec's deletes)")) + a["window"] = ("base", path, s, e) + return a + if s > len(base) or e > len(base): + a.update(rank=0, exit=3, status="OUT_OF_RANGE (base file has %d lines)" % len(base)) + return a + now = newpath or path + hs = tree.hunks(tree.base, *dict.fromkeys([path, now])) + if hs is None: + a.update(rank=0, exit=3, status="UNRESOLVED git diff failed") + return a + ns, ne, ins, dele, touched = carry(s, e, hs) + if touched: + st = "TOUCHED(+%d/-%d inside)" % (ins, dele) + elif ns != s: + st = "MOVED(%+d)" % (ns - s) + else: + st = "SAME" + if newpath: + st = "RENAMED -> " + newpath + " " + st + a.update(status=st, now=(now, ns, ne)) + a["window"] = ("wt", now, ns, ne) + return a + + +def table_line(a): + line = "[%d] %s:%d-%d %s" % (a["k"], a["path"], a["s"], a["e"], a["status"]) + if a["now"]: + line += " -> %s:%d-%d" % a["now"] + return line + " # " + " ".join(a["change"].split())[:70] + + +def window_body(tree, a): + kind, path, s, e = a["window"] + lines = tree.base_lines(path) if kind == "base" else tree.wt_lines(path) + cap = "== [%d] %s:%d-%d%s %s" % (a["k"], path, s, e, " AT BASE" if kind == "base" else "", a["status"]) + if lines is None: + return cap, ["(file not readable in the worktree)"] + lo, hi = max(1, s - CONTEXT), min(len(lines), e + CONTEXT) + body = ["%6d %s" % (n, lines[n - 1]) for n in range(lo, hi + 1)] + return cap, body or ["(no lines: file has %d lines)" % len(lines)] + + +def overlaps_since(tree, since, a): + kind, path, s, e = a["window"] + if kind != "wt": + return False + hs = tree.hunks(since, path) + if hs is None: + return False + for _, _, c, d in hs: + if d == 0: + if s - 1 <= c <= e: + return True + elif c <= e and c + d - 1 >= s: + return True + return False + + +def main(argv): + p = Parser(prog="context-pack.py") + p.add_argument("spec_id") + p.add_argument("worktree") + p.add_argument("--since") + p.add_argument("--only") + p.add_argument("--spec-json") + p.add_argument("--max-lines", type=int, default=DEFAULT_MAX_LINES) + ns = p.parse_args(argv) + if ns.max_lines < 1: + p.error("") + only = None + if ns.only is not None: + try: + only = {int(x) for x in ns.only.split(",")} + except ValueError: + p.error("") + wt = ns.worktree + if git(wt, "rev-parse", "--git-dir") is None: + raise Fail(2, "worktree: not a git worktree") + spec = load_spec(ns.spec_id, ns.spec_json) + fcs = spec["file_changes"] + if only is not None and not only <= set(range(1, len(fcs) + 1)): + raise Fail(2, "--only: index outside file_changes") + base = str(spec["base"]) + resolved = HEX.match(base) and git(wt, "rev-parse", "--verify", "--quiet", base + "^{commit}") + tree = Tree(wt, base) + listed = bool(resolved) and tree.load_changes() + tree.siblings = lambda: [x for x in (git(wt, "ls-tree", "-r", "--name-only", base) or b"") + .decode("utf-8", "replace").split("\n") if x] + if ns.since is not None and (ns.since.startswith("-") or + git(wt, "rev-parse", "--verify", "--quiet", ns.since + "^{commit}") is None): + raise Fail(2, "--since: not a commit in this worktree") + deleted_ok = {d["path"] for d in spec.get("deletes", [])} + anchors = [resolve(tree, i + 1, fc, deleted_ok, bool(resolved), listed) for i, fc in enumerate(fcs)] + + head = (git(wt, "rev-parse", "--short", "HEAD") or b"none").decode().strip() + out = ["context-pack %s base %s HEAD %s modified %d" % ( + ns.spec_id, base[:12] if HEX.match(base) else "unresolvable", head, len(tree.changes))] + out += [table_line(a) for a in sorted(anchors, key=lambda a: a["rank"])] + out.append("") + want = [a for a in anchors if a["window"] and (only is None or a["k"] in only) + and (ns.since is None or overlaps_since(tree, ns.since, a))] + table_len = len(fcs) + skipped = [] + left = ns.max_lines - len(out) + for idx, a in enumerate(want): + cap, body = window_body(tree, a) + full = len(body) + if only is None and full > WINDOW_CAP: + body = body[:WINDOW_CAP] + reserve = 1 if idx < len(want) - 1 or skipped else 0 # room for the not-expanded line + need = 1 + len(body) + (1 if len(body) < full else 0) + if only is not None and need > left - reserve and left - reserve >= 4: + body = body[:left - reserve - 2] + need = 1 + len(body) + 1 + if need > left - reserve: + skipped.append(a["k"]) + continue + out.append(cap) + out += body + if len(body) < full: + out.append("%d more lines: --only %d --max-lines %d" % (full - len(body), a["k"], table_len + full + 3)) + left -= need + if skipped: + out.append("not expanded: --only " + ",".join(map(str, skipped))) + sys.stdout.reconfigure(encoding="utf-8", errors="replace", newline="\n") + sys.stdout.write("\n".join(out) + "\n") + return max([a["exit"] for a in anchors] or [0]) + + +if __name__ == "__main__": + try: + sys.exit(main(sys.argv[1:])) + except Fail as f: + sys.stderr.write("context-pack: %s\n" % f.msg) + sys.exit(f.code) diff --git a/tools/fixtures/context-pack/fake-eureka-state.py b/tools/fixtures/context-pack/fake-eureka-state.py new file mode 100644 index 0000000..cb1bda2 --- /dev/null +++ b/tools/fixtures/context-pack/fake-eureka-state.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""A stand-in for eureka-state's stdio MCP: answers initialize and tools/call view. +FAKE_SPEC: the view result to return. FAKE_MODE=unavailable answers isError +Unavailable (naming HUGINN_ENDPOINT, as the real daemon's text might). FAKE_COUNT: +a file that gains one line per view call. FAKE_MODE=needenv answers the spec only when +EUREKA_INSTANCE is "t" and HUGINN_ENDPOINT equals FAKE_NEED, else isError Misconfigured.""" +import json, os, sys + +inited = False +for ln in sys.stdin: + msg = json.loads(ln) + if "id" not in msg: + inited = inited or msg.get("method") == "notifications/initialized" + continue + if msg["method"] == "initialize": + p = msg.get("params", {}) + if msg["id"] != 1 or not p.get("protocolVersion") or not p.get("clientInfo", {}).get("name") or "capabilities" not in p: + sys.exit(1) + res = {"protocolVersion": "2024-11-05", "capabilities": {}, "serverInfo": {"name": "fake", "version": "0"}} + else: + p = msg.get("params", {}) + if (not inited or msg["method"] != "tools/call" or p.get("name") != "view" or msg["id"] != 2 + or p.get("arguments") != {"id": {"kind": "CutSpec", "id": os.environ["FAKE_WANT"]}}): + sys.exit(1) + with open(os.environ["FAKE_COUNT"], "a") as f: + f.write("view\n") + if os.environ.get("FAKE_MODE") == "null": + res = {"content": [{"type": "text", "text": "null"}]} + elif os.environ.get("FAKE_MODE") == "garbage": + res = {"content": [{"type": "text", "text": "not json"}]} + elif os.environ.get("FAKE_MODE") == "invalid": + res = {"isError": True, "content": [{"type": "text", "text": os.environ.get("FAKE_CODE", "InvalidInput") + ": " + os.environ.get("HUGINN_ENDPOINT", "")}]} + elif os.environ.get("FAKE_MODE") == "needenv" and not ( + os.environ.get("EUREKA_INSTANCE") == "t" and os.environ.get("HUGINN_ENDPOINT") == os.environ["FAKE_NEED"]): + res = {"isError": True, "content": [{"type": "text", "text": "Misconfigured"}]} + elif os.environ.get("FAKE_MODE") == "unavailable": + res = {"isError": True, "content": [{"type": "text", + "text": "Unavailable: " + os.environ.get("HUGINN_ENDPOINT", "")}]} + else: + res = {"content": [{"type": "text", "text": open(os.environ["FAKE_SPEC"], encoding="utf-8").read()}]} + sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": res}) + "\n") + sys.stdout.flush() diff --git a/tools/fixtures/context-pack/spec.json b/tools/fixtures/context-pack/spec.json new file mode 100644 index 0000000..0545102 --- /dev/null +++ b/tools/fixtures/context-pack/spec.json @@ -0,0 +1,30 @@ +{"id": {"id": "t:cut_spec:cut-x.r1", "kind": "CutSpec"}, + "status": "InForce", + "document": {"kind": "cut_spec", "value": { + "base": "BASE", "deletes": [], + "file_changes": [ + {"change": "moved below the insertion", "location": {"path": "a.txt", "line": 20, "end_line": 22}}, + {"change": "insertion after the last line is outside", "location": {"path": "a.txt", "line": 3, "end_line": 5}}, + {"change": "insertion just before the range", "location": {"path": "a.txt", "line": 6, "end_line": 7}}, + {"change": "insertion inside the range", "location": {"path": "a.txt", "line": 4, "end_line": 8}}, + {"change": "delete above and edit inside", "location": {"path": "b.txt", "line": 8, "end_line": 12}}, + {"change": "moved up by a deletion", "location": {"path": "b.txt", "line": 30, "end_line": 31}}, + {"change": "deleted file", "location": {"path": "c.txt", "line": 2, "end_line": 3}}, + {"change": "renamed file", "location": {"path": "d.txt", "line": 4, "end_line": 5}}, + {"change": "path absent at base", "location": {"path": "sub/a.txt", "line": 1, "end_line": 2}}, + {"change": "range past EOF", "location": {"path": "e.txt", "line": 5, "end_line": 50}}, + {"change": "uncommitted edit", "location": {"path": "f.txt", "line": 5, "end_line": 6}}, + {"change": "last line is in range", "location": {"path": "e.txt", "line": 10, "end_line": 10}}, + {"change": "one past the last line", "location": {"path": "e.txt", "line": 10, "end_line": 11}}, + {"change": "CRLF file", "location": {"path": "h.txt", "line": 2, "end_line": 3}}, + {"change": "straddles the head of a replaced block", "location": {"path": "m.txt", "line": 12, "end_line": 13}}, + {"change": "replaced block starts inside", "location": {"path": "m.txt", "line": 8, "end_line": 11}}, + {"change": "just after a replaced block", "location": {"path": "m.txt", "line": 15, "end_line": 16}}, + {"change": "deletion follows the range", "location": {"path": "m.txt", "line": 28, "end_line": 28}}, + {"change": "deletion at the next line", "location": {"path": "m.txt", "line": 29, "end_line": 29}}, + {"change": "deletion ends just before", "location": {"path": "m.txt", "line": 32, "end_line": 32}}, + {"change": "after the deletion", "location": {"path": "m.txt", "line": 33, "end_line": 33}}, + {"change": "insertion at the first line", "location": {"path": "a.txt", "line": 5, "end_line": 7}}, + {"change": "deletion ends just before the range", "location": {"path": "b.txt", "line": 4, "end_line": 5}}, + {"change": "untracked file the cut creates", "location": {"path": "n.txt", "line": 1, "end_line": 2}} + ]}}} diff --git a/tools/test-tools.sh b/tools/test-tools.sh index 92b6334..d93b9a6 100644 --- a/tools/test-tools.sh +++ b/tools/test-tools.sh @@ -225,4 +225,292 @@ r=$(WM=2 wo log "$T/l.log" '(unclosed'); wchk "invalid regex" 3 "$r" grep -q 'not a valid extended regex' "$T/wo.out" || { echo "FAIL wait-on: no regex message"; fail=1; } r=$(wo bogus); wchk "bad usage" 2 "$r" wait + +# context-pack.py: a spec's anchors carried from its base to the worktree (no network) +CP=$here/context-pack.py; CF=$here/fixtures/context-pack +if py -3 -c pass >/dev/null 2>&1; then PY="py -3"; elif python3 -c pass >/dev/null 2>&1; then PY=python3; else PY=python; fi +wp() { cygpath -m "$1" 2>/dev/null || echo "$1"; } +R=$T/cp; mkdir -p "$R"; Rw=$(wp "$R") +g() { git -C "$R" -c user.name=t -c user.email=t@t -c core.autocrlf=false "$@" >/dev/null 2>&1; } +seqf() { local i; for i in $(seq 1 "$2"); do echo "$1$i"; done; } +g init -q +seqf a 40 >"$R/a.txt"; seqf b 40 >"$R/b.txt"; seqf c 10 >"$R/c.txt"; seqf d 20 >"$R/d.txt" +seqf e 10 >"$R/e.txt"; seqf f 20 >"$R/f.txt"; seqf g 300 >"$R/big.txt"; seqf h 5 | sed 's/$/ /' >"$R/h.txt"; seqf m 40 >"$R/m.txt" +g add -A; g commit -q -m base; BASE=$(git -C "$R" rev-parse HEAD) +{ sed -n 1,5p "$R/a.txt"; printf 'ins1\nins2\nins3\n'; sed -n '6,$p' "$R/a.txt"; } >"$R/a.new"; mv "$R/a.new" "$R/a.txt" +sed -e '2,3d' -e 's/^b10$/b10-EDITED/' "$R/b.txt" >"$R/b.new"; mv "$R/b.new" "$R/b.txt" +sed -e '10,14c\X1\nX2' -e '30,31d' "$R/m.txt" >"$R/m.new"; mv "$R/m.new" "$R/m.txt" +g rm -q c.txt; g mv d.txt d2.txt; g add -A; g commit -q -m second +sed -i 's/^f5$/f5-UNCOMMITTED/' "$R/f.txt"; printf 'n1\nn2\nn3\n' >"$R/n.txt" +sed "s/\"BASE\"/\"$BASE\"/" "$CF/spec.json" >"$T/cp-spec.json"; SP=$(wp "$T/cp-spec.json") +cpo() { $PY "$CP" t:cut_spec:cut-x.r1 "$Rw" "$@" >"$T/cp.out" 2>"$T/cp.err"; echo $?; } +cchk() { if [ "$3" = "$2" ]; then echo "ok context-pack $1: exit $3"; else echo "FAIL context-pack $1: exit $3, want $2"; fail=1; fi; } +cin() { grep -qF -- "$2" "$T/cp.out" && echo "ok context-pack $1" || { echo "FAIL context-pack $1: missing: $2"; fail=1; }; } +cnot() { if grep -qF -- "$2" "$T/cp.out" "$T/cp.err"; then echo "FAIL context-pack $1: must not contain: $2"; fail=1; else echo "ok context-pack $1"; fi; } +r=$(cpo --spec-json "$SP"); cchk "fixture (GONE and OUT_OF_RANGE)" 3 "$r" +cin "header" "context-pack t:cut_spec:cut-x.r1 base ${BASE:0:12} HEAD" +cin "header counts modified files" " modified 6" +cin "MOVED(+3)" "[1] a.txt:20-22 MOVED(+3) -> a.txt:23-25" +cin "insertion at the last line is outside" "[2] a.txt:3-5 SAME -> a.txt:3-5" +cin "insertion before the range shifts it" "[3] a.txt:6-7 MOVED(+3) -> a.txt:9-10" +cin "insertion inside widens" "[4] a.txt:4-8 TOUCHED(+3/-0 inside) -> a.txt:4-11" +cin "delete above, edit inside" "[5] b.txt:8-12 TOUCHED(+1/-1 inside) -> b.txt:6-10" +cin "MOVED(-2)" "[6] b.txt:30-31 MOVED(-2) -> b.txt:28-29" +cin "GONE" "[7] c.txt:2-3 GONE (not named by the spec's deletes)" +cin "RENAMED" "[8] d.txt:4-5 RENAMED -> d2.txt SAME -> d2.txt:4-5" +cin "NEW with basename candidates" "[9] sub/a.txt:1-2 NEW (at base: a.txt)" +cin "OUT_OF_RANGE" "[10] e.txt:5-50 OUT_OF_RANGE (base file has 10 lines)" +cin "uncommitted edit is TOUCHED" "[11] f.txt:5-6 TOUCHED(+1/-1 inside)" +cin "last line of the file is in range" "[12] e.txt:10-10 SAME" +cin "one past the last line is out of range" "[13] e.txt:10-11 OUT_OF_RANGE (base file has 10 lines)" +cin "CRLF file reads SAME" "[14] h.txt:2-3 SAME" +cin "hunk straddling the head clamps to the new block" "[15] m.txt:12-13 TOUCHED(+2/-5 inside) -> m.txt:10-11" +cin "hunk starting inside and ending past the range" "[16] m.txt:8-11 TOUCHED(+2/-5 inside) -> m.txt:8-11" +cin "range just after a replaced block shifts" "[17] m.txt:15-16 MOVED(-3) -> m.txt:12-13" +cin "deletion after the range moves nothing else" "[18] m.txt:28-28 MOVED(-3) -> m.txt:25-25" +cin "deletion at the next line" "[19] m.txt:29-29 MOVED(-3) -> m.txt:26-26" +cin "deletion ending just before the range" "[20] m.txt:32-32 MOVED(-5) -> m.txt:27-27" +cin "range after both hunks" "[21] m.txt:33-33 MOVED(-5) -> m.txt:28-28" +cin "insertion at the first line is inside" "[22] a.txt:5-7 TOUCHED(+3/-0 inside) -> a.txt:5-10" +cin "hunk ending just before the range shifts it" "[23] b.txt:4-5 MOVED(-2) -> b.txt:2-3" +cin "NEW file present in the worktree gets a window" "== [24] n.txt:1-2" +cin "NEW window shows the file" " 2 n2" +cin "CRLF window text" " 2 h2" +{ tr -d ' ' <"$T/cp.out" | cmp -s - "$T/cp.out"; } && echo "ok context-pack CRLF is stripped from windows" || { echo "FAIL context-pack CR in output"; fail=1; } +cin "window numbers are worktree lines" " 6 b8" +cin "window shows the edit" "b10-EDITED" +cin "GONE window is labelled AT BASE" "== [7] c.txt:2-3 AT BASE" +cin "GONE window shows base text" " 2 c2" +cin "renamed window reads the new path" "== [8] d2.txt:4-5" +[ "$(grep -c '^\[' "$T/cp.out")" = 24 ] && echo "ok context-pack table lists every anchor" || { echo "FAIL context-pack table count"; fail=1; } +{ sed -n 2p "$T/cp.out" | grep -q '^\[7\]' && sed -n 6p "$T/cp.out" | grep -q '^\[24\]' && sed -n 7p "$T/cp.out" | grep -q '^\[1\]'; } && echo "ok context-pack problem anchors first" || { echo "FAIL context-pack problem anchors first"; fail=1; } +# deletes naming the file, and a valid range, make the same spec exit 0 +sed -e 's/"deletes": \[\]/"deletes": [{"path": "c.txt"}]/' -e 's/"end_line": 50/"end_line": 6/' -e 's/"end_line": 11/"end_line": 10/' "$T/cp-spec.json" >"$T/cp-spec0.json" +r=$(cpo --spec-json "$(wp "$T/cp-spec0.json")"); cchk "GONE named by deletes, no OUT_OF_RANGE" 0 "$r" +cin "GONE in deletes has no qualifier" "[7] c.txt:2-3 GONE #" +# unreachable base +sed "s/\"$BASE\"/\"deadbeef\"/" "$T/cp-spec.json" >"$T/cp-specb.json" +r=$(cpo --spec-json "$(wp "$T/cp-specb.json")"); cchk "unreachable base" 3 "$r" +cin "UNRESOLVED hint" "UNRESOLVED base not a commit here: git fetch origin deadbeef" +[ "$(grep -c 'UNRESOLVED' "$T/cp.out")" = 24 ] && echo "ok context-pack every anchor UNRESOLVED" || { echo "FAIL context-pack UNRESOLVED count"; fail=1; } +cnot "UNRESOLVED prints no window" "== [" +# --since: every anchor stays in the table; only the edited one has a window +r=$(cpo --spec-json "$SP" --since HEAD); cchk "--since" 3 "$r" +cin "--since keeps the table" "[1] a.txt:20-22 MOVED(+3)" +cin "--since prints the edited window" "== [11] f.txt:5-6" +cin "--since window shows the edit" "f5-UNCOMMITTED" +cnot "--since omits untouched windows" "== [1] " +cnot "--since omits the GONE window" "== [7] " +r=$(cpo --spec-json "$SP" --since "$BASE"); cin "--since an older commit widens" "== [4] a.txt:4-11" +cnot "--since an older commit still omits untouched windows" "== [2] " +r=$(cpo --spec-json "$SP" --since "$BASE" --only 15,16,17,18,19,20,21) +for k in 15 16 19 20; do cin "--since window $k overlaps a hunk" "== [$k] "; done +for k in 17 18 21; do cnot "--since window $k does not overlap" "== [$k] "; done +r=$(cpo --spec-json "$SP" --only 2,11); cin "--only prints the named window" "== [2] " +cnot "--only omits the others" "== [1] " +r=$(cpo --spec-json "$SP" --only 99); cchk "--only outside file_changes" 2 "$r"; grep -qF "context-pack: --only: index outside file_changes" "$T/cp.err" && echo "ok context-pack names --only" || { echo "FAIL context-pack names --only"; fail=1; } +r=$(cpo --spec-json "$SP" --since feedface); cchk "--since not a commit" 2 "$r"; grep -qF "context-pack: --since: not a commit in this worktree" "$T/cp.err" && echo "ok context-pack names --since" || { echo "FAIL context-pack names --since"; fail=1; } +Z70=$(printf 'z%.0s' $(seq 1 70)) +echo '{"base": "'$BASE'", "deletes": [], "file_changes": [ + {"change": "up", "location": {"path": "../a.txt", "line": 1, "end_line": 1}}, + {"change": "abs", "location": {"path": "/a.txt", "line": 1, "end_line": 1}}, + {"change": "drive", "location": {"path": "c:a.txt", "line": 1, "end_line": 1}}, + {"change": "'$Z70'zzzz", "location": {"path": "a.txt", "line": 1, "end_line": 1}}]}' >"$T/cp-specu.json" +r=$(cpo --spec-json "$(wp "$T/cp-specu.json")"); cchk "unsafe paths" 3 "$r" +[ "$(grep -c 'UNRESOLVED path or git diff unusable' "$T/cp.out")" = 3 ] && echo "ok context-pack unsafe paths are UNRESOLVED" || { echo "FAIL context-pack unsafe paths"; fail=1; } +cin "a long change is cut at 70 characters" "# $Z70" +cnot "a long change is cut at 70 characters (not 71)" "${Z70}z" +# caps +{ echo '{"base": "'$BASE'", "deletes": [], "file_changes": [' + for i in $(seq 1 30); do echo '{"change": "c'$i'", "location": {"path": "big.txt", "line": '$((i*5))', "end_line": '$((i*5+9))'}}'$([ $i -lt 30 ] && echo ,); done + echo ']}'; } >"$T/cp-spec30.json" +r=$(cpo --spec-json "$(wp "$T/cp-spec30.json")" --max-lines 200); cchk "30 anchors" 0 "$r" +[ "$(wc -l <"$T/cp.out")" -le 200 ] && echo "ok context-pack output within --max-lines" || { echo "FAIL context-pack over 200 lines"; fail=1; } +[ "$(grep -c '^\[' "$T/cp.out")" = 30 ] && echo "ok context-pack capped output keeps the full table" || { echo "FAIL context-pack capped table"; fail=1; } +[ "$(grep -c '^== \[' "$T/cp.out")" = 9 ] && echo "ok context-pack expands 9 windows of 17 lines" || { echo "FAIL context-pack window count: $(grep -c '^== \[' "$T/cp.out")"; fail=1; } +cin "rest listed as not expanded" "not expanded: --only 10,11,12" +echo '{"base": "'$BASE'", "deletes": [], "file_changes": [{"change": "long", "location": {"path": "big.txt", "line": 20, "end_line": 169}}]}' >"$T/cp-spec1.json" +S1=$(wp "$T/cp-spec1.json") +r=$(cpo --spec-json "$S1"); cchk "150-line range" 0 "$r" +cin "window cut at 100 lines" "56 more lines: --only 1 --max-lines 160" +[ "$(grep -c '^ *[0-9]* g' "$T/cp.out")" = 100 ] && echo "ok context-pack long window prints 100 lines" || { echo "FAIL context-pack long window"; fail=1; } +r=$(cpo --spec-json "$S1" --only 1 --max-lines 160); cchk "--only with the suggested --max-lines" 0 "$r" +cnot "full window needs no more-lines note" "more lines" +[ "$(wc -l <"$T/cp.out" | tr -d ' ')" = 160 ] && echo "ok context-pack suggested --max-lines is exact" || { echo "FAIL context-pack suggested max: $(wc -l <"$T/cp.out")"; fail=1; } +# usage and unreadable spec; no error echoes an input value +r=$(cpo --spec-json "$SP" --max-lines CANARY-usage); cchk "bad --max-lines" 2 "$r"; cnot "usage error echoes no value" "CANARY-usage" +r=$(cpo --spec-json "$T/CANARY-nofile.json"); cchk "unreadable --spec-json" 4 "$r"; grep -qF "context-pack: --spec-json: unreadable; --spec-json reads a saved view" "$T/cp.err" && echo "ok context-pack names the unreadable --spec-json" || { echo "FAIL context-pack names the unreadable --spec-json"; fail=1; }; cnot "unreadable --spec-json echoes no path" "CANARY-nofile" +grep -qF -- "--spec-json" "$T/cp.err" && echo "ok context-pack names --spec-json" || { echo "FAIL context-pack error does not name --spec-json"; fail=1; } +r=$($PY "$CP" CANARY-id "$Rw" >"$T/cp.out" 2>"$T/cp.err"; echo $?); cchk "spec id that is no cut_spec id" 2 "$r"; grep -qF "context-pack: spec id: not a cut_spec id" "$T/cp.err" && echo "ok context-pack names the spec id field" || { echo "FAIL context-pack names the spec id field"; fail=1; }; cnot "bad id echoes no value" "CANARY-id" +r=$($PY "$CP" t:cut_spec:cut-x.r1 "$T/CANARY-notrepo" --spec-json "$SP" >"$T/cp.out" 2>"$T/cp.err"; echo $?); cchk "worktree is not a repo" 2 "$r"; grep -qF "context-pack: worktree: not a git worktree" "$T/cp.err" && echo "ok context-pack names the worktree field" || { echo "FAIL context-pack names the worktree field"; fail=1; }; cnot "worktree error echoes no path" "CANARY-notrepo" +# the spec by id through the stdio MCP binding (a fake eureka-state), retried once on Unavailable +H=$T/cphome; mkdir -p "$H"; FK=$(wp "$CF/fake-eureka-state.py") +$PY - "$(wp "$H")/.claude.json" "$($PY -c 'import sys;print(sys.executable)')" "$FK" <<'PYE' +import json, sys +json.dump({"mcpServers": {"eureka-state": {"type": "stdio", "command": sys.argv[2], "args": [sys.argv[3]], + "env": {"EUREKA_INSTANCE": "t", "HUGINN_ENDPOINT": "rudp://binding"}}}}, open(sys.argv[1], "w")) +PYE +Hw=$(wp "$H") +mcp() { HOME="$Hw" USERPROFILE="$Hw" FAKE_WANT=t:cut_spec:cut-x.r1 FAKE_SPEC="$SP" FAKE_COUNT="$(wp "$T/cp.count")" "$@"; } +$PY "$CP" t:cut_spec:cut-x.r1 "$Rw" --spec-json "$SP" >"$T/cp.want" 2>/dev/null +: >"$T/cp.count" +mcp $PY "$CP" t:cut_spec:cut-x.r1 "$Rw" >"$T/cp.out" 2>"$T/cp.err"; r=$? +cchk "spec by id through the binding" 3 "$r" +cmp -s "$T/cp.out" "$T/cp.want" && echo "ok context-pack by id equals --spec-json" || { echo "FAIL context-pack by id differs from --spec-json"; fail=1; } +: >"$T/cp.count" +mcp env FAKE_MODE=unavailable HUGINN_ENDPOINT=rudp://CANARY-endpoint $PY "$CP" t:cut_spec:cut-x.r1 "$Rw" >"$T/cp.out" 2>"$T/cp.err"; r=$? +cchk "Unavailable twice" 4 "$r"; cnot "Unavailable echoes no endpoint" "CANARY-endpoint" +[ "$(wc -l <"$T/cp.count" | tr -d ' ')" = 2 ] && echo "ok context-pack retried Unavailable exactly once" || { echo "FAIL context-pack view attempts: $(wc -l <"$T/cp.count")"; fail=1; } +grep -qF "context-pack: view: Unavailable; --spec-json reads a saved view" "$T/cp.err" && echo "ok context-pack Unavailable names --spec-json" || { echo "FAIL context-pack Unavailable hint"; fail=1; } +mcp env EUREKA_STATE_EXE="$T/CANARY-exe" $PY "$CP" t:cut_spec:cut-x.r1 "$Rw" >"$T/cp.out" 2>"$T/cp.err"; r=$? +cchk "unspawnable EUREKA_STATE_EXE" 4 "$r"; grep -qF "context-pack: transport: Unavailable; --spec-json reads a saved view" "$T/cp.err" && echo "ok context-pack names the transport failure" || { echo "FAIL context-pack names the transport failure"; fail=1; }; cnot "spawn error echoes no path" "CANARY-exe" +: >"$T/cp.count" +mcp env FAKE_MODE=invalid HUGINN_ENDPOINT=rudp://CANARY-endpoint2 $PY "$CP" t:cut_spec:cut-x.r1 "$Rw" >"$T/cp.out" 2>"$T/cp.err"; r=$? +cchk "a refusal that is not Unavailable" 4 "$r"; [ "$(wc -l <"$T/cp.count" | tr -d " ")" = 1 ] && echo "ok context-pack does not retry a refusal" || { echo "FAIL context-pack retried a refusal"; fail=1; }; cnot "refusal echoes no endpoint" "CANARY-endpoint2" +grep -qF "context-pack: view: InvalidInput; --spec-json reads a saved view" "$T/cp.err" && echo "ok context-pack names the refusal code" || { echo "FAIL context-pack refusal code"; fail=1; } +for code in Rejected Misconfigured TooLarge Unencodable Internal; do + mcp env FAKE_MODE=invalid FAKE_CODE=$code $PY "$CP" t:cut_spec:cut-x.r1 "$Rw" >"$T/cp.out" 2>"$T/cp.err"; r=$? + grep -qF "context-pack: view: $code; --spec-json reads a saved view" "$T/cp.err" && [ $r = 4 ] && echo "ok context-pack names the code $code" || { echo "FAIL context-pack code $code"; fail=1; } +done +mcp env FAKE_MODE=garbage $PY "$CP" t:cut_spec:cut-x.r1 "$Rw" >"$T/cp.out" 2>"$T/cp.err"; r=$? +cchk "unparsable answer" 4 "$r"; grep -qF "context-pack: view: unparsable answer; --spec-json reads a saved view" "$T/cp.err" && echo "ok context-pack names the unparsable answer" || { echo "FAIL context-pack unparsable message"; fail=1; } +mcp env FAKE_MODE=null $PY "$CP" t:cut_spec:cut-x.r1 "$Rw" >"$T/cp.out" 2>"$T/cp.err"; r=$? +cchk "no such cut_spec" 4 "$r"; grep -qF "context-pack: view: no such cut_spec; --spec-json reads a saved view" "$T/cp.err" && echo "ok context-pack names the missing spec" || { echo "FAIL context-pack missing spec message"; fail=1; } +mcp env HOME="$(wp "$T")/nohome" USERPROFILE="$(wp "$T")/nohome" $PY "$CP" t:cut_spec:cut-x.r1 "$Rw" >"$T/cp.out" 2>"$T/cp.err"; r=$? +cchk "no binding and no EUREKA_STATE_EXE" 4 "$r"; grep -qF "context-pack: binding: eureka-state is not configured; --spec-json reads a saved view" "$T/cp.err" && echo "ok context-pack names the missing binding" || { echo "FAIL context-pack binding message"; fail=1; } +echo '{}' >"$T/cp-empty.json"; r=$(cpo --spec-json "$(wp "$T/cp-empty.json")"); cchk "spec-json that is no cut_spec" 4 "$r" +grep -qF "context-pack: spec: not a cut_spec value; --spec-json reads a saved view" "$T/cp.err" && echo "ok context-pack names the spec shape" || { echo "FAIL context-pack spec shape message"; fail=1; } +grep -qF "usage: " "$T/cp.err" || echo "ok context-pack spec error prints no usage text" +r=$(cpo --max-lines 0); cchk "usage: no spec source" 2 "$r" +grep -qF "context-pack: usage error" "$T/cp.err" && echo "ok context-pack usage error text" || { echo "FAIL context-pack usage text"; fail=1; } +# glob characters in a spec path name that file only; straddling hunks; --since boundaries +R2=$T/cp2; mkdir -p "$R2/src/[a]" "$R2/src/a"; R2w=$(wp "$R2") +g2() { git -C "$R2" -c user.name=t -c user.email=t@t -c core.autocrlf=false "$@" >/dev/null 2>&1; } +g2 init -q +seqf x 30 >"$R2/src/[a]/f.txt"; seqf x 30 >"$R2/src/a/f.txt" +for n in s t u v; do seqf x 30 >"$R2/$n.txt"; done; seqf w 12 >"$R2/w.txt"; seqf g 200 >"$R2/big.txt"; echo q1 >"$R2/one.txt" +g2 add -A; g2 commit -q -m base; B2=$(git -C "$R2" rev-parse HEAD) +{ printf 'new1\nnew2\n'; cat "$R2/src/a/f.txt"; } >"$R2/src/a/f.new"; mv "$R2/src/a/f.new" "$R2/src/a/f.txt" +sed -i 8,12d "$R2/s.txt"; sed -i 15,25d "$R2/t.txt" +sed -i '8,12c\NEW' "$R2/u.txt"; sed -i '15,25c\NEW' "$R2/v.txt" +g2 add -A; g2 commit -q -m second; S2=$(git -C "$R2" rev-parse HEAD) +sed -i 's/^w5$/w5-EDITED/' "$R2/w.txt" +echo '{"base": "'$B2'", "deletes": [], "file_changes": [ + {"change": "glob path", "location": {"path": "src/[a]/f.txt", "line": 10, "end_line": 12}}, + {"change": "pure deletion above the start", "location": {"path": "s.txt", "line": 10, "end_line": 20}}, + {"change": "pure deletion past the end", "location": {"path": "t.txt", "line": 10, "end_line": 20}}, + {"change": "one-line replacement above the start", "location": {"path": "u.txt", "line": 10, "end_line": 20}}, + {"change": "one-line replacement past the end", "location": {"path": "v.txt", "line": 10, "end_line": 20}}, + {"change": "edit on the last line", "location": {"path": "w.txt", "line": 3, "end_line": 5}}, + {"change": "edit on the first line", "location": {"path": "w.txt", "line": 5, "end_line": 7}}, + {"change": "after the edit", "location": {"path": "w.txt", "line": 6, "end_line": 8}}, + {"change": "before the edit", "location": {"path": "w.txt", "line": 1, "end_line": 4}}]}' >"$T/cp-spec2.json" +SP2=$(wp "$T/cp-spec2.json") +cp2() { $PY "$CP" t:cut_spec:cut-x.r1 "$R2w" "$@" >"$T/cp.out" 2>"$T/cp.err"; echo $?; } +r=$(cp2 --spec-json "$SP2"); cchk "straddle fixture" 0 "$r" +cin "a glob path reads its own file: SAME" "[1] src/[a]/f.txt:10-12 SAME -> src/[a]/f.txt:10-12" +cin "pure deletion starting above the anchor starts at the first survivor" "[2] s.txt:10-20 TOUCHED(+0/-5 inside) -> s.txt:8-15" +cin "pure deletion ending past the anchor ends at the last survivor" "[3] t.txt:10-20 TOUCHED(+0/-11 inside) -> t.txt:10-14" +cin "one-line replacement above the start begins at the replacement" "[4] u.txt:10-20 TOUCHED(+1/-5 inside) -> u.txt:8-16" +cin "one-line replacement past the end ends at the replacement" "[5] v.txt:10-20 TOUCHED(+1/-11 inside) -> v.txt:10-15" +cin "straddle window starts at the mapped range" "== [2] s.txt:8-15" +r=$(cp2 --spec-json "$SP2" --since "$S2" --only 6,7,8,9) +cin "--since: an edit on the anchor's last line is inside" "== [6] w.txt:3-5" +cin "--since: an edit on the anchor's first line is inside" "== [7] w.txt:5-7" +cnot "--since: an anchor after the edit has no window" "== [8] " +cnot "--since: an anchor before the edit has no window" "== [9] " +cin "--since keeps every anchor in the table" "[9] w.txt:1-4 SAME" +# a --since commit whose tree is unreadable: git diff fails, so no anchor is claimed edited +BL=$(echo only | git -C "$R2" hash-object -w --stdin); TR=$(printf '100644 blob %s\tonly.txt\n' "$BL" | git -C "$R2" mktree) +CM=$(git -C "$R2" -c user.name=t -c user.email=t@t commit-tree "$TR" -m orphan) +chmod u+w "$R2/.git/objects/${TR:0:2}/${TR:2}" 2>/dev/null; rm -f "$R2/.git/objects/${TR:0:2}/${TR:2}" +r=$(cp2 --spec-json "$SP2" --since "$CM"); cchk "--since with an unreadable tree" 0 "$r" +cnot "--since: a failed git diff claims no edited window" "== [" +cin "--since: a failed git diff keeps the table" "[6] w.txt:3-5" +# a spec value of the wrong type is a typed refusal naming the field, never a traceback or the value +$PY - "$T" "$SP" <<'PYE' +import copy, json, sys +t, sp = sys.argv[1], sys.argv[2] +spec = json.load(open(sp, encoding="utf-8")) +cases = { + "line": (lambda v: v["file_changes"][1]["location"].update(line="CANARY-line"), "file_changes[2].location.line"), + "line-bool": (lambda v: v["file_changes"][0]["location"].update(line=True), "file_changes[1].location.line"), + "end_line": (lambda v: v["file_changes"][2]["location"].update(end_line="CANARY-end"), "file_changes[3].location.end_line"), + "path": (lambda v: v["file_changes"][0]["location"].update(path=["CANARY-path"]), "file_changes[1].location.path"), + "nolocation": (lambda v: v["file_changes"][3].pop("location"), "file_changes[4].location"), + "location-str": (lambda v: v["file_changes"][0].update(location="CANARY-loc"), "file_changes[1].location"), + "entry-str": (lambda v: v["file_changes"].__setitem__(0, "CANARY-str"), "file_changes[1]"), + "change": (lambda v: v["file_changes"][0].update(change={"CANARY": 1}), "file_changes[1].change"), + "fcs-str": (lambda v: v.update(file_changes="CANARY-fcs"), "file_changes"), + "base": (lambda v: v.update(base=12345), "base"), + "deletes-str": (lambda v: v.update(deletes="CANARY-del"), "deletes"), + "deletes-nopath": (lambda v: v.update(deletes=[{"note": "CANARY-note"}]), "deletes[1].path"), + "deletes-entry-str": (lambda v: v.update(deletes=["CANARY-d"]), "deletes[1].path"), +} +names = [] +for name, (edit, field) in cases.items(): + d = copy.deepcopy(spec) + edit(d["document"]["value"]) + json.dump(d, open("%s/cp-bad-%s.json" % (t, name), "w")) + names.append("%s %s" % (name, field)) +open(t + "/cp-bad.list", "w", newline=chr(10)).write("\n".join(names) + "\n") +PYE +while read -r nm fld; do + r=$(cpo --spec-json "$(wp "$T/cp-bad-$nm.json")"); cchk "wrong-typed spec: $nm" 4 "$r" + grep -qF "context-pack: spec: $fld: InvalidInput; --spec-json reads a saved view" "$T/cp.err" && echo "ok context-pack names $fld" || { echo "FAIL context-pack message for $nm: $(cat "$T/cp.err")"; fail=1; } + cnot "wrong-typed spec $nm: no value" "CANARY"; cnot "wrong-typed spec $nm: no traceback" "Traceback" +done <"$T/cp-bad.list" +echo '{"base": "CANARY-base", "deletes": [], "file_changes": [{"change": "c", "location": {"path": "a.txt", "line": 1, "end_line": 2}}]}' >"$T/cp-specnb.json" +r=$(cpo --spec-json "$(wp "$T/cp-specnb.json")"); cchk "a base that is no sha" 3 "$r" +cin "a base that is no sha reads UNRESOLVED" "UNRESOLVED base not a commit here" +cin "a base that is no sha is not echoed in the header" "base unresolvable HEAD" +echo '{"file_changes": [{"change": "c", "location": {"path": "a.txt", "line": 1}}]}' >"$T/cp-spec-nobase.json" +r=$(cpo --spec-json "$(wp "$T/cp-spec-nobase.json")"); cchk "a spec value without a base" 4 "$r" +grep -qF "context-pack: spec: not a cut_spec value; --spec-json reads a saved view" "$T/cp.err" && echo "ok context-pack a missing base is a typed refusal" || { echo "FAIL context-pack missing base: $(cat "$T/cp.err")"; fail=1; } +# a --max-lines below the table: the table always lists every anchor and no window prints +r=$(cpo --spec-json "$(wp "$T/cp-spec30.json")" --max-lines 5); cchk "--max-lines below the table" 0 "$r" +[ "$(grep -c '^\[' "$T/cp.out")" = 30 ] && echo "ok context-pack a small budget keeps the full table" || { echo "FAIL context-pack small budget table"; fail=1; } +cnot "a small budget prints no window" "== [" +[ -z "$(sed -n 32p "$T/cp.out")" ] && echo "ok context-pack a blank line follows the table" || { echo "FAIL context-pack blank line after the table"; fail=1; } +cin "a small budget lists every window as not expanded" "not expanded: --only 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30" +# --only truncates a window to the budget and says how to see the rest +r=$(cpo --spec-json "$S1" --only 1 --max-lines 50); cchk "--only truncation" 0 "$r" +[ "$(wc -l <"$T/cp.out" | tr -d ' ')" = 50 ] && echo "ok context-pack --only fills the budget exactly" || { echo "FAIL context-pack --only budget: $(wc -l <"$T/cp.out")"; fail=1; } +[ "$(grep -c '^ *[0-9]* g' "$T/cp.out")" = 45 ] && echo "ok context-pack --only truncated window has 45 lines" || { echo "FAIL context-pack --only window lines"; fail=1; } +cin "--only truncation note counts the rest" "111 more lines: --only 1 --max-lines 160" +r=$(cpo --spec-json "$S1" --only 1 --max-lines 7); cchk "--only at the smallest truncatable budget" 0 "$r" +[ "$(wc -l <"$T/cp.out" | tr -d ' ')" = 7 ] && [ "$(grep -c '^ *[0-9]* g' "$T/cp.out")" = 2 ] && echo "ok context-pack --only budget 7 prints 2 window lines" || { echo "FAIL context-pack --only budget 7"; fail=1; } +r=$(cpo --spec-json "$S1" --only 1 --max-lines 6); cin "--only below the smallest truncation is not expanded" "not expanded: --only 1" +cnot "--only below the smallest truncation prints no window" "== [" +echo '{"base": "'$BASE'", "deletes": [], "file_changes": [ + {"change": "long", "location": {"path": "big.txt", "line": 20, "end_line": 169}}, + {"change": "short", "location": {"path": "big.txt", "line": 200, "end_line": 210}}]}' >"$T/cp-spec2w.json" +r=$(cpo --spec-json "$(wp "$T/cp-spec2w.json")" --only 1,2 --max-lines 50); cchk "--only two windows" 0 "$r" +[ "$(wc -l <"$T/cp.out" | tr -d ' ')" = 50 ] && echo "ok context-pack --only keeps room for the not-expanded line" || { echo "FAIL context-pack --only reserve: $(wc -l <"$T/cp.out")"; fail=1; } +cin "--only: the window that no longer fits is listed" "not expanded: --only 2" +r=$(cpo --spec-json "$(wp "$T/cp-spec2w.json")" --only 1,2 --max-lines 161); cchk "--only first window needs exactly the remaining budget" 0 "$r" +cin "--only: a window that fills the budget minus the reserve is cut, not skipped" "== [1] big.txt:20-169" +[ "$(wc -l <"$T/cp.out" | tr -d ' ')" = 161 ] && echo "ok context-pack --only cut window keeps the reserve line" || { echo "FAIL context-pack --only exact need: $(wc -l <"$T/cp.out")"; fail=1; } +r=$(cpo --spec-json "$(wp "$T/cp-spec2w.json")" --only 1,2 --max-lines 8); cchk "--only two windows, too small to cut" 0 "$r" +cnot "--only: below four lines after the reserve nothing is cut" "== [" +cin "--only: both are listed as not expanded" "not expanded: --only 1,2" +echo '{"base": "'$B2'", "deletes": [], "file_changes": [ + {"change": "long", "location": {"path": "big.txt", "line": 20, "end_line": 169}}, + {"change": "tiny", "location": {"path": "one.txt", "line": 1, "end_line": 1}}]}' >"$T/cp-spec3.json" +r=$(cp2 --spec-json "$(wp "$T/cp-spec3.json")" --only 1,2 --max-lines 100); cchk "--only cut window then a tiny one" 0 "$r" +[ "$(wc -l <"$T/cp.out" | tr -d ' ')" = 100 ] && echo "ok context-pack a cut window is charged what it prints" || { echo "FAIL context-pack cut window charge: $(wc -l <"$T/cp.out")"; fail=1; } +cin "a cut window leaves no room for the next one" "not expanded: --only 2" +r=$(cpo --spec-json "$S1" --max-lines 104); cin "a 100-line cut window needs 102 lines of budget" "not expanded: --only 1" +r=$(cpo --spec-json "$S1" --max-lines 105); cin "a 100-line cut window fits in 105 lines" "56 more lines: --only 1 --max-lines 160" +[ "$(wc -l <"$T/cp.out" | tr -d ' ')" = 105 ] && echo "ok context-pack a 100-line cut window fills 105 lines" || { echo "FAIL context-pack cut window at 105: $(wc -l <"$T/cp.out")"; fail=1; } +grep -qxF "56 more lines: --only 1 --max-lines 160" "$T/cp.out" && echo "ok context-pack the more-lines note is its own line" || { echo "FAIL context-pack more-lines note line"; fail=1; } +r=$(cpo --spec-json "$(wp "$T/cp-spec30.json")" --max-lines 202); cchk "reserve line at the budget edge" 0 "$r" +[ "$(grep -c '^== \[' "$T/cp.out")" = 9 ] && [ "$(wc -l <"$T/cp.out" | tr -d ' ')" -le 202 ] && echo "ok context-pack a window that would crowd out the not-expanded line is skipped" || { echo "FAIL context-pack reserve at 202: $(wc -l <"$T/cp.out")"; fail=1; } +# the binding env block reaches the server; the caller environment overrides it +mcp env -u HUGINN_ENDPOINT -u EUREKA_INSTANCE FAKE_MODE=needenv FAKE_NEED=rudp://binding $PY "$CP" t:cut_spec:cut-x.r1 "$Rw" >"$T/cp.out" 2>"$T/cp.err"; r=$? +cchk "env from the binding reaches the server" 3 "$r"; cmp -s "$T/cp.out" "$T/cp.want" && echo "ok context-pack binding env supplies the server environment" || { echo "FAIL context-pack binding env not applied"; fail=1; } +mcp env -u EUREKA_INSTANCE HUGINN_ENDPOINT=rudp://caller FAKE_MODE=needenv FAKE_NEED=rudp://caller $PY "$CP" t:cut_spec:cut-x.r1 "$Rw" >"$T/cp.out" 2>"$T/cp.err"; r=$? +cchk "the caller environment overrides the binding" 3 "$r"; cmp -s "$T/cp.out" "$T/cp.want" && echo "ok context-pack caller env wins over binding env" || { echo "FAIL context-pack caller env lost"; fail=1; } +# standard library plus git only +if grep -qE '^import (requests|yaml|tree_sitter)|^from (requests|yaml|tree_sitter)|ctags|tree-sitter|"rg"' "$CP"; then echo "FAIL context-pack: a dependency beyond git"; fail=1; else echo "ok context-pack: standard library and git only"; fi exit $fail