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 <noreply@anthropic.com>
This commit is contained in:
Eureka 2026-11-01 02:42:53 +01:00
commit ce3860949c
3 changed files with 91 additions and 34 deletions

View file

@ -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")

View file

@ -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"}}

View file

@ -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 -- <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"
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"