chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
"""Minimal MCP stdio client for the Windows red-test suite.
|
||||
|
||||
Drives a real codebase-memory-mcp(.exe) over a line-delimited JSON-RPC stdio
|
||||
pipe. The pipe carries UTF-8 bytes, so a non-ASCII repo_path reaches the server
|
||||
without passing through the Windows ANSI command-line code page (which mangles
|
||||
argv for a binary whose main() is not wmain/GetCommandLineW). This isolates the
|
||||
server's real path handling from CLI-argv encoding artifacts.
|
||||
|
||||
No third-party dependencies — standard library only.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
class McpError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class McpServer:
|
||||
def __init__(self, binary, cache_dir=None, extra_env=None, cwd=None):
|
||||
self.binary = binary
|
||||
self._id = 0
|
||||
self.proc = None
|
||||
self._stderr = []
|
||||
env = dict(os.environ)
|
||||
if cache_dir:
|
||||
env["CBM_CACHE_DIR"] = cache_dir # isolate the graph DB location
|
||||
if extra_env:
|
||||
env.update(extra_env)
|
||||
self.env = env
|
||||
self.cwd = cwd
|
||||
|
||||
def __enter__(self):
|
||||
self.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
self.close()
|
||||
|
||||
def start(self):
|
||||
self.proc = subprocess.Popen(
|
||||
[self.binary],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
env=self.env, cwd=self.cwd, bufsize=0)
|
||||
threading.Thread(target=self._drain_stderr, daemon=True).start()
|
||||
|
||||
def _drain_stderr(self):
|
||||
try:
|
||||
for line in self.proc.stderr:
|
||||
self._stderr.append(line.decode("utf-8", "replace"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def stderr_text(self):
|
||||
return "".join(self._stderr)
|
||||
|
||||
def _send(self, obj):
|
||||
data = json.dumps(obj, ensure_ascii=False).encode("utf-8")
|
||||
self.proc.stdin.write(data + b"\n")
|
||||
self.proc.stdin.flush()
|
||||
|
||||
def _read_message(self, timeout=60):
|
||||
result = {}
|
||||
|
||||
def reader():
|
||||
try:
|
||||
result["line"] = self.proc.stdout.readline()
|
||||
except Exception as ex:
|
||||
result["exc"] = ex
|
||||
|
||||
th = threading.Thread(target=reader, daemon=True)
|
||||
th.start()
|
||||
th.join(timeout)
|
||||
if th.is_alive():
|
||||
raise McpError("timeout after %ss (hang)" % timeout)
|
||||
if "exc" in result:
|
||||
raise McpError("read error: %r" % result["exc"])
|
||||
line = result.get("line", b"")
|
||||
if not line:
|
||||
raise McpError("EOF / server closed stdout")
|
||||
# strict: an invalid-UTF-8 JSON-RPC response is itself a failure.
|
||||
return json.loads(line.decode("utf-8", "strict"))
|
||||
|
||||
def request(self, method, params=None, timeout=60):
|
||||
self._id += 1
|
||||
rid = self._id
|
||||
self._send({"jsonrpc": "2.0", "id": rid, "method": method,
|
||||
"params": params or {}})
|
||||
deadline = time.time() + timeout
|
||||
while True:
|
||||
msg = self._read_message(timeout=max(1, deadline - time.time()))
|
||||
if msg.get("id") == rid:
|
||||
return msg
|
||||
if time.time() > deadline:
|
||||
raise McpError("timeout waiting for id=%d" % rid)
|
||||
|
||||
def notify(self, method, params=None):
|
||||
self._send({"jsonrpc": "2.0", "method": method, "params": params or {}})
|
||||
|
||||
def initialize(self, timeout=60):
|
||||
resp = self.request("initialize", {
|
||||
"protocolVersion": "2024-11-05", "capabilities": {},
|
||||
"clientInfo": {"name": "windows-red-test", "version": "1.0"}}, timeout)
|
||||
if "error" in resp:
|
||||
raise McpError("initialize error: %r" % resp["error"])
|
||||
try:
|
||||
self.notify("notifications/initialized")
|
||||
except Exception:
|
||||
pass
|
||||
return resp
|
||||
|
||||
def tools_list(self, timeout=60):
|
||||
resp = self.request("tools/list", {}, timeout=timeout)
|
||||
if "error" in resp:
|
||||
raise McpError("tools/list error: %r" % resp["error"])
|
||||
return resp["result"]["tools"]
|
||||
|
||||
def call_tool(self, name, arguments, timeout=180):
|
||||
return self.request("tools/call",
|
||||
{"name": name, "arguments": arguments}, timeout=timeout)
|
||||
|
||||
@staticmethod
|
||||
def tool_text(resp):
|
||||
if "error" in resp:
|
||||
return None, resp["error"]
|
||||
parts = [c.get("text", "") for c in resp.get("result", {}).get("content", [])
|
||||
if c.get("type") == "text"]
|
||||
return "".join(parts), None
|
||||
|
||||
def close(self):
|
||||
if not self.proc:
|
||||
return
|
||||
try:
|
||||
self.proc.stdin.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.proc.wait(timeout=10)
|
||||
except Exception:
|
||||
try:
|
||||
self.proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,135 @@
|
||||
"""GREEN regression guard — `cli index_repository` honors a non-ASCII repo_path.
|
||||
|
||||
Guards the CLI-argv fix for issue #636 / #423 / #20 on native Windows.
|
||||
|
||||
The documented entrypoint `codebase-memory-mcp cli index_repository '<json>'`
|
||||
receives its JSON argument through argv. main() used to take only the narrow
|
||||
`int main(int argc, char **argv)` (src/main.c), so on Windows the C runtime handed
|
||||
it argv in the active ANSI code page: a repo_path containing non-ASCII characters
|
||||
was mangled (or, when yyjson rejected the now-invalid UTF-8, the whole argument was
|
||||
discarded), and the command failed with "repo_path is required" / "Pipeline failed"
|
||||
instead of indexing the real directory.
|
||||
|
||||
Fixed: on Windows main() now rebuilds argv from the wide command line
|
||||
(GetCommandLineW + CommandLineToArgvW) and converts each element to UTF-8, so a
|
||||
non-ASCII repo path survives. This test asserts that fix stays in place — it was RED
|
||||
before it and is GREEN after. (It is inherently green on Linux/macOS, where argv is
|
||||
already UTF-8 bytes.)
|
||||
|
||||
The directory itself is created with the Windows wide API (Python uses
|
||||
CreateFileW/_wmkdir under the hood), so it genuinely exists on disk; only the
|
||||
argv path delivery was lossy.
|
||||
|
||||
Exit code: 0 == honored (green), 1 == rejected/mangled (red), 2 == setup error.
|
||||
|
||||
Usage:
|
||||
python test_cli_non_ascii_arg.py <path-to-codebase-memory-mcp[.exe]>
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
MATH_TS = (
|
||||
"export function add(a: number, b: number): number { return a + b; }\n"
|
||||
"export class Calc { total = 0; push(x: number): void { this.total = "
|
||||
"add(this.total, x); } }\n"
|
||||
)
|
||||
|
||||
|
||||
def make_fixture(root):
|
||||
src = os.path.join(root, "src")
|
||||
os.makedirs(src, exist_ok=True)
|
||||
with open(os.path.join(src, "math.ts"), "wb") as f:
|
||||
f.write(MATH_TS.encode("utf-8"))
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("usage: python test_cli_non_ascii_arg.py <binary>")
|
||||
return 2
|
||||
binary = os.path.abspath(sys.argv[1])
|
||||
if not os.path.exists(binary):
|
||||
print("FAIL: binary not found: %s" % binary)
|
||||
return 2
|
||||
|
||||
work = tempfile.mkdtemp(prefix="cbm_win_cliarg_")
|
||||
try:
|
||||
# Non-ASCII repo directory (created via the OS wide API → really exists).
|
||||
repo = os.path.join(work, "café_日本語_repo")
|
||||
make_fixture(repo)
|
||||
cache = os.path.join(work, "cache")
|
||||
os.makedirs(cache, exist_ok=True)
|
||||
|
||||
# Sanity: an ASCII control path must index through the CLI, proving the
|
||||
# CLI path itself works and isolating the failure to argv encoding.
|
||||
ascii_repo = os.path.join(work, "ascii_repo")
|
||||
make_fixture(ascii_repo)
|
||||
env = dict(os.environ)
|
||||
env["CBM_CACHE_DIR"] = os.path.join(work, "cache_ascii")
|
||||
ctrl = subprocess.run(
|
||||
[binary, "cli", "index_repository",
|
||||
json.dumps({"repo_path": ascii_repo})],
|
||||
capture_output=True, timeout=120, env=env)
|
||||
ctrl_out = (ctrl.stdout or b"").decode("utf-8", "replace")
|
||||
if '"nodes"' not in ctrl_out:
|
||||
print("SETUP FAIL: ASCII control did not index via CLI:\n%s" %
|
||||
ctrl_out[:300])
|
||||
return 2
|
||||
|
||||
env2 = dict(os.environ)
|
||||
env2["CBM_CACHE_DIR"] = cache
|
||||
# Exercise the DEFAULT (supervisor-enabled) path, not in-process. The non-ASCII
|
||||
# repo path must survive BOTH the argv read (main() wide command line) AND the
|
||||
# supervisor -> worker spawn (CreateProcessW). The suite runner sets
|
||||
# CBM_INDEX_SUPERVISOR=0 for determinism across the other guards; forcing it OFF
|
||||
# here would run in-process and mask the spawn-boundary half of #423/#20, so we
|
||||
# drop the override and let the supervisor wrap the worker as it does for users.
|
||||
env2.pop("CBM_INDEX_SUPERVISOR", None)
|
||||
arg = json.dumps({"repo_path": repo}, ensure_ascii=False)
|
||||
p = subprocess.run([binary, "cli", "index_repository", arg],
|
||||
capture_output=True, timeout=120, env=env2)
|
||||
out = (p.stdout or b"").decode("utf-8", "replace")
|
||||
err = (p.stderr or b"").decode("utf-8", "replace")
|
||||
honored = '"nodes"' in out and '"nodes":0' not in out.replace(" ", "")
|
||||
print("ASCII control: indexed OK")
|
||||
print("non-ASCII argv (supervised): rc=%d" % p.returncode)
|
||||
print(" stdout: %s" % out[:200].replace("\n", " "))
|
||||
print(" stderr: %s" % err[-200:].replace("\n", " "))
|
||||
# #973 variant: the reporter's exact shape — Traditional-Chinese dir,
|
||||
# FLAG-form --repo-path + --mode fast, supervised. This adds coverage
|
||||
# of the flag->JSON converter and the repo-path canonicalization,
|
||||
# which used ANSI _access/_fullpath (locale-dependent — corrupted CJK
|
||||
# paths on CJK-codepage systems) before the cbm_canonical_path fix.
|
||||
cjk_repo = os.path.join(work, "\u96f7\u9054\u6e2c\u8a66")
|
||||
make_fixture(cjk_repo)
|
||||
env3 = dict(os.environ)
|
||||
env3["CBM_CACHE_DIR"] = os.path.join(work, "cache_cjk")
|
||||
env3.pop("CBM_INDEX_SUPERVISOR", None)
|
||||
p2 = subprocess.run([binary, "cli", "index_repository",
|
||||
"--repo-path", cjk_repo, "--mode", "fast"],
|
||||
capture_output=True, timeout=120, env=env3)
|
||||
out2 = (p2.stdout or b"").decode("utf-8", "replace")
|
||||
cjk_ok = '"nodes"' in out2 and '"nodes":0' not in out2.replace(" ", "")
|
||||
print("CJK flag-form (--repo-path, supervised, fast): rc=%d" % p2.returncode)
|
||||
print(" stdout: %s" % out2[:200].replace("\n", " "))
|
||||
if not cjk_ok:
|
||||
print("\nRED: CJK flag-form repo_path was not indexed (#973 — "
|
||||
"canonicalization or spawn boundary mangled the path).")
|
||||
return 1
|
||||
|
||||
if honored:
|
||||
print("\nGREEN: CLI honored the non-ASCII repo_path (argv + worker spawn).")
|
||||
return 0
|
||||
print("\nRED: CLI did not index the non-ASCII repo_path — the path was mangled "
|
||||
"either in the argv read (narrow main()) or re-mangled at the "
|
||||
"supervisor->worker CreateProcess boundary (ANSI code page).")
|
||||
return 1
|
||||
finally:
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,109 @@
|
||||
r"""GREEN regression guard — the PreToolUse hook augmenter fires on Windows.
|
||||
|
||||
Guards the fix for issue #618 (landed on main via #619) at the product surface.
|
||||
|
||||
`codebase-memory-mcp hook-augment` is the non-blocking Claude Code PreToolUse
|
||||
Grep/Glob augmenter: given a hook payload it should emit a `hookSpecificOutput`
|
||||
with `additionalContext` listing graph symbols that match the searched token.
|
||||
|
||||
Before #619 it emitted nothing for every payload on Windows: `src/cli/hook_augment.c`
|
||||
gated on POSIX-style absolute paths (`cwd[0] == '/'` and a walk-up loop over
|
||||
`dir[0] == '/'`). A Windows `cwd` is a drive-letter path (`C:\...` / `C:/...`),
|
||||
so `cwd[0]` was never `'/'` and the augmenter bailed before querying the graph.
|
||||
#619 added `cbm_is_walkable_abs_path` (accepts `X:/` drive-letter roots), so the
|
||||
augmenter now fires for a drive-letter cwd.
|
||||
|
||||
This test indexes a repo with a known symbol, confirms `search_graph` finds it
|
||||
(control — proves the index and project name are fine), then invokes
|
||||
`hook-augment` exactly as the installed PreToolUse hook does and asserts a
|
||||
`hookSpecificOutput` payload is produced. It fails (red) if that fix regresses.
|
||||
Also passes on Linux/macOS (`cwd` starts with `/`).
|
||||
|
||||
Exit code: 0 == augmenter fired (green), 1 == no-op (regression), 2 == setup error.
|
||||
|
||||
Usage:
|
||||
python test_hook_augment.py <path-to-codebase-memory-mcp[.exe]>
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
SYMBOL = "someIndexedSymbol"
|
||||
SRC = "export function %s(a: number): number { return a + 1; }\n" % SYMBOL
|
||||
|
||||
|
||||
def run_cli(binary, cache, args, stdin=None, timeout=120):
|
||||
env = dict(os.environ)
|
||||
env["CBM_CACHE_DIR"] = cache
|
||||
return subprocess.run([binary] + args, capture_output=True, timeout=timeout,
|
||||
env=env, input=stdin)
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("usage: python test_hook_augment.py <binary>")
|
||||
return 2
|
||||
binary = os.path.abspath(sys.argv[1])
|
||||
if not os.path.exists(binary):
|
||||
print("FAIL: binary not found: %s" % binary)
|
||||
return 2
|
||||
|
||||
work = tempfile.mkdtemp(prefix="cbm_win_hook_")
|
||||
try:
|
||||
repo = os.path.join(work, "repo")
|
||||
os.makedirs(os.path.join(repo, "src"), exist_ok=True)
|
||||
with open(os.path.join(repo, "src", "m.ts"), "wb") as f:
|
||||
f.write(SRC.encode("utf-8"))
|
||||
cache = os.path.join(work, "cache")
|
||||
os.makedirs(cache, exist_ok=True)
|
||||
|
||||
# repo_path / cwd in the forward-slash drive form Claude Code passes.
|
||||
repo_fwd = repo.replace("\\", "/")
|
||||
idx = run_cli(binary, cache, ["cli", "index_repository",
|
||||
json.dumps({"repo_path": repo_fwd})])
|
||||
idx_out = (idx.stdout or b"").decode("utf-8", "replace")
|
||||
if '"nodes"' not in idx_out:
|
||||
print("SETUP FAIL: index did not run:\n%s" % idx_out[:300])
|
||||
return 2
|
||||
|
||||
# Control: prove the symbol is indexed and queryable.
|
||||
lp = run_cli(binary, cache, ["cli", "list_projects", "{}"])
|
||||
projects = json.loads((lp.stdout or b"").decode("utf-8", "replace"))["projects"]
|
||||
name = projects[0]["name"]
|
||||
sg = run_cli(binary, cache, ["cli", "search_graph",
|
||||
json.dumps({"label": "Function",
|
||||
"name_pattern": ".*%s.*" % SYMBOL,
|
||||
"project": name})])
|
||||
if SYMBOL not in (sg.stdout or b"").decode("utf-8", "replace"):
|
||||
print("SETUP FAIL: control search_graph did not find %s" % SYMBOL)
|
||||
return 2
|
||||
print("control: search_graph finds %s in project %s" % (SYMBOL, name))
|
||||
|
||||
# Invoke hook-augment exactly as the installed PreToolUse hook does.
|
||||
payload = json.dumps({
|
||||
"hook_event_name": "PreToolUse",
|
||||
"tool_name": "Grep",
|
||||
"cwd": repo_fwd,
|
||||
"tool_input": {"pattern": SYMBOL},
|
||||
}).encode("utf-8")
|
||||
ha = run_cli(binary, cache, ["hook-augment"], stdin=payload, timeout=60)
|
||||
out = (ha.stdout or b"").decode("utf-8", "replace").strip()
|
||||
print("hook-augment rc=%d stdout=%r" % (ha.returncode, out[:200]))
|
||||
|
||||
fired = ("hookSpecificOutput" in out) and ("additionalContext" in out)
|
||||
if fired:
|
||||
print("\nGREEN: PreToolUse augmenter emitted additionalContext.")
|
||||
return 0
|
||||
print("\nREGRESSION (red): hook-augment produced no hookSpecificOutput on "
|
||||
"Windows (drive-letter cwd rejected — has the #619 "
|
||||
"cbm_is_walkable_abs_path handling in hook_augment.c regressed?).")
|
||||
return 1
|
||||
finally:
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Regression guard for issue #996 — dump phase must write the graph DB into a
|
||||
NON-ASCII cache directory on Windows.
|
||||
|
||||
The existing test_non_ascii_path.py exercises non-ASCII REPO paths against an
|
||||
ASCII cache, so it never covers the dump->cache write. #996's reporter (a
|
||||
non-ASCII %USERPROFILE%, e.g. C:\\Users\\Kovács János) saw extract/resolve
|
||||
succeed and `pipeline.err phase=dump`: cbm_writer_open used a raw ANSI-CP
|
||||
fopen for the hand-rolled SQLite writer (internal/cbm/sqlite_writer.c), the
|
||||
one file-creating call on the dump chain without UTF-8→wide conversion.
|
||||
Fixed by routing it through cbm_fopen (same pattern as #700/#973).
|
||||
|
||||
This guard indexes an ASCII repo into a NON-ASCII cache dir (CBM_CACHE_DIR is
|
||||
read before any USERPROFILE derivation, so it isolates the writer cleanly).
|
||||
GREEN is non-vacuous: the index must succeed AND a query_graph readback must
|
||||
count Function nodes > 0 — proving the DB was written to and reopened from
|
||||
the non-ASCII cache, not merely that no error surfaced.
|
||||
|
||||
Passes on Linux/macOS either way (byte-transparent UTF-8 filesystems).
|
||||
|
||||
Exit code: 0 == invariant holds, 1 == regression, 2 == setup error.
|
||||
|
||||
Usage:
|
||||
python test_non_ascii_cache_dump.py <path-to-codebase-memory-mcp[.exe]>
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from mcp_stdio import McpServer # noqa: E402
|
||||
|
||||
MATH_TS = (
|
||||
"export function add(a: number, b: number): number { return a + b; }\n"
|
||||
"export function mul(a: number, b: number): number { return add(a, a); }\n"
|
||||
"export class Calc {\n"
|
||||
" total: number = 0;\n"
|
||||
" push(x: number): void { this.total = add(this.total, x); }\n"
|
||||
"}\n"
|
||||
)
|
||||
|
||||
# Mixed scripts in ONE segment — one shot covers the classes the sibling
|
||||
# test exercises separately (the writer either converts wide or it doesn't).
|
||||
NON_ASCII_CACHE_SEGMENT = "cache_café_Ωμέγα_日本語"
|
||||
|
||||
|
||||
def graph_function_count(server, project):
|
||||
resp = server.call_tool(
|
||||
"query_graph",
|
||||
{
|
||||
"project": project,
|
||||
"format": "json",
|
||||
"query": "MATCH (n:Function) RETURN count(n) AS c",
|
||||
},
|
||||
)
|
||||
text, err = McpServer.tool_text(resp)
|
||||
if err or not text:
|
||||
return 0
|
||||
data = json.loads(text)
|
||||
rows = data.get("rows") or []
|
||||
if not rows or not rows[0]:
|
||||
return 0
|
||||
return int(rows[0][0])
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 2:
|
||||
print("usage: test_non_ascii_cache_dump.py <binary>")
|
||||
return 2
|
||||
binary = os.path.abspath(sys.argv[1])
|
||||
if not os.path.exists(binary):
|
||||
print(f"SETUP: binary not found: {binary}")
|
||||
return 2
|
||||
|
||||
work = tempfile.mkdtemp(prefix="cbm_996_")
|
||||
try:
|
||||
repo = os.path.join(work, "ascii_repo")
|
||||
os.makedirs(repo)
|
||||
with open(os.path.join(repo, "math.ts"), "w", encoding="utf-8") as f:
|
||||
f.write(MATH_TS)
|
||||
|
||||
cache = os.path.join(work, NON_ASCII_CACHE_SEGMENT)
|
||||
os.makedirs(cache)
|
||||
|
||||
with McpServer(binary, cache_dir=cache) as s:
|
||||
resp = s.call_tool("index_repository", {"repo_path": repo})
|
||||
text, err = McpServer.tool_text(resp)
|
||||
if err:
|
||||
print(f"FAIL: index_repository rpc error: {err}")
|
||||
return 1
|
||||
text = text or ""
|
||||
if '"error"' in text:
|
||||
print(f"FAIL: index_repository into non-ASCII cache errored: {text[:300]}")
|
||||
return 1
|
||||
if "phase" in text and "dump" in text and "error" in text.lower():
|
||||
print(f"FAIL: dump phase error: {text[:300]}")
|
||||
return 1
|
||||
|
||||
# Non-vacuous readback: the DB must exist under the non-ASCII
|
||||
# cache and answer queries.
|
||||
project = None
|
||||
try:
|
||||
project = json.loads(text).get("project")
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
if not project:
|
||||
# TOON-shaped success output: fall back to list_projects.
|
||||
lp = s.call_tool("list_projects", {})
|
||||
lp_text, _ = McpServer.tool_text(lp)
|
||||
lp_text = lp_text or ""
|
||||
for line in lp_text.splitlines():
|
||||
if "ascii_repo" in line:
|
||||
project = line.split(",")[0].strip().strip('"')
|
||||
break
|
||||
if not project:
|
||||
print("FAIL: could not determine project name after index")
|
||||
return 1
|
||||
|
||||
count = graph_function_count(s, project)
|
||||
if count < 1:
|
||||
print(f"FAIL: readback from non-ASCII cache found {count} Function nodes")
|
||||
return 1
|
||||
|
||||
print(f"OK: dump wrote and reopened graph DB under non-ASCII cache ({count} functions)")
|
||||
return 0
|
||||
finally:
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,167 @@
|
||||
"""GREEN regression guard — non-ASCII repo paths keep all definitions on Windows.
|
||||
|
||||
Guards the fix for issue #636 / #357 (landed on main via #700) at the product
|
||||
surface (real codebase-memory-mcp process, real SQLite DB, real stdio). Two
|
||||
byte-identical TypeScript fixtures are indexed: one under an ASCII parent path,
|
||||
one under a non-ASCII parent path. The invariant under test:
|
||||
|
||||
A byte-identical fixture must produce equivalent graph counts regardless of
|
||||
whether its absolute path contains non-ASCII characters.
|
||||
|
||||
Before #700 native Windows extracted only File/Folder nodes for every non-ASCII
|
||||
copy (Latin-1 accents, Cyrillic, CJK, Greek) — zero definitions — while the ASCII
|
||||
copy extracted functions/classes/methods. Root cause: each pipeline pass read
|
||||
source bytes with plain fopen(path, "rb") (src/pipeline/pass_definitions.c,
|
||||
pass_calls.c, …); on Windows fopen() interprets the UTF-8 path in the active ANSI
|
||||
code page, so a non-ASCII path could not be opened and the parser received
|
||||
nothing. #700 routed the per-pass reads through cbm_fopen (→ _wfopen with a wide
|
||||
path, src/foundation/compat_fs.c), so non-ASCII paths now parse identically.
|
||||
|
||||
This guard fails (red) if that fix regresses. It also passes on Linux/macOS
|
||||
(byte-transparent UTF-8 filesystem).
|
||||
|
||||
Exit code: 0 == invariant holds (green), 1 == invariant violated (regression),
|
||||
2 == environment/setup error.
|
||||
|
||||
Usage:
|
||||
python test_non_ascii_path.py <path-to-codebase-memory-mcp[.exe]>
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from mcp_stdio import McpServer # noqa: E402
|
||||
|
||||
MATH_TS = (
|
||||
"export function add(a: number, b: number): number { return a + b; }\n"
|
||||
"export function mul(a: number, b: number): number { return add(a, a); }\n"
|
||||
"export class Calc {\n"
|
||||
" total: number = 0;\n"
|
||||
" push(x: number): void { this.total = add(this.total, x); }\n"
|
||||
"}\n"
|
||||
)
|
||||
MAIN_TS = (
|
||||
'import { add, mul, Calc } from "./math";\n'
|
||||
"function run(): number {\n"
|
||||
" const c = new Calc();\n"
|
||||
" c.push(add(1, 2));\n"
|
||||
" return mul(3, 4);\n"
|
||||
"}\n"
|
||||
"run();\n"
|
||||
)
|
||||
|
||||
# Distinct non-ASCII scripts — each must behave like the ASCII baseline.
|
||||
NON_ASCII_SEGMENTS = {
|
||||
"latin1_accents": "café_repo",
|
||||
"cyrillic": "проект_repo",
|
||||
"cjk": "日本語_repo",
|
||||
"greek": "Ωμέγα_repo",
|
||||
}
|
||||
|
||||
|
||||
def make_fixture(root):
|
||||
src = os.path.join(root, "src")
|
||||
os.makedirs(src, exist_ok=True)
|
||||
for name, text in (("math.ts", MATH_TS), ("main.ts", MAIN_TS)):
|
||||
with open(os.path.join(src, name), "wb") as f:
|
||||
f.write(text.encode("utf-8")) # exact bytes, identical across copies
|
||||
|
||||
|
||||
def index_and_count(binary, repo, cache):
|
||||
"""Index `repo` into an isolated cache and return label-resolved counts."""
|
||||
os.makedirs(cache, exist_ok=True)
|
||||
with McpServer(binary, cache_dir=cache) as s:
|
||||
s.initialize()
|
||||
resp = s.call_tool("index_repository", {"repo_path": repo}, timeout=180)
|
||||
_, err = s.tool_text(resp)
|
||||
if err:
|
||||
return {"error": "index tools/call error: %r" % err}
|
||||
lp = s.call_tool("list_projects", {}, timeout=60)
|
||||
lp_txt, _ = s.tool_text(lp)
|
||||
projects = json.loads(lp_txt).get("projects") or []
|
||||
if not projects:
|
||||
return {"error": "no project listed after index"}
|
||||
p = projects[0]
|
||||
out = {"name": p.get("name"), "nodes": p.get("nodes"),
|
||||
"edges": p.get("edges")}
|
||||
# Definition-level counts prove the parser ran (not just discovery).
|
||||
# query_graph defaults to TOON text; this scripted consumer requests
|
||||
# format="json" ({"columns":[...],"rows":[["<n>"]],...}) explicitly.
|
||||
name = p.get("name")
|
||||
defs = 0
|
||||
for label in ("Function", "Class", "Method"):
|
||||
q = "MATCH (n:%s) RETURN count(n)" % label
|
||||
r = s.call_tool("query_graph",
|
||||
{"query": q, "project": name, "format": "json"},
|
||||
timeout=60)
|
||||
t, _ = s.tool_text(r)
|
||||
try:
|
||||
rows = json.loads(t).get("rows") or []
|
||||
if rows and rows[0]:
|
||||
defs += int(rows[0][0])
|
||||
except Exception:
|
||||
pass
|
||||
out["definition_nodes"] = defs
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("usage: python test_non_ascii_path.py <binary>")
|
||||
return 2
|
||||
binary = os.path.abspath(sys.argv[1])
|
||||
if not os.path.exists(binary):
|
||||
print("FAIL: binary not found: %s" % binary)
|
||||
return 2
|
||||
|
||||
work = tempfile.mkdtemp(prefix="cbm_win_nonascii_")
|
||||
failures = []
|
||||
try:
|
||||
ascii_repo = os.path.join(work, "ascii_repo")
|
||||
make_fixture(ascii_repo)
|
||||
base = index_and_count(binary, ascii_repo, os.path.join(work, "c_ascii"))
|
||||
if base.get("error") or not base.get("nodes"):
|
||||
print("SETUP FAIL: ASCII baseline did not index: %r" % base)
|
||||
return 2
|
||||
print("baseline (ASCII): nodes=%s edges=%s definitions=%s" %
|
||||
(base["nodes"], base["edges"], base["definition_nodes"]))
|
||||
if base["definition_nodes"] < 1:
|
||||
print("SETUP FAIL: ASCII baseline produced no definitions: %r" % base)
|
||||
return 2
|
||||
|
||||
for key, seg in NON_ASCII_SEGMENTS.items():
|
||||
repo = os.path.join(work, seg)
|
||||
make_fixture(repo)
|
||||
got = index_and_count(binary, repo, os.path.join(work, "c_" + key))
|
||||
ok = (not got.get("error")
|
||||
and got.get("nodes") == base["nodes"]
|
||||
and got.get("edges") == base["edges"]
|
||||
and got.get("definition_nodes") == base["definition_nodes"])
|
||||
status = "PASS" if ok else "FAIL"
|
||||
print("[%s] non-ascii/%-14s nodes=%s edges=%s definitions=%s "
|
||||
"(baseline %s/%s/%s) name=%r" %
|
||||
(status, key, got.get("nodes"), got.get("edges"),
|
||||
got.get("definition_nodes"), base["nodes"], base["edges"],
|
||||
base["definition_nodes"], got.get("name")))
|
||||
if not ok:
|
||||
failures.append(key)
|
||||
finally:
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
|
||||
if failures:
|
||||
print("\nREGRESSION (red): %d/%d non-ASCII path variants lost "
|
||||
"definitions: %s" %
|
||||
(len(failures), len(NON_ASCII_SEGMENTS), ", ".join(failures)))
|
||||
print("Invariant violated: byte-identical fixtures under non-ASCII paths "
|
||||
"must extract the same definitions as the ASCII baseline (fixed by "
|
||||
"#700 — has the cbm_fopen routing in the pass readers regressed?).")
|
||||
return 1
|
||||
print("\nGREEN: all non-ASCII path variants matched the ASCII baseline.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,186 @@
|
||||
r"""GREEN regression guard — the UI directory picker enumerates all logical drives.
|
||||
|
||||
Guards the fix for issue #548 (landed on main). `handle_browse`
|
||||
(src/ui/http_server.c) appends a `"roots"` array to every `/api/browse`
|
||||
response; on Windows `append_roots_json` fills it from `GetLogicalDrives()` as
|
||||
`"C:/"`, `"D:/"`, … so the directory picker can reach every drive (POSIX emits
|
||||
a single `"/"`). This test asserts that user-level invariant against the running
|
||||
embedded HTTP UI.
|
||||
|
||||
Before #548 the picker did `opendir("/")`, listing only the current drive's
|
||||
subdirectories under `dirs` with no drive enumeration — non-system drives were
|
||||
unreachable. The original red test asserted drives appeared in `dirs`; the fix
|
||||
intentionally exposes them via the separate `roots` field, so this guard checks
|
||||
`roots` (and that each advertised drive is actually browsable).
|
||||
|
||||
Requires a UI build (`make -f Makefile.cbm cbm-with-ui`) because the HTTP server
|
||||
only starts when the frontend is embedded. Runs green with a single drive (C:/
|
||||
must be advertised and browsable); a machine with D:/E: exercises the multi-drive
|
||||
reach more fully.
|
||||
|
||||
Exit code: 0 == all drives advertised in roots and reachable (green),
|
||||
1 == a drive is missing from roots or not browsable (regression),
|
||||
2 == precondition not met (no UI build / server down).
|
||||
|
||||
Usage:
|
||||
python test_ui_drive_listing.py <path-to-codebase-memory-mcp-ui[.exe]> [port]
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
def list_fixed_drives():
|
||||
# Python 3.12+: os.listdrives(). Fall back to scanning A:..Z:.
|
||||
listdrives = getattr(os, "listdrives", None)
|
||||
if listdrives:
|
||||
try:
|
||||
return [d for d in listdrives()]
|
||||
except Exception:
|
||||
pass
|
||||
found = []
|
||||
for ch in "CDEFGHIJKLMNOPQRSTUVWXYZ":
|
||||
root = "%s:\\" % ch
|
||||
if os.path.isdir(root):
|
||||
found.append(root)
|
||||
return found
|
||||
|
||||
|
||||
def free_port():
|
||||
s = socket.socket()
|
||||
s.bind(("127.0.0.1", 0))
|
||||
p = s.getsockname()[1]
|
||||
s.close()
|
||||
return p
|
||||
|
||||
|
||||
def http_get_json(url, timeout=5):
|
||||
with urllib.request.urlopen(url, timeout=timeout) as r:
|
||||
return json.loads(r.read().decode("utf-8", "replace"))
|
||||
|
||||
|
||||
def browse(port, path):
|
||||
return http_get_json("http://127.0.0.1:%d/api/browse?path=%s" %
|
||||
(port, urllib.parse.quote(path)))
|
||||
|
||||
|
||||
def wait_for_server(port, timeout=20):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=1):
|
||||
return True
|
||||
except OSError:
|
||||
time.sleep(0.3)
|
||||
return False
|
||||
|
||||
|
||||
def norm(s):
|
||||
"""Canonical drive key: 'D:\\', 'D:/', 'D:', 'D' -> 'D:'."""
|
||||
s = str(s).rstrip("\\/").upper()
|
||||
return s if s.endswith(":") else (s + ":" if len(s) == 1 else s)
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("usage: python test_ui_drive_listing.py <ui-binary> [port]")
|
||||
return 2
|
||||
binary = os.path.abspath(sys.argv[1])
|
||||
if not os.path.exists(binary):
|
||||
print("FAIL: binary not found: %s" % binary)
|
||||
return 2
|
||||
|
||||
drives = list_fixed_drives()
|
||||
print("fixed drives: %s" % drives)
|
||||
if not drives:
|
||||
print("PRECONDITION: no fixed drives detected.")
|
||||
return 2
|
||||
|
||||
work = tempfile.mkdtemp(prefix="cbm_win_uidrv_")
|
||||
port = int(sys.argv[2]) if len(sys.argv) > 2 else free_port()
|
||||
env = dict(os.environ)
|
||||
env["CBM_CACHE_DIR"] = os.path.join(work, "cache")
|
||||
os.makedirs(env["CBM_CACHE_DIR"], exist_ok=True)
|
||||
proc = subprocess.Popen([binary, "--ui=true", "--port=%d" % port],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, env=env)
|
||||
try:
|
||||
if not wait_for_server(port, timeout=25):
|
||||
print("PRECONDITION: HTTP server did not start on port %d. Is this a "
|
||||
"UI build (make -f Makefile.cbm cbm-with-ui)?" % port)
|
||||
return 2
|
||||
|
||||
# Control: browsing an explicit existing directory must return a payload
|
||||
# carrying the roots field, proving the endpoint works and isolating any
|
||||
# failure to drive enumeration itself. roots is appended to *every*
|
||||
# browse response, so any valid directory surfaces it.
|
||||
home = os.environ.get("USERPROFILE") or os.path.expanduser("~")
|
||||
home_fwd = home.replace("\\", "/")
|
||||
try:
|
||||
ctrl = browse(port, home_fwd)
|
||||
except Exception as ex:
|
||||
print("PRECONDITION: control /api/browse?path=%s failed: %r" %
|
||||
(home_fwd, ex))
|
||||
return 2
|
||||
roots = ctrl.get("roots")
|
||||
print("control browse(%r) -> dirs(%d) roots=%s" %
|
||||
(home_fwd, len(ctrl.get("dirs", [])), roots))
|
||||
if roots is None:
|
||||
print("PRECONDITION: response has no 'roots' field; build predates "
|
||||
"#548 or endpoint non-functional.")
|
||||
return 2
|
||||
|
||||
# Invariant 1: every fixed drive is advertised in roots.
|
||||
adv = {norm(r) for r in roots}
|
||||
missing = [d for d in drives if norm(d) not in adv]
|
||||
if missing:
|
||||
print("\nRED: drives %s are not advertised in the picker's roots "
|
||||
"array %s (handle_browse/append_roots_json did not enumerate "
|
||||
"them)." % (missing, roots))
|
||||
return 1
|
||||
|
||||
# Invariant 2: every advertised drive is actually browsable (a user can
|
||||
# select it). This is the user-level reach the fix promises.
|
||||
unreachable = []
|
||||
for d in drives:
|
||||
drive_root = norm(d) + "/" # "D:/"
|
||||
try:
|
||||
resp = browse(port, drive_root)
|
||||
if resp.get("roots") is None and "dirs" not in resp:
|
||||
unreachable.append(d)
|
||||
except Exception as ex:
|
||||
print(" browse(%r) failed: %r" % (drive_root, ex))
|
||||
unreachable.append(d)
|
||||
if unreachable:
|
||||
print("\nRED: drives advertised in roots but not browsable: %s" %
|
||||
unreachable)
|
||||
return 1
|
||||
|
||||
print("\nGREEN: all %d fixed drive(s) advertised in roots and reachable "
|
||||
"from the UI picker." % len(drives))
|
||||
return 0
|
||||
finally:
|
||||
try:
|
||||
proc.stdin.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
except Exception:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user