diff --git a/README.md b/README.md index f8f39e97..ceb09185 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-18%20%7C%2020%20%7C%2022-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)](#) @@ -144,6 +144,9 @@ CodeGraph detects web-framework routing files and emits `route` nodes linked by ### 1. Run the Installer +Requires Node.js 18, 20, or 22. Current Node.js 24+ releases are blocked because +their 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..e3467b4a 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,36 +7,85 @@ */ import { describe, it, expect } from 'vitest'; -import { buildNode25BlockBanner } from '../src/bin/node-version-check'; +import { + assertSupportedNodeVersion, + buildUnsupportedNodeBlockBanner, + getNodeMajor, + isUnsupportedNodeVersion, + 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('18.20.0')).toBe(false); + expect(isUnsupportedNodeVersion('20.19.4')).toBe(false); + expect(isUnsupportedNodeVersion('22.11.0')).toBe(false); + }); + + it('blocks Node 24 and newer before WASM compilation can crash', () => { + expect(isUnsupportedNodeVersion('24.13.0')).toBe(true); + expect(isUnsupportedNodeVersion('25.0.0')).toBe(true); + }); +}); + +describe('shouldBlockUnsupportedNodeVersion', () => { + it('honors the explicit unsafe override', () => { + expect(shouldBlockUnsupportedNodeVersion('24.13.0', false)).toBe(true); + expect(shouldBlockUnsupportedNodeVersion('24.13.0', true)).toBe(false); + }); +}); + +describe('assertSupportedNodeVersion', () => { + it('throws a recovery banner before unsafe runtimes can compile WASM', () => { + expect(() => assertSupportedNodeVersion('24.13.0', false)).toThrow( + /Unsupported Node.js version: 24\.13\.0/ + ); + }); + + it('allows unsafe runtimes when the override is active', () => { + expect(() => assertSupportedNodeVersion('24.13.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('24.13.0')).toContain( + 'Unsupported Node.js version: 24.13.0' ); }); it('names the V8 turboshaft WASM root cause and the OOM symptom', () => { - const banner = buildNode25BlockBanner('25.7.0'); + const banner = buildUnsupportedNodeBlockBanner('24.13.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 24.x and 25.x'); }); it('points users to Node 22 LTS via nvm and Homebrew', () => { - const banner = buildNode25BlockBanner('25.7.0'); + const banner = buildUnsupportedNodeBlockBanner('24.13.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('24.13.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('24.13.0')).toContain( 'github.com/colbymchenry/codegraph/issues/81' ); }); diff --git a/package-lock.json b/package-lock.json index 3cd20819..4db87b77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,7 @@ "vitest": "^2.1.9" }, "engines": { - "node": ">=18.0.0 <25.0.0" + "node": ">=18.0.0 <24.0.0" }, "optionalDependencies": { "better-sqlite3": "^11.0.0" diff --git a/package.json b/package.json index f3f35887..b5f70204 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,6 @@ "better-sqlite3": "^11.0.0" }, "engines": { - "node": ">=18.0.0 <25.0.0" + "node": ">=18.0.0 <24.0.0" } } diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 8ee7a6f9..411bf0d6 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -24,7 +24,10 @@ import * as fs from 'fs'; import { getCodeGraphDir, isInitialized } from '../directory'; import { createShimmerProgress } from '../ui/shimmer-progress'; -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 { @@ -46,17 +49,16 @@ async function loadCodeGraph(): Promise { const importESM = new Function('specifier', 'return import(specifier)') as (specifier: string) => Promise; -// Block CodeGraph on Node.js 25.x — V8's turboshaft WASM JIT has a Zone -// allocator bug that reliably crashes when compiling tree-sitter -// grammars (see #54, #81, #140). The previous behaviour was a soft -// console.warn that scrolls off-screen before the OOM crash 30 seconds -// later, leading to a steady stream of "what is this OOM" reports. +// Block CodeGraph on Node.js 24+ — current V8 turboshaft WASM JIT builds +// have a Zone allocator bug that reliably crashes when compiling +// tree-sitter grammars (see #54, #81, #140). The previous behaviour was +// a soft console.warn that scrolls off-screen before the OOM crash +// seconds later, leading to a steady stream of "what is this OOM" reports. // 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 6aed1615..542454c4 100644 --- a/src/bin/node-version-check.ts +++ b/src/bin/node-version-check.ts @@ -1,29 +1,59 @@ /** * Node.js version compatibility check. * - * 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. + * Current Node 24.x and 25.x releases have 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. */ +export const MIN_UNSAFE_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_UNSAFE_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)); + } +} + /** * Build the bordered banner shown when CodeGraph detects an - * unsupported Node.js major version (currently 25+). Pinned via unit + * unsupported Node.js major version (currently 24+). Pinned via unit * test so the recovery commands and override instructions can't be * silently stripped by future edits. */ -export function buildNode25BlockBanner(nodeVersion: string): string { +export function buildUnsupportedNodeBlockBanner(nodeVersion: string): string { const sep = '─'.repeat(72); return [ sep, `[CodeGraph] Unsupported Node.js version: ${nodeVersion}`, sep, - 'Node.js 25.x has a V8 WASM JIT (turboshaft) Zone allocator bug that', - 'crashes with `Fatal process out of memory: Zone` when CodeGraph', - 'compiles tree-sitter grammars. CodeGraph WILL crash on this Node', - 'version mid-indexing. See https://github.com/colbymchenry/codegraph/issues/81', + 'Current Node.js 24.x and 25.x releases have a V8 WASM JIT', + '(turboshaft) Zone allocator bug that crashes with', + '`Fatal process out of memory: Zone` when CodeGraph compiles', + 'tree-sitter grammars. CodeGraph WILL crash on this Node version', + 'mid-indexing. See https://github.com/colbymchenry/codegraph/issues/81', '', 'Fix: install Node.js 22 LTS:', ' nvm install 22 && nvm use 22 # nvm', 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