Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

[![npm version](https://img.shields.io/npm/v/@colbymchenry/codegraph.svg)](https://www.npmjs.com/package/@colbymchenry/codegraph)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Node.js](https://img.shields.io/badge/Node.js-18+-green.svg)](https://nodejs.org/)
[![Node.js](https://img.shields.io/badge/Node.js-20%20%7C%2022%20%7C%2024-green.svg)](https://nodejs.org/)

[![Windows](https://img.shields.io/badge/Windows-supported-blue.svg)](#)
[![macOS](https://img.shields.io/badge/macOS-supported-blue.svg)](#)
Expand Down Expand Up @@ -149,6 +149,9 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by

### 1. Run the Installer

Requires Node.js 20, 22, or 24. Node.js 25+ is blocked because its V8 WASM
compiler can crash while CodeGraph loads tree-sitter grammars.

```bash
npx @colbymchenry/codegraph
```
Expand Down
102 changes: 93 additions & 9 deletions __tests__/node-version-check.test.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,127 @@
/**
* Pin the Node-25 block banner content. The banner replaced a soft
* Pin the unsafe Node block banner content. The banner replaced a soft
* `console.warn` because the warning was scrolling off-screen before
* the OOM crash 30 seconds later, generating duplicate bug reports
* (#54, #81, #140). The recipe and override env var below are
* load-bearing — if any of them get edited away, this test catches it.
*/

import { describe, it, expect } from 'vitest';
import { buildNode25BlockBanner } from '../src/bin/node-version-check';
import {
assertSupportedNodeVersion,
buildUnsafeWasmFallbackBlockBanner,
buildUnsupportedNodeBlockBanner,
getNodeMajor,
isUnsafeWasmFallbackNodeVersion,
isUnsupportedNodeVersion,
shouldBlockWasmFallbackForNode,
shouldBlockUnsupportedNodeVersion,
} from '../src/bin/node-version-check';

describe('buildNode25BlockBanner', () => {
describe('getNodeMajor', () => {
it('parses the major from a Node version string', () => {
expect(getNodeMajor('24.13.0')).toBe(24);
});

it('returns null for malformed versions', () => {
expect(getNodeMajor('not-a-version')).toBeNull();
});
});

describe('isUnsupportedNodeVersion', () => {
it('allows supported LTS Node versions', () => {
expect(isUnsupportedNodeVersion('20.19.4')).toBe(false);
expect(isUnsupportedNodeVersion('22.11.0')).toBe(false);
expect(isUnsupportedNodeVersion('24.13.0')).toBe(false);
});

it('blocks Node 25 and newer before WASM compilation can crash', () => {
expect(isUnsupportedNodeVersion('25.0.0')).toBe(true);
});
});

describe('isUnsafeWasmFallbackNodeVersion', () => {
it('blocks the Node 24 WASM fallback path while allowing native Node 24', () => {
expect(isUnsafeWasmFallbackNodeVersion('22.11.0')).toBe(false);
expect(isUnsafeWasmFallbackNodeVersion('24.13.0')).toBe(true);
});
});

describe('shouldBlockUnsupportedNodeVersion', () => {
it('honors the explicit unsafe override', () => {
expect(shouldBlockUnsupportedNodeVersion('25.0.0', false)).toBe(true);
expect(shouldBlockUnsupportedNodeVersion('25.0.0', true)).toBe(false);
});
});

describe('shouldBlockWasmFallbackForNode', () => {
it('honors the explicit unsafe override', () => {
expect(shouldBlockWasmFallbackForNode('24.13.0', false)).toBe(true);
expect(shouldBlockWasmFallbackForNode('24.13.0', true)).toBe(false);
});
});

describe('assertSupportedNodeVersion', () => {
it('throws a recovery banner before unsafe runtimes can compile WASM', () => {
expect(() => assertSupportedNodeVersion('25.0.0', false)).toThrow(
/Unsupported Node.js version: 25\.0\.0/
);
});

it('allows Node 24 because the native SQLite backend supports it', () => {
expect(() => assertSupportedNodeVersion('24.13.0', false)).not.toThrow();
});

it('allows unsupported runtimes when the override is active', () => {
expect(() => assertSupportedNodeVersion('25.0.0', true)).not.toThrow();
});
});

describe('buildUnsupportedNodeBlockBanner', () => {
it('embeds the reported Node version in the header', () => {
expect(buildNode25BlockBanner('25.9.0')).toContain(
'Unsupported Node.js version: 25.9.0'
expect(buildUnsupportedNodeBlockBanner('25.0.0')).toContain(
'Unsupported Node.js version: 25.0.0'
);
});

it('names the V8 turboshaft WASM root cause and the OOM symptom', () => {
const banner = buildNode25BlockBanner('25.7.0');
const banner = buildUnsupportedNodeBlockBanner('25.0.0');
expect(banner).toContain('V8 WASM JIT');
expect(banner).toContain('turboshaft');
expect(banner).toContain('Fatal process out of memory: Zone');
expect(banner).toContain('Node.js 25.x');
});

it('points users to Node 22 LTS via nvm and Homebrew', () => {
const banner = buildNode25BlockBanner('25.7.0');
const banner = buildUnsupportedNodeBlockBanner('25.0.0');
expect(banner).toContain('Node.js 22 LTS');
expect(banner).toContain('nvm install 22');
expect(banner).toContain('brew install node@22');
});

it('documents the CODEGRAPH_ALLOW_UNSAFE_NODE override', () => {
const banner = buildNode25BlockBanner('25.7.0');
const banner = buildUnsupportedNodeBlockBanner('25.0.0');
expect(banner).toContain('CODEGRAPH_ALLOW_UNSAFE_NODE=1');
});

it('links to issue #81 for the root-cause writeup', () => {
expect(buildNode25BlockBanner('25.7.0')).toContain(
expect(buildUnsupportedNodeBlockBanner('25.0.0')).toContain(
'github.com/colbymchenry/codegraph/issues/81'
);
});
});

describe('buildUnsafeWasmFallbackBlockBanner', () => {
it('explains that Node 24 is supported with native SQLite but not WASM fallback', () => {
const banner = buildUnsafeWasmFallbackBlockBanner(
'24.13.0',
"Cannot find module 'better-sqlite3'"
);
expect(banner).toContain('Unsafe WASM fallback blocked');
expect(banner).toContain('native better-sqlite3');
expect(banner).toContain('npm rebuild better-sqlite3');
expect(banner).toContain(
"Native load error: Cannot find module 'better-sqlite3'"
);
});
});
10 changes: 6 additions & 4 deletions src/bin/codegraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ import { getCodeGraphDir, isInitialized } from '../directory';
import { createShimmerProgress } from '../ui/shimmer-progress';
import { getGlyphs } from '../ui/glyphs';

import { buildNode25BlockBanner } from './node-version-check';
import {
buildUnsupportedNodeBlockBanner,
isUnsupportedNodeVersion,
} from './node-version-check';

// Lazy-load heavy modules (CodeGraph, runInstaller) to keep CLI startup fast.
async function loadCodeGraph(): Promise<typeof import('../index')> {
Expand Down Expand Up @@ -55,9 +58,8 @@ const importESM = new Function('specifier', 'return import(specifier)') as
// Hard-exit before any WASM work; allow override via env var for users
// who patched V8 themselves or want to test a future fix.
const nodeVersion = process.versions.node;
const nodeMajor = parseInt(nodeVersion.split('.')[0] ?? '0', 10);
if (nodeMajor >= 25) {
process.stderr.write(buildNode25BlockBanner(nodeVersion) + '\n');
if (isUnsupportedNodeVersion(nodeVersion)) {
process.stderr.write(buildUnsupportedNodeBlockBanner(nodeVersion) + '\n');
if (!process.env.CODEGRAPH_ALLOW_UNSAFE_NODE) {
process.exit(1);
}
Expand Down
81 changes: 77 additions & 4 deletions src/bin/node-version-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,84 @@
*
* Node 25.x has a V8 turboshaft WASM JIT Zone allocator bug that
* reliably crashes CodeGraph with `Fatal process out of memory: Zone`
* during tree-sitter grammar compilation. This module owns the
* user-facing banner shown before exit. Kept side-effect-free so it's
* safe to import from tests without triggering CLI bootstrap.
* during tree-sitter grammar compilation. Node 24.x is supported when
* the native SQLite backend loads, but its WASM fallback path can hit
* the same crash class. This module owns the user-facing runtime
* guards. Kept side-effect-free so it's safe to import from tests
* without triggering CLI bootstrap.
*/

export const MIN_UNSUPPORTED_NODE_MAJOR = 25;
export const MIN_UNSAFE_WASM_NODE_MAJOR = 24;

export function getNodeMajor(nodeVersion: string): number | null {
const major = Number.parseInt(nodeVersion.split('.')[0] ?? '', 10);
return Number.isFinite(major) ? major : null;
}

export function isUnsupportedNodeVersion(nodeVersion: string): boolean {
const major = getNodeMajor(nodeVersion);
return major !== null && major >= MIN_UNSUPPORTED_NODE_MAJOR;
}

export function isUnsafeWasmFallbackNodeVersion(nodeVersion: string): boolean {
const major = getNodeMajor(nodeVersion);
return major !== null && major >= MIN_UNSAFE_WASM_NODE_MAJOR;
}

export function shouldBlockUnsupportedNodeVersion(
nodeVersion: string,
allowUnsafe: boolean = Boolean(process.env.CODEGRAPH_ALLOW_UNSAFE_NODE)
): boolean {
return isUnsupportedNodeVersion(nodeVersion) && !allowUnsafe;
}

export function assertSupportedNodeVersion(
nodeVersion: string = process.versions.node,
allowUnsafe?: boolean
): void {
if (shouldBlockUnsupportedNodeVersion(nodeVersion, allowUnsafe)) {
throw new Error(buildUnsupportedNodeBlockBanner(nodeVersion));
}
}

export function shouldBlockWasmFallbackForNode(
nodeVersion: string = process.versions.node,
allowUnsafe: boolean = Boolean(process.env.CODEGRAPH_ALLOW_UNSAFE_NODE)
): boolean {
return isUnsafeWasmFallbackNodeVersion(nodeVersion) && !allowUnsafe;
}

export function buildUnsafeWasmFallbackBlockBanner(
nodeVersion: string,
nativeError?: string
): string {
const sep = '-'.repeat(72);
const lines = [
sep,
`[CodeGraph] Unsafe WASM fallback blocked on Node.js ${nodeVersion}`,
sep,
'Node.js 24.x can run CodeGraph through the native better-sqlite3',
'backend, but the WASM SQLite fallback may trigger the V8 WASM JIT',
'(turboshaft) crash path while loading CodeGraph grammars.',
'',
'Fix the native backend, then retry:',
' npm rebuild better-sqlite3',
' npm install better-sqlite3 --save',
'',
'Or use Node.js 22 LTS:',
' nvm install 22 && nvm use 22',
'',
'To override (NOT recommended - you may OOM):',
' CODEGRAPH_ALLOW_UNSAFE_NODE=1 codegraph ...',
];
if (nativeError) {
lines.push('', `Native load error: ${nativeError}`);
}
lines.push(sep);
return lines.join('\n');
}

/**
* Build the bordered banner shown when CodeGraph detects an
* unsupported Node.js major version (currently 25+). Pinned via unit
Expand All @@ -17,7 +90,7 @@
* Uses ASCII glyphs to stay readable on Windows OEM-codepage consoles
* (see ../ui/glyphs.ts for the rationale).
*/
export function buildNode25BlockBanner(nodeVersion: string): string {
export function buildUnsupportedNodeBlockBanner(nodeVersion: string): string {
const sep = '-'.repeat(72);
return [
sep,
Expand Down
11 changes: 11 additions & 0 deletions src/db/sqlite-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
* node-sqlite3-wasm (WASM fallback) for universal cross-platform support.
*/

import {
buildUnsafeWasmFallbackBlockBanner,
shouldBlockWasmFallbackForNode,
} from '../bin/node-version-check';

export interface SqliteStatement {
run(...params: any[]): { changes: number; lastInsertRowid: number | bigint };
get(...params: any[]): any;
Expand Down Expand Up @@ -250,6 +255,12 @@ export function createDatabase(dbPath: string): { db: SqliteDatabase; backend: S
nativeError = error instanceof Error ? error.message : String(error);
}

if (shouldBlockWasmFallbackForNode()) {
throw new Error(
buildUnsafeWasmFallbackBlockBanner(process.versions.node, nativeError)
);
}

// Fall back to WASM
try {
const db = new WasmDatabaseAdapter(dbPath);
Expand Down
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import { GraphTraverser, GraphQueryManager } from './graph';
import { ContextBuilder, createContextBuilder } from './context';
import { Mutex, FileLock } from './utils';
import { FileWatcher, WatchOptions } from './sync';
import { assertSupportedNodeVersion } from './bin/node-version-check';

// Re-export types for consumers
export * from './types';
Expand Down Expand Up @@ -151,6 +152,7 @@ export class CodeGraph {
config: CodeGraphConfig,
projectRoot: string
) {
assertSupportedNodeVersion();
this.db = db;
this.queries = queries;
this.config = config;
Expand Down Expand Up @@ -183,6 +185,7 @@ export class CodeGraph {
* @returns A new CodeGraph instance
*/
static async init(projectRoot: string, options: InitOptions = {}): Promise<CodeGraph> {
assertSupportedNodeVersion();
await initGrammars();
const resolvedRoot = path.resolve(projectRoot);

Expand Down Expand Up @@ -220,6 +223,7 @@ export class CodeGraph {
* Initialize synchronously (without indexing)
*/
static initSync(projectRoot: string, options: Omit<InitOptions, 'index' | 'onProgress'> = {}): CodeGraph {
assertSupportedNodeVersion();
const resolvedRoot = path.resolve(projectRoot);

// Check if already initialized
Expand Down Expand Up @@ -253,6 +257,7 @@ export class CodeGraph {
* @returns A CodeGraph instance
*/
static async open(projectRoot: string, options: OpenOptions = {}): Promise<CodeGraph> {
assertSupportedNodeVersion();
await initGrammars();
const resolvedRoot = path.resolve(projectRoot);

Expand Down Expand Up @@ -289,6 +294,7 @@ export class CodeGraph {
* Open synchronously (without sync)
*/
static openSync(projectRoot: string): CodeGraph {
assertSupportedNodeVersion();
const resolvedRoot = path.resolve(projectRoot);

// Check if initialized
Expand Down