diff --git a/.github/scripts/e2e-cases.sh b/.github/scripts/e2e-cases.sh new file mode 100755 index 000000000..aedcfca91 --- /dev/null +++ b/.github/scripts/e2e-cases.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# The query cases (tests/run.py), every language, answered by the INSTALLED packages. +# +# e2e-cases.sh [tests/run.py args...] +# +# e2e-install.sh proves an install answers every verb on one case per language. That is a smoke test: 0.1.10 +# passed it on Windows while 43 of the cases' 1,844 checks failed there — tests not linked through a file path, +# an absolute file:line refused, any argument holding ' or " mangled on its way into Git Bash. None of it is +# platform-specific code; all of it is how Windows writes paths and command lines. So the cases run here, from +# this checkout's plugin scripts, with the engine and parser of the packages a user installs (AXIOMCODE_ENGINE), +# souffle hidden as it is for a user. +# +# CODEGRAPH_TGZ is the packed @axiomcode/code-graph, as for e2e-install.sh. +# ───────────────────────────────────────────────────────────────────────────── +set -uo pipefail + +engines="${1:?usage: e2e-cases.sh [tests/run.py args...]}"; platform="${2:?platform}"; shift 2 +root="$(cd "$(dirname "$0")/../.." && pwd)" +engines="$(cd "$engines" && pwd)" +W="$(mktemp -d)"; SHADOW=""; trap 'rm -rf "$W" ${SHADOW:+"$SHADOW"}' EXIT +fail() { echo "::error::e2e cases: $*"; exit 1; } +[ -n "${CODEGRAPH_TGZ:-}" ] || fail "CODEGRAPH_TGZ is not set" + +version="$(node "$root/.github/scripts/version.mjs" get)" +bash "$root/packaging/assemble-engine-package.sh" "$platform" "$version" "$engines" "$W/engine" >/dev/null \ + || fail "the engine package did not assemble" +mkdir -p "$W/tgz" "$W/proj" +( cd "$W/engine" && npm pack --silent --pack-destination "$W/tgz" >/dev/null ) || fail "npm pack of the engine package failed" +cp "$CODEGRAPH_TGZ" "$W/tgz/" || fail "no code-graph tarball at $CODEGRAPH_TGZ" +( cd "$W/proj" && npm init -y >/dev/null && npm install --no-audit --no-fund --loglevel=error "$W"/tgz/*.tgz ) \ + || fail "npm install of the packed tarballs failed" +export AXIOMCODE_ENGINE="$W/proj/node_modules/@axiomcode/code-graph" +[ -d "$AXIOMCODE_ENGINE/graph" ] || fail "the installed package has no graph/ at $AXIOMCODE_ENGINE" + +. "$root/graph/test/tools/hide-souffle.sh" +py="$(command -v python3 || command -v python)" || fail "no python" +# the bash a native program starts, as the CLI hands it on: on Windows a bare `bash` is System32's WSL launcher +command -v cygpath >/dev/null 2>&1 && export AXIOMCODE_BASH="${AXIOMCODE_BASH:-$(cygpath -m "$BASH")}" +# and Python in UTF-8, as every launcher of the product sets it (the dispatcher, find-python.js): some tests start a +# verb's script directly, and an answer's → is not cp1252 +export PYTHONUTF8=1 +rc=0 +PATH="$SANDBOX_PATH" "$py" "$root/tests/run.py" "$@" || rc=1 +# what only Windows can show for real: its separators, its exec, its symlinks, its command lines +for t in mixed_separators no_symlink no_exec windows_argv; do + PATH="$SANDBOX_PATH" "$py" "$root/tests/$t.py" > "$W/$t.log" 2>&1 && echo "$t: $(tail -1 "$W/$t.log")" \ + || { echo "::error::e2e cases: tests/$t.py failed"; tail -30 "$W/$t.log"; rc=1; } +done +exit $rc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a99207659..9251933e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -551,7 +551,7 @@ jobs: - { os: macos-15, platform: darwin-arm64 } - { os: macos-15-intel, platform: darwin-x64 } runs-on: ${{ matrix.target.os }} - timeout-minutes: 45 + timeout-minutes: 90 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -581,6 +581,21 @@ jobs: bash .github/scripts/e2e-install.sh artifacts/engines "$PLATFORM" "$lang" "$case_dir" echo "::endgroup::" done + # The smoke test above is one case per language; Windows writes paths and command lines differently enough that + # a release passed it there with 43 case checks failing (tests not linked through a file path, an absolute + # file:line refused, ' and " mangled into Git Bash). Every query case, answered by the installed packages. + - name: every query case, installed from the tarballs, no Soufflé (Windows) + if: matrix.target.platform == 'win32-x64' + shell: bash + env: + PLATFORM: ${{ matrix.target.platform }} + run: | + set -euo pipefail + # the decompilers `--library auto` uses for a Java class jar and a NuGet package, as the engine job installs them + mvn -q dependency:get -Dartifact=org.vineflower:vineflower:1.10.1 + dotnet tool install -g ilspycmd --version 8.2.0.7535 && export PATH="$USERPROFILE/.dotnet/tools:$PATH" + export CODEGRAPH_TGZ="$(ls "$PWD"/artifacts/tgz/*.tgz)" + bash .github/scripts/e2e-cases.sh artifacts/engines "$PLATFORM" --jobs 4 # A single job the branch ruleset can require. Without it, every new matrix # entry has to be added to the protection rules by hand, and a matrix job that diff --git a/bin/axiomcode b/bin/axiomcode index 99f3f6688..8b2219e41 100755 --- a/bin/axiomcode +++ b/bin/axiomcode @@ -49,6 +49,12 @@ # build option above.) # ───────────────────────────────────────────────────────────────────────────── set -eu +# Windows: a native caller (bin/axiomcode.js, the MCP server, ax_exec.bash_argv) hands the arguments over in +# AXIOMCODE_ARGV, each ended by \x1f, because msys misparses them on a command line. Taken once, so a script this one +# starts gets its own. +if [ -n "${AXIOMCODE_ARGV+x}" ]; then + set --; while IFS= read -r -d $'\x1f' _a; do set -- "$@" "$_a"; done < <(printf '%s' "$AXIOMCODE_ARGV"); unset AXIOMCODE_ARGV _a +fi # RESOLVE $0 THROUGH SYMLINKS BEFORE THE WALK. npm installs a `bin` entry as a link in # node_modules/.bin, so under `npx axiomcode` or a global install $0 is that link and # dirname "$0" is .bin/, a directory this package does not live under. The walk below then diff --git a/bin/axiomcode.js b/bin/axiomcode.js index 3f54b1e57..302d2f724 100755 --- a/bin/axiomcode.js +++ b/bin/axiomcode.js @@ -50,7 +50,12 @@ if (error) fail(error); const py = findPython(); // AXIOMCODE_BASH too, for the builds Python starts: a bare `bash` from Python on Windows is WSL's or nothing. const env = { ...(py.exe ? withPython(process.env, py) : process.env), AXIOMCODE_BASH: bash }; -const r = spawnSync(bash, [path.join(__dirname, 'axiomcode'), ...args], { stdio: 'inherit', env }); +// On Windows the arguments travel in AXIOMCODE_ARGV, each ended by \x1f, not on the command line: Git Bash parses a +// command line by msys rules, where `'` quotes too and `\"` is no escape, so `context "the user's cart"` lost every +// argument after the apostrophe and a quoted string arrived as \Key\. bin/axiomcode turns them back into "$@". +const win = process.platform === 'win32'; +const r = spawnSync(bash, [path.join(__dirname, 'axiomcode'), ...(win ? [] : args)], + { stdio: 'inherit', env: win ? { ...env, AXIOMCODE_ARGV: args.map((a) => `${a}\x1f`).join('') } : env }); if (r.error) fail(`could not start bash: ${r.error.message}`); // Die of the same signal the CLI died of, so a caller sees what really happened. if (r.signal) process.kill(process.pid, r.signal); diff --git a/gemini-extension.json b/gemini-extension.json index 4e8cef176..1df99d930 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "axiomcode", - "version": "0.1.10", + "version": "0.1.11", "description": "Ask your repository how its code connects: who calls this, what breaks if I change it, which tests an edit reaches, how A reaches B. Answers come from a resolved call graph and are verified against it; nothing is guessed.", "contextFileName": "plugins/axiomcode/AGENTS.md", "mcpServers": { diff --git a/package.json b/package.json index fbff87117..efc30729b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@axiomcode/code-graph", - "version": "0.1.10", + "version": "0.1.11", "description": "AxiomCode Graph: a resolved call graph of your codebase grounded in formal methods, so you and your coding agents can see who calls what, what a change breaks, and which tests it reaches.", "repository": { "type": "git", @@ -31,11 +31,11 @@ ], "license": "FSL-1.1-Apache-2.0", "optionalDependencies": { - "@axiomcode/engine-darwin-arm64": "0.1.10", - "@axiomcode/engine-darwin-x64": "0.1.10", - "@axiomcode/engine-linux-x64": "0.1.10", - "@axiomcode/engine-linux-arm64": "0.1.10", - "@axiomcode/engine-win32-x64": "0.1.10" + "@axiomcode/engine-darwin-arm64": "0.1.11", + "@axiomcode/engine-darwin-x64": "0.1.11", + "@axiomcode/engine-linux-x64": "0.1.11", + "@axiomcode/engine-linux-arm64": "0.1.11", + "@axiomcode/engine-win32-x64": "0.1.11" }, "bin": { "axiomcode": "bin/axiomcode.js" diff --git a/parser/src/workflows/csharp/csharp-project-analyzer.ts b/parser/src/workflows/csharp/csharp-project-analyzer.ts index 71b81934a..07d451245 100644 --- a/parser/src/workflows/csharp/csharp-project-analyzer.ts +++ b/parser/src/workflows/csharp/csharp-project-analyzer.ts @@ -223,7 +223,8 @@ export class CSharpProjectAnalyzer { continue; } context.seen.add(absoluteFilePath); - const relativePath = path.relative(options.baseMservPath, absoluteFilePath); + // `/` on every platform, as the graph stores a file: on Windows `Shop\Pricer.cs` matched no rule joining paths + const relativePath = path.relative(options.baseMservPath, absoluteFilePath).split(path.sep).join('/'); // THE GOVERNING PROJECT decides the framework and the symbols, unless the // caller fixed them. See cs-project-config.ts for why this is read at all. @@ -498,7 +499,7 @@ function readProjectConfigFor( defineConstants: config.defineConstants, implicitFrameworkDefines: config.implicitFrameworkDefines, langVersion: config.langVersion, - projectPath: path.relative(baseMservPath, config.projectFile), + projectPath: path.relative(baseMservPath, config.projectFile).split(path.sep).join('/'), implicitUsings: config.implicitUsings, usings: config.usings, }; diff --git a/parser/src/workflows/python/python-project-analyzer.ts b/parser/src/workflows/python/python-project-analyzer.ts index 4b7ea3df7..fe56d6ae6 100644 --- a/parser/src/workflows/python/python-project-analyzer.ts +++ b/parser/src/workflows/python/python-project-analyzer.ts @@ -572,11 +572,14 @@ export class PythonProjectAnalyzer { rootDir: string, baseMservPath: string ): string { - const fromMserv = path.relative(baseMservPath, filePath); + // Written with `/` on every platform, as the other languages' IR writes it: the rules join a module's directory to a + // string (`os.path.join(HERE, 'graph-core.py')`, `include("shop.urls")`) with `/`, and on Windows `pkg\changed.py` + // matched none of them. + const fromMserv = path.relative(baseMservPath, filePath).split(path.sep).join('/'); if (fromMserv !== '' && !fromMserv.startsWith('..') && !path.isAbsolute(fromMserv)) { return fromMserv; } - return path.relative(rootDir, filePath) || path.basename(filePath); + return path.relative(rootDir, filePath).split(path.sep).join('/') || path.basename(filePath); } /** The directory names above a module's top-level package, outermost first (see ProjectModuleFacts.outerDirs). */ diff --git a/plugins/axiomcode/.claude-plugin/plugin.json b/plugins/axiomcode/.claude-plugin/plugin.json index 98067cc05..9ba508081 100644 --- a/plugins/axiomcode/.claude-plugin/plugin.json +++ b/plugins/axiomcode/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "axiomcode", "description": "Ask your repository how its code connects: who calls this, what breaks if I change it, which tests an edit reaches, how A reaches B. Answers come from a resolved call graph and are verified against it; nothing is guessed.", - "version": "0.1.10", + "version": "0.1.11", "author": { "name": "AxiomCode" } diff --git a/plugins/axiomcode/.codex-plugin/plugin.json b/plugins/axiomcode/.codex-plugin/plugin.json index 3940024ae..ebc7fba80 100644 --- a/plugins/axiomcode/.codex-plugin/plugin.json +++ b/plugins/axiomcode/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "axiomcode", - "version": "0.1.10", + "version": "0.1.11", "description": "Ask your repository how its code connects: who calls this, what breaks if I change it, which tests an edit reaches, how A reaches B. Answers come from a resolved call graph and are verified against it; nothing is guessed.", "author": { "name": "AxiomCode", "url": "https://github.com/AxiomCodeAI/axiomcodegraph" }, "homepage": "https://github.com/AxiomCodeAI/axiomcodegraph", diff --git a/plugins/axiomcode/.cursor-plugin/plugin.json b/plugins/axiomcode/.cursor-plugin/plugin.json index ada8f9aab..3ae6d77ac 100644 --- a/plugins/axiomcode/.cursor-plugin/plugin.json +++ b/plugins/axiomcode/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "axiomcode", - "version": "0.1.10", + "version": "0.1.11", "description": "Ask your repository how its code connects: who calls this, what breaks if I change it, which tests an edit reaches, how A reaches B. Answers come from a resolved call graph and are verified against it; nothing is guessed. Java, TypeScript, Python, JavaScript.", "author": { "name": "AxiomCode" }, "homepage": "https://github.com/AxiomCodeAI/axiomcodegraph", diff --git a/plugins/axiomcode/mcp/server.py b/plugins/axiomcode/mcp/server.py index a4f5d2c4f..7846e21b4 100755 --- a/plugins/axiomcode/mcp/server.py +++ b/plugins/axiomcode/mcp/server.py @@ -186,7 +186,13 @@ def run(args, cwd=None, timeout=900): note = stale_note(root) head = (note + '\n') if note else '' try: - r = subprocess.run([BASH, ax, *args], cwd=cwd or None, capture_output=True, text=True, timeout=timeout, env=env) + # On Windows the arguments travel in AXIOMCODE_ARGV, not on the command line: Git Bash parses a command line by + # msys rules (`'` quotes, `\"` is no escape), so a question holding an apostrophe lost every argument after it. + # The same as ax_exec.bash_argv, which this server does not import. + argv = [BASH, ax, *args] + if os.name == 'nt': + argv, env = [BASH, ax], dict(env or os.environ, AXIOMCODE_ARGV=''.join(f'{a}\x1f' for a in args)) + r = subprocess.run(argv, cwd=cwd or None, capture_output=True, text=True, timeout=timeout, env=env) except OSError as e: return (f"axiomcode could not start bash ({BASH}): {e}. On Windows it needs the bash that comes with " "Git for Windows; install it, or set AXIOMCODE_BASH to its bin\\bash.exe.") diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py index 987925cfc..8907aa37b 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py @@ -24,6 +24,7 @@ partial list is not read as a complete one. """ import collections, difflib, os, re, subprocess, sys, time +import ax_exec SPLIT = re.compile(r'[^A-Za-z0-9]+') CAMEL = re.compile(r'[A-Z]+(?![a-z])|[A-Z][a-z0-9]*|[a-z0-9]+') @@ -492,7 +493,8 @@ def ensure_graph(repo, db): if nowait: os.makedirs(os.path.join(repo, '.axiomcode'), exist_ok=True) log = open(os.path.join(repo, '.axiomcode', 'first-build.log'), 'w') - p = _start_build([bash, build, repo], log, env) + argv, benv = ax_exec.bash_argv(build, [repo], env, bash) + p = _start_build(argv, log, benv) # until the build holds its lock a second query would see no build and start another, so that much is always waited up = time.time() + 10 while p.poll() is None and not ax_fresh.building(repo) and time.time() < up: time.sleep(0.05) @@ -508,7 +510,8 @@ def ensure_graph(repo, db): os.makedirs(os.path.join(repo, '.axiomcode'), exist_ok=True) logp = os.path.join(repo, '.axiomcode', 'first-build.log') with open(logp, 'w') as log: - p = _start_build([bash, build, repo], log, env) + argv, benv = ax_exec.bash_argv(build, [repo], env, bash) + p = _start_build(argv, log, benv) shown = [0] def relay(): try: @@ -597,8 +600,8 @@ def _rebuild_in_background(repo): try: log = open(os.path.join(repo, '.axiomcode', 'refresh.log'), 'a') kw = dict(start_new_session=True) if os.name != 'nt' else dict(creationflags=0x00000008 | 0x00000200) - subprocess.Popen([os.environ.get('AXIOMCODE_BASH') or 'bash', build, repo], stdin=subprocess.DEVNULL, stdout=log, stderr=log, - close_fds=True, env=env, **kw) + argv, env = ax_exec.bash_argv(build, [repo], env) + subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=log, stderr=log, close_fds=True, env=env, **kw) except OSError: pass diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_exec.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_exec.py index 975f32366..1c159033f 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_exec.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_exec.py @@ -36,3 +36,22 @@ def become(argv): except KeyboardInterrupt: rc = 130 sys.exit(rc) + + +ARGV_ENV = 'AXIOMCODE_ARGV' + + +def bash_argv(script, args, env=None, bash=None): + """(argv, env) that run the bash script `script` with exactly `args`. ON WINDOWS THE COMMAND LINE CANNOT CARRY THEM: + Git Bash's msys runtime parses it by its own rules, where `'` quotes too and `\\"` is no escape, so `it's` swallowed + every argument after it and `"Widgets:MinCount"` arrived as `\\Widgets:MinCount\\`. There they travel in + AXIOMCODE_ARGV, each ended by \\x1f, and the script's prologue turns them back into "$@": an environment value + reaches bash as written. Elsewhere they are passed on the command line as before. WINDOWS_ARGV is read at call time, + so a test can take the Windows branch on any OS.""" + bash = bash or os.environ.get('AXIOMCODE_BASH') or 'bash' + if not WINDOWS_ARGV: return [bash, script, *args], env + e = dict(os.environ if env is None else env); e[ARGV_ENV] = ''.join(f'{a}\x1f' for a in args) + return [bash, script], e + + +WINDOWS_ARGV = os.name == 'nt' diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py index c86933102..08d0b4a7f 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py @@ -26,6 +26,7 @@ ax_fresh.py baseline [] when HEAD moved, wait for the baseline to follow it (changed, test-impact) ax_fresh.py worker the worker itself (what kick detaches) ax_fresh.py lock take the build lock on an fd the calling shell holds open + ax_fresh.py hold [--try] the same on Windows, held for the build by this process until it ends ax_fresh.py count the source files of each language, walked as the parser walks ax_fresh.py chosen the --lang and --src an explicit index chose, which a rebuild keeps ax_fresh.py newer exit 0 (saying so) when a newer axiomcode built the graph: never rebuilt by this one @@ -226,7 +227,7 @@ def snapshot(repo, lang, src): """the file table: {rel: [size, mtime_ns, sha1]} of every file the parser would read under src""" files = {} for p in watched(src, lang): - try: st = os.stat(p); files[os.path.relpath(p, repo)] = [st.st_size, st.st_mtime_ns, digest(p)] + try: st = os.stat(p); files[os.path.relpath(p, repo).replace(os.sep, '/')] = [st.st_size, st.st_mtime_ns, digest(p)] except OSError: pass return files @@ -253,7 +254,7 @@ def dep_snapshot(repo): for f in files: if f in DEP_FILES or f.endswith('.csproj') or f.startswith('requirements') and f.endswith('.txt'): pth = os.path.join(root, f) - try: st = os.stat(pth); out[os.path.relpath(pth, repo)] = f'{st.st_size}:{st.st_mtime_ns}' + try: st = os.stat(pth); out[os.path.relpath(pth, repo).replace(os.sep, '/')] = f'{st.st_size}:{st.st_mtime_ns}' except OSError: pass # a Java dependency is fetched into a repository outside the project (~/.m2, Gradle's cache), which no file here # records: which declared dependencies are present there is part of the fingerprint, so one fetched after the @@ -271,7 +272,7 @@ def dep_snapshot(repo): pass for env in ('.venv', 'venv', 'env', '.env'): for sp in glob.glob(os.path.join(repo, env, 'lib*', '*', 'site-packages')) + glob.glob(os.path.join(repo, env, 'Lib', 'site-packages')): - try: out[os.path.relpath(sp, repo)] = hashlib.sha1('|'.join(sorted(d for d in os.listdir(sp) if d.endswith('.dist-info'))).encode()).hexdigest() + try: out[os.path.relpath(sp, repo).replace(os.sep, '/')] = hashlib.sha1('|'.join(sorted(d for d in os.listdir(sp) if d.endswith('.dist-info'))).encode()).hexdigest() except OSError: pass return out @@ -295,7 +296,7 @@ def changes(repo, table=None): old = t.get('files', {}); src = os.path.join(repo, t.get('src') or '') changed, added, seen = [], [], set() for p in watched(src, t.get('lang', '')): - rel = os.path.relpath(p, repo); seen.add(rel) + rel = os.path.relpath(p, repo).replace(os.sep, '/'); seen.add(rel) try: st = os.stat(p) except OSError: continue o = old.get(rel) @@ -312,7 +313,7 @@ def changes(repo, table=None): for l in SOURCE: if l in mine: continue for p in watched(src, l): - rel = os.path.relpath(p, repo) + rel = os.path.relpath(p, repo).replace(os.sep, '/') if p.endswith(SOURCE[l]) and rel not in old: added.append(rel); break changed += dep_changes(t, repo) return sorted(changed), sorted(added), sorted(set(old) - seen) @@ -851,6 +852,30 @@ def _flock(fd, block): time.sleep(0.5) except OSError: return False +def hold(path, winpid, block): + """THE BUILD LOCK ON WINDOWS (Git Bash). There the build's `lock 9` cannot lock anything: fd 9 is Git Bash's, and a + native python.exe inherits only 0-2, so every build ran unlocked and two of one repository (a refresh and a query's + build, two refreshes) deleted each other's .progress and base-commit mid-build. This process holds the lock in the + build's place: it opens the lock file itself, takes the byte building() probes, says `held` (`busy` with --try when + another build has it), and waits for the build's Windows process to end, however it ends; Windows drops the lock + with this process. Returns the exit status: 0 held and released, 75 busy.""" + import ctypes, msvcrt + fd = os.open(path, os.O_RDWR | os.O_CREAT) + while True: + try: msvcrt.locking(fd, msvcrt.LK_NBLCK, 1); break + except OSError: + if not block: print('busy', flush=True); return 75 + time.sleep(0.5) + print('held', flush=True) + for f in (sys.stdout, sys.stderr): # the build reads `held` and deletes that file: leave it closed + try: f.close() + except OSError: pass + k = ctypes.windll.kernel32 + h = k.OpenProcess(0x00100000, False, int(winpid)) # SYNCHRONIZE + if h: + k.WaitForSingleObject(h, 0xFFFFFFFF); k.CloseHandle(h) + return 0 + def building(repo): """True while a build holds the build lock (the worker's or an explicit `axiomcode index`)""" p = os.path.join(repo, '.axiomcode', 'build.lock') @@ -1197,7 +1222,9 @@ def worker(repo): # the worker is detached and has no console, so Windows would give the console program bash a new, visible # window for the length of every rebuild; CREATE_NO_WINDOW keeps it hidden try: - r = subprocess.run([os.environ.get('AXIOMCODE_BASH') or 'bash', os.path.join(H, 'axiomcode-build'), repo], env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + import ax_exec + argv, env = ax_exec.bash_argv(os.path.join(H, 'axiomcode-build'), [repo], env) + r = subprocess.run(argv, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, **(dict(creationflags=0x08000000) if os.name == 'nt' else {})) finally: give_slot(slot) @@ -1605,6 +1632,8 @@ def main(argv): cmd = argv[1] if cmd == 'lock': return 0 if _flock(int(argv[2]), '--try' not in argv) else 75 + if cmd == 'hold': + return hold(argv[2], argv[3], '--try' not in argv) repo = os.path.realpath(argv[2]) if len(argv) > 2 else os.getcwd() if cmd in ('query', 'baseline', 'relink'): relink(repo, locked='--locked' in argv[3:4]) # every query verb passes here if cmd == 'relink': return 0 diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_libs.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_libs.py index d0616ed7c..622c61c88 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_libs.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_libs.py @@ -302,8 +302,9 @@ def compiled(src, engine, cache): log = out + '.log' os.makedirs(cache, exist_ok=True) # bin/axiomcode is a bash script: run through bash, which Windows cannot do for it (WinError 193) - rc = subprocess.run([os.environ.get('AXIOMCODE_BASH') or 'bash', os.path.join(engine, 'bin', 'axiomcode'), 'parser', src, out, '--library'], - stdout=open(log, 'w'), stderr=subprocess.STDOUT).returncode + import ax_exec + argv, env = ax_exec.bash_argv(os.path.join(engine, 'bin', 'axiomcode'), ['parser', src, out, '--library']) + rc = subprocess.run(argv, env=env, stdout=open(log, 'w'), stderr=subprocess.STDOUT).returncode if rc != 0 or not is_ir(out): note(f'{src} did not compile (rc {rc}, see {log}); not staged') return None diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_runner_setup.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_runner_setup.py index 67c74191d..4a3c9e153 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_runner_setup.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_runner_setup.py @@ -18,6 +18,7 @@ (`projects:` / `workspace`, or a vitest.workspace file), else the configuration's own root. """ import os +import posixpath import re CONFIG_NAME = re.compile(r'^(vitest\.config|vite\.config|vitest\.workspace|vitest\.shared|jest\.config)(\.[\w-]+)*\.(c|m)?[jt]s$') @@ -26,6 +27,8 @@ ROOTDIR = re.compile(r'''\brootDir\s*:\s*(['"])([^'"]*)\1''') LISTS_PROJECTS = re.compile(r'\b(projects|workspace)\s*:\s*\[') EXTS = ('', '.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs', '/index.ts', '/index.js') +# Paths here are repository-relative and written with `/`, as the graph stores them: posixpath, never os.path, which +# writes `\` on Windows, so no configuration there matched a file the graph holds. SKIP_DIRS = {'node_modules', '.git', 'dist', 'build', 'coverage', '.axiomcode', '.next', 'out'} @@ -34,7 +37,7 @@ def _configs(repo): dirs[:] = [x for x in dirs if x not in SKIP_DIRS and not x.startswith('.')] for f in files: if CONFIG_NAME.match(f): - yield os.path.relpath(os.path.join(d, f), repo) + yield os.path.relpath(os.path.join(d, f), repo).replace(os.sep, '/') def _read(repo, rel): @@ -48,9 +51,9 @@ def _resolve(repo, base, spec, files): """the repository file a set-up entry names, or None (a package, or nothing there)""" spec = spec.replace('', '.') if not spec.startswith(('.', '/')): return None # a package's own set-up - rel = os.path.normpath(os.path.join(base, spec.lstrip('/') if spec.startswith('/') else spec)) + rel = posixpath.normpath(posixpath.join(base, spec.lstrip('/') if spec.startswith('/') else spec)) for e in EXTS: - if (rel + e).replace(os.sep, '/') in files: return (rel + e).replace(os.sep, '/') + if rel + e in files: return rel + e return None @@ -58,15 +61,15 @@ def setup_files(repo, files): """[(setup file, kind, scope dir)] for every set-up file a runner configuration here names; `files` is the set of repository-relative files the graph holds. kind is 'each' (setupFiles*) or 'global' (globalSetup).""" cfgs = sorted(_configs(repo)) - lister = {os.path.dirname(c) for c in cfgs if os.path.basename(c).startswith('vitest.workspace') + lister = {posixpath.dirname(c) for c in cfgs if os.path.basename(c).startswith('vitest.workspace') or LISTS_PROJECTS.search(_read(repo, c))} out = set() for c in cfgs: text = _read(repo, c) if not KEY.search(text): continue - cdir = os.path.dirname(c) + cdir = posixpath.dirname(c) rd = ROOTDIR.search(text) - root = os.path.normpath(os.path.join(cdir, rd.group(2))) if rd else cdir + root = posixpath.normpath(posixpath.join(cdir, rd.group(2))) if rd else cdir root = '' if root == '.' else root for key, val in KEY.findall(text): kind = 'global' if key == 'globalSetup' else 'each' diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_spawn.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_spawn.py index f15e6c4a7..c6b5654c5 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_spawn.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_spawn.py @@ -18,6 +18,7 @@ they cannot drift apart on what counts as a spawn. """ import os +import posixpath import re # the process-starting calls, per language family. A bare `run(` / `call(` in Python is a spawn only where the file @@ -105,7 +106,7 @@ def _literals(text, arg, seen=(), depth=0): def _resolve(lits, f, known): """the indexed files a run of consecutive pieces names, tried from the test's own directory up to the root""" - here = os.path.dirname(f).split('/') if os.path.dirname(f) else [] + here = posixpath.dirname(f).split('/') if posixpath.dirname(f) else [] bases = ['/'.join(here[:k]) for k in range(len(here), -1, -1)] out = set() for j in range(len(lits)): @@ -113,7 +114,8 @@ def _resolve(lits, f, known): cand = '/'.join(p.strip('/') if k else p.rstrip('/') for k, p in enumerate(lits[i:j + 1]) if p) if not cand or cand.startswith('/') or cand in ('.', '..'): continue for b in bases: - p = os.path.normpath(os.path.join(b, cand)) if b else os.path.normpath(cand) + # repository-relative, `/` as the graph stores it: os.path wrote `\` on Windows and matched no file + p = posixpath.normpath(posixpath.join(b, cand)) if b else posixpath.normpath(cand) # `require.resolve('../bin/semver')`, `node bin/cli`: the runtime adds the extension, so try its own — # only on ONE literal that is itself a path: the middle piece of `join(ROOT, 'mcp', 'server.py')` is a # directory, and `mcp` + `.py` named a sibling test file that nothing runs diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode index bd6ed13b7..fc720397d 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode @@ -80,6 +80,12 @@ # ON WINDOWS $0 CAN MIX SEPARATORS: the MCP server joins its script path onto AXIOMCODE_PLUGIN_ROOT with os.path.join, # C:/.../plugins/axiomcode\skills\axiomcode\scripts\axiomcode. Splitting that on '/' alone lands on .../plugins, and # every verb then runs a helper that is not there. `dirname` split on either; so does this, as bin/axiomcode does. +# Windows: a native caller (bin/axiomcode.js, the MCP server, ax_exec.bash_argv) hands the arguments over in +# AXIOMCODE_ARGV, each ended by \x1f, because msys misparses them on a command line. Taken once, so a script this one +# starts gets its own. +if [ -n "${AXIOMCODE_ARGV+x}" ]; then + set --; while IFS= read -r -d $'\x1f' _a; do set -- "$@" "$_a"; done < <(printf '%s' "$AXIOMCODE_ARGV"); unset AXIOMCODE_ARGV _a +fi H="${0//\\//}"; case "$H" in */*) H="${H%/*}";; *) H=.;; esac; H="$(cd "${H:-/}" && pwd)" # no `dirname`: see bin/axiomcode # PYTHON UNDER ANOTHER NAME (#1331). Every verb runs `python3`, which a python.org install on Windows does not diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build index f0e1f5e78..210b6b42f 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build @@ -15,6 +15,12 @@ # (the `axiomcode` dispatcher) ask each of them. Each graph is one language: no call is followed from one to another. # Each is published as soon as its own language is solved, the main one first, so a query never waits for another # language's compile; until the last one is in, .axiomcode/out/building names the languages still to come. +# Windows: a native caller (bin/axiomcode.js, the MCP server, ax_exec.bash_argv) hands the arguments over in +# AXIOMCODE_ARGV, each ended by \x1f, because msys misparses them on a command line. Taken once, so a script this one +# starts gets its own. +if [ -n "${AXIOMCODE_ARGV+x}" ]; then + set --; while IFS= read -r -d $'\x1f' _a; do set -- "$@" "$_a"; done < <(printf '%s' "$AXIOMCODE_ARGV"); unset AXIOMCODE_ARGV _a +fi set -euo pipefail H="$(cd "$(dirname "$0")" && pwd)" # a repository that is not there is an error, never the working directory, and nothing is built @@ -159,9 +165,21 @@ OUT="$REPO/.axiomcode/out"; mkdir -p "$OUT" # write $OUT/$LANG_ARG; two at once clobber each other's graph. The lock is on fd 9, which this shell holds for its # whole life, so the OS drops it when the build ends however it ends: no stale lock to time out. exec 9>"$REPO/.axiomcode/build.lock" -if ! python3 "$H/ax_fresh.py" lock 9 --try; then +# ON WINDOWS a native python cannot lock Git Bash's fd 9, so a helper holds the lock for this build until it ends +# (ax_fresh.py hold): it answers `held` or `busy` in a file, then holds on, released when this shell's process ends. +take_lock(){ + if [ -r "/proc/$$/winpid" ]; then + local f="$REPO/.axiomcode/build.lock.$$" r="" + python3 "$H/ax_fresh.py" hold "$REPO/.axiomcode/build.lock" "$(cat "/proc/$$/winpid")" "$@" > "$f" 2>/dev/null & + local hp=$! + while [ -z "$r" ] && kill -0 "$hp" 2>/dev/null; do r="$(cat "$f" 2>/dev/null)"; [ -n "$r" ] || sleep 0.2; done + [ -n "$r" ] || r="$(cat "$f" 2>/dev/null)" + rm -f "$f"; [ "$r" = held ] + else python3 "$H/ax_fresh.py" lock 9 "$@"; fi +} +if ! take_lock --try; then [ -n "${AXIOMCODE_BACKGROUND:-}" ] || echo "a graph build is already running for $REPO; waiting for it …" - python3 "$H/ax_fresh.py" lock 9 || { echo "could not take the build lock ($REPO/.axiomcode/build.lock)"; exit 75; } + take_lock || { echo "could not take the build lock ($REPO/.axiomcode/build.lock)"; exit 75; } fi python3 "$H/ax_fresh.py" relink "$REPO" --locked || true # a pointer copied from another checkout is this one's again (#1605) BUILD_T0=$SECONDS # how long a build takes to a usable graph: a query decides from it whether to wait (#1595) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph index af4b1b6c3..6ad4f2497 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph @@ -232,7 +232,9 @@ def run_build(repo, env=None): b = next((os.path.realpath(c) for c in cands if os.path.isfile(c)), None) if not b: sys.exit("axiomcode-build not found beside this script (set AXIOMCODE_ENGINE to the engine checkout)") sys.stdout.flush() - r = subprocess.run([os.environ.get('AXIOMCODE_BASH') or 'bash', b, repo], env=env) # a bash script: Windows cannot exec it itself + import ax_exec + argv, env = ax_exec.bash_argv(b, [repo], env) + r = subprocess.run(argv, env=env) # a bash script: Windows cannot exec it itself if r.returncode: sys.exit(r.returncode) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 03fbfaf67..2cb8cf86a 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -60,7 +60,7 @@ about values: a callable that is reached may or may not change behaviour; a call resolved chain to the change or reaches it through something the graph cannot see (reflection, a framework, a library callback) — the bound line says how many such sites there are. """ -import collections, contextlib, fnmatch, importlib.machinery, importlib.util, io, json, os, re, shutil, subprocess, sys, tempfile, time +import collections, contextlib, fnmatch, importlib.machinery, importlib.util, io, json, os, posixpath, re, shutil, subprocess, sys, tempfile, time PROF = os.environ.get('AXIOMCODE_PROFILE'); _t0 = time.time() def prof(what): if PROF: print(f" [{time.time() - _t0:5.1f}s] {what}", file=sys.stderr) @@ -963,7 +963,7 @@ class Impact: k = text.find(')', m.end()) for pat in self.JS_GLOB_PAT.findall(text[m.end():k] if k > 0 else ''): if pat.startswith('!'): continue - full = os.path.normpath(os.path.join(os.path.dirname(f), pat)) if pat.startswith('.') else pat.lstrip('/') + full = posixpath.normpath(posixpath.join(posixpath.dirname(f), pat)) if pat.startswith('.') else pat.lstrip('/') out.update(x for x in files if fnmatch.fnmatch(x, full)) return out @@ -1024,7 +1024,7 @@ class Impact: return {n for n in names if re.match(r'^[A-Za-z_$][\w$]*$', n)} def _js_resolve(self, f, raw, byfile): - if raw.startswith('.'): return self._js_file(os.path.normpath(os.path.join(os.path.dirname(f), raw)), byfile) + if raw.startswith('.'): return self._js_file(posixpath.normpath(posixpath.join(posixpath.dirname(f), raw)), byfile) if raw.startswith('#'): return self._pkg_imports(f, raw, byfile) return self._workspace_import(raw, byfile) or self._ts_alias(f, raw, byfile) @@ -1047,7 +1047,7 @@ class Impact: self._tsc = ([], {}) for d, dirs, fs in os.walk(self.g.repo): dirs[:] = sorted(x for x in dirs if x not in self.SKIP_DIR and not x.startswith('.')) - rd = os.path.relpath(d, self.g.repo); rd = '' if rd == '.' else rd + rd = os.path.relpath(d, self.g.repo).replace(os.sep, '/'); rd = '' if rd == '.' else rd for fn in sorted(fs): if re.match(r'^[tj]sconfig.*\.json$', fn): try: txt = open(os.path.join(d, fn), encoding='utf-8', errors='replace').read() @@ -1056,10 +1056,10 @@ class Impact: try: co = json.loads(re.sub(r',(\s*[}\]])', r'\1', txt)) except ValueError: continue co = (co.get('compilerOptions') or {}) if isinstance(co, dict) else {} - base = os.path.normpath(os.path.join(rd, co.get('baseUrl') or '.')) + base = posixpath.normpath(posixpath.join(rd, co.get('baseUrl') or '.')) for k, vs in (co.get('paths') or {}).items(): if not isinstance(vs, list): continue - tg = [os.path.normpath(os.path.join(base, v[:-1] if v.endswith('*') else v)) + ('/' if v.endswith('/*') else '') + tg = [posixpath.normpath(posixpath.join(base, v[:-1] if v.endswith('*') else v)) + ('/' if v.endswith('/*') else '') for v in vs if isinstance(v, str)] self._tsc[0].append((rd, k[:-1] if k.endswith('*') else k, k.endswith('*'), tg)) elif fn == 'package.json': @@ -1077,7 +1077,7 @@ class Impact: def _ts_alias(self, f, raw, byfile): for cands in self._alias_matches(f, raw): for c in cands: - t = self._js_file(os.path.normpath(c), byfile) + t = self._js_file(posixpath.normpath(c), byfile) if t: return t return None @@ -1089,7 +1089,7 @@ class Impact: ents = []; self._leaves(maps[d].get(raw), ents) for e in ents: e = re.sub(r'\.[mc]?js$', '', e) - t = self._js_file(os.path.normpath(os.path.join(d, e)), byfile) + t = self._js_file(posixpath.normpath(posixpath.join(d, e)), byfile) if t: return t return None if not d: return None @@ -1135,7 +1135,7 @@ class Impact: leaves(ex.get('.') if isinstance(ex, dict) and any(k.startswith('.') for k in ex) else ex) entries += [x for x in (pj.get('main'), pj.get('module')) if isinstance(x, str)] for e in entries: - hit = self._js_file(os.path.normpath(os.path.join(base, e)) if base else os.path.normpath(e), byfile, False) + hit = self._js_file(posixpath.normpath(posixpath.join(base, e)) if base else posixpath.normpath(e), byfile, False) if hit: return hit return None @@ -1179,7 +1179,7 @@ class Impact: entry = pick(ex) or pj.get('module') or pj.get('main') or 'index' else: entry = sub - t = self._js_file(os.path.normpath(os.path.join(d, entry)), byfile) if entry else None + t = self._js_file(posixpath.normpath(posixpath.join(d, entry)), byfile) if entry else None if t: return t # THE SOURCE AN ENTRY IS BUILT FROM. `exports` / `main` name the build output (dist/index.js, # dist/types/helper/x/index.d.ts), which is not in the repository; a test of the workspace that imports the package @@ -1196,12 +1196,12 @@ class Impact: cands += [e, 'src/' + rest, rest] cands += ['src/index', 'index'] if sub == '.' else ['src/' + sub[2:], sub[2:]] for c in cands: - t = self._js_file(os.path.normpath(os.path.join(d, c)), byfile) + t = self._js_file(posixpath.normpath(posixpath.join(d, c)), byfile) if t: return t # …and the package's source directory as a module (its index, never its package.json again): `main: dayjs.min.js` # names a bundle the checkout does not hold, and its tests run the source the build starts from if sub == '.': - return next((x for x in (self._js_file(os.path.normpath(os.path.join(d, sd)) if d else sd, byfile, False) + return next((x for x in (self._js_file(posixpath.normpath(posixpath.join(d, sd)) if d else sd, byfile, False) for sd in ('src', 'lib', 'source')) if x), None) return None @@ -1298,7 +1298,7 @@ class Impact: text = '\n'.join(self.lines(f)) # the raw text: code() blanks the specifiers names = {a or b for _k, a, b in self.GLOBAL_EXPORT.findall(text)} - {''} if names: - near = {os.path.normpath(os.path.join(os.path.dirname(f), s)) for s in self.IMPORT_FROM.findall(text)} + near = {posixpath.normpath(posixpath.join(posixpath.dirname(f), s)) for s in self.IMPORT_FROM.findall(text)} for nf, ids in by_file.items(): if os.path.splitext(nf)[0] in near or nf == f: fx += [i for i in ids if g.sym[i]['name'] in names] diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index aad1bdf1e..74fd3331c 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -457,10 +457,14 @@ class G: its leading `/` dropped as before, so a root-relative `/src/a.js` still matches — and is named as outside when not.""" while f.startswith('./'): f = f[2:] if not os.path.isabs(f): return f, False + # Containment by commonpath on normcased paths, not a `root + '/'` prefix: on Windows abspath writes `\\` and + # either case for the drive, so a file under the repository was refused as outside it. real = os.path.realpath(f) - for root in {self.repo, os.path.abspath(self.repo)}: + for root in {self.repo, os.path.abspath(self.repo), os.path.realpath(self.repo)}: for p in (real, os.path.abspath(f)): - if p == root or p.startswith(root.rstrip('/') + '/'): + try: inside = os.path.commonpath([os.path.normcase(p), os.path.normcase(root)]) == os.path.normcase(root) + except ValueError: inside = False # another drive + if inside: return os.path.relpath(p, root).replace(os.sep, '/'), False return f.lstrip('/'), True def file_lines(self, f): diff --git a/tests/README.md b/tests/README.md index 6b25a253e..06150d2fd 100644 --- a/tests/README.md +++ b/tests/README.md @@ -75,6 +75,9 @@ One check needs no graph and is its own script: no rules and no soufflé impact answers from SQL (builds real graphs, needs the engine) python3 tests/no_symlink.py impact and the Datalog path answer where os.symlink is refused, as it is for an unelevated Windows user (WinError 1314); indexes a case, so it needs the engine + python3 tests/windows_argv.py an argument holding ' or " reaches the verb as written when a native program starts + Git Bash the Windows way (AXIOMCODE_ARGV, ax_exec.bash_argv), through both entry + points; taken once; the command-line branch is the control (no engine) python3 tests/no_exec.py impact, path and context answer, with their exit status, where os.exec* does not replace the process, as on Windows; no script or hook calls os.exec* but ax_exec.py (#1640; indexes a case, so it needs the engine) diff --git a/tests/no_exec.py b/tests/no_exec.py index b8faa006a..6c7e18f6c 100644 --- a/tests/no_exec.py +++ b/tests/no_exec.py @@ -72,7 +72,7 @@ def main(): subprocess.run(['git', 'init', '-q', '.'], cwd=repo, check=True) subprocess.run(['git', 'add', '-A'], cwd=repo, check=True) subprocess.run(['git', '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'x'], cwd=repo, check=True) - r = subprocess.run(['bash', os.path.join(SCRIPTS, 'axiomcode-build'), repo], capture_output=True, text=True, timeout=600) + r = subprocess.run([os.environ.get('AXIOMCODE_BASH') or 'bash', os.path.join(SCRIPTS, 'axiomcode-build'), repo], capture_output=True, text=True, timeout=600) if r.returncode != 0: print('no_exec: index failed\n' + (r.stdout + r.stderr)[-1500:]); return 1 site = os.path.join(work, 'site'); os.makedirs(site) diff --git a/tests/no_symlink.py b/tests/no_symlink.py index 15254b180..a8c29cdb5 100644 --- a/tests/no_symlink.py +++ b/tests/no_symlink.py @@ -30,7 +30,7 @@ def main(): subprocess.run(['git', 'init', '-q', '.'], cwd=repo, check=True) subprocess.run(['git', 'add', '-A'], cwd=repo, check=True) subprocess.run(['git', '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'x'], cwd=repo, check=True) - r = subprocess.run(['bash', os.path.join(SCRIPTS, 'axiomcode-build'), repo], capture_output=True, text=True, timeout=600) + r = subprocess.run([os.environ.get('AXIOMCODE_BASH') or 'bash', os.path.join(SCRIPTS, 'axiomcode-build'), repo], capture_output=True, text=True, timeout=600) if r.returncode != 0: print('no_symlink: index failed\n' + (r.stdout + r.stderr)[-1500:]); return 1 site = os.path.join(work, 'site'); os.makedirs(site) diff --git a/tests/run.py b/tests/run.py index 90b9be302..72991bc6b 100755 --- a/tests/run.py +++ b/tests/run.py @@ -35,9 +35,31 @@ and neither announces itself as a want/avoid mismatch. If anyone ever "simplifies" `pending` into a skip, that is the property they will have removed. """ -import builtins, concurrent.futures, io, json, os, re, shutil, subprocess, sys +import builtins, concurrent.futures, io, json, os, re, shutil, sqlite3, subprocess, sys HERE = os.path.dirname(os.path.abspath(__file__)); ROOT = os.path.dirname(HERE) AX = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode') +# THE DISPATCHER IS STARTED AS THE PRODUCT STARTS IT, so the cases answer for Windows too: Git Bash found the way the CLI +# finds it (a bare `bash` there is System32's WSL launcher), MSYS=noglob as the CLI sets it (`'*'` reached the verbs as the +# directory's file names), and the arguments handed over by ax_exec.bash_argv (msys misparses `'` and `"` on a command line). +sys.path.insert(0, os.path.dirname(AX)); import ax_exec, ax_fresh, time +def clear(path): + """remove a case's .axiomcode once nothing is writing it. A check that edits a file leaves a background refresh + running after the case's last answer; removed under it, the refresh failed and wrote that failure into what Windows + could not delete (an open file there cannot be), and the next run of the case found a graph that "failed on this + tree", which is not retried. So: wait for the refresh and any build, then remove, retrying what is still held.""" + d = os.path.join(path, '.axiomcode') + end = time.time() + 180 + while os.path.isdir(d) and time.time() < end and (ax_fresh.refresher_running(path) or ax_fresh.building(path)): time.sleep(0.5) + while os.path.isdir(d) and time.time() < end: + shutil.rmtree(d, ignore_errors=True) + if os.path.isdir(d): time.sleep(0.5) +for _s in (sys.stdout, sys.stderr): _s.reconfigure(encoding='utf-8', errors='replace') # answers print → and ·; Windows' file encoding is cp1252 +BASH = os.environ.get('AXIOMCODE_BASH') or (subprocess.run(['node', '-e', "process.stdout.write(require(process.argv[1]).findBash().bash || '')", + os.path.join(ROOT, 'plugins', 'axiomcode', 'mcp', 'find-bash.js')], capture_output=True, text=True).stdout if os.name == 'nt' else '') or 'bash' +BASE_ENV = dict(os.environ, MSYS=(os.environ.get('MSYS', '') + ' noglob').strip()) if os.name == 'nt' else None +def ax(argv, env=None): + a, e = ax_exec.bash_argv(AX, argv, env if env is not None else BASE_ENV, BASH) + return subprocess.run(a, capture_output=True, text=True, encoding='utf-8', errors='replace', env=e) args = sys.argv[1:]; keep = '--keep' in args; verbose = '-v' in args lang = args[args.index('--lang') + 1] if '--lang' in args else None jobs = int(args[args.index('--jobs') + 1]) if '--jobs' in args else 1 @@ -53,27 +75,30 @@ def run_case(case): l, name, path = case; buf = io.StringIO(); fail = tot = pend = 0 def print(*a, flush=False, **k): builtins.print(*a, file=buf, **k) print(f"… {l}/{name}", flush=True) - spec = json.load(open(os.path.join(path, 'case.json'))) - build = ['bash', AX, 'index', path, '--lang', spec.get('lang', l)] + (['--src', spec['src']] if spec.get('src') else []) \ + spec = json.load(open(os.path.join(path, 'case.json'), encoding='utf-8')) + clear(path) # what a stopped run left: start as a fresh checkout does + build = ['index', path, '--lang', spec.get('lang', l)] + (['--src', spec['src']] if spec.get('src') else []) \ + (['--library', os.path.join(path, spec['library'])] if spec.get('library') else []) # a staged dependency root, relative to the case - r = subprocess.run(build, capture_output=True, text=True) + r = ax(build) if r.returncode: print(f"FAIL {l}/{name}: index failed: {(r.stderr or r.stdout)[-300:]}"); return buf.getvalue(), 0, 1, 0 for stmt in spec.get('sql', []): # facts a framework extension would have written - subprocess.run(['sqlite3', os.path.join(path, '.axiomcode', 'out', 'graph.sqlite'), stmt], capture_output=True, text=True) + try: # the module, not the sqlite3 program: Windows has none + with sqlite3.connect(os.path.join(path, '.axiomcode', 'out', 'graph.sqlite')) as db: db.executescript(stmt) + except sqlite3.Error: pass # "edit": [file, old, new] — the case edits one of its files before that check runs (an asserted link whose line moved # or changed); every edited file, and a links file the case wrote, is put back when the case ends - links_file = os.path.join(path, 'axiomcode-links.tsv'); links_had = open(links_file).read() if os.path.exists(links_file) else None + links_file = os.path.join(path, 'axiomcode-links.tsv'); links_had = open(links_file, encoding='utf-8', newline='').read() if os.path.exists(links_file) else None edited = {} for ch in spec['checks']: tot += 1 if ch.get('edit'): - ef, old, new = ch['edit']; fp = os.path.join(path, ef); txt = open(fp).read() + ef, old, new = ch['edit']; fp = os.path.join(path, ef); txt = open(fp, encoding='utf-8', newline='').read() edited.setdefault(fp, txt) if old not in txt: print(f"FAIL {l}/{name}: the edit's old text is not in {ef}"); fail += 1; continue - open(fp, 'w').write(txt.replace(old, new, 1)) + open(fp, 'w', encoding='utf-8', newline='').write(txt.replace(old, new, 1)) # "env": {…} — the check runs with these set (AXIOMCODE_FRONT=1: the answer the installed command and MCP give) - out = subprocess.run(['bash', AX] + [a.replace('{repo}', path) for a in ch['run']] + ([path] if ch['run'][0] != 'index' else []), capture_output=True, text=True, - env=dict(os.environ, **ch['env']) if ch.get('env') else None) + out = ax([a.replace('{repo}', path) for a in ch['run']] + ([path] if ch['run'][0] != 'index' else []), + dict(BASE_ENV or os.environ, **ch['env']) if ch.get('env') else None) text = out.stdout + out.stderr # a [text] row quoting this case.json is the spec read back (a name no graph declares is searched as text, and the # case file lies in the searched tree): its own `avoid` strings there are not the tool's answer @@ -92,7 +117,7 @@ def print(*a, flush=False, **k): builtins.print(*a, file=buf, **k) # "same_as": [run …] — STDOUT must be BYTE-IDENTICAL to another run's: a switch that must not change an answer # (evidence on an answer with no uncertain row, a default that is off) is a comparison, not a substring if ch.get('same_as'): - other = subprocess.run(['bash', AX] + [a.replace('{repo}', path) for a in ch['same_as']] + [path], capture_output=True, text=True) + other = ax([a.replace('{repo}', path) for a in ch['same_as']] + [path]) if other.stdout != out.stdout: a_, b_ = out.stdout.split('\n'), other.stdout.split('\n') i = next((i for i, (x, y) in enumerate(zip(a_, b_)) if x != y), min(len(a_), len(b_))) @@ -115,11 +140,11 @@ def print(*a, flush=False, **k): builtins.print(*a, file=buf, **k) # a traceback's last line is the error itself: keep the head (what it answered) and the tail (why it stopped) print(' ' + '\n '.join(lines[:14] + (['…'] + lines[-12:] if len(lines) > 26 else lines[14:]))) elif verbose: print(f"ok {l}/{name}: {ch['why']}") - for fp, txt in edited.items(): open(fp, 'w').write(txt) + for fp, txt in edited.items(): open(fp, 'w', encoding='utf-8', newline='').write(txt) if links_had is None: if os.path.exists(links_file): os.remove(links_file) - else: open(links_file, 'w').write(links_had) - if not keep: shutil.rmtree(os.path.join(path, '.axiomcode'), ignore_errors=True) + else: open(links_file, 'w', encoding='utf-8', newline='').write(links_had) + if not keep: clear(path) return buf.getvalue(), tot, fail, pend diff --git a/tests/windows_argv.py b/tests/windows_argv.py new file mode 100644 index 000000000..b3d29fbb5 --- /dev/null +++ b/tests/windows_argv.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""tests/windows_argv.py: an argument holding ' or " reaches the verb as written when started the Windows way. + +A native program (bin/axiomcode.js, the MCP server, a build Python starts) cannot hand Git Bash its arguments on a +command line: the msys runtime parses one by its own rules, where `'` quotes too and `\\"` is no escape. So on Windows +`context "how does the user's cart work"` lost every argument after the apostrophe, and `impact '"Widgets:MinCount"'` +asked for `\\Widgets:MinCount\\`. There the arguments travel in AXIOMCODE_ARGV (ax_exec.bash_argv) and each bash +entry point turns them back into "$@". This takes that Windows branch on any OS (ax_exec.WINDOWS_ARGV) through both +entry points, with a repository argument the dispatcher names back when it does not exist. + +Checks: + each entry point names back an argument with ', ", a space and a backslash exactly, through AXIOMCODE_ARGV + an empty argument and one with a newline keep their place: the argument after them is still the repository + the variable is taken once: a script the entry point starts does not see it (the build would re-read the CLI's args) + control: the command-line branch (every other OS) answers the same +""" +import os, subprocess, sys, tempfile +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SCRIPTS = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts') +sys.path.insert(0, SCRIPTS) +import ax_exec + +ENTRIES = {'dispatcher': os.path.join(SCRIPTS, 'axiomcode'), 'bin': os.path.join(ROOT, 'bin', 'axiomcode')} +fails = total = 0 + + +def check(why, ok, detail=''): + global fails, total + total += 1 + if not ok: fails += 1; print(f"FAIL {why}\n {detail}") + + +def run(entry, args, windows): + ax_exec.WINDOWS_ARGV = windows + argv, env = ax_exec.bash_argv(entry, args, dict(os.environ)) + r = subprocess.run(argv, env=env, capture_output=True, text=True, timeout=120) + return r.stdout + r.stderr + + +with tempfile.TemporaryDirectory() as tmp: + odd = os.path.join(tmp, 'no such \'repo\' "q" a\\b') # never created: the dispatcher names it back + for name, entry in ENTRIES.items(): + for windows in (True, False): + how = 'AXIOMCODE_ARGV' if windows else 'the command line' + out = run(entry, ['impact', 'foo', odd], windows) + check(f"{name}: an argument with ', \", a space and a backslash arrives as written through {how}", + f"no such directory: {odd}" in out, out[-300:]) + out = run(entry, ['impact', '', odd], windows) if name == 'dispatcher' else '' + if name == 'dispatcher': + check(f"{name}: an empty argument keeps its place through {how}", f"no such directory: {odd}" in out, out[-300:]) + out = run(entry, ['impact', 'a\nb', odd], windows) + check(f"{name}: an argument with a newline keeps its place through {how}", f"no such directory: {odd}" in out, out[-300:]) + + # taken once: an entry point that sets the variable for itself would hand the CLI's arguments to every script it + # starts. A probe script beside the dispatcher prints what it was given and whether the variable reached it. + probe = os.path.join(tmp, 'probe.sh') + with open(probe, 'w') as h: + h.write(open(ENTRIES['dispatcher']).read().split('\n', 1)[0] + '\n') + src = open(ENTRIES['dispatcher']).read() + start = src.index('if [ -n "${AXIOMCODE_ARGV+x}" ]; then'); end = src.index('\nfi\n', start) + 4 + h.write(src[start:end]) + h.write('printf "<%s>" "$@"; echo; bash -c \'echo "child=${AXIOMCODE_ARGV-unset}"\'\n') + ax_exec.WINDOWS_ARGV = True + argv, env = ax_exec.bash_argv(probe, ["it's", '"x"'], dict(os.environ)) + out = subprocess.run(argv, env=env, capture_output=True, text=True).stdout + check("the prologue rebuilds \"$@\" exactly", "<\"x\">" in out, out) + check("the variable is taken once: a script started after it does not see it", "child=unset" in out, out) + +print(f"{total - fails} of {total} check(s) held" if not fails else f"{fails} of {total} FAILED") +sys.exit(1 if fails else 0)