diff --git a/README.md b/README.md index 49cf8d54..dc94762c 100644 --- a/README.md +++ b/README.md @@ -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)](#) @@ -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 ``` diff --git a/__tests__/node-version-check.test.ts b/__tests__/node-version-check.test.ts index d7b725cb..e3a41b46 100644 --- a/__tests__/node-version-check.test.ts +++ b/__tests__/node-version-check.test.ts @@ -1,5 +1,5 @@ /** - * 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 @@ -7,37 +7,121 @@ */ 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'" + ); + }); +}); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index de608c36..cc059b54 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -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 { @@ -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); } diff --git a/src/bin/node-version-check.ts b/src/bin/node-version-check.ts index 4d7539a5..7ffea4a7 100644 --- a/src/bin/node-version-check.ts +++ b/src/bin/node-version-check.ts @@ -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 @@ -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, diff --git a/src/db/sqlite-adapter.ts b/src/db/sqlite-adapter.ts index c3d31c8f..4d0c46c8 100644 --- a/src/db/sqlite-adapter.ts +++ b/src/db/sqlite-adapter.ts @@ -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; @@ -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); diff --git a/src/index.ts b/src/index.ts index 7d586741..6a84f199 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; @@ -151,6 +152,7 @@ export class CodeGraph { config: CodeGraphConfig, projectRoot: string ) { + assertSupportedNodeVersion(); this.db = db; this.queries = queries; this.config = config; @@ -183,6 +185,7 @@ export class CodeGraph { * @returns A new CodeGraph instance */ static async init(projectRoot: string, options: InitOptions = {}): Promise { + assertSupportedNodeVersion(); await initGrammars(); const resolvedRoot = path.resolve(projectRoot); @@ -220,6 +223,7 @@ export class CodeGraph { * Initialize synchronously (without indexing) */ static initSync(projectRoot: string, options: Omit = {}): CodeGraph { + assertSupportedNodeVersion(); const resolvedRoot = path.resolve(projectRoot); // Check if already initialized @@ -253,6 +257,7 @@ export class CodeGraph { * @returns A CodeGraph instance */ static async open(projectRoot: string, options: OpenOptions = {}): Promise { + assertSupportedNodeVersion(); await initGrammars(); const resolvedRoot = path.resolve(projectRoot); @@ -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