mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-25 01:01:28 +00:00
Merge branch 'main' into feat/Desktop-app
This commit is contained in:
commit
f45fc30ce1
55 changed files with 4208 additions and 293 deletions
102
.github/workflows/ci-tests.yml
vendored
102
.github/workflows/ci-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
8
gitnexus-web/package-lock.json
generated
8
gitnexus-web/package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
28
gitnexus/package-lock.json
generated
28
gitnexus/package-lock.json
generated
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
39
gitnexus/scripts/build-tree-sitter-swift.cjs
Normal file
39
gitnexus/scripts/build-tree-sitter-swift.cjs
Normal file
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
72
gitnexus/scripts/materialize-vendor-grammars.cjs
Normal file
72
gitnexus/scripts/materialize-vendor-grammars.cjs
Normal file
|
|
@ -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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<RespawnExit> =>
|
||||
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<boolean> {
|
||||
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...';
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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=<KB> to include files above the default cap.`);
|
||||
warnLargeFileSkip(
|
||||
` Set GITNEXUS_MAX_FILE_SIZE=<KB> to include files above the default cap.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
: '',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<Array<{ path: string; content: string }>> = [];
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<string>,
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -165,28 +165,9 @@ export function findClassBindingInScope(
|
|||
receiverName: string,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
): SymbolDefinition | undefined {
|
||||
let currentId: ScopeId | null = startScope;
|
||||
const visited = new Set<ScopeId>();
|
||||
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<ScopeId>();
|
||||
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 +
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<void> => {
|
||||
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 <repo-path> --index-only`.',
|
||||
);
|
||||
throw new Error(shadowSidecarRecoveryMessage(dbPath, triggeringErr));
|
||||
}
|
||||
};
|
||||
|
||||
const reopenReadOnlyAfterMissingShadow = async (
|
||||
dbPath: string,
|
||||
err: unknown,
|
||||
): Promise<LbugConnectionHandle> => {
|
||||
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<LbugConnectionHandle> => {
|
||||
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<LbugConnectionHandle> => {
|
||||
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<unknown | null> => {
|
||||
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<void> => {
|
|||
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<void> => {
|
|||
);
|
||||
}
|
||||
}
|
||||
if (closingDbPath) {
|
||||
await finalizeLbugSidecarsAfterClose(closingDbPath, { logger });
|
||||
}
|
||||
};
|
||||
|
||||
export const closeLbug = async (): Promise<void> => {
|
||||
|
|
|
|||
|
|
@ -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<TryQuarantineResult> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<lbug.Database> {
|
||||
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<void> {
|
|||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
|
|
|||
353
gitnexus/src/core/lbug/sidecar-recovery.ts
Normal file
353
gitnexus/src/core/lbug/sidecar-recovery.ts
Normal file
|
|
@ -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<string>`) 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<string, number>();
|
||||
|
||||
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 <repo-path> --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<LbugSidecarState> {
|
||||
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<string> {
|
||||
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<LbugSidecarState> {
|
||||
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<void> {
|
||||
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<string[]> {
|
||||
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<string[]> {
|
||||
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();
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
9
gitnexus/test/fixtures/lang-resolution/javascript-class-instance-singleton/src/consumer.js
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/javascript-class-instance-singleton/src/consumer.js
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { fooService } from './service.js';
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @returns {string}
|
||||
*/
|
||||
export function caller(id) {
|
||||
return fooService.getUser(id);
|
||||
}
|
||||
11
gitnexus/test/fixtures/lang-resolution/javascript-class-instance-singleton/src/service.js
vendored
Normal file
11
gitnexus/test/fixtures/lang-resolution/javascript-class-instance-singleton/src/service.js
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export class FooService {
|
||||
/**
|
||||
* @param {string} id
|
||||
* @returns {string}
|
||||
*/
|
||||
getUser(id) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
export const fooService = new FooService();
|
||||
9
gitnexus/test/fixtures/lang-resolution/javascript-factory-singleton/src/consumer.js
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/javascript-factory-singleton/src/consumer.js
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { fooService } from './service.js';
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @returns {string}
|
||||
*/
|
||||
export function caller(id) {
|
||||
return fooService.getUser(id);
|
||||
}
|
||||
18
gitnexus/test/fixtures/lang-resolution/javascript-factory-singleton/src/service.js
vendored
Normal file
18
gitnexus/test/fixtures/lang-resolution/javascript-factory-singleton/src/service.js
vendored
Normal file
|
|
@ -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();
|
||||
5
gitnexus/test/fixtures/lang-resolution/typescript-class-instance-singleton/src/consumer.ts
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/typescript-class-instance-singleton/src/consumer.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { fooService } from './service';
|
||||
|
||||
export function caller(id: string) {
|
||||
return fooService.getUser(id);
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/typescript-class-instance-singleton/src/service.ts
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/typescript-class-instance-singleton/src/service.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export class FooService {
|
||||
getUser(id: string) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
export const fooService = new FooService();
|
||||
5
gitnexus/test/fixtures/lang-resolution/typescript-factory-singleton/src/consumer.ts
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/typescript-factory-singleton/src/consumer.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { fooService } from './service';
|
||||
|
||||
export function caller(id: string) {
|
||||
return fooService.getUser(id);
|
||||
}
|
||||
11
gitnexus/test/fixtures/lang-resolution/typescript-factory-singleton/src/service.ts
vendored
Normal file
11
gitnexus/test/fixtures/lang-resolution/typescript-factory-singleton/src/service.ts
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export class FooService {
|
||||
getUser(id: string) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
export function makeFooService(): FooService {
|
||||
return new FooService();
|
||||
}
|
||||
|
||||
export const fooService = makeFooService();
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -438,6 +438,28 @@ describe('filesystem-walker', () => {
|
|||
.filter((r) => String(r.msg ?? '').includes('GITNEXUS_MAX_FILE_SIZE=<KB>'));
|
||||
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)', () => {
|
||||
|
|
|
|||
|
|
@ -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, string>): 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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -87,6 +87,33 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly<Record<string, Readonly
|
|||
'argCount > 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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, string>): 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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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<typeof import('child_process')>('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<typeof vi.spyOn>;
|
||||
let stderrWriteSpy: ReturnType<typeof vi.spyOn>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
147
gitnexus/test/unit/analyze-respawn-progress-terminal.test.ts
Normal file
147
gitnexus/test/unit/analyze-respawn-progress-terminal.test.ts
Normal file
|
|
@ -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<typeof vi.spyOn>;
|
||||
let stderrWriteSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
244
gitnexus/test/unit/parse-impl-worker-lazy-cache.test.ts
Normal file
244
gitnexus/test/unit/parse-impl-worker-lazy-cache.test.ts
Normal file
|
|
@ -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<string, ParseWorkerResult[]>([
|
||||
[chunkHash, [emptyWorkerResult(rel, 'cached')]],
|
||||
]),
|
||||
usedKeys: new Set<string>(),
|
||||
};
|
||||
|
||||
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<string, ParseWorkerResult[]>(),
|
||||
usedKeys: new Set<string>(),
|
||||
};
|
||||
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<string, ParseWorkerResult[]>(),
|
||||
usedKeys: new Set<string>(),
|
||||
};
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
327
gitnexus/test/unit/sidecar-recovery.test.ts
Normal file
327
gitnexus/test/unit/sidecar-recovery.test.ts
Normal file
|
|
@ -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<typeof logger>) => {
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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*['"`]\*['"`]/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue