Merge branch 'main' into feat/Desktop-app

This commit is contained in:
Sparsh 2026-05-21 22:02:16 +05:30 • committed by GitHub
commit f45fc30ce1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 4208 additions and 293 deletions

View file

@ -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

View file

@ -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)

View file

@ -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"

View file

@ -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",

View file

@ -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"
},

View file

@ -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",

View file

@ -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');

View file

@ -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.
*

View 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);
}

View file

@ -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 });

View 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.`,
);
}
}

View file

@ -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...';

View file

@ -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.
*

View file

@ -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.`,
);
}
}

View file

@ -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'
: '',
});
}
});

View file

@ -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);

View file

@ -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';

View file

@ -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;
}

View file

@ -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'
);
}

View file

@ -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;
}
}
}
}

View file

@ -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 +

View file

@ -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;

View file

@ -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'
: '',
});
}
}

View file

@ -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

View file

@ -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> => {

View file

@ -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));
}

View 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();
};

View file

@ -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();

View file

@ -0,0 +1,9 @@
import { fooService } from './service.js';
/**
* @param {string} id
* @returns {string}
*/
export function caller(id) {
return fooService.getUser(id);
}

View file

@ -0,0 +1,11 @@
export class FooService {
/**
* @param {string} id
* @returns {string}
*/
getUser(id) {
return id;
}
}
export const fooService = new FooService();

View file

@ -0,0 +1,9 @@
import { fooService } from './service.js';
/**
* @param {string} id
* @returns {string}
*/
export function caller(id) {
return fooService.getUser(id);
}

View 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();

View file

@ -0,0 +1,5 @@
import { fooService } from './service';
export function caller(id: string) {
return fooService.getUser(id);
}

View file

@ -0,0 +1,7 @@
export class FooService {
getUser(id: string) {
return id;
}
}
export const fooService = new FooService();

View file

@ -0,0 +1,5 @@
import { fooService } from './service';
export function caller(id: string) {
return fooService.getUser(id);
}

View file

@ -0,0 +1,11 @@
export class FooService {
getUser(id: string) {
return id;
}
}
export function makeFooService(): FooService {
return new FooService();
}
export const fooService = makeFooService();

View file

@ -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);
});
});

View file

@ -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)', () => {

View file

@ -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']);
});
});

View file

@ -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

View file

@ -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,
},
]);
});
});

View file

@ -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,
},
]);
});
});

View file

@ -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') {

View file

@ -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();
});
});

View 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');
});
});

View file

@ -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');
});
});

View file

@ -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();
});
});

View 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,
);
});
});

View file

@ -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']);
});
});

View file

@ -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/);
});
});

View 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');
});
});
});

View file

@ -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*['"`]\*['"`]/);
});
});

View file

@ -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"
},

View file

@ -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"
}

View file

@ -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"
},