diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb6710f5..dbb5196f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,7 @@ permissions: contents: read env: - # bin/axiomcode and the parser need Node ≥ 22.5. + # bin/axiomcode and the parser need Node ≥ 22.13 (node:sqlite without a flag). NODE_VERSION: '22' SOUFFLE_VERSION: '2.5' SOUFFLE_SHA512: '6b86e554f6aa5abf8a8b55d8312ae37c0957c5bd6c9edeea89246db9406f645ec5e600b84fe6636b1c163da556f0da6c3d2dad46c1083413f2fcf4f95b9ac62c' diff --git a/README.md b/README.md index 2ff85549..c9153fa3 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ npm Nightly (dev) License: FSL-1.1-Apache-2.0 - Node ≥ 22.5 + Node ≥ 22.13

@@ -125,7 +125,7 @@ AxiomCode Graph is two parts. The **engine** (`@axiomcode/code-graph` on npm) pa graph; it also provides the `axiomcode` command and an MCP server. The **plugin** (`plugins/axiomcode/`) is the agent-facing frontend: a skill, four MCP tools, and hooks. Install the engine first. -Requirements: **Node ≥ 22.5** and **Python 3** (`python3`, or `python` / `py` on Windows). On Windows, also +Requirements: **Node ≥ 22.13** and **Python 3** (`python3`, or `python` / `py` on Windows). On Windows, also [Git for Windows](https://git-scm.com/download/win): the CLI runs under its bash. The engine ships as a prebuilt binary for macOS (Apple Silicon and Intel), Linux (x64 and arm64) and Windows x64, and `npm install` takes the one for your platform. No Soufflé and no compiler are needed, with one exception: @@ -334,9 +334,9 @@ command's usage. The graph stays current on its own. Every file the parser reads is recorded with its hash at build time; after an edit, a shell command, a finished turn, at session start, and before a query, anything that differs starts one background rebuild per repository, with the language, `--src` and `--library` of the graph it replaces. Every -command keeps reading the previous graph until the new one is indexed and swapped in. A query waits up to -`AXIOMCODE_FRESH_WAIT` seconds (default 10) for it, then answers from the previous graph with a `graph refresh:` line -naming the files it predates. The MCP server also checks every repository it has answered for once 15 minutes have +command keeps reading the previous graph until the new one is indexed and swapped in. A query answers from the +previous graph at once, with a `graph refresh:` line naming the files it predates and the rows in them marked; +`--fresh` waits for the rebuild instead, and `AXIOMCODE_FRESH_WAIT` (seconds, default 0) lets every query wait that long. The MCP server also checks every repository it has answered for once 15 minutes have passed since its last update (`AXIOMCODE_REFRESH_INTERVAL`, seconds; 0 turns it off), which catches edits made while a session sits idle. The graph records when and why it was built in `index_meta` (`refreshed_at`, `refresh_reason`). `AXIOMCODE_NO_REFRESH=1` turns the rebuilds off, not the check: an answer from a graph older than an edit still diff --git a/bin/axiomcode.js b/bin/axiomcode.js index 72a1fc1a..3f54b1e5 100755 --- a/bin/axiomcode.js +++ b/bin/axiomcode.js @@ -37,6 +37,14 @@ if (args.length === 1 && args[0] === '--version') { process.exit(0); } +// THE NODE FLOOR, SAID BEFORE ANYTHING RUNS. The graph is written with node:sqlite, which Node ships without a flag from +// 22.13 (and 23.4); on 22.5-22.12 the index failed minutes in with "No such built-in module: node:sqlite". Asked by +// version, not by loading the module, which would print Node's experimental-feature warning on every command. +const [nodeMaj, nodeMin] = process.versions.node.split('.').map(Number); +if (!(nodeMaj > 23 || (nodeMaj === 23 && nodeMin >= 4) || (nodeMaj === 22 && nodeMin >= 13))) { + fail(`axiomcode needs Node 22.13 or newer (it writes the graph with node:sqlite); this is Node ${process.versions.node}.`); +} + const { bash, error } = findBash(); if (error) fail(error); const py = findPython(); diff --git a/graph/bundle/cli.ts b/graph/bundle/cli.ts index adb2061c..ad975fad 100644 --- a/graph/bundle/cli.ts +++ b/graph/bundle/cli.ts @@ -95,7 +95,7 @@ export async function main(argv = process.argv.slice(2)): Promise { log(`▶ wrote ${dbPath}${a.debug ? ' (+ csv/*.csv, --debug)' : ''}`); } else { fs.rmSync(dbPath, { force: true }); - console.error(` ! node ${process.versions.node} has no node:sqlite (needs ≥ 22.5) — graph.sqlite NOT written; csv/*.csv is complete`); + console.error(` ! node ${process.versions.node} has no node:sqlite (needs ≥ 22.13) — graph.sqlite NOT written; csv/*.csv is complete`); } log(`▶ bundle complete in ${((Date.now() - t0) / 1000).toFixed(1)}s`); } diff --git a/graph/bundle/node-sqlite.d.ts b/graph/bundle/node-sqlite.d.ts index a2d35d8c..618db642 100644 --- a/graph/bundle/node-sqlite.d.ts +++ b/graph/bundle/node-sqlite.d.ts @@ -1,5 +1,5 @@ /** - * The slice of `node:sqlite` this package uses. Node ships the module from 22.5; the + * The slice of `node:sqlite` this package uses. Node ships the module unflagged from 22.13; the * installed @types/node predates it, so the four members used here are declared locally * rather than pulling a newer type package for one import. */ diff --git a/graph/bundle/write.ts b/graph/bundle/write.ts index fcc4a347..d00899fe 100644 --- a/graph/bundle/write.ts +++ b/graph/bundle/write.ts @@ -20,10 +20,11 @@ export function writeCoreCsv(graphDir: string, core: CoreTables, log: (s: string } } -/** Node ≥ 22.5 ships `node:sqlite`. */ +/** Whether `node:sqlite` loads here. Asked of the module, not the version: Node 22.5–22.12 and 23.0–23.3 ship it only + * behind --experimental-sqlite, and a version test passed them, so the import that followed failed with "No such + * built-in module: node:sqlite" on Node 22.11. */ export function sqliteAvailable(): boolean { - const [maj, min] = process.versions.node.split('.').map(Number); - return maj! > 22 || (maj === 22 && min! >= 5); + try { require('node:sqlite'); return true; } catch { return false; } } function createSql(t: TableSpec, prefix = ''): string { diff --git a/graph/test/csharp/README.md b/graph/test/csharp/README.md index 56247142..1663ccb8 100644 --- a/graph/test/csharp/README.md +++ b/graph/test/csharp/README.md @@ -306,7 +306,7 @@ dotnet build -c Release graph/test/csharp/ground-truth/AxiomCsOracle dotnet build -c Release graph/test/csharp/runtime-oracle/AxiomCsInstrument ``` -`node` must be 22.5 or newer (what the repository itself requires), and the SAME version across a baseline and a fix run: a +`node` must be 22.13 or newer (what the repository itself requires), and the SAME version across a baseline and a fix run: a number measured on one version and compared against another is not a measurement. Every script exits 77 when a tool it needs is missing, so a machine without the toolchain skips rather than reporting a false failure. diff --git a/package.json b/package.json index 2a28f9fe..448fe575 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "schema-doc": "tsx graph/bundle/cli.ts --print-schema > graph/bundle/SCHEMA.md" }, "engines": { - "node": ">=22.5.0" + "node": ">=22.13.0" }, "devDependencies": { "@types/node": "^20.10.0", diff --git a/parser/package.json b/parser/package.json index a8ac8e3b..46a15929 100644 --- a/parser/package.json +++ b/parser/package.json @@ -13,7 +13,7 @@ "test": "vitest" }, "engines": { - "node": ">=22.5.0" + "node": ">=22.13.0" }, "dependencies": { "entities": "6.0.1", diff --git a/plugins/axiomcode/mcp/find-bash.js b/plugins/axiomcode/mcp/find-bash.js index 2d58c4bf..3414048d 100644 --- a/plugins/axiomcode/mcp/find-bash.js +++ b/plugins/axiomcode/mcp/find-bash.js @@ -33,12 +33,32 @@ function gitBash() { return candidates.find((p) => fs.existsSync(p)); } +// AXIOMCODE_BASH as a program would be started: a path is taken as written, a bare name is looked up on PATH. The +// command exports the bash it chose to everything it starts, and on POSIX that is the bare `bash`, so `axiomcode mcp` +// reached the MCP launcher with AXIOMCODE_BASH=bash; read as a path, it "did not exist", and the launcher warned that +// every tool would fail while every tool worked (#1357). The PATH search skips relative directories, as which.js does. +// Not on Windows: the command always exports a full path there, and a bare `bash` on that PATH is WSL's (see above). +function onPath(name) { + if (path.isAbsolute(name) || /[\\/]/.test(name)) return fs.existsSync(name) ? name : null; + if (process.platform === 'win32') return null; + for (const d of (process.env.PATH || '').split(path.delimiter)) { + if (!d || !path.isAbsolute(d)) continue; + const p = path.join(d, name); + try { fs.accessSync(p, fs.constants.X_OK); if (fs.statSync(p).isFile()) return p; } catch { /* not here */ } + } + return null; +} + // { bash } or { error }: callers decide how to fail, because the MCP launcher must say it on stderr and // still leave the client a reason, while the command exits 127 as a shell would for a missing program. function findBash() { const override = process.env.AXIOMCODE_BASH; if (override) { - return fs.existsSync(override) ? { bash: override } : { error: `AXIOMCODE_BASH is set to ${override}, which does not exist.` }; + const found = onPath(override); + if (found) return { bash: found }; + const named = !(path.isAbsolute(override) || /[\\/]/.test(override)); + return { error: `AXIOMCODE_BASH is set to ${override}, which ` + (!named ? 'does not exist.' + : process.platform === 'win32' ? 'is not a full path; on Windows set it to the full path of Git Bash\'s bash.exe.' : 'is not on PATH.') }; } if (process.platform !== 'win32') return { bash: 'bash' }; const found = gitBash(); diff --git a/tests/mcp.py b/tests/mcp.py index 2870385e..b6e43791 100644 --- a/tests/mcp.py +++ b/tests/mcp.py @@ -377,6 +377,20 @@ def main(): env=dict(os.environ, AXIOMCODE_BASH=os.path.join(work, 'no-bash'))) if r.returncode != 127 or 'AXIOMCODE_BASH' not in r.stderr: bad.append(f"axiomcode with a missing AXIOMCODE_BASH: exit {r.returncode}, stderr {r.stderr.strip()[:200]!r}") + # A bare name is a program on PATH, not a file in the working directory: the command exports AXIOMCODE_BASH=bash + # on POSIX, and the MCP launcher it starts warned that every tool would fail while they all worked (#1357). + # Named but not on PATH is still the error above, said as such. + if os.name != 'nt': + find = os.path.join(ROOT, 'plugins', 'axiomcode', 'mcp', 'find-bash.js') + probe = f"const r = require({json.dumps(find)}).findBash(); process.stdout.write(JSON.stringify(r))" + for name, ok in (('bash', True), ('no-such-bash-1357', False)): + p = subprocess.run(['node', '-e', probe], capture_output=True, text=True, cwd=work, + env=dict(os.environ, AXIOMCODE_BASH=name)) + got = json.loads(p.stdout or '{}') + if ok and not (got.get('bash') and os.path.isabs(got['bash']) and not got.get('error')): + bad.append(f"AXIOMCODE_BASH={name}, a program on PATH: {got}") + if not ok and 'is not on PATH' not in (got.get('error') or ''): + bad.append(f"AXIOMCODE_BASH={name}, on no PATH: {got}") for b in bad: print('FAIL', b) print('ok' if not bad else f'{len(bad)} failure(s)')