From ce3860949cf7102505c52fea8b38a773ec0e7f41 Mon Sep 17 00:00:00 2001 From: Eureka Date: Sun, 1 Nov 2026 02:42:53 +0100 Subject: [PATCH] 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"