diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index b044d9318..f354e626b 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -75,3 +75,105 @@ jobs: build: 'true' - run: npx vitest run working-directory: gitnexus + + # End-to-end smoke test for the #1728 packaging fix: pack the published + # tarball, install it globally into a temp prefix, and assert no junction + # creation (the EPERM root cause) plus working CLI plus vendor cleanliness + # (#836). Runs on windows-latest because that is the platform the fix + # targets; the in-repo `npm ci` job above only exercises the dev-tree path + # and skips the tarball reify step where the historical EPERM occurred. + packaged-install-smoke: + name: packaged install smoke (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + steps: + # persist-credentials: false — this job runs npm pack + npm install -g + # from a tarball and never pushes back; the token in .git/config would + # be at risk of leaking through any future artifact-upload step + # (zizmor artipacked audit). Disable upfront. + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: ./.github/actions/setup-gitnexus + with: + build: 'true' + + - name: Pack gitnexus tarball + shell: bash + run: npm pack + working-directory: gitnexus + + - name: Install gitnexus tarball into isolated prefix + shell: bash + run: | + set -euo pipefail + PREFIX="$RUNNER_TEMP/gitnexus-smoke" + mkdir -p "$PREFIX" + TARBALL=$(find . -maxdepth 1 -name 'gitnexus-*.tgz' -print -quit) + if [ -z "$TARBALL" ]; then + echo "ERROR: no gitnexus-*.tgz tarball found in $(pwd)" >&2 + exit 1 + fi + echo "Installing $TARBALL into $PREFIX" + npm install -g --prefix "$PREFIX" "./$TARBALL" --no-audit --no-fund + echo "PREFIX=$PREFIX" >> "$GITHUB_ENV" + working-directory: gitnexus + + - name: Assert no junctions or vendor build artifacts + shell: bash + run: | + set -euo pipefail + # Locate the installed gitnexus package across npm prefix layouts + # (lib/node_modules on POSIX, node_modules on Windows). + for candidate in "$PREFIX/lib/node_modules/gitnexus" "$PREFIX/node_modules/gitnexus"; do + if [ -d "$candidate" ]; then + INSTALLED="$candidate" + break + fi + done + if [ -z "${INSTALLED:-}" ]; then + echo "ERROR: installed gitnexus package not found under $PREFIX" >&2 + ls -la "$PREFIX" || true + exit 1 + fi + echo "Installed package at: $INSTALLED" + + # #836 invariant: no node_modules/ or build/ under any vendor/*. + BAD=$(find "$INSTALLED/vendor" \( -name node_modules -o -name build \) -print 2>/dev/null || true) + if [ -n "$BAD" ]; then + echo "ERROR: vendor tree contains forbidden build artifacts (#836):" >&2 + echo "$BAD" >&2 + exit 1 + fi + + # #1728 invariant: materialized grammar dirs are real directories, + # not junctions/symlinks (which is what the EPERM regression created). + for name in tree-sitter-dart tree-sitter-proto tree-sitter-swift; do + entry="$INSTALLED/node_modules/$name" + if [ ! -e "$entry" ]; then + echo "WARN: $name not materialized (toolchain/prebuild may be unavailable on $RUNNER_OS)" + continue + fi + if [ -L "$entry" ]; then + echo "ERROR: $entry is a symlink/junction — #1728 regression" >&2 + exit 1 + fi + if [ ! -d "$entry" ]; then + echo "ERROR: $entry is not a directory" >&2 + exit 1 + fi + done + + - name: Assert gitnexus --version works + shell: bash + run: | + set -euo pipefail + if [ "$RUNNER_OS" = "Windows" ]; then + "$PREFIX/gitnexus.cmd" --version + else + "$PREFIX/bin/gitnexus" --version + fi diff --git a/README.md b/README.md index b3caf7f7a..9b14396a8 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ That's it. This indexes the codebase, installs agent skills, registers Claude Co To configure MCP for your editor, run `npx gitnexus setup` once — or set it up manually below. -> **Faster install (no C++ toolchain needed):** set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` before `npm install -g gitnexus` to skip the native `tree-sitter-dart` and `tree-sitter-proto` builds. Dart/Proto files won't be parsed, but install completes in seconds without `python3`/`make`/`g++`. Strict `=1` only — any other value falls through to the rebuild. +> **Faster install (no C++ toolchain needed):** set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` before `npm install -g gitnexus` to skip vendored grammar materialize/build (`tree-sitter-dart`, `tree-sitter-proto`, `tree-sitter-swift`). Dart/Proto/Swift files won't be parsed, but install completes in seconds without `python3`/`make`/`g++`. Strict `=1` only — any other value falls through to the rebuild. ### MCP Setup @@ -245,7 +245,7 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max | `GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD`| `max(3, poolSize)` | Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, every subsequent dispatch rejects until a fresh pool is created. | Hosts where a SIGSEGV-prone native grammar should trip the breaker sooner; CI runners that should fail loudly. | | `GITNEXUS_CHUNK_BYTE_BUDGET` | `2097152` (2 MB) | Chunk boundary used for cache-key composition and dispatch. Smaller = finer-grained cache hits but more dispatch overhead. | Tuning incremental-analyze cache behavior on monorepos. | | `GITNEXUS_NO_GITIGNORE` | unset | When set, skips `.gitignore` parsing. `.gitnexusignore` is still honored. | Indexing a repo whose `.gitignore` excludes files you actually want indexed (e.g., generated code committed for cross-repo lookup). | -| `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` | unset | When `=1` strictly, skips native builds for `tree-sitter-dart` / `tree-sitter-proto` at install time. | Installing on a host without a C++ toolchain; you're willing to skip Dart/Proto parsing. | +| `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` | unset | When `=1` strictly, skips vendored grammar materialize/build for `tree-sitter-dart`, `tree-sitter-proto`, and `tree-sitter-swift` at install time. | Installing on a host without a C++ toolchain or where Swift prebuilds don't match; you're willing to skip Dart/Proto/Swift parsing. | #### Publishing to understand-quickly (opt-in) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index ced2e01a2..47803c469 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -41,7 +41,7 @@ "sigma": "^3.0.2", "tailwindcss": "^4.2.4", "uuid": "^14.0.0", - "zod": "^4.3.6" + "zod": "^4.4.3" }, "devDependencies": { "@babel/types": "^7.29.0", @@ -8949,9 +8949,9 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index d4580b716..6a2bc0187 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -51,7 +51,7 @@ "sigma": "^3.0.2", "tailwindcss": "^4.2.4", "uuid": "^14.0.0", - "zod": "^4.3.6" + "zod": "^4.4.3" }, "devDependencies": { "@babel/types": "^7.29.0", diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index cfa743483..eb67809ed 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -68,10 +68,7 @@ "optionalDependencies": { "node-addon-api": "^8.0.0", "node-gyp-build": "^4.8.0", - "tree-sitter-dart": "file:./vendor/tree-sitter-dart", - "tree-sitter-kotlin": "^0.3.8", - "tree-sitter-proto": "file:./vendor/tree-sitter-proto", - "tree-sitter-swift": "file:./vendor/tree-sitter-swift" + "tree-sitter-kotlin": "^0.3.8" } }, "../gitnexus-shared": { @@ -4860,10 +4857,6 @@ } } }, - "node_modules/tree-sitter-dart": { - "resolved": "vendor/tree-sitter-dart", - "link": true - }, "node_modules/tree-sitter-go": { "version": "0.23.4", "resolved": "https://registry.npmjs.org/tree-sitter-go/-/tree-sitter-go-0.23.4.tgz", @@ -4967,10 +4960,6 @@ } } }, - "node_modules/tree-sitter-proto": { - "resolved": "vendor/tree-sitter-proto", - "link": true - }, "node_modules/tree-sitter-python": { "version": "0.23.4", "resolved": "https://registry.npmjs.org/tree-sitter-python/-/tree-sitter-python-0.23.4.tgz", @@ -5028,10 +5017,6 @@ } } }, - "node_modules/tree-sitter-swift": { - "resolved": "vendor/tree-sitter-swift", - "link": true - }, "node_modules/tree-sitter-typescript": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/tree-sitter-typescript/-/tree-sitter-typescript-0.23.2.tgz", @@ -5475,8 +5460,8 @@ }, "vendor/tree-sitter-dart": { "version": "1.0.0", + "extraneous": true, "license": "ISC", - "optional": true, "peerDependencies": { "tree-sitter": "^0.21.0" }, @@ -5488,21 +5473,16 @@ }, "vendor/tree-sitter-proto": { "version": "0.4.1", + "extraneous": true, "license": "MIT", - "optional": true, "peerDependencies": { "tree-sitter": ">=0.21.0" } }, "vendor/tree-sitter-swift": { "version": "0.7.1", - "hasInstallScript": true, + "extraneous": true, "license": "MIT", - "optional": true, - "dependencies": { - "node-addon-api": "^8.0.0", - "node-gyp-build": "^4.8.0" - }, "peerDependencies": { "tree-sitter": "^0.21.1 || ^0.22.1" }, diff --git a/gitnexus/package.json b/gitnexus/package.json index 3863a0457..fb40cba56 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -48,7 +48,7 @@ "test:integration": "vitest run test/integration", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "postinstall": "node scripts/build-tree-sitter-dart.cjs && node scripts/build-tree-sitter-proto.cjs", + "postinstall": "node scripts/materialize-vendor-grammars.cjs && node scripts/build-tree-sitter-dart.cjs && node scripts/build-tree-sitter-proto.cjs && node scripts/build-tree-sitter-swift.cjs", "prepare": "node scripts/build.js", "prepack": "node scripts/build.js" }, @@ -92,10 +92,7 @@ "optionalDependencies": { "node-addon-api": "^8.0.0", "node-gyp-build": "^4.8.0", - "tree-sitter-dart": "file:./vendor/tree-sitter-dart", - "tree-sitter-kotlin": "^0.3.8", - "tree-sitter-proto": "file:./vendor/tree-sitter-proto", - "tree-sitter-swift": "file:./vendor/tree-sitter-swift" + "tree-sitter-kotlin": "^0.3.8" }, "devDependencies": { "@types/cli-progress": "^3.11.6", diff --git a/gitnexus/scripts/build-tree-sitter-dart.cjs b/gitnexus/scripts/build-tree-sitter-dart.cjs index 3c56e9f0c..d7542e253 100644 --- a/gitnexus/scripts/build-tree-sitter-dart.cjs +++ b/gitnexus/scripts/build-tree-sitter-dart.cjs @@ -1,4 +1,8 @@ #!/usr/bin/env node +/** + * Build tree-sitter-dart native binding in node_modules/ after materialize-vendor-grammars.cjs. + * Vendored source lives in vendor/ only; see #836 and #1728. + */ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); diff --git a/gitnexus/scripts/build-tree-sitter-proto.cjs b/gitnexus/scripts/build-tree-sitter-proto.cjs index 47e091daf..eaefd14e6 100644 --- a/gitnexus/scripts/build-tree-sitter-proto.cjs +++ b/gitnexus/scripts/build-tree-sitter-proto.cjs @@ -4,7 +4,7 @@ * * Why this script exists: * tree-sitter-proto is vendored under gitnexus/vendor/tree-sitter-proto/ - * and declared as a `file:` optionalDependency. Previously, the vendored + * and copied into node_modules/ by materialize-vendor-grammars.cjs. Previously, the vendored * package had its own `dependencies` and `install` script, which caused * npm to create `vendor/tree-sitter-proto/node_modules/` and * `vendor/tree-sitter-proto/build/` during install. Those directories @@ -20,9 +20,8 @@ * gitnexus's own optionalDependencies, and moved native compilation here. * * What this does: - * Runs `npx node-gyp rebuild` inside `node_modules/tree-sitter-proto/` - * (which npm creates as a copy of vendor/tree-sitter-proto/ when - * resolving the file: dep). Build output lands in + * Runs `npx node-gyp rebuild` inside `node_modules/tree-sitter-proto/`. + * Build output lands in * `node_modules/tree-sitter-proto/build/Release/tree_sitter_proto_binding.node` * — under npm-managed territory, safe on upgrade. * diff --git a/gitnexus/scripts/build-tree-sitter-swift.cjs b/gitnexus/scripts/build-tree-sitter-swift.cjs new file mode 100644 index 000000000..cbdd6eb54 --- /dev/null +++ b/gitnexus/scripts/build-tree-sitter-swift.cjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node +/** + * Probe tree-sitter-swift prebuild availability at install time. + * + * The vendored package ships platform prebuilds; node-gyp-build selects the + * correct binary at require time. This script calls node-gyp-build once + * against the materialized package so a missing-prebuild failure surfaces + * as an install-time warning (with the rest of the gitnexus install + * succeeding) rather than as a runtime error the first time Swift parsing + * is requested. The result is discarded — it does not copy, register, or + * mutate anything; the runtime require() path in parser-loader does the + * actual load. Running this probe here instead of an npm `install` script + * on the vendored package preserves the #836 hygiene (no scripts.install + * inside vendor/). + */ +const fs = require('fs'); +const path = require('path'); + +if (process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS === '1') { + console.warn('[tree-sitter-swift] Skipping prebuild probe (GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1).'); + process.exit(0); +} + +const swiftDir = path.join(__dirname, '..', 'node_modules', 'tree-sitter-swift'); + +try { + if (!fs.existsSync(path.join(swiftDir, 'bindings', 'node', 'index.js'))) { + process.exit(0); + } + + const nodeGypBuild = require('node-gyp-build'); + nodeGypBuild(swiftDir); +} catch (err) { + console.warn('[tree-sitter-swift] Prebuild probe failed:', err.message); + console.warn( + '[tree-sitter-swift] Swift parsing will be unavailable. Non-Swift functionality is unaffected.', + ); + process.exit(0); +} diff --git a/gitnexus/scripts/build.js b/gitnexus/scripts/build.js index 61e902dc6..e651708e3 100644 --- a/gitnexus/scripts/build.js +++ b/gitnexus/scripts/build.js @@ -18,6 +18,22 @@ const ROOT = path.resolve(__dirname, '..'); const SHARED_ROOT = path.resolve(ROOT, '..', 'gitnexus-shared'); const DIST = path.join(ROOT, 'dist'); const SHARED_DEST = path.join(DIST, '_shared'); +const DEFAULT_BUILD_TIMEOUT_MS = 300_000; + +function getBuildTimeoutMs() { + const raw = process.env.GITNEXUS_BUILD_TIMEOUT_MS; + if (raw === undefined || raw.trim() === '') return DEFAULT_BUILD_TIMEOUT_MS; + + const parsed = Number.parseInt(raw, 10); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + + console.warn( + `[build] ignoring invalid GITNEXUS_BUILD_TIMEOUT_MS=${JSON.stringify(raw)}; using ${DEFAULT_BUILD_TIMEOUT_MS}ms`, + ); + return DEFAULT_BUILD_TIMEOUT_MS; +} + +const BUILD_TIMEOUT_MS = getBuildTimeoutMs(); // ── 1. Build gitnexus-shared ─────────────────────────────────────── console.log('[build] compiling gitnexus-shared…'); @@ -25,11 +41,11 @@ const tscCmd = process.platform === 'win32' ? path.join('node_modules', '.bin', 'tsc.cmd') : path.join('node_modules', '.bin', 'tsc'); -execSync(tscCmd, { cwd: SHARED_ROOT, stdio: 'inherit', timeout: 120_000 }); +execSync(tscCmd, { cwd: SHARED_ROOT, stdio: 'inherit', timeout: BUILD_TIMEOUT_MS }); // ── 2. Build gitnexus ────────────────────────────────────────────── console.log('[build] compiling gitnexus…'); -execSync(tscCmd, { cwd: ROOT, stdio: 'inherit', timeout: 120_000 }); +execSync(tscCmd, { cwd: ROOT, stdio: 'inherit', timeout: BUILD_TIMEOUT_MS }); // ── 3. Copy shared dist ──────────────────────────────────────────── console.log('[build] copying shared module into dist/_shared…'); @@ -82,9 +98,9 @@ if (fs.existsSync(path.join(WEB_ROOT, 'package.json'))) { console.log('[build] building gitnexus-web…'); if (!fs.existsSync(path.join(WEB_ROOT, 'node_modules'))) { console.log('[build] installing gitnexus-web dependencies…'); - execSync('npm ci', { cwd: WEB_ROOT, stdio: 'inherit', timeout: 600_000 }); + execSync('npm ci', { cwd: WEB_ROOT, stdio: 'inherit', timeout: BUILD_TIMEOUT_MS }); } - execSync('npm run build', { cwd: WEB_ROOT, stdio: 'inherit', timeout: 600_000 }); + execSync('npm run build', { cwd: WEB_ROOT, stdio: 'inherit', timeout: BUILD_TIMEOUT_MS }); // Copy dist → gitnexus/web/ (shipped in the npm package) fs.rmSync(WEB_DEST, { recursive: true, force: true }); diff --git a/gitnexus/scripts/materialize-vendor-grammars.cjs b/gitnexus/scripts/materialize-vendor-grammars.cjs new file mode 100644 index 000000000..399696f9b --- /dev/null +++ b/gitnexus/scripts/materialize-vendor-grammars.cjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node +/** + * Copy vendored tree-sitter grammars into node_modules/ using real files (fs.cpSync). + * + * Published gitnexus used to declare these as optionalDependencies with + * `file:./vendor/...`, which makes npm symlink/junction vendor → node_modules on + * install. Windows without Developer Mode often fails with EPERM (#1728). + * + * Vendor trees stay read-only in gitnexus/vendor/; build artifacts must only + * land under node_modules/ (see #836). + */ +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..'); +const VENDORED_GRAMMARS = ['tree-sitter-dart', 'tree-sitter-proto', 'tree-sitter-swift']; + +if (process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS === '1') { + console.warn( + '[gitnexus] Skipping vendored grammar materialize (GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1). Dart/Proto/Swift parsing will be unavailable.', + ); + process.exit(0); +} + +for (const name of VENDORED_GRAMMARS) { + const src = path.join(ROOT, 'vendor', name); + const dest = path.join(ROOT, 'node_modules', name); + + if (!fs.existsSync(src)) { + console.warn(`[gitnexus] vendor/${name} missing; skipping materialize.`); + continue; + } + + // Sequence: copy src → partial; rename dest → backup; rename partial → dest; + // remove backup. If any step fails, restore from backup so a previously- + // materialized grammar is never lost. Targets the #1728 EPERM scenario plus + // narrower failure modes (Windows AV scanner racing on rename, EBUSY mid-swap). + const partial = `${dest}.materialize-tmp`; + const backup = `${dest}.materialize-bak`; + try { + fs.mkdirSync(path.join(ROOT, 'node_modules'), { recursive: true }); + fs.rmSync(partial, { recursive: true, force: true }); + fs.rmSync(backup, { recursive: true, force: true }); + fs.cpSync(src, partial, { recursive: true, verbatim: true }); + if (fs.existsSync(dest)) { + fs.renameSync(dest, backup); + } + try { + fs.renameSync(partial, dest); + } catch (renameErr) { + // Best-effort rollback: restore the previous dest from backup. + if (fs.existsSync(backup)) { + try { + fs.renameSync(backup, dest); + } catch { + // If rollback also fails, the prior backup directory still exists on + // disk — the catch block below surfaces both errors via the warning. + } + } + throw renameErr; + } + fs.rmSync(backup, { recursive: true, force: true }); + } catch (err) { + // Fail-soft: a single locked/inaccessible file (common on Windows) must not + // abort the whole gitnexus install. Matches build-tree-sitter-*.cjs pattern. + fs.rmSync(partial, { recursive: true, force: true }); + console.warn(`[gitnexus] Could not materialize vendor/${name}: ${err.message}`); + console.warn( + `[gitnexus] ${name} parsing will be unavailable. Other functionality is unaffected.`, + ); + } +} diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 6ea04df05..29253f969 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -9,7 +9,7 @@ */ import path from 'path'; -import { execFileSync } from 'child_process'; +import { spawn } from 'child_process'; import v8 from 'v8'; import cliProgress from 'cli-progress'; import { closeLbug } from '../core/lbug/lbug-adapter.js'; @@ -37,6 +37,7 @@ import { isHfDownloadFailure } from '../core/embeddings/hf-env.js'; // previous behaviour silently swallowed stack traces and made #1169 // indistinguishable from a no-op success on Windows. const realStderrWrite = process.stderr.write.bind(process.stderr); +const realStdoutWrite = process.stdout.write.bind(process.stdout); const writeFatalToStderr = (label: string, err: unknown): void => { const isErr = err instanceof Error; @@ -78,15 +79,274 @@ const HEAP_FLAG = `--max-old-space-size=${RESPAWN_HEAP_MB}`; /** Increase default stack size (KB) to prevent stack overflow on deep class hierarchies. */ const STACK_KB = 4096; const STACK_FLAG = `--stack-size=${STACK_KB}`; +const RESPAWN_OUTPUT_TAIL_CHARS = 1024 * 1024; +const RESPAWN_PROGRESS_ENV = 'GITNEXUS_RESPAWN_PROGRESS_TTY'; + +interface CliProgressTerminal { + cursorSave(): void; + cursorRestore(): void; + cursor(enabled: boolean): void; + lineWrapping(enabled: boolean): void; + cursorTo(x?: number | null, y?: number | null): void; + cursorRelative(dx?: number | null, dy?: number | null): void; + cursorRelativeReset(): void; + clearRight(): void; + clearLine(): void; + clearBottom(): void; + newline(): void; + write(s: string, rawWrite?: boolean): void; + isTTY(): boolean; + getWidth(): number; +} + +const terminalColumns = (): number => { + const parsed = Number(process.env.COLUMNS); + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 80; +}; + +const ANSI_ESCAPE_PATTERN = + /\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\)|[PX^_][\s\S]*?\x1B\\|[78]|[@-Z\\-_])/y; + +interface IntlSegmenterLike { + segment(input: string): Iterable<{ segment: string }>; +} + +type IntlWithOptionalSegmenter = typeof Intl & { + Segmenter?: new ( + locales?: string | string[], + options?: { granularity?: 'grapheme' }, + ) => IntlSegmenterLike; +}; + +const splitGraphemes = (text: string): string[] => { + const Segmenter = (Intl as IntlWithOptionalSegmenter).Segmenter; + if (Segmenter) { + return Array.from( + new Segmenter(undefined, { granularity: 'grapheme' }).segment(text), + (s) => s.segment, + ); + } + return Array.from(text); +}; + +const isZeroWidthCodePoint = (codePoint: number): boolean => + codePoint === 0x200d || + (codePoint >= 0x0300 && codePoint <= 0x036f) || + (codePoint >= 0x1ab0 && codePoint <= 0x1aff) || + (codePoint >= 0x1dc0 && codePoint <= 0x1dff) || + (codePoint >= 0x20d0 && codePoint <= 0x20ff) || + (codePoint >= 0xfe00 && codePoint <= 0xfe0f) || + (codePoint >= 0xfe20 && codePoint <= 0xfe2f); + +const isWideCodePoint = (codePoint: number): boolean => + codePoint >= 0x1100 && + (codePoint <= 0x115f || + codePoint === 0x2329 || + codePoint === 0x232a || + (codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) || + (codePoint >= 0xac00 && codePoint <= 0xd7a3) || + (codePoint >= 0xf900 && codePoint <= 0xfaff) || + (codePoint >= 0xfe10 && codePoint <= 0xfe19) || + (codePoint >= 0xfe30 && codePoint <= 0xfe6f) || + (codePoint >= 0xff00 && codePoint <= 0xff60) || + (codePoint >= 0xffe0 && codePoint <= 0xffe6) || + (codePoint >= 0x1f300 && codePoint <= 0x1faff) || + (codePoint >= 0x20000 && codePoint <= 0x3fffd)); + +const visibleColumns = (text: string): number => { + let columns = 0; + for (const char of Array.from(text)) { + const codePoint = char.codePointAt(0); + if (codePoint === undefined || isZeroWidthCodePoint(codePoint)) continue; + columns += isWideCodePoint(codePoint) ? 2 : 1; + } + return columns; +}; + +const readAnsiEscapeAt = (text: string, index: number): string | undefined => { + ANSI_ESCAPE_PATTERN.lastIndex = index; + return ANSI_ESCAPE_PATTERN.exec(text)?.[0]; +}; + +const truncateAnsiToColumns = (text: string, maxColumns: number): string => { + if (!Number.isFinite(maxColumns) || maxColumns <= 0) return ''; + + let output = ''; + let columns = 0; + let index = 0; + + while (index < text.length) { + const escape = readAnsiEscapeAt(text, index); + if (escape) { + output += escape; + index += escape.length; + continue; + } + + const nextEscapeIndex = text.indexOf('\x1B', index); + const plainEnd = nextEscapeIndex === -1 ? text.length : nextEscapeIndex; + const plainText = text.slice(index, plainEnd); + + for (const segment of splitGraphemes(plainText)) { + const width = visibleColumns(segment); + if (width > 0 && columns + width > maxColumns) return output; + output += segment; + columns += width; + } + + index = plainEnd; + } + + return output; +}; + +const createAnsiPipeTerminal = (stream: NodeJS.WriteStream): CliProgressTerminal => { + let linewrap = true; + let dy = 0; + const write = (s: string): void => { + stream.write(s); + }; + const moveVertical = (delta: number): void => { + if (delta > 0) write(`\x1B[${delta}B`); + else if (delta < 0) write(`\x1B[${Math.abs(delta)}A`); + }; + + return { + cursorSave: () => write('\x1B7'), + cursorRestore: () => write('\x1B8'), + cursor: (enabled) => write(enabled ? '\x1B[?25h' : '\x1B[?25l'), + lineWrapping: (enabled) => { + linewrap = enabled; + write(enabled ? '\x1B[?7h' : '\x1B[?7l'); + }, + cursorTo: (x = null, y = null) => { + if (typeof y === 'number' && typeof x === 'number') { + write(`\x1B[${y + 1};${x + 1}H`); + return; + } + if (typeof x === 'number') { + write(x === 0 ? '\r' : `\x1B[${x + 1}G`); + } + }, + cursorRelative: (dx = null, nextDy = null) => { + if (typeof dx === 'number' && dx !== 0) { + write(dx > 0 ? `\x1B[${dx}C` : `\x1B[${Math.abs(dx)}D`); + } + if (typeof nextDy === 'number' && nextDy !== 0) { + dy += nextDy; + moveVertical(nextDy); + } + }, + cursorRelativeReset: () => { + moveVertical(-dy); + write('\r'); + dy = 0; + }, + clearRight: () => write('\x1B[0K'), + clearLine: () => write('\x1B[2K'), + clearBottom: () => write('\x1B[0J'), + newline: () => { + write('\n'); + dy++; + }, + write: (s, rawWrite = false) => { + const width = terminalColumns(); + write(linewrap && rawWrite === false ? truncateAnsiToColumns(s, width) : s); + }, + isTTY: () => true, + getWidth: terminalColumns, + }; +}; + +const shouldBridgeRespawnProgressTty = (): boolean => + process.stderr.isTTY === true || process.stdout.isTTY === true; + +interface RespawnExit { + status?: number | null; + signal?: NodeJS.Signals | null; + stdout?: string; + stderr?: string; + message?: string; +} + +const appendOutputTail = (tail: string, chunk: unknown): string => { + const text = Buffer.isBuffer(chunk) + ? chunk.toString('utf8') + : typeof chunk === 'string' + ? chunk + : String(chunk ?? ''); + if (!text) return tail; + const next = tail + text; + return next.length > RESPAWN_OUTPUT_TAIL_CHARS ? next.slice(-RESPAWN_OUTPUT_TAIL_CHARS) : next; +}; + +/** + * Run the respawned analyzer while teeing child output through to the parent + * and keeping a bounded tail for crash classification. + * + * `execFileSync(..., { stdio: 'inherit' })` preserved live progress but hid + * stderr/stdout from the parent on abnormal exits. That made every + * SIGABRT/status-134 child look like an output-less V8 heap OOM, even when the + * terminal had already shown a native crash such as + * `libc++abi: ... Napi::Error`. Piped streams plus an explicit tee keeps the UX + * and gives `childProcessLikelyOom` the evidence it needs. + */ +const runRespawnedAnalyze = ( + args: readonly string[], + env: NodeJS.ProcessEnv, +): Promise => + new Promise((resolve) => { + let stdout = ''; + let stderr = ''; + let settled = false; + const finish = (exit: RespawnExit): void => { + if (settled) return; + settled = true; + resolve(exit); + }; + + const child = spawn(process.execPath, [...args], { + stdio: ['inherit', 'pipe', 'pipe'], + env, + }); + + child.stdout?.on('data', (chunk) => { + stdout = appendOutputTail(stdout, chunk); + realStdoutWrite(chunk); + }); + child.stderr?.on('data', (chunk) => { + stderr = appendOutputTail(stderr, chunk); + realStderrWrite(chunk); + }); + child.on('error', (err) => { + finish({ + status: 1, + signal: null, + stdout, + stderr, + message: err instanceof Error ? err.message : String(err), + }); + }); + child.on('close', (status, signal) => { + finish({ + status, + signal, + stdout, + stderr, + message: `Command failed: ${process.execPath} ${args.join(' ')}`, + }); + }); + }); /** * Heuristic for "child re-exec likely died from V8 OOM". * - * Platform-independent detection is best-effort: V8/Node usually emit - * stable heap-exhaustion phrases in stderr/message across Linux/macOS/Windows - * (for example "JavaScript heap out of memory" or "Reached heap limit"), - * while some environments only expose status/signal (e.g. 134/SIGABRT). - * We combine both text signatures and process-exit signatures. + * Platform-independent detection is best-effort: V8/Node usually emit stable + * heap-exhaustion phrases in stderr/message across Linux/macOS/Windows (for + * example "JavaScript heap out of memory" or "Reached heap limit"). When the + * child produced no output at all, we still treat status 134/SIGABRT as likely + * heap OOM. If stderr/stdout contains a native crash diagnostic, the output + * evidence wins and we do not print heap guidance. */ const childProcessLikelyOom = (err: unknown): boolean => { if (!err || typeof err !== 'object') return false; @@ -122,6 +382,31 @@ const childProcessLikelyOom = (err: unknown): boolean => { return e.status === 134 || e.signal === 'SIGABRT'; }; +const childProcessLikelyNativeAbort = (err: unknown): boolean => { + if (!err || typeof err !== 'object') return false; + const e = err as { + stderr?: unknown; + stdout?: unknown; + message?: unknown; + }; + const hasNativeAbortSignature = (v: unknown): boolean => { + const text = ( + Buffer.isBuffer(v) ? v.toString('utf8') : typeof v === 'string' ? v : '' + ).toLowerCase(); + if (!text) return false; + return ( + text.includes('napi::error') || + text.includes('libc++abi: terminating') || + text.includes('abort trap') || + text.includes('native stack') || + text.includes('native worker') || + text.includes('native binding') + ); + }; + + return [e.message, e.stderr, e.stdout].some((v) => hasNativeAbortSignature(v)); +}; + const forceHeapOOMForTestIfEnabled = (): void => { if (process.env.GITNEXUS_TEST_FORCE_HEAP_OOM !== '1') return; // Allocate JS strings (not Buffers) so pressure lands on V8 heap itself. @@ -131,7 +416,7 @@ const forceHeapOOMForTestIfEnabled = (): void => { }; /** Re-exec the process with a 16GB heap and larger stack if we're currently below that. */ -function ensureHeap(): boolean { +async function ensureHeap(): Promise { const nodeOpts = process.env.NODE_OPTIONS || ''; if (nodeOpts.includes('--max-old-space-size')) return false; @@ -143,13 +428,15 @@ function ensureHeap(): boolean { const cliFlags = [HEAP_FLAG]; if (!nodeOpts.includes('--stack-size')) cliFlags.push(STACK_FLAG); - try { - execFileSync(process.execPath, [...cliFlags, ...process.argv.slice(1)], { - stdio: 'inherit', - env: { ...process.env, NODE_OPTIONS: `${nodeOpts} ${HEAP_FLAG}`.trim() }, - }); - } catch (e: unknown) { - if (childProcessLikelyOom(e)) { + const childArgs = [...cliFlags, ...process.argv.slice(1)]; + const childEnv = { + ...process.env, + NODE_OPTIONS: `${nodeOpts} ${HEAP_FLAG}`.trim(), + }; + if (shouldBridgeRespawnProgressTty()) childEnv[RESPAWN_PROGRESS_ENV] = '1'; + const childExit = await runRespawnedAnalyze(childArgs, childEnv); + if (childExit.status !== 0 || childExit.signal) { + if (childProcessLikelyOom(childExit)) { cliError( ` Analysis likely ran out of memory.\n` + ` Retry with a larger heap if your machine allows it:\n` + @@ -158,11 +445,18 @@ function ensureHeap(): boolean { ` If this persists, it may be a native crash unrelated to heap size.\n`, { recoveryHint: 'heap-oom-respawn' }, ); + } else if (childProcessLikelyNativeAbort(childExit)) { + cliError( + ` Analysis aborted in a native worker or native binding path.\n` + + ` Try one of these recovery paths:\n` + + ` gitnexus analyze --workers 0\n` + + ` npm uninstall -g gitnexus && npm install -g gitnexus@latest\n` + + ` Use Node 22 LTS if you are on a newer non-LTS runtime.\n`, + { recoveryHint: 'native-worker-abort' }, + ); } const status = - typeof e === 'object' && e !== null && 'status' in e && typeof e.status === 'number' - ? e.status - : 1; + typeof childExit.status === 'number' && childExit.status !== 0 ? childExit.status : 1; process.exitCode = status; } return true; @@ -185,6 +479,7 @@ const ANALYZE_CLI_ENV_KEYS = [ 'GITNEXUS_EMBEDDING_BATCH_SIZE', 'GITNEXUS_EMBEDDING_SUB_BATCH_SIZE', 'GITNEXUS_EMBEDDING_DEVICE', + 'GITNEXUS_ANALYZE_PROGRESS_ACTIVE', ] as const; type AnalyzeEnvSnapshot = Record<(typeof ANALYZE_CLI_ENV_KEYS)[number], string | undefined>; @@ -292,7 +587,7 @@ export const shouldGenerateCommunitySkillFiles = ( ): boolean => Boolean(options?.skills && pipelineResult && !options?.indexOnly); export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOptions) => { - if (ensureHeap()) return; + if (await ensureHeap()) return; forceHeapOOMForTestIfEnabled(); // Install fatal handlers immediately after re-exec resolution so any @@ -515,19 +810,25 @@ const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions): } // ── CLI progress bar setup ───────────────────────────────────────── - const bar = new cliProgress.SingleBar( - { - format: ' {bar} {percentage}% | {phase}', - barCompleteChar: '\u2588', - barIncompleteChar: '\u2591', - hideCursor: true, - barGlue: '', - autopadding: true, - clearOnComplete: false, - stopOnComplete: false, - }, - cliProgress.Presets.shades_grey, - ); + const barOptions: cliProgress.Options & { terminal?: CliProgressTerminal } = { + format: ' {bar} {percentage}% | {phase}', + barCompleteChar: '\u2588', + barIncompleteChar: '\u2591', + hideCursor: true, + barGlue: '', + autopadding: true, + clearOnComplete: false, + stopOnComplete: false, + }; + if (process.env[RESPAWN_PROGRESS_ENV] === '1' && process.stderr.isTTY !== true) { + // Heap respawn pipes stderr so the parent can classify native/OOM crashes. + // The parent was a real TTY when it opted into this env var, so forward + // ANSI cursor controls through the pipe instead of cli-progress' non-TTY + // newline mode. That keeps one-line redraw UX while retaining stderr tail + // capture for diagnostics. + barOptions.terminal = createAnsiPipeTerminal(process.stderr); + } + const bar = new cliProgress.SingleBar(barOptions, cliProgress.Presets.shades_grey); bar.start(100, 0, { phase: 'Initializing...' }); @@ -561,7 +862,7 @@ const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions): // eslint-disable-next-line no-console -- intentional console-routing for progress bar UX const origError = console.error.bind(console); let barCurrentValue = 0; - const barLog = (...args: any[]) => { + const barLog = (...args: unknown[]) => { process.stdout.write('\x1b[2K\r'); origLog(args.map((a) => (typeof a === 'string' ? a : String(a))).join(' ')); bar.update(barCurrentValue); @@ -571,6 +872,7 @@ const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions): console.warn = barLog; // eslint-disable-next-line no-console -- intentional console-routing for progress bar UX console.error = barLog; + process.env.GITNEXUS_ANALYZE_PROGRESS_ACTIVE = '1'; // Track elapsed time per phase let lastPhaseLabel = 'Initializing...'; diff --git a/gitnexus/src/cli/optional-grammars.ts b/gitnexus/src/cli/optional-grammars.ts index 14f6c3c5e..e12b471e0 100644 --- a/gitnexus/src/cli/optional-grammars.ts +++ b/gitnexus/src/cli/optional-grammars.ts @@ -1,15 +1,18 @@ /** * Optional grammar availability check. * - * tree-sitter-dart and tree-sitter-proto are optionalDependencies that - * require a `node-gyp rebuild` at install time. The build can be skipped - * via GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 (postinstall scripts), or it can - * silently soft-fail when the C++ toolchain is missing. + * tree-sitter-dart, tree-sitter-proto, and tree-sitter-swift are vendored + * under vendor/ and materialized into node_modules/ at postinstall. Dart + * and Proto are built from source with node-gyp; Swift ships platform + * prebuilds activated via node-gyp-build. All three can be skipped via + * GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 (postinstall scripts), or can silently + * soft-fail when the toolchain is missing (Dart/Proto) or no prebuild + * matches the host platform (Swift). * * Either path produces the same observable: the .node binding is absent * at runtime. This helper detects that condition and surfaces a single - * stderr line per missing grammar so users learn why .dart/.proto support - * is unavailable instead of silently getting a degraded index. + * stderr line per missing grammar so users learn why .dart/.proto/.swift + * support is unavailable instead of silently getting a degraded index. */ import { createRequire } from 'module'; @@ -29,6 +32,7 @@ interface OptionalGrammar { const OPTIONAL_GRAMMARS: OptionalGrammar[] = [ { name: 'tree-sitter-dart', pkg: 'tree-sitter-dart', extensions: ['.dart'] }, { name: 'tree-sitter-proto', pkg: 'tree-sitter-proto', extensions: ['.proto'] }, + { name: 'tree-sitter-swift', pkg: 'tree-sitter-swift', extensions: ['.swift'] }, ]; export interface MissingGrammar { @@ -40,8 +44,8 @@ export interface MissingGrammar { * Returns the list of optional grammars whose native binding cannot be * loaded. Actually `require()`s the package — `require.resolve` would * locate the entry path even when the `.node` binding is absent (the - * `file:` package directory is installed regardless of postinstall - * outcome), giving false negatives for the exact users we want to warn: + * package directory exists without a working `.node` binding), giving false + * negatives for the exact users we want to warn: * those who installed with `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` or whose * native rebuild soft-failed for missing toolchain. * diff --git a/gitnexus/src/core/ingestion/filesystem-walker.ts b/gitnexus/src/core/ingestion/filesystem-walker.ts index c30ea4321..9ba959ea6 100644 --- a/gitnexus/src/core/ingestion/filesystem-walker.ts +++ b/gitnexus/src/core/ingestion/filesystem-walker.ts @@ -23,6 +23,21 @@ export interface FilePath { } const READ_CONCURRENCY = 32; +const ANALYZE_PROGRESS_ACTIVE_ENV = 'GITNEXUS_ANALYZE_PROGRESS_ACTIVE'; + +const warnLargeFileSkip = (message: string): void => { + if (process.env[ANALYZE_PROGRESS_ACTIVE_ENV] === '1') { + // analyze.ts routes console.warn through the progress bar logger while + // the bar is active. Emitting the operator-facing large-file notice there + // avoids raw pino NDJSON corrupting the one-line progress display in the + // heap-respawn child, whose stderr is intentionally piped for crash + // classification. + // eslint-disable-next-line no-console -- intentionally routed by analyze progress UI + console.warn(message); + return; + } + logger.warn(message); +}; /** * Phase 1: Scan repository — stat files to get paths + sizes, no content loaded. @@ -76,7 +91,9 @@ export const walkRepositoryPaths = async ( const isDefault = maxFileSizeBytes === DEFAULT_MAX_FILE_SIZE_BYTES; const isOverrideUnset = !process.env.GITNEXUS_MAX_FILE_SIZE; const suffix = isDefault ? ', likely generated/vendored' : ''; - logger.warn(` Skipped ${skippedLarge} large files (>${maxFileSizeBytes / 1024}KB${suffix})`); + warnLargeFileSkip( + ` Skipped ${skippedLarge} large files (>${maxFileSizeBytes / 1024}KB${suffix})`, + ); // Always show at least the first few paths so users can diagnose why // edges are missing from a specific file (issue #1659). The full list is @@ -88,17 +105,19 @@ export const walkRepositoryPaths = async ( const showAll = isVerboseIngestionEnabled() || skippedLargePaths.length <= SKIPPED_PREVIEW_CAP; const preview = showAll ? skippedLargePaths : skippedLargePaths.slice(0, SKIPPED_PREVIEW_CAP); for (const p of preview) { - logger.warn(` - ${p}`); + warnLargeFileSkip(` - ${p}`); } if (!showAll) { const remaining = skippedLargePaths.length - SKIPPED_PREVIEW_CAP; - logger.warn(` ...and ${remaining} more (set GITNEXUS_VERBOSE=1 to list them all)`); + warnLargeFileSkip(` ...and ${remaining} more (set GITNEXUS_VERBOSE=1 to list them all)`); } // Only hint about the env var when the user has not set it at all. An // explicit GITNEXUS_MAX_FILE_SIZE=512 happens to resolve to the same // bytes as the default but the operator clearly already knows the knob. if (isDefault && isOverrideUnset) { - logger.warn(` Set GITNEXUS_MAX_FILE_SIZE= to include files above the default cap.`); + warnLargeFileSkip( + ` Set GITNEXUS_MAX_FILE_SIZE= to include files above the default cap.`, + ); } } diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 28a080eb2..6465a9782 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -14,6 +14,7 @@ import { isVerboseIngestionEnabled } from './utils/verbose.js'; import { getDefinitionNodeFromCaptures, findEnclosingClassInfo, + findObjectLiteralBindingInfo, getLabelFromCaptures, CLASS_CONTAINER_TYPES, type SyntaxNode, @@ -531,6 +532,10 @@ const processParsingSequential = async ( ) : null; const enclosingClassId = enclosingClassInfo?.classId ?? null; + const objectLiteralOwnerInfo = + !enclosingClassId && nodeLabel === 'Method' && definitionNode + ? findObjectLiteralBindingInfo(definitionNode, file.path) + : null; // Qualify method/property IDs with enclosing class name to avoid collisions // e.g. "Method:animal.dart:Animal.speak" vs "Method:animal.dart:Dog.speak" @@ -785,7 +790,7 @@ const processParsingSequential = async ( returnType: methodProps.returnType as string | undefined, declaredType, templateArguments: classTemplateArguments, - ownerId: enclosingClassId ?? undefined, + ownerId: enclosingClassId ?? objectLiteralOwnerInfo?.ownerId ?? undefined, qualifiedName: qualifiedTypeName, }); @@ -805,15 +810,18 @@ const processParsingSequential = async ( graph.addRelationship(relationship); // ── HAS_METHOD / HAS_PROPERTY: link member to enclosing class ── - if (enclosingClassId) { + const ownerIdForMemberEdge = enclosingClassId ?? objectLiteralOwnerInfo?.ownerId ?? null; + if (ownerIdForMemberEdge) { const memberEdgeType = nodeLabel === 'Property' ? 'HAS_PROPERTY' : 'HAS_METHOD'; graph.addRelationship({ - id: generateId(memberEdgeType, `${enclosingClassId}->${nodeId}`), - sourceId: enclosingClassId, + id: generateId(memberEdgeType, `${ownerIdForMemberEdge}->${nodeId}`), + sourceId: ownerIdForMemberEdge, targetId: nodeId, type: memberEdgeType, confidence: 1.0, - reason: '', + reason: objectLiteralOwnerInfo + ? 'object literal method belongs to exported object binding' + : '', }); } }); diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts index 46945d74c..10e4557d2 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts @@ -48,7 +48,7 @@ import { ASTCache, createASTCache } from '../ast-cache.js'; import { type PipelineProgress, getLanguageFromFilename } from 'gitnexus-shared'; import { readFileContents } from '../filesystem-walker.js'; import { isLanguageAvailable } from '../../tree-sitter/parser-loader.js'; -import { createWorkerPool } from '../workers/worker-pool.js'; +import { createWorkerPool, WorkerPoolInitializationError } from '../workers/worker-pool.js'; import type { WorkerPool } from '../workers/worker-pool.js'; import type { ExtractedAssignment, @@ -252,20 +252,23 @@ export async function runChunkedParseAndResolve( const MIN_BYTES_FOR_WORKERS = options?.workerThresholdsForTest?.minBytes ?? 512 * 1024; const totalBytes = parseableScanned.reduce((s, f) => s + f.size, 0); - // Create worker pool once, reuse across chunks. + // Create worker pool lazily, reuse across cache-miss chunks. // // `workerPoolSize === 0` is a programmatic equivalent of `skipWorkers: // true` per the `PipelineOptions.workerPoolSize` contract. Short- - // circuiting here avoids constructing a useless pool that rejects - // every dispatch (with a `Worker pool parsing stopped` warn log per - // chunk) just to fall back to the sequential path via the error - // catch — the gate honors the docstring directly. - let workerPool: WorkerPool | undefined; - if ( + // circuiting here avoids constructing a useless pool. The pool is + // intentionally NOT created before parse-cache lookup: a warm-cache + // all-hit run should replay cached worker output without loading + // parse-worker.js or any tree-sitter/N-API native bindings. + const shouldUseWorkers = !options?.skipWorkers && options?.workerPoolSize !== 0 && - (totalParseable >= MIN_FILES_FOR_WORKERS || totalBytes >= MIN_BYTES_FOR_WORKERS) - ) { + (totalParseable >= MIN_FILES_FOR_WORKERS || totalBytes >= MIN_BYTES_FOR_WORKERS); + let workerPool: WorkerPool | undefined; + let workerPoolDisabled = false; + const getOrCreateWorkerPool = (): WorkerPool | undefined => { + if (!shouldUseWorkers || workerPoolDisabled) return undefined; + if (workerPool) return workerPool; try { // U20.U3 test-only injection: integration tests pass a custom // worker script URL via `workerUrlForTest` (mirrors the @@ -296,13 +299,16 @@ export async function runChunkedParseAndResolve( } } workerPool = createWorkerPool(workerUrl, options?.workerPoolSize); + return workerPool; } catch (err) { + workerPoolDisabled = true; logger.warn( { err: (err as Error).message }, 'Worker pool creation failed, using sequential fallback:', ); + return undefined; } - } + }; let filesParsedSoFar = 0; @@ -418,12 +424,18 @@ export async function runChunkedParseAndResolve( // never saw the log (M3 from PR #1693 review). const chunkStartMs: number | null = verboseThroughputLog ? Date.now() : null; - const chunkContents = await chunkContentPromises[chunkIdx]!; + const chunkContentPromise = chunkContentPromises[chunkIdx]; + if (!chunkContentPromise) { + throw new Error(`Missing prefetched parse chunk ${chunkIdx + 1}/${numChunks}`); + } + const chunkContents = await chunkContentPromise; chunkContentPromises[chunkIdx] = undefined; // release the in-memory copy startChunkPrefetch(chunkIdx + parseChunkConcurrency); - const chunkFiles = chunkPaths - .filter((p) => chunkContents.has(p)) - .map((p) => ({ path: p, content: chunkContents.get(p)! })); + const chunkFiles: Array<{ path: string; content: string }> = []; + for (const p of chunkPaths) { + const content = chunkContents.get(p); + if (content !== undefined) chunkFiles.push({ path: p, content }); + } // Compute the chunk's content-hash signature (if cache available). let chunkHash: string | null = null; @@ -436,7 +448,7 @@ export async function runChunkedParseAndResolve( } let chunkWorkerData: WorkerExtractedData | null; - const cachedRaw = chunkHash ? parseCache!.entries.get(chunkHash) : undefined; + const cachedRaw = chunkHash && parseCache ? parseCache.entries.get(chunkHash) : undefined; // Track every chunk hash we touched so the orchestrator can // prune stale entries (chunks whose composition no longer @@ -450,7 +462,7 @@ export async function runChunkedParseAndResolve( chunkWorkerData = mergeChunkResults(graph, symbolTable, cachedRaw); if (isDev) { logger.info( - `📦 parse-cache HIT: chunk ${chunkIdx + 1}/${numChunks} (${chunkFiles.length} files, ${chunkHash!.slice(0, 8)})`, + `📦 parse-cache HIT: chunk ${chunkIdx + 1}/${numChunks} (${chunkFiles.length} files, ${chunkHash?.slice(0, 8) ?? 'unknown'})`, ); } // Progress update so UI advances even on a cache hit. @@ -474,33 +486,61 @@ export async function runChunkedParseAndResolve( // them under the chunk hash for the next run. chunkCacheMisses++; const rawResults: ParseWorkerResult[] = []; - chunkWorkerData = await processParsing( - graph, - chunkFiles, - symbolTable, - astCache, - scopeTreeCache, - (current, _total, filePath) => { - const globalCurrent = filesParsedSoFar + current; - // Parse phase covers 20-70 (M2). Deferred extraction handles 70-95. - const parsingProgress = 20 + (globalCurrent / totalParseable) * 50; - onProgress({ - phase: 'parsing', - percent: Math.round(parsingProgress), - message: `Parsing chunk ${chunkIdx + 1}/${numChunks}...`, - detail: filePath, - stats: { - filesProcessed: globalCurrent, - totalFiles: totalParseable, - nodesCreated: graph.nodeCount, - }, - }); - }, - workerPool, - // Capture raw results only when we have a cache to write to — - // otherwise we'd retain extra arrays for nothing. - parseCache && chunkHash ? rawResults : undefined, - ); + const progressForChunk = (current: number, _total: number, filePath: string) => { + const globalCurrent = filesParsedSoFar + current; + // Parse phase covers 20-70 (M2). Deferred extraction handles 70-95. + const parsingProgress = 20 + (globalCurrent / totalParseable) * 50; + onProgress({ + phase: 'parsing', + percent: Math.round(parsingProgress), + message: `Parsing chunk ${chunkIdx + 1}/${numChunks}...`, + detail: filePath, + stats: { + filesProcessed: globalCurrent, + totalFiles: totalParseable, + nodesCreated: graph.nodeCount, + }, + }); + }; + const activeWorkerPool = getOrCreateWorkerPool(); + try { + chunkWorkerData = await processParsing( + graph, + chunkFiles, + symbolTable, + astCache, + scopeTreeCache, + progressForChunk, + activeWorkerPool, + // Capture raw results only when we have a cache to write to — + // otherwise we'd retain extra arrays for nothing. + parseCache && chunkHash && activeWorkerPool ? rawResults : undefined, + ); + } catch (err) { + if (!(err instanceof WorkerPoolInitializationError)) throw err; + logger.warn( + { + err: err.message, + readinessFailures: err.readinessFailures, + }, + 'Worker pool initialization failed, using sequential fallback:', + ); + rawResults.length = 0; + workerPoolDisabled = true; + const failedPool = workerPool; + workerPool = undefined; + await failedPool?.terminate().catch(() => undefined); + chunkWorkerData = await processParsing( + graph, + chunkFiles, + symbolTable, + astCache, + scopeTreeCache, + progressForChunk, + undefined, + undefined, + ); + } // Persist the raw results for this chunk hash. Sequential path // doesn't populate rawResults (it writes directly to graph), so // small repos without worker pool simply don't cache. That's fine. @@ -843,9 +883,11 @@ export async function runChunkedParseAndResolve( const cachedSequentialChunkFiles: Array> = []; for (const chunkPaths of sequentialChunkPaths) { const chunkContents = await readFileContents(repoPath, chunkPaths); - const chunkFiles = chunkPaths - .filter((p) => chunkContents.has(p)) - .map((p) => ({ path: p, content: chunkContents.get(p)! })); + const chunkFiles: Array<{ path: string; content: string }> = []; + for (const p of chunkPaths) { + const content = chunkContents.get(p); + if (content !== undefined) chunkFiles.push({ path: p, content }); + } cachedSequentialChunkFiles.push(chunkFiles); astCache = createASTCache(chunkFiles.length); const sequentialHeritage = await extractExtractedHeritageFromFiles(chunkFiles, astCache); diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index 746b9d694..09080d2c6 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -693,8 +693,14 @@ function normalizeNodeLabel(kindStr: string): SymbolDefinition['type'] | undefin case 'property': return 'Property'; case 'variable': - case 'const': return 'Variable'; + // `const` / `let` declarations align with the legacy DAG parse phase, + // which emits `Const` graph nodes via `@definition.const` capture for + // `lexical_declaration`. Returning `'Const'` here lets resolveDefGraphId's + // qualified-key path succeed for value receivers without relying on the + // simple-key fallback (PR #1718 review Finding 1 / 2026-05-21-002 U4). + case 'const': + return 'Const'; case 'typealias': case 'type_alias': return 'TypeAlias'; diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/edges.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/edges.ts index 080972691..2562868e4 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/edges.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/edges.ts @@ -98,3 +98,54 @@ export function tryEmitEdge( }); return true; } + +/** + * Variant of `tryEmitEdge` that takes a pre-resolved target graph id + * instead of resolving it from a `SymbolDefinition`. Used by the + * value-receiver-owner bridge (`receiver-bound-calls.ts` Case 5) where + * the picked owner-indexed method def carries no `qualifiedName` (object + * literals have no class owner to seed it) and therefore cannot + * round-trip through `resolveDefGraphId`. The def's `nodeId` IS the + * canonical graph node id (written by the parse phase), so the caller + * passes it directly. + * + * All other invariants of `tryEmitEdge` apply: dedup key shape, collapse + * flag honoring, edge-type mapping, caller-id resolution. + */ +export function tryEmitEdgeWithExplicitTargetId( + graph: KnowledgeGraph, + scopes: ScopeResolutionIndexes, + nodeLookup: GraphNodeLookup, + site: { + readonly inScope: ScopeId; + readonly atRange: { startLine: number; startCol: number }; + readonly kind: string; + }, + targetGraphId: string, + reason: string, + seen: Set, + confidence = 0.85, + collapseByCallerTarget = false, +): boolean { + const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup); + const edgeType = mapReferenceKindToEdgeType(site.kind as Reference['kind']); + if (callerGraphId === undefined) return false; + if (edgeType === undefined) return false; + + const useCollapsed = collapseByCallerTarget && edgeType === 'CALLS'; + const dedupKey = useCollapsed + ? `${edgeType}:${callerGraphId}->${targetGraphId}` + : `${edgeType}:${callerGraphId}->${targetGraphId}:${site.atRange.startLine}:${site.atRange.startCol}`; + if (seen.has(dedupKey)) return false; + seen.add(dedupKey); + + graph.addRelationship({ + id: `rel:${dedupKey}`, + sourceId: callerGraphId, + targetId: targetGraphId, + type: edgeType, + confidence, + reason, + }); + return true; +} diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts index fd8c3cf23..8c29f8f2c 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts @@ -159,6 +159,12 @@ export function isLinkableLabel(label: NodeLabel): boolean { // ACCESSES edges target field nodes (e.g. `user.name = "x"` → // ACCESSES edge to User's `name` Variable/Property node). label === 'Variable' || - label === 'Property' + label === 'Property' || + // Const is linkable so the value-receiver-owner bridge in + // `receiver-bound-calls.ts` Case 5 can translate the scope-resolution + // `Variable` def for `export const fooService = {...}` to the canonical + // `Const:filePath:name` graph node id, against which object-literal + // method symbols register their `ownerId` (PR #1718 / issue #1358). + label === 'Const' ); } diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index 430d83c2d..6a9469693 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -21,6 +21,11 @@ * but not a namespace prefix → compound resolver * 7. **Case 4 (simple typeBinding)** — `typeRef.rawName` has no dot → * MRO walk + `findOwnedMember` + * 8. **Case 5 (value-receiver bridge)** — receiver is a `Const`/`Variable` + * whose `nodeId` is referenced as an `ownerId` in `model.methods` + * (object-literal services). Last-resort fallback for lowercase + * receivers with no class-like or type-binding match. Mirrors + * the legacy DAG bridge in `call-processor.ts`. * * Reordering or merging cases changes resolution semantics. * @@ -46,9 +51,10 @@ import { findExportedDef, findOwnedMember, findReceiverTypeBinding, + findValueBindingInScope, isClassLike, } from '../scope/walkers.js'; -import { tryEmitEdge } from '../graph-bridge/edges.js'; +import { tryEmitEdge, tryEmitEdgeWithExplicitTargetId } from '../graph-bridge/edges.js'; import { resolveCompoundReceiverClass } from '../passes/compound-receiver.js'; import { resolveDefGraphId } from '../graph-bridge/ids.js'; import { @@ -706,6 +712,61 @@ export function emitReceiverBoundCalls( } } } + + // ── Case 5: value-receiver bridge (object-literal services) ── + // When prior cases couldn't resolve the receiver as a class or + // type binding, fall back to value-binding resolution. Covers: + // + // export const fooService = { getUser(id) {...} }; + // import { fooService } from './service'; + // fooService.getUser(id); // ← resolve here + // + // `fooService` is a `Const`/`Variable` (not class-like, no typeBinding + // for unannotated literals), so Cases 2-4 skip it. Scope-resolution + // defs for non-class values carry a synthetic id, so we translate to + // the canonical graph node ID via `resolveDefGraphId` before owner- + // indexed lookup — the parser writes the graph node ID as `ownerId` + // on the method symbol-table entry to match. + // + // Object-literal methods do not carry a `qualifiedName` (no class + // owner to seed it), so the picked def cannot round-trip through + // `tryEmitEdge` → `resolveDefGraphId`. We use + // `tryEmitEdgeWithExplicitTargetId` instead, passing `picked.nodeId` + // directly — same dedup-key shape, collapse-flag honoring, and + // caller resolution as `tryEmitEdge`. + const valueDef = findValueBindingInScope(site.inScope, receiverName, scopes); + if (valueDef !== undefined) { + const ownerGraphId = + resolveDefGraphId(valueDef.filePath, valueDef, nodeLookup) ?? valueDef.nodeId; + const picked = pickOverload(ownerGraphId, memberName, site, model, provider); + if (picked === OVERLOAD_AMBIGUOUS) { + handledSites.add(siteKey); + continue; + } + if (picked !== undefined) { + const reason = + site.kind === 'write' || site.kind === 'read' + ? site.kind + : picked.filePath !== parsed.filePath + ? 'import-resolved' + : 'global'; + const confidence = site.kind === 'write' || site.kind === 'read' ? 1.0 : 0.85; + const ok = tryEmitEdgeWithExplicitTargetId( + graph, + scopes, + nodeLookup, + site, + picked.nodeId, + reason, + seen, + confidence, + collapse, + ); + if (ok) emitted++; + handledSites.add(siteKey); + continue; + } + } } } diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index 6e087d4f7..a9bb188d2 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -165,28 +165,9 @@ export function findClassBindingInScope( receiverName: string, scopes: ScopeResolutionIndexes, ): SymbolDefinition | undefined { - let currentId: ScopeId | null = startScope; - const visited = new Set(); - while (currentId !== null) { - if (visited.has(currentId)) return undefined; - visited.add(currentId); - const scope = scopes.scopeTree.getScope(currentId); - if (scope === undefined) return undefined; + const local = walkScopeChain(startScope, receiverName, scopes, (def) => isClassLike(def.type)); + if (local !== undefined) return local; - const localBindings = scope.bindings.get(receiverName); - if (localBindings !== undefined) { - for (const b of localBindings) { - if (isClassLike(b.def.type)) return b.def; - } - } - - const importedBindings = lookupBindingsAt(currentId, receiverName, scopes); - for (const b of importedBindings) { - if (isClassLike(b.def.type)) return b.def; - } - - currentId = scope.parent; - } // Fallback for languages (Go) where namespace-style imports don't // create scope bindings: resolve via QualifiedNameIndex. Only fires // when the scope-chain walk found nothing; single-match wins. @@ -211,6 +192,89 @@ export function findClassBindingInScope( return undefined; } +/** + * Predicate for value-receiver bridge: the labels for which + * `reconcileOwnership` registers methods/fields under the def's + * `nodeId` as the `ownerId`. Explicit allowlist so future NodeLabel + * additions (Module, Namespace, TypeAlias, EnumMember, etc.) do NOT + * silently widen the bridge — adding a new ownerable label requires + * touching both this predicate and `reconcileOwnership`. + * + * See: `scope-resolution/pipeline/reconcile-ownership.ts` Property / + * Variable / Const / Static registration block. + */ +export function isOwnableValueLabel(t: string): boolean { + return t === 'Const' || t === 'Variable' || t === 'Property' || t === 'Static'; +} + +/** + * Look up a value-binding (Const/Variable/Property/Static) by name in + * the given scope's chain. Used by the value-receiver-owner bridge + * for object-literal services such as: + * + * export const fooService = { getUser(id) {...} }; + * + * where `fooService` is a `Const`/`Variable` whose `nodeId` is the + * `ownerId` of the member method. Neither `findClassBindingInScope` + * (rejects non-class-like) nor `findReceiverTypeBinding` (no typeBinding + * for an unannotated literal) finds it. + * + * Mirrors `findClassBindingInScope` exactly; only the accepted def-type + * predicate differs. + */ +export function findValueBindingInScope( + startScope: ScopeId, + receiverName: string, + scopes: ScopeResolutionIndexes, +): SymbolDefinition | undefined { + return walkScopeChain(startScope, receiverName, scopes, (def) => isOwnableValueLabel(def.type)); +} + +/** + * Generic scope-chain walker. Walks from `startScope` toward the root, + * consulting both the local `scope.bindings` channel and the dual-source + * `lookupBindingsAt` view (finalized + augmented). At each scope, local + * bindings are exhausted BEFORE imported/augmented bindings — preserves + * JavaScript-style lexical scoping where a local `const x` shadows an + * imported `x` of the same name. + * + * Returns the first binding `def` matching `predicate`. Cycles in the + * scope graph terminate the walk (defensive — should not occur in + * well-formed inputs). + */ +function walkScopeChain( + startScope: ScopeId, + name: string, + scopes: ScopeResolutionIndexes, + predicate: (def: SymbolDefinition) => boolean, +): SymbolDefinition | undefined { + let currentId: ScopeId | null = startScope; + const visited = new Set(); + while (currentId !== null) { + if (visited.has(currentId)) return undefined; + visited.add(currentId); + const scope = scopes.scopeTree.getScope(currentId); + if (scope === undefined) return undefined; + + // Local first: a `const x` in this scope shadows any imported `x`. + const localBindings = scope.bindings.get(name); + if (localBindings !== undefined) { + for (const b of localBindings) { + if (predicate(b.def)) return b.def; + } + } + + // Then imported/augmented bindings — only consulted when no local match. + const importedBindings = lookupBindingsAt(currentId, name, scopes); + for (const b of importedBindings) { + if (predicate(b.def)) return b.def; + } + + currentId = scope.parent; + } + return undefined; +} + /** * Look up a callable (Function/Method/Constructor) by name in the * given scope's chain. Uses the dual-source pattern (scope.bindings + diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index 351cfdedb..98e795686 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -411,6 +411,123 @@ export const findEnclosingClassInfo = ( return null; }; +/** Object literal binding info for TS/JS shorthand methods. */ +export interface ObjectLiteralBindingInfo { + ownerId: string; +} + +/** + * Block-statement AST types that disqualify an object-literal binding from + * carrying a HAS_METHOD edge. A `const` declared inside one of these is block- + * scoped and cannot be imported, so attributing methods to it would create + * false-positive cross-file edges. + */ +const BLOCK_SCOPE_BOUNDARY_TYPES = new Set([ + 'statement_block', + 'if_statement', + 'else_clause', + 'for_statement', + 'for_in_statement', + 'for_of_statement', + 'while_statement', + 'do_statement', + 'try_statement', + 'catch_clause', + 'finally_clause', + 'switch_statement', + 'switch_case', + 'switch_default', + 'with_statement', +]); + +/** + * Find the file-scope variable that owns an object literal method definition. + * + * Covers TypeScript/JavaScript shorthand object methods such as: + * + * export const service = { async load() {} }; + * + * tree-sitter represents `load` as a `method_definition` inside an `object`, + * not inside a class container. Without this fallback, ingestion emits a + * top-level `Method` node but no edge from the exported `service` value to + * that method, so impact queries cannot discover `service.load`. + * + * Two-phase walk: + * Phase A walks up from `node` tracking how many `object` ancestors we + * cross. The first `variable_declarator` reached with `objectDepth >= 1` + * is the candidate owner — unless `objectDepth > 1` (the method belongs + * to a nested object literal; we return null rather than misattribute + * to the outer binding). Hitting a function/class container before the + * declarator returns null (catches IIFE-wrapped literals). + * Phase B walks the declarator's own ancestors. Any function or class + * ancestor before reaching `program`/`export_statement` returns null + * (catches `const` declared inside a function body). Any block-statement + * ancestor also returns null (catches block-scoped declarations inside + * top-level `if`/`for`/`try`/etc., which cannot be imported). + */ +export const findObjectLiteralBindingInfo = ( + node: SyntaxNode, + filePath: string, +): ObjectLiteralBindingInfo | null => { + // ── Phase A: walk up from node, count `object` ancestors, find declarator + let current: SyntaxNode | null = node; + let objectDepth = 0; + let declarator: SyntaxNode | null = null; + + while (current) { + if (current.type === 'object') { + objectDepth += 1; + } + + if (current.type === 'variable_declarator' && objectDepth >= 1) { + if (objectDepth > 1) { + // Method belongs to a nested object literal; safe under-approximation. + return null; + } + declarator = current; + break; + } + + if ( + current !== node && + (FUNCTION_NODE_TYPES.has(current.type) || CLASS_CONTAINER_TYPES.has(current.type)) + ) { + // Function/class container encountered before owning declarator + // (e.g. IIFE-wrapped object literal). Bail out. + return null; + } + + current = current.parent; + } + + if (!declarator) return null; + + // ── Phase B: declarator must live at file scope (program / export_statement) + // with no function, class, or block-statement ancestor in between. + let anc: SyntaxNode | null = declarator.parent; + while (anc) { + if (anc.type === 'program' || anc.type === 'export_statement') { + break; + } + if (FUNCTION_NODE_TYPES.has(anc.type) || CLASS_CONTAINER_TYPES.has(anc.type)) { + return null; + } + if (BLOCK_SCOPE_BOUNDARY_TYPES.has(anc.type)) { + return null; + } + anc = anc.parent; + } + + const nameNode = declarator.childForFieldName?.('name'); + if (!nameNode || nameNode.type !== 'identifier') return null; + + const declaration = declarator.parent; + const ownerLabel = declaration?.type === 'variable_declaration' ? 'Variable' : 'Const'; + return { + ownerId: generateId(ownerLabel, `${filePath}:${nameNode.text}`), + }; +}; + /** Convenience wrapper: returns just the class ID string (backward compat). */ export const findEnclosingClassId = (node: SyntaxNode, filePath: string): string | null => { return findEnclosingClassInfo(node, filePath)?.classId ?? null; diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index eee9593a1..ccb88e0de 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -50,6 +50,7 @@ import { FUNCTION_NODE_TYPES, getDefinitionNodeFromCaptures, findEnclosingClassInfo, + findObjectLiteralBindingInfo, type EnclosingClassInfo, getLabelFromCaptures, findDescendant, @@ -2068,6 +2069,10 @@ const processFileGroup = ( ) : null; const enclosingClassId = enclosingClassInfo?.classId ?? null; + const objectLiteralOwnerInfo = + !enclosingClassId && nodeLabel === 'Method' && definitionNode + ? findObjectLiteralBindingInfo(definitionNode, file.path) + : null; // Qualify method/property IDs with enclosing class name to avoid collisions const qualifiedName = enclosingClassInfo @@ -2306,6 +2311,7 @@ const processFileGroup = ( }); // enclosingClassId already computed above (before nodeId generation) + const ownerId = enclosingClassId ?? objectLiteralOwnerInfo?.ownerId; result.symbols.push({ filePath: file.path, @@ -2322,7 +2328,7 @@ const processFileGroup = ( ...(classTemplateArguments !== undefined && classTemplateArguments.length > 0 ? { templateArguments: classTemplateArguments } : {}), - ...(enclosingClassId ? { ownerId: enclosingClassId } : {}), + ...(ownerId !== undefined ? { ownerId } : {}), visibility: methodProps.visibility as string | undefined, isStatic: methodProps.isStatic as boolean | undefined, isReadonly: methodProps.isReadonly as boolean | undefined, @@ -2355,15 +2361,17 @@ const processFileGroup = ( }); // ── HAS_METHOD / HAS_PROPERTY: link member to enclosing class ── - if (enclosingClassId) { + if (ownerId !== undefined) { const memberEdgeType = nodeLabel === 'Property' ? 'HAS_PROPERTY' : 'HAS_METHOD'; result.relationships.push({ - id: generateId(memberEdgeType, `${enclosingClassId}->${nodeId}`), - sourceId: enclosingClassId, + id: generateId(memberEdgeType, `${ownerId}->${nodeId}`), + sourceId: ownerId, targetId: nodeId, type: memberEdgeType, confidence: 1.0, - reason: '', + reason: objectLiteralOwnerInfo + ? 'object literal method belongs to exported object binding' + : '', }); } } diff --git a/gitnexus/src/core/ingestion/workers/worker-pool.ts b/gitnexus/src/core/ingestion/workers/worker-pool.ts index fc032bd56..d29b352ab 100644 --- a/gitnexus/src/core/ingestion/workers/worker-pool.ts +++ b/gitnexus/src/core/ingestion/workers/worker-pool.ts @@ -235,6 +235,20 @@ export class WorkerPoolDispatchError extends Error { } } +export class WorkerPoolInitializationError extends WorkerPoolDispatchError { + readonly readinessFailures: readonly string[]; + + constructor( + message: string, + quarantinedPaths: readonly string[] = [], + readinessFailures: readonly string[] = [], + ) { + super(message, quarantinedPaths); + this.name = 'WorkerPoolInitializationError'; + this.readinessFailures = readinessFailures; + } +} + /** Message shapes sent back by worker threads. */ type WorkerOutgoingMessage = | { type: 'progress'; filesProcessed: number } @@ -592,6 +606,7 @@ export const createWorkerPool = ( // 1100+ LOC of pool plumbing. Public worker-pool API is unchanged — // `getQuarantinedPaths()` still returns the same defensive copy. const quarantine = createQuarantine(); + const initialReadinessFailures: string[] = []; // Per-slot consecutive-failure counter (F6): replaces the prior pool-wide // scalar so a chronically-failing slot trips the breaker on its own // failure streak instead of being masked by another slot's successes. @@ -636,6 +651,7 @@ export const createWorkerPool = ( try { await waitForWorkerReady(w); } catch (err) { + initialReadinessFailures.push(err instanceof Error ? err.message : String(err)); logger.warn( { workerIndex: i, @@ -673,7 +689,15 @@ export const createWorkerPool = ( } if (items.length === 0) return []; if (activeSlots.size === 0) { - throw new WorkerPoolDispatchError('Worker pool has no active workers', []); + const detail = + initialReadinessFailures.length > 0 + ? ` after initial ready handshake: ${initialReadinessFailures.join('; ')}` + : ''; + throw new WorkerPoolInitializationError( + `Worker pool has no active workers${detail}`, + [], + initialReadinessFailures, + ); } // Layer 3: filter out quarantined paths so a known-bad file never reaches diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index 77e9f65f1..da9809dba 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -27,6 +27,16 @@ import { waitForWindowsHandleRelease, type LbugConnectionHandle, } from './lbug-config.js'; +import { + finalizeLbugSidecarsAfterClose, + inspectLbugSidecars, + isMissingShadowSidecarError, + isReadOnlyShadowReplayError, + preflightLbugSidecars, + quarantineWalForMissingShadow, + renameFailureMessage, + shadowSidecarRecoveryMessage, +} from './sidecar-recovery.js'; import { isVectorExtensionSupportedByPlatform } from '../platform/capabilities.js'; import { logger } from '../logger.js'; @@ -437,6 +447,180 @@ const queryAndDrain = async (targetConn: lbug.Connection, cypher: string): Promi await drainQueryResult(queryResult); }; +const READ_ONLY_SHADOW_REPLAY_PROBE = 'MATCH (n) RETURN n LIMIT 1'; + +/** + * Reject the quarantine path when the orphan WAL is too large to safely + * discard (>TINY_ORPHAN_WAL_BYTES). Mirrors the preflight policy at + * sidecar-recovery.ts:153-160 ("warn, do not quarantine"). Symmetric across + * read-only and writable recovery paths (PR #1747 review D2). + * + * Throws shadowSidecarRecoveryMessage immediately when the WAL is large, + * preserving the uncheckpointed pages for explicit operator recovery. + * Returns silently when the WAL is absent, tiny, or in any other state + * where the existing recovery path is safe to proceed. + */ +const refuseLargeWalQuarantine = async ( + dbPath: string, + mode: 'read-only' | 'writable', + triggeringErr: unknown, +): Promise => { + const state = await inspectLbugSidecars(dbPath); + if (state.kind === 'orphan-wal') { + logger.warn( + `GitNexus: refusing to quarantine large WAL (${state.walBytes} bytes) at ${dbPath}.wal during ${mode} recovery; ` + + 'manual recovery required — run `gitnexus analyze --force --index-only`.', + ); + throw new Error(shadowSidecarRecoveryMessage(dbPath, triggeringErr)); + } +}; + +const reopenReadOnlyAfterMissingShadow = async ( + dbPath: string, + err: unknown, +): Promise => { + await refuseLargeWalQuarantine(dbPath, 'read-only', err); + try { + await quarantineWalForMissingShadow(dbPath, { + logger, + level: 'warn', + reason: 'read-only recovery', + }); + } catch (renameErr) { + throw new Error(renameFailureMessage(dbPath, renameErr)); + } + + const reopened = await openLbugConnection(lbug, dbPath, { readOnly: true }); + try { + await queryAndDrain(reopened.conn, READ_ONLY_SHADOW_REPLAY_PROBE); + return reopened; + } catch (retryErr) { + await closeLbugConnection(reopened); + if (isMissingShadowSidecarError(retryErr) || isReadOnlyShadowReplayError(retryErr)) { + throw new Error(shadowSidecarRecoveryMessage(dbPath, retryErr)); + } + throw retryErr; + } +}; + +const reopenWritableAfterMissingShadow = async ( + dbPath: string, + err: unknown, +): Promise => { + await refuseLargeWalQuarantine(dbPath, 'writable', err); + try { + await quarantineWalForMissingShadow(dbPath, { + logger, + level: 'warn', + reason: 'writable recovery', + }); + } catch (renameErr) { + throw new Error(renameFailureMessage(dbPath, renameErr)); + } + + return await openLbugConnection(lbug, dbPath); +}; + +const ensureReadOnlyConnectionUsable = async ( + dbPath: string, + handle: LbugConnectionHandle, +): Promise => { + try { + await queryAndDrain(handle.conn, READ_ONLY_SHADOW_REPLAY_PROBE); + return handle; + } catch (err) { + if (isMissingShadowSidecarError(err)) { + await closeLbugConnection(handle); + return await reopenReadOnlyAfterMissingShadow(dbPath, err); + } + if (!isReadOnlyShadowReplayError(err)) { + await closeLbugConnection(handle); + throw err; + } + } + + await closeLbugConnection(handle); + + const writable = await openLbugConnection(lbug, dbPath); + let missingShadowError: unknown; + try { + await queryAndDrain(writable.conn, READ_ONLY_SHADOW_REPLAY_PROBE); + } catch (err) { + if (isMissingShadowSidecarError(err)) { + missingShadowError = err; + } else { + throw err; + } + } finally { + await closeLbugConnection(writable); + } + if (missingShadowError) { + return await reopenReadOnlyAfterMissingShadow(dbPath, missingShadowError); + } + + const reopened = await openLbugConnection(lbug, dbPath, { readOnly: true }); + try { + await queryAndDrain(reopened.conn, READ_ONLY_SHADOW_REPLAY_PROBE); + return reopened; + } catch (err) { + await closeLbugConnection(reopened); + if (isMissingShadowSidecarError(err)) { + throw new Error(shadowSidecarRecoveryMessage(dbPath, err)); + } + throw err; + } +}; + +const resetOpenConnectionState = (): void => { + currentDbPath = null; + ftsLoaded = false; + vectorExtensionLoaded = false; + ensuredFTSIndexes.clear(); +}; + +const runSchemaCreationQueries = async (dbPath: string): Promise => { + for (const schemaQuery of SCHEMA_QUERIES) { + try { + await queryAndDrain(conn, schemaQuery); + } catch (err) { + if (isMissingShadowSidecarError(err)) { + return err; + } + + const msg = err instanceof Error ? err.message : String(err); + // Suppression list: + // - "already exists": expected idempotent re-create on existing DBs + // - "could not set lock on file": LadybugDB v0.16.1 emits this on + // Windows when CREATE NODE TABLE runs against a path that was + // just opened (the WAL handle from a fresh Database briefly + // contests the table's first-write lock). The table is created + // anyway and any genuine cross-process lock contention surfaces + // on the next operation via withLbugDb's retry. Logging it here + // would just be noise in CI. + // + // WAL corruption: the first DDL write after DB open triggers WAL + // replay — if the WAL file was left in a corrupt state by an + // interrupted previous run, the native engine throws here. Rather + // than logging a WARN and continuing in a broken state, close the + // DB cleanly and surface an actionable error so the caller (serve, + // MCP, analyze) can exit with a clear recovery message. + if (isWalCorruptionError(err)) { + await safeClose(); + resetOpenConnectionState(); + throw new Error( + `LadybugDB WAL corruption detected at ${dbPath}. ${WAL_RECOVERY_SUGGESTION}\n` + + ` Original error: ${msg.slice(0, 200)}`, + ); + } + if (!msg.includes('already exists') && !isDbBusyError(err) && !isReadOnlyDbError(err)) { + logger.warn(`⚠️ Schema creation warning: ${msg.slice(0, 120)}`); + } + } + } + + return null; +}; + export const initLbug = async (dbPath: string) => { return runWithSessionLock(() => ensureLbugInitialized(dbPath)); }; @@ -580,58 +764,46 @@ const doInitLbug = async (dbPath: string, readOnly: boolean = false) => { // Ensure parent directory exists const parentDir = path.dirname(dbPath); await fs.mkdir(parentDir, { recursive: true }); + await preflightLbugSidecars(dbPath, { + mode: readOnly ? 'read-only' : 'write', + logger, + allowQuarantine: true, + }); const opened = readOnly ? await openLbugConnection(lbug, dbPath, { readOnly: true }) : await openLbugConnection(lbug, dbPath); - db = opened.db; - conn = opened.conn; + const usable = readOnly ? await ensureReadOnlyConnectionUsable(dbPath, opened) : opened; + db = usable.db; + conn = usable.conn; currentDbReadOnly = readOnly; } finally { await releaseInitLock(); } - for (const schemaQuery of SCHEMA_QUERIES) { - try { - await queryAndDrain(conn, schemaQuery); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - // Suppression list: - // - "already exists": expected idempotent re-create on existing DBs - // - "could not set lock on file": LadybugDB v0.16.1 emits this on - // Windows when CREATE NODE TABLE runs against a path that was - // just opened (the WAL handle from a fresh Database briefly - // contests the table's first-write lock). The table is created - // anyway and any genuine cross-process lock contention surfaces - // on the next operation via withLbugDb's retry. Logging it here - // would just be noise in CI. - // - // WAL corruption: the first DDL write after DB open triggers WAL - // replay — if the WAL file was left in a corrupt state by an - // interrupted previous run, the native engine throws here. Rather - // than logging a WARN and continuing in a broken state, close the - // DB cleanly and surface an actionable error so the caller (serve, - // MCP, analyze) can exit with a clear recovery message. - if (isWalCorruptionError(err)) { + if (!readOnly) { + const missingShadowError = await runSchemaCreationQueries(dbPath); + if (missingShadowError) { + await safeClose(); + resetOpenConnectionState(); + const reopened = await reopenWritableAfterMissingShadow(dbPath, missingShadowError); + db = reopened.db; + conn = reopened.conn; + currentDbReadOnly = false; + + const retryMissingShadowError = await runSchemaCreationQueries(dbPath); + if (retryMissingShadowError) { await safeClose(); - currentDbPath = null; - ftsLoaded = false; - vectorExtensionLoaded = false; - ensuredFTSIndexes.clear(); - throw new Error( - `LadybugDB WAL corruption detected at ${dbPath}. ${WAL_RECOVERY_SUGGESTION}\n` + - ` Original error: ${msg.slice(0, 200)}`, - ); - } - if (!msg.includes('already exists') && !isDbBusyError(err) && !isReadOnlyDbError(err)) { - logger.warn(`⚠️ Schema creation warning: ${msg.slice(0, 120)}`); + resetOpenConnectionState(); + throw new Error(shadowSidecarRecoveryMessage(dbPath, retryMissingShadowError)); } } } - // FTS powers baseline search, so initialize it with the core DB. VECTOR is - // only required for semantic embeddings and is probed lazily there. - await loadFTSExtension(); + // FTS powers baseline search, so initialize it with the core DB. Read-only + // serve/MCP paths must never run DDL or trigger network INSTALL; analyze owns + // schema/index creation and extension installation. + await loadFTSExtension(undefined, readOnly ? { policy: 'load-only' } : {}); currentDbPath = dbPath; return { db, conn }; @@ -1348,8 +1520,10 @@ export const flushWAL = async (): Promise => { try { const checkpointResult = await conn.query('CHECKPOINT'); await drainQueryResult(checkpointResult); - } catch { - /* ignore — older LadybugDB or schemaless DB may not accept it */ + } catch (err) { + logger.debug( + `GitNexus: LadybugDB CHECKPOINT skipped/failed during WAL flush: ${summarizeError(err)}`, + ); } }; @@ -1404,6 +1578,9 @@ export const safeClose = async (): Promise => { ); } } + if (closingDbPath) { + await finalizeLbugSidecarsAfterClose(closingDbPath, { logger }); + } }; export const closeLbug = async (): Promise => { diff --git a/gitnexus/src/core/lbug/pool-adapter.ts b/gitnexus/src/core/lbug/pool-adapter.ts index f551c65f8..7eb8e7d14 100644 --- a/gitnexus/src/core/lbug/pool-adapter.ts +++ b/gitnexus/src/core/lbug/pool-adapter.ts @@ -25,6 +25,15 @@ import { isWalCorruptionError, WAL_RECOVERY_SUGGESTION, } from './lbug-config.js'; +import { + isMissingFsError, + isMissingShadowSidecarError, + isReadOnlyShadowReplayError, + preflightLbugSidecars, + quarantineWalForMissingShadow, + renameFailureMessage, + statIfExists, +} from './sidecar-recovery.js'; /** * Probe whether a Windows FTS extension binary is locally installed under @@ -304,16 +313,148 @@ const WAITER_TIMEOUT_MS = 15_000; const LOCK_RETRY_ATTEMPTS = 3; const LOCK_RETRY_DELAY_MS = 2000; +const SHADOW_REPLAY_PROBE_QUERY = 'MATCH (n) RETURN n LIMIT 1'; + +const poolSidecarLogger = { + warn: (message: string): void => { + realStderrWrite(`${message}\n`); + }, + debug: (_message: string): void => {}, + info: (message: string): void => { + realStderrWrite(`${message}\n`); + }, +}; + +type TryQuarantineResult = { kind: 'quarantined'; path: string } | { kind: 'peer-handled' }; + +/** + * Pool-local quarantine guard that tolerates the concurrent-peer race the + * direct adapter does NOT face (the direct adapter holds `acquireInitLock`, + * a cross-process file lock, around its quarantine calls — so any ENOENT + * there is a real bug, not a benign race). + * + * On ENOENT from `fs.rename`, re-inspects via `statIfExists` to confirm the + * WAL really is gone. If gone, returns `{ kind: 'peer-handled' }`. If the + * WAL is somehow still present after the ENOENT (filesystem race we don't + * fully model), re-throws as a classified error rather than silently + * returning success — preserves the lock-invariant principle at the pool + * sites too. + * + * On any non-ENOENT failure, classifies through `renameFailureMessage`: + * EACCES/EPERM/EBUSY → permission-specific message; everything else + * (including the LadybugDB missing-shadow error if it ever propagates here) + * → `shadowSidecarRecoveryMessage`. + * + * See plan: docs/plans/2026-05-21-001-fix-pr-1747-quarantine-enoent-and-large-wal-plan.md (U2) + */ +async function tryQuarantineForMissingShadow( + dbPath: string, + opts: { reason: string }, +): Promise { + try { + const quarantinePath = await quarantineWalForMissingShadow(dbPath, { + logger: poolSidecarLogger, + level: 'warn', + reason: opts.reason, + }); + return { kind: 'quarantined', path: quarantinePath }; + } catch (err) { + if (isMissingFsError(err)) { + const walStat = await statIfExists(`${dbPath}.wal`); + if (walStat === null) { + return { kind: 'peer-handled' }; + } + // Defensive: ENOENT during rename but WAL still present afterwards. + // Don't silently swallow — surface a classified error. ENOENT falls + // through to shadowSidecarRecoveryMessage in renameFailureMessage. + throw new Error(renameFailureMessage(dbPath, err)); + } + // Classify the rename failure itself — EACCES/EPERM/EBUSY get the + // permission-specific message; everything else falls through. + throw new Error(renameFailureMessage(dbPath, err)); + } +} + +async function probeDatabaseForShadowReplay(db: lbug.Database): Promise { + const conn = createConnection(db); + try { + const queryResult = await conn.query(SHADOW_REPLAY_PROBE_QUERY); + const result = Array.isArray(queryResult) ? queryResult[0] : queryResult; + await result.getAll(); + result.close?.(); + } finally { + await conn.close().catch(() => {}); + } +} + +async function replayShadowPagesWithWritableOpen(dbPath: string): Promise { + let db: lbug.Database | undefined; + try { + db = createLbugDatabase(lbug, dbPath, { throwOnWalReplayFailure: false }); + await db.init(); + await probeDatabaseForShadowReplay(db); + } catch (err) { + if (isMissingShadowSidecarError(err)) { + await tryQuarantineForMissingShadow(dbPath, { + reason: 'pool writable replay recovery', + }); + return; + } + throw err; + } finally { + if (db) await db.close().catch(() => {}); + } +} async function openReadOnlyDatabase(dbPath: string): Promise { let db: lbug.Database | undefined; silenceStdout(); try { + await preflightLbugSidecars(dbPath, { + mode: 'read-only', + logger: poolSidecarLogger, + allowQuarantine: true, + }); db = createLbugDatabase(lbug, dbPath, { readOnly: true, throwOnWalReplayFailure: false, }); await db.init(); + try { + await probeDatabaseForShadowReplay(db); + } catch (err) { + if (isMissingShadowSidecarError(err)) { + await db.close().catch(() => {}); + db = undefined; + await tryQuarantineForMissingShadow(dbPath, { + reason: 'pool read-only recovery', + }); + await preflightLbugSidecars(dbPath, { + mode: 'read-only', + logger: poolSidecarLogger, + allowQuarantine: true, + }); + db = createLbugDatabase(lbug, dbPath, { + readOnly: true, + throwOnWalReplayFailure: false, + }); + await db.init(); + await probeDatabaseForShadowReplay(db); + return db; + } + if (!isReadOnlyShadowReplayError(err)) { + throw err; + } + await db.close().catch(() => {}); + db = undefined; + await replayShadowPagesWithWritableOpen(dbPath); + db = createLbugDatabase(lbug, dbPath, { + readOnly: true, + throwOnWalReplayFailure: false, + }); + await db.init(); + await probeDatabaseForShadowReplay(db); + } return db; } catch (err) { if (db) await db.close().catch(() => {}); @@ -423,8 +564,17 @@ async function doInitLbug(repoId: string, dbPath: string): Promise { } } + if ( + lastError.message.startsWith('LadybugDB checkpoint sidecar is missing') || + lastError.message.startsWith('GitNexus could not move the LadybugDB WAL sidecar') || + isMissingShadowSidecarError(lastError) + ) { + throw lastError; + } + const isLockError = - lastError.message.includes('Could not set lock') || lastError.message.includes('lock'); + lastError.message.includes('Could not set lock') || + /\block(\b|ed|ing)/i.test(lastError.message); if (!isLockError || attempt === LOCK_RETRY_ATTEMPTS) break; await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_DELAY_MS * attempt)); } diff --git a/gitnexus/src/core/lbug/sidecar-recovery.ts b/gitnexus/src/core/lbug/sidecar-recovery.ts new file mode 100644 index 000000000..f9e86719d --- /dev/null +++ b/gitnexus/src/core/lbug/sidecar-recovery.ts @@ -0,0 +1,353 @@ +import fs from 'fs/promises'; +import path from 'path'; + +export type LbugSidecarState = + | { kind: 'clean'; dbPath: string } + | { kind: 'wal-with-shadow'; dbPath: string; walBytes: number; shadowBytes: number } + | { kind: 'tiny-orphan-wal'; dbPath: string; walBytes: number } + | { kind: 'orphan-wal'; dbPath: string; walBytes: number } + | { kind: 'orphan-shadow'; dbPath: string; shadowBytes: number }; + +export interface SidecarRecoveryLogger { + warn: (message: string) => void; + info?: (message: string) => void; + debug?: (message: string) => void; +} + +export const TINY_ORPHAN_WAL_BYTES = 4 * 1024; + +/** + * Counter-based warn anti-spam (PR #1747 review, Finding 6). + * + * The previous design (`warnedKeys: Set`) warned exactly once per key + * per process and silently downgraded all subsequent occurrences to debug. In + * a long-lived `gitnexus serve` process touching the same dbPath repeatedly, + * a persistent condition produced one warn at the first occurrence and then + * 99+ silent debug lines — invisible to operators reading warn-level logs. + * + * The counter-based design warns on logarithmic milestones so persistence + * stays visible. Geometric spacing keeps total warn count bounded at O(log N) + * for a condition that fires N times. + */ +const warnedKeyCounts = new Map(); + +const WARN_MILESTONES = [1, 10, 100, 1000, 10000] as const; + +const ordinal = (n: number): string => { + switch (n) { + case 1: + return '1st'; + case 10: + return '10th'; + case 100: + return '100th'; + case 1000: + return '1000th'; + case 10000: + return '10000th'; + default: + return `${n}th`; + } +}; + +export const isMissingFsError = (err: unknown): boolean => + (err as NodeJS.ErrnoException | undefined)?.code === 'ENOENT'; + +const missing = isMissingFsError; + +const sidecarPreflightDisabled = (): boolean => + /^(1|true|yes|on)$/i.test(process.env.GITNEXUS_DISABLE_LBUG_SIDECAR_PREFLIGHT ?? ''); + +export const statIfExists = async (filePath: string): Promise<{ size: number } | null> => { + try { + const statFn = (fs as typeof fs & { stat?: typeof fs.stat }).stat; + if (typeof statFn === 'function') { + const stat = await statFn(filePath); + return { size: stat.size }; + } + // Some focused unit tests provide a deliberately tiny fs mock. Treat a + // path as present only when access succeeds, with an unknown/zero size. + await fs.access(filePath); + return { size: 0 }; + } catch (err) { + if (missing(err)) return null; + throw err; + } +}; + +const logDebug = (logger: SidecarRecoveryLogger, message: string): void => { + if (logger.debug) logger.debug(message); +}; + +const logInfo = (logger: SidecarRecoveryLogger, message: string): void => { + if (logger.info) logger.info(message); + else logDebug(logger, message); +}; + +/** + * Log at warn-level on logarithmic milestone occurrences (1st, 10th, 100th, + * 1000th, 10000th); debug-level otherwise. Past the first occurrence the warn + * message is suffixed with the occurrence count so operators can see the + * condition's persistence at a glance. + * + * The signature and key convention (`${dbPath}:suffix`) are unchanged from the + * previous warn-once implementation — call sites need no edits. + */ +const warnOnce = (logger: SidecarRecoveryLogger, key: string, message: string): void => { + const next = (warnedKeyCounts.get(key) ?? 0) + 1; + warnedKeyCounts.set(key, next); + const isMilestone = (WARN_MILESTONES as readonly number[]).includes(next); + if (!isMilestone) { + logDebug(logger, message); + return; + } + if (next === 1) { + logger.warn(message); + return; + } + logger.warn(`${message} (${ordinal(next)} occurrence of this condition)`); +}; + +// LADYBUGDB-CONTRACT: matches @ladybugdb/core ^0.16.1 native error text. +// When bumping LadybugDB, re-validate this regex against the new error format +// — `git grep "LADYBUGDB-CONTRACT"` enumerates every version-coupled spot. +export const isMissingShadowSidecarError = (err: unknown): boolean => { + const msg = err instanceof Error ? err.message : String(err); + return /Cannot open file .*\.shadow: No such file or directory/i.test(msg); +}; + +// LADYBUGDB-CONTRACT: matches @ladybugdb/core ^0.16.1 native error text. +// When bumping LadybugDB, re-validate this regex against the new error format +// — `git grep "LADYBUGDB-CONTRACT"` enumerates every version-coupled spot. +export const isReadOnlyShadowReplayError = (err: unknown): boolean => { + const msg = err instanceof Error ? err.message : String(err); + return /replay shadow pages under read-only mode/i.test(msg); +}; + +export const shadowSidecarRecoveryMessage = (dbPath: string, err: unknown): string => { + const msg = err instanceof Error ? err.message : String(err); + return ( + `LadybugDB checkpoint sidecar is missing for ${dbPath}. ` + + 'Rebuild the index with `gitnexus analyze --force --index-only` and restart `gitnexus serve`.' + + `\n Original error: ${msg.slice(0, 200)}` + ); +}; + +const PERMISSION_RENAME_CODES = new Set(['EACCES', 'EPERM', 'EBUSY']); + +export const isPermissionRenameError = (err: unknown): boolean => { + const code = (err as NodeJS.ErrnoException | undefined)?.code; + return typeof code === 'string' && PERMISSION_RENAME_CODES.has(code); +}; + +/** + * Classify a failure surfaced by quarantine rename into an actionable user-facing + * message. + * + * - EACCES / EPERM / EBUSY → permission-specific message pointing at filesystem + * ACLs, AV exclusions, and file-locks. Importantly does NOT instruct the user + * to rebuild the index — the underlying problem is environmental, not data + * integrity, and re-running after fixing the lock/permission will succeed. + * - Everything else (including the LadybugDB "Cannot open file *.shadow" + * missing-shadow error, ENOSPC, EROFS, EIO, and any other thrown Error) → + * falls back to `shadowSidecarRecoveryMessage`, preserving today's behavior. + * + * Use at caller catches around `quarantineWalForMissingShadow` and any other + * path where an `fs.rename`-class failure may surface to operators. + */ +export const renameFailureMessage = (dbPath: string, err: unknown): string => { + if (isPermissionRenameError(err)) { + const code = (err as NodeJS.ErrnoException).code; + const msg = err instanceof Error ? err.message : String(err); + return ( + `GitNexus could not move the LadybugDB WAL sidecar at ${dbPath}.wal because of a ` + + `filesystem permission or file-lock error (${code}). ` + + 'Check filesystem ACLs, antivirus exclusions for the index directory, and ' + + 'whether another process holds an open handle on the file. ' + + 'The index does not need to be rebuilt — re-running the failing command after ' + + 'resolving the lock or permission should succeed.' + + `\n Original error: ${msg.slice(0, 200)}` + ); + } + return shadowSidecarRecoveryMessage(dbPath, err); +}; + +export async function inspectLbugSidecars(dbPath: string): Promise { + const wal = await statIfExists(`${dbPath}.wal`); + const shadow = await statIfExists(`${dbPath}.shadow`); + + if (wal && shadow) { + return { kind: 'wal-with-shadow', dbPath, walBytes: wal.size, shadowBytes: shadow.size }; + } + if (wal) { + if (wal.size <= TINY_ORPHAN_WAL_BYTES) { + return { kind: 'tiny-orphan-wal', dbPath, walBytes: wal.size }; + } + return { kind: 'orphan-wal', dbPath, walBytes: wal.size }; + } + if (shadow) { + return { kind: 'orphan-shadow', dbPath, shadowBytes: shadow.size }; + } + return { kind: 'clean', dbPath }; +} + +export async function quarantineWalForMissingShadow( + dbPath: string, + options: { + logger: SidecarRecoveryLogger; + level?: 'debug' | 'info' | 'warn'; + reason?: string; + }, +): Promise { + const walPath = `${dbPath}.wal`; + const quarantinePath = `${walPath}.missing-shadow.${Date.now()}-${Math.random() + .toString(36) + .slice(2)}`; + await fs.rename(walPath, quarantinePath); + + const message = + `GitNexus: quarantined WAL ${path.basename(quarantinePath)} because LadybugDB shadow sidecar was missing; ` + + `continuing from last checkpoint${options.reason ? ` (${options.reason})` : ''}`; + + if (options.level === 'warn') { + warnOnce(options.logger, `${dbPath}:missing-shadow-quarantine`, message); + } else if (options.level === 'info') { + logInfo(options.logger, message); + } else { + logDebug(options.logger, message); + } + + return quarantinePath; +} + +export async function preflightLbugSidecars( + dbPath: string, + options: { + mode: 'read-only' | 'write'; + logger: SidecarRecoveryLogger; + allowQuarantine: boolean; + }, +): Promise { + let state: LbugSidecarState; + try { + state = await inspectLbugSidecars(dbPath); + } catch (err) { + logDebug( + options.logger, + `GitNexus: unable to inspect LadybugDB sidecars before ${options.mode} open; continuing without preflight repair: ${(err as Error).message}`, + ); + return { kind: 'clean', dbPath }; + } + if (sidecarPreflightDisabled() || !options.allowQuarantine) return state; + + if (state.kind === 'tiny-orphan-wal') { + await quarantineWalForMissingShadow(dbPath, { + logger: options.logger, + level: 'debug', + reason: `${options.mode} preflight tiny orphan WAL (${state.walBytes} bytes)`, + }); + return inspectLbugSidecars(dbPath); + } + + if (state.kind === 'orphan-wal') { + warnOnce( + options.logger, + `${dbPath}:orphan-wal-preflight:${options.mode}`, + `GitNexus: found ${state.walBytes} byte lbug.wal without lbug.shadow before ${options.mode} open; ` + + 'will rely on LadybugDB replay/recovery instead of deleting pending WAL data.', + ); + } + + return state; +} + +export async function finalizeLbugSidecarsAfterClose( + dbPath: string, + options: { logger: SidecarRecoveryLogger }, +): Promise { + if (sidecarPreflightDisabled()) return; + + let state: LbugSidecarState; + try { + state = await inspectLbugSidecars(dbPath); + } catch (err) { + logDebug( + options.logger, + `GitNexus: unable to inspect LadybugDB sidecars after close; skipping post-close repair: ${(err as Error).message}`, + ); + return; + } + if (state.kind === 'clean' || state.kind === 'wal-with-shadow') return; + + for (const delayMs of [25, 50, 100]) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + try { + state = await inspectLbugSidecars(dbPath); + } catch (err) { + logDebug( + options.logger, + `GitNexus: unable to inspect LadybugDB sidecars after close; skipping post-close repair: ${(err as Error).message}`, + ); + return; + } + if (state.kind === 'clean' || state.kind === 'wal-with-shadow') return; + } + + if (state.kind === 'tiny-orphan-wal') { + try { + await quarantineWalForMissingShadow(dbPath, { + logger: options.logger, + level: 'debug', + reason: `post-close tiny orphan WAL (${state.walBytes} bytes)`, + }); + } catch (err) { + if (!missing(err)) { + warnOnce( + options.logger, + `${dbPath}:post-close-tiny-quarantine-failed`, + `GitNexus: failed to quarantine tiny orphan WAL after close (${(err as Error).message}); next read may recover reactively.`, + ); + } + } + return; + } + + if (state.kind === 'orphan-wal') { + warnOnce( + options.logger, + `${dbPath}:post-close-orphan-wal`, + `GitNexus: lbug.wal (${state.walBytes} bytes) remains without lbug.shadow after close; ` + + 'keeping it for recovery. If this repeats, run `gitnexus analyze --force --index-only` or the sidecar repair command.', + ); + } +} + +export async function listQuarantinedMissingShadowWals(dbPath: string): Promise { + const dir = path.dirname(dbPath); + const base = path.basename(dbPath); + let entries: string[]; + try { + entries = await fs.readdir(dir); + } catch (err) { + if (missing(err)) return []; + throw err; + } + return entries + .filter((entry) => entry.startsWith(`${base}.wal.missing-shadow.`)) + .map((entry) => path.join(dir, entry)) + .sort(); +} + +export async function cleanQuarantinedMissingShadowWals(dbPath: string): Promise { + const files = await listQuarantinedMissingShadowWals(dbPath); + const deleted: string[] = []; + for (const file of files) { + await fs.unlink(file); + deleted.push(file); + } + return deleted; +} + +export const _resetSidecarRecoveryWarningsForTest = (): void => { + warnedKeyCounts.clear(); +}; diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts index 4c55536ed..d90e63e62 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -717,6 +717,12 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => ); app.use(express.json({ limit: '10mb' })); + // No explicit OPTIONS route is registered. The Chromium Private Network + // Access header is set by the global middleware above (pre-cors), and + // `cors()` itself handles OPTIONS preflights for every path. Registering a + // wildcard OPTIONS catchall here would throw under Express 5's stricter + // path parser (the source of the original startup crash this branch fixed). + // Initialize MCP backend (multi-repo, shared across all MCP sessions) const backend = new LocalBackend(); await backend.init(); diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-class-instance-singleton/src/consumer.js b/gitnexus/test/fixtures/lang-resolution/javascript-class-instance-singleton/src/consumer.js new file mode 100644 index 000000000..9c51372f7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-class-instance-singleton/src/consumer.js @@ -0,0 +1,9 @@ +import { fooService } from './service.js'; + +/** + * @param {string} id + * @returns {string} + */ +export function caller(id) { + return fooService.getUser(id); +} diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-class-instance-singleton/src/service.js b/gitnexus/test/fixtures/lang-resolution/javascript-class-instance-singleton/src/service.js new file mode 100644 index 000000000..079e643f0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-class-instance-singleton/src/service.js @@ -0,0 +1,11 @@ +export class FooService { + /** + * @param {string} id + * @returns {string} + */ + getUser(id) { + return id; + } +} + +export const fooService = new FooService(); diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-factory-singleton/src/consumer.js b/gitnexus/test/fixtures/lang-resolution/javascript-factory-singleton/src/consumer.js new file mode 100644 index 000000000..9c51372f7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-factory-singleton/src/consumer.js @@ -0,0 +1,9 @@ +import { fooService } from './service.js'; + +/** + * @param {string} id + * @returns {string} + */ +export function caller(id) { + return fooService.getUser(id); +} diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-factory-singleton/src/service.js b/gitnexus/test/fixtures/lang-resolution/javascript-factory-singleton/src/service.js new file mode 100644 index 000000000..c0472b396 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-factory-singleton/src/service.js @@ -0,0 +1,18 @@ +export class FooService { + /** + * @param {string} id + * @returns {string} + */ + getUser(id) { + return id; + } +} + +/** + * @returns {FooService} + */ +export function makeFooService() { + return new FooService(); +} + +export const fooService = makeFooService(); diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-class-instance-singleton/src/consumer.ts b/gitnexus/test/fixtures/lang-resolution/typescript-class-instance-singleton/src/consumer.ts new file mode 100644 index 000000000..7d1298ec7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-class-instance-singleton/src/consumer.ts @@ -0,0 +1,5 @@ +import { fooService } from './service'; + +export function caller(id: string) { + return fooService.getUser(id); +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-class-instance-singleton/src/service.ts b/gitnexus/test/fixtures/lang-resolution/typescript-class-instance-singleton/src/service.ts new file mode 100644 index 000000000..e8a729ac9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-class-instance-singleton/src/service.ts @@ -0,0 +1,7 @@ +export class FooService { + getUser(id: string) { + return id; + } +} + +export const fooService = new FooService(); diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-factory-singleton/src/consumer.ts b/gitnexus/test/fixtures/lang-resolution/typescript-factory-singleton/src/consumer.ts new file mode 100644 index 000000000..7d1298ec7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-factory-singleton/src/consumer.ts @@ -0,0 +1,5 @@ +import { fooService } from './service'; + +export function caller(id: string) { + return fooService.getUser(id); +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-factory-singleton/src/service.ts b/gitnexus/test/fixtures/lang-resolution/typescript-factory-singleton/src/service.ts new file mode 100644 index 000000000..89a70d553 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-factory-singleton/src/service.ts @@ -0,0 +1,11 @@ +export class FooService { + getUser(id: string) { + return id; + } +} + +export function makeFooService(): FooService { + return new FooService(); +} + +export const fooService = makeFooService(); diff --git a/gitnexus/test/integration/ast-helpers-object-literal-binding.test.ts b/gitnexus/test/integration/ast-helpers-object-literal-binding.test.ts new file mode 100644 index 000000000..99f3700aa --- /dev/null +++ b/gitnexus/test/integration/ast-helpers-object-literal-binding.test.ts @@ -0,0 +1,181 @@ +/** + * Integration tests for `findObjectLiteralBindingInfo`. + * + * Drives the helper against real tree-sitter ASTs (TypeScript) and pins the + * Phase A / Phase B boundary semantics from the PR #1718 production-readiness + * review (U1): + * - happy path: file-scope export const / const / export var → returns binding + * - local-inside-function / arrow / class-constructor → null + * - nested object literal → null (safe under-approximation) + * - block-scoped declaration (if / for body) → null + * - IIFE-wrapped object literal → null + * - assignment without declarator → null (no throw) + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import Parser from 'tree-sitter'; +import { loadParser, loadLanguage } from '../../src/core/tree-sitter/parser-loader.js'; +import { SupportedLanguages } from '../../src/config/supported-languages.js'; +import { findObjectLiteralBindingInfo } from '../../src/core/ingestion/utils/ast-helpers.js'; +import { generateId } from '../../src/lib/utils.js'; + +let parser: Parser; + +beforeAll(async () => { + parser = await loadParser(); + await loadLanguage(SupportedLanguages.TypeScript, 'fixture.ts'); +}); + +/** Locate every method_definition AST node by name. */ +function findMethodNodes(root: Parser.SyntaxNode, methodName: string): Parser.SyntaxNode[] { + const out: Parser.SyntaxNode[] = []; + const visit = (node: Parser.SyntaxNode) => { + if (node.type === 'method_definition') { + const name = node.childForFieldName('name'); + if (name?.text === methodName) out.push(node); + } + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child) visit(child); + } + }; + visit(root); + return out; +} + +function parseTs(code: string): Parser.Tree { + return parser.parse(code); +} + +describe('findObjectLiteralBindingInfo — happy paths', () => { + it('exported const + shorthand method → owner binding', () => { + const tree = parseTs(`export const fooService = { async getUser(id: string) { return id; } };`); + const [methodNode] = findMethodNodes(tree.rootNode, 'getUser'); + expect(methodNode).toBeDefined(); + const result = findObjectLiteralBindingInfo(methodNode, 'src/foo.ts'); + expect(result).toEqual({ ownerId: generateId('Const', 'src/foo.ts:fooService') }); + }); + + it('bare file-scope const → owner binding', () => { + const tree = parseTs(`const fooService = { getUser(id: string) { return id; } };`); + const [methodNode] = findMethodNodes(tree.rootNode, 'getUser'); + const result = findObjectLiteralBindingInfo(methodNode, 'src/foo.ts'); + expect(result).toEqual({ ownerId: generateId('Const', 'src/foo.ts:fooService') }); + }); + + it('exported var (variable_declaration) → Variable label', () => { + const tree = parseTs(`export var legacyService = { run() {} };`); + const [methodNode] = findMethodNodes(tree.rootNode, 'run'); + const result = findObjectLiteralBindingInfo(methodNode, 'src/legacy.ts'); + expect(result).toEqual({ ownerId: generateId('Variable', 'src/legacy.ts:legacyService') }); + }); +}); + +describe('findObjectLiteralBindingInfo — negative: container boundaries', () => { + it('local const inside exported function → null', () => { + const tree = parseTs(` + export function processAll() { + const handler = { run(x: string) { return x; } }; + return handler; + } + `); + const [methodNode] = findMethodNodes(tree.rootNode, 'run'); + expect(findObjectLiteralBindingInfo(methodNode, 'src/p.ts')).toBe(null); + }); + + it('local const inside exported arrow function → null', () => { + const tree = parseTs(` + export const make = () => { + const h = { run() {} }; + return h; + }; + `); + const [methodNode] = findMethodNodes(tree.rootNode, 'run'); + expect(findObjectLiteralBindingInfo(methodNode, 'src/p.ts')).toBe(null); + }); + + it('local const inside class constructor → null', () => { + const tree = parseTs(` + export class C { + constructor() { + const h = { run() {} }; + void h; + } + } + `); + const [methodNode] = findMethodNodes(tree.rootNode, 'run'); + expect(findObjectLiteralBindingInfo(methodNode, 'src/c.ts')).toBe(null); + }); +}); + +describe('findObjectLiteralBindingInfo — negative: nested literals', () => { + it('inner method of nested literal → null (safe under-approximation)', () => { + const tree = parseTs(`export const s = { nested: { method() {} } };`); + const [methodNode] = findMethodNodes(tree.rootNode, 'method'); + expect(findObjectLiteralBindingInfo(methodNode, 'src/s.ts')).toBe(null); + }); + + it('top-level method alongside nested literal still binds to outer', () => { + const tree = parseTs(`export const s = { nested: { inner() {} }, outer() {} };`); + const [outerNode] = findMethodNodes(tree.rootNode, 'outer'); + expect(findObjectLiteralBindingInfo(outerNode, 'src/s.ts')).toEqual({ + ownerId: generateId('Const', 'src/s.ts:s'), + }); + const [innerNode] = findMethodNodes(tree.rootNode, 'inner'); + expect(findObjectLiteralBindingInfo(innerNode, 'src/s.ts')).toBe(null); + }); +}); + +describe('findObjectLiteralBindingInfo — negative: block scope', () => { + it('declared inside top-level if-block → null', () => { + const tree = parseTs(` + const cond = true; + if (cond) { + const handler = { run() {} }; + void handler; + } + `); + const [methodNode] = findMethodNodes(tree.rootNode, 'run'); + expect(findObjectLiteralBindingInfo(methodNode, 'src/p.ts')).toBe(null); + }); + + it('declared inside for-of body → null', () => { + const tree = parseTs(` + const arr = [1, 2]; + for (const _i of arr) { + const h = { run() {} }; + void h; + } + `); + const [methodNode] = findMethodNodes(tree.rootNode, 'run'); + expect(findObjectLiteralBindingInfo(methodNode, 'src/p.ts')).toBe(null); + }); + + it('declared inside try-block → null', () => { + const tree = parseTs(` + try { + const h = { run() {} }; + void h; + } catch {} + `); + const [methodNode] = findMethodNodes(tree.rootNode, 'run'); + expect(findObjectLiteralBindingInfo(methodNode, 'src/p.ts')).toBe(null); + }); +}); + +describe('findObjectLiteralBindingInfo — negative: IIFE and assignment', () => { + it('IIFE-wrapped object literal → null', () => { + const tree = parseTs(`export const x = (() => ({ m() {} }))();`); + const [methodNode] = findMethodNodes(tree.rootNode, 'm'); + expect(findObjectLiteralBindingInfo(methodNode, 'src/x.ts')).toBe(null); + }); + + it('assignment expression (no variable_declarator) → null without throwing', () => { + const tree = parseTs(` + let y: any; + y = { m() {} }; + `); + const [methodNode] = findMethodNodes(tree.rootNode, 'm'); + expect(() => findObjectLiteralBindingInfo(methodNode, 'src/y.ts')).not.toThrow(); + expect(findObjectLiteralBindingInfo(methodNode, 'src/y.ts')).toBe(null); + }); +}); diff --git a/gitnexus/test/integration/filesystem-walker.test.ts b/gitnexus/test/integration/filesystem-walker.test.ts index 1d743a898..8f934573c 100644 --- a/gitnexus/test/integration/filesystem-walker.test.ts +++ b/gitnexus/test/integration/filesystem-walker.test.ts @@ -438,6 +438,28 @@ describe('filesystem-walker', () => { .filter((r) => String(r.msg ?? '').includes('GITNEXUS_MAX_FILE_SIZE=')); expect(hint.length).toBe(0); }); + + it('routes large-file notices through console.warn while analyze progress is active', async () => { + const originalProgressActive = process.env.GITNEXUS_ANALYZE_PROGRESS_ACTIVE; + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + process.env.GITNEXUS_ANALYZE_PROGRESS_ACTIVE = '1'; + await walkRepositoryPaths(sizeDir); + const messages = warnSpy.mock.calls.map(([msg]) => String(msg)); + expect(messages.some((m) => m.includes('Skipped 1 large files'))).toBe(true); + expect(messages.some((m) => m.includes(BIG_FILE))).toBe(true); + expect(cap.records().filter((r) => String(r.msg ?? '').includes('Skipped '))).toHaveLength( + 0, + ); + } finally { + warnSpy.mockRestore(); + if (originalProgressActive === undefined) { + delete process.env.GITNEXUS_ANALYZE_PROGRESS_ACTIVE; + } else { + process.env.GITNEXUS_ANALYZE_PROGRESS_ACTIVE = originalProgressActive; + } + } + }); }); describe('large file skip preview cap (#1659)', () => { diff --git a/gitnexus/test/integration/object-literal-owner-resolution.test.ts b/gitnexus/test/integration/object-literal-owner-resolution.test.ts new file mode 100644 index 000000000..b0641d7f0 --- /dev/null +++ b/gitnexus/test/integration/object-literal-owner-resolution.test.ts @@ -0,0 +1,279 @@ +/** + * Integration tests for PR #1718 production-readiness review (U4). + * + * Proves the bug fix for issue #1358 end-to-end: + * + * export const fooService = { getUser(id: string) { return id; } }; + * // consumer.ts + * import { fooService } from './service'; + * export function caller(id: string) { return fooService.getUser(id); } + * + * After this PR, the full ingestion pipeline must emit: + * - `Const:fooService` ── HAS_METHOD ─► `Method:getUser` + * - `Function:caller` ── CALLS ─► `Method:getUser` + * + * The CALLS edge is the canonical proof: `gitnexus_impact` upstream traversal + * is a graph walk over CALLS, so if the edge exists, impact returns the + * caller. Asserting the edge directly avoids wiring an entire `withTestLbugDB` + * fixture for what is effectively a graph-shape assertion. + * + * Test set: + * - Test A: sequential pipeline produces both edges with the right `ownerId` + * - Test B: worker-mode pipeline produces identical edge sets (skipped when + * `dist/parse-worker.js` is missing; CI builds it before running tests) + * - Test C: local-scoped object literal inside a function emits no false- + * positive HAS_METHOD (proves U1 boundary guard is load-bearing) + * - Test D: nested object literal binds neither method to outer (safe + * under-approximation proof) + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + getRelationships, + getNodesByLabel, + runPipelineFromRepo, + type PipelineResult, +} from './resolvers/helpers.js'; +import { generateId } from '../../src/lib/utils.js'; + +const DIST_WORKER = path.resolve( + __dirname, + '..', + '..', + 'dist', + 'core', + 'ingestion', + 'workers', + 'parse-worker.js', +); +const hasDistWorker = fs.existsSync(DIST_WORKER); + +// CI tripwire: worker-parity test (Test B below) silently skips when +// `dist/parse-worker.js` is missing. That's fine locally — devs may not +// have run `npm run build` — but on CI a missing dist would mean U3 +// (worker-path ownerId emission) is unverified. Fail hard so a missing +// dist surfaces as a red build, not a green test with a silent skip. +// Locally, run `npm run build` before this suite to exercise worker mode. +if (!hasDistWorker && process.env.CI) { + throw new Error( + 'dist/parse-worker.js missing on CI — worker-parity test would silently skip. ' + + 'Ensure the build runs before this suite.', + ); +} + +/** Materialise a tiny fixture repo on disk. Returns the absolute repo root. */ +function writeFixture(files: Record): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gnx-objlit-')); + for (const [rel, content] of Object.entries(files)) { + const full = path.join(root, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + } + return root; +} + +function removeFixture(root: string): void { + fs.rmSync(root, { recursive: true, force: true }); +} + +const SERVICE_TS = `export const fooService = { + getUser(id: string) { return id; }, + saveUser(id: string) { return id; }, +}; +`; + +const CONSUMER_TS = `import { fooService } from './service'; + +export function caller(id: string) { + return fooService.getUser(id); +} +`; + +// ── Test A: sequential pipeline ────────────────────────────────────────────── + +describe('object-literal owner resolution — sequential pipeline (PR #1718)', () => { + let repoRoot: string; + let result: PipelineResult; + + beforeAll(async () => { + repoRoot = writeFixture({ + 'src/service.ts': SERVICE_TS, + 'src/consumer.ts': CONSUMER_TS, + }); + result = await runPipelineFromRepo(repoRoot, () => undefined, { + skipGraphPhases: true, + skipWorkers: true, + }); + }, 60000); + + afterAll(() => removeFixture(repoRoot)); + + it('emits Const:fooService, Method:getUser, Function:caller exactly once', () => { + expect(getNodesByLabel(result, 'Const').filter((n) => n === 'fooService').length).toBe(1); + expect(getNodesByLabel(result, 'Method').filter((n) => n === 'getUser').length).toBe(1); + expect(getNodesByLabel(result, 'Function').filter((n) => n === 'caller').length).toBe(1); + }); + + it('emits exactly the expected HAS_METHOD edges from fooService', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const fromFoo = hasMethod + .filter((e) => e.source === 'fooService') + .map((e) => e.target) + .sort(); + expect(fromFoo).toEqual(['getUser', 'saveUser']); + }); + + it('the fooService Const node uses the expected graph node ID', () => { + const expectedNodeId = generateId('Const', 'src/service.ts:fooService'); + let fooServiceNode: { id: string; label: string } | undefined; + result.graph.forEachNode((n) => { + if (n.label === 'Const' && n.properties.name === 'fooService') { + fooServiceNode = { id: n.id, label: n.label }; + } + }); + expect(fooServiceNode).toBeDefined(); + expect(fooServiceNode!.id).toBe(expectedNodeId); + }); + + it('emits a CALLS edge from caller to getUser with the expected target/confidence/reason (issue #1358 fix)', () => { + const calls = getRelationships(result, 'CALLS'); + const callerToGetUser = calls + .filter((e) => e.source === 'caller' && e.target === 'getUser') + .map((e) => ({ + targetId: e.rel.targetId, + confidence: e.rel.confidence, + reason: e.rel.reason, + })); + + // The Method node id encodes arity disambiguation (#1 = one-arity overload). + // Pin the canonical id so a regression that targets a phantom node fails. + const expectedTargetId = generateId('Method', 'src/service.ts:getUser#1'); + expect(callerToGetUser).toEqual([ + { + targetId: expectedTargetId, + confidence: 0.85, + reason: 'import-resolved', + }, + ]); + }); +}); + +// ── Test B: worker-mode parity ─────────────────────────────────────────────── + +describe.skipIf(!hasDistWorker)('object-literal owner resolution — worker parity', () => { + let repoRoot: string; + let sequentialResult: PipelineResult; + let workerResult: PipelineResult; + + beforeAll(async () => { + repoRoot = writeFixture({ + 'src/service.ts': SERVICE_TS, + 'src/consumer.ts': CONSUMER_TS, + }); + sequentialResult = await runPipelineFromRepo(repoRoot, () => undefined, { + skipGraphPhases: true, + skipWorkers: true, + }); + workerResult = await runPipelineFromRepo(repoRoot, () => undefined, { + skipGraphPhases: true, + skipWorkers: false, + workerThresholdsForTest: { minFiles: 1, minBytes: 1 }, + }); + }, 90000); + + afterAll(() => removeFixture(repoRoot)); + + it('produces the same HAS_METHOD edge set as sequential', () => { + const seqEdges = getRelationships(sequentialResult, 'HAS_METHOD') + .map((e) => `${e.source}->${e.target}`) + .sort(); + const workerEdges = getRelationships(workerResult, 'HAS_METHOD') + .map((e) => `${e.source}->${e.target}`) + .sort(); + expect(workerEdges).toEqual(seqEdges); + }); + + it('produces the same CALLS edge set as sequential', () => { + const seqEdges = getRelationships(sequentialResult, 'CALLS') + .map((e) => `${e.source}->${e.target}`) + .sort(); + const workerEdges = getRelationships(workerResult, 'CALLS') + .map((e) => `${e.source}->${e.target}`) + .sort(); + expect(workerEdges).toEqual(seqEdges); + }); +}); + +// ── Test C: negative — local object literal inside a function body ────────── + +describe('object-literal owner resolution — negative (local literal)', () => { + let repoRoot: string; + let result: PipelineResult; + + beforeAll(async () => { + repoRoot = writeFixture({ + 'src/p.ts': `export function processAll() { + const handler = { run(id: string) { return id; } }; + return handler; +} +`, + }); + result = await runPipelineFromRepo(repoRoot, () => undefined, { + skipGraphPhases: true, + skipWorkers: true, + }); + }, 60000); + + afterAll(() => removeFixture(repoRoot)); + + it('emits no HAS_METHOD edge targeting `run` (no false-positive owner attribution)', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const targetingRun = hasMethod.filter((e) => e.target === 'run'); + expect(targetingRun.length).toBe(0); + }); + + it('the run method node carries no ownerId property', () => { + let runNode: { properties: { name: string; ownerId?: string }; label: string } | undefined; + result.graph.forEachNode((n) => { + if (n.label === 'Method' && n.properties.name === 'run') { + runNode = n as typeof runNode; + } + }); + expect(runNode).toBeDefined(); + expect(runNode!.properties.ownerId).toBe(undefined); + }); +}); + +// ── Test D: negative — nested object literal ───────────────────────────────── + +describe('object-literal owner resolution — negative (nested literal)', () => { + let repoRoot: string; + let result: PipelineResult; + + beforeAll(async () => { + repoRoot = writeFixture({ + 'src/n.ts': `export const s = { + nested: { method(id: string) { return id; } }, + outer(id: string) { return id; }, +}; +`, + }); + result = await runPipelineFromRepo(repoRoot, () => undefined, { + skipGraphPhases: true, + skipWorkers: true, + }); + }, 60000); + + afterAll(() => removeFixture(repoRoot)); + + it('binds the top-level outer method to s but does NOT bind the nested method', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const fromS = hasMethod + .filter((e) => e.source === 's') + .map((e) => e.target) + .sort(); + expect(fromS).toEqual(['outer']); + }); +}); diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index 296f3e458..c7dff0947 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -87,6 +87,33 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly required (2>1) on candidate with default param emits NO edge post-fix', 'variadic candidate, argCount < required (1<2) emits NO edge', ]), + typescript: new Set([ + // Issue #1358 sub-cases: class-instance singleton (`export const foo = new Foo()`) + // and factory-pattern singleton (`export const foo = makeFoo()`) cross-file + // CALLS resolution. The scope-resolution path resolves these via + // `@type-binding.constructor` capture (TS query) + + // `propagateImportedReturnTypes` mirror + receiver-bound Case 4 simple + // typeBinding lookup. The legacy DAG's typeEnv does not propagate + // `new Foo()` constructor inference across module boundaries — verified + // by `scope-parity / typescript parity` CI job failure. Node-existence + // and HAS_METHOD edge assertions pass under legacy DAG (parser-level + // emission is intact); only the cross-file CALLS edge resolution + // requires the scope-resolution chain. Scope-resolver-only correctness + // wins; backporting requires constructor-typeBinding cross-file + // propagation in the legacy DAG. + 'resolves caller.fooService.getUser() to FooService.getUser via constructor-inferred typeBinding', + 'resolves caller.fooService.getUser() through the factory chain to FooService.getUser', + ]), + javascript: new Set([ + // Mirrors the TypeScript class-instance and factory-pattern singleton + // resolution gates above. JavaScript fails on the same 2 CALLS-edge + // resolution tests under `REGISTRY_PRIMARY_JAVASCRIPT=0` for the same + // reason — no cross-file constructor-typeBinding propagation in the + // legacy DAG path. Verified by `scope-parity / javascript parity` CI + // job failure on the bare singleton tests before this exclusion landed. + 'resolves caller.fooService.getUser() to FooService.getUser via constructor-inferred typeBinding', + 'resolves caller.fooService.getUser() through the factory chain to FooService.getUser', + ]), python: new Set([ // Suffix-fallback lex tiebreak depends on the registry-primary // resolver's deterministic sort. The legacy resolver returns the diff --git a/gitnexus/test/integration/resolvers/javascript.test.ts b/gitnexus/test/integration/resolvers/javascript.test.ts index ccb4df618..25316ac6d 100644 --- a/gitnexus/test/integration/resolvers/javascript.test.ts +++ b/gitnexus/test/integration/resolvers/javascript.test.ts @@ -1,11 +1,12 @@ /** * JavaScript: self/this resolution, parent resolution, super resolution */ -import { describe, it, expect, beforeAll } from 'vitest'; +import { describe, expect, beforeAll } from 'vitest'; import path from 'path'; import { FIXTURES, CROSS_FILE_FIXTURES, + createResolverParityIt, getRelationships, getNodesByLabel, getNodesByLabelFull, @@ -14,6 +15,13 @@ import { type PipelineResult, } from './helpers.js'; +// Shadow vitest's `it` with the parity-gated runner so tests listed in +// `LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES.javascript` (helpers.ts) skip +// under `REGISTRY_PRIMARY_JAVASCRIPT=0` (legacy DAG mode) and run normally +// under the default registry-primary path. The scope-parity CI gate +// requires this for the issue #1358 singleton describes below. +const it = createResolverParityIt('javascript'); + // --------------------------------------------------------------------------- // skipGraphPhases: verify pipeline works correctly when graph phases are skipped // --------------------------------------------------------------------------- @@ -541,3 +549,101 @@ describe('JavaScript Child extends Parent — inherited method resolution (SM-9) expect(parentMethodCall!.source).toBe('run'); }); }); + +// --------------------------------------------------------------------------- +// Issue #1358: class-instance singleton (`export const x = new C()`) +// PR #1718 closed the object-literal-shorthand sub-case; this fixture covers +// the class-instance sub-case for JavaScript. Same resolution chain as TS but +// the receiver type comes from the `new ClassName()` initializer (no JSDoc +// annotation needed — the @type-binding.constructor capture handles it). +// --------------------------------------------------------------------------- + +describe('JavaScript class-instance singleton resolution (issue #1358 sub-case)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'javascript-class-instance-singleton'), + () => {}, + { skipGraphPhases: true }, + ); + }, 60000); + + it('detects FooService class, getUser method, caller function, fooService Const', () => { + expect(getNodesByLabel(result, 'Class')).toContain('FooService'); + expect(getNodesByLabel(result, 'Method')).toContain('getUser'); + expect(getNodesByLabel(result, 'Function')).toContain('caller'); + expect(getNodesByLabel(result, 'Const')).toContain('fooService'); + }); + + it('emits HAS_METHOD edge from FooService to getUser', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const fromClass = hasMethod.filter((e) => e.source === 'FooService').map((e) => e.target); + expect(fromClass).toEqual(['getUser']); + }); + + it('resolves caller.fooService.getUser() to FooService.getUser via constructor-inferred typeBinding', () => { + const calls = getRelationships(result, 'CALLS'); + const projected = calls + .filter((e) => e.source === 'caller' && e.target === 'getUser') + .map((e) => ({ + targetFilePath: e.targetFilePath, + reason: e.rel.reason, + confidence: e.rel.confidence, + })); + + expect(projected).toEqual([ + { + targetFilePath: 'src/service.js', + reason: 'import-resolved', + confidence: 0.85, + }, + ]); + }); +}); + +// --------------------------------------------------------------------------- +// Issue #1358: factory-pattern singleton (`export const x = makeC()`) +// Tests the @type-binding.alias chain-follow for JS — fooService aliases the +// return of makeFooService(), whose JSDoc @returns {FooService} ties the chain +// back to the class. Resolution propagates cross-file via +// propagateImportedReturnTypes followChainPostFinalize. +// --------------------------------------------------------------------------- + +describe('JavaScript factory-pattern singleton resolution (issue #1358 sub-case)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'javascript-factory-singleton'), + () => {}, + { skipGraphPhases: true }, + ); + }, 60000); + + it('detects FooService class, makeFooService function, fooService Const, caller function', () => { + expect(getNodesByLabel(result, 'Class')).toContain('FooService'); + expect(getNodesByLabel(result, 'Function')).toContain('makeFooService'); + expect(getNodesByLabel(result, 'Function')).toContain('caller'); + expect(getNodesByLabel(result, 'Const')).toContain('fooService'); + }); + + it('resolves caller.fooService.getUser() through the factory chain to FooService.getUser', () => { + const calls = getRelationships(result, 'CALLS'); + const projected = calls + .filter((e) => e.source === 'caller' && e.target === 'getUser') + .map((e) => ({ + targetFilePath: e.targetFilePath, + reason: e.rel.reason, + confidence: e.rel.confidence, + })); + + expect(projected).toEqual([ + { + targetFilePath: 'src/service.js', + reason: 'import-resolved', + confidence: 0.85, + }, + ]); + }); +}); diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts index 8b6e50d20..88dceda83 100644 --- a/gitnexus/test/integration/resolvers/typescript.test.ts +++ b/gitnexus/test/integration/resolvers/typescript.test.ts @@ -1,12 +1,13 @@ /** * TypeScript: heritage resolution + ambiguous symbol disambiguation */ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, expect, beforeAll, afterAll } from 'vitest'; import path from 'path'; import fs from 'node:fs'; import os from 'node:os'; import { FIXTURES, + createResolverParityIt, getRelationships, getNodesByLabel, getNodesByLabelFull, @@ -15,6 +16,13 @@ import { type PipelineResult, } from './helpers.js'; +// Shadow vitest's `it` with the parity-gated runner so tests listed in +// `LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES.typescript` (helpers.ts) skip +// under `REGISTRY_PRIMARY_TYPESCRIPT=0` (legacy DAG mode) and run normally +// under the default registry-primary path. The scope-parity CI gate +// requires this for the issue #1358 singleton describes below. +const it = createResolverParityIt('typescript'); + function writeFixtureRepo(root: string, files: Record): void { for (const [relPath, content] of Object.entries(files)) { const fullPath = path.join(root, relPath); @@ -2936,3 +2944,100 @@ export function createUtf8User(): void { } }); }); + +// --------------------------------------------------------------------------- +// Issue #1358: class-instance singleton (`export const x = new C()`) +// PR #1718 closed the object-literal-shorthand sub-case; this fixture covers +// the class-instance sub-case. Resolution chain: @type-binding.constructor +// (TS query) → propagateImportedReturnTypes (cross-file mirror) → +// receiver-bound Case 4 (simple typeBinding) → MRO walk. +// --------------------------------------------------------------------------- + +describe('TypeScript class-instance singleton resolution (issue #1358 sub-case)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'typescript-class-instance-singleton'), + () => {}, + { skipGraphPhases: true }, + ); + }, 60000); + + it('detects FooService class, getUser method, caller function, fooService Const', () => { + expect(getNodesByLabel(result, 'Class')).toContain('FooService'); + expect(getNodesByLabel(result, 'Method')).toContain('getUser'); + expect(getNodesByLabel(result, 'Function')).toContain('caller'); + expect(getNodesByLabel(result, 'Const')).toContain('fooService'); + }); + + it('emits HAS_METHOD edge from FooService to getUser', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const fromClass = hasMethod.filter((e) => e.source === 'FooService').map((e) => e.target); + expect(fromClass).toEqual(['getUser']); + }); + + it('resolves caller.fooService.getUser() to FooService.getUser via constructor-inferred typeBinding', () => { + const calls = getRelationships(result, 'CALLS'); + const projected = calls + .filter((e) => e.source === 'caller' && e.target === 'getUser') + .map((e) => ({ + targetFilePath: e.targetFilePath, + reason: e.rel.reason, + confidence: e.rel.confidence, + })); + + expect(projected).toEqual([ + { + targetFilePath: 'src/service.ts', + reason: 'import-resolved', + confidence: 0.85, + }, + ]); + }); +}); + +// --------------------------------------------------------------------------- +// Issue #1358: factory-pattern singleton (`export const x = makeC()`) +// Tests the @type-binding.alias chain-follow path through +// propagateImportedReturnTypes (followChainPostFinalize) — fooService aliases +// makeFooService's return type, which the constructor seeds as FooService. +// --------------------------------------------------------------------------- + +describe('TypeScript factory-pattern singleton resolution (issue #1358 sub-case)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'typescript-factory-singleton'), + () => {}, + { skipGraphPhases: true }, + ); + }, 60000); + + it('detects FooService class, makeFooService function, fooService Const, caller function', () => { + expect(getNodesByLabel(result, 'Class')).toContain('FooService'); + expect(getNodesByLabel(result, 'Function')).toContain('makeFooService'); + expect(getNodesByLabel(result, 'Function')).toContain('caller'); + expect(getNodesByLabel(result, 'Const')).toContain('fooService'); + }); + + it('resolves caller.fooService.getUser() through the factory chain to FooService.getUser', () => { + const calls = getRelationships(result, 'CALLS'); + const projected = calls + .filter((e) => e.source === 'caller' && e.target === 'getUser') + .map((e) => ({ + targetFilePath: e.targetFilePath, + reason: e.rel.reason, + confidence: e.rel.confidence, + })); + + expect(projected).toEqual([ + { + targetFilePath: 'src/service.ts', + reason: 'import-resolved', + confidence: 0.85, + }, + ]); + }); +}); diff --git a/gitnexus/test/integration/worker-pool.test.ts b/gitnexus/test/integration/worker-pool.test.ts index 5c5f11944..b8740957b 100644 --- a/gitnexus/test/integration/worker-pool.test.ts +++ b/gitnexus/test/integration/worker-pool.test.ts @@ -394,7 +394,7 @@ describe('worker pool integration', () => { const { parentPort } = require('node:worker_threads'); const markerPath = ${JSON.stringify(markerPath)}; if (fs.existsSync(markerPath)) { - throw new Error('simulated startup crash'); + process.exit(1); } parentPort.on('message', (msg) => { if (msg && msg.type === 'sub-batch') { diff --git a/gitnexus/test/unit/analyze-heap-respawn.test.ts b/gitnexus/test/unit/analyze-heap-respawn.test.ts index 2f094ddbf..9a2db1659 100644 --- a/gitnexus/test/unit/analyze-heap-respawn.test.ts +++ b/gitnexus/test/unit/analyze-heap-respawn.test.ts @@ -1,11 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { EventEmitter } from 'node:events'; -const execFileSyncMock = vi.fn(); +const spawnMock = vi.fn(); const getHeapStatisticsMock = vi.fn(); vi.mock('child_process', async () => { const actual = await vi.importActual('child_process'); - return { ...actual, execFileSync: execFileSyncMock }; + return { ...actual, spawn: spawnMock }; }); vi.mock('v8', () => ({ @@ -18,33 +19,99 @@ vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({ closeLbug: vi.fn(async () => undefined), })); +const mockSpawnExit = ({ + status = 0, + signal = null, + stdout = '', + stderr = '', +}: { + status?: number | null; + signal?: NodeJS.Signals | null; + stdout?: string | Buffer; + stderr?: string | Buffer; +} = {}) => { + spawnMock.mockImplementationOnce(() => { + const child = new EventEmitter() as EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + }; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + queueMicrotask(() => { + if (stdout) child.stdout.emit('data', stdout); + if (stderr) child.stderr.emit('data', stderr); + child.emit('close', status, signal); + }); + return child; + }); +}; + +const setStreamIsTTY = (stream: NodeJS.WriteStream, value: boolean): (() => void) => { + const descriptor = Object.getOwnPropertyDescriptor(stream, 'isTTY'); + Object.defineProperty(stream, 'isTTY', { configurable: true, value }); + return () => { + if (descriptor) Object.defineProperty(stream, 'isTTY', descriptor); + else delete (stream as NodeJS.WriteStream & { isTTY?: boolean }).isTTY; + }; +}; + describe('analyzeCommand heap respawn', () => { let initialNodeOptions: string | undefined; + let stdoutWriteSpy: ReturnType; + let stderrWriteSpy: ReturnType; + let restoreStdoutIsTTY: (() => void) | undefined; + let restoreStderrIsTTY: (() => void) | undefined; beforeEach(() => { initialNodeOptions = process.env.NODE_OPTIONS; vi.resetModules(); - execFileSyncMock.mockReset(); + spawnMock.mockReset(); getHeapStatisticsMock.mockReset(); process.exitCode = undefined; + stdoutWriteSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + stderrWriteSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); }); afterEach(() => { + restoreStdoutIsTTY?.(); + restoreStderrIsTTY?.(); + restoreStdoutIsTTY = undefined; + restoreStderrIsTTY = undefined; + stdoutWriteSpy.mockRestore(); + stderrWriteSpy.mockRestore(); if (initialNodeOptions === undefined) delete process.env.NODE_OPTIONS; else process.env.NODE_OPTIONS = initialNodeOptions; }); - it('re-execs analyze with 16GB heap when no max-old-space-size is present', async () => { + it('re-execs analyze with 16GB heap and bridges progress redraw when parent is a TTY', async () => { delete process.env.NODE_OPTIONS; + restoreStderrIsTTY = setStreamIsTTY(process.stderr, true); getHeapStatisticsMock.mockReturnValue({ heap_size_limit: 512 * 1024 * 1024 }); + mockSpawnExit(); const { analyzeCommand } = await import('../../src/cli/analyze.js'); await analyzeCommand(undefined, {}); - expect(execFileSyncMock).toHaveBeenCalledTimes(1); - const [, args, opts] = execFileSyncMock.mock.calls[0]; + expect(spawnMock).toHaveBeenCalledTimes(1); + const [, args, opts] = spawnMock.mock.calls[0]; expect(args).toContain('--max-old-space-size=16384'); expect(opts.env.NODE_OPTIONS).toContain('--max-old-space-size=16384'); + expect(opts.env.GITNEXUS_RESPAWN_PROGRESS_TTY).toBe('1'); + }); + + it('does not force ANSI progress when the parent output is not a TTY', async () => { + delete process.env.NODE_OPTIONS; + restoreStdoutIsTTY = setStreamIsTTY(process.stdout, false); + restoreStderrIsTTY = setStreamIsTTY(process.stderr, false); + getHeapStatisticsMock.mockReturnValue({ heap_size_limit: 512 * 1024 * 1024 }); + mockSpawnExit(); + + const { analyzeCommand } = await import('../../src/cli/analyze.js'); + await analyzeCommand(undefined, {}); + + expect(spawnMock).toHaveBeenCalledTimes(1); + const [, , opts] = spawnMock.mock.calls[0]; + expect(opts.env.GITNEXUS_RESPAWN_PROGRESS_TTY).toBeUndefined(); }); it('does not re-exec when NODE_OPTIONS already defines max-old-space-size', async () => { @@ -54,18 +121,13 @@ describe('analyzeCommand heap respawn', () => { const { analyzeCommand } = await import('../../src/cli/analyze.js'); await analyzeCommand('/__gitnexus_nonexistent__', {}); - expect(execFileSyncMock).not.toHaveBeenCalled(); + expect(spawnMock).not.toHaveBeenCalled(); }); it('prints heap guidance when respawned analyze exits with likely OOM', async () => { delete process.env.NODE_OPTIONS; getHeapStatisticsMock.mockReturnValue({ heap_size_limit: 512 * 1024 * 1024 }); - execFileSyncMock.mockImplementationOnce(() => { - const err = new Error('child failed') as Error & { status?: number; signal?: string }; - err.status = undefined; - err.signal = 'SIGABRT'; - throw err; - }); + mockSpawnExit({ status: null, signal: 'SIGABRT' }); const { _captureLogger } = await import('../../src/core/logger.js'); const cap = _captureLogger(); @@ -89,18 +151,12 @@ describe('analyzeCommand heap respawn', () => { it('prints heap guidance when child stderr contains heap OOM signature', async () => { delete process.env.NODE_OPTIONS; getHeapStatisticsMock.mockReturnValue({ heap_size_limit: 512 * 1024 * 1024 }); - execFileSyncMock.mockImplementationOnce(() => { - const err = new Error('Command failed') as Error & { - status?: number; - signal?: string; - stderr?: Buffer; - }; - err.status = 1; - err.signal = undefined; - err.stderr = Buffer.from( + mockSpawnExit({ + status: 1, + signal: null, + stderr: Buffer.from( 'FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory', - ); - throw err; + ), }); const { _captureLogger } = await import('../../src/core/logger.js'); @@ -118,16 +174,10 @@ describe('analyzeCommand heap respawn', () => { it('prints heap guidance when child stdout contains heap OOM signature', async () => { delete process.env.NODE_OPTIONS; getHeapStatisticsMock.mockReturnValue({ heap_size_limit: 512 * 1024 * 1024 }); - execFileSyncMock.mockImplementationOnce(() => { - const err = new Error('Command failed') as Error & { - status?: number; - signal?: string; - stdout?: string; - }; - err.status = 1; - err.signal = undefined; - err.stdout = 'FATAL ERROR: JavaScript heap out of memory'; - throw err; + mockSpawnExit({ + status: 1, + signal: null, + stdout: 'FATAL ERROR: JavaScript heap out of memory', }); const { _captureLogger } = await import('../../src/core/logger.js'); @@ -145,19 +195,7 @@ describe('analyzeCommand heap respawn', () => { it('prints heap guidance when child exits 134 without output', async () => { delete process.env.NODE_OPTIONS; getHeapStatisticsMock.mockReturnValue({ heap_size_limit: 512 * 1024 * 1024 }); - execFileSyncMock.mockImplementationOnce(() => { - const err = new Error('Command failed') as Error & { - status?: number; - signal?: string; - stderr?: string; - stdout?: string; - }; - err.status = 134; - err.signal = undefined; - err.stderr = ''; - err.stdout = ''; - throw err; - }); + mockSpawnExit({ status: 134, signal: null, stderr: '', stdout: '' }); const { _captureLogger } = await import('../../src/core/logger.js'); const cap = _captureLogger(); @@ -174,16 +212,10 @@ describe('analyzeCommand heap respawn', () => { it('does not print heap guidance for non-OOM child failures with output', async () => { delete process.env.NODE_OPTIONS; getHeapStatisticsMock.mockReturnValue({ heap_size_limit: 512 * 1024 * 1024 }); - execFileSyncMock.mockImplementationOnce(() => { - const err = new Error('Command failed') as Error & { - status?: number; - signal?: string; - stderr?: Buffer; - }; - err.status = 2; - err.signal = undefined; - err.stderr = Buffer.from('parser failed: invalid token'); - throw err; + mockSpawnExit({ + status: 2, + signal: null, + stderr: Buffer.from('parser failed: invalid token'), }); const { _captureLogger } = await import('../../src/core/logger.js'); @@ -197,4 +229,30 @@ describe('analyzeCommand heap respawn', () => { ); cap.restore(); }); + + it('does not print heap guidance when a SIGABRT child emitted a native N-API crash', async () => { + delete process.env.NODE_OPTIONS; + getHeapStatisticsMock.mockReturnValue({ heap_size_limit: 512 * 1024 * 1024 }); + mockSpawnExit({ + status: 134, + signal: null, + stderr: Buffer.from('libc++abi: terminating due to uncaught exception of type Napi::Error'), + }); + + const { _captureLogger } = await import('../../src/core/logger.js'); + const cap = _captureLogger(); + const { analyzeCommand } = await import('../../src/cli/analyze.js'); + await analyzeCommand(undefined, {}); + + expect(process.exitCode).toBe(134); + expect(cap.records().some((r) => r.msg.includes('Analysis likely ran out of memory.'))).toBe( + false, + ); + expect(cap.records().some((r) => r.msg.includes('Analysis aborted in a native worker'))).toBe( + true, + ); + expect(cap.records().some((r) => r.recoveryHint === 'native-worker-abort')).toBe(true); + expect(stderrWriteSpy).toHaveBeenCalled(); + cap.restore(); + }); }); diff --git a/gitnexus/test/unit/analyze-respawn-progress-terminal.test.ts b/gitnexus/test/unit/analyze-respawn-progress-terminal.test.ts new file mode 100644 index 000000000..de90ae467 --- /dev/null +++ b/gitnexus/test/unit/analyze-respawn-progress-terminal.test.ts @@ -0,0 +1,147 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +interface CapturedTerminal { + cursorTo(x?: number | null, y?: number | null): void; + lineWrapping(enabled: boolean): void; + clearRight(): void; + newline(): void; + write(s: string, rawWrite?: boolean): void; + isTTY(): boolean; +} + +interface CapturedBarOptions { + noTTYOutput?: boolean; + notTTYSchedule?: number; + terminal?: CapturedTerminal; +} + +const mocks = vi.hoisted(() => ({ + runFullAnalysisMock: vi.fn(), + capturedBarOptions: [] as CapturedBarOptions[], +})); + +vi.mock('cli-progress', () => ({ + default: { + SingleBar: vi.fn(function (options: CapturedBarOptions) { + mocks.capturedBarOptions.push(options); + return { + start: vi.fn(), + update: vi.fn(), + stop: vi.fn(), + }; + }), + Presets: { shades_grey: {} }, + }, +})); + +vi.mock('../../src/core/run-analyze.js', () => ({ + runFullAnalysis: mocks.runFullAnalysisMock, +})); + +vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({ + closeLbug: vi.fn(async () => undefined), +})); + +vi.mock('../../src/storage/repo-manager.js', () => ({ + getStoragePaths: vi.fn(() => ({ storagePath: '.gitnexus', lbugPath: '.gitnexus/lbug' })), + getGlobalRegistryPath: vi.fn(() => 'registry.json'), + RegistryNameCollisionError: class RegistryNameCollisionError extends Error {}, + AnalysisNotFinalizedError: class AnalysisNotFinalizedError extends Error {}, + assertAnalysisFinalized: vi.fn(async () => undefined), +})); + +vi.mock('../../src/storage/git.js', () => ({ + getGitRoot: vi.fn(() => '/repo'), + hasGitDir: vi.fn(() => true), +})); + +vi.mock('../../src/core/ingestion/utils/max-file-size.js', () => ({ + getMaxFileSizeBannerMessage: vi.fn(() => null), +})); + +const setStreamIsTTY = (stream: NodeJS.WriteStream, value: boolean): (() => void) => { + const descriptor = Object.getOwnPropertyDescriptor(stream, 'isTTY'); + Object.defineProperty(stream, 'isTTY', { configurable: true, value }); + return () => { + if (descriptor) Object.defineProperty(stream, 'isTTY', descriptor); + else delete (stream as NodeJS.WriteStream & { isTTY?: boolean }).isTTY; + }; +}; + +describe('analyzeCommand respawn progress terminal bridge', () => { + const ORIGINAL_NODE_OPTIONS = process.env.NODE_OPTIONS; + const ORIGINAL_RESPAWN_PROGRESS = process.env.GITNEXUS_RESPAWN_PROGRESS_TTY; + const ORIGINAL_COLUMNS = process.env.COLUMNS; + let restoreStderrIsTTY: (() => void) | undefined; + let stdoutWriteSpy: ReturnType; + let stderrWriteSpy: ReturnType; + + beforeEach(() => { + vi.resetModules(); + mocks.runFullAnalysisMock.mockReset(); + mocks.capturedBarOptions.length = 0; + mocks.runFullAnalysisMock.mockResolvedValue({ + repoName: 'repo', + repoPath: '/repo', + stats: {}, + alreadyUpToDate: true, + }); + process.exitCode = undefined; + process.env.NODE_OPTIONS = '--max-old-space-size=8192'; + process.env.GITNEXUS_RESPAWN_PROGRESS_TTY = '1'; + restoreStderrIsTTY = setStreamIsTTY(process.stderr, false); + stdoutWriteSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + stderrWriteSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + stdoutWriteSpy.mockRestore(); + stderrWriteSpy.mockRestore(); + restoreStderrIsTTY?.(); + restoreStderrIsTTY = undefined; + if (ORIGINAL_NODE_OPTIONS === undefined) delete process.env.NODE_OPTIONS; + else process.env.NODE_OPTIONS = ORIGINAL_NODE_OPTIONS; + if (ORIGINAL_RESPAWN_PROGRESS === undefined) delete process.env.GITNEXUS_RESPAWN_PROGRESS_TTY; + else process.env.GITNEXUS_RESPAWN_PROGRESS_TTY = ORIGINAL_RESPAWN_PROGRESS; + if (ORIGINAL_COLUMNS === undefined) delete process.env.COLUMNS; + else process.env.COLUMNS = ORIGINAL_COLUMNS; + }); + + it('uses an ANSI terminal shim instead of cli-progress non-TTY newline mode', async () => { + const { analyzeCommand } = await import('../../src/cli/analyze.js'); + + await analyzeCommand(undefined, {}); + + expect(mocks.capturedBarOptions).toHaveLength(1); + const options = mocks.capturedBarOptions[0]; + expect(options.noTTYOutput).toBeUndefined(); + expect(options.notTTYSchedule).toBeUndefined(); + expect(options.terminal).toBeDefined(); + expect(options.terminal.isTTY()).toBe(true); + + options.terminal.cursorTo(0, null); + options.terminal.clearRight(); + options.terminal.newline(); + expect(stderrWriteSpy).toHaveBeenCalledWith('\r'); + expect(stderrWriteSpy).toHaveBeenCalledWith('\x1B[0K'); + expect(stderrWriteSpy).toHaveBeenCalledWith('\n'); + }); + + it('truncates wrapped progress writes without splitting ANSI escapes or surrogate pairs', async () => { + process.env.COLUMNS = '3'; + const { analyzeCommand } = await import('../../src/cli/analyze.js'); + + await analyzeCommand(undefined, {}); + + const options = mocks.capturedBarOptions[0]; + options.terminal.write('ab\x1B[31mcd'); + expect(stderrWriteSpy).toHaveBeenLastCalledWith('ab\x1B[31mc'); + + process.env.COLUMNS = '4'; + options.terminal.write('abc😀def'); + expect(stderrWriteSpy).toHaveBeenLastCalledWith('abc'); + + options.terminal.write('abc😀def', true); + expect(stderrWriteSpy).toHaveBeenLastCalledWith('abc😀def'); + }); +}); diff --git a/gitnexus/test/unit/cli-commands.test.ts b/gitnexus/test/unit/cli-commands.test.ts index a42afb25c..e930f2c9c 100644 --- a/gitnexus/test/unit/cli-commands.test.ts +++ b/gitnexus/test/unit/cli-commands.test.ts @@ -45,24 +45,26 @@ describe('CLI commands', () => { }); describe('optional parser dependencies', () => { - it('uses vendored source for tree-sitter-dart instead of a remote dependency', async () => { + it('materializes vendored grammars at postinstall instead of file: optionalDependencies (#1728)', async () => { const pkg = await import('../../package.json', { with: { type: 'json' } }); - expect(pkg.default.optionalDependencies['tree-sitter-dart']).toBe( - 'file:./vendor/tree-sitter-dart', - ); + const optional = pkg.default.optionalDependencies ?? {}; + expect(optional['tree-sitter-dart']).toBeUndefined(); + expect(optional['tree-sitter-proto']).toBeUndefined(); + expect(optional['tree-sitter-swift']).toBeUndefined(); + expect(pkg.default.scripts.postinstall).toContain('materialize-vendor-grammars.cjs'); + expect(pkg.default.files).toContain('vendor'); }); - it('uses the vendored official Swift runtime package instead of source-building on install', async () => { + it('keeps vendored Swift runtime with prebuilds and hoisted activation script', async () => { const pkg = await import('../../package.json', { with: { type: 'json' } }); const swiftPkg = await import('../../vendor/tree-sitter-swift/package.json', { with: { type: 'json' }, }); expect(pkg.default.dependencies['tree-sitter']).toBe('^0.21.1'); - expect(pkg.default.optionalDependencies['tree-sitter-swift']).toBe( - 'file:./vendor/tree-sitter-swift', - ); - expect(pkg.default.scripts.postinstall).not.toContain('tree-sitter-swift'); + expect(pkg.default.scripts.postinstall).toContain('build-tree-sitter-swift.cjs'); expect(swiftPkg.default.version).toBe('0.7.1'); + expect(swiftPkg.default.scripts?.install).toBeUndefined(); + expect(swiftPkg.default.dependencies).toBeUndefined(); expect(swiftPkg.default.peerDependencies['tree-sitter']).toContain('^0.21.1'); }); }); diff --git a/gitnexus/test/unit/lbug-adapter-wal-schema.test.ts b/gitnexus/test/unit/lbug-adapter-wal-schema.test.ts index c5f4b6773..2cd1b1ed9 100644 --- a/gitnexus/test/unit/lbug-adapter-wal-schema.test.ts +++ b/gitnexus/test/unit/lbug-adapter-wal-schema.test.ts @@ -41,6 +41,7 @@ function makeFsMock(dbPath: string) { throw ENOENT; }), unlink: vi.fn(async () => {}), + rename: vi.fn(async () => {}), mkdir: vi.fn(async () => {}), open: makeOpenMock(), }, @@ -78,8 +79,8 @@ describe('doInitLbug WAL corruption guard — structural', () => { expect(schemaLoopBody).toMatch(/await safeClose\(\)/); }); - it('WAL guard resets currentDbPath to null', () => { - expect(schemaLoopBody).toMatch(/currentDbPath = null/); + it('WAL guard resets open connection state', () => { + expect(schemaLoopBody).toMatch(/resetOpenConnectionState\(\)/); }); it('WAL guard throws with WAL_RECOVERY_SUGGESTION in the message', () => { @@ -211,6 +212,284 @@ describe('doInitLbug WAL corruption guard — behavioural', () => { await adapter.closeLbug(); }); + it('quarantines the WAL and retries writable schema creation when shadow sidecar is missing', async () => { + vi.resetModules(); + + const dbPath = '/tmp/gitnexus-lbug-writable-shadow-missing/lbug'; + const missingShadowError = new Error( + `IO exception: Cannot open file ${dbPath}.shadow: No such file or directory`, + ); + const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() }; + const firstConn = { + query: vi.fn().mockRejectedValueOnce(missingShadowError).mockResolvedValue(queryResult), + close: vi.fn(async () => {}), + }; + const firstDb = { close: vi.fn(async () => {}) }; + const recoveredConn = { + query: vi.fn(async () => queryResult), + close: vi.fn(async () => {}), + }; + const recoveredDb = { close: vi.fn(async () => {}) }; + const openLbugConnectionMock = vi + .fn() + .mockResolvedValueOnce({ db: firstDb, conn: firstConn }) + .mockResolvedValueOnce({ db: recoveredDb, conn: recoveredConn }); + const fsMock = makeFsMock(dbPath); + const ensureMock = vi.fn(async () => false); + const warnMock = vi.fn(); + + vi.doMock('fs/promises', () => fsMock); + vi.doMock('../../src/core/lbug/schema.js', () => SCHEMA_MOCK); + vi.doMock('../../src/core/lbug/lbug-config.js', () => ({ + openLbugConnection: openLbugConnectionMock, + closeLbugConnection: async (handle: { conn: typeof firstConn; db: typeof firstDb }) => { + await handle.conn.close(); + await handle.db.close(); + }, + isDbBusyError: vi.fn(() => false), + isOpenRetryExhausted: vi.fn(() => false), + isWalCorruptionError: vi.fn(() => false), + WAL_RECOVERY_SUGGESTION: + 'WAL corruption detected. Run `gitnexus analyze --force` to rebuild the index.', + waitForWindowsHandleRelease: vi.fn(async () => true), + })); + vi.doMock('../../src/core/lbug/extension-loader.js', () => ({ + extensionManager: { + ensure: ensureMock, + getCapabilities: vi.fn(() => []), + reset: vi.fn(), + }, + })); + vi.doMock('../../src/core/logger.js', () => ({ + logger: { warn: warnMock, info: vi.fn(), error: vi.fn(), debug: vi.fn() }, + })); + + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + + await expect(adapter.initLbug(dbPath)).resolves.toBeDefined(); + + expect(openLbugConnectionMock).toHaveBeenCalledTimes(2); + expect(fsMock.default.rename).toHaveBeenCalledWith( + `${dbPath}.wal`, + expect.stringContaining(`${dbPath}.wal.missing-shadow.`), + ); + expect(recoveredConn.query).toHaveBeenCalledWith(SCHEMA_MOCK.SCHEMA_QUERIES[0]); + expect(warnMock).not.toHaveBeenCalledWith(expect.stringContaining('Schema creation warning')); + + await adapter.closeLbug(); + }); + + it('skips schema DDL and uses load-only FTS policy for read-only opens', async () => { + vi.resetModules(); + + const dbPath = '/tmp/gitnexus-lbug-readonly-schema-skip/lbug'; + const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() }; + const conn = { + query: vi.fn(async () => queryResult), + close: vi.fn(async () => {}), + }; + const db = { close: vi.fn(async () => {}) }; + const openLbugConnectionMock = vi.fn(async () => ({ db, conn })); + const ensureMock = vi.fn(async () => false); + const warnMock = vi.fn(); + + vi.doMock('fs/promises', () => makeFsMock(dbPath)); + vi.doMock('../../src/core/lbug/schema.js', () => SCHEMA_MOCK); + vi.doMock('../../src/core/lbug/lbug-config.js', () => ({ + openLbugConnection: openLbugConnectionMock, + closeLbugConnection: vi.fn(async () => {}), + isDbBusyError: vi.fn(() => false), + isOpenRetryExhausted: vi.fn(() => false), + isWalCorruptionError: vi.fn(() => false), + WAL_RECOVERY_SUGGESTION: + 'WAL corruption detected. Run `gitnexus analyze --force` to rebuild the index.', + waitForWindowsHandleRelease: vi.fn(async () => true), + })); + vi.doMock('../../src/core/lbug/extension-loader.js', () => ({ + extensionManager: { + ensure: ensureMock, + getCapabilities: vi.fn(() => []), + reset: vi.fn(), + }, + })); + vi.doMock('../../src/core/logger.js', () => ({ + logger: { warn: warnMock, info: vi.fn(), error: vi.fn(), debug: vi.fn() }, + })); + + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + + await expect(adapter.withLbugDb(dbPath, async () => 'ok', { readOnly: true })).resolves.toBe( + 'ok', + ); + + expect(openLbugConnectionMock).toHaveBeenCalledWith(expect.anything(), dbPath, { + readOnly: true, + }); + expect(conn.query).not.toHaveBeenCalledWith(SCHEMA_MOCK.SCHEMA_QUERIES[0]); + expect(ensureMock).toHaveBeenCalledWith(expect.any(Function), 'fts', 'FTS', { + policy: 'load-only', + }); + expect(warnMock).not.toHaveBeenCalledWith(expect.stringContaining('Schema creation warning')); + + await adapter.closeLbug(); + }); + + it('replays dirty shadow pages with a temporary writable open before read-only serving', async () => { + vi.resetModules(); + + const dbPath = '/tmp/gitnexus-lbug-readonly-shadow-replay/lbug'; + const shadowReplayError = new Error( + "Runtime exception: Couldn't replay shadow pages under read-only mode. Please re-open the database with read-write mode to replay shadow pages.", + ); + const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() }; + const readOnlyConn1 = { + query: vi.fn().mockRejectedValueOnce(shadowReplayError), + close: vi.fn(async () => {}), + }; + const readOnlyDb1 = { close: vi.fn(async () => {}) }; + const writableConn = { + query: vi.fn(async () => queryResult), + close: vi.fn(async () => {}), + }; + const writableDb = { close: vi.fn(async () => {}) }; + const readOnlyConn2 = { + query: vi.fn(async () => queryResult), + close: vi.fn(async () => {}), + }; + const readOnlyDb2 = { close: vi.fn(async () => {}) }; + const openLbugConnectionMock = vi + .fn() + .mockResolvedValueOnce({ db: readOnlyDb1, conn: readOnlyConn1 }) + .mockResolvedValueOnce({ db: writableDb, conn: writableConn }) + .mockResolvedValueOnce({ db: readOnlyDb2, conn: readOnlyConn2 }); + const ensureMock = vi.fn(async () => false); + + vi.doMock('fs/promises', () => makeFsMock(dbPath)); + vi.doMock('../../src/core/lbug/schema.js', () => SCHEMA_MOCK); + vi.doMock('../../src/core/lbug/lbug-config.js', () => ({ + openLbugConnection: openLbugConnectionMock, + closeLbugConnection: async (handle: { + conn: typeof readOnlyConn1; + db: typeof readOnlyDb1; + }) => { + await handle.conn.close(); + await handle.db.close(); + }, + isDbBusyError: vi.fn(() => false), + isOpenRetryExhausted: vi.fn(() => false), + isWalCorruptionError: vi.fn(() => false), + WAL_RECOVERY_SUGGESTION: + 'WAL corruption detected. Run `gitnexus analyze --force` to rebuild the index.', + waitForWindowsHandleRelease: vi.fn(async () => true), + })); + vi.doMock('../../src/core/lbug/extension-loader.js', () => ({ + extensionManager: { + ensure: ensureMock, + getCapabilities: vi.fn(() => []), + reset: vi.fn(), + }, + })); + vi.doMock('../../src/core/logger.js', () => ({ + logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() }, + })); + + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + + await expect(adapter.withLbugDb(dbPath, async () => 'ok', { readOnly: true })).resolves.toBe( + 'ok', + ); + + expect(openLbugConnectionMock).toHaveBeenNthCalledWith(1, expect.anything(), dbPath, { + readOnly: true, + }); + expect(openLbugConnectionMock).toHaveBeenNthCalledWith(2, expect.anything(), dbPath); + expect(openLbugConnectionMock).toHaveBeenNthCalledWith(3, expect.anything(), dbPath, { + readOnly: true, + }); + expect(readOnlyConn1.close).toHaveBeenCalled(); + expect(readOnlyDb1.close).toHaveBeenCalled(); + expect(writableConn.query).toHaveBeenCalledWith('MATCH (n) RETURN n LIMIT 1'); + expect(writableConn.close).toHaveBeenCalled(); + expect(writableDb.close).toHaveBeenCalled(); + expect(readOnlyConn2.query).toHaveBeenCalledWith('MATCH (n) RETURN n LIMIT 1'); + expect(ensureMock).toHaveBeenCalledWith(expect.any(Function), 'fts', 'FTS', { + policy: 'load-only', + }); + + await adapter.closeLbug(); + }); + + it('quarantines the WAL and reopens read-only when the shadow sidecar is missing', async () => { + vi.resetModules(); + + const dbPath = '/tmp/gitnexus-lbug-readonly-shadow-missing/lbug'; + const missingShadowError = new Error( + `IO exception: Cannot open file ${dbPath}.shadow: No such file or directory`, + ); + const readOnlyConn = { + query: vi.fn().mockRejectedValueOnce(missingShadowError), + close: vi.fn(async () => {}), + }; + const readOnlyDb = { close: vi.fn(async () => {}) }; + const recoveredConn = { + query: vi.fn(async () => ({ getAll: vi.fn(async () => []), close: vi.fn() })), + close: vi.fn(async () => {}), + }; + const recoveredDb = { close: vi.fn(async () => {}) }; + const openLbugConnectionMock = vi + .fn() + .mockResolvedValueOnce({ + db: readOnlyDb, + conn: readOnlyConn, + }) + .mockResolvedValueOnce({ + db: recoveredDb, + conn: recoveredConn, + }); + const fsMock = makeFsMock(dbPath); + + vi.doMock('fs/promises', () => fsMock); + vi.doMock('../../src/core/lbug/schema.js', () => SCHEMA_MOCK); + vi.doMock('../../src/core/lbug/lbug-config.js', () => ({ + openLbugConnection: openLbugConnectionMock, + closeLbugConnection: async (handle: { conn: typeof readOnlyConn; db: typeof readOnlyDb }) => { + await handle.conn.close(); + await handle.db.close(); + }, + isDbBusyError: vi.fn(() => false), + isOpenRetryExhausted: vi.fn(() => false), + isWalCorruptionError: vi.fn(() => false), + WAL_RECOVERY_SUGGESTION: + 'WAL corruption detected. Run `gitnexus analyze --force` to rebuild the index.', + waitForWindowsHandleRelease: vi.fn(async () => true), + })); + vi.doMock('../../src/core/lbug/extension-loader.js', () => ({ + extensionManager: { + ensure: vi.fn(async () => false), + getCapabilities: vi.fn(() => []), + reset: vi.fn(), + }, + })); + vi.doMock('../../src/core/logger.js', () => ({ + logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() }, + })); + + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + + await expect(adapter.withLbugDb(dbPath, async () => 'ok', { readOnly: true })).resolves.toBe( + 'ok', + ); + expect(openLbugConnectionMock).toHaveBeenCalledTimes(2); + expect(readOnlyConn.close).toHaveBeenCalled(); + expect(readOnlyDb.close).toHaveBeenCalled(); + expect(fsMock.default.rename).toHaveBeenCalledWith( + `${dbPath}.wal`, + expect.stringContaining(`${dbPath}.wal.missing-shadow.`), + ); + + await adapter.closeLbug(); + }); + it('calls safeClose() (db.close) when WAL corruption is detected mid-schema', async () => { vi.resetModules(); @@ -257,3 +536,181 @@ describe('doInitLbug WAL corruption guard — behavioural', () => { expect(db.close).toHaveBeenCalled(); }); }); + +// ─── Symmetric WAL-size gate (PR #1747 review, D2) ────────────────────────── +// +// Both reopenWritableAfterMissingShadow and reopenReadOnlyAfterMissingShadow +// must refuse to quarantine a WAL larger than TINY_ORPHAN_WAL_BYTES (4096). +// The pre-PR behavior silently quarantined any size of WAL during recovery — +// on the read-only path this could permanently orphan uncheckpointed pages +// because a later writable open would see a `clean` state and never replay. + +const TINY_ORPHAN_WAL_BYTES_TEST = 4 * 1024; + +/** + * Variant of makeFsMock where the `.wal` path is classified by + * inspectLbugSidecars based on a chosen size. Use to drive the + * `orphan-wal` vs `tiny-orphan-wal` branches of refuseLargeWalQuarantine + * without spinning up real files. + */ +function makeFsMockWithWalSize(dbPath: string, walBytes: number | 'missing') { + const ENOENT = Object.assign(new Error(`ENOENT: ${dbPath}`), { code: 'ENOENT' }); + const isWal = (p: string): boolean => p === `${dbPath}.wal`; + const isShadow = (p: string): boolean => p === `${dbPath}.shadow`; + return { + default: { + lstat: vi.fn(async () => { + throw ENOENT; + }), + access: vi.fn(async (p: string) => { + if (isWal(p) && walBytes !== 'missing') return; + throw ENOENT; + }), + stat: vi.fn(async (p: string) => { + if (isWal(p)) { + if (walBytes === 'missing') throw ENOENT; + return { size: walBytes }; + } + if (isShadow(p)) throw ENOENT; + return { size: 0 }; + }), + unlink: vi.fn(async () => {}), + rename: vi.fn(async () => {}), + mkdir: vi.fn(async () => {}), + open: makeOpenMock(), + }, + }; +} + +describe('Symmetric WAL-size gate during missing-shadow recovery (PR #1747 D2)', () => { + afterEach(() => { + vi.resetModules(); + vi.unstubAllEnvs(); + }); + + const setupShadowMissingRecovery = (dbPath: string, walBytes: number | 'missing') => { + const missingShadowError = new Error( + `IO exception: Cannot open file ${dbPath}.shadow: No such file or directory`, + ); + const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() }; + const firstConn = { + query: vi.fn().mockRejectedValueOnce(missingShadowError).mockResolvedValue(queryResult), + close: vi.fn(async () => {}), + }; + const firstDb = { close: vi.fn(async () => {}) }; + const recoveredConn = { + query: vi.fn(async () => queryResult), + close: vi.fn(async () => {}), + }; + const recoveredDb = { close: vi.fn(async () => {}) }; + const openLbugConnectionMock = vi + .fn() + .mockResolvedValueOnce({ db: firstDb, conn: firstConn }) + .mockResolvedValueOnce({ db: recoveredDb, conn: recoveredConn }); + const fsMock = makeFsMockWithWalSize(dbPath, walBytes); + const warnMock = vi.fn(); + + vi.doMock('fs/promises', () => fsMock); + vi.doMock('../../src/core/lbug/schema.js', () => SCHEMA_MOCK); + vi.doMock('../../src/core/lbug/lbug-config.js', () => ({ + openLbugConnection: openLbugConnectionMock, + closeLbugConnection: async (handle: { conn: typeof firstConn; db: typeof firstDb }) => { + await handle.conn.close(); + await handle.db.close(); + }, + isDbBusyError: vi.fn(() => false), + isOpenRetryExhausted: vi.fn(() => false), + isWalCorruptionError: vi.fn(() => false), + WAL_RECOVERY_SUGGESTION: + 'WAL corruption detected. Run `gitnexus analyze --force` to rebuild the index.', + waitForWindowsHandleRelease: vi.fn(async () => true), + })); + vi.doMock('../../src/core/lbug/extension-loader.js', () => ({ + extensionManager: { + ensure: vi.fn(async () => false), + getCapabilities: vi.fn(() => []), + reset: vi.fn(), + }, + })); + vi.doMock('../../src/core/logger.js', () => ({ + logger: { warn: warnMock, info: vi.fn(), error: vi.fn(), debug: vi.fn() }, + })); + + return { fsMock, openLbugConnectionMock, warnMock }; + }; + + it('writable recovery: refuses to quarantine a large WAL (4097 bytes) and throws shadow-recovery message', async () => { + vi.resetModules(); + const dbPath = '/tmp/gitnexus-lbug-large-wal-writable/lbug'; + const { fsMock, warnMock } = setupShadowMissingRecovery(dbPath, TINY_ORPHAN_WAL_BYTES_TEST + 1); + + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + + await expect(adapter.initLbug(dbPath)).rejects.toThrow( + /LadybugDB checkpoint sidecar is missing/, + ); + expect(fsMock.default.rename).not.toHaveBeenCalled(); + expect(warnMock).toHaveBeenCalledWith( + expect.stringContaining('refusing to quarantine large WAL'), + ); + expect(warnMock).toHaveBeenCalledWith(expect.stringContaining('writable recovery')); + }); + + it('read-only recovery: refuses to quarantine a large WAL (4097 bytes) and throws shadow-recovery message', async () => { + vi.resetModules(); + const dbPath = '/tmp/gitnexus-lbug-large-wal-readonly/lbug'; + const { fsMock, warnMock } = setupShadowMissingRecovery(dbPath, TINY_ORPHAN_WAL_BYTES_TEST + 1); + + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + + await expect( + adapter.withLbugDb(dbPath, async () => 'unreached', { readOnly: true }), + ).rejects.toThrow(/LadybugDB checkpoint sidecar is missing/); + expect(fsMock.default.rename).not.toHaveBeenCalled(); + expect(warnMock).toHaveBeenCalledWith( + expect.stringContaining('refusing to quarantine large WAL'), + ); + expect(warnMock).toHaveBeenCalledWith(expect.stringContaining('read-only recovery')); + }); + + it('writable recovery: WAL at exactly TINY_ORPHAN_WAL_BYTES (4096 bytes) is treated as tiny and quarantined', async () => { + vi.resetModules(); + const dbPath = '/tmp/gitnexus-lbug-boundary-tiny/lbug'; + const { fsMock } = setupShadowMissingRecovery(dbPath, TINY_ORPHAN_WAL_BYTES_TEST); + + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + + await expect(adapter.initLbug(dbPath)).resolves.toBeDefined(); + expect(fsMock.default.rename).toHaveBeenCalledWith( + `${dbPath}.wal`, + expect.stringContaining(`${dbPath}.wal.missing-shadow.`), + ); + await adapter.closeLbug(); + }); + + it('writable recovery: WAL at TINY_ORPHAN_WAL_BYTES + 1 (4097 bytes) is treated as orphan-wal and refused', async () => { + vi.resetModules(); + const dbPath = '/tmp/gitnexus-lbug-boundary-large/lbug'; + const { fsMock } = setupShadowMissingRecovery(dbPath, TINY_ORPHAN_WAL_BYTES_TEST + 1); + + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + + await expect(adapter.initLbug(dbPath)).rejects.toThrow(); + expect(fsMock.default.rename).not.toHaveBeenCalled(); + }); + + it('tiny-WAL recovery path: writable recovery still quarantines and proceeds for a 1024-byte WAL', async () => { + vi.resetModules(); + const dbPath = '/tmp/gitnexus-lbug-tiny-wal/lbug'; + const { fsMock } = setupShadowMissingRecovery(dbPath, 1024); + + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + + await expect(adapter.initLbug(dbPath)).resolves.toBeDefined(); + expect(fsMock.default.rename).toHaveBeenCalledWith( + `${dbPath}.wal`, + expect.stringContaining(`${dbPath}.wal.missing-shadow.`), + ); + await adapter.closeLbug(); + }); +}); diff --git a/gitnexus/test/unit/parse-impl-worker-lazy-cache.test.ts b/gitnexus/test/unit/parse-impl-worker-lazy-cache.test.ts new file mode 100644 index 000000000..44242b02d --- /dev/null +++ b/gitnexus/test/unit/parse-impl-worker-lazy-cache.test.ts @@ -0,0 +1,244 @@ +/** + * Regression coverage for native-worker startup on warm parse-cache runs. + * + * A cache-hit chunk must replay cached worker output without spawning the + * parse-worker. Spawning workers on a warm cache hit still loads tree-sitter + * native bindings at top level, which was the root trigger for intermittent + * `libc++abi ... Napi::Error` crashes in linked local builds. + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; +import { runChunkedParseAndResolve } from '../../src/core/ingestion/pipeline-phases/parse-impl.js'; +import { computeChunkHash, fileContentHash } from '../../src/storage/parse-cache.js'; +import type { ParseWorkerResult } from '../../src/core/ingestion/workers/parse-worker.js'; + +const emptyWorkerResult = (filePath: string, name: string): ParseWorkerResult => ({ + nodes: [ + { + id: `Function:${filePath}:${name}`, + label: 'Function', + properties: { + name, + filePath, + startLine: 1, + endLine: 1, + language: 'typescript', + }, + }, + ], + relationships: [], + symbols: [], + imports: [], + calls: [], + assignments: [], + heritage: [], + routes: [], + fetchCalls: [], + decoratorRoutes: [], + toolDefs: [], + ormQueries: [], + constructorBindings: [], + fileScopeBindings: [], + parsedFiles: [], + skippedLanguages: {}, + fileCount: 1, +}); + +const writeReadyWorker = (workerPath: string, markerPath: string): void => { + fs.writeFileSync( + workerPath, + ` +const fs = require('node:fs'); +const { parentPort } = require('node:worker_threads'); +fs.writeFileSync(${JSON.stringify(markerPath)}, 'spawned'); +parentPort.postMessage({ type: 'ready' }); +parentPort.on('message', () => {}); +`, + ); +}; + +const writeResultWorker = (workerPath: string, markerPath: string): void => { + fs.writeFileSync( + workerPath, + ` +const fs = require('node:fs'); +const { parentPort } = require('node:worker_threads'); +const decoder = new TextDecoder('utf-8'); +fs.writeFileSync(${JSON.stringify(markerPath)}, 'spawned'); +parentPort.postMessage({ type: 'ready' }); +const accumulated = { + nodes: [], relationships: [], symbols: [], imports: [], calls: [], assignments: [], heritage: [], + routes: [], fetchCalls: [], decoratorRoutes: [], toolDefs: [], ormQueries: [], constructorBindings: [], + fileScopeBindings: [], parsedFiles: [], skippedLanguages: {}, fileCount: 0, +}; +parentPort.on('message', (msg) => { + if (msg && msg.type === 'sub-batch') { + for (const file of msg.files) { + const filePath = file.path; + const name = filePath.split('/').pop().replace(/\\.ts$/, ''); + accumulated.nodes.push({ + id: 'Function:' + filePath + ':' + name, + label: 'Function', + properties: { name, filePath, startLine: 1, endLine: 1, language: 'typescript' }, + }); + accumulated.fileCount++; + // Decode to exercise the same transfer-list shape as production. + if (file.content && typeof file.content !== 'string') decoder.decode(file.content); + } + parentPort.postMessage({ type: 'progress', filesProcessed: accumulated.fileCount }); + parentPort.postMessage({ type: 'sub-batch-done' }); + return; + } + if (msg && msg.type === 'flush') parentPort.postMessage({ type: 'result', data: accumulated }); +}); +`, + ); +}; + +const writeExitBeforeReadyWorker = (workerPath: string): void => { + fs.writeFileSync(workerPath, `process.exit(1);\n`); +}; + +describe('parse-impl worker pool lazy startup', () => { + let tempDir = ''; + let repoDir = ''; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'parse-impl-worker-lazy-cache-')); + repoDir = path.join(tempDir, 'repo'); + fs.mkdirSync(repoDir, { recursive: true }); + }); + + afterEach(() => { + if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('does not spawn a parse worker when every chunk is served from parse cache', async () => { + const rel = 'src/cached.ts'; + const content = 'export function cached() { return 1; }\n'; + const full = path.join(repoDir, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + + const chunkHash = computeChunkHash([{ filePath: rel, contentHash: fileContentHash(content) }]); + const parseCache = { + version: 'test', + entries: new Map([ + [chunkHash, [emptyWorkerResult(rel, 'cached')]], + ]), + usedKeys: new Set(), + }; + + const markerPath = path.join(tempDir, 'worker-spawned.marker'); + const workerPath = path.join(tempDir, 'ready-worker.js'); + writeReadyWorker(workerPath, markerPath); + + const graph = createKnowledgeGraph(); + await runChunkedParseAndResolve( + graph, + [{ path: rel, size: fs.statSync(full).size }], + [rel], + 1, + repoDir, + Date.now(), + () => {}, + { + workerThresholdsForTest: { minFiles: 1, minBytes: 1 }, + workerUrlForTest: pathToFileURL(workerPath), + workerPoolSize: 1, + parseCache, + }, + ); + + expect(fs.existsSync(markerPath)).toBe(false); + expect(parseCache.usedKeys.has(chunkHash)).toBe(true); + expect(Array.from(graph.nodes.values()).some((n) => n.properties.name === 'cached')).toBe(true); + }); + + it('spawns the parse worker lazily on the first cache miss and stores raw results', async () => { + const rel = 'src/miss.ts'; + const content = 'export function miss() { return 1; }\n'; + const full = path.join(repoDir, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + + const markerPath = path.join(tempDir, 'worker-spawned.marker'); + const workerPath = path.join(tempDir, 'result-worker.js'); + writeResultWorker(workerPath, markerPath); + + const parseCache = { + version: 'test', + entries: new Map(), + usedKeys: new Set(), + }; + const chunkHash = computeChunkHash([{ filePath: rel, contentHash: fileContentHash(content) }]); + + const graph = createKnowledgeGraph(); + await runChunkedParseAndResolve( + graph, + [{ path: rel, size: fs.statSync(full).size }], + [rel], + 1, + repoDir, + Date.now(), + () => {}, + { + workerThresholdsForTest: { minFiles: 1, minBytes: 1 }, + workerUrlForTest: pathToFileURL(workerPath), + workerPoolSize: 1, + parseCache, + }, + ); + + expect(fs.existsSync(markerPath)).toBe(true); + expect(parseCache.entries.has(chunkHash)).toBe(true); + expect(Array.from(graph.nodes.values()).some((n) => n.properties.name === 'miss')).toBe(true); + }); + + it('falls back to sequential parsing when initial workers exit before ready', async () => { + const rel = 'src/fallback.ts'; + const content = 'export function fallback() { return 1; }\n'; + const full = path.join(repoDir, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + + const workerPath = path.join(tempDir, 'exit-before-ready-worker.js'); + writeExitBeforeReadyWorker(workerPath); + + const parseCache = { + version: 'test', + entries: new Map(), + usedKeys: new Set(), + }; + const chunkHash = computeChunkHash([{ filePath: rel, contentHash: fileContentHash(content) }]); + + const graph = createKnowledgeGraph(); + const result = await runChunkedParseAndResolve( + graph, + [{ path: rel, size: fs.statSync(full).size }], + [rel], + 1, + repoDir, + Date.now(), + () => {}, + { + workerThresholdsForTest: { minFiles: 1, minBytes: 1 }, + workerUrlForTest: pathToFileURL(workerPath), + workerPoolSize: 1, + parseCache, + }, + ); + + expect(result.usedWorkerPool).toBe(false); + expect(parseCache.usedKeys.has(chunkHash)).toBe(true); + expect(parseCache.entries.has(chunkHash)).toBe(false); + expect(Array.from(graph.nodes.values()).some((n) => n.properties.name === 'fallback')).toBe( + true, + ); + }); +}); diff --git a/gitnexus/test/unit/parsing-worker-fallback.test.ts b/gitnexus/test/unit/parsing-worker-fallback.test.ts index ec90b64b1..04aa413c2 100644 --- a/gitnexus/test/unit/parsing-worker-fallback.test.ts +++ b/gitnexus/test/unit/parsing-worker-fallback.test.ts @@ -143,3 +143,47 @@ describe('processParsing — worker-pool error propagation (U20)', () => { expect(progressDetails).toContain('1 worker-quarantined file(s) skipped'); }); }); + +describe('TypeScript object literal method exports', () => { + it('links exported object literal shorthand methods back to the exported object', async () => { + const graph = createKnowledgeGraph(); + + await processParsing( + graph, + [ + { + path: 'src/foo.ts', + content: `export const fooService = { + async getUser(id: string) { + return findUser(id); + }, + saveUser(user: User) { + return persist(user); + }, +}; +`, + }, + ], + createSymbolTable(), + createASTCache(), + createASTCache(), + ); + + const service = graph.nodes.find( + (node) => node.label === 'Const' && node.properties.name === 'fooService', + ); + expect(service, 'exported object literal should be captured as a Const').toBeDefined(); + + const methodNames = new Set( + graph.nodes.filter((node) => node.label === 'Method').map((node) => node.properties.name), + ); + expect(methodNames).toEqual(new Set(['getUser', 'saveUser'])); + + const linkedMethodNames = graph.relationships + .filter((rel) => rel.type === 'HAS_METHOD' && rel.sourceId === service!.id) + .map((rel) => graph.getNode(rel.targetId)?.properties.name) + .sort(); + + expect(linkedMethodNames).toEqual(['getUser', 'saveUser']); + }); +}); diff --git a/gitnexus/test/unit/pool-wal-recovery.test.ts b/gitnexus/test/unit/pool-wal-recovery.test.ts index cda42806a..647c80f1e 100644 --- a/gitnexus/test/unit/pool-wal-recovery.test.ts +++ b/gitnexus/test/unit/pool-wal-recovery.test.ts @@ -6,7 +6,8 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const { stderrWriteMock } = vi.hoisted(() => ({ +const { connectionQueryMock, stderrWriteMock } = vi.hoisted(() => ({ + connectionQueryMock: vi.fn(), stderrWriteMock: vi.fn(), })); @@ -23,6 +24,7 @@ vi.mock('@ladybugdb/core', () => ({ Database: vi.fn(), Connection: vi.fn(function (this: any) { this.close = vi.fn().mockResolvedValue(undefined); + this.query = connectionQueryMock; }), }, })); @@ -68,6 +70,11 @@ describe('WAL corruption recovery in doInitLbug (#1402)', () => { (fs.rename as any).mockReset(); mockInit.mockReset(); mockClose.mockReset(); + connectionQueryMock.mockReset(); + connectionQueryMock.mockResolvedValue({ + getAll: vi.fn().mockResolvedValue([]), + close: vi.fn(), + }); mockInit.mockResolvedValue(undefined); mockClose.mockResolvedValue(undefined); (fs.stat as any).mockResolvedValue({}); @@ -110,6 +117,79 @@ describe('WAL corruption recovery in doInitLbug (#1402)', () => { ); }); + it('replays shadow pages with a temporary writable open before pooling read-only DBs', async () => { + const { initLbug } = await import('../../src/core/lbug/pool-adapter.js'); + const dbPath = '/tmp/test-shadow-replay/lbug'; + + const readOnlyDb1 = makeMockDb(); + const writableDb = makeMockDb(); + const readOnlyDb2 = makeMockDb(); + connectionQueryMock + .mockRejectedValueOnce( + new Error( + "Runtime exception: Couldn't replay shadow pages under read-only mode. Please re-open the database with read-write mode to replay shadow pages.", + ), + ) + .mockResolvedValue({ + getAll: vi.fn().mockResolvedValue([]), + close: vi.fn(), + }); + (createLbugDatabase as any) + .mockReturnValueOnce(readOnlyDb1) + .mockReturnValueOnce(writableDb) + .mockReturnValueOnce(readOnlyDb2); + + await initLbug('test-repo-shadow-replay', dbPath); + + expect(createLbugDatabase).toHaveBeenNthCalledWith( + 1, + expect.anything(), + dbPath, + expect.objectContaining({ readOnly: true, throwOnWalReplayFailure: false }), + ); + expect(createLbugDatabase).toHaveBeenNthCalledWith( + 2, + expect.anything(), + dbPath, + expect.objectContaining({ throwOnWalReplayFailure: false }), + ); + expect(createLbugDatabase).toHaveBeenNthCalledWith( + 3, + expect.anything(), + dbPath, + expect.objectContaining({ readOnly: true, throwOnWalReplayFailure: false }), + ); + expect(readOnlyDb1.close).toHaveBeenCalled(); + expect(writableDb.close).toHaveBeenCalled(); + expect(fs.rename).not.toHaveBeenCalled(); + }); + + it('quarantines WAL and reopens read-only when the Ladybug shadow sidecar is missing', async () => { + const { initLbug } = await import('../../src/core/lbug/pool-adapter.js'); + const dbPath = '/tmp/test-shadow-missing/lbug'; + + const readOnlyDb1 = makeMockDb(); + const readOnlyDb2 = makeMockDb(); + connectionQueryMock + .mockRejectedValueOnce( + new Error(`IO exception: Cannot open file ${dbPath}.shadow: No such file or directory`), + ) + .mockResolvedValue({ + getAll: vi.fn().mockResolvedValue([]), + close: vi.fn(), + }); + (createLbugDatabase as any).mockReturnValueOnce(readOnlyDb1).mockReturnValueOnce(readOnlyDb2); + + await initLbug('test-repo-shadow-missing', dbPath); + + expect(createLbugDatabase).toHaveBeenCalledTimes(2); + expect(readOnlyDb1.close).toHaveBeenCalled(); + expect(fs.rename).toHaveBeenCalledWith( + dbPath + '.wal', + expect.stringContaining('.wal.missing-shadow.'), + ); + }); + it('does not quarantine on lock error (preserves existing lock retry)', async () => { const { initLbug } = await import('../../src/core/lbug/pool-adapter.js'); const setTimeoutSpy = vi.spyOn(global, 'setTimeout').mockImplementation((callback: any) => { @@ -177,3 +257,149 @@ describe('WAL corruption recovery in doInitLbug (#1402)', () => { await expect(initLbug('test-repo-enoent', dbPath)).rejects.toThrow(/gitnexus analyze/); }); }); + +describe('Pool-adapter missing-shadow quarantine: TOCTOU + permission classification (PR #1747 review)', () => { + beforeEach(() => { + (createLbugDatabase as any).mockReset(); + (fs.stat as any).mockReset(); + (fs.rename as any).mockReset(); + mockInit.mockReset(); + mockClose.mockReset(); + connectionQueryMock.mockReset(); + connectionQueryMock.mockResolvedValue({ + getAll: vi.fn().mockResolvedValue([]), + close: vi.fn(), + }); + mockInit.mockResolvedValue(undefined); + mockClose.mockResolvedValue(undefined); + (fs.stat as any).mockResolvedValue({ size: 128 }); + (fs.rename as any).mockResolvedValue(undefined); + }); + + afterEach(async () => { + vi.useRealTimers(); + await closeLbug().catch(() => {}); + vi.clearAllMocks(); + }); + + const enoent = (): NodeJS.ErrnoException => { + const e = new Error('ENOENT: peer already moved it') as NodeJS.ErrnoException; + e.code = 'ENOENT'; + return e; + }; + const fsErr = (code: string): NodeJS.ErrnoException => { + const e = new Error(`simulated ${code}`) as NodeJS.ErrnoException; + e.code = code; + return e; + }; + const shadowError = (dbPath: string): Error => + new Error(`IO exception: Cannot open file ${dbPath}.shadow: No such file or directory`); + + /** + * Make fs.stat ENOENT for the .wal path only — simulates "peer process + * already quarantined the WAL". Other paths (the main dbPath, .shadow) + * resolve normally so doInitLbug's existence check and preflight don't trip. + */ + const stubWalGoneAfterRename = (walPath: string): void => { + (fs.stat as any).mockImplementation((p: string) => { + if (p === walPath) return Promise.reject(enoent()); + return Promise.resolve({ size: 128 }); + }); + }; + + it('treats ENOENT on rename as peer-handled when WAL is confirmed gone (openReadOnlyDatabase)', async () => { + const { initLbug } = await import('../../src/core/lbug/pool-adapter.js'); + const dbPath = '/tmp/test-pool-enoent-race/lbug'; + + stubWalGoneAfterRename(`${dbPath}.wal`); + (fs.rename as any).mockRejectedValueOnce(enoent()); + + const readOnlyDb1 = makeMockDb(); + const readOnlyDb2 = makeMockDb(); + connectionQueryMock.mockRejectedValueOnce(shadowError(dbPath)).mockResolvedValue({ + getAll: vi.fn().mockResolvedValue([]), + close: vi.fn(), + }); + (createLbugDatabase as any).mockReturnValueOnce(readOnlyDb1).mockReturnValueOnce(readOnlyDb2); + + await initLbug('test-repo-pool-enoent', dbPath); + + expect(createLbugDatabase).toHaveBeenCalledTimes(2); + expect(readOnlyDb1.close).toHaveBeenCalled(); + expect(fs.rename).toHaveBeenCalledWith( + `${dbPath}.wal`, + expect.stringContaining('.wal.missing-shadow.'), + ); + }); + + it('classifies EACCES on rename with permission-specific message (openReadOnlyDatabase)', async () => { + const { initLbug } = await import('../../src/core/lbug/pool-adapter.js'); + const dbPath = '/tmp/test-pool-eacces/lbug'; + + (fs.rename as any).mockRejectedValueOnce(fsErr('EACCES')); + + const readOnlyDb1 = makeMockDb(); + connectionQueryMock.mockRejectedValueOnce(shadowError(dbPath)); + (createLbugDatabase as any).mockReturnValueOnce(readOnlyDb1); + + await expect(initLbug('test-repo-pool-eacces', dbPath)).rejects.toThrow( + /EACCES.*permission|permission.*EACCES|file-lock.*EACCES|EACCES.*file-lock/s, + ); + }); + + it('classifies EPERM on rename with permission-specific message', async () => { + const { initLbug } = await import('../../src/core/lbug/pool-adapter.js'); + const dbPath = '/tmp/test-pool-eperm/lbug'; + + (fs.rename as any).mockRejectedValueOnce(fsErr('EPERM')); + + const readOnlyDb1 = makeMockDb(); + connectionQueryMock.mockRejectedValueOnce(shadowError(dbPath)); + (createLbugDatabase as any).mockReturnValueOnce(readOnlyDb1); + + await expect(initLbug('test-repo-pool-eperm', dbPath)).rejects.toThrow(/EPERM/); + }); + + it('classifies EBUSY on rename with permission-specific message (common on Windows under AV)', async () => { + const { initLbug } = await import('../../src/core/lbug/pool-adapter.js'); + const dbPath = '/tmp/test-pool-ebusy/lbug'; + + (fs.rename as any).mockRejectedValueOnce(fsErr('EBUSY')); + + const readOnlyDb1 = makeMockDb(); + connectionQueryMock.mockRejectedValueOnce(shadowError(dbPath)); + (createLbugDatabase as any).mockReturnValueOnce(readOnlyDb1); + + await expect(initLbug('test-repo-pool-ebusy', dbPath)).rejects.toThrow(/EBUSY/); + }); + + it('falls through to shadowSidecarRecoveryMessage for ENOSPC on rename', async () => { + const { initLbug } = await import('../../src/core/lbug/pool-adapter.js'); + const dbPath = '/tmp/test-pool-enospc/lbug'; + + (fs.rename as any).mockRejectedValueOnce(fsErr('ENOSPC')); + + const readOnlyDb1 = makeMockDb(); + connectionQueryMock.mockRejectedValueOnce(shadowError(dbPath)); + (createLbugDatabase as any).mockReturnValueOnce(readOnlyDb1); + + await expect(initLbug('test-repo-pool-enospc', dbPath)).rejects.toThrow(/Rebuild the index/); + }); + + it('defensive: ENOENT on rename but WAL still present → classified error (not silent peer-handled)', async () => { + const { initLbug } = await import('../../src/core/lbug/pool-adapter.js'); + const dbPath = '/tmp/test-pool-defensive/lbug'; + + // Note: NOT calling stubWalGoneAfterRename — fs.stat defaults to resolve. + (fs.rename as any).mockRejectedValueOnce(enoent()); + + const readOnlyDb1 = makeMockDb(); + connectionQueryMock.mockRejectedValueOnce(shadowError(dbPath)); + (createLbugDatabase as any).mockReturnValueOnce(readOnlyDb1); + + // ENOENT → defensive branch sees WAL still present → throws classified error. + // Since ENOENT does not match permission codes, classifier falls through to + // shadowSidecarRecoveryMessage. + await expect(initLbug('test-repo-pool-defensive', dbPath)).rejects.toThrow(/Rebuild the index/); + }); +}); diff --git a/gitnexus/test/unit/sidecar-recovery.test.ts b/gitnexus/test/unit/sidecar-recovery.test.ts new file mode 100644 index 000000000..c0a2c4752 --- /dev/null +++ b/gitnexus/test/unit/sidecar-recovery.test.ts @@ -0,0 +1,327 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { readFileSync } from 'node:fs'; +import { + _resetSidecarRecoveryWarningsForTest, + finalizeLbugSidecarsAfterClose, + inspectLbugSidecars, + isPermissionRenameError, + isReadOnlyShadowReplayError, + listQuarantinedMissingShadowWals, + preflightLbugSidecars, + renameFailureMessage, + shadowSidecarRecoveryMessage, + TINY_ORPHAN_WAL_BYTES, +} from '../../src/core/lbug/sidecar-recovery.js'; + +const logger = () => ({ warn: vi.fn(), info: vi.fn(), debug: vi.fn() }); + +describe('LadybugDB sidecar recovery', () => { + let dir: string; + let dbPath: string; + + beforeEach(async () => { + _resetSidecarRecoveryWarningsForTest(); + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-sidecar-recovery-')); + dbPath = path.join(dir, 'lbug'); + await fs.writeFile(dbPath, 'db'); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await fs.rm(dir, { recursive: true, force: true }); + }); + + it('classifies clean sidecars', async () => { + await expect(inspectLbugSidecars(dbPath)).resolves.toEqual({ kind: 'clean', dbPath }); + }); + + it('classifies WAL with shadow as replayable by LadybugDB', async () => { + await fs.writeFile(`${dbPath}.wal`, Buffer.alloc(128)); + await fs.writeFile(`${dbPath}.shadow`, Buffer.alloc(64)); + + await expect(inspectLbugSidecars(dbPath)).resolves.toEqual({ + kind: 'wal-with-shadow', + dbPath, + walBytes: 128, + shadowBytes: 64, + }); + }); + + it('preflight quarantines tiny orphan WAL without WARN noise', async () => { + await fs.writeFile(`${dbPath}.wal`, Buffer.alloc(34)); + const log = logger(); + + const state = await preflightLbugSidecars(dbPath, { + mode: 'read-only', + logger: log, + allowQuarantine: true, + }); + + expect(state.kind).toBe('clean'); + await expect(fs.stat(`${dbPath}.wal`)).rejects.toMatchObject({ code: 'ENOENT' }); + const files = await fs.readdir(dir); + expect(files.some((file) => file.startsWith('lbug.wal.missing-shadow.'))).toBe(true); + expect(log.warn).not.toHaveBeenCalled(); + expect(log.debug).toHaveBeenCalledWith(expect.stringContaining('preflight tiny orphan WAL')); + }); + + it('does not silently quarantine large orphan WAL during preflight', async () => { + await fs.writeFile(`${dbPath}.wal`, Buffer.alloc(TINY_ORPHAN_WAL_BYTES + 1)); + const log = logger(); + + const state = await preflightLbugSidecars(dbPath, { + mode: 'read-only', + logger: log, + allowQuarantine: true, + }); + + expect(state).toEqual({ + kind: 'orphan-wal', + dbPath, + walBytes: TINY_ORPHAN_WAL_BYTES + 1, + }); + await expect(fs.stat(`${dbPath}.wal`)).resolves.toBeDefined(); + expect(log.warn).toHaveBeenCalledTimes(1); + }); + + it('finalize quarantines tiny orphan WAL after close', async () => { + await fs.writeFile(`${dbPath}.wal`, Buffer.alloc(34)); + const log = logger(); + + await finalizeLbugSidecarsAfterClose(dbPath, { logger: log }); + + await expect(fs.stat(`${dbPath}.wal`)).rejects.toMatchObject({ code: 'ENOENT' }); + const files = await fs.readdir(dir); + expect(files.some((file) => file.startsWith('lbug.wal.missing-shadow.'))).toBe(true); + expect(log.warn).not.toHaveBeenCalled(); + }); + + it('can be disabled through GITNEXUS_DISABLE_LBUG_SIDECAR_PREFLIGHT', async () => { + vi.stubEnv('GITNEXUS_DISABLE_LBUG_SIDECAR_PREFLIGHT', '1'); + await fs.writeFile(`${dbPath}.wal`, Buffer.alloc(34)); + const log = logger(); + + const state = await preflightLbugSidecars(dbPath, { + mode: 'read-only', + logger: log, + allowQuarantine: true, + }); + + expect(state.kind).toBe('tiny-orphan-wal'); + await expect(fs.stat(`${dbPath}.wal`)).resolves.toBeDefined(); + }); + + describe('renameFailureMessage classifier (PR #1747 review)', () => { + const fsErr = (code: string, message = `simulated ${code}`): NodeJS.ErrnoException => { + const e = new Error(message) as NodeJS.ErrnoException; + e.code = code; + return e; + }; + + it('classifies EACCES as a permission/file-lock error (not "rebuild")', () => { + const out = renameFailureMessage('/tmp/lbug', fsErr('EACCES', 'permission denied')); + expect(out).toContain('/tmp/lbug.wal'); + expect(out).toContain('EACCES'); + expect(out).toContain('permission'); + expect(out).not.toContain('Rebuild the index'); + }); + + it('classifies EPERM as a permission/file-lock error', () => { + const out = renameFailureMessage('/tmp/lbug', fsErr('EPERM')); + expect(out).toContain('EPERM'); + expect(out).not.toContain('Rebuild the index'); + }); + + it('classifies EBUSY as a permission/file-lock error (common on Windows under AV)', () => { + const out = renameFailureMessage('/tmp/lbug', fsErr('EBUSY')); + expect(out).toContain('EBUSY'); + expect(out).not.toContain('Rebuild the index'); + }); + + it('falls through to shadowSidecarRecoveryMessage for the LadybugDB missing-shadow error', () => { + const shadowErr = new Error('Cannot open file /tmp/lbug.shadow: No such file or directory'); + expect(renameFailureMessage('/tmp/lbug', shadowErr)).toBe( + shadowSidecarRecoveryMessage('/tmp/lbug', shadowErr), + ); + }); + + it('falls through to shadowSidecarRecoveryMessage for ENOSPC (residual; flagged in plan)', () => { + const err = fsErr('ENOSPC'); + expect(renameFailureMessage('/tmp/lbug', err)).toBe( + shadowSidecarRecoveryMessage('/tmp/lbug', err), + ); + }); + + it('falls through to shadowSidecarRecoveryMessage for EROFS and EIO (residual; flagged in plan)', () => { + const eRofs = fsErr('EROFS'); + const eIo = fsErr('EIO'); + expect(renameFailureMessage('/tmp/lbug', eRofs)).toBe( + shadowSidecarRecoveryMessage('/tmp/lbug', eRofs), + ); + expect(renameFailureMessage('/tmp/lbug', eIo)).toBe( + shadowSidecarRecoveryMessage('/tmp/lbug', eIo), + ); + }); + + it('falls through to shadowSidecarRecoveryMessage for a generic Error without a code', () => { + const generic = new Error('something else broke'); + expect(renameFailureMessage('/tmp/lbug', generic)).toBe( + shadowSidecarRecoveryMessage('/tmp/lbug', generic), + ); + }); + + it('isPermissionRenameError returns true only for EACCES/EPERM/EBUSY', () => { + expect(isPermissionRenameError(fsErr('EACCES'))).toBe(true); + expect(isPermissionRenameError(fsErr('EPERM'))).toBe(true); + expect(isPermissionRenameError(fsErr('EBUSY'))).toBe(true); + expect(isPermissionRenameError(fsErr('ENOENT'))).toBe(false); + expect(isPermissionRenameError(fsErr('ENOSPC'))).toBe(false); + expect(isPermissionRenameError(new Error('shadow missing'))).toBe(false); + }); + }); + + describe('Centralized isReadOnlyShadowReplayError (PR #1747 review, F4 dedup)', () => { + it('matches LadybugDB read-only shadow-replay error', () => { + const err = new Error( + "Runtime exception: Couldn't replay shadow pages under read-only mode. Please re-open the database with read-write mode to replay shadow pages.", + ); + expect(isReadOnlyShadowReplayError(err)).toBe(true); + }); + + it('false-positive guard: rejects unrelated errors', () => { + expect(isReadOnlyShadowReplayError(new Error('something else entirely'))).toBe(false); + expect(isReadOnlyShadowReplayError(new Error('replay shadow pages'))).toBe(false); // missing "under read-only mode" + }); + + it('structural: lbug-adapter.ts no longer defines isReadOnlyShadowReplayError locally', () => { + const source = readFileSync( + path.join(__dirname, '..', '..', 'src', 'core', 'lbug', 'lbug-adapter.ts'), + 'utf-8', + ); + // The original regex literal should appear nowhere in lbug-adapter.ts + // (it now lives in sidecar-recovery.ts only). + expect(source).not.toMatch(/replay shadow pages under read-only mode/); + }); + + it('structural: pool-adapter.ts no longer defines isReadOnlyShadowReplayError locally', () => { + const source = readFileSync( + path.join(__dirname, '..', '..', 'src', 'core', 'lbug', 'pool-adapter.ts'), + 'utf-8', + ); + expect(source).not.toMatch(/replay shadow pages under read-only mode/); + }); + + it('structural: sidecar-recovery.ts carries exactly two LADYBUGDB-CONTRACT markers (one per shadow predicate)', () => { + const source = readFileSync( + path.join(__dirname, '..', '..', 'src', 'core', 'lbug', 'sidecar-recovery.ts'), + 'utf-8', + ); + const markers = source.match(/\/\/ LADYBUGDB-CONTRACT:/g) ?? []; + expect(markers.length).toBe(2); + }); + }); + + it('lists only missing-shadow WAL quarantine files for cleanup', async () => { + await fs.writeFile(`${dbPath}.wal.missing-shadow.1-a`, ''); + await fs.writeFile(`${dbPath}.wal.missing-shadow.2-b`, ''); + await fs.writeFile(`${dbPath}.wal.corrupt.3-c`, ''); + await fs.writeFile(path.join(dir, 'other.wal.missing-shadow.4-d'), ''); + + await expect(listQuarantinedMissingShadowWals(dbPath)).resolves.toEqual([ + `${dbPath}.wal.missing-shadow.1-a`, + `${dbPath}.wal.missing-shadow.2-b`, + ]); + }); + + describe('Counter-based warnOnce milestones (PR #1747 review, F6)', () => { + // Use the public observable surface: drive `warnOnce` indirectly via + // `preflightLbugSidecars` (which calls warnOnce for orphan-WAL) and count + // logger.warn vs logger.debug invocations across many cycles. This avoids + // coupling tests to `warnOnce`'s private signature. + + const triggerOrphanWalPreflight = async (path: string, log: ReturnType) => { + // Each call must restage a >TINY_ORPHAN_WAL_BYTES WAL because preflight + // does not consume large WALs (it returns 'orphan-wal' and warns). + await fs.writeFile(`${path}.wal`, Buffer.alloc(TINY_ORPHAN_WAL_BYTES + 1)); + await preflightLbugSidecars(path, { + mode: 'read-only', + logger: log, + allowQuarantine: true, + }); + }; + + it('first occurrence warns; occurrences 2-9 debug; 10th warns with "10th occurrence" suffix', async () => { + const log = logger(); + for (let i = 1; i <= 10; i++) { + await triggerOrphanWalPreflight(dbPath, log); + } + expect(log.warn).toHaveBeenCalledTimes(2); + expect(log.warn).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('lbug.wal without lbug.shadow'), + ); + expect(log.warn).toHaveBeenNthCalledWith( + 2, + expect.stringContaining('(10th occurrence of this condition)'), + ); + expect(log.debug).toHaveBeenCalledTimes(8); + }); + + it('100th occurrence warns with "100th occurrence" suffix', async () => { + const log = logger(); + for (let i = 1; i <= 100; i++) { + await triggerOrphanWalPreflight(dbPath, log); + } + // Milestones at 1, 10, 100 → 3 warns total. + expect(log.warn).toHaveBeenCalledTimes(3); + expect(log.warn).toHaveBeenNthCalledWith( + 3, + expect.stringContaining('(100th occurrence of this condition)'), + ); + }); + + it('different keys do not share counters (different dbPaths warn independently)', async () => { + const log = logger(); + const dirB = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-sidecar-recovery-B-')); + const dbPathB = path.join(dirB, 'lbug'); + await fs.writeFile(dbPathB, 'db'); + + try { + await triggerOrphanWalPreflight(dbPath, log); + await triggerOrphanWalPreflight(dbPathB, log); + + // Each path fires its first-occurrence warn independently. + expect(log.warn).toHaveBeenCalledTimes(2); + expect(log.debug).toHaveBeenCalledTimes(0); + } finally { + await fs.rm(dirB, { recursive: true, force: true }); + } + }); + + it('_resetSidecarRecoveryWarningsForTest zeroes the counter so the next call fires warn again', async () => { + const log = logger(); + await triggerOrphanWalPreflight(dbPath, log); + await triggerOrphanWalPreflight(dbPath, log); + expect(log.warn).toHaveBeenCalledTimes(1); + expect(log.debug).toHaveBeenCalledTimes(1); + + _resetSidecarRecoveryWarningsForTest(); + + await triggerOrphanWalPreflight(dbPath, log); + // Post-reset, counter is back to 1 — fires warn (not debug). + expect(log.warn).toHaveBeenCalledTimes(2); + expect(log.debug).toHaveBeenCalledTimes(1); + }); + + it('first-occurrence warn message does NOT include the occurrence-count suffix', async () => { + const log = logger(); + await triggerOrphanWalPreflight(dbPath, log); + expect(log.warn).toHaveBeenCalledTimes(1); + const firstWarnMessage = (log.warn as any).mock.calls[0][0] as string; + expect(firstWarnMessage).not.toContain('occurrence of this condition'); + }); + }); +}); diff --git a/gitnexus/test/unit/web-ui-serving.test.ts b/gitnexus/test/unit/web-ui-serving.test.ts index bd1c7faaf..545272403 100644 --- a/gitnexus/test/unit/web-ui-serving.test.ts +++ b/gitnexus/test/unit/web-ui-serving.test.ts @@ -1,5 +1,6 @@ import path from 'node:path'; import http from 'node:http'; +import { readFileSync } from 'node:fs'; import express from 'express'; import { describe, expect, it, vi, beforeEach } from 'vitest'; import { _captureLogger } from '../../src/core/logger.js'; @@ -323,4 +324,18 @@ describe('Real Express dispatch — API and asset isolation', () => { const status = await makeRequest(app, 'GET', '/'); expect(status).toBe(200); }); + + it('does not register a legacy "*" OPTIONS route (Express 5 startup crash regression guard)', async () => { + // The original PR #1747 startup crash was `app.options('*', ...)` throwing + // under Express 5's stricter path parser. The fix on main is to NOT register + // any explicit OPTIONS route — cors() handles preflights automatically and + // the Access-Control-Allow-Private-Network header is set by global middleware + // before cors. This regression guard fails loudly if someone re-adds a legacy + // wildcard route to api.ts. + const apiSource = readFileSync( + path.join(__dirname, '..', '..', 'src', 'server', 'api.ts'), + 'utf-8', + ); + expect(apiSource).not.toMatch(/app\.options\(\s*['"`]\*['"`]/); + }); }); diff --git a/gitnexus/vendor/tree-sitter-dart/package.json b/gitnexus/vendor/tree-sitter-dart/package.json index 27927dca8..65d1b9b63 100644 --- a/gitnexus/vendor/tree-sitter-dart/package.json +++ b/gitnexus/vendor/tree-sitter-dart/package.json @@ -6,7 +6,7 @@ "license": "ISC", "main": "bindings/node", "types": "bindings/node", - "_vendoredBy": "gitnexus - pinned to UserNobody14/tree-sitter-dart commit 80e23c07b64494f7e21090bb3450223ef0b192f4. Build deps are hoisted into gitnexus/package.json optionalDependencies, and native compilation is performed by gitnexus/scripts/build-tree-sitter-dart.cjs at gitnexus postinstall.", + "_vendoredBy": "gitnexus - pinned to UserNobody14/tree-sitter-dart commit 80e23c07b64494f7e21090bb3450223ef0b192f4. Copied to node_modules/ by materialize-vendor-grammars.cjs; native build via build-tree-sitter-dart.cjs (#1728, #836).", "peerDependencies": { "tree-sitter": "^0.21.0" }, diff --git a/gitnexus/vendor/tree-sitter-proto/package.json b/gitnexus/vendor/tree-sitter-proto/package.json index aea236ea3..914a5fe05 100644 --- a/gitnexus/vendor/tree-sitter-proto/package.json +++ b/gitnexus/vendor/tree-sitter-proto/package.json @@ -5,7 +5,7 @@ "repository": "https://github.com/coder3101/tree-sitter-proto", "license": "MIT", "main": "bindings/node", - "_vendoredBy": "gitnexus — build deps (node-addon-api, node-gyp-build) are hoisted into gitnexus/package.json optionalDependencies, and native compilation is performed by gitnexus/scripts/build-tree-sitter-proto.cjs at gitnexus postinstall. Do NOT re-add a dependencies block or an install script here — doing so reintroduces https://github.com/abhigyanpatwari/GitNexus/issues/836 (ENOTEMPTY on global upgrade).", + "_vendoredBy": "gitnexus — materialized to node_modules/ by materialize-vendor-grammars.cjs; native build via build-tree-sitter-proto.cjs. Do NOT re-add dependencies or an install script (#836, #1728).", "peerDependencies": { "tree-sitter": ">=0.21.0" } diff --git a/gitnexus/vendor/tree-sitter-swift/package.json b/gitnexus/vendor/tree-sitter-swift/package.json index 2f36bd0a6..119418aaf 100644 --- a/gitnexus/vendor/tree-sitter-swift/package.json +++ b/gitnexus/vendor/tree-sitter-swift/package.json @@ -9,14 +9,7 @@ "type": "git", "url": "git+https://github.com/alex-pinkus/tree-sitter-swift.git" }, - "_vendoredBy": "gitnexus - minimal runtime package copied from official tree-sitter-swift@0.7.1 (gitHead 88bfd19a89be9d0481b14566fb6160cccea2fe0a). Keeps upstream prebuilds while allowing GitNexus to stay on tree-sitter@0.21.1 until #858 is resolved.", - "scripts": { - "install": "node-gyp-build" - }, - "dependencies": { - "node-addon-api": "^8.0.0", - "node-gyp-build": "^4.8.0" - }, + "_vendoredBy": "gitnexus - minimal runtime package copied from official tree-sitter-swift@0.7.1 (gitHead 88bfd19a89be9d0481b14566fb6160cccea2fe0a). Prebuild activation runs via gitnexus/scripts/build-tree-sitter-swift.cjs after materialize-vendor-grammars.cjs (no install script here — avoids #836 / #1728).", "peerDependencies": { "tree-sitter": "^0.21.1 || ^0.22.1" },