chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:28:05 +08:00
commit 41cb1c0170
1830 changed files with 38276124 additions and 0 deletions
+146
View File
@@ -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
+135
View File
@@ -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())
+109
View File
@@ -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())
+132
View File
@@ -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())
+167
View File
@@ -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())
+186
View File
@@ -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())