Merge pull request 'context-pack: resolve reads, check symbols, capped caller lists (cut-context-pack-reads r3)' (#38) from hands/context-pack-reads into main

This commit is contained in:
Eureka 2026-11-01 03:07:38 +00:00
commit f6325062a5
6 changed files with 337 additions and 55 deletions

View file

@ -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: 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; repo, branch and base; `deletes` first, with exact paths and line counts;
`keeps_moves`; `adds`; `file_changes` at `file:line` **for code that exists**; `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` 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 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 a new revision, admitted with the resolution that supersedes the old one, never

View file

@ -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 <spec id> <worktree moving. After admitting, run `py -3 tools/context-pack.py <spec id> <worktree
at base>`: every anchor must read SAME and each window must show what its at base>`: every anchor must read SAME and each window must show what its
change names, or admit a revision. 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 - cite in `rulings` every ruling in force the cut rests on
- the deploy owner: for any cut that changes what a service needs (transport, - 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, env, dependency, state), the recipe, binding and runbook that must admit it,
@ -226,8 +233,9 @@ Repo/branch: <repo> <branch> at HEAD <sha>. Check that git status is clean
first. Pinned siblings: <repo@sha>. Do not change them. first. Pinned siblings: <repo@sha>. Do not change them.
Before any grep or Read, expand the pack: `py -3 tools/context-pack.py <cut_spec Before any grep or Read, expand the pack: `py -3 tools/context-pack.py <cut_spec
id> <worktree>`. An anchor that reads NEW for a path the spec does not create, id> <worktree>`. 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 OUT_OF_RANGE, UNRESOLVED, a MISSING read, SYMBOL_MISSING, or GONE outside
it in deviations. 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, 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 Edit and Write as well as the shell's working directory. An absolute path into
the main checkout silently edits the wrong tree. the main checkout silently edits the wrong tree.

View file

@ -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 <file> [ <file> ]`, jest `FAIL <file>` 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/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 <file> [ <file> ]`, jest `FAIL <file>` 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/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/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 <cut_spec id> <worktree> [--since <sha>] [--only <k>[,<k>...]] [--spec-json <file>] [--max-lines <n>]`. 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 <sha>` 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 <cut_spec id> <worktree> [--since <sha>] [--only <k>|r<k>[,...]] [--spec-json <file>] [--max-lines <n>]`. Carries a cut_spec's `file_changes` and `reads` anchors (read rows `r<k>`; 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 <sha>` 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/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 `<absolute python.exe> 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 <missing script>` 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) | | `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 `<absolute python.exe> 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 <missing script>` 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) |

View file

@ -1,21 +1,26 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Carry a cut_spec's file_changes anchors from the spec's base to a worktree. """Carry a cut_spec's file_changes anchors from the spec's base to a worktree.
Usage: context-pack.py <cut_spec id> <worktree> [--since <sha>] [--only <k>[,<k>...]] Usage: context-pack.py <cut_spec id> <worktree> [--since <sha>] [--only <k>|r<k>[,...]]
[--spec-json <file>] [--max-lines <n>] [--spec-json <file>] [--max-lines <n>]
Prints a header, a status table (one line per anchor) and bounded windows of the Prints a header, a status table (one line per anchor: file_changes as <k>, reads
code each anchor names, numbered in the worktree's lines. Exit: 0 every anchor as r<k>) and bounded windows of the code each anchor names, numbered in the
carried; 3 an anchor cannot be carried (OUT_OF_RANGE, UNRESOLVED, GONE outside 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. the spec's deletes); 2 usage; 4 spec unreadable. Standard library plus git.
Errors name a field and a code, never an input value. Errors name a field and a code, never an input value.
--max-lines bounds the windows, not the table: the table always lists every --max-lines bounds the windows and caller lists, not the table: the table always
anchor, so a budget smaller than the table prints the table and no window. 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 argparse
import json import json
import os import os
import re import re
import shlex
import subprocess import subprocess
import sys import sys
@ -26,6 +31,12 @@ WINDOW_CAP = 100
CONTEXT = 3 CONTEXT = 3
DEFAULT_MAX_LINES = 800 DEFAULT_MAX_LINES = 800
HUNK = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@") 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}$") 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) raise Fail(4, "spec: %s: InvalidInput" % field)
if not isinstance(spec["base"], str): if not isinstance(spec["base"], str):
bad("base") 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"] fcs = spec["file_changes"]
if not isinstance(fcs, list): if not isinstance(fcs, list):
bad("file_changes") bad("file_changes")
@ -91,17 +113,21 @@ def check_spec_shape(spec):
at = "file_changes[%d]" % k at = "file_changes[%d]" % k
if not isinstance(fc, dict): if not isinstance(fc, dict):
bad(at) bad(at)
loc = fc.get("location") check_loc(at + ".location", 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): if "change" in fc and not isinstance(fc["change"], str):
bad(at + ".change") 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", []) dels = spec.get("deletes", [])
if not isinstance(dels, list): if not isinstance(dels, list):
bad("deletes") bad("deletes")
@ -236,12 +262,34 @@ def safe_path(path):
and ".." not in path.replace("\\", "/").split("/") 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"(?<![A-Za-z0-9_])" + re.escape(symbol) + r"(?![A-Za-z0-9_])", text) is not None
return symbol in text
def resolve(tree, label, fc, deleted_ok, base_ok, listed, read=False):
"""One anchor: carried to the worktree, then its symbol (if any) looked up in
the mapped window (the AT BASE window for GONE)."""
a = carry_anchor(tree, label, fc, deleted_ok, base_ok, listed, read)
sym = fc["location"].get("symbol")
a["symbol"] = sym or None
if sym and a["window"]:
kind, path, s, e = a["window"]
lines = tree.base_lines(path) if kind == "base" else tree.wt_lines(path)
if lines is not None and not has_symbol(sym, lines[s - 1:e]):
a["status"] += " SYMBOL_MISSING " + sym
a["exit"] = 3
return a
def carry_anchor(tree, label, fc, deleted_ok, base_ok, listed, read):
loc = fc["location"] loc = fc["location"]
path, s = loc["path"], int(loc["line"]) path, s = loc["path"], int(loc["line"])
e = int(loc.get("end_line") or s) e = int(loc.get("end_line") or s)
a = {"k": k, "path": path, "s": s, "e": e, "change": str(fc.get("change", "")), a = {"k": label, "path": path, "s": s, "e": e, "change": str(fc.get("change", "")),
"rank": 1, "exit": 0, "status": "", "now": None, "window": None} "rank": 1, "exit": 0, "status": "", "now": None, "window": None, "read": read}
if not base_ok: if not base_ok:
a.update(rank=0, exit=3, status="UNRESOLVED base not a commit here: git fetch origin " + tree.base) a.update(rank=0, exit=3, status="UNRESOLVED base not a commit here: git fetch origin " + tree.base)
return a return a
@ -249,6 +297,9 @@ def resolve(tree, k, fc, deleted_ok, base_ok, listed):
a.update(rank=0, exit=3, status="UNRESOLVED path or git diff unusable") a.update(rank=0, exit=3, status="UNRESOLVED path or git diff unusable")
return a return a
base = tree.base_lines(path) base = tree.base_lines(path)
if base is None and read:
a.update(rank=0, exit=3, status="MISSING (read path absent at base)")
return a
if base is None: if base is None:
sib = [p for p in tree.siblings() if p.rsplit("/", 1)[-1] == path.rsplit("/", 1)[-1]][:3] 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.update(rank=0, status="NEW" + (" (at base: " + ", ".join(sib) + ")" if sib else ""))
@ -285,7 +336,7 @@ def resolve(tree, k, fc, deleted_ok, base_ok, listed):
def table_line(a): def table_line(a):
line = "[%d] %s:%d-%d %s" % (a["k"], a["path"], a["s"], a["e"], a["status"]) line = "[%s] %s:%d-%d %s" % (a["k"], a["path"], a["s"], a["e"], a["status"])
if a["now"]: if a["now"]:
line += " -> %s:%d-%d" % a["now"] line += " -> %s:%d-%d" % a["now"]
return line + " # " + " ".join(a["change"].split())[:70] return line + " # " + " ".join(a["change"].split())[:70]
@ -294,7 +345,8 @@ def table_line(a):
def window_body(tree, a): def window_body(tree, a):
kind, path, s, e = a["window"] kind, path, s, e = a["window"]
lines = tree.base_lines(path) if kind == "base" else tree.wt_lines(path) 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: if lines is None:
return cap, ["(file not readable in the worktree)"] return cap, ["(file not readable in the worktree)"]
lo, hi = max(1, s - CONTEXT), min(len(lines), e + CONTEXT) lo, hi = max(1, s - CONTEXT), min(len(lines), e + CONTEXT)
@ -318,6 +370,46 @@ def overlaps_since(tree, since, a):
return False 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 -- <path>" % ("-w " if IDENT.match(symbol) else "", shlex.quote(symbol)))
return out
def parse_only(text, p):
"""Labels: <k> for file_changes, r<k> 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): def main(argv):
p = Parser(prog="context-pack.py") p = Parser(prog="context-pack.py")
p.add_argument("spec_id") p.add_argument("spec_id")
@ -329,19 +421,18 @@ def main(argv):
ns = p.parse_args(argv) ns = p.parse_args(argv)
if ns.max_lines < 1: if ns.max_lines < 1:
p.error("") p.error("")
only = None only = parse_only(ns.only, p) if ns.only is not None else None
if ns.only is not None:
try:
only = {int(x) for x in ns.only.split(",")}
except ValueError:
p.error("")
wt = ns.worktree wt = ns.worktree
if git(wt, "rev-parse", "--git-dir") is None: if git(wt, "rev-parse", "--git-dir") is None:
raise Fail(2, "worktree: not a git worktree") raise Fail(2, "worktree: not a git worktree")
spec = load_spec(ns.spec_id, ns.spec_json) spec = load_spec(ns.spec_id, ns.spec_json)
fcs = spec["file_changes"] fcs = spec["file_changes"]
if only is not None and not only <= set(range(1, len(fcs) + 1)): reads = spec.get("reads") or []
raise Fail(2, "--only: index outside file_changes") 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"]) base = str(spec["base"])
resolved = HEX.match(base) and git(wt, "rev-parse", "--verify", "--quiet", base + "^{commit}") resolved = HEX.match(base) and git(wt, "rev-parse", "--verify", "--quiet", base + "^{commit}")
tree = Tree(wt, base) tree = Tree(wt, base)
@ -352,7 +443,9 @@ def main(argv):
git(wt, "rev-parse", "--verify", "--quiet", ns.since + "^{commit}") is None): git(wt, "rev-parse", "--verify", "--quiet", ns.since + "^{commit}") is None):
raise Fail(2, "--since: not a commit in this worktree") raise Fail(2, "--since: not a commit in this worktree")
deleted_ok = {d["path"] for d in spec.get("deletes", [])} 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() head = (git(wt, "rev-parse", "--short", "HEAD") or b"none").decode().strip()
out = ["context-pack %s base %s HEAD %s modified %d" % ( out = ["context-pack %s base %s HEAD %s modified %d" % (
@ -361,29 +454,58 @@ def main(argv):
out.append("") out.append("")
want = [a for a in anchors if a["window"] and (only is None or a["k"] in only) 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))] and (ns.since is None or overlaps_since(tree, ns.since, a))]
table_len = len(fcs) table_len = len(anchors)
skipped = [] def place(room):
left = ns.max_lines - len(out) """The windows that fit in room lines: (lines, skipped labels, room left, lines all would take)."""
for idx, a in enumerate(want): lines, skipped, left, natural = [], [], room, 0
cap, body = window_body(tree, a) for idx, a in enumerate(want):
full = len(body) cap, body = window_body(tree, a)
if only is None and full > WINDOW_CAP: full = len(body)
body = body[:WINDOW_CAP] if only is None and full > WINDOW_CAP:
reserve = 1 if idx < len(want) - 1 or skipped else 0 # room for the not-expanded line body = body[:WINDOW_CAP]
need = 1 + len(body) + (1 if len(body) < full else 0) need = natural_need = 1 + len(body) + (1 if len(body) < full else 0)
if only is not None and need > left - reserve and left - reserve >= 4: reserve = 1 if idx < len(want) - 1 or skipped else 0 # room for the not-expanded line
body = body[:left - reserve - 2] if only is not None and need > left - reserve and left - reserve >= 4:
need = 1 + len(body) + 1 body = body[:left - reserve - 2]
if need > left - reserve: need = 1 + len(body) + 1
skipped.append(a["k"]) 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 = []
for sym, ls in lists:
if len(ls) > left:
cut.append((sym, len(ls)))
continue continue
out.append(cap) out += ls
out += body left -= len(ls)
if len(body) < full: if cut:
out.append("%d more lines: --only %d --max-lines %d" % (full - len(body), a["k"], table_len + full + 3)) out.append("callers not printed: %s: --max-lines %d" % (
left -= need ",".join(c[0] for c in cut), table_len + 2 + natural + list_total))
if skipped: if unlisted:
out.append("not expanded: --only " + ",".join(map(str, skipped))) out.append("callers not listed: " + ",".join(unlisted))
sys.stdout.reconfigure(encoding="utf-8", errors="replace", newline="\n") sys.stdout.reconfigure(encoding="utf-8", errors="replace", newline="\n")
sys.stdout.write("\n".join(out) + "\n") sys.stdout.write("\n".join(out) + "\n")
return max([a["exit"] for a in anchors] or [0]) return max([a["exit"] for a in anchors] or [0])

View file

@ -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": 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, "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"}}
]}}}

View file

@ -236,6 +236,15 @@ seqf() { local i; for i in $(seq 1 "$2"); do echo "$1$i"; done; }
g init -q 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 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" 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 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 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) 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 -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 '2,3d' -e 's/^b10$/b10-EDITED/' "$R/b.txt" >"$R/b.new"; mv "$R/b.new" "$R/b.txt"
@ -337,6 +346,124 @@ 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" 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" 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; } [ "$(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" "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"
cin "per-file count line" " 30 many1.txt"
cin "the narrowing command" "narrow: git grep -n -I -w -F -e needle_many -- <path>"
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"
# 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 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"
[ "$(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:"
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 -- <path>" "$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; }
cin "a non-identifier narrowing command has no -w" "narrow: git grep -n -I -F -e q.zq -- <path>"
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; }
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"
# 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 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 # 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 "$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" 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 +573,14 @@ cases = {
"deletes-str": (lambda v: v.update(deletes="CANARY-del"), "deletes"), "deletes-str": (lambda v: v.update(deletes="CANARY-del"), "deletes"),
"deletes-nopath": (lambda v: v.update(deletes=[{"note": "CANARY-note"}]), "deletes[1].path"), "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"), "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 = [] names = []
for name, (edit, field) in cases.items(): for name, (edit, field) in cases.items():