diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml new file mode 100644 index 000000000..a3feb2ad7 --- /dev/null +++ b/.github/workflows/CI.yml @@ -0,0 +1,33 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + name: Test (Node ${{ matrix.node-version }}, ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] # windows-latest + node-version: [24, 26] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - run: npm ci + + - name: Build + run: npm run build + + - name: Run tests + run: npm test diff --git a/__tests__/mcp-initialize.test.ts b/__tests__/mcp-initialize.test.ts index 4a57ebae0..23c168ebd 100644 --- a/__tests__/mcp-initialize.test.ts +++ b/__tests__/mcp-initialize.test.ts @@ -23,6 +23,7 @@ function spawnServer(cwd: string): ChildProcessWithoutNullStreams { return spawn(process.execPath, [BIN, 'serve', '--mcp'], { cwd, stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, CODEGRAPH_NO_RELAUNCH: '1' }, }) as ChildProcessWithoutNullStreams; } @@ -99,9 +100,12 @@ describe('MCP initialize handshake (issue #172)', () => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-init-')); }); - afterEach(() => { - if (child && !child.killed) { - child.kill('SIGKILL'); + afterEach(async () => { + if (child) { + if (!child.killed) child.kill('SIGKILL'); + if (child.exitCode === null) { + await new Promise(resolve => child!.once('close', resolve)); + } child = null; } fs.rmSync(tempDir, { recursive: true, force: true }); diff --git a/__tests__/mcp-roots.test.ts b/__tests__/mcp-roots.test.ts index 8e1d4520d..36d9174c3 100644 --- a/__tests__/mcp-roots.test.ts +++ b/__tests__/mcp-roots.test.ts @@ -29,6 +29,7 @@ function spawnServer(cwd: string): ChildProcessWithoutNullStreams { return spawn(process.execPath, [BIN, 'serve', '--mcp', '--no-watch'], { cwd, stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, CODEGRAPH_NO_RELAUNCH: '1' }, }) as ChildProcessWithoutNullStreams; } @@ -84,9 +85,12 @@ describe('MCP project resolution via roots/list (issue #196)', () => { projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-proj-')); }); - afterEach(() => { - if (child && !child.killed) { - child.kill('SIGKILL'); + afterEach(async () => { + if (child) { + if (!child.killed) child.kill('SIGKILL'); + if (child.exitCode === null) { + await new Promise(resolve => child!.once('close', resolve)); + } child = null; } fs.rmSync(cwdDir, { recursive: true, force: true }); diff --git a/package.json b/package.json index 5455ced92..6ed993986 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,6 @@ "vitest": "^2.1.9" }, "engines": { - "node": ">=20.0.0 <25.0.0" + "node": ">=20.0.0 <25.0.0 || >=26.0.0" } } diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 6bc63b3fd..f92267229 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -61,7 +61,7 @@ const importESM = new Function('specifier', 'return import(specifier)') as // 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) { +if (nodeMajor === 25) { process.stderr.write(buildNode25BlockBanner(nodeVersion) + '\n'); if (!process.env.CODEGRAPH_ALLOW_UNSAFE_NODE) { process.exit(1); diff --git a/vitest.config.ts b/vitest.config.ts index 2449a989e..814a07233 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,10 +1,23 @@ import { defineConfig } from 'vitest/config'; +const NODE_MAJOR = Number.parseInt(process.versions.node.split('.')[0] ?? '0', 10); + +// The V8 turboshaft WASM Zone OOM bug that crashes tree-sitter grammar +// compilation exists in Node 22–25.x. Node 26+ fixes it; forcing +// --liftoff-only on Node 26 Windows is empirically tied to a fork-worker +// teardown crash in tinypool, so only apply the flag where it's needed. +const NEEDS_LIFTOFF_ONLY = NODE_MAJOR >= 22 && NODE_MAJOR <= 25; + export default defineConfig({ test: { globals: true, environment: 'node', include: ['__tests__/**/*.test.ts'], + poolOptions: { + forks: { + execArgv: NEEDS_LIFTOFF_ONLY ? ['--liftoff-only'] : [], + }, + }, coverage: { provider: 'v8', reporter: ['text', 'json', 'html'],