diff --git a/tools/fixtures/context-pack/fake-eureka-state.py b/tools/fixtures/context-pack/fake-eureka-state.py index e59cd86..5018845 100644 --- a/tools/fixtures/context-pack/fake-eureka-state.py +++ b/tools/fixtures/context-pack/fake-eureka-state.py @@ -5,19 +5,30 @@ Unavailable (naming HUGINN_ENDPOINT, as the real daemon's text might). FAKE_COUN a file that gains one line per view call.""" import json, os, sys +inited = False for ln in sys.stdin: msg = json.loads(ln) if "id" not in msg: + inited = inited or msg.get("method") == "notifications/initialized" continue if msg["method"] == "initialize": + p = msg.get("params", {}) + if msg["id"] != 1 or not p.get("protocolVersion") or not p.get("clientInfo", {}).get("name") or "capabilities" not in p: + sys.exit(1) res = {"protocolVersion": "2024-11-05", "capabilities": {}, "serverInfo": {"name": "fake", "version": "0"}} else: + p = msg.get("params", {}) + if (not inited or msg["method"] != "tools/call" or p.get("name") != "view" or msg["id"] != 2 + or p.get("arguments") != {"id": {"kind": "CutSpec", "id": os.environ["FAKE_WANT"]}}): + sys.exit(1) with open(os.environ["FAKE_COUNT"], "a") as f: f.write("view\n") if os.environ.get("FAKE_MODE") == "null": res = {"content": [{"type": "text", "text": "null"}]} + elif os.environ.get("FAKE_MODE") == "garbage": + res = {"content": [{"type": "text", "text": "not json"}]} elif os.environ.get("FAKE_MODE") == "invalid": - res = {"isError": True, "content": [{"type": "text", "text": "InvalidInput: " + os.environ.get("HUGINN_ENDPOINT", "")}]} + res = {"isError": True, "content": [{"type": "text", "text": os.environ.get("FAKE_CODE", "InvalidInput") + ": " + os.environ.get("HUGINN_ENDPOINT", "")}]} elif os.environ.get("FAKE_MODE") == "unavailable": res = {"isError": True, "content": [{"type": "text", "text": "Unavailable: " + os.environ.get("HUGINN_ENDPOINT", "")}]} diff --git a/tools/fixtures/context-pack/spec.json b/tools/fixtures/context-pack/spec.json index 80c4757..0545102 100644 --- a/tools/fixtures/context-pack/spec.json +++ b/tools/fixtures/context-pack/spec.json @@ -16,5 +16,15 @@ {"change": "uncommitted edit", "location": {"path": "f.txt", "line": 5, "end_line": 6}}, {"change": "last line is in range", "location": {"path": "e.txt", "line": 10, "end_line": 10}}, {"change": "one past the last line", "location": {"path": "e.txt", "line": 10, "end_line": 11}}, - {"change": "CRLF file", "location": {"path": "h.txt", "line": 2, "end_line": 3}} + {"change": "CRLF file", "location": {"path": "h.txt", "line": 2, "end_line": 3}}, + {"change": "straddles the head of a replaced block", "location": {"path": "m.txt", "line": 12, "end_line": 13}}, + {"change": "replaced block starts inside", "location": {"path": "m.txt", "line": 8, "end_line": 11}}, + {"change": "just after a replaced block", "location": {"path": "m.txt", "line": 15, "end_line": 16}}, + {"change": "deletion follows the range", "location": {"path": "m.txt", "line": 28, "end_line": 28}}, + {"change": "deletion at the next line", "location": {"path": "m.txt", "line": 29, "end_line": 29}}, + {"change": "deletion ends just before", "location": {"path": "m.txt", "line": 32, "end_line": 32}}, + {"change": "after the deletion", "location": {"path": "m.txt", "line": 33, "end_line": 33}}, + {"change": "insertion at the first line", "location": {"path": "a.txt", "line": 5, "end_line": 7}}, + {"change": "deletion ends just before the range", "location": {"path": "b.txt", "line": 4, "end_line": 5}}, + {"change": "untracked file the cut creates", "location": {"path": "n.txt", "line": 1, "end_line": 2}} ]}}} diff --git a/tools/test-tools.sh b/tools/test-tools.sh index 8ed791c..0cc02ac 100644 --- a/tools/test-tools.sh +++ b/tools/test-tools.sh @@ -235,12 +235,13 @@ g() { git -C "$R" -c user.name=t -c user.email=t@t -c core.autocrlf=false "$@" > 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 e 10 >"$R/e.txt"; seqf f 20 >"$R/f.txt"; seqf g 300 >"$R/big.txt"; seqf h 5 | sed 's/$/ /' >"$R/h.txt"; seqf m 40 >"$R/m.txt" g add -A; g commit -q -m base; BASE=$(git -C "$R" rev-parse HEAD) { sed -n 1,5p "$R/a.txt"; printf 'ins1\nins2\nins3\n'; sed -n '6,$p' "$R/a.txt"; } >"$R/a.new"; mv "$R/a.new" "$R/a.txt" sed -e '2,3d' -e 's/^b10$/b10-EDITED/' "$R/b.txt" >"$R/b.new"; mv "$R/b.new" "$R/b.txt" +sed -e '10,14c\X1\nX2' -e '30,31d' "$R/m.txt" >"$R/m.new"; mv "$R/m.new" "$R/m.txt" g rm -q c.txt; g mv d.txt d2.txt; g add -A; g commit -q -m second -sed -i 's/^f5$/f5-UNCOMMITTED/' "$R/f.txt" +sed -i 's/^f5$/f5-UNCOMMITTED/' "$R/f.txt"; printf 'n1\nn2\nn3\n' >"$R/n.txt" sed "s/\"BASE\"/\"$BASE\"/" "$CF/spec.json" >"$T/cp-spec.json"; SP=$(wp "$T/cp-spec.json") cpo() { $PY "$CP" t:cut_spec:cut-x.r1 "$Rw" "$@" >"$T/cp.out" 2>"$T/cp.err"; echo $?; } cchk() { if [ "$3" = "$2" ]; then echo "ok context-pack $1: exit $3"; else echo "FAIL context-pack $1: exit $3, want $2"; fail=1; fi; } @@ -248,7 +249,7 @@ cin() { grep -qF -- "$2" "$T/cp.out" && echo "ok context-pack $1" || { echo "F cnot() { if grep -qF -- "$2" "$T/cp.out" "$T/cp.err"; then echo "FAIL context-pack $1: must not contain: $2"; fail=1; else echo "ok context-pack $1"; fi; } r=$(cpo --spec-json "$SP"); cchk "fixture (GONE and OUT_OF_RANGE)" 3 "$r" cin "header" "context-pack t:cut_spec:cut-x.r1 base ${BASE:0:12} HEAD" -cin "header counts modified files" " modified 5" +cin "header counts modified files" " modified 6" cin "MOVED(+3)" "[1] a.txt:20-22 MOVED(+3) -> a.txt:23-25" cin "insertion at the last line is outside" "[2] a.txt:3-5 SAME -> a.txt:3-5" cin "insertion before the range shifts it" "[3] a.txt:6-7 MOVED(+3) -> a.txt:9-10" @@ -263,6 +264,17 @@ cin "uncommitted edit is TOUCHED" "[11] f.txt:5-6 TOUCHED(+1/-1 inside)" cin "last line of the file is in range" "[12] e.txt:10-10 SAME" cin "one past the last line is out of range" "[13] e.txt:10-11 OUT_OF_RANGE (base file has 10 lines)" cin "CRLF file reads SAME" "[14] h.txt:2-3 SAME" +cin "hunk straddling the head clamps to the new block" "[15] m.txt:12-13 TOUCHED(+2/-5 inside) -> m.txt:10-11" +cin "hunk starting inside and ending past the range" "[16] m.txt:8-11 TOUCHED(+2/-5 inside) -> m.txt:8-11" +cin "range just after a replaced block shifts" "[17] m.txt:15-16 MOVED(-3) -> m.txt:12-13" +cin "deletion after the range moves nothing else" "[18] m.txt:28-28 MOVED(-3) -> m.txt:25-25" +cin "deletion at the next line" "[19] m.txt:29-29 MOVED(-3) -> m.txt:26-26" +cin "deletion ending just before the range" "[20] m.txt:32-32 MOVED(-5) -> m.txt:27-27" +cin "range after both hunks" "[21] m.txt:33-33 MOVED(-5) -> m.txt:28-28" +cin "insertion at the first line is inside" "[22] a.txt:5-7 TOUCHED(+3/-0 inside) -> a.txt:5-10" +cin "hunk ending just before the range shifts it" "[23] b.txt:4-5 MOVED(-2) -> b.txt:2-3" +cin "NEW file present in the worktree gets a window" "== [24] n.txt:1-2" +cin "NEW window shows the file" " 2 n2" cin "CRLF window text" " 2 h2" { tr -d ' ' <"$T/cp.out" | cmp -s - "$T/cp.out"; } && echo "ok context-pack CRLF is stripped from windows" || { echo "FAIL context-pack CR in output"; fail=1; } cin "window numbers are worktree lines" " 6 b8" @@ -270,8 +282,8 @@ cin "window shows the edit" "b10-EDITED" cin "GONE window is labelled AT BASE" "== [7] c.txt:2-3 AT BASE" cin "GONE window shows base text" " 2 c2" cin "renamed window reads the new path" "== [8] d2.txt:4-5" -[ "$(grep -c '^\[' "$T/cp.out")" = 14 ] && echo "ok context-pack table lists every anchor" || { echo "FAIL context-pack table count"; fail=1; } -{ sed -n 2p "$T/cp.out" | grep -q '^\[7\]' && sed -n 6p "$T/cp.out" | grep -q '^\[1\]'; } && echo "ok context-pack problem anchors first" || { echo "FAIL context-pack problem anchors first"; fail=1; } +[ "$(grep -c '^\[' "$T/cp.out")" = 24 ] && echo "ok context-pack table lists every anchor" || { echo "FAIL context-pack table count"; fail=1; } +{ sed -n 2p "$T/cp.out" | grep -q '^\[7\]' && sed -n 6p "$T/cp.out" | grep -q '^\[24\]' && sed -n 7p "$T/cp.out" | grep -q '^\[1\]'; } && echo "ok context-pack problem anchors first" || { echo "FAIL context-pack problem anchors first"; fail=1; } # deletes naming the file, and a valid range, make the same spec exit 0 sed -e 's/"deletes": \[\]/"deletes": [{"path": "c.txt"}]/' -e 's/"end_line": 50/"end_line": 6/' -e 's/"end_line": 11/"end_line": 10/' "$T/cp-spec.json" >"$T/cp-spec0.json" r=$(cpo --spec-json "$(wp "$T/cp-spec0.json")"); cchk "GONE named by deletes, no OUT_OF_RANGE" 0 "$r" @@ -280,7 +292,7 @@ cin "GONE in deletes has no qualifier" "[7] c.txt:2-3 GONE #" sed "s/\"$BASE\"/\"deadbeef\"/" "$T/cp-spec.json" >"$T/cp-specb.json" r=$(cpo --spec-json "$(wp "$T/cp-specb.json")"); cchk "unreachable base" 3 "$r" cin "UNRESOLVED hint" "UNRESOLVED base not a commit here: git fetch origin deadbeef" -[ "$(grep -c 'UNRESOLVED' "$T/cp.out")" = 14 ] && echo "ok context-pack every anchor UNRESOLVED" || { echo "FAIL context-pack UNRESOLVED count"; fail=1; } +[ "$(grep -c 'UNRESOLVED' "$T/cp.out")" = 24 ] && echo "ok context-pack every anchor UNRESOLVED" || { echo "FAIL context-pack UNRESOLVED count"; fail=1; } cnot "UNRESOLVED prints no window" "== [" # --since: every anchor stays in the table; only the edited one has a window r=$(cpo --spec-json "$SP" --since HEAD); cchk "--since" 3 "$r" @@ -291,10 +303,23 @@ cnot "--since omits untouched windows" "== [1] " cnot "--since omits the GONE window" "== [7] " r=$(cpo --spec-json "$SP" --since "$BASE"); cin "--since an older commit widens" "== [4] a.txt:4-11" cnot "--since an older commit still omits untouched windows" "== [2] " +r=$(cpo --spec-json "$SP" --since "$BASE" --only 15,16,17,18,19,20,21) +for k in 15 16 19 20; do cin "--since window $k overlaps a hunk" "== [$k] "; done +for k in 17 18 21; do cnot "--since window $k does not overlap" "== [$k] "; done r=$(cpo --spec-json "$SP" --only 2,11); cin "--only prints the named window" "== [2] " cnot "--only omits the others" "== [1] " -r=$(cpo --spec-json "$SP" --only 99); cchk "--only outside file_changes" 2 "$r" -r=$(cpo --spec-json "$SP" --since feedface); cchk "--since not a commit" 2 "$r" +r=$(cpo --spec-json "$SP" --only 99); cchk "--only outside file_changes" 2 "$r"; grep -qF "context-pack: --only: index outside file_changes" "$T/cp.err" && echo "ok context-pack names --only" || { echo "FAIL context-pack names --only"; fail=1; } +r=$(cpo --spec-json "$SP" --since feedface); cchk "--since not a commit" 2 "$r"; grep -qF "context-pack: --since: not a commit in this worktree" "$T/cp.err" && echo "ok context-pack names --since" || { echo "FAIL context-pack names --since"; fail=1; } +Z70=$(printf 'z%.0s' $(seq 1 70)) +echo '{"base": "'$BASE'", "deletes": [], "file_changes": [ + {"change": "up", "location": {"path": "../a.txt", "line": 1, "end_line": 1}}, + {"change": "abs", "location": {"path": "/a.txt", "line": 1, "end_line": 1}}, + {"change": "drive", "location": {"path": "c:a.txt", "line": 1, "end_line": 1}}, + {"change": "'$Z70'zzzz", "location": {"path": "a.txt", "line": 1, "end_line": 1}}]}' >"$T/cp-specu.json" +r=$(cpo --spec-json "$(wp "$T/cp-specu.json")"); cchk "unsafe paths" 3 "$r" +[ "$(grep -c 'UNRESOLVED path or git diff unusable' "$T/cp.out")" = 3 ] && echo "ok context-pack unsafe paths are UNRESOLVED" || { echo "FAIL context-pack unsafe paths"; fail=1; } +cin "a long change is cut at 70 characters" "# $Z70" +cnot "a long change is cut at 70 characters (not 71)" "${Z70}z" # caps { echo '{"base": "'$BASE'", "deletes": [], "file_changes": [' for i in $(seq 1 30); do echo '{"change": "c'$i'", "location": {"path": "big.txt", "line": '$((i*5))', "end_line": '$((i*5+9))'}}'$([ $i -lt 30 ] && echo ,); done @@ -314,10 +339,10 @@ cnot "full window needs no more-lines note" "more lines" [ "$(wc -l <"$T/cp.out" | tr -d ' ')" = 160 ] && echo "ok context-pack suggested --max-lines is exact" || { echo "FAIL context-pack suggested max: $(wc -l <"$T/cp.out")"; fail=1; } # usage and unreadable spec; no error echoes an input value r=$(cpo --spec-json "$SP" --max-lines CANARY-usage); cchk "bad --max-lines" 2 "$r"; cnot "usage error echoes no value" "CANARY-usage" -r=$(cpo --spec-json "$T/CANARY-nofile.json"); cchk "unreadable --spec-json" 4 "$r"; 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" grep -qF -- "--spec-json" "$T/cp.err" && echo "ok context-pack names --spec-json" || { echo "FAIL context-pack error does not name --spec-json"; fail=1; } -r=$($PY "$CP" CANARY-id "$Rw" >"$T/cp.out" 2>"$T/cp.err"; echo $?); cchk "spec id that is no cut_spec id" 2 "$r"; cnot "bad id echoes no value" "CANARY-id" -r=$($PY "$CP" t:cut_spec:cut-x.r1 "$T/CANARY-notrepo" --spec-json "$SP" >"$T/cp.out" 2>"$T/cp.err"; echo $?); cchk "worktree is not a repo" 2 "$r"; cnot "worktree error echoes no path" "CANARY-notrepo" +r=$($PY "$CP" CANARY-id "$Rw" >"$T/cp.out" 2>"$T/cp.err"; echo $?); cchk "spec id that is no cut_spec id" 2 "$r"; grep -qF "context-pack: spec id: not a cut_spec id" "$T/cp.err" && echo "ok context-pack names the spec id field" || { echo "FAIL context-pack names the spec id field"; fail=1; }; cnot "bad id echoes no value" "CANARY-id" +r=$($PY "$CP" t:cut_spec:cut-x.r1 "$T/CANARY-notrepo" --spec-json "$SP" >"$T/cp.out" 2>"$T/cp.err"; echo $?); cchk "worktree is not a repo" 2 "$r"; grep -qF "context-pack: worktree: not a git worktree" "$T/cp.err" && echo "ok context-pack names the worktree field" || { echo "FAIL context-pack names the worktree field"; fail=1; }; cnot "worktree error echoes no path" "CANARY-notrepo" # the spec by id through the stdio MCP binding (a fake eureka-state), retried once on Unavailable H=$T/cphome; mkdir -p "$H"; FK=$(wp "$CF/fake-eureka-state.py") $PY - "$(wp "$H")/.claude.json" "$($PY -c 'import sys;print(sys.executable)')" "$FK" <<'PYE' @@ -326,7 +351,7 @@ json.dump({"mcpServers": {"eureka-state": {"type": "stdio", "command": sys.argv[ "env": {"EUREKA_INSTANCE": "t", "HUGINN_ENDPOINT": "rudp://binding"}}}}, open(sys.argv[1], "w")) PYE Hw=$(wp "$H") -mcp() { HOME="$Hw" USERPROFILE="$Hw" FAKE_SPEC="$SP" FAKE_COUNT="$(wp "$T/cp.count")" "$@"; } +mcp() { HOME="$Hw" USERPROFILE="$Hw" FAKE_WANT=t:cut_spec:cut-x.r1 FAKE_SPEC="$SP" FAKE_COUNT="$(wp "$T/cp.count")" "$@"; } $PY "$CP" t:cut_spec:cut-x.r1 "$Rw" --spec-json "$SP" >"$T/cp.want" 2>/dev/null : >"$T/cp.count" mcp $PY "$CP" t:cut_spec:cut-x.r1 "$Rw" >"$T/cp.out" 2>"$T/cp.err"; r=$? @@ -338,10 +363,17 @@ cchk "Unavailable twice" 4 "$r"; cnot "Unavailable echoes no endpoint" "CANARY-e [ "$(wc -l <"$T/cp.count" | tr -d ' ')" = 2 ] && echo "ok context-pack retried Unavailable exactly once" || { echo "FAIL context-pack view attempts: $(wc -l <"$T/cp.count")"; fail=1; } grep -qF "context-pack: view: Unavailable; --spec-json reads a saved view" "$T/cp.err" && echo "ok context-pack Unavailable names --spec-json" || { echo "FAIL context-pack Unavailable hint"; fail=1; } mcp env EUREKA_STATE_EXE="$T/CANARY-exe" $PY "$CP" t:cut_spec:cut-x.r1 "$Rw" >"$T/cp.out" 2>"$T/cp.err"; r=$? -cchk "unspawnable EUREKA_STATE_EXE" 4 "$r"; cnot "spawn error echoes no path" "CANARY-exe" +cchk "unspawnable EUREKA_STATE_EXE" 4 "$r"; grep -qF "context-pack: transport: Unavailable; --spec-json reads a saved view" "$T/cp.err" && echo "ok context-pack names the transport failure" || { echo "FAIL context-pack names the transport failure"; fail=1; }; cnot "spawn error echoes no path" "CANARY-exe" +: >"$T/cp.count" mcp env FAKE_MODE=invalid HUGINN_ENDPOINT=rudp://CANARY-endpoint2 $PY "$CP" t:cut_spec:cut-x.r1 "$Rw" >"$T/cp.out" 2>"$T/cp.err"; r=$? -cchk "a refusal that is not Unavailable" 4 "$r"; cnot "refusal echoes no endpoint" "CANARY-endpoint2" +cchk "a refusal that is not Unavailable" 4 "$r"; [ "$(wc -l <"$T/cp.count" | tr -d " ")" = 1 ] && echo "ok context-pack does not retry a refusal" || { echo "FAIL context-pack retried a refusal"; fail=1; }; cnot "refusal echoes no endpoint" "CANARY-endpoint2" grep -qF "context-pack: view: InvalidInput; --spec-json reads a saved view" "$T/cp.err" && echo "ok context-pack names the refusal code" || { echo "FAIL context-pack refusal code"; fail=1; } +for code in Rejected Misconfigured TooLarge Unencodable Internal; do + mcp env FAKE_MODE=invalid FAKE_CODE=$code $PY "$CP" t:cut_spec:cut-x.r1 "$Rw" >"$T/cp.out" 2>"$T/cp.err"; r=$? + grep -qF "context-pack: view: $code; --spec-json reads a saved view" "$T/cp.err" && [ $r = 4 ] && echo "ok context-pack names the code $code" || { echo "FAIL context-pack code $code"; fail=1; } +done +mcp env FAKE_MODE=garbage $PY "$CP" t:cut_spec:cut-x.r1 "$Rw" >"$T/cp.out" 2>"$T/cp.err"; r=$? +cchk "unparsable answer" 4 "$r"; grep -qF "context-pack: view: unparsable answer; --spec-json reads a saved view" "$T/cp.err" && echo "ok context-pack names the unparsable answer" || { echo "FAIL context-pack unparsable message"; fail=1; } mcp env FAKE_MODE=null $PY "$CP" t:cut_spec:cut-x.r1 "$Rw" >"$T/cp.out" 2>"$T/cp.err"; r=$? cchk "no such cut_spec" 4 "$r"; grep -qF "context-pack: view: no such cut_spec; --spec-json reads a saved view" "$T/cp.err" && echo "ok context-pack names the missing spec" || { echo "FAIL context-pack missing spec message"; fail=1; } mcp env HOME="$(wp "$T")/nohome" USERPROFILE="$(wp "$T")/nohome" $PY "$CP" t:cut_spec:cut-x.r1 "$Rw" >"$T/cp.out" 2>"$T/cp.err"; r=$?