168 lines
6.7 KiB
Python
168 lines
6.7 KiB
Python
"""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())
|