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 @@
-
+
@@ -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)')