diff --git a/CLAUDE.md b/CLAUDE.md index cb147dac9..f309cace4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,6 +74,7 @@ npm run test:coverage # Coverage report npx vitest run tests/parsers/javascript.test.ts # Single test file npx vitest run -t "finds cycles" # Single test by name npm run build:wasm # Rebuild WASM grammars from devDeps (built automatically on npm install) +npm run doctor # Check this worktree for a stale native binary / missing WASM grammars (runs automatically via pretest) ``` **Linter/Formatter:** [Biome](https://biomejs.dev/) — config in `biome.json`, scoped to `src/` and `tests/`. @@ -103,6 +104,7 @@ Source is TypeScript in `src/`, compiled via `tsup`. The Rust native engine live | `shared/paginate.ts` | Pagination helpers for bounded query results | | **`infrastructure/`** | **Platform and I/O plumbing** | | `infrastructure/config.ts` | `.codegraphrc.json` loading, env overrides, `apiKeyCommand` secret resolution | +| `infrastructure/doctor.ts` | Environment health checks — stale `better-sqlite3` native ABI, incomplete `grammars/`; see `npm run doctor` | | `infrastructure/logger.ts` | Structured logging (`warn`, `debug`, `info`, `error`) | | `infrastructure/native.ts` | Native napi-rs addon loader with WASM fallback | | `infrastructure/registry.ts` | Global repo registry (`~/.codegraph/registry.json`) for multi-repo MCP | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d6d883298..c51b8b470 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,6 +17,16 @@ npm test # run the full test suite **Requirements:** Node.js >= 20 +**Working in multiple git worktrees?** Each worktree gets its own untracked +`node_modules/` and `grammars/` — neither is shared via git — so every fresh +`git worktree add` needs its own `npm install`. A worktree set up before a +host Node upgrade, or where `npm install` was interrupted, can be left with a +`better-sqlite3` binary compiled for the wrong Node ABI or an incomplete +`grammars/` directory; both fail in confusing ways deep inside a build or test +run. Run `npm run doctor` to check (or `npm run doctor -- --fix` to repair +in place, scoped to the current worktree) — it also runs automatically before +`npm test` via `pretest`. + ## Contributor License Agreement (CLA) All contributors must sign the [Contributor License Agreement](CLA.md) before @@ -88,6 +98,7 @@ npm run test:coverage # Coverage report npx vitest run tests/parsers/go.test.js # Single test file npx vitest run -t "finds cycles" # Single test by name npm run build:wasm # Rebuild WASM grammars +npm run doctor # Check for a stale native binary / missing WASM grammars ``` ## Branch Naming Convention diff --git a/package.json b/package.json index 1d51ba687..d018511ab 100644 --- a/package.json +++ b/package.json @@ -83,8 +83,12 @@ "build:wasm": "node scripts/node-ts.js scripts/build-wasm.ts", "typecheck": "tsc --noEmit", "verify-imports": "node scripts/node-ts.js scripts/verify-imports.ts", + "doctor": "node --experimental-strip-types --import ./scripts/ts-resolve-loader.js scripts/doctor.ts", + "pretest": "npm run doctor", "test": "vitest run", + "pretest:watch": "npm run doctor", "test:watch": "vitest", + "pretest:coverage": "npm run doctor", "test:coverage": "vitest run --coverage", "test:regression-guard": "vitest run tests/benchmarks/regression-guard.test.ts", "lint": "biome check src/ tests/", diff --git a/scripts/doctor.ts b/scripts/doctor.ts new file mode 100644 index 000000000..1dfeb6674 --- /dev/null +++ b/scripts/doctor.ts @@ -0,0 +1,112 @@ +#!/usr/bin/env node +/** + * codegraph environment doctor (issue #1733). + * + * Detects two classes of silent per-worktree environment drift: a stale + * better-sqlite3 native binary (ABI mismatch after a Node upgrade) and an + * incomplete grammars/ directory (interrupted or skipped `npm run build:wasm`). + * Both are untracked, worktree-local state — see CLAUDE.md "Parallel + * Sessions" — so every fresh `git worktree add` needs this checked at least + * once, and a long-lived worktree needs it re-checked after a host Node + * upgrade. + * + * Usage: + * npm run doctor # report only; exits 1 only on a blocking ('fail') check + * npm run doctor -- --fix # also run the scoped fix command(s), then re-check + * + * A missing *optional*-language grammar reports as a non-blocking WARN, not + * FAIL — this repo's own parsers are designed to degrade gracefully when a + * non-required grammar is unavailable (see CLAUDE.md), so `npm test` must + * stay runnable in that case, just with narrower language coverage. + * + * Also wired as the `pretest` lifecycle script (report-only, no --fix) so + * `npm test` fails fast with one actionable message instead of a wall of + * unrelated-looking failures scattered across the suite — but only for a + * genuinely blocking problem (stale native binary, missing required grammar). + * + * `--fix` is opt-in rather than automatic: the fixes themselves (`npm rebuild + * better-sqlite3`, `npm run build:wasm`) can take anywhere from seconds to + * over a minute, so running them unattended on every `npm test` would add + * unpredictable latency to a hot dev-loop command. Detect-and-report is the + * safe default; healing is one explicit flag away. Both fix commands always + * run with cwd pinned to this script's own repo root (never process.cwd()), + * so they can never touch a different worktree or a global install. + */ +import { execFileSync } from 'node:child_process'; +import os from 'node:os'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { runDoctorChecks } from '../src/infrastructure/doctor.ts'; + +// doctor.ts's DoctorCheck/DoctorReport interfaces are intentionally not +// exported (nothing outside that module constructs one independently), so +// this derives the shapes structurally rather than importing them by name. +type DoctorReport = ReturnType; +type DoctorCheck = DoctorReport['checks'][number]; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +// npm on Windows is npm.cmd; Node refuses to spawn .cmd/.bat without a shell. +const NPM_SHELL = os.platform() === 'win32'; + +const STATUS_MARK: Record = { + ok: 'OK ', + warn: 'WARN', + fail: 'FAIL', +}; + +function printReport(report: DoctorReport): void { + for (const check of report.checks) { + console.log(`[${STATUS_MARK[check.status]}] ${check.label}: ${check.detail}`); + if (check.status !== 'ok' && check.fixCommand) { + console.log(` fix: ${check.fixCommand}`); + } + } +} + +/** Run a non-ok check's fix command, scoped to this repo's own root. */ +function runFix(check: DoctorCheck): void { + if (!check.fixCommand) return; + const [cmd, ...args] = check.fixCommand.split(' '); + if (!cmd) return; + console.log(`\n> ${check.fixCommand}`); + try { + execFileSync(cmd, args, { cwd: repoRoot, stdio: 'inherit', shell: NPM_SHELL }); + } catch (err) { + console.error(` fix command failed: ${(err as Error).message}`); + } +} + +const shouldFix = process.argv.includes('--fix'); + +let report = runDoctorChecks(); +printReport(report); + +// --fix repairs anything with something to fix — a non-blocking 'warn' (e.g. +// a missing optional grammar) is still worth fixing when explicitly asked, +// even though it wouldn't block pretest/npm test on its own. +const needsFix = report.checks.filter((c) => c.status !== 'ok'); +if (shouldFix && needsFix.length > 0) { + console.log(`\n--fix passed — attempting scoped repairs in ${repoRoot}`); + for (const check of needsFix) runFix(check); + + console.log('\nRe-checking...'); + report = runDoctorChecks(); + printReport(report); +} + +if (!report.ok) { + console.error('\ncodegraph doctor: environment is NOT healthy — see fix command(s) above.'); + if (!shouldFix) { + console.error('Re-run with --fix to attempt an automatic, worktree-scoped repair:'); + console.error(' npm run doctor -- --fix'); + } + process.exit(1); +} + +const hasWarnings = report.checks.some((c) => c.status === 'warn'); +console.log( + hasWarnings + ? '\ncodegraph doctor: environment healthy (non-blocking warnings above).' + : '\ncodegraph doctor: environment healthy.', +); diff --git a/src/domain/parser.ts b/src/domain/parser.ts index 2042c2099..5208222c2 100644 --- a/src/domain/parser.ts +++ b/src/domain/parser.ts @@ -105,8 +105,16 @@ import { const __dirname = path.dirname(fileURLToPath(import.meta.url)); +/** + * Absolute path to the `grammars/` directory holding pre-built WASM grammar + * files. Exported so other modules (e.g. `infrastructure/doctor.ts`) can + * verify installation completeness without duplicating this relative-path + * computation. + */ +export const GRAMMARS_DIR = path.join(__dirname, '..', '..', 'grammars'); + function grammarPath(name: string): string { - return path.join(__dirname, '..', '..', 'grammars', name); + return path.join(GRAMMARS_DIR, name); } let _initialized: boolean = false; diff --git a/src/infrastructure/doctor.ts b/src/infrastructure/doctor.ts new file mode 100644 index 000000000..d14ed62d4 --- /dev/null +++ b/src/infrastructure/doctor.ts @@ -0,0 +1,329 @@ +/** + * Environment health checks ("doctor") for a codegraph checkout. + * + * Every git worktree (see CLAUDE.md "Parallel Sessions") gets its own + * untracked `node_modules/` and `grammars/` — neither is shared via git, so + * every fresh `git worktree add` needs its own `npm install`. Two classes of + * drift are both silent until something fails deep inside a build or test + * run, surfacing as a cryptic native-module stack trace or a swallowed parse + * failure rather than a clear diagnosis (issue #1733): + * + * 1. `better-sqlite3`'s compiled `.node` binary is a classic V8/NAN addon + * (not N-API), so it is tied to the exact Node ABI + * (`process.versions.modules`) it was compiled under. Upgrading Node in + * place without `npm rebuild` leaves a stale binary that throws on load + * — and better-sqlite3 sits on the hot path for nearly every command + * (see `db/builder/pipeline.ts`), so this one failure looks like almost + * everything is broken. + * 2. `grammars/*.wasm` is populated by `npm run build:wasm` (via the + * `prepare` lifecycle script) from tree-sitter grammar devDependencies. + * A worktree set up before that step finished — or where it failed + * partway — is left with only a partial grammar set. + * + * Grammar completeness is NOT all-or-nothing: `LANGUAGE_REGISTRY` marks only + * JS/TS/TSX as `required: true` — every other language is designed to fail + * gracefully at runtime when its grammar is missing (see this repo's own + * CLAUDE.md: "Non-required parsers ... fail gracefully if their WASM grammar + * is unavailable"). A worktree that can't fetch one optional grammar's + * devDependency (e.g. a sandboxed environment and a `git+ssh` grammar + * package) should still be able to build/test with reduced language coverage + * — not get hard-blocked before a single test runs. So `checkWasmGrammars` + * only fails (blocks `pretest`) when a *required* grammar is missing; a + * missing optional grammar is surfaced as a non-blocking 'warn'. + * + * Design: each check's *decision* logic is a pure function (`parseAbiMismatchError`, + * `findMissingGrammars`) that takes plain data and is trivial to unit test with + * fake inputs. The public `checkXxx` functions wrap that logic with the real + * I/O (a `require()` attempt, a directory listing) behind an injectable + * parameter, so tests can simulate a broken environment without touching this + * worktree's real native binary or grammars/ directory. + * + * This module only detects — it never mutates the environment. `scripts/doctor.ts` + * is the CLI entry point that also knows how to *fix* what this module reports. + */ +import { readdirSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import type { LanguageRegistryEntry } from '../types.js'; + +const _require = createRequire(import.meta.url); + +/** + * `domain/parser.js` eagerly imports `web-tree-sitter` at module scope, so a + * fully broken `npm install` (not just a stale `better-sqlite3` binary) + * would otherwise crash this entire module with an unhandled + * `MODULE_NOT_FOUND` before a single doctor check can run — defeating the + * point of a tool whose job is to turn opaque startup crashes into a clear + * diagnostic (#1733). Loaded once via top-level await so `checkWasmGrammars` + * stays synchronous for callers/tests; a load failure is surfaced as a + * doctor check row instead of propagating. + */ +let parserModule: { + GRAMMARS_DIR: string; + LANGUAGE_REGISTRY: readonly LanguageRegistryEntry[]; +} | null = null; +let parserModuleError: string | null = null; +try { + parserModule = await import('../domain/parser.js'); +} catch (e) { + parserModuleError = e instanceof Error ? e.message : String(e); +} + +/** + * 'ok' — nothing to report. 'warn' — non-blocking (e.g. an optional grammar + * is missing); does not fail the check or the overall report. 'fail' — + * blocking; fails the overall report and `pretest`. + */ +type DoctorCheckStatus = 'ok' | 'warn' | 'fail'; + +/** + * Result of a single doctor check. Not exported by name — consumers (e.g. + * `scripts/doctor.ts`) type against `DoctorReport['checks'][number]` / + * `ReturnType` structurally rather than importing + * this interface, since nothing outside this module currently constructs a + * `DoctorCheck` independently of calling `checkBetterSqlite3Abi` / + * `checkWasmGrammars` / `runDoctorChecks`. + */ +interface DoctorCheck { + /** Stable machine-readable id, e.g. 'better-sqlite3-abi'. */ + id: string; + /** Human-readable label for report output. */ + label: string; + status: DoctorCheckStatus; + /** One-line human-readable explanation of the result. */ + detail: string; + /** Shell command that resolves this failure/warning. Absent when status is 'ok'. */ + fixCommand?: string; +} + +/** Aggregate result of running every doctor check. Not exported — see DoctorCheck. */ +interface DoctorReport { + /** False only when some check's status is 'fail' — a 'warn' never flips this. */ + ok: boolean; + checks: DoctorCheck[]; +} + +/** + * Parsed ABI numbers from a Node native-addon load-failure error message. + * Not exported — it's only ever consumed as `parseAbiMismatchError`'s return + * value; callers destructure the fields rather than naming the type. + */ +interface AbiMismatchInfo { + /** NODE_MODULE_VERSION the binary was compiled against. */ + compiledVersion: number; + /** NODE_MODULE_VERSION the current Node process requires. */ + requiredVersion: number; +} + +// Node's native-addon ABI mismatch error has been stable for many major +// versions, e.g.: +// "The module '/path/to/better_sqlite3.node' +// was compiled against a different Node.js version using +// NODE_MODULE_VERSION 127. This version of Node.js requires +// NODE_MODULE_VERSION 131. Please try re-compiling or re-installing +// the module (for instance, using `npm rebuild` or `npm install`)." +// Detection never depends on this regex matching — any thrown error from +// loadModule() is already treated as "broken" by checkBetterSqlite3Abi. +// This only extracts the two version numbers for a friendlier message; if a +// future Node release rewords the message, the check still fails correctly, +// it just falls back to the raw error text instead of the parsed numbers. +const ABI_MISMATCH_RE = /NODE_MODULE_VERSION (\d+)\.[\s\S]*?NODE_MODULE_VERSION (\d+)/; + +/** + * Parse Node's native-addon ABI mismatch error text into structured version + * numbers. Returns null for any message that doesn't match the known format + * (e.g. a missing-module error, a permission error, or reworded future text). + */ +export function parseAbiMismatchError(message: string): AbiMismatchInfo | null { + const match = ABI_MISMATCH_RE.exec(message); + if (!match) return null; + return { compiledVersion: Number(match[1]), requiredVersion: Number(match[2]) }; +} + +/** + * Check whether better-sqlite3's compiled native binary loads under the + * current Node process. + * + * Requiring it is the only reliable way to learn a prebuilt `.node` file's + * ABI compatibility — Node exposes no static introspection API for a + * candidate binary's compiled-against version — so this performs a real (but + * fast, in-process, side-effect-free beyond module caching) require() rather + * than shelling out to a subprocess. + * + * `loadModule` is injectable so tests can simulate a stale binary, a missing + * install, or a healthy load without touching this worktree's real + * node_modules/. + */ +export function checkBetterSqlite3Abi( + loadModule: () => unknown = () => _require('better-sqlite3'), +): DoctorCheck { + const id = 'better-sqlite3-abi'; + const label = 'better-sqlite3 native binary'; + try { + loadModule(); + return { + id, + label, + status: 'ok', + detail: `loads cleanly under Node ${process.version} (ABI ${process.versions.modules})`, + }; + } catch (err) { + const code = (err as NodeJS.ErrnoException)?.code; + const message = err instanceof Error ? err.message : String(err); + + if (code === 'MODULE_NOT_FOUND') { + return { + id, + label, + status: 'fail', + detail: 'better-sqlite3 is not installed in this worktree', + fixCommand: 'npm install', + }; + } + + const mismatch = parseAbiMismatchError(message); + const detail = mismatch + ? `compiled for NODE_MODULE_VERSION ${mismatch.compiledVersion}, but Node ${process.version} requires ${mismatch.requiredVersion} — likely a stale binary from before a Node upgrade` + : `failed to load: ${message.split('\n')[0]}`; + return { id, label, status: 'fail', detail, fixCommand: 'npm rebuild better-sqlite3' }; + } +} + +/** + * The subset of a language registry entry the grammar-completeness check + * needs. Not exported — `checkWasmGrammars`'s default parameter is the real + * `LANGUAGE_REGISTRY`; this narrower shape only exists so callers (tests) can + * pass minimal fake entries without fabricating an `extractor`/`extensions`. + */ +type GrammarRegistryEntry = Pick; + +/** + * Pure: given the language registry and the set of grammar filenames that + * actually exist on disk, return the entries whose `.wasm` file is missing. + */ +export function findMissingGrammars( + registry: readonly GrammarRegistryEntry[], + existingFiles: ReadonlySet, +): GrammarRegistryEntry[] { + return registry.filter((entry) => !existingFiles.has(entry.grammarFile)); +} + +/** List `.wasm` filenames present in the real `grammars/` directory. */ +function listInstalledGrammarFiles(): ReadonlySet { + if (!parserModule) return new Set(); // parser module failed to load — see checkWasmGrammars + try { + return new Set(readdirSync(parserModule.GRAMMARS_DIR).filter((f) => f.endsWith('.wasm'))); + } catch { + return new Set(); // grammars/ doesn't exist at all + } +} + +/** Render up to `max` grammar filenames, with a "+N more" suffix beyond that. */ +function sampleList(entries: readonly GrammarRegistryEntry[], max = 5): string { + const sample = entries + .slice(0, max) + .map((e) => e.grammarFile) + .join(', '); + const suffix = entries.length > max ? `, +${entries.length - max} more` : ''; + return `${sample}${suffix}`; +} + +/** + * Check that every language in the registry has its WASM grammar file + * present on disk. Checks the *full* registry (all 30+ languages), not just + * the `required` tier (JS/TS/TSX) that `isWasmAvailable()` in `domain/parser.ts` + * gates parser startup on — a worktree can start up fine with only the + * required grammars while silently missing most language support. + * + * A missing `required` grammar (JS/TS/TSX) is a hard failure — the parser + * can't function without it. A missing *optional* grammar is reported as a + * non-blocking warning: non-required parsers are designed to fail gracefully + * at runtime (see the module doc comment), so this must never flip `status` + * to 'fail' on its own — that would incorrectly block `pretest`/`npm test` + * for every worktree missing even one rarely-used language's grammar. + * + * Both `registry` and `listGrammarFiles` are injectable so tests can supply a + * fake registry and a fake "what's on disk" set without touching the real + * grammars/ directory. `registry` defaults to the real `LANGUAGE_REGISTRY` + * unless the `domain/parser.js` module itself failed to load (see the + * top-level-await block above), in which case this reports a clear 'fail' + * instead of silently claiming zero missing grammars. + */ +export function checkWasmGrammars( + registry?: readonly GrammarRegistryEntry[], + listGrammarFiles: () => ReadonlySet = listInstalledGrammarFiles, +): DoctorCheck { + const id = 'wasm-grammars'; + const label = 'WASM tree-sitter grammars'; + + if (registry === undefined) { + if (!parserModule) { + return { + id, + label, + status: 'fail', + detail: `cannot check grammar completeness — parser module failed to load: ${parserModuleError}`, + fixCommand: 'npm install', + }; + } + registry = parserModule.LANGUAGE_REGISTRY; + } + + const existing = listGrammarFiles(); + const missing = findMissingGrammars(registry, existing); + + if (missing.length === 0) { + return { + id, + label, + status: 'ok', + detail: `all ${registry.length} grammar files present in grammars/`, + }; + } + + const missingRequired = missing.filter((e) => e.required); + const missingOptional = missing.filter((e) => !e.required); + + if (missingRequired.length > 0) { + const optionalNote = + missingOptional.length > 0 + ? `; ${missingOptional.length} optional grammar file(s) also missing` + : ''; + return { + id, + label, + status: 'fail', + detail: + `${missingRequired.length} required grammar file(s) missing ` + + `(${sampleList(missingRequired)})${optionalNote}`, + fixCommand: 'npm run build:wasm', + }; + } + + // Only optional grammars are missing — all required (JS/TS/TSX) grammars + // are present, so parsing isn't broken, just narrower. Non-blocking. + return { + id, + label, + status: 'warn', + detail: + `all required grammar files present; ${missingOptional.length} optional ` + + `grammar file(s) missing, non-blocking (${sampleList(missingOptional)})`, + fixCommand: 'npm run build:wasm', + }; +} + +/** + * Run every doctor check and aggregate the result. Fast and read-only — safe + * to call frequently (e.g. from a `pretest` hook) since neither check spawns + * a subprocess or mutates the environment. + * + * `checks` is injectable (defaulting to the two real checks) so the + * ok/'fail'-only aggregation rule — a 'warn' must never flip the overall + * report unhealthy — can be unit tested directly with fake check results, + * independent of the real native binary or grammars/ directory. + */ +export function runDoctorChecks( + checks: readonly DoctorCheck[] = [checkBetterSqlite3Abi(), checkWasmGrammars()], +): DoctorReport { + return { ok: checks.every((c) => c.status !== 'fail'), checks: [...checks] }; +} diff --git a/tests/unit/doctor.test.ts b/tests/unit/doctor.test.ts new file mode 100644 index 000000000..1cb19a9ed --- /dev/null +++ b/tests/unit/doctor.test.ts @@ -0,0 +1,316 @@ +/** + * Unit tests for src/infrastructure/doctor.ts (issue #1733). + * + * Covers the two failure classes reported against a stale worktree: a + * better-sqlite3 native binary compiled for an older Node ABI, and a + * grammars/ directory missing most of its .wasm files. Every check function + * takes its I/O (require(), directory listing) as an injectable parameter, + * so these tests simulate both broken states with fakes — no real native + * binary or grammars/ file is touched or modified. + * + * Grammar-completeness tests specifically cover the required-vs-optional + * split: a missing *required* (JS/TS/TSX) grammar must fail the check and the + * overall report (blocking `pretest`), but a missing *optional* grammar must + * only warn — never flip the report unhealthy — per this repo's own + * CLAUDE.md ("Non-required parsers ... fail gracefully if their WASM grammar + * is unavailable"). + */ +import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { LANGUAGE_REGISTRY } from '../../src/domain/parser.js'; +import { + checkBetterSqlite3Abi, + checkWasmGrammars, + findMissingGrammars, + parseAbiMismatchError, + runDoctorChecks, +} from '../../src/infrastructure/doctor.js'; + +describe('parseAbiMismatchError', () => { + it('parses the real Node native-addon ABI mismatch message', () => { + const message = `The module '/repo/node_modules/better-sqlite3/build/Release/better_sqlite3.node' +was compiled against a different Node.js version using +NODE_MODULE_VERSION 137. This version of Node.js requires +NODE_MODULE_VERSION 147. Please try re-compiling or re-installing +the module (for instance, using \`npm rebuild\` or \`npm install\`).`; + + expect(parseAbiMismatchError(message)).toEqual({ + compiledVersion: 137, + requiredVersion: 147, + }); + }); + + it('returns null for unrelated error text', () => { + expect(parseAbiMismatchError("Cannot find module 'better-sqlite3'")).toBeNull(); + expect(parseAbiMismatchError('Segmentation fault (core dumped)')).toBeNull(); + expect(parseAbiMismatchError('')).toBeNull(); + }); + + it('returns null when only one NODE_MODULE_VERSION mention is present', () => { + expect(parseAbiMismatchError('NODE_MODULE_VERSION 137 only mentioned once')).toBeNull(); + }); +}); + +describe('checkBetterSqlite3Abi', () => { + it('reports ok when loadModule succeeds', () => { + const result = checkBetterSqlite3Abi(() => ({ Database: class {} })); + expect(result.status).toBe('ok'); + expect(result.id).toBe('better-sqlite3-abi'); + expect(result.fixCommand).toBeUndefined(); + expect(result.detail).toContain(process.version); + }); + + it('reports a parsed ABI mismatch with the rebuild fix command', () => { + const message = `The module '/repo/node_modules/better-sqlite3/build/Release/better_sqlite3.node' +was compiled against a different Node.js version using +NODE_MODULE_VERSION 137. This version of Node.js requires +NODE_MODULE_VERSION 147. Please try re-compiling or re-installing +the module (for instance, using \`npm rebuild\` or \`npm install\`).`; + const result = checkBetterSqlite3Abi(() => { + throw new Error(message); + }); + + expect(result.status).toBe('fail'); + expect(result.fixCommand).toBe('npm rebuild better-sqlite3'); + expect(result.detail).toContain('137'); + expect(result.detail).toContain('147'); + }); + + it('reports a missing install with the npm install fix command', () => { + const result = checkBetterSqlite3Abi(() => { + const err = Object.assign(new Error("Cannot find module 'better-sqlite3'"), { + code: 'MODULE_NOT_FOUND', + }); + throw err; + }); + + expect(result.status).toBe('fail'); + expect(result.fixCommand).toBe('npm install'); + expect(result.detail).toMatch(/not installed/i); + }); + + it('falls back to the raw message and a generic rebuild fix when the error is unrecognized', () => { + // Simulates a future Node version rewording the ABI-mismatch message — + // detection must still fail closed rather than silently reporting healthy. + const result = checkBetterSqlite3Abi(() => { + throw new Error('Segmentation fault (core dumped)'); + }); + + expect(result.status).toBe('fail'); + expect(result.fixCommand).toBe('npm rebuild better-sqlite3'); + expect(result.detail).toContain('Segmentation fault'); + }); + + it('handles non-Error throws gracefully', () => { + const result = checkBetterSqlite3Abi(() => { + throw 'boom'; + }); + expect(result.status).toBe('fail'); + expect(result.detail).toContain('boom'); + }); +}); + +describe('findMissingGrammars (pure)', () => { + const registry = [ + { id: 'javascript', grammarFile: 'tree-sitter-javascript.wasm', required: true }, + { id: 'python', grammarFile: 'tree-sitter-python.wasm', required: false }, + { id: 'rust', grammarFile: 'tree-sitter-rust.wasm', required: false }, + ]; + + it('returns entries whose grammar file is absent from the existing set', () => { + const missing = findMissingGrammars(registry, new Set(['tree-sitter-javascript.wasm'])); + expect(missing.map((m) => m.id)).toEqual(['python', 'rust']); + }); + + it('returns an empty array when every grammar file is present', () => { + const all = new Set(registry.map((r) => r.grammarFile)); + expect(findMissingGrammars(registry, all)).toEqual([]); + }); + + it('returns the full registry when nothing is installed', () => { + expect(findMissingGrammars(registry, new Set())).toEqual(registry); + }); + + it('does not mutate its inputs', () => { + const existing = new Set(['tree-sitter-javascript.wasm']); + const registryCopy = [...registry]; + findMissingGrammars(registry, existing); + expect(registry).toEqual(registryCopy); + expect(existing.size).toBe(1); + }); +}); + +describe('checkWasmGrammars (fake registry + fake listGrammarFiles)', () => { + // Mirrors the real LANGUAGE_REGISTRY shape: a small required tier (like + // JS/TS/TSX) plus a larger optional tier (like every other language). + const registry = [ + { id: 'javascript', grammarFile: 'tree-sitter-javascript.wasm', required: true }, + { id: 'typescript', grammarFile: 'tree-sitter-typescript.wasm', required: true }, + { id: 'python', grammarFile: 'tree-sitter-python.wasm', required: false }, + { id: 'rust', grammarFile: 'tree-sitter-rust.wasm', required: false }, + { id: 'go', grammarFile: 'tree-sitter-go.wasm', required: false }, + { id: 'java', grammarFile: 'tree-sitter-java.wasm', required: false }, + { id: 'ruby', grammarFile: 'tree-sitter-ruby.wasm', required: false }, + { id: 'php', grammarFile: 'tree-sitter-php.wasm', required: false }, + ]; + const requiredFiles = registry.filter((r) => r.required).map((r) => r.grammarFile); + + it('reports ok when every grammar file is present', () => { + const result = checkWasmGrammars(registry, () => new Set(registry.map((r) => r.grammarFile))); + expect(result.status).toBe('ok'); + expect(result.id).toBe('wasm-grammars'); + expect(result.detail).toBe(`all ${registry.length} grammar files present in grammars/`); + expect(result.fixCommand).toBeUndefined(); + }); + + it('reports warn — never fail — when only optional grammars are missing and all required are present', () => { + // All required (javascript, typescript) present; all 6 optional missing. + const result = checkWasmGrammars(registry, () => new Set(requiredFiles)); + expect(result.status).toBe('warn'); + expect(result.status).not.toBe('fail'); + expect(result.fixCommand).toBe('npm run build:wasm'); + expect(result.detail).toContain('all required grammar files present'); + expect(result.detail).toContain('6 optional'); + expect(result.detail).toContain('non-blocking'); + // 6 missing optional entries, truncated sample: 5 shown + "+1 more". + expect(result.detail).toContain('+1 more'); + }); + + it('reports fail when a required grammar is missing, even if it is the only one missing', () => { + const present = new Set(registry.map((r) => r.grammarFile)); + present.delete('tree-sitter-javascript.wasm'); // drop one required grammar + const result = checkWasmGrammars(registry, () => present); + + expect(result.status).toBe('fail'); + expect(result.fixCommand).toBe('npm run build:wasm'); + expect(result.detail).toContain('1 required grammar file(s) missing'); + expect(result.detail).toContain('tree-sitter-javascript.wasm'); + // No optional grammars are missing in this scenario. + expect(result.detail).not.toContain('optional grammar file(s) also missing'); + }); + + it('reports fail and still mentions optional grammars missing alongside a required one', () => { + // Only typescript present: javascript (required) AND all 6 optional missing. + const result = checkWasmGrammars(registry, () => new Set(['tree-sitter-typescript.wasm'])); + + expect(result.status).toBe('fail'); + expect(result.detail).toContain('1 required grammar file(s) missing'); + expect(result.detail).toContain('tree-sitter-javascript.wasm'); + expect(result.detail).toContain('6 optional grammar file(s) also missing'); + }); + + it('reports fail when the grammars directory does not exist (empty set)', () => { + const result = checkWasmGrammars(registry, () => new Set()); + expect(result.status).toBe('fail'); + expect(result.detail).toContain(`${requiredFiles.length} required grammar file(s) missing`); + }); +}); + +describe('checkWasmGrammars (real fs I/O against a temp directory, real LANGUAGE_REGISTRY)', () => { + let tmpDir: string; + + afterEach(() => { + if (tmpDir) rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('detects a healthy directory containing every registered grammar file', () => { + tmpDir = mkdtempSync(path.join(os.tmpdir(), 'cg-doctor-grammars-healthy-')); + for (const entry of LANGUAGE_REGISTRY) { + writeFileSync(path.join(tmpDir, entry.grammarFile), ''); + } + + const result = checkWasmGrammars(LANGUAGE_REGISTRY, () => new Set(readdirSync(tmpDir))); + expect(result.status).toBe('ok'); + expect(result.detail).toBe( + `all ${LANGUAGE_REGISTRY.length} grammar files present in grammars/`, + ); + }); + + it('detects a near-empty directory (mirrors the reported worktree: 1 of ~40 files) — required grammars missing, so it fails', () => { + tmpDir = mkdtempSync(path.join(os.tmpdir(), 'cg-doctor-grammars-broken-')); + // Only the erlang (optional) grammar present, exactly as in the original + // bug report — JS/TS/TSX (required) are among the 35 missing files, so + // this must still be a hard failure, not just a warning. + writeFileSync(path.join(tmpDir, 'tree-sitter-erlang.wasm'), ''); + + const result = checkWasmGrammars(LANGUAGE_REGISTRY, () => new Set(readdirSync(tmpDir))); + expect(result.status).toBe('fail'); + expect(result.fixCommand).toBe('npm run build:wasm'); + expect(result.detail).toContain('required grammar file(s) missing'); + expect(result.detail).toContain('tree-sitter-javascript.wasm'); + }); + + it('detects a directory missing exactly one required grammar', () => { + tmpDir = mkdtempSync(path.join(os.tmpdir(), 'cg-doctor-grammars-partial-')); + const [, ...rest] = LANGUAGE_REGISTRY; // drop the first entry (javascript, required) + for (const entry of rest) { + writeFileSync(path.join(tmpDir, entry.grammarFile), ''); + } + + const result = checkWasmGrammars(LANGUAGE_REGISTRY, () => new Set(readdirSync(tmpDir))); + expect(result.status).toBe('fail'); + expect(result.detail).toContain('tree-sitter-javascript.wasm'); + expect(result.detail).toContain('1 required grammar file(s) missing'); + }); + + it('detects a directory missing only an optional grammar (all required present) — warns, does not fail', () => { + tmpDir = mkdtempSync(path.join(os.tmpdir(), 'cg-doctor-grammars-optional-gap-')); + // tree-sitter-erlang.wasm is an optional (required: false) grammar. + for (const entry of LANGUAGE_REGISTRY) { + if (entry.grammarFile === 'tree-sitter-erlang.wasm') continue; + writeFileSync(path.join(tmpDir, entry.grammarFile), ''); + } + + const result = checkWasmGrammars(LANGUAGE_REGISTRY, () => new Set(readdirSync(tmpDir))); + expect(result.status).toBe('warn'); + expect(result.status).not.toBe('fail'); + expect(result.detail).toContain('tree-sitter-erlang.wasm'); + expect(result.detail).toContain('non-blocking'); + }); +}); + +describe('runDoctorChecks', () => { + it('a non-blocking warn check does not flip the overall report unhealthy', () => { + const report = runDoctorChecks([ + { id: 'a', label: 'A', status: 'ok', detail: 'fine' }, + { id: 'b', label: 'B', status: 'warn', detail: 'minor issue, non-blocking' }, + ]); + expect(report.ok).toBe(true); + }); + + it('a fail check flips the overall report unhealthy, even alongside ok/warn checks', () => { + const report = runDoctorChecks([ + { id: 'a', label: 'A', status: 'ok', detail: 'fine' }, + { id: 'b', label: 'B', status: 'warn', detail: 'minor issue, non-blocking' }, + { id: 'c', label: 'C', status: 'fail', detail: 'broken' }, + ]); + expect(report.ok).toBe(false); + }); + + it('an all-ok report is healthy', () => { + const report = runDoctorChecks([{ id: 'a', label: 'A', status: 'ok', detail: 'fine' }]); + expect(report.ok).toBe(true); + }); + + it('runs both real checks against the real environment and returns a well-formed report', () => { + // Uses the real defaults (real require('better-sqlite3'), real grammars/ + // directory) — by the time this test runs, `pretest` has already gated + // `npm test` on a healthy environment, but this asserts shape rather than + // hard-coding a specific status so a single-file run on a mid-repair + // machine doesn't fail on an unrelated assertion. + const report = runDoctorChecks(); + + expect(typeof report.ok).toBe('boolean'); + expect(report.checks).toHaveLength(2); + expect(report.checks.map((c) => c.id)).toEqual(['better-sqlite3-abi', 'wasm-grammars']); + for (const check of report.checks) { + expect(['ok', 'warn', 'fail']).toContain(check.status); + expect(typeof check.label).toBe('string'); + expect(typeof check.detail).toBe('string'); + expect(check.detail.length).toBeGreaterThan(0); + } + expect(report.ok).toBe(report.checks.every((c) => c.status !== 'fail')); + }); +});