From c29671c4acd040db073b54b303b9781dbddefb48 Mon Sep 17 00:00:00 2001 From: Eureka Date: Sun, 1 Nov 2026 01:50:05 +0100 Subject: [PATCH 1/5] context-pack.py: reads, symbol checks and capped caller lists Read anchors resolve like file_changes (r rows, MISSING when absent at base), anchors with a symbol must hold it in the mapped window or gain SYMBOL_MISSING, and one capped git-grep caller list per symbol follows the windows within --max-lines. Specs without reads or symbols print what v1 did. Co-Authored-By: Claude Sonnet 5.5 --- tools/context-pack.py | 181 ++++++++++++++++---- tools/fixtures/context-pack/spec-reads.json | 17 ++ tools/test-tools.sh | 59 +++++++ 3 files changed, 224 insertions(+), 33 deletions(-) create mode 100644 tools/fixtures/context-pack/spec-reads.json diff --git a/tools/context-pack.py b/tools/context-pack.py index 13cbd96..7f9a80a 100644 --- a/tools/context-pack.py +++ b/tools/context-pack.py @@ -1,21 +1,26 @@ #!/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 [,...]] +Usage: context-pack.py [--since ] [--only |r[,...]] [--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 +Prints a header, a status table (one line per anchor: file_changes as , reads +as r) and bounded windows of the code each anchor names, numbered in the +worktree's lines: edit windows, then READ windows, then one caller list per +distinct symbol. A symbol an anchor names must be in its mapped window or the +status gains SYMBOL_MISSING. Exit: 0 every anchor carried; 3 an anchor cannot be +carried (OUT_OF_RANGE, UNRESOLVED, MISSING read, SYMBOL_MISSING, 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. +--max-lines bounds the windows and caller lists, not the table: the table always +lists every anchor, so a budget smaller than the table prints the table and no +window. Precedence: table, edit windows, read windows, caller lists. """ import argparse import json import os import re +import shlex import subprocess import sys @@ -26,6 +31,12 @@ WINDOW_CAP = 100 CONTEXT = 3 DEFAULT_MAX_LINES = 800 HUNK = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@") +MAX_CALLER_SYMBOLS = 8 +MAX_CALLER_HITS = 50 +MAX_CALLER_FILES = 20 +HIT_TEXT = 160 +ONLY = re.compile(r"^(r?)([0-9]+)$") +IDENT = re.compile(r"^[A-Za-z0-9_]+$") HEX = re.compile(r"^[0-9a-fA-F]{7,40}$") @@ -84,6 +95,17 @@ def check_spec_shape(spec): raise Fail(4, "spec: %s: InvalidInput" % field) if not isinstance(spec["base"], str): bad("base") + def check_loc(at, loc): + if not isinstance(loc, dict): + bad(at) + if not isinstance(loc.get("path"), str): + bad(at + ".path") + if not is_int(loc.get("line")): + bad(at + ".line") + if loc.get("end_line") is not None and not is_int(loc["end_line"]): + bad(at + ".end_line") + if loc.get("symbol") is not None and not isinstance(loc["symbol"], str): + bad(at + ".symbol") fcs = spec["file_changes"] if not isinstance(fcs, list): bad("file_changes") @@ -91,17 +113,21 @@ def check_spec_shape(spec): 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") + check_loc(at + ".location", fc.get("location")) if "change" in fc and not isinstance(fc["change"], str): bad(at + ".change") + reads = spec.get("reads") + if reads is None: + reads = [] + if not isinstance(reads, list): + bad("reads") + for k, rd in enumerate(reads, 1): + at = "reads[%d]" % k + if not isinstance(rd, dict): + bad(at) + check_loc(at + ".location", rd.get("location")) + if "why" in rd and not isinstance(rd["why"], str): + bad(at + ".why") dels = spec.get("deletes", []) if not isinstance(dels, list): bad("deletes") @@ -236,12 +262,34 @@ def safe_path(path): and ".." not in path.replace("\\", "/").split("/") -def resolve(tree, k, fc, deleted_ok, base_ok, listed): +def has_symbol(symbol, lines): + text = "\n".join(lines) + if IDENT.match(symbol): + return re.search(r"(? %s:%d-%d" % a["now"] return line + " # " + " ".join(a["change"].split())[:70] @@ -294,7 +345,8 @@ def table_line(a): 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"]) + where = "%s:%d-%d%s %s" % (path, s, e, " AT BASE" if kind == "base" else "", a["status"]) + cap = ("== READ %s: %s (%s)" % (a["k"], a["change"][:200], where)) if a["read"] else "== [%s] %s" % (a["k"], where) if lines is None: return cap, ["(file not readable in the worktree)"] lo, hi = max(1, s - CONTEXT), min(len(lines), e + CONTEXT) @@ -318,6 +370,46 @@ def overlaps_since(tree, since, a): return False +def caller_list(wt, symbol, windows): + """The lines of one CALLERS list: hits of the symbol in the worktree outside + every mapped window of the spec.""" + args = ["grep", "-n", "-I", "--no-color", "-z", "-F"] + (["-w"] if IDENT.match(symbol) else []) + ["-e", symbol] + raw = git(wt, *args) or b"" + hits = [] + for rec in raw.decode("utf-8", "replace").split("\n"): + parts = rec.split("\0", 2) + if len(parts) < 3 or not parts[1].isdigit(): + continue + path, n = parts[0], int(parts[1]) + if any(path == wp and ws <= n <= we for wp, ws, we in windows): + continue + hits.append((path, n, parts[2].rstrip("\r"))) + counts = {} + for path, _, _ in hits: + counts[path] = counts.get(path, 0) + 1 + out = ["CALLERS %s: %d hits in %d files" % (symbol, len(hits), len(counts))] + if len(hits) <= MAX_CALLER_HITS: + out += ["%s:%d: %s" % (path, n, text[:HIT_TEXT]) for path, n, text in hits] + else: + ranked = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0])) + out += ["%6d %s" % (c, path) for path, c in ranked[:MAX_CALLER_FILES]] + if len(ranked) > MAX_CALLER_FILES: + out.append("(%d more files)" % (len(ranked) - MAX_CALLER_FILES)) + out.append("narrow: git grep -n -I %s-F -e %s -- " % ("-w " if IDENT.match(symbol) else "", shlex.quote(symbol))) + return out + + +def parse_only(text, p): + """Labels: for file_changes, r for reads.""" + labels = set() + for x in text.split(","): + m = ONLY.match(x) + if not m: + p.error("") + labels.add(m.group(1) + str(int(m.group(2)))) + return labels + + def main(argv): p = Parser(prog="context-pack.py") p.add_argument("spec_id") @@ -329,19 +421,18 @@ def main(argv): 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("") + only = parse_only(ns.only, p) if ns.only is not None else None 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") + reads = spec.get("reads") or [] + if only is not None: + if not {x for x in only if x[0] != "r"} <= {str(i) for i in range(1, len(fcs) + 1)}: + raise Fail(2, "--only: index outside file_changes") + if not {x for x in only if x[0] == "r"} <= {"r%d" % i for i in range(1, len(reads) + 1)}: + raise Fail(2, "--only: index outside reads") base = str(spec["base"]) resolved = HEX.match(base) and git(wt, "rev-parse", "--verify", "--quiet", base + "^{commit}") tree = Tree(wt, base) @@ -352,7 +443,9 @@ def main(argv): 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)] + anchors = [resolve(tree, str(i + 1), fc, deleted_ok, bool(resolved), listed) for i, fc in enumerate(fcs)] + anchors += [resolve(tree, "r%d" % (i + 1), {"location": rd["location"], "change": rd.get("why", "")}, + deleted_ok, bool(resolved), listed, read=True) for i, rd in enumerate(reads)] head = (git(wt, "rev-parse", "--short", "HEAD") or b"none").decode().strip() out = ["context-pack %s base %s HEAD %s modified %d" % ( @@ -361,8 +454,8 @@ def main(argv): 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 = [] + table_len = len(anchors) + skipped, skipped_need = [], 0 left = ns.max_lines - len(out) for idx, a in enumerate(want): cap, body = window_body(tree, a) @@ -376,14 +469,36 @@ def main(argv): need = 1 + len(body) + 1 if need > left - reserve: skipped.append(a["k"]) + skipped_need += need 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)) + out.append("%d more lines: --only %s --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))) + out.append("not expanded: --only " + ",".join(skipped)) + left -= 1 + windows = [(a["window"][1], a["window"][2], a["window"][3]) for a in anchors + if a["window"] and a["window"][0] == "wt"] + symbols = list(dict.fromkeys(a["symbol"] for a in want if a["symbol"])) + lists = [(sym, caller_list(wt, sym, windows)) for sym in symbols[:MAX_CALLER_SYMBOLS]] + unlisted = symbols[MAX_CALLER_SYMBOLS:] + cut = [] + fits_all = sum(len(ls) for _, ls in lists) + (1 if unlisted else 0) <= left + for idx, (sym, ls) in enumerate(lists): + reserve = 0 if fits_all else 1 + if len(ls) > left - reserve: + cut.append((sym, len(ls))) + continue + out += ls + left -= len(ls) + if cut: + out.append("callers not printed: %s: --max-lines %d" % ( + ",".join(c[0] for c in cut), + len(out) - (1 if skipped else 0) + skipped_need + sum(c[1] for c in cut) + (1 if unlisted else 0))) + if unlisted: + out.append("callers not listed: " + ",".join(unlisted)) 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]) diff --git a/tools/fixtures/context-pack/spec-reads.json b/tools/fixtures/context-pack/spec-reads.json new file mode 100644 index 0000000..841e5d4 --- /dev/null +++ b/tools/fixtures/context-pack/spec-reads.json @@ -0,0 +1,17 @@ +{"id": {"id": "t:cut_spec:cut-x.r1", "kind": "CutSpec"}, + "status": "InForce", + "document": {"kind": "cut_spec", "value": { + "base": "BASE", "deletes": [], + "file_changes": [ + {"change": "symbol present in the moved window", "location": {"path": "a.txt", "line": 20, "end_line": 22, "symbol": "a21"}}, + {"change": "symbol the window lacks", "location": {"path": "b.txt", "line": 30, "end_line": 31, "symbol": "b99"}}, + {"change": "identifier with 3 callers outside the windows", "location": {"path": "sym.txt", "line": 4, "end_line": 6, "symbol": "needle_three"}}, + {"change": "non-identifier symbol is a literal substring", "location": {"path": "sym.txt", "line": 9, "end_line": 9, "symbol": "::ba"}} + ], + "reads": [ + {"why": "moved below the insertion", "location": {"path": "a.txt", "line": 20, "end_line": 22}}, + {"why": "deleted path", "location": {"path": "c.txt", "line": 2, "end_line": 3}}, + {"why": "path absent at base", "location": {"path": "sub/a.txt", "line": 1, "end_line": 2}}, + {"why": "identifier with 60 callers", "location": {"path": "sym.txt", "line": 7, "end_line": 8, "symbol": "needle_many"}}, + {"why": "an identifier matches whole words only", "location": {"path": "a.txt", "line": 20, "end_line": 22, "symbol": "a2"}} + ]}}} diff --git a/tools/test-tools.sh b/tools/test-tools.sh index 6360480..374a66f 100644 --- a/tools/test-tools.sh +++ b/tools/test-tools.sh @@ -236,6 +236,9 @@ 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" +printf 's1\ns2\ns3\ns4\ndef needle_three\ns6\ns7 needle_many\ns8\nfoo::bar\ns10\n' >"$R/sym.txt" +printf 'needle_three call 1\nneedle_three call 2\nneedle_three call 3\nneedle_threefold is another word\n' >"$R/callers.txt" +seqf "needle_many x." 30 >"$R/many1.txt"; seqf "needle_many y." 30 >"$R/many2.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" @@ -337,6 +340,54 @@ cin "window cut at 100 lines" "56 more lines: --only 1 --max-lines 160" 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; } +# reads, symbols and caller lists +sed "s/\"BASE\"/\"$BASE\"/" "$CF/spec-reads.json" >"$T/cp-spec-reads.json"; SR=$(wp "$T/cp-spec-reads.json") +r=$(cpo --spec-json "$SR"); cchk "reads and symbols" 3 "$r" +cin "a read that moved" "[r1] a.txt:20-22 MOVED(+3) -> a.txt:23-25" +cin "a read on a deleted path is GONE" "[r2] c.txt:2-3 GONE (not named by the spec's deletes)" +cin "a read absent at base is MISSING" "[r3] sub/a.txt:1-2 MISSING (read path absent at base)" +cin "a present symbol adds no suffix" "[1] a.txt:20-22 MOVED(+3) -> a.txt:23-25" +cin "a missing symbol is named" "[2] b.txt:30-31 MOVED(-2) SYMBOL_MISSING b99" +cin "an identifier matches whole words only" "MOVED(+3) SYMBOL_MISSING a2 -> a.txt:23-25" +cin "a non-identifier symbol is a literal substring" "[4] sym.txt:9-9 SAME" +cnot "the substring symbol is not missing" "SYMBOL_MISSING ::ba" +cin "the read caption carries its why" "== READ r1: moved below the insertion (a.txt:23-25 MOVED(+3))" +cin "a GONE read prints its window at base" "== READ r2: deleted path (c.txt:2-3 AT BASE" +[ "$(grep -c '^\[' "$T/cp.out")" = 9 ] && echo "ok context-pack the table lists edits and reads" || { echo "FAIL context-pack table rows with reads"; fail=1; } +[ "$(grep -n '^== READ r1' "$T/cp.out" | cut -d: -f1)" -gt "$(grep -n '^== \[4\]' "$T/cp.out" | cut -d: -f1)" ] && echo "ok context-pack read windows follow the edit windows" || { echo "FAIL context-pack read window order"; fail=1; } +[ "$(grep -n '^CALLERS' "$T/cp.out" | head -1 | cut -d: -f1)" -gt "$(grep -n '^== READ r5' "$T/cp.out" | cut -d: -f1)" ] && echo "ok context-pack caller lists follow the read windows" || { echo "FAIL context-pack caller list order"; fail=1; } +cin "3 callers are all listed" "CALLERS needle_three: 3 hits in 1 files" +cin "caller hit format" "callers.txt:2: needle_three call 2" +cnot "the hit inside the symbol's own window is dropped" "sym.txt:5: def needle_three" +cnot "a longer word is no caller" "needle_threefold" +cin "60 hits print per-file counts" "CALLERS needle_many: 60 hits in 2 files" +cin "per-file count line" " 30 many1.txt" +cin "the narrowing command" "narrow: git grep -n -I -w -F -e needle_many -- " +cnot "60 hits print no hit lines" "many1.txt:1:" +r=$(cpo --spec-json "$SR" --only r1); cchk "--only r1 keeps the exit of all anchors" 3 "$r" +cin "--only r1 prints the read" "== READ r1: moved below the insertion" +cnot "--only r1 prints no other read" "== READ r2"; cnot "--only r1 prints no edit window" "== [1]" +r=$(cpo --spec-json "$SR" --only r9); cchk "--only past the reads" 2 "$r"; grep -qF "context-pack: --only: index outside reads" "$T/cp.err" && echo "ok context-pack names the reads bound" || { echo "FAIL context-pack reads bound"; fail=1; } +r=$(cpo --spec-json "$SR" --only 1,r4); cin "--only mixes edit and read labels" "CALLERS needle_many: 60 hits"; cnot "--only lists callers of the selected anchors only" "CALLERS b99" +r=$(cpo --spec-json "$SR" --since "$(git -C "$R" rev-parse HEAD)"); cnot "--since HEAD prints no read window" "== READ"; cnot "--since HEAD prints no caller list" "CALLERS" +r=$(cpo --spec-json "$SR" --max-lines 3); cchk "--max-lines below the table with reads" 3 "$r" +[ "$(grep -c '^\[' "$T/cp.out")" = 9 ] && echo "ok context-pack a small budget keeps every edit and read row" || { echo "FAIL context-pack small budget with reads"; fail=1; } +cnot "a small budget prints no window" "== "; cnot "a small budget prints no caller list" "CALLERS" +r=$(cpo --spec-json "$SR" --max-lines 60); cchk "caller lists cut by the budget" 3 "$r" +[ "$(wc -l <"$T/cp.out" | tr -d ' ')" -le 60 ] && echo "ok context-pack caller lists stay within --max-lines" || { echo "FAIL context-pack caller lists over budget"; fail=1; } +cin "the cut caller lists are named" "callers not printed: " +SUGG=$(grep -o '^callers not printed: .*--max-lines [0-9]*' "$T/cp.out" | grep -o '[0-9]*$') +r=$(cpo --spec-json "$SR" --max-lines "$SUGG"); cnot "the suggested --max-lines prints every caller list" "callers not printed" +[ "$(wc -l <"$T/cp.out" | tr -d ' ')" = "$SUGG" ] && echo "ok context-pack suggested caller --max-lines is exact" || { echo "FAIL context-pack suggested caller max: $(wc -l <"$T/cp.out") vs $SUGG"; fail=1; } +{ echo '{"base": "'$BASE'", "deletes": [], "file_changes": [' + for i in 1 2 3 4 5 6 7 8 9; do echo '{"change": "s'$i'", "location": {"path": "big.txt", "line": '$i', "end_line": '$i', "symbol": "g'$i'"}}'$([ $i -lt 9 ] && echo ,); done + echo ']}'; } >"$T/cp-spec9sym.json" +r=$(cpo --spec-json "$(wp "$T/cp-spec9sym.json")" --max-lines 400); cchk "nine symbols" 0 "$r" +[ "$(grep -c '^CALLERS' "$T/cp.out")" = 8 ] && echo "ok context-pack at most 8 caller lists" || { echo "FAIL context-pack caller list count"; fail=1; } +cin "the ninth symbol is named" "callers not listed: g9" +echo '{"base": "'$BASE'", "deletes": [], "file_changes": [], "reads": [{"why": "w", "location": {"path": "a.txt", "line": 20, "end_line": 22, "symbol": "a21"}}]}' >"$T/cp-spec-r0.json" +r=$(cpo --spec-json "$(wp "$T/cp-spec-r0.json")"); cchk "a spec of one read, symbol present" 0 "$r" +cin "a read with a symbol present: no suffix" "[r1] a.txt:20-22 MOVED(+3) -> a.txt:23-25 # w" # 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" @@ -446,6 +497,14 @@ cases = { "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"), + "reads-str": (lambda v: v.update(reads="CANARY-reads"), "reads"), + "read-entry-str": (lambda v: v.update(reads=["CANARY-read"]), "reads[1]"), + "read-nolocation": (lambda v: v.update(reads=[{"why": "w"}]), "reads[1].location"), + "read-path": (lambda v: v.update(reads=[{"why": "w", "location": {"path": ["CANARY-p"], "line": 1}}]), "reads[1].location.path"), + "read-line": (lambda v: v.update(reads=[{"why": "w", "location": {"path": "a.txt", "line": "CANARY-l"}}]), "reads[1].location.line"), + "read-why": (lambda v: v.update(reads=[{"why": {"CANARY": 1}, "location": {"path": "a.txt", "line": 1}}]), "reads[1].why"), + "read-symbol": (lambda v: v.update(reads=[{"why": "w", "location": {"path": "a.txt", "line": 1, "symbol": 7}}]), "reads[1].location.symbol"), + "edit-symbol": (lambda v: v["file_changes"][0]["location"].update(symbol=["CANARY-s"]), "file_changes[1].location.symbol"), } names = [] for name, (edit, field) in cases.items(): -- 2.47.3 From 21b92bd85e1ebbcd4eafec28be17e769aa01b96d Mon Sep 17 00:00:00 2001 From: Eureka Date: Sun, 1 Nov 2026 01:50:06 +0100 Subject: [PATCH 2/5] Imagination writes reads and symbols; Hands uses the pack's caller lists Co-Authored-By: Claude Sonnet 5.5 --- SKILL.md | 2 +- references/briefs.md | 12 ++++++++++-- tools/INDEX.md | 2 +- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/SKILL.md b/SKILL.md index 43c7637..71a5ce1 100644 --- a/SKILL.md +++ b/SKILL.md @@ -123,7 +123,7 @@ Brief an Imagination agent to admit the `cut_spec` for the next cut against the current Body; `depends_on` names the cut labels it waits on. The fields are the spec standard: repo, branch and base; `deletes` first, with exact paths and line counts; `keeps_moves`; `adds`; `file_changes` at `file:line` **for code that exists**; -an `authority_map` for anything that changes ownership; `verification` (builds, +`reads` for code Hands must see but not change; an `authority_map` for anything that changes ownership; `verification` (builds, tests with the rule each pins, negative greps, operator checks); an `estimate` for the ledger; and the `rulings` and `questions` it rests on. A changed spec is a new revision, admitted with the resolution that supersedes the old one, never diff --git a/references/briefs.md b/references/briefs.md index cd8407f..a5c1a18 100644 --- a/references/briefs.md +++ b/references/briefs.md @@ -186,6 +186,13 @@ The cut_spec's fields are the spec standard. Beyond filling them: moving. After admitting, run `py -3 tools/context-pack.py `: every anchor must read SAME and each window must show what its change names, or admit a revision. +- code Hands must see but not change goes in `reads` (path and line range at + base, at most 100 lines, a `why` of at most 200 bytes, at most 16 per spec), + never as prose path:line in first, adds, keeps_moves or a change, unless it + is in another repo. Give a `symbol` on a read or file_change whose lines + define or call a named thing, written as it appears there. After admitting, + the tool at base must show every anchor SAME with no SYMBOL_MISSING, or admit + a revision. - cite in `rulings` every ruling in force the cut rests on - the deploy owner: for any cut that changes what a service needs (transport, env, dependency, state), the recipe, binding and runbook that must admit it, @@ -226,8 +233,9 @@ Repo/branch: at HEAD . Check that git status is clean first. Pinned siblings: . Do not change them. Before any grep or Read, expand the pack: `py -3 tools/context-pack.py `. An anchor that reads NEW for a path the spec does not create, -OUT_OF_RANGE, UNRESOLVED, or GONE outside deletes is a spec discrepancy: record -it in deviations. +OUT_OF_RANGE, UNRESOLVED, a MISSING read, SYMBOL_MISSING, or GONE outside +deletes is a spec discrepancy: record it in deviations. The pack lists the +callers of every symbol; grep beyond them only for what the pack names as cut. When the brief names a worktree, every tool call's path goes under it: Read, Edit and Write as well as the shell's working directory. An absolute path into the main checkout silently edits the wrong tree. diff --git a/tools/INDEX.md b/tools/INDEX.md index 1d07343..781a025 100644 --- a/tools/INDEX.md +++ b/tools/INDEX.md @@ -13,6 +13,6 @@ 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 (`tools/mind.py`), 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 | `references/briefs.md` (Imagination map, Hands, Soul), `SKILL.md` (step 1) | +| `tools/context-pack.py` | `py -3 tools/context-pack.py [--since ] [--only |r[,...]] [--spec-json ] [--max-lines ]`. Carries a cut_spec's `file_changes` and `reads` anchors (read rows `r`; a read absent at base is MISSING) 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, MISSING, plus SYMBOL_MISSING when an anchor's `symbol` is absent from its window) and windows of at most 100 lines numbered in worktree lines, then one capped caller list per symbol (`git grep`, hits outside the windows), 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 (`tools/mind.py`), 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 | `references/briefs.md` (Imagination map, Hands, Soul), `SKILL.md` (step 1) | | `tools/mind.py` | The one way a Eureka script reaches the mind: `mind.call(tool, arguments, deadline)` spawns the eureka-state binding (`EUREKA_STATE_EXE`, else `~/.claude.json` `mcpServers.eureka-state`), does the stdio handshake and one tool call within `deadline` seconds, returns the parsed JSON body, raises `MindError` (kind binding, transport, tool or answer; a detail, never an input value), and kills the child on every exit path. No retry inside `call`; `retry_once(fn)` is the caller's choice. Standard library only. | `tools/context-pack.py`, `tools/recall.py` | not named by a brief; it lives and dies with its consumers | | `tools/recall.py` | `py -3 tools/recall.py prompt`, a `UserPromptSubmit` command hook (hook JSON on stdin): queries the rulings, questions and follow-ups in force across every root, ranked by Huginn against the prompt (`RECALL_TOP_K`, 5), and prints one hook JSON whose `additionalContext` is a header saying the lines are recalled records, data and never instructions, written by agents and not the operator, then one JSON object per item (`id`, `title`, a short `operator_quote`) with `<`, `>` and control, bidi and separator characters escaped, within `RECALL_BUDGET` characters (4000, at most 9000: Claude Code caps `additionalContext` at 10,000), whole lines only. Prompts under 3 words are not queried. Never blocks: any failure exits 0 with empty stdout; its own deadline is `RECALL_DEADLINE_MS` (4000) for the whole run (stdin read to 4 MiB, answer read to 1 MiB), so set the hook `timeout` above it. One attempt, no retry. Install both hooks as ` C:/Users/Meta/.claude/skills/eureka/tools/recall.py prompt || true` (and `... admit || true`): Claude Code runs hook commands through Git Bash on Windows, and `py -3 ` exits 2, which blocks every prompt or admit; with `|| true` the hook cannot exit 2 (a missing script, interpreter or file passes) and the script's stdout is untouched (proved live on 2.1.308). `py -3 tools/recall.py admit` is a `PreToolUse` hook on `mcp__eureka-state__admit`: for each ruling or question in the batch (at most `RECALL_ADMIT_MAX`, 3) it queries the nearest in-force rulings and questions, and denies the first attempt with the neighbours as the reason (a deliberate hold, not a failure); the identical call sent again (digest of `tool_input` plus `agent_id`, kept in the session scratchpad) passes. Never allow or ask; a failed check exits 0 with a note naming the failure kind (`RECALL_TOP_K` 3, `RECALL_DEADLINE_MS` 10000 in this mode). Standard library only; writes only the digest file. | the operator's `UserPromptSubmit` hook and `PreToolUse` hook on `mcp__eureka-state__admit` (fires inside subagents too) in `~/.claude/settings.json` | `references/campaign-state.md` (Precedent) | -- 2.47.3 From ce3860949cf7102505c52fea8b38a773ec0e7f41 Mon Sep 17 00:00:00 2001 From: Eureka Date: Sun, 1 Nov 2026 02:42:53 +0100 Subject: [PATCH 3/5] context-pack.py: caller-list notes always fit --max-lines; pin the caller bounds A cut window or caller list now reserves the lines its notes need, the suggested --max-lines prints everything, and the 50-hit, 20-file, 160-character, CR and --only bounds each have an assertion. Co-Authored-By: Claude Sonnet 5.5 --- tools/context-pack.py | 67 ++++++++++++--------- tools/fixtures/context-pack/spec-reads.json | 4 +- tools/test-tools.sh | 54 ++++++++++++++++- 3 files changed, 91 insertions(+), 34 deletions(-) diff --git a/tools/context-pack.py b/tools/context-pack.py index 7f9a80a..5db651a 100644 --- a/tools/context-pack.py +++ b/tools/context-pack.py @@ -455,48 +455,55 @@ def main(argv): 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(anchors) - skipped, skipped_need = [], 0 - 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"]) - skipped_need += need - continue - out.append(cap) - out += body - if len(body) < full: - out.append("%d more lines: --only %s --max-lines %d" % (full - len(body), a["k"], table_len + full + 3)) - left -= need - if skipped: - out.append("not expanded: --only " + ",".join(skipped)) - left -= 1 + def place(room): + """The windows that fit in room lines: (lines, skipped labels, room left, lines all would take).""" + lines, skipped, left, natural = [], [], room, 0 + 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] + need = natural_need = 1 + len(body) + (1 if len(body) < full else 0) + reserve = 1 if idx < len(want) - 1 or skipped else 0 # room for the not-expanded line + if only is not None and need > left - reserve and left - reserve >= 4: + body = body[:left - reserve - 2] + need = 1 + len(body) + 1 + natural += natural_need + if need > left - reserve: + skipped.append(a["k"]) + continue + lines += [cap] + body + if len(body) < full: + lines.append("%d more lines: --only %s --max-lines %d" % (full - len(body), a["k"], table_len + full + 3)) + left -= need + if skipped: + lines.append("not expanded: --only " + ",".join(skipped)) + left -= 1 + return lines, skipped, left, natural + windows = [(a["window"][1], a["window"][2], a["window"][3]) for a in anchors if a["window"] and a["window"][0] == "wt"] symbols = list(dict.fromkeys(a["symbol"] for a in want if a["symbol"])) lists = [(sym, caller_list(wt, sym, windows)) for sym in symbols[:MAX_CALLER_SYMBOLS]] unlisted = symbols[MAX_CALLER_SYMBOLS:] + list_total = sum(len(ls) for _, ls in lists) + (1 if unlisted else 0) + room = ns.max_lines - len(out) + lines, skipped, left, natural = place(room - list_total) + if skipped and lists: # the lists lose: keep only the notes that name what is cut + lines, skipped, left, natural = place(room - (1 if lists else 0) - (1 if unlisted else 0)) + elif lists: + left += list_total - (1 if unlisted else 0) + out += lines cut = [] - fits_all = sum(len(ls) for _, ls in lists) + (1 if unlisted else 0) <= left - for idx, (sym, ls) in enumerate(lists): - reserve = 0 if fits_all else 1 - if len(ls) > left - reserve: + for sym, ls in lists: + if len(ls) > left: cut.append((sym, len(ls))) continue out += ls left -= len(ls) if cut: out.append("callers not printed: %s: --max-lines %d" % ( - ",".join(c[0] for c in cut), - len(out) - (1 if skipped else 0) + skipped_need + sum(c[1] for c in cut) + (1 if unlisted else 0))) + ",".join(c[0] for c in cut), table_len + 2 + natural + list_total)) if unlisted: out.append("callers not listed: " + ",".join(unlisted)) sys.stdout.reconfigure(encoding="utf-8", errors="replace", newline="\n") diff --git a/tools/fixtures/context-pack/spec-reads.json b/tools/fixtures/context-pack/spec-reads.json index 841e5d4..c86645b 100644 --- a/tools/fixtures/context-pack/spec-reads.json +++ b/tools/fixtures/context-pack/spec-reads.json @@ -5,12 +5,12 @@ "file_changes": [ {"change": "symbol present in the moved window", "location": {"path": "a.txt", "line": 20, "end_line": 22, "symbol": "a21"}}, {"change": "symbol the window lacks", "location": {"path": "b.txt", "line": 30, "end_line": 31, "symbol": "b99"}}, - {"change": "identifier with 3 callers outside the windows", "location": {"path": "sym.txt", "line": 4, "end_line": 6, "symbol": "needle_three"}}, + {"change": "identifier with 3 callers outside the windows", "location": {"path": "sym.txt", "line": 4, "end_line": 5, "symbol": "needle_three"}}, {"change": "non-identifier symbol is a literal substring", "location": {"path": "sym.txt", "line": 9, "end_line": 9, "symbol": "::ba"}} ], "reads": [ {"why": "moved below the insertion", "location": {"path": "a.txt", "line": 20, "end_line": 22}}, - {"why": "deleted path", "location": {"path": "c.txt", "line": 2, "end_line": 3}}, + {"why": "deleted path", "location": {"path": "c.txt", "line": 2, "end_line": 3, "symbol": "c9"}}, {"why": "path absent at base", "location": {"path": "sub/a.txt", "line": 1, "end_line": 2}}, {"why": "identifier with 60 callers", "location": {"path": "sym.txt", "line": 7, "end_line": 8, "symbol": "needle_many"}}, {"why": "an identifier matches whole words only", "location": {"path": "a.txt", "line": 20, "end_line": 22, "symbol": "a2"}} diff --git a/tools/test-tools.sh b/tools/test-tools.sh index 374a66f..7a5e4cb 100644 --- a/tools/test-tools.sh +++ b/tools/test-tools.sh @@ -237,7 +237,12 @@ 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" printf 's1\ns2\ns3\ns4\ndef needle_three\ns6\ns7 needle_many\ns8\nfoo::bar\ns10\n' >"$R/sym.txt" -printf 'needle_three call 1\nneedle_three call 2\nneedle_three call 3\nneedle_threefold is another word\n' >"$R/callers.txt" +printf 'needle_three call 1\nneedle_three call 2\nneedle_three call 3\nneedle_threefold is another word\n' >"$R/zcallers.txt" +echo 'bound_fifty bound_fiftyone spread_sym long_symbol q.zq sym_bad' >"$R/anchors.txt" +seqf "bound_fifty x" 50 >"$R/bound50.txt"; seqf "bound_fiftyone x" 51 >"$R/bound51.txt"; seqf "q.zq x" 51 >"$R/dotted.txt" +printf 'long_symbol %s%s\r' "$(printf '%0150d' 0 | tr 0 z)" "$(printf '%0100d' 0 | tr 0 y)" >"$R/longline.txt"; printf '\n' >>"$R/longline.txt" +printf 'sym_bad \377 end\n' >"$R/bad.txt" +mkdir -p "$R/manyf"; for i in $(seq -w 1 21); do seqf "spread_sym $i-" 3 >"$R/manyf/f$i.txt"; done; echo "spread_sym extra" >>"$R/manyf/f21.txt" seqf "needle_many x." 30 >"$R/many1.txt"; seqf "needle_many y." 30 >"$R/many2.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" @@ -357,7 +362,7 @@ cin "a GONE read prints its window at base" "== READ r2: deleted path (c.txt:2- [ "$(grep -n '^== READ r1' "$T/cp.out" | cut -d: -f1)" -gt "$(grep -n '^== \[4\]' "$T/cp.out" | cut -d: -f1)" ] && echo "ok context-pack read windows follow the edit windows" || { echo "FAIL context-pack read window order"; fail=1; } [ "$(grep -n '^CALLERS' "$T/cp.out" | head -1 | cut -d: -f1)" -gt "$(grep -n '^== READ r5' "$T/cp.out" | cut -d: -f1)" ] && echo "ok context-pack caller lists follow the read windows" || { echo "FAIL context-pack caller list order"; fail=1; } cin "3 callers are all listed" "CALLERS needle_three: 3 hits in 1 files" -cin "caller hit format" "callers.txt:2: needle_three call 2" +cin "caller hit format" "zcallers.txt:2: needle_three call 2" cnot "the hit inside the symbol's own window is dropped" "sym.txt:5: def needle_three" cnot "a longer word is no caller" "needle_threefold" cin "60 hits print per-file counts" "CALLERS needle_many: 60 hits in 2 files" @@ -388,6 +393,51 @@ cin "the ninth symbol is named" "callers not listed: g9" echo '{"base": "'$BASE'", "deletes": [], "file_changes": [], "reads": [{"why": "w", "location": {"path": "a.txt", "line": 20, "end_line": 22, "symbol": "a21"}}]}' >"$T/cp-spec-r0.json" r=$(cpo --spec-json "$(wp "$T/cp-spec-r0.json")"); cchk "a spec of one read, symbol present" 0 "$r" cin "a read with a symbol present: no suffix" "[r1] a.txt:20-22 MOVED(+3) -> a.txt:23-25 # w" +# caller-list bounds: 50 hits list, 51 count; 20 files; a hit's text is cut at 160; CR and bad bytes +{ echo '{"base": "'$BASE'", "deletes": [], "file_changes": [' + n=0; for sy in bound_fifty bound_fiftyone spread_sym long_symbol q.zq sym_bad; do n=$((n+1)) + echo '{"change": "'$sy'", "location": {"path": "anchors.txt", "line": 1, "end_line": 1, "symbol": "'$sy'"}}'$([ $n -lt 6 ] && echo ,); done + echo ']}'; } >"$T/cp-specb.json" +r=$(cpo --spec-json "$(wp "$T/cp-specb.json")" --max-lines 400); cchk "caller list bounds" 0 "$r" +cin "exactly 50 hits are listed" "CALLERS bound_fifty: 50 hits in 1 files" +[ "$(grep -c '^bound50.txt:' "$T/cp.out")" = 50 ] && echo "ok context-pack 50 hits print as lines" || { echo "FAIL context-pack 50 hit lines"; fail=1; } +cin "51 hits are counted" "CALLERS bound_fiftyone: 51 hits in 1 files" +cin "51 hits: the per-file count" " 51 bound51.txt" +cnot "51 hits print no hit lines" "bound51.txt:1:" +cin "21 files: the 21st is named" "(1 more files)" +[ "$(grep -c '^ *[34] manyf/' "$T/cp.out")" = 20 ] && echo "ok context-pack at most 20 files are counted" || { echo "FAIL context-pack file cap: $(grep -c '^ *[34] manyf/' "$T/cp.out")"; fail=1; } +cin "files are ranked by hits, largest first" " 4 manyf/f21.txt" +[ "$(grep -n 'manyf/f21.txt' "$T/cp.out" | cut -d: -f1)" -lt "$(grep -n 'manyf/f01.txt' "$T/cp.out" | cut -d: -f1)" ] && echo "ok context-pack the largest file prints first" || { echo "FAIL context-pack file order"; fail=1; } +cin "a non-identifier narrowing command has no -w" "narrow: git grep -n -I -F -e q.zq -- " +LL=$(grep '^longline.txt:1: ' "$T/cp.out"); [ "${#LL}" = 176 ] && echo "ok context-pack a hit's text is cut at 160 characters" || { echo "FAIL context-pack hit text length ${#LL}"; fail=1; } +cnot "a CR in a hit is stripped" "$(printf '\r')" +cin "a hit with invalid UTF-8 still prints" "bad.txt:1: sym_bad" +r=$(cpo --spec-json "$SR" --only 5); cchk "--only past the last edit anchor" 2 "$r"; grep -qxF "context-pack: --only: index outside file_changes" "$T/cp.err" && echo "ok context-pack --only 5 is outside four anchors" || { echo "FAIL context-pack --only 5"; fail=1; } +r=$(cpo --spec-json "$SR" --only 4); cchk "--only the last edit anchor" 3 "$r"; cin "--only 4 prints that window" "== [4] sym.txt:9-9" +r=$(cpo --spec-json "$SR" --only r6); cchk "--only past the last read" 2 "$r"; grep -qxF "context-pack: --only: index outside reads" "$T/cp.err" && echo "ok context-pack --only r6 is outside five reads" || { echo "FAIL context-pack --only r6"; fail=1; } +r=$(cpo --spec-json "$SR" --only r5); cchk "--only the last read" 3 "$r"; cin "--only r5 prints that window" "== READ r5:" +sed -n 3p "$T/cp.out" | grep -q '^\[r3\]' && echo "ok context-pack a MISSING read sorts with the problem anchors" || { echo "FAIL context-pack MISSING read order"; fail=1; } +cin "a GONE read checks its symbol at base" "GONE (not named by the spec's deletes) SYMBOL_MISSING c9" +{ echo '{"base": "'$BASE'", "deletes": [], "file_changes": [{"location": {"path": "a.txt", "line": 1, "end_line": 2, "symbol": "zz9"}}],' + echo '"reads": [{"location": {"path": "a.txt", "line": 1, "end_line": 2}}, {"why": "'$(printf '%0250d' 0 | tr 0 w)'", "location": {"path": "a.txt", "line": 3, "end_line": 3}}]}'; } >"$T/cp-specw.json" +r=$(cpo --spec-json "$(wp "$T/cp-specw.json")"); cchk "a missing symbol alone fails the run" 3 "$r" +cin "a symbol absent from an unmoved window" "[1] a.txt:1-2 SAME SYMBOL_MISSING zz9" +cnot "an anchor without a change or why gets no filler" "XXXX" +W200=$(printf '%0200d' 0 | tr 0 w); cin "the read caption is cut at 200" "== READ r2: $W200 (a.txt:3-3" +# the caller lists obey --max-lines at every budget, and the suggested value prints them all +{ echo '{"base": "'$BASE'", "deletes": [], "file_changes": [' + for i in 1 2 3 4 5 6 7 8 9 10; do echo '{"change": "s'$i'", "location": {"path": "big.txt", "line": '$i', "end_line": '$i', "symbol": "g'$i'"}}'$([ $i -lt 10 ] && echo ,); done + echo ']}'; } >"$T/cp-spec10sym.json"; S10=$(wp "$T/cp-spec10sym.json") +r=$(cpo --spec-json "$S10" --max-lines 400); cin "two symbols past the eighth are named together" "callers not listed: g9,g10" +FULLN=$(wc -l <"$T/cp.out" | tr -d ' ') +for b in $((FULLN-1)) $((FULLN-3)) $((FULLN-6)) $((FULLN-9)) $((FULLN-30)); do + r=$(cpo --spec-json "$S10" --max-lines "$b") + [ "$(wc -l <"$T/cp.out" | tr -d ' ')" -le "$b" ] && echo "ok context-pack budget $b holds" || { echo "FAIL context-pack budget $b exceeded: $(wc -l <"$T/cp.out")"; fail=1; } + SG=$(grep -o '^callers not printed: .*--max-lines [0-9]*' "$T/cp.out" | grep -o '[0-9]*$') + if [ -n "$SG" ]; then r=$(cpo --spec-json "$S10" --max-lines "$SG"); [ "$(wc -l <"$T/cp.out" | tr -d ' ')" = "$FULLN" ] && ! grep -q 'callers not printed\|not expanded' "$T/cp.out" && echo "ok context-pack budget $b: the suggestion prints everything" || { echo "FAIL context-pack budget $b: suggestion $SG gave $(wc -l <"$T/cp.out") of $FULLN"; fail=1; }; fi +done +r=$(cpo --spec-json "$S10" --max-lines $((FULLN-1))); [ "$(grep -c '^CALLERS' "$T/cp.out")" = 6 ] && echo "ok context-pack one line short drops two lists for the note" || { echo "FAIL context-pack one line short: $(grep -c '^CALLERS' "$T/cp.out") lists"; fail=1; } +cin "the dropped lists are named" "callers not printed: g7,g8: --max-lines $FULLN" # 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" -- 2.47.3 From 5929db8225a01e4d3ec449ed2962dc4d5b89663a Mon Sep 17 00:00:00 2001 From: Eureka Date: Sun, 1 Nov 2026 03:09:28 +0100 Subject: [PATCH 4/5] test-tools: pin the context-pack window budgets, caller-list bounds and --only truncation Co-Authored-By: Claude Sonnet 5.5 --- tools/test-tools.sh | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/tools/test-tools.sh b/tools/test-tools.sh index 7a5e4cb..fa0228b 100644 --- a/tools/test-tools.sh +++ b/tools/test-tools.sh @@ -238,11 +238,12 @@ seqf a 40 >"$R/a.txt"; seqf b 40 >"$R/b.txt"; seqf c 10 >"$R/c.txt"; seqf d 20 > 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" printf 's1\ns2\ns3\ns4\ndef needle_three\ns6\ns7 needle_many\ns8\nfoo::bar\ns10\n' >"$R/sym.txt" printf 'needle_three call 1\nneedle_three call 2\nneedle_three call 3\nneedle_threefold is another word\n' >"$R/zcallers.txt" -echo 'bound_fifty bound_fiftyone spread_sym long_symbol q.zq sym_bad' >"$R/anchors.txt" +echo 'bound_fifty bound_fiftyone spread_sym long_symbol q.zq sym_bad twenty_sym' >"$R/anchors.txt" seqf "bound_fifty x" 50 >"$R/bound50.txt"; seqf "bound_fiftyone x" 51 >"$R/bound51.txt"; seqf "q.zq x" 51 >"$R/dotted.txt" printf 'long_symbol %s%s\r' "$(printf '%0150d' 0 | tr 0 z)" "$(printf '%0100d' 0 | tr 0 y)" >"$R/longline.txt"; printf '\n' >>"$R/longline.txt" -printf 'sym_bad \377 end\n' >"$R/bad.txt" +printf 'sym_bad \377 endX\r\n' >"$R/bad.txt" mkdir -p "$R/manyf"; for i in $(seq -w 1 21); do seqf "spread_sym $i-" 3 >"$R/manyf/f$i.txt"; done; echo "spread_sym extra" >>"$R/manyf/f21.txt" +mkdir -p "$R/twf"; for i in $(seq -w 1 20); do seqf "twenty_sym $i-" 3 >"$R/twf/f$i.txt"; done seqf "needle_many x." 30 >"$R/many1.txt"; seqf "needle_many y." 30 >"$R/many2.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" @@ -395,8 +396,8 @@ r=$(cpo --spec-json "$(wp "$T/cp-spec-r0.json")"); cchk "a spec of one read, sym cin "a read with a symbol present: no suffix" "[r1] a.txt:20-22 MOVED(+3) -> a.txt:23-25 # w" # caller-list bounds: 50 hits list, 51 count; 20 files; a hit's text is cut at 160; CR and bad bytes { echo '{"base": "'$BASE'", "deletes": [], "file_changes": [' - n=0; for sy in bound_fifty bound_fiftyone spread_sym long_symbol q.zq sym_bad; do n=$((n+1)) - echo '{"change": "'$sy'", "location": {"path": "anchors.txt", "line": 1, "end_line": 1, "symbol": "'$sy'"}}'$([ $n -lt 6 ] && echo ,); done + n=0; for sy in bound_fifty bound_fiftyone spread_sym long_symbol q.zq sym_bad twenty_sym; do n=$((n+1)) + echo '{"change": "'$sy'", "location": {"path": "anchors.txt", "line": 1, "end_line": 1, "symbol": "'$sy'"}}'$([ $n -lt 7 ] && echo ,); done echo ']}'; } >"$T/cp-specb.json" r=$(cpo --spec-json "$(wp "$T/cp-specb.json")" --max-lines 400); cchk "caller list bounds" 0 "$r" cin "exactly 50 hits are listed" "CALLERS bound_fifty: 50 hits in 1 files" @@ -404,7 +405,10 @@ cin "exactly 50 hits are listed" "CALLERS bound_fifty: 50 hits in 1 files" cin "51 hits are counted" "CALLERS bound_fiftyone: 51 hits in 1 files" cin "51 hits: the per-file count" " 51 bound51.txt" cnot "51 hits print no hit lines" "bound51.txt:1:" -cin "21 files: the 21st is named" "(1 more files)" +grep -qx "(1 more files)" "$T/cp.out" && echo "ok context-pack 21 files: the 21st is named" || { echo "FAIL context-pack 21st file line"; fail=1; } +grep -qx "narrow: git grep -n -I -F -e q.zq -- " "$T/cp.out" && echo "ok context-pack the narrowing line is exact" || { echo "FAIL context-pack narrowing line"; fail=1; } +cin "exactly 20 files name no more" "CALLERS twenty_sym: 60 hits in 20 files" +[ "$(grep -c '(0 more files)' "$T/cp.out")" = 0 ] && echo "ok context-pack 20 files print no more-files line" || { echo "FAIL context-pack 20 files"; fail=1; } [ "$(grep -c '^ *[34] manyf/' "$T/cp.out")" = 20 ] && echo "ok context-pack at most 20 files are counted" || { echo "FAIL context-pack file cap: $(grep -c '^ *[34] manyf/' "$T/cp.out")"; fail=1; } cin "files are ranked by hits, largest first" " 4 manyf/f21.txt" [ "$(grep -n 'manyf/f21.txt' "$T/cp.out" | cut -d: -f1)" -lt "$(grep -n 'manyf/f01.txt' "$T/cp.out" | cut -d: -f1)" ] && echo "ok context-pack the largest file prints first" || { echo "FAIL context-pack file order"; fail=1; } @@ -412,6 +416,8 @@ cin "a non-identifier narrowing command has no -w" "narrow: git grep -n -I -F -e LL=$(grep '^longline.txt:1: ' "$T/cp.out"); [ "${#LL}" = 176 ] && echo "ok context-pack a hit's text is cut at 160 characters" || { echo "FAIL context-pack hit text length ${#LL}"; fail=1; } cnot "a CR in a hit is stripped" "$(printf '\r')" cin "a hit with invalid UTF-8 still prints" "bad.txt:1: sym_bad" +cin "a hit keeps a trailing X and loses its CR" "endX" +grep -qx "bad.txt:1: sym_bad .* endX" "$T/cp.out" && echo "ok context-pack a hit ends at its last character" || { echo "FAIL context-pack hit end"; fail=1; } r=$(cpo --spec-json "$SR" --only 5); cchk "--only past the last edit anchor" 2 "$r"; grep -qxF "context-pack: --only: index outside file_changes" "$T/cp.err" && echo "ok context-pack --only 5 is outside four anchors" || { echo "FAIL context-pack --only 5"; fail=1; } r=$(cpo --spec-json "$SR" --only 4); cchk "--only the last edit anchor" 3 "$r"; cin "--only 4 prints that window" "== [4] sym.txt:9-9" r=$(cpo --spec-json "$SR" --only r6); cchk "--only past the last read" 2 "$r"; grep -qxF "context-pack: --only: index outside reads" "$T/cp.err" && echo "ok context-pack --only r6 is outside five reads" || { echo "FAIL context-pack --only r6"; fail=1; } @@ -438,6 +444,24 @@ for b in $((FULLN-1)) $((FULLN-3)) $((FULLN-6)) $((FULLN-9)) $((FULLN-30)); do done r=$(cpo --spec-json "$S10" --max-lines $((FULLN-1))); [ "$(grep -c '^CALLERS' "$T/cp.out")" = 6 ] && echo "ok context-pack one line short drops two lists for the note" || { echo "FAIL context-pack one line short: $(grep -c '^CALLERS' "$T/cp.out") lists"; fail=1; } cin "the dropped lists are named" "callers not printed: g7,g8: --max-lines $FULLN" +# window budgets: the exact thresholds, and what the caller lists leave of them +r=$(cpo --spec-json "$S1" --max-lines 104); cnot "104 lines expand no 100-line window" "== ["; cin "104 lines skip a 100-line window" "not expanded: --only 1" +r=$(cpo --spec-json "$S1" --max-lines 105); [ "$(wc -l <"$T/cp.out" | tr -d ' ')" = 105 ] && grep -qx "56 more lines: --only 1 --max-lines 160" "$T/cp.out" && echo "ok context-pack 105 lines hold a capped window and its note" || { echo "FAIL context-pack 105-line budget"; fail=1; } +S30=$(wp "$T/cp-spec30.json") +r=$(cpo --spec-json "$S30" --max-lines 186); [ "$(grep -c '^== ' "$T/cp.out")" = 9 ] && [ "$(wc -l <"$T/cp.out" | tr -d ' ')" = 186 ] && echo "ok context-pack 186 lines hold 9 windows and the note" || { echo "FAIL context-pack 186-line budget"; fail=1; } +r=$(cpo --spec-json "$S30" --max-lines 185); [ "$(grep -c '^== ' "$T/cp.out")" = 8 ] && echo "ok context-pack 185 lines hold 8 windows" || { echo "FAIL context-pack 185-line budget"; fail=1; } +r=$(cpo --spec-json "$S30" --max-lines 542); [ "$(grep -c '^== ' "$T/cp.out")" = 30 ] && echo "ok context-pack 542 lines hold all 30 windows" || { echo "FAIL context-pack 542-line budget"; fail=1; }; cnot "542 lines need no not-expanded note" "not expanded" +for b in 530 531 532 533 534 535 536 537 538 539 540 541; do + r=$(cpo --spec-json "$S30" --max-lines $b); [ "$(wc -l <"$T/cp.out" | tr -d ' ')" -le $b ] && grep -q '^not expanded: --only 30$' "$T/cp.out" && echo "ok context-pack budget $b skips only the last window" || { echo "FAIL context-pack budget $b: $(wc -l <"$T/cp.out") lines"; fail=1; } +done +for b in 37 38 39 40 41 42 43 44 45 46 47; do + r=$(cpo --spec-json "$S30" --only 1,2 --max-lines $b); [ "$(wc -l <"$T/cp.out" | tr -d ' ')" = $b ] && [ "$(grep -c '^== ' "$T/cp.out")" = 1 ] && grep -q 'more lines: --only 1 ' "$T/cp.out" && echo "ok context-pack --only 1,2 at $b lines truncates window 1 to fit" || { echo "FAIL context-pack --only 1,2 at $b: $(wc -l <"$T/cp.out") lines"; fail=1; } +done +r=$(cpo --spec-json "$S30" --only 1,2 --max-lines 36); cnot "--only 1,2 at 36 lines expands nothing" "== [" +r=$(cpo --spec-json "$S10" --max-lines 94); [ "$(wc -l <"$T/cp.out" | tr -d ' ')" = 94 ] && [ "$(grep -c '^== ' "$T/cp.out")" = 10 ] && [ "$(grep -c '^CALLERS' "$T/cp.out")" = 6 ] && echo "ok context-pack one line short keeps every window and 6 lists" || { echo "FAIL context-pack 94-line S10"; fail=1; } +r=$(cpo --spec-json "$S10" --max-lines 86); [ "$(wc -l <"$T/cp.out" | tr -d ' ')" = 86 ] && [ "$(grep -c '^== ' "$T/cp.out")" = 9 ] && [ "$(grep -c '^CALLERS' "$T/cp.out")" = 5 ] && echo "ok context-pack 86 lines: 9 windows, 5 lists, every note" || { echo "FAIL context-pack 86-line S10"; fail=1; } +r=$(cpo --spec-json "$S10" --max-lines 65); [ "$(grep -c '^== ' "$T/cp.out")" = 7 ] && [ "$(grep -c '^CALLERS' "$T/cp.out")" = 0 ] && grep -q '^callers not printed: ' "$T/cp.out" && grep -q '^callers not listed: g9,g10' "$T/cp.out" && echo "ok context-pack windows outrank caller lists" || { echo "FAIL context-pack 65-line S10"; fail=1; } +r=$(cpo --spec-json "$SR" --max-lines 62); [ "$(wc -l <"$T/cp.out" | tr -d ' ')" = 61 ] && [ "$(grep -c '^CALLERS' "$T/cp.out")" = 4 ] && echo "ok context-pack 62 lines: a list that needs 2 lines waits" || { echo "FAIL context-pack 62-line SR"; 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" -- 2.47.3 From 3c1d59cf2d7cb22b4f40d1e0c48ff0881fa68a58 Mon Sep 17 00:00:00 2001 From: Eureka Date: Sun, 1 Nov 2026 03:41:20 +0100 Subject: [PATCH 5/5] test-tools: pin that a budget with caller lists and no unlisted symbols wastes no line Co-Authored-By: Claude Sonnet 5.5 --- tools/test-tools.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/test-tools.sh b/tools/test-tools.sh index fa0228b..4590f2f 100644 --- a/tools/test-tools.sh +++ b/tools/test-tools.sh @@ -461,6 +461,8 @@ r=$(cpo --spec-json "$S30" --only 1,2 --max-lines 36); cnot "--only 1,2 at 36 li r=$(cpo --spec-json "$S10" --max-lines 94); [ "$(wc -l <"$T/cp.out" | tr -d ' ')" = 94 ] && [ "$(grep -c '^== ' "$T/cp.out")" = 10 ] && [ "$(grep -c '^CALLERS' "$T/cp.out")" = 6 ] && echo "ok context-pack one line short keeps every window and 6 lists" || { echo "FAIL context-pack 94-line S10"; fail=1; } r=$(cpo --spec-json "$S10" --max-lines 86); [ "$(wc -l <"$T/cp.out" | tr -d ' ')" = 86 ] && [ "$(grep -c '^== ' "$T/cp.out")" = 9 ] && [ "$(grep -c '^CALLERS' "$T/cp.out")" = 5 ] && echo "ok context-pack 86 lines: 9 windows, 5 lists, every note" || { echo "FAIL context-pack 86-line S10"; fail=1; } r=$(cpo --spec-json "$S10" --max-lines 65); [ "$(grep -c '^== ' "$T/cp.out")" = 7 ] && [ "$(grep -c '^CALLERS' "$T/cp.out")" = 0 ] && grep -q '^callers not printed: ' "$T/cp.out" && grep -q '^callers not listed: g9,g10' "$T/cp.out" && echo "ok context-pack windows outrank caller lists" || { echo "FAIL context-pack 65-line S10"; fail=1; } +r=$(cpo --spec-json "$SR" --max-lines 40); [ "$(wc -l <"$T/cp.out" | tr -d ' ')" = 40 ] && [ "$(grep -c '^== ' "$T/cp.out")" = 3 ] && [ "$(grep -c '^CALLERS' "$T/cp.out")" = 2 ] && echo "ok context-pack 40 lines: 3 windows, 2 lists, no line unused" || { echo "FAIL context-pack 40-line SR"; fail=1; } +r=$(cpo --spec-json "$SR" --max-lines 44); [ "$(wc -l <"$T/cp.out" | tr -d ' ')" = 44 ] && [ "$(grep -c '^CALLERS' "$T/cp.out")" = 3 ] && echo "ok context-pack 44 lines: 3 lists" || { echo "FAIL context-pack 44-line SR"; fail=1; } r=$(cpo --spec-json "$SR" --max-lines 62); [ "$(wc -l <"$T/cp.out" | tr -d ' ')" = 61 ] && [ "$(grep -c '^CALLERS' "$T/cp.out")" = 4 ] && echo "ok context-pack 62 lines: a list that needs 2 lines waits" || { echo "FAIL context-pack 62-line SR"; 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" -- 2.47.3