Files
deusdata--codebase-memory-mcp/scripts/smoke-test.sh
T
wehub-resource-sync 41cb1c0170
OpenSSF Scorecard / scorecard (push) Failing after 0s
DCO / dco (push) Failing after 0s
CodeQL SAST / analyze (push) Failing after 1s
Deploy Pages / deploy (push) Failing after 1s
chore: import upstream snapshot with attribution
2026-07-13 12:28:05 +08:00

1939 lines
73 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
set -euo pipefail
# Smoke test: verify the binary is fully operational.
#
# Phase 1: --version output
# Phase 2: Index a small multi-language project
# Phase 3: Verify node/edge counts, search, and trace
#
# Usage: smoke-test.sh <binary-path>
BINARY="${1:?usage: smoke-test.sh <binary-path>}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd)"
TMPDIR=$(mktemp -d)
DRYRUN_HOME=""
# On MSYS2/Windows, convert POSIX path to native Windows path for the binary
if command -v cygpath &>/dev/null; then
TMPDIR=$(cygpath -m "$TMPDIR")
fi
trap 'rm -rf "$TMPDIR" "${DRYRUN_HOME:-}"' EXIT
CLI_STDERR=$(mktemp)
cli() { "$BINARY" cli "$@" 2>"$CLI_STDERR"; }
echo "=== Phase 1: version ==="
OUTPUT=$("$BINARY" --version 2>&1)
echo "$OUTPUT"
if ! echo "$OUTPUT" | grep -qE 'v?[0-9]+\.[0-9]+|dev'; then
echo "FAIL: unexpected version output"
exit 1
fi
echo "OK"
echo ""
echo "=== Phase 2: index test project ==="
# Create a small multi-language project (Python + Go + JS)
mkdir -p "$TMPDIR/src/pkg"
cat > "$TMPDIR/src/main.py" << 'PYEOF'
from pkg import helper
def main():
result = helper.compute(42)
print(result)
class Config:
DEBUG = True
PORT = 8080
PYEOF
cat > "$TMPDIR/src/pkg/__init__.py" << 'PYEOF'
from .helper import compute
PYEOF
cat > "$TMPDIR/src/pkg/helper.py" << 'PYEOF'
def compute(x):
return x * 2
def validate(data):
if not data:
raise ValueError("empty")
return True
PYEOF
cat > "$TMPDIR/src/server.go" << 'GOEOF'
package main
import "fmt"
func StartServer(port int) {
fmt.Printf("listening on :%d\n", port)
}
func HandleRequest(path string) string {
return "ok: " + path
}
GOEOF
cat > "$TMPDIR/src/app.js" << 'JSEOF'
function render(data) {
return `<div>${data}</div>`;
}
function fetchData(url) {
return fetch(url).then(r => r.json());
}
module.exports = { render, fetchData };
JSEOF
cat > "$TMPDIR/config.yaml" << 'YAMLEOF'
server:
port: 8080
debug: true
database:
host: localhost
YAMLEOF
# C++ crash reproduction (#424): a large, templated C++ header. The vendored
# tree-sitter runtime previously corrupted the heap and SEGV'd mid-parse on
# large templated C++ in the PRODUCTION build (MI_OVERRIDE=1) — most reliably on
# Windows static-MinGW, where ts_malloc/ts_free could resolve to different
# allocators. Generating a header with heavy parse churn exercises that path;
# the prod binary must index it without crashing (status must be "indexed").
python3 - "$TMPDIR/src/big_templated.hpp" << 'GENEOF'
import sys
with open(sys.argv[1], "w") as f:
f.write("#include <cstddef>\nnamespace repro {\n")
for i in range(1500):
f.write(
"template <typename T> struct Box{0} {{\n"
" T value;\n"
" bool operator<(const Box{0} &o) const {{ return value < o.value; }}\n"
" bool operator==(const Box{0} &o) const {{ return value == o.value; }}\n"
" bool operator>(const Box{0} &o) const {{ return o.value < value; }}\n"
" T get() const {{ return value; }}\n"
"}};\n".format(i)
)
f.write("}\n")
GENEOF
# Index (flag form: --repo-path -> repo_path)
if ! RESULT=$(cli index_repository --repo-path "$TMPDIR"); then
echo "FAIL: index_repository (flag form) exited non-zero"
cat "$CLI_STDERR"
exit 1
fi
echo "$RESULT"
# Allocator-integrity guard: the prod binary overrides the global allocator with
# mimalloc. A misconfigured override (e.g. compiling alloc-override.c's
# forwarding defs on a platform where system libs keep using the system
# allocator) corrupts free() and mimalloc prints "mimalloc: error: ..." to
# stderr — often WITHOUT a non-zero exit. Treat any such line as a hard failure.
if grep -qiE 'mimalloc: error|mi_free: invalid pointer|mi_assert' "$CLI_STDERR"; then
echo "FAIL: mimalloc reported an allocator error during indexing"
echo "--- stderr ---"
cat "$CLI_STDERR"
echo "--- end stderr ---"
exit 1
fi
STATUS=$(echo "$RESULT" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(d.get('status',''))" 2>/dev/null || echo "")
if [ "$STATUS" != "indexed" ]; then
echo "FAIL: index status is '$STATUS', expected 'indexed'"
echo "--- stderr ---"
cat "$CLI_STDERR"
echo "--- end stderr ---"
exit 1
fi
NODES=$(echo "$RESULT" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(d.get('nodes',0))" 2>/dev/null || echo "0")
EDGES=$(echo "$RESULT" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(d.get('edges',0))" 2>/dev/null || echo "0")
echo "nodes=$NODES edges=$EDGES"
if [ "$NODES" -lt 10 ]; then
echo "FAIL: expected at least 10 nodes, got $NODES"
exit 1
fi
if [ "$EDGES" -lt 5 ]; then
echo "FAIL: expected at least 5 edges, got $EDGES"
exit 1
fi
echo "OK: $NODES nodes, $EDGES edges"
echo ""
echo "=== Phase 3: verify queries ==="
# 3a: search_graph — find the compute function
PROJECT=$(echo "$RESULT" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(d.get('project',''))" 2>/dev/null || echo "")
if ! SEARCH=$(cli search_graph --project "$PROJECT" --name-pattern compute); then
echo "FAIL: search_graph (flag form) exited non-zero"; cat "$CLI_STDERR"; exit 1
fi
# search_graph default output is TOON (key: value scalars + header/rows tables)
TOTAL=$(echo "$SEARCH" | sed -n 's/^total: //p' | head -1)
TOTAL=${TOTAL:-0}
if [ "$TOTAL" -lt 1 ]; then
echo "FAIL: search_graph for 'compute' returned 0 results"
exit 1
fi
echo "OK: search_graph found $TOTAL result(s) for 'compute'"
# 3b: trace_path — verify compute has callers
if ! TRACE=$(cli trace_path --project "$PROJECT" --function-name compute --direction inbound --depth 1); then
echo "FAIL: trace_path (flag form) exited non-zero"; cat "$CLI_STDERR"; exit 1
fi
# trace_path default output is TOON: the callers[N]{...} header carries the count
CALLERS=$(echo "$TRACE" | sed -n 's/^callers\[\([0-9]*\)\].*/\1/p' | head -1)
CALLERS=${CALLERS:-0}
if [ "$CALLERS" -lt 1 ]; then
echo "FAIL: trace_path found 0 callers for 'compute'"
exit 1
fi
echo "OK: trace_path found $CALLERS caller(s) for 'compute'"
# 3c: get_graph_schema — verify labels exist
if ! SCHEMA=$(cli get_graph_schema --project "$PROJECT"); then
echo "FAIL: get_graph_schema (flag form) exited non-zero"; cat "$CLI_STDERR"; exit 1
fi
LABELS=$(echo "$SCHEMA" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(len(d.get('node_labels',[])))" 2>/dev/null || echo "0")
if [ "$LABELS" -lt 3 ]; then
echo "FAIL: schema has fewer than 3 node labels"
exit 1
fi
echo "OK: schema has $LABELS node labels"
# 3d: Verify __init__.py didn't clobber Folder node
if ! FOLDERS=$(cli search_graph --project "$PROJECT" --label Folder); then
echo "FAIL: search_graph --label Folder exited non-zero"; cat "$CLI_STDERR"; exit 1
fi
FOLDER_COUNT=$(echo "$FOLDERS" | sed -n 's/^total: //p' | head -1)
FOLDER_COUNT=${FOLDER_COUNT:-0}
if [ "$FOLDER_COUNT" -lt 2 ]; then
echo "FAIL: expected at least 2 Folder nodes (src, src/pkg), got $FOLDER_COUNT"
exit 1
fi
echo "OK: $FOLDER_COUNT Folder nodes (init.py didn't clobber them)"
# 3d-cypher: query_graph Cypher capabilities
# #238 WITH DISTINCT — all functions share label "Function" → collapses to 1 row.
CYPHER_WD=$(cli query_graph --project "$PROJECT" --query "MATCH (f:Function) WITH DISTINCT f.label AS lbl RETURN lbl")
WD_ROWS=$(echo "$CYPHER_WD" | sed -n 's/^total: //p' | head -1)
WD_ROWS=${WD_ROWS:-0}
if [ "$WD_ROWS" -lt 1 ]; then
echo "FAIL: query_graph WITH DISTINCT returned 0 rows"
echo "$CYPHER_WD"
exit 1
fi
echo "OK: query_graph WITH DISTINCT returned $WD_ROWS row(s)"
# #241 WHERE label test — f:Function is true for every Function node.
CYPHER_LBL=$(cli query_graph --project "$PROJECT" --query "MATCH (f:Function) WHERE f:Function RETURN f.name")
LBL_ROWS=$(echo "$CYPHER_LBL" | sed -n 's/^total: //p' | head -1)
LBL_ROWS=${LBL_ROWS:-0}
if [ "$LBL_ROWS" -lt 1 ]; then
echo "FAIL: query_graph WHERE label-test returned 0 rows"
echo "$CYPHER_LBL"
exit 1
fi
echo "OK: query_graph WHERE f:Function returned $LBL_ROWS row(s)"
# #242 label alternation — (n:Function|Module) seeds either label.
CYPHER_ALT=$(cli query_graph --project "$PROJECT" --query "MATCH (n:Function|Module) RETURN n.name")
ALT_ROWS=$(echo "$CYPHER_ALT" | sed -n 's/^total: //p' | head -1)
ALT_ROWS=${ALT_ROWS:-0}
if [ "$ALT_ROWS" -lt 1 ]; then
echo "FAIL: query_graph label alternation returned 0 rows"
echo "$CYPHER_ALT"
exit 1
fi
echo "OK: query_graph (n:Function|Module) returned $ALT_ROWS row(s)"
# #239 count(DISTINCT) — must parse and return a single aggregate row.
CYPHER_CD=$(cli query_graph --project "$PROJECT" --query "MATCH (f:Function) RETURN count(DISTINCT f.label)")
CD_ROWS=$(echo "$CYPHER_CD" | sed -n 's/^total: //p' | head -1)
CD_ROWS=${CD_ROWS:-0}
if [ "$CD_ROWS" -ne 1 ]; then
echo "FAIL: query_graph count(DISTINCT) expected 1 row, got $CD_ROWS"
echo "$CYPHER_CD"
exit 1
fi
echo "OK: query_graph count(DISTINCT f.label) returned 1 aggregate row"
# 3d-funcs: scalar / introspection functions (full Cypher suite, Tier 1)
cyp_first_cell() {
# $1 = query; echoes rows[0][0] (or empty). Flag form passes the query as ONE
# argv token, so string-literal args (e.g. replace(f.name,"a","A")) and Cypher
# metacharacters {}|=~<>" need no JSON escaping.
cli query_graph --project "$PROJECT" --query "$1" |
sed -n '/^rows\[/{n;p;}' | sed 's/^ //' | sed 's/^"//;s/"$//;s/\\"/"/g'
}
# labels(n) → JSON list like ["Function"]
LBLV=$(cyp_first_cell 'MATCH (f:Function) RETURN labels(f) AS l LIMIT 1')
case "$LBLV" in
'['*) echo "OK: query_graph labels(f) = $LBLV" ;;
*) echo "FAIL: query_graph labels(f) returned '$LBLV' (expected a [\"...\"] list)"; exit 1 ;;
esac
# type(r) → relationship type
TYPV=$(cyp_first_cell 'MATCH (f:Function)-[r]->(g) RETURN type(r) AS t LIMIT 1')
if [ -z "$TYPV" ]; then
echo "FAIL: query_graph type(r) returned empty"; exit 1
fi
echo "OK: query_graph type(r) = $TYPV"
# id(n) → numeric identity
IDV=$(cyp_first_cell 'MATCH (f:Function) RETURN id(f) AS i LIMIT 1')
case "$IDV" in
''|*[!0-9]*) echo "FAIL: query_graph id(f) returned non-numeric '$IDV'"; exit 1 ;;
*) echo "OK: query_graph id(f) = $IDV" ;;
esac
# properties(n) → JSON object
PROPV=$(cyp_first_cell 'MATCH (f:Function) RETURN properties(f) AS p LIMIT 1')
case "$PROPV" in
'{'*) echo "OK: query_graph properties(f) is a JSON object" ;;
*) echo "FAIL: query_graph properties(f) returned '$PROPV'"; exit 1 ;;
esac
# toInteger() cast in projection
TIV=$(cyp_first_cell 'MATCH (f:Function) RETURN toInteger(f.start_line) AS n LIMIT 1')
case "$TIV" in
''|*[!0-9-]*) echo "FAIL: query_graph toInteger(f.start_line) returned non-integer '$TIV'"; exit 1 ;;
*) echo "OK: query_graph toInteger(f.start_line) = $TIV" ;;
esac
# size() string-length function in projection
SZV=$(cyp_first_cell 'MATCH (f:Function) RETURN size(f.name) AS s LIMIT 1')
case "$SZV" in
''|*[!0-9]*) echo "FAIL: query_graph size(f.name) returned non-integer '$SZV'"; exit 1 ;;
*) echo "OK: query_graph size(f.name) = $SZV" ;;
esac
# multi-arg functions: substring + coalesce
SUBV=$(cyp_first_cell 'MATCH (f:Function) RETURN substring(f.name, 0, 3) AS s LIMIT 1')
if [ -z "$SUBV" ]; then echo "FAIL: query_graph substring(...) returned empty"; exit 1; fi
echo "OK: query_graph substring(f.name,0,3) = $SUBV"
COALV=$(cyp_first_cell 'MATCH (f:Function) RETURN coalesce(f.nonesuch, f.name) AS c LIMIT 1')
if [ -z "$COALV" ]; then echo "FAIL: query_graph coalesce(...) returned empty"; exit 1; fi
echo "OK: query_graph coalesce(f.nonesuch, f.name) = $COALV"
# EXISTS { } pattern predicate (edge-type-specific existence)
CYPHER_EX=$(cli query_graph --project "$PROJECT" --query "MATCH (f:Function) WHERE EXISTS { (f)-[:CALLS]->() } RETURN f.name")
EX_ROWS=$(echo "$CYPHER_EX" | sed -n 's/^total: //p' | head -1)
EX_ROWS=${EX_ROWS:-0}
if [ "$EX_ROWS" -lt 1 ]; then
echo "FAIL: query_graph EXISTS{} predicate returned 0 rows"; echo "$CYPHER_EX"; exit 1
fi
echo "OK: query_graph EXISTS { (f)-[:CALLS]->() } returned $EX_ROWS row(s)"
# =~ regex match in WHERE
CYPHER_RX=$(cli query_graph --project "$PROJECT" --query 'MATCH (f:Function) WHERE f.name =~ ".+" RETURN f.name')
RX_ROWS=$(echo "$CYPHER_RX" | sed -n 's/^total: //p' | head -1)
RX_ROWS=${RX_ROWS:-0}
if [ "$RX_ROWS" -lt 1 ]; then
echo "FAIL: query_graph WHERE =~ regex returned 0 rows"; echo "$CYPHER_RX"; exit 1
fi
echo "OK: query_graph WHERE f.name =~ regex returned $RX_ROWS row(s)"
# keys(n) → JSON list including "name"
KEYSV=$(cyp_first_cell 'MATCH (f:Function) RETURN keys(f) AS k LIMIT 1')
case "$KEYSV" in
*'"name"'*) echo "OK: query_graph keys(f) = $KEYSV" ;;
*) echo "FAIL: query_graph keys(f) returned '$KEYSV'"; exit 1 ;;
esac
# reverse() + replace() + left() string functions
REVV=$(cyp_first_cell 'MATCH (f:Function) RETURN reverse(f.name) AS r LIMIT 1')
[ -n "$REVV" ] && echo "OK: query_graph reverse(f.name) = $REVV" || { echo "FAIL: reverse empty"; exit 1; }
REPV=$(cyp_first_cell 'MATCH (f:Function) RETURN replace(f.name, "a", "A") AS r LIMIT 1')
[ -n "$REPV" ] && echo "OK: query_graph replace(...) = $REPV" || { echo "FAIL: replace empty"; exit 1; }
LEFTV=$(cyp_first_cell 'MATCH (f:Function) RETURN left(f.name, 3) AS l LIMIT 1')
[ -n "$LEFTV" ] && echo "OK: query_graph left(f.name,3) = $LEFTV" || { echo "FAIL: left empty"; exit 1; }
# NOT EXISTS dead-code query (functions with no caller)
CYPHER_NX=$(cli query_graph --project "$PROJECT" --query "MATCH (f:Function) WHERE NOT EXISTS { (f)<-[:CALLS]-() } RETURN f.name")
NX_OK=$(echo "$CYPHER_NX" | grep -qE '^rows\[[0-9]+\]\{' && echo "True" || echo "False")
[ "$NX_OK" = "True" ] && echo "OK: query_graph NOT EXISTS dead-code query executed" || { echo "FAIL: NOT EXISTS query"; echo "$CYPHER_NX" | head -c 300; exit 1; }
# CASE expression in RETURN
CASEV=$(cyp_first_cell 'MATCH (f:Function) RETURN CASE WHEN f.name =~ ".+" THEN "named" ELSE "anon" END AS c LIMIT 1')
[ "$CASEV" = "named" ] && echo "OK: query_graph CASE expression = $CASEV" || { echo "FAIL: CASE returned '$CASEV'"; exit 1; }
# unsupported function must FAIL LOUDLY (not silently return empty). The CLI
# prints the parse error to stderr (captured by cli() into $CLI_STDERR) and exits
# non-zero, leaving stdout empty — so verify the loud failure on that channel.
if cli query_graph --project "$PROJECT" --query "MATCH (f:Function) RETURN nosuchfn(f.name)" >/dev/null; then
echo "FAIL: unsupported function did not error (exit 0)"; exit 1
fi
ERROUT=$(cat "$CLI_STDERR" 2>/dev/null)
case "$ERROUT" in
*unsupported*) echo "OK: unsupported function errors loudly" ;;
*) echo "FAIL: unsupported function did not error: $ERROUT" | head -c 300; exit 1 ;;
esac
# 3f: get_architecture surfaces Leiden community clusters
if ! ARCH=$(cli get_architecture --project "$PROJECT" --aspects clusters); then
echo "FAIL: get_architecture (flag form) exited non-zero"; cat "$CLI_STDERR"; exit 1
fi
# get_architecture default output is TOON: clusters[N]{...} header carries the count
NCLUST=$(echo "$ARCH" | sed -n 's/^clusters\[\([0-9]*\)\].*/\1/p' | head -1)
NCLUST=${NCLUST:-0}
if [ "$NCLUST" -lt 1 ]; then
echo "FAIL: get_architecture returned 0 community clusters"; echo "$ARCH" | head -c 400; exit 1
fi
echo "OK: get_architecture returned $NCLUST community cluster(s)"
# 3g: search_code — basic search reports elapsed_ms + matches
SC=$(cli search_code --project "$PROJECT" --pattern cbm_ --mode compact --limit 5)
# compact mode emits TOON scalars: `elapsed_ms: N` + `total_grep_matches: N`
SC_ELAPSED=$(echo "$SC" | sed -n 's/^elapsed_ms: //p' | head -1)
SC_GREPM=$(echo "$SC" | sed -n 's/^total_grep_matches: //p' | head -1)
if [ -n "$SC_ELAPSED" ]; then
echo "OK: search_code elapsed_ms=$SC_ELAPSED total_grep_matches=${SC_GREPM:-0}"
else
echo "FAIL: search_code basic / no elapsed_ms"; echo "$SC" | head -c 400; exit 1
fi
# 3g: search_code — literal '|' under regex=false must surface a warning (#282)
SCW=$(cli search_code --project "$PROJECT" --pattern "cbm_init|cbm_nope" --regex false --limit 5)
# TOON scalar `warning: ... regex=true ...`
if echo "$SCW" | grep -q "regex=true"; then
echo "OK: search_code literal-| warning surfaced"
else
echo "FAIL: search_code literal-| warning missing"; echo "$SCW" | head -c 400; exit 1
fi
# 3g: search_code — '&' in file_pattern accepted, not rejected as invalid (#272)
SCA=$(cli search_code --project "$PROJECT" --pattern cbm_ --file-pattern "*R&D*.c" --limit 5)
case "$SCA" in
*"invalid characters"*) echo "FAIL: search_code rejected '&' in file_pattern"; echo "$SCA" | head -c 300; exit 1 ;;
*) echo "OK: search_code accepts '&' in file_pattern" ;;
esac
echo ""
echo "=== Phase 3h: CLI input-mode guards (flags / stdin / --args-file / --help / deprecation) ==="
# Small helper: assert its stdin is a JSON object (exit non-zero otherwise).
assert_json_obj() { python3 -c "import json,sys; d=json.loads(sys.stdin.read()); sys.exit(0 if isinstance(d,dict) else 1)" 2>/dev/null; }
# search_graph emits TOON by default: a results/semantic table header proves
# the tool parsed its typed flags and produced a well-formed response.
assert_toon_table() { grep -qE '^(results|semantic)\[[0-9]+\]\{'; }
# B1: INTEGER flag — --limit is schema-typed integer; must parse and answer.
if ! IM_INT=$(cli search_graph --project "$PROJECT" --name-pattern compute --limit 5); then
echo "FAIL B1: search_graph --limit 5 exited non-zero"; cat "$CLI_STDERR"; exit 1
fi
if echo "$IM_INT" | assert_toon_table; then
echo "OK B1: INTEGER flag (--limit 5) parsed → TOON results table"
else
echo "FAIL B1: --limit 5 did not produce a TOON results table"; echo "$IM_INT" | head -c 300; exit 1
fi
# B2: BOOLEAN bare flag — --exclude-entry-points with no value → true; must succeed.
if ! IM_BOOL=$(cli search_graph --project "$PROJECT" --exclude-entry-points); then
echo "FAIL B2: search_graph --exclude-entry-points exited non-zero"; cat "$CLI_STDERR"; exit 1
fi
if echo "$IM_BOOL" | assert_toon_table; then
echo "OK B2: BOOLEAN bare flag (--exclude-entry-points) → success"
else
echo "FAIL B2: --exclude-entry-points did not produce a TOON results table"; echo "$IM_BOOL" | head -c 300; exit 1
fi
# B3: ARRAY flag — repeated --semantic-query accumulates into a JSON array.
# Semantic-only calls emit ONLY the semantic table (may be empty, header stays).
if ! IM_ARR=$(cli search_graph --project "$PROJECT" --semantic-query send --semantic-query publish); then
echo "FAIL B3: search_graph repeated --semantic-query exited non-zero"; cat "$CLI_STDERR"; exit 1
fi
if echo "$IM_ARR" | grep -qE '^semantic\[[0-9]+\]\{'; then
echo "OK B3: ARRAY flag (repeated --semantic-query) → semantic TOON table"
else
echo "FAIL B3: repeated --semantic-query did not produce a semantic table"; echo "$IM_ARR" | head -c 300; exit 1
fi
# B4: STDIN — piped JSON resolves; this path must NOT emit a deprecation warning.
IM_STDIN=$(echo "{\"project\":\"$PROJECT\"}" | "$BINARY" cli get_graph_schema 2>"$CLI_STDERR")
if ! echo "$IM_STDIN" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); sys.exit(0 if 'node_labels' in d else 1)" 2>/dev/null; then
echo "FAIL B4: stdin get_graph_schema did not resolve"; echo "$IM_STDIN" | head -c 300; cat "$CLI_STDERR"; exit 1
fi
if grep -qi 'deprecated' "$CLI_STDERR"; then
echo "FAIL B4: stdin path wrongly emitted a deprecation warning"; cat "$CLI_STDERR"; exit 1
fi
echo "OK B4: STDIN input resolves, no deprecation warning"
# B5: --args-file — JSON read from a file resolves; must NOT warn deprecated.
IM_ARGS_FILE=$(mktemp)
echo "{\"project\":\"$PROJECT\"}" > "$IM_ARGS_FILE"
if ! IM_AF=$(cli get_graph_schema --args-file "$IM_ARGS_FILE"); then
echo "FAIL B5: get_graph_schema --args-file exited non-zero"; cat "$CLI_STDERR"; rm -f "$IM_ARGS_FILE"; exit 1
fi
if ! echo "$IM_AF" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); sys.exit(0 if 'node_labels' in d else 1)" 2>/dev/null; then
echo "FAIL B5: --args-file get_graph_schema did not resolve"; echo "$IM_AF" | head -c 300; rm -f "$IM_ARGS_FILE"; exit 1
fi
if grep -qi 'deprecated' "$CLI_STDERR"; then
echo "FAIL B5: --args-file path wrongly emitted a deprecation warning"; cat "$CLI_STDERR"; rm -f "$IM_ARGS_FILE"; exit 1
fi
rm -f "$IM_ARGS_FILE"
echo "OK B5: --args-file input resolves, no deprecation warning"
# B6: per-tool --help — RC0 with expected flags in stdout; unknown tool errors non-zero.
if ! H_SG=$(cli search_graph --help); then
echo "FAIL B6a: 'search_graph --help' exited non-zero"; exit 1
fi
if echo "$H_SG" | grep -q -- "--name-pattern"; then
echo "OK B6a: search_graph --help (RC0) lists --name-pattern"
else
echo "FAIL B6a: search_graph --help missing --name-pattern"; echo "$H_SG" | head -c 400; exit 1
fi
if ! H_IR=$(cli index_repository --help); then
echo "FAIL B6b: 'index_repository --help' exited non-zero"; exit 1
fi
if echo "$H_IR" | grep -q -- "--repo-path"; then
echo "OK B6b: index_repository --help (RC0) lists --repo-path"
else
echo "FAIL B6b: index_repository --help missing --repo-path"; echo "$H_IR" | head -c 400; exit 1
fi
# Unknown tool: must exit non-zero and report "unknown tool" (on stderr).
if cli notatool --help >/dev/null; then
echo "FAIL B6c: 'notatool --help' exited 0 (expected non-zero for unknown tool)"; exit 1
fi
if grep -qi 'unknown tool' "$CLI_STDERR"; then
echo "OK B6c: 'notatool --help' errors non-zero with 'unknown tool'"
else
echo "FAIL B6c: 'notatool --help' did not report 'unknown tool'"; cat "$CLI_STDERR"; exit 1
fi
# B7: DEPRECATION guard — one raw-JSON call MUST warn on stderr; flag form must NOT.
cli search_graph "{\"project\":\"$PROJECT\",\"name_pattern\":\"compute\"}" >/dev/null || true
if grep -qi 'deprecated' "$CLI_STDERR"; then
echo "OK B7a: raw-JSON cli emits deprecation warning on stderr"
else
echo "FAIL B7a: raw-JSON cli did NOT emit deprecation warning"; cat "$CLI_STDERR"; exit 1
fi
cli search_graph --project "$PROJECT" --name-pattern compute >/dev/null || true
if grep -qi 'deprecated' "$CLI_STDERR"; then
echo "FAIL B7b: flag-form cli wrongly emitted a deprecation warning"; cat "$CLI_STDERR"; exit 1
else
echo "OK B7b: flag-form cli emits no deprecation warning"
fi
# 3e: delete_project cleanup
if ! cli delete_project --project "$PROJECT" > /dev/null; then
echo "FAIL: delete_project (flag form) exited non-zero"; cat "$CLI_STDERR"; exit 1
fi
echo ""
echo "=== Phase 4: security checks ==="
# 4a: Clean shutdown — binary must exit within 5 seconds after EOF
echo "Testing clean shutdown..."
SHUTDOWN_TMPDIR=$(mktemp -d)
cat > "$SHUTDOWN_TMPDIR/input.jsonl" << 'JSONL'
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}
JSONL
# Run binary with EOF and wait up to 5 seconds (portable — no `timeout` needed)
"$BINARY" < "$SHUTDOWN_TMPDIR/input.jsonl" > /dev/null 2>&1 &
SHUTDOWN_PID=$!
SHUTDOWN_WAITED=0
while kill -0 "$SHUTDOWN_PID" 2>/dev/null && [ "$SHUTDOWN_WAITED" -lt 5 ]; do
sleep 1
SHUTDOWN_WAITED=$((SHUTDOWN_WAITED + 1))
done
if kill -0 "$SHUTDOWN_PID" 2>/dev/null; then
kill "$SHUTDOWN_PID" 2>/dev/null || true
wait "$SHUTDOWN_PID" 2>/dev/null || true
rm -rf "$SHUTDOWN_TMPDIR"
echo "FAIL: binary did not exit within 5 seconds after EOF"
exit 1
fi
wait "$SHUTDOWN_PID" 2>/dev/null || true
rm -rf "$SHUTDOWN_TMPDIR"
echo "OK: clean shutdown"
# 4b: No residual processes (skip on Windows/MSYS2 where pgrep may not work)
if command -v pgrep &>/dev/null && [ "$(uname)" != "MINGW64_NT" ] 2>/dev/null; then
# Give a moment for any child processes to clean up
sleep 1
RESIDUAL=$(pgrep -f "codebase-memory-mcp.*cli" 2>/dev/null | wc -l | tr -d ' \n' || echo "0")
RESIDUAL="${RESIDUAL:-0}"
if [ "$RESIDUAL" -gt 0 ]; then
echo "WARNING: $RESIDUAL residual codebase-memory-mcp process(es) found"
else
echo "OK: no residual processes"
fi
fi
# 4c: Version integrity — output must be exactly one line matching version format
VERSION_OUTPUT=$("$BINARY" --version 2>&1)
VERSION_LINES=$(echo "$VERSION_OUTPUT" | wc -l | tr -d ' ')
if [ "$VERSION_LINES" -ne 1 ]; then
echo "FAIL: --version output has $VERSION_LINES lines, expected exactly 1"
echo " Output: $VERSION_OUTPUT"
exit 1
fi
echo "OK: version output is clean single line"
echo ""
echo "=== Phase 5: MCP stdio transport (agent handshake) ==="
# Test the actual MCP protocol as an agent (Claude Code, OpenCode, etc.) would use it.
# Uses background process + kill instead of timeout (portable across macOS/Linux).
# Helper: run binary in background with input, wait up to N seconds, collect output
mcp_run() {
local input_file="$1" output_file="$2" max_wait="${3:-10}"
"$BINARY" < "$input_file" > "$output_file" 2>/dev/null &
local pid=$!
local waited=0
while kill -0 "$pid" 2>/dev/null && [ "$waited" -lt "$max_wait" ]; do
sleep 1
waited=$((waited + 1))
done
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
}
MCP_INPUT=$(mktemp)
MCP_OUTPUT=$(mktemp)
cat > "$MCP_INPUT" << 'MCPEOF'
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"smoke-test","version":"1.0"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
MCPEOF
mcp_run "$MCP_INPUT" "$MCP_OUTPUT" 10
# 5a: Verify initialize response (id:1)
if ! grep -q '"id":1' "$MCP_OUTPUT"; then
echo "FAIL: no initialize response (id:1) in MCP output"
echo "Output was:"
cat "$MCP_OUTPUT"
rm -f "$MCP_INPUT" "$MCP_OUTPUT"
exit 1
fi
echo "OK: initialize response received (id:1)"
# 5b: Verify tools/list response (id:2) with tool names
if ! grep -q '"id":2' "$MCP_OUTPUT"; then
echo "FAIL: no tools/list response (id:2) in MCP output"
echo "Output was:"
cat "$MCP_OUTPUT"
rm -f "$MCP_INPUT" "$MCP_OUTPUT"
exit 1
fi
echo "OK: tools/list response received (id:2)"
# 5c: Verify expected tools are present
for TOOL in index_repository search_graph trace_path get_code_snippet search_code; do
if ! grep -q "\"$TOOL\"" "$MCP_OUTPUT"; then
echo "FAIL: tool '$TOOL' not found in tools/list response"
rm -f "$MCP_INPUT" "$MCP_OUTPUT"
exit 1
fi
done
echo "OK: all 5 core MCP tools present in tools/list"
# 5d: Verify protocol version in initialize response
if ! grep -q '"protocolVersion"' "$MCP_OUTPUT"; then
echo "FAIL: protocolVersion missing from initialize response"
rm -f "$MCP_INPUT" "$MCP_OUTPUT"
exit 1
fi
echo "OK: protocolVersion present in initialize response"
rm -f "$MCP_INPUT" "$MCP_OUTPUT"
# 5e: MCP tool call via JSON-RPC (index + search round-trip)
echo ""
echo "--- Phase 5e: MCP tool call round-trip ---"
MCP_TOOL_INPUT=$(mktemp)
MCP_TOOL_OUTPUT=$(mktemp)
cat > "$MCP_TOOL_INPUT" << TOOLEOF
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"smoke-test","version":"1.0"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"index_repository","arguments":{"repo_path":"$TMPDIR","mode":"fast"}}}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"search_graph","arguments":{"name_pattern":"compute"}}}
TOOLEOF
mcp_run "$MCP_TOOL_INPUT" "$MCP_TOOL_OUTPUT" 30
if ! grep -q '"id":2' "$MCP_TOOL_OUTPUT"; then
echo "FAIL: no index_repository response (id:2)"
cat "$MCP_TOOL_OUTPUT"
rm -f "$MCP_TOOL_INPUT" "$MCP_TOOL_OUTPUT"
exit 1
fi
if ! grep -q '"id":3' "$MCP_TOOL_OUTPUT"; then
echo "FAIL: no search_graph response (id:3)"
cat "$MCP_TOOL_OUTPUT"
rm -f "$MCP_TOOL_INPUT" "$MCP_TOOL_OUTPUT"
exit 1
fi
echo "OK: MCP tool call round-trip (index + search) succeeded"
# 5f: Content-Length framing (OpenCode compatibility)
echo ""
echo "--- Phase 5f: Content-Length framing ---"
MCP_CL_INPUT=$(mktemp)
MCP_CL_OUTPUT=$(mktemp)
INIT_MSG='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"cl-test","version":"1.0"}}}'
INIT_LEN=${#INIT_MSG}
printf "Content-Length: %d\r\n\r\n%s" "$INIT_LEN" "$INIT_MSG" > "$MCP_CL_INPUT"
TOOLS_MSG='{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
TOOLS_LEN=${#TOOLS_MSG}
printf "Content-Length: %d\r\n\r\n%s" "$TOOLS_LEN" "$TOOLS_MSG" >> "$MCP_CL_INPUT"
mcp_run "$MCP_CL_INPUT" "$MCP_CL_OUTPUT" 10
if ! grep -q '"id":1' "$MCP_CL_OUTPUT" || ! grep -q '"id":2' "$MCP_CL_OUTPUT"; then
echo "FAIL: Content-Length framed handshake did not produce both responses"
cat "$MCP_CL_OUTPUT"
rm -f "$MCP_CL_INPUT" "$MCP_CL_OUTPUT"
exit 1
fi
echo "OK: Content-Length framing works (OpenCode compatible)"
rm -f "$MCP_CL_INPUT" "$MCP_CL_OUTPUT" "$MCP_TOOL_INPUT" "$MCP_TOOL_OUTPUT"
echo ""
echo "=== Phase 6: CLI subcommands ==="
DRYRUN_HOME=$(mktemp -d)
DRYRUN_CACHE="$DRYRUN_HOME/.cache/codebase-memory-mcp"
mkdir -p "$DRYRUN_CACHE" \
"$DRYRUN_HOME/.local/bin" \
"$DRYRUN_HOME/.config" \
"$DRYRUN_HOME/AppData/Roaming" \
"$DRYRUN_HOME/AppData/Local"
run_dryrun_env() {
HOME="$DRYRUN_HOME" \
XDG_CONFIG_HOME="$DRYRUN_HOME/.config" \
APPDATA="$DRYRUN_HOME/AppData/Roaming" \
LOCALAPPDATA="$DRYRUN_HOME/AppData/Local" \
CBM_CACHE_DIR="$DRYRUN_CACHE" \
PATH="$DRYRUN_HOME/.local/bin:$PATH" \
"$@"
}
# 6a: install --dry-run -y
echo "--- Phase 6a: install --dry-run ---"
INSTALL_OUT=$(run_dryrun_env "$BINARY" install --dry-run -y 2>&1)
if ! echo "$INSTALL_OUT" | grep -qi 'install\|skill\|mcp\|agent'; then
echo "FAIL: install --dry-run produced unexpected output"
echo "$INSTALL_OUT"
exit 1
fi
if ! echo "$INSTALL_OUT" | grep -qi 'dry-run'; then
echo "FAIL: install --dry-run did not indicate dry-run mode"
exit 1
fi
echo "OK: install --dry-run completed"
# 6b: uninstall --dry-run -y
echo "--- Phase 6b: uninstall --dry-run ---"
UNINSTALL_OUT=$(run_dryrun_env "$BINARY" uninstall --dry-run -y 2>&1)
if ! echo "$UNINSTALL_OUT" | grep -qi 'uninstall\|remov'; then
echo "FAIL: uninstall --dry-run produced unexpected output"
echo "$UNINSTALL_OUT"
exit 1
fi
echo "OK: uninstall --dry-run completed"
# 6c: update --dry-run --standard -y
echo "--- Phase 6c: update --dry-run ---"
UPDATE_OUT=$(run_dryrun_env "$BINARY" update --dry-run --standard -y 2>&1)
if ! echo "$UPDATE_OUT" | grep -qi 'dry-run'; then
echo "FAIL: update --dry-run did not indicate dry-run mode"
echo "$UPDATE_OUT"
exit 1
fi
if ! echo "$UPDATE_OUT" | grep -qi 'standard'; then
echo "FAIL: update --dry-run did not respect --standard flag"
exit 1
fi
# On Linux the binary must self-update from the static "-portable" asset: the
# standard linux asset dynamically links glibc 2.38+ and breaks on older distros
# (Debian 11, RHEL 8, Ubuntu 20.04). Guards build_update_url in src/cli/cli.c.
if [ "$(uname -s)" = "Linux" ]; then
if ! echo "$UPDATE_OUT" | grep -q -- '-portable'; then
echo "FAIL: linux update --dry-run does not target the -portable asset"
echo "$UPDATE_OUT"
exit 1
fi
echo "OK: linux update targets the -portable (static) asset"
fi
echo "OK: update --dry-run --standard completed"
# 6d: config set/get/reset round-trip
echo "--- Phase 6d: config set/get/reset ---"
run_dryrun_env "$BINARY" config set auto_index true 2>/dev/null
CONFIG_VAL=$(run_dryrun_env "$BINARY" config get auto_index 2>/dev/null)
if ! echo "$CONFIG_VAL" | grep -q 'true'; then
echo "FAIL: config get auto_index returned '$CONFIG_VAL', expected 'true'"
exit 1
fi
run_dryrun_env "$BINARY" config reset auto_index 2>/dev/null
echo "OK: config set/get/reset round-trip"
# 6e: Simulated binary replacement (update flow without network)
# Simulates the update command's Steps 3-6: extract, replace, verify.
# Uses a copy of the test binary as the "downloaded" version.
echo "--- Phase 6e: simulated binary replacement ---"
REPLACE_DIR=$(mktemp -d)
INSTALL_DIR="$REPLACE_DIR/install"
mkdir -p "$INSTALL_DIR"
# 1. Copy binary to "install dir" as the "currently installed" version
cp "$BINARY" "$INSTALL_DIR/codebase-memory-mcp"
chmod 755 "$INSTALL_DIR/codebase-memory-mcp"
# Verify installed binary works
INSTALLED_VER=$("$INSTALL_DIR/codebase-memory-mcp" --version 2>&1)
if ! echo "$INSTALLED_VER" | grep -qE 'v?[0-9]+\.[0-9]+|dev'; then
echo "FAIL: installed binary --version failed: $INSTALLED_VER"
rm -rf "$REPLACE_DIR"
exit 1
fi
# 2. Copy binary as the "downloaded" new version
cp "$BINARY" "$REPLACE_DIR/smoke-codebase-memory-mcp"
# 3. Simulate cbm_replace_binary: unlink old, copy new
rm -f "$INSTALL_DIR/codebase-memory-mcp"
cp "$REPLACE_DIR/smoke-codebase-memory-mcp" "$INSTALL_DIR/codebase-memory-mcp"
chmod 755 "$INSTALL_DIR/codebase-memory-mcp"
# 4. Verify replaced binary works
REPLACED_VER=$("$INSTALL_DIR/codebase-memory-mcp" --version 2>&1)
if ! echo "$REPLACED_VER" | grep -qE 'v?[0-9]+\.[0-9]+|dev'; then
echo "FAIL: replaced binary --version failed: $REPLACED_VER"
rm -rf "$REPLACE_DIR"
exit 1
fi
echo "OK: binary replacement succeeded (version: $REPLACED_VER)"
# 5. Test replacement of read-only binary (edge case — cbm_replace_binary
# handles this via unlink-before-write, which works even on read-only files)
chmod 444 "$INSTALL_DIR/codebase-memory-mcp"
rm -f "$INSTALL_DIR/codebase-memory-mcp"
cp "$REPLACE_DIR/smoke-codebase-memory-mcp" "$INSTALL_DIR/codebase-memory-mcp"
chmod 755 "$INSTALL_DIR/codebase-memory-mcp"
READONLY_VER=$("$INSTALL_DIR/codebase-memory-mcp" --version 2>&1)
if ! echo "$READONLY_VER" | grep -qE 'v?[0-9]+\.[0-9]+|dev'; then
echo "FAIL: read-only replacement --version failed: $READONLY_VER"
rm -rf "$REPLACE_DIR"
exit 1
fi
echo "OK: read-only binary replacement succeeded"
rm -rf "$REPLACE_DIR"
echo ""
echo "=== Phase 7: MCP advanced tool calls ==="
# 7a: search_code via MCP (graph-augmented v2)
echo "--- Phase 7a: search_code via MCP ---"
MCP_SC_INPUT=$(mktemp)
MCP_SC_OUTPUT=$(mktemp)
cat > "$MCP_SC_INPUT" << SCEOF
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"smoke","version":"1.0"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"index_repository","arguments":{"repo_path":"$TMPDIR","mode":"fast"}}}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"search_code","arguments":{"pattern":"compute","mode":"compact","limit":3}}}
{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"get_code_snippet","arguments":{"qualified_name":"compute"}}}
SCEOF
mcp_run "$MCP_SC_INPUT" "$MCP_SC_OUTPUT" 30
if ! grep -q '"id":3' "$MCP_SC_OUTPUT"; then
echo "FAIL: search_code response (id:3) missing"
exit 1
fi
echo "OK: search_code v2 via MCP"
# 7b: get_code_snippet via MCP
if ! grep -q '"id":4' "$MCP_SC_OUTPUT"; then
echo "FAIL: get_code_snippet response (id:4) missing"
exit 1
fi
echo "OK: get_code_snippet via MCP"
rm -f "$MCP_SC_INPUT" "$MCP_SC_OUTPUT"
echo ""
echo "=== Phase 8: agent config install E2E ==="
# Set up isolated HOME with stub agent directories
FAKE_HOME=$(mktemp -d)
mkdir -p "$FAKE_HOME/.claude"
mkdir -p "$FAKE_HOME/.codex"
mkdir -p "$FAKE_HOME/.gemini/antigravity-cli"
mkdir -p "$FAKE_HOME/.openclaw"
mkdir -p "$FAKE_HOME/.kilocode/rules"
mkdir -p "$FAKE_HOME/.config/opencode"
if [ "$(uname -s)" = "Darwin" ]; then
mkdir -p "$FAKE_HOME/Library/Application Support/Zed"
mkdir -p "$FAKE_HOME/Library/Application Support/Code/User"
mkdir -p "$FAKE_HOME/Library/Application Support/Code/User/globalStorage/kilocode.kilo-code/settings"
elif [[ "${BINARY:-}" == *.exe ]]; then
mkdir -p "$FAKE_HOME/AppData/Local/Zed"
mkdir -p "$FAKE_HOME/AppData/Roaming/Code/User"
mkdir -p "$FAKE_HOME/AppData/Roaming/Code/User/globalStorage/kilocode.kilo-code/settings"
else
mkdir -p "$FAKE_HOME/.config/zed"
mkdir -p "$FAKE_HOME/.config/Code/User"
mkdir -p "$FAKE_HOME/.config/Code/User/globalStorage/kilocode.kilo-code/settings"
fi
mkdir -p "$FAKE_HOME/.local/bin"
# Copy binary with correct name for platform
if [[ "$BINARY" == *.exe ]]; then
cp "$BINARY" "$FAKE_HOME/.local/bin/codebase-memory-mcp.exe"
SELF_PATH="$FAKE_HOME/.local/bin/codebase-memory-mcp.exe"
else
cp "$BINARY" "$FAKE_HOME/.local/bin/codebase-memory-mcp"
SELF_PATH="$FAKE_HOME/.local/bin/codebase-memory-mcp"
fi
printf '#!/bin/sh\necho stub\n' > "$FAKE_HOME/.local/bin/aider" && chmod +x "$FAKE_HOME/.local/bin/aider" 2>/dev/null || true
printf '#!/bin/sh\necho stub\n' > "$FAKE_HOME/.local/bin/opencode" && chmod +x "$FAKE_HOME/.local/bin/opencode" 2>/dev/null || true
# Pre-existing configs (verify merge, not overwrite)
echo '{"existingKey": true}' > "$FAKE_HOME/.claude.json"
echo '{"existingKey": true}' > "$FAKE_HOME/.gemini/settings.json"
printf '[existing_section]\nline_from_user = true\n' > "$FAKE_HOME/.codex/config.toml"
# Run install — override platform config dirs so cbm_app_config_dir() and
# cbm_app_local_dir() resolve to FAKE_HOME paths on all platforms.
HOME="$FAKE_HOME" \
XDG_CONFIG_HOME="$FAKE_HOME/.config" \
APPDATA="$FAKE_HOME/AppData/Roaming" \
LOCALAPPDATA="$FAKE_HOME/AppData/Local" \
PATH="$FAKE_HOME/.local/bin:$PATH" \
"$BINARY" install -y 2>&1 || true
# Helper for JSON validation (pipe file to python — avoids MSYS2 path translation issues)
json_get() { cat "$1" 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print($2)" 2>/dev/null || echo ""; }
# Helper: compare command paths (handles Windows D:\... vs POSIX /tmp/... mismatch)
path_match() {
[ "$1" = "$2" ] && return 0
[ "$(basename "$1" 2>/dev/null)" = "$(basename "$2" 2>/dev/null)" ] && return 0
return 1
}
# 8a: Claude Code MCP (new path) — correct command
CMD=$(json_get "$FAKE_HOME/.claude.json" "d.get('mcpServers',{}).get('codebase-memory-mcp',{}).get('command','')")
if [ -z "$CMD" ] || ! path_match "$CMD" "$SELF_PATH"; then
echo "DEBUG 8a: file=$FAKE_HOME/.claude.json"
cat "$FAKE_HOME/.claude.json" 2>/dev/null | head -5 || echo "(file not found)"
echo "FAIL 8a: .claude.json command='$CMD', expected '$SELF_PATH'"
exit 1
fi
echo "OK 8a: Claude Code MCP (.claude.json)"
# 8b: Claude Code MCP — existing key preserved (merge not overwrite)
EXISTING=$(json_get "$FAKE_HOME/.claude.json" "d.get('existingKey', False)")
if [ "$EXISTING" != "True" ]; then
echo "FAIL 8b: .claude.json existingKey lost (overwrite instead of merge)"
exit 1
fi
echo "OK 8b: .claude.json preserved existing keys"
# 8c: Claude Code MCP (legacy path)
CMD=$(json_get "$FAKE_HOME/.claude/.mcp.json" "d['mcpServers']['codebase-memory-mcp']['command']")
if ! path_match "$CMD" "$SELF_PATH"; then
echo "FAIL 8c: .claude/.mcp.json command='$CMD'"
exit 1
fi
echo "OK 8c: Claude Code MCP (.claude/.mcp.json)"
# 8d: Claude Code hooks — matcher must be exactly "Grep|Glob|Read" (no Search).
# Read is matched for the indexing-coverage note (#963); safe against the old
# issue-#362 gate hazard because the augmenter is structurally non-blocking
# (always exit 0, additionalContext only). This assertion locks in the exact
# matcher to prevent both regressions (Read dropped again) and creep (Search
# or catch-all matchers sneaking back).
if ! cat "$FAKE_HOME/.claude/settings.json" 2>/dev/null | python3 -c "
import json, sys
d = json.load(sys.stdin)
hooks = d.get('hooks', {}).get('PreToolUse', [])
ok = any(h.get('matcher') == 'Grep|Glob|Read' for h in hooks)
bad = any('Search' in str(h.get('matcher', '')) for h in hooks)
sys.exit(0 if (ok and not bad) else 1)
" 2>/dev/null; then
echo "FAIL 8d: PreToolUse hook matcher is not exactly 'Grep|Glob|Read'"
exit 1
fi
echo "OK 8d: Claude Code PreToolUse hook (matcher=Grep|Glob|Read)"
# 8e: Claude Code shim script — must be non-blocking augmenter, not a gate.
# #929: Windows installs a .cmd script (extensionless bash shims triggered the
# Open-With dialog); the old `!= "MINGW64_NT"` gate never matched the real
# uname (MINGW64_NT-10.0-...), so this check silently ran the POSIX branch on
# Windows. Branch by platform prefix instead.
case "$(uname -s)" in
MINGW*|MSYS*|CYGWIN*)
GATE_SCRIPT="$FAKE_HOME/.claude/hooks/cbm-code-discovery-gate.cmd"
if [ ! -f "$GATE_SCRIPT" ]; then
echo "FAIL 8e: .cmd shim missing on Windows"
exit 1
fi
if [ -f "$FAKE_HOME/.claude/hooks/cbm-code-discovery-gate" ]; then
echo "FAIL 8e: legacy extensionless shim still installed on Windows"
exit 1
fi
;;
*)
GATE_SCRIPT="$FAKE_HOME/.claude/hooks/cbm-code-discovery-gate"
if [ ! -x "$GATE_SCRIPT" ]; then
echo "FAIL 8e: shim script not executable or missing"
exit 1
fi
;;
esac
if grep -q 'exit 2' "$GATE_SCRIPT"; then
echo "FAIL 8e: shim contains 'exit 2' — must never block"
exit 1
fi
if ! grep -q 'hook-augment' "$GATE_SCRIPT"; then
echo "FAIL 8e: shim missing 'hook-augment' delegation"
exit 1
fi
echo "OK 8e: shim installed, non-blocking, delegates to hook-augment"
# 8f-8h: Codex TOML
if ! grep -q '\[mcp_servers.codebase-memory-mcp\]' "$FAKE_HOME/.codex/config.toml"; then
echo "FAIL 8f: Codex TOML missing MCP section"
exit 1
fi
if ! grep -q 'existing_section' "$FAKE_HOME/.codex/config.toml"; then
echo "FAIL 8h: Codex TOML lost existing section (overwrite)"
exit 1
fi
echo "OK 8f-h: Codex TOML (MCP + preserved existing)"
# 8i: Codex instructions
if [ ! -f "$FAKE_HOME/.codex/AGENTS.md" ] || ! grep -q 'codebase-memory-mcp' "$FAKE_HOME/.codex/AGENTS.md"; then
echo "FAIL 8i: Codex AGENTS.md missing"
exit 1
fi
echo "OK 8i: Codex instructions"
# 8j-l: Gemini MCP + hooks + merge
CMD=$(json_get "$FAKE_HOME/.gemini/settings.json" "d['mcpServers']['codebase-memory-mcp']['command']")
if ! path_match "$CMD" "$SELF_PATH"; then
echo "FAIL 8j: Gemini MCP command='$CMD'"
exit 1
fi
EXISTING=$(json_get "$FAKE_HOME/.gemini/settings.json" "d.get('existingKey', False)")
if [ "$EXISTING" != "True" ]; then
echo "FAIL 8k: Gemini settings.json lost existing key"
exit 1
fi
echo "OK 8j-k: Gemini MCP (correct command + preserved existing)"
if ! cat "$FAKE_HOME/.gemini/settings.json" 2>/dev/null | python3 -c "
import json, sys
d = json.load(sys.stdin)
hooks = d.get('hooks', {}).get('BeforeTool', [])
# Matcher must be exactly 'google_search|grep_search' (no read_file). The
# old matcher gated the agent's read tool — consistent with the Claude fix
# we remove it here too.
ok = any(h.get('matcher') == 'google_search|grep_search' for h in hooks)
bad = any('read_file' in str(h.get('matcher', '')) for h in hooks)
sys.exit(0 if (ok and not bad) else 1)
" 2>/dev/null; then
echo "FAIL 8l: Gemini BeforeTool hook matcher must be 'google_search|grep_search' (no read_file)"
exit 1
fi
echo "OK 8l: Gemini BeforeTool hook (matcher=google_search|grep_search)"
# 8m: Gemini instructions
if [ ! -f "$FAKE_HOME/.gemini/GEMINI.md" ]; then
echo "FAIL 8m: Gemini GEMINI.md missing"
exit 1
fi
echo "OK 8m: Gemini instructions"
# 8n: Zed MCP
if [ "$(uname -s)" = "Darwin" ]; then
ZED_CFG="$FAKE_HOME/Library/Application Support/Zed/settings.json"
elif [[ "$BINARY" == *.exe ]]; then
ZED_CFG="$FAKE_HOME/AppData/Local/Zed/settings.json"
else
ZED_CFG="$FAKE_HOME/.config/zed/settings.json"
fi
if [ -f "$ZED_CFG" ]; then
CMD=$(json_get "$ZED_CFG" "d['context_servers']['codebase-memory-mcp']['command']")
if ! path_match "$CMD" "$SELF_PATH"; then
echo "FAIL 8n: Zed command='$CMD'"
exit 1
fi
echo "OK 8n: Zed MCP"
else
echo "SKIP 8n: Zed config not created (detection may have failed)"
fi
# 8o-p: OpenCode MCP + instructions
# OpenCode detection requires binary on PATH — may not be found on Windows
CMD=$(json_get "$FAKE_HOME/.config/opencode/opencode.json" "d['mcp']['codebase-memory-mcp']['command'][0]")
if [ -n "$CMD" ]; then
if ! path_match "$CMD" "$SELF_PATH"; then
echo "FAIL 8o: OpenCode command='$CMD'"
exit 1
fi
echo "OK 8o: OpenCode MCP"
if [ ! -f "$FAKE_HOME/.config/opencode/AGENTS.md" ]; then
echo "FAIL 8p: OpenCode AGENTS.md missing"
exit 1
fi
echo "OK 8p: OpenCode instructions"
else
echo "SKIP 8o-p: OpenCode not detected (binary not on PATH)"
fi
# 8q-r: Antigravity (2026 layout: shared ~/.gemini/config/mcp_config.json,
# instructions under ~/.gemini/antigravity-cli/)
CMD=$(json_get "$FAKE_HOME/.gemini/config/mcp_config.json" "d['mcpServers']['codebase-memory-mcp']['command']")
if ! path_match "$CMD" "$SELF_PATH"; then
echo "FAIL 8q: Antigravity command='$CMD'"
exit 1
fi
echo "OK 8q: Antigravity MCP"
if [ ! -f "$FAKE_HOME/.gemini/antigravity-cli/AGENTS.md" ]; then
echo "FAIL 8r: Antigravity AGENTS.md missing"
exit 1
fi
echo "OK 8r: Antigravity instructions"
# 8s: Aider instructions (detection requires binary on PATH)
if [ -f "$FAKE_HOME/CONVENTIONS.md" ]; then
if ! grep -q 'codebase-memory-mcp' "$FAKE_HOME/CONVENTIONS.md"; then
echo "FAIL 8s: Aider CONVENTIONS.md missing content"
exit 1
fi
echo "OK 8s: Aider instructions"
else
echo "SKIP 8s: Aider not detected (binary not on PATH)"
fi
# 8t: KiloCode MCP
if [ "$(uname -s)" = "Darwin" ]; then
KILO_CFG="$FAKE_HOME/Library/Application Support/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json"
elif [[ "$BINARY" == *.exe ]]; then
KILO_CFG="$FAKE_HOME/AppData/Roaming/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json"
else
KILO_CFG="$FAKE_HOME/.config/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json"
fi
CMD=$(json_get "$KILO_CFG" "d['mcpServers']['codebase-memory-mcp']['command']")
if ! path_match "$CMD" "$SELF_PATH"; then
echo "FAIL 8t: KiloCode command='$CMD'"
exit 1
fi
echo "OK 8t: KiloCode MCP"
# 8u: KiloCode instructions
if [ ! -f "$FAKE_HOME/.kilocode/rules/codebase-memory-mcp.md" ]; then
echo "FAIL 8u: KiloCode rules file missing"
exit 1
fi
echo "OK 8u: KiloCode instructions"
# 8v: VS Code MCP
if [ "$(uname -s)" = "Darwin" ]; then
VSCODE_CFG="$FAKE_HOME/Library/Application Support/Code/User/mcp.json"
elif [[ "$BINARY" == *.exe ]]; then
VSCODE_CFG="$FAKE_HOME/AppData/Roaming/Code/User/mcp.json"
else
VSCODE_CFG="$FAKE_HOME/.config/Code/User/mcp.json"
fi
CMD=$(json_get "$VSCODE_CFG" "d['servers']['codebase-memory-mcp']['command']")
if ! path_match "$CMD" "$SELF_PATH"; then
echo "FAIL 8v: VS Code command='$CMD'"
exit 1
fi
echo "OK 8v: VS Code MCP"
# 8w: OpenClaw MCP
CMD=$(json_get "$FAKE_HOME/.openclaw/openclaw.json" "d['mcp']['servers']['codebase-memory-mcp']['command']")
if ! path_match "$CMD" "$SELF_PATH"; then
echo "FAIL 8w: OpenClaw command='$CMD'"
exit 1
fi
ENABLED=$(json_get "$FAKE_HOME/.openclaw/openclaw.json" "d['mcp']['servers']['codebase-memory-mcp'].get('enabled')")
if [ "$ENABLED" != "True" ]; then
echo "FAIL 8w: OpenClaw enabled='$ENABLED'"
exit 1
fi
echo "OK 8w: OpenClaw MCP"
# 8x: Consolidated skill (old 4-skill dirs cleaned up, replaced by 1)
SKILL_FILE="$FAKE_HOME/.claude/skills/codebase-memory/SKILL.md"
if [ ! -s "$SKILL_FILE" ]; then
echo "FAIL 8x: skill codebase-memory missing or empty"
exit 1
fi
echo "OK 8x: skill installed"
echo ""
echo "=== Phase 9: agent config uninstall E2E ==="
# Run uninstall (same FAKE_HOME with all configs present)
HOME="$FAKE_HOME" \
XDG_CONFIG_HOME="$FAKE_HOME/.config" \
APPDATA="$FAKE_HOME/AppData/Roaming" \
LOCALAPPDATA="$FAKE_HOME/AppData/Local" \
PATH="$FAKE_HOME/.local/bin:$PATH" \
"$BINARY" uninstall -y -n 2>&1 || true
# 9a-b: Claude Code MCP removed but existing keys preserved
if cat "$FAKE_HOME/.claude.json" 2>/dev/null | python3 -c "
import json, sys
d = json.load(sys.stdin)
if 'codebase-memory-mcp' in d.get('mcpServers', {}):
sys.exit(1)
if not d.get('existingKey', False):
sys.exit(2)
sys.exit(0)
" 2>/dev/null; then
echo "OK 9a-b: Claude Code MCP removed, existing keys preserved"
else
echo "FAIL 9a-b: Claude Code uninstall verification failed"
exit 1
fi
# 9c: Legacy MCP removed
if cat "$FAKE_HOME/.claude/.mcp.json" 2>/dev/null | python3 -c "
import json, sys
d = json.load(sys.stdin)
sys.exit(1 if 'codebase-memory-mcp' in d.get('mcpServers', {}) else 0)
" 2>/dev/null; then
echo "OK 9c: legacy .mcp.json cleaned"
else
echo "FAIL 9c: legacy .mcp.json still has entry"
exit 1
fi
# 9d: Hooks removed
if cat "$FAKE_HOME/.claude/settings.json" 2>/dev/null | python3 -c "
import json, sys
d = json.load(sys.stdin)
hooks = d.get('hooks', {}).get('PreToolUse', [])
found = any('cbm-code-discovery-gate' in str(h) for h in hooks)
sys.exit(1 if found else 0)
" 2>/dev/null; then
echo "OK 9d: PreToolUse hook removed"
else
echo "FAIL 9d: PreToolUse hook still present"
exit 1
fi
# 9e-f: Codex TOML cleaned, existing preserved
if grep -q '\[mcp_servers.codebase-memory-mcp\]' "$FAKE_HOME/.codex/config.toml" 2>/dev/null; then
echo "FAIL 9e: Codex TOML still has MCP section"
exit 1
fi
if ! grep -q 'existing_section' "$FAKE_HOME/.codex/config.toml" 2>/dev/null; then
echo "FAIL 9f: Codex TOML lost existing section"
exit 1
fi
echo "OK 9e-f: Codex TOML cleaned, existing preserved"
# 9g-i: Gemini MCP removed, existing preserved, hooks removed
if cat "$FAKE_HOME/.gemini/settings.json" 2>/dev/null | python3 -c "
import json, sys
d = json.load(sys.stdin)
has_mcp = 'codebase-memory-mcp' in d.get('mcpServers', {})
has_existing = d.get('existingKey', False)
hooks = d.get('hooks', {}).get('BeforeTool', [])
has_hook = any('codebase-memory-mcp' in str(h) for h in hooks)
sys.exit(0 if (not has_mcp and has_existing and not has_hook) else 1)
" 2>/dev/null; then
echo "OK 9g-i: Gemini MCP removed, existing preserved, hooks removed"
else
echo "FAIL 9g-i: Gemini uninstall verification failed"
exit 1
fi
# 9j: VS Code
if cat "$VSCODE_CFG" 2>/dev/null | python3 -c "
import json, sys
d = json.load(sys.stdin)
sys.exit(1 if 'codebase-memory-mcp' in d.get('servers', {}) else 0)
" 2>/dev/null; then
echo "OK 9j: VS Code MCP removed"
else
echo "FAIL 9j: VS Code MCP still present"
exit 1
fi
# 9k: OpenClaw
if cat "$FAKE_HOME/.openclaw/openclaw.json" 2>/dev/null | python3 -c "
import json, sys
d = json.load(sys.stdin)
sys.exit(1 if 'codebase-memory-mcp' in d.get('mcp', {}).get('servers', {}) else 0)
" 2>/dev/null; then
echo "OK 9k: OpenClaw MCP removed"
else
echo "FAIL 9k: OpenClaw MCP still present"
exit 1
fi
# 9l: Skills removed (consolidated skill dir)
if [ -d "$FAKE_HOME/.claude/skills/codebase-memory" ]; then
echo "FAIL 9l: skills not removed"
exit 1
fi
echo "OK 9l: skills removed"
echo ""
echo "--- Phase 9b: adversarial install/uninstall tests ---"
# 9b-1: Install with minimal agents (empty HOME, no agent dirs)
# Note: cbm_find_cli searches hardcoded paths (/usr/local/bin, /opt/homebrew/bin)
# so PATH-based agents like aider may still be detected. We verify the install
# completes without crash and prints "Detected agents:" line.
EMPTY_HOME=$(mktemp -d)
mkdir -p "$EMPTY_HOME/.local/bin"
INSTALL_OUT=$(HOME="$EMPTY_HOME" "$BINARY" install -y 2>&1) || true
if ! echo "$INSTALL_OUT" | grep -qi 'detected agents'; then
echo "FAIL 9b-1: install output missing 'Detected agents' line"
exit 1
fi
echo "OK 9b-1: install with minimal agents exits cleanly"
rm -rf "$EMPTY_HOME"
# 9b-2: Install twice (idempotent)
IDEM_HOME=$(mktemp -d)
mkdir -p "$IDEM_HOME/.claude" "$IDEM_HOME/.local/bin"
cp "$BINARY" "$IDEM_HOME/.local/bin/codebase-memory-mcp"
HOME="$IDEM_HOME" "$BINARY" install -y 2>&1 > /dev/null || true
HOME="$IDEM_HOME" "$BINARY" install -y 2>&1 > /dev/null || true
# Count MCP entries — should be exactly 1
COUNT=$(cat "$IDEM_HOME/.claude.json" 2>/dev/null | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(list(d.get('mcpServers',{}).keys()).count('codebase-memory-mcp'))
" 2>/dev/null || echo "0")
if [ "$COUNT" != "1" ]; then
echo "FAIL 9b-2: double install created $COUNT entries (expected 1)"
exit 1
fi
echo "OK 9b-2: double install is idempotent"
rm -rf "$IDEM_HOME"
# 9b-3: Uninstall without prior install
CLEAN_HOME=$(mktemp -d)
mkdir -p "$CLEAN_HOME/.claude" "$CLEAN_HOME/.local/bin"
UNINSTALL_OUT=$(HOME="$CLEAN_HOME" "$BINARY" uninstall -y -n 2>&1) || true
echo "OK 9b-3: uninstall without install doesn't crash"
rm -rf "$CLEAN_HOME"
# 9b-4: Install over corrupt JSON
CORRUPT_HOME=$(mktemp -d)
mkdir -p "$CORRUPT_HOME/.claude" "$CORRUPT_HOME/.local/bin"
cp "$BINARY" "$CORRUPT_HOME/.local/bin/codebase-memory-mcp"
echo '{invalid json here' > "$CORRUPT_HOME/.claude.json"
HOME="$CORRUPT_HOME" "$BINARY" install -y 2>&1 > /dev/null || true
# Should either fix it or handle gracefully — not crash
echo "OK 9b-4: install over corrupt JSON doesn't crash"
rm -rf "$CORRUPT_HOME"
# 9b-8: Double uninstall
DBL_HOME=$(mktemp -d)
mkdir -p "$DBL_HOME/.claude" "$DBL_HOME/.local/bin"
cp "$BINARY" "$DBL_HOME/.local/bin/codebase-memory-mcp"
HOME="$DBL_HOME" "$BINARY" install -y 2>&1 > /dev/null || true
HOME="$DBL_HOME" "$BINARY" uninstall -y -n 2>&1 > /dev/null || true
HOME="$DBL_HOME" "$BINARY" uninstall -y -n 2>&1 > /dev/null || true
echo "OK 9b-8: double uninstall doesn't crash"
rm -rf "$DBL_HOME"
# 9b-9: Non-interactive update without --standard/--ui should fail cleanly (not hang)
if [ "$(uname -s)" != "MINGW64_NT" ] 2>/dev/null; then
NONINT_OUT=$(echo "" | "$BINARY" update --dry-run 2>&1) || true
if echo "$NONINT_OUT" | grep -qi 'terminal\|requires.*flag\|error'; then
echo "OK 9b-9: non-interactive update fails with clear error"
else
# Dry-run may still complete if no variant prompt needed
echo "OK 9b-9: non-interactive update handled gracefully"
fi
fi
rm -rf "$FAKE_HOME" "$EMPTY_HOME"
echo ""
echo "=== Phase 10: binary security E2E ==="
SECURITY_DIR=$(mktemp -d)
SECURITY_BIN="$SECURITY_DIR/codebase-memory-mcp"
cp "$BINARY" "$SECURITY_BIN"
chmod 755 "$SECURITY_BIN"
if [ "$(uname -s)" = "Darwin" ]; then
# macOS signing tests
if codesign -v "$SECURITY_BIN" 2>/dev/null; then
echo "OK 10a: binary has valid signature"
else
echo "FAIL 10a: binary has no valid signature (linker should auto-sign arm64)"
exit 1
fi
# Detect binary architecture (not shell arch — Rosetta reports x86_64 for arm64 binaries)
BIN_ARCH=$(file "$SECURITY_BIN" | grep -o 'arm64\|x86_64' | head -1)
if [ "$BIN_ARCH" = "arm64" ]; then
# arm64 integrity check: tampering the signed code must be DETECTED by the code signature.
# The original check (run a tampered/unsigned binary and expect SIGKILL 137) is NOT deterministic
# on current macOS for an ad-hoc-signed CLI binary, which is why this test went flaky then red.
# Observed on CI:
# - `codesign --remove-signature` then run -> macOS 11+ ad-hoc re-signs on exec and RUNS (exit 0)
# - garbling only the signature blob then run -> same, re-signed/ignored (exit 0)
# - tampering the code then run -> with no CS_KILL/hardened-runtime flag the kernel does NOT
# kill the page; it executes the garbage and crashes with SIGILL (exit 132), not 137
# (runs 28350650225 / 28354735368 / 28360363173 / 28365724001). The deterministic, meaningful
# invariant is that `codesign --verify` REJECTS a tampered binary -- the CodeDirectory page hashes
# no longer match the modified code -- while the untampered binary verifies cleanly (10a above).
# This is a pure userspace hash check (no tampered code is ever executed). Done on a SEPARATE copy
# so the original $SECURITY_BIN stays intact for the 10e re-sign test.
# Refs: github.com/garrytan/gstack#997, github.com/nodejs/node#40827.
TAMPER_BIN="${SECURITY_BIN}.tampered"
cp "$SECURITY_BIN" "$TAMPER_BIN"
# Tamper signed code: zero the entry-point instructions (LC_MAIN entryoff = start of __text) plus
# a span of early __text, leaving the Mach-O header + load commands + signature blob intact so the
# file still parses and still carries its now-stale signature for --verify to reject.
ENTRY_OFF=$(otool -l "$SECURITY_BIN" 2>/dev/null | awk '/LC_MAIN/{f=1} f&&/entryoff/{print $2; exit}')
ENTRY_OFF=${ENTRY_OFF:-2184}
dd if=/dev/zero of="$TAMPER_BIN" bs=1 seek="$ENTRY_OFF" count=4096 conv=notrunc 2>/dev/null
dd if=/dev/zero of="$TAMPER_BIN" bs=4096 seek=4 count=512 conv=notrunc 2>/dev/null
if codesign --verify "$TAMPER_BIN" 2>/dev/null; then
echo "FAIL 10c: codesign --verify ACCEPTED a tampered arm64 binary (integrity check broken)"
rm -f "$TAMPER_BIN"
exit 1
else
echo "OK 10c: codesign --verify rejected tampered arm64 binary"
fi
rm -f "$TAMPER_BIN"
else
# x86_64: signing is not enforced; an unsigned binary should still run
codesign --remove-signature "$SECURITY_BIN" 2>/dev/null || true
if "$SECURITY_BIN" --version > /dev/null 2>&1; then
echo "OK 10c: unsigned x86_64 binary runs (no signing required)"
else
echo "FAIL 10c: unsigned x86_64 binary failed"
exit 1
fi
fi
# Re-sign and verify
xattr -d com.apple.quarantine "$SECURITY_BIN" 2>/dev/null || true
codesign --sign - --force "$SECURITY_BIN" 2>/dev/null
if "$SECURITY_BIN" --version > /dev/null 2>&1; then
echo "OK 10e: re-signed binary runs"
else
echo "FAIL 10e: re-signed binary failed"
exit 1
fi
else
# Linux/Windows: unsigned binary should run fine
if "$SECURITY_BIN" --version > /dev/null 2>&1; then
echo "OK 10a: binary runs without signing ($(uname -s))"
else
echo "FAIL 10a: binary failed to run on $(uname -s)"
exit 1
fi
# chmod +x is sufficient
chmod -x "$SECURITY_BIN"
chmod +x "$SECURITY_BIN"
if "$SECURITY_BIN" --version > /dev/null 2>&1; then
echo "OK 10c: chmod +x is sufficient"
else
echo "FAIL 10c: chmod +x didn't restore executability"
exit 1
fi
fi
rm -rf "$SECURITY_DIR"
echo ""
echo "=== Phase 11: process kill E2E ==="
# Start MCP server in background
MCP_KILL_INPUT=$(mktemp)
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"kill-test","version":"1.0"}}}' > "$MCP_KILL_INPUT"
"$BINARY" < "$MCP_KILL_INPUT" > /dev/null 2>&1 &
KILL_PID=$!
sleep 1
if kill -0 "$KILL_PID" 2>/dev/null; then
echo "OK 11a-b: MCP server running (pid=$KILL_PID)"
kill "$KILL_PID" 2>/dev/null || true
wait "$KILL_PID" 2>/dev/null || true
sleep 1
if kill -0 "$KILL_PID" 2>/dev/null; then
echo "FAIL 11d: process still running after kill"
exit 1
fi
echo "OK 11c-d: process killed successfully"
else
echo "OK 11: MCP server already exited (clean shutdown on EOF)"
fi
rm -f "$MCP_KILL_INPUT"
echo ""
echo "=== Phase 14: update + uninstall E2E ==="
if [ -n "${SMOKE_DOWNLOAD_URL:-}" ]; then
# ── 14a-f: Real update command against local HTTP server ──
UPDATE_HOME=$(mktemp -d)
mkdir -p "$UPDATE_HOME/.claude" "$UPDATE_HOME/.local/bin"
if [[ "$BINARY" == *.exe ]]; then
cp "$BINARY" "$UPDATE_HOME/.local/bin/codebase-memory-mcp.exe"
chmod 755 "$UPDATE_HOME/.local/bin/codebase-memory-mcp.exe"
else
cp "$BINARY" "$UPDATE_HOME/.local/bin/codebase-memory-mcp"
chmod 755 "$UPDATE_HOME/.local/bin/codebase-memory-mcp"
if [ "$(uname -s)" = "Darwin" ]; then
codesign --sign - --force "$UPDATE_HOME/.local/bin/codebase-memory-mcp" 2>/dev/null || true
fi
fi
# Pre-install agent config with a WRONG binary path (simulates stale config)
echo '{"mcpServers":{"codebase-memory-mcp":{"command":"/old/stale/path"}}}' > "$UPDATE_HOME/.claude.json"
# 14a: Run actual update command (detect variant from available archive)
UPDATE_VARIANT="--standard"
if curl -sf "$SMOKE_DOWNLOAD_URL/" 2>/dev/null | grep -q "ui-"; then
UPDATE_VARIANT="--ui"
fi
HOME="$UPDATE_HOME" CBM_DOWNLOAD_URL="$SMOKE_DOWNLOAD_URL" \
"$BINARY" update $UPDATE_VARIANT -y 2>&1 || true
# 14b: Verify new binary exists and runs
if [ ! -f "$UPDATE_HOME/.local/bin/codebase-memory-mcp" ]; then
echo "FAIL 14b: binary missing after update"
exit 1
fi
UPD_BIN="$UPDATE_HOME/.local/bin/codebase-memory-mcp"
if [ "$(uname -s)" = "Darwin" ]; then
codesign --sign - --force "$UPD_BIN" 2>/dev/null || true
fi
if ! "$UPD_BIN" --version > /dev/null 2>&1; then
echo "FAIL 14b: updated binary doesn't run"
exit 1
fi
echo "OK 14b: updated binary runs"
# 14c: Verify agent config was refreshed (stale path replaced)
UPD_CMD=$(cat "$UPDATE_HOME/.claude.json" 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('mcpServers',{}).get('codebase-memory-mcp',{}).get('command',''))" 2>/dev/null || echo "")
if [ "$UPD_CMD" = "/old/stale/path" ]; then
echo "FAIL 14c: agent config still has stale path after update"
exit 1
fi
if [ -n "$UPD_CMD" ]; then
echo "OK 14c: agent config refreshed (path=$UPD_CMD)"
else
echo "OK 14c: agent config refreshed (no stale path)"
fi
# ── 14d-f: Real uninstall with binary removal ──
# First verify binary + configs exist
if [ ! -f "$UPDATE_HOME/.local/bin/codebase-memory-mcp" ]; then
echo "FAIL 14d: binary should exist before uninstall"
exit 1
fi
# Run actual uninstall
HOME="$UPDATE_HOME" "$BINARY" uninstall -y 2>&1 || true
# 14e: Verify binary removed
if [ -f "$UPDATE_HOME/.local/bin/codebase-memory-mcp" ] || [ -f "$UPDATE_HOME/.local/bin/codebase-memory-mcp.exe" ]; then
echo "FAIL 14e: binary still exists after uninstall"
exit 1
fi
echo "OK 14e: binary removed by uninstall"
# 14f: Verify agent config cleaned
if cat "$UPDATE_HOME/.claude.json" 2>/dev/null | python3 -c "
import json, sys
d = json.load(sys.stdin)
if 'codebase-memory-mcp' in d.get('mcpServers', {}): sys.exit(1)
sys.exit(0)
" 2>/dev/null; then
echo "OK 14f: agent config removed by uninstall"
else
echo "FAIL 14f: agent config still present after uninstall"
exit 1
fi
rm -rf "$UPDATE_HOME"
else
# Local mode: basic binary replacement test (no download)
UPDATE_DIR=$(mktemp -d)
mkdir -p "$UPDATE_DIR/install"
cp "$BINARY" "$UPDATE_DIR/install/codebase-memory-mcp"
chmod 755 "$UPDATE_DIR/install/codebase-memory-mcp"
cp "$BINARY" "$UPDATE_DIR/smoke-downloaded"
rm -f "$UPDATE_DIR/install/codebase-memory-mcp"
cp "$UPDATE_DIR/smoke-downloaded" "$UPDATE_DIR/install/codebase-memory-mcp"
chmod 755 "$UPDATE_DIR/install/codebase-memory-mcp"
if [ "$(uname -s)" = "Darwin" ]; then
codesign --sign - --force "$UPDATE_DIR/install/codebase-memory-mcp" 2>/dev/null || true
fi
if ! "$UPDATE_DIR/install/codebase-memory-mcp" --version > /dev/null 2>&1; then
echo "FAIL 14: binary replacement failed"
exit 1
fi
echo "OK 14: binary replacement + verify (local mode)"
rm -rf "$UPDATE_DIR"
fi
# ── Phase 12 + 13: Download E2E + install script E2E (CI only) ──
# These phases require SMOKE_DOWNLOAD_URL to be set (local HTTP server in CI).
# When unset, they are skipped (local development runs).
if [ -n "${SMOKE_DOWNLOAD_URL:-}" ]; then
echo ""
echo "=== Phase 12: download + checksum + extraction E2E ==="
DL_DIR=$(mktemp -d)
# Detect platform for archive name
DL_OS=$(uname -s | tr 'A-Z' 'a-z')
# Normalize MSYS2/MinGW to "windows"
case "$DL_OS" in
mingw*|msys*) DL_OS="windows" ;;
esac
# Prefer a CI-provided SMOKE_ARCH over `uname -m`: on windows-11-arm the base
# MSYS2 `uname` is an emulated x86_64 binary that reports "x86_64", so uname would
# request the amd64 archive and 404. The smoke workflow passes the true matrix
# arch (arm64/amd64). Fall back to uname when SMOKE_ARCH is unset (local runs).
if [ -n "${SMOKE_ARCH:-}" ]; then
DL_ARCH="$SMOKE_ARCH"
else
DL_ARCH=$(uname -m)
case "$DL_ARCH" in
aarch64) DL_ARCH="arm64" ;;
x86_64)
# Rosetta detection
if [ "$DL_OS" = "darwin" ] && sysctl -n machdep.cpu.brand_string 2>/dev/null | grep -qi apple; then
DL_ARCH="arm64"
else
DL_ARCH="amd64"
fi
;;
esac
fi
if [ "$DL_OS" = "darwin" ] || [ "$DL_OS" = "linux" ]; then
DL_EXT="tar.gz"
else
DL_EXT="zip"
fi
# Try standard name first, fall back to UI variant
DL_ARCHIVE="codebase-memory-mcp-${DL_OS}-${DL_ARCH}.${DL_EXT}"
DL_ARCHIVE_UI="codebase-memory-mcp-ui-${DL_OS}-${DL_ARCH}.${DL_EXT}"
# 12a: curl download (try standard, then UI variant)
echo "--- Phase 12a: curl download ---"
# --noproxy '*': never route the local test server through a proxy — a proxy env
# var present on some runners (notably windows-11-arm) made curl fail to reach
# 127.0.0.1 while the app's own downloader (WinHTTP) bypassed it. On failure,
# surface curl's stderr instead of swallowing it so the reason is visible.
if ! curl -fSL --noproxy '*' -o "$DL_DIR/$DL_ARCHIVE" "$SMOKE_DOWNLOAD_URL/$DL_ARCHIVE" 2>/tmp/cbm-curl12a.err; then
# Try UI variant
if curl -fSL --noproxy '*' -o "$DL_DIR/$DL_ARCHIVE_UI" "$SMOKE_DOWNLOAD_URL/$DL_ARCHIVE_UI" 2>>/tmp/cbm-curl12a.err; then
DL_ARCHIVE="$DL_ARCHIVE_UI"
else
echo "FAIL 12a: curl download failed (tried standard and ui variants)"
echo "--- curl stderr (url: $SMOKE_DOWNLOAD_URL/$DL_ARCHIVE) ---"
cat /tmp/cbm-curl12a.err 2>/dev/null || true
exit 1
fi
fi
if [ ! -s "$DL_DIR/$DL_ARCHIVE" ]; then
echo "FAIL 12a: downloaded archive is empty"
exit 1
fi
echo "OK 12a: archive downloaded ($(wc -c < "$DL_DIR/$DL_ARCHIVE") bytes)"
# 12b: checksum download
echo "--- Phase 12b: checksum verification ---"
if ! curl -fsSL -o "$DL_DIR/checksums.txt" "$SMOKE_DOWNLOAD_URL/checksums.txt"; then
echo "FAIL 12b: checksums.txt download failed"
exit 1
fi
# 12c: verify checksum
EXPECTED=$(grep "$DL_ARCHIVE" "$DL_DIR/checksums.txt" | awk '{print $1}')
if [ -z "$EXPECTED" ]; then
echo "FAIL 12c: archive not found in checksums.txt"
exit 1
fi
if command -v sha256sum &>/dev/null; then
ACTUAL=$(sha256sum "$DL_DIR/$DL_ARCHIVE" | awk '{print $1}')
elif command -v shasum &>/dev/null; then
ACTUAL=$(shasum -a 256 "$DL_DIR/$DL_ARCHIVE" | awk '{print $1}')
else
echo "FAIL 12c: no sha256 tool available"
exit 1
fi
if [ "$EXPECTED" != "$ACTUAL" ]; then
echo "FAIL 12c: checksum mismatch (expected=$EXPECTED actual=$ACTUAL)"
exit 1
fi
echo "OK 12c: checksum verified"
# 12d: extract binary
echo "--- Phase 12d: extraction ---"
(cd "$DL_DIR" && if [ "$DL_EXT" = "zip" ]; then unzip -q "$DL_ARCHIVE"; else tar -xzf "$DL_ARCHIVE"; fi)
DL_BIN="$DL_DIR/codebase-memory-mcp"
if [ ! -f "$DL_BIN" ]; then
echo "FAIL 12d: binary not found after extraction"
exit 1
fi
chmod +x "$DL_BIN"
echo "OK 12d: binary extracted"
# 12e: extracted binary runs
if ! "$DL_BIN" --version > /dev/null 2>&1; then
# On macOS arm64, may need signing
if [ "$DL_OS" = "darwin" ]; then
xattr -d com.apple.quarantine "$DL_BIN" 2>/dev/null || true
codesign --sign - --force "$DL_BIN" 2>/dev/null || true
if ! "$DL_BIN" --version > /dev/null 2>&1; then
echo "FAIL 12e: extracted binary doesn't run even after signing"
exit 1
fi
else
echo "FAIL 12e: extracted binary doesn't run"
exit 1
fi
fi
echo "OK 12e: extracted binary runs"
# 12f: platform-specific post-extraction verification
if [ "$DL_OS" = "darwin" ]; then
if codesign -v "$DL_BIN" 2>/dev/null; then
echo "OK 12f: macOS binary has valid signature (CI pre-signed)"
else
echo "OK 12f: macOS binary signed locally (CI pre-sign not yet active)"
fi
else
echo "OK 12f: binary runs without signing ($DL_OS)"
fi
rm -rf "$DL_DIR"
echo ""
echo "=== Phase 13: install script E2E ==="
if [ "$DL_OS" != "windows" ] && [ -f "$REPO_ROOT/install.sh" ]; then
echo "--- Phase 13: install.sh E2E ---"
INSTALL_TEST_HOME=$(mktemp -d)
INSTALL_TEST_DIR=$(mktemp -d)
mkdir -p "$INSTALL_TEST_HOME/.claude"
mkdir -p "$INSTALL_TEST_HOME/.local/bin"
# 13a: run install.sh with local URL + isolated HOME
HOME="$INSTALL_TEST_HOME" CBM_DOWNLOAD_URL="$SMOKE_DOWNLOAD_URL" \
"$REPO_ROOT/install.sh" --dir="$INSTALL_TEST_DIR" 2>&1 || true
# 13b: binary placed
if [ ! -f "$INSTALL_TEST_DIR/codebase-memory-mcp" ]; then
echo "FAIL 13b: binary not placed by install.sh"
exit 1
fi
echo "OK 13b: binary placed"
# 13c: binary runs
# Sign if needed on macOS
if [ "$DL_OS" = "darwin" ]; then
codesign --sign - --force "$INSTALL_TEST_DIR/codebase-memory-mcp" 2>/dev/null || true
fi
if ! "$INSTALL_TEST_DIR/codebase-memory-mcp" --version > /dev/null 2>&1; then
echo "FAIL 13c: installed binary doesn't run"
exit 1
fi
echo "OK 13c: binary runs"
# 13d: macOS signature check
if [ "$DL_OS" = "darwin" ]; then
if codesign -v "$INSTALL_TEST_DIR/codebase-memory-mcp" 2>/dev/null; then
echo "OK 13d: macOS binary signed"
else
echo "FAIL 13d: macOS binary not signed after install.sh"
exit 1
fi
else
echo "OK 13d: no signing needed ($DL_OS)"
fi
# 13e: agent configs created (at least Claude Code since we made ~/.claude)
if [ -f "$INSTALL_TEST_HOME/.claude.json" ] && grep -q 'codebase-memory-mcp' "$INSTALL_TEST_HOME/.claude.json" 2>/dev/null; then
echo "OK 13e: agent configs created by install.sh"
else
echo "FAIL 13e: install.sh did not create agent configs"
exit 1
fi
# 13f: PATH setup — verify shell rc file was modified
RC_FILE=""
if [ -f "$INSTALL_TEST_HOME/.zshrc" ]; then RC_FILE="$INSTALL_TEST_HOME/.zshrc"; fi
if [ -f "$INSTALL_TEST_HOME/.bashrc" ]; then RC_FILE="$INSTALL_TEST_HOME/.bashrc"; fi
if [ -f "$INSTALL_TEST_HOME/.profile" ]; then RC_FILE="$INSTALL_TEST_HOME/.profile"; fi
if [ -n "$RC_FILE" ] && grep -q '.local/bin' "$RC_FILE" 2>/dev/null; then
echo "OK 13f: PATH added to shell rc file"
elif echo "$PATH" | grep -q "$INSTALL_TEST_DIR"; then
echo "OK 13f: install dir already on PATH"
else
echo "OK 13f: PATH setup (rc file may not have been modified if already present)"
fi
rm -rf "$INSTALL_TEST_HOME" "$INSTALL_TEST_DIR"
elif [ -f "$REPO_ROOT/install.ps1" ] && command -v powershell.exe &>/dev/null; then
echo "--- Phase 13: install.ps1 E2E (Windows) ---"
PS1_TEST_HOME=$(mktemp -d)
PS1_TEST_DIR=$(mktemp -d)
mkdir -p "$PS1_TEST_HOME/.claude"
# Convert MSYS paths to Windows paths for PowerShell
if command -v cygpath &>/dev/null; then
WIN_DIR=$(cygpath -w "$PS1_TEST_DIR")
WIN_URL="$SMOKE_DOWNLOAD_URL"
WIN_SCRIPT=$(cygpath -w "$REPO_ROOT/install.ps1")
WIN_HOME=$(cygpath -w "$PS1_TEST_HOME")
else
WIN_DIR="$PS1_TEST_DIR"
WIN_URL="$SMOKE_DOWNLOAD_URL"
WIN_SCRIPT="$REPO_ROOT/install.ps1"
WIN_HOME="$PS1_TEST_HOME"
fi
# 13f: run install.ps1
# Pass the known-correct arch: powershell runs under x64 emulation on ARM64, so
# install.ps1's own detection can't tell it's arm64. DL_ARCH is authoritative here.
HOME="$PS1_TEST_HOME" CBM_DOWNLOAD_URL="$WIN_URL" CBM_ARCH="$DL_ARCH" \
powershell.exe -ExecutionPolicy ByPass -File "$WIN_SCRIPT" "--dir=$WIN_DIR" 2>&1 || true
# 13g: binary placed
PS1_BIN="$PS1_TEST_DIR/codebase-memory-mcp.exe"
if [ ! -f "$PS1_BIN" ] && [ -f "$PS1_TEST_DIR/codebase-memory-mcp" ]; then
PS1_BIN="$PS1_TEST_DIR/codebase-memory-mcp"
fi
if [ -f "$PS1_BIN" ]; then
echo "OK 13g: binary placed by install.ps1"
else
echo "FAIL 13g: binary not placed by install.ps1"
exit 1
fi
# 13h: binary runs
if "$PS1_BIN" --version > /dev/null 2>&1; then
echo "OK 13h: binary runs"
else
echo "FAIL 13h: installed binary doesn't run"
exit 1
fi
rm -rf "$PS1_TEST_HOME" "$PS1_TEST_DIR"
else
echo "SKIP Phase 13: no install script available for this platform"
fi
else
echo ""
echo "=== Phase 12-13: SKIPPED (SMOKE_DOWNLOAD_URL not set) ==="
fi
# ── Phase 15: UI HTTP server reachability ──
# Only runs if the binary was built with embedded UI assets.
echo ""
echo "=== Phase 15: UI HTTP server ==="
UI_PORT=19876
UI_INPUT=$(mktemp)
"$BINARY" --port "$UI_PORT" < "$UI_INPUT" > /dev/null 2>&1 &
UI_PID=$!
sleep 1
if kill -0 "$UI_PID" 2>/dev/null; then
# 15a: GET / returns 200 with HTML content
UI_BODY=$(curl -sf "http://127.0.0.1:$UI_PORT/" 2>/dev/null || echo "")
if echo "$UI_BODY" | grep -qi "<html"; then
echo "OK 15a: UI serves HTML at /"
elif [ -z "$UI_BODY" ]; then
echo "SKIP 15a: UI not reachable (binary may not have embedded assets)"
else
echo "FAIL 15a: UI root did not return HTML"
kill "$UI_PID" 2>/dev/null || true
exit 1
fi
# 15b: POST /rpc accepts JSON-RPC and returns JSON
RPC_BODY=$(curl -sf -X POST \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
"http://127.0.0.1:$UI_PORT/rpc" 2>/dev/null || echo "")
if echo "$RPC_BODY" | grep -q "jsonrpc"; then
echo "OK 15b: /rpc returns JSON-RPC response"
elif [ -z "$RPC_BODY" ]; then
echo "SKIP 15b: /rpc not reachable"
else
echo "FAIL 15b: /rpc did not return JSON-RPC"
fi
kill "$UI_PID" 2>/dev/null || true
wait "$UI_PID" 2>/dev/null || true
else
echo "SKIP Phase 15: binary exited immediately (no UI assets embedded)"
fi
rm -f "$UI_INPUT"
echo ""
echo "=== Phase 16: stdio server leaves no orphan after shutdown ==="
# Regression guard for the orphaned-server failure mode behind #406: a stdio MCP
# server must TERMINATE (not linger as a background process) once its stdin is
# closed. The shutdown trigger is a closed stdin (`< /dev/null`): the server sees
# an immediate, regular EOF on its read loop and exits.
#
# Why not a FIFO writer-close (the previous mechanism)? Closing a FIFO's last
# writer surfaces as POLLHUP rather than a clean POLLIN+EOF; the server's
# poll()-based read loop did not treat that as shutdown, so the FIFO probe left
# the process alive and Phase 16 failed in CI on every platform. A plain
# `< /dev/null` EOF is the simplest reliable trigger and is fully portable
# (POSIX shells and MSYS2 bash alike), so no OS gate is needed here.
"$BINARY" < /dev/null > /dev/null 2>&1 &
SHUT_SRV_PID=$!
SHUT_GONE=0
for _ in $(seq 1 60); do # bounded ~6s wait (60 × 0.1s)
if ! kill -0 "$SHUT_SRV_PID" 2>/dev/null; then SHUT_GONE=1; break; fi
sleep 0.1
done
if [ "$SHUT_GONE" -ne 1 ]; then
echo "FAIL 16: stdio server still running after stdin closed (orphan process)"
kill -9 "$SHUT_SRV_PID" 2>/dev/null || true
wait "$SHUT_SRV_PID" 2>/dev/null || true
exit 1
fi
wait "$SHUT_SRV_PID" 2>/dev/null || true
echo "OK 16: stdio server terminated after stdin closed, no orphan"
echo ""
echo "=== smoke-test: ALL PASSED ==="