Merge remote-tracking branch 'upstream/main'

This commit is contained in:
ArgonarioD 2026-07-23 14:50:09 +08:00
commit f6614dc9ac
62 changed files with 3127 additions and 491 deletions

View file

@ -46,7 +46,7 @@ jobs:
with:
path: ~/.lbdb/extension
key: lbug-fts-${{ runner.os }}-${{ hashFiles('gitnexus/package-lock.json') }}
- name: Ensure FTS extension installed
- name: Ensure FTS + VECTOR extensions installed
run: npx tsx scripts/ensure-fts.ts
working-directory: gitnexus
- name: Run sharded tests with coverage (blob)
@ -205,6 +205,10 @@ jobs:
# tsx-on-source path in CI (both entry points stay covered).
env:
GITNEXUS_REQUIRE_FTS: '1'
# #2623: the win32 VECTOR gate is gone, so the vector suites genuinely
# run here — require the extension so an unavailable VECTOR is a loud
# failure, never a silent skip (same contract as GITNEXUS_REQUIRE_FTS).
GITNEXUS_REQUIRE_VECTOR: '1'
GITNEXUS_E2E_CLI: dist
# #2449: hosted Windows runners intermittently push the busiest shard past
# the default 15-minute watchdog. 20 minutes restores real headroom while
@ -219,19 +223,21 @@ jobs:
- uses: ./.github/actions/setup-gitnexus
with:
build: 'true'
# Warm-cache the installed LadybugDB FTS extension (~/.lbdb/extension) per
# OS + lockfile so a warm run skips the network install entirely, and the
# parallel shards share one download across runs. Pure reliability/speed:
# on a cache miss the tests self-install FTS on demand (see
# test/helpers/fts-availability.ts), so a miss just falls back to install —
# never a correctness dependency. Keyed by lockfile hash so a LadybugDB
# version bump re-installs; per-OS because the extension is a native binary.
# Warm-cache the installed LadybugDB FTS + VECTOR extensions
# (~/.lbdb/extension) per OS + lockfile so a warm run skips the network
# install entirely, and the parallel shards share one download across
# runs. Pure reliability/speed: on a cache miss the tests self-install on
# demand (see test/helpers/fts-availability.ts), so a miss just falls
# back to install — never a correctness dependency. Keyed by lockfile
# hash so a LadybugDB version bump re-installs; per-OS because the
# extensions are native binaries. (Key name kept as lbug-fts for cache
# continuity — the path covers every extension in the shared home.)
- name: Cache LadybugDB FTS extension
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
with:
path: ~/.lbdb/extension
key: lbug-fts-${{ runner.os }}-${{ hashFiles('gitnexus/package-lock.json') }}
- name: Ensure FTS extension installed
- name: Ensure FTS + VECTOR extensions installed
run: npx tsx scripts/ensure-fts.ts
working-directory: gitnexus
- name: Run platform-sensitive tests

View file

@ -493,7 +493,7 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max
| `GITNEXUS_WORKER_READY_TIMEOUT_MS` | `5000` | Startup budget in milliseconds for a parse worker to load its grammar bindings and report `{type:'ready'}`. Slots that miss it are treated as startup crashes. | Slow or heavily loaded hosts where a full pool cold-starting concurrently needs more than 5s, and analyze aborts with "did not report ready within 5000ms". |
| `GITNEXUS_FTS_STEMMER` | `porter` | Stemmer used when rebuilding BM25/FTS indexes. Use `none` for CJK-heavy repositories, or a language stemmer such as `german`, `french`, or `spanish` for matching repository comments. Re-run `gitnexus analyze --repair-fts` after changing it. | Keyword search quality is poor for non-English comments or identifiers under English stemming. |
| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold in bytes. Equivalent to `--wal-checkpoint-threshold <bytes>`. `-1` keeps LadybugDB's stock threshold (~16 MiB). Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. | You need a larger or smaller WAL auto-checkpoint threshold for your analyze workload. |
| `GITNEXUS_LBUG_BUFFER_POOL_SIZE` | min(2 GiB, 80% RAM) | LadybugDB buffer-pool ceiling in bytes for every GitNexus database (analyze, MCP server, serve, group bridges). `0` restores LadybugDB's native unbounded default of 80% of system RAM; invalid values warn and fall back to the default (#2557). | A long-lived `gitnexus mcp` or a big incremental `analyze` uses too much memory, or a huge repo's working set genuinely needs a pool larger than 2 GiB. |
| `GITNEXUS_LBUG_BUFFER_POOL_SIZE` | min(2 GiB, 80% RAM) | LadybugDB buffer-pool ceiling in bytes for every GitNexus database (analyze, MCP server, serve, group bridges). `0` restores LadybugDB's native unbounded default of 80% of system RAM; invalid values warn and fall back to the default (#2557). During `analyze` the pool is right-sized to the graph, scaled on non-4 KiB-page hosts by the page-size granule ratio up to min(2 GiB × pageSize/4 KiB, 80% RAM) (#2631); this env var overrides all of that as an absolute value. | A long-lived `gitnexus mcp` or a big incremental `analyze` uses too much memory, or a huge repo's working set genuinely needs a pool larger than 2 GiB. |
| `GITNEXUS_LBUG_MAX_DB_SIZE` | `17179869184` (16 GiB) | Maximum size in bytes of a single LadybugDB database file — an mmap/disk-address-space ceiling, not a memory limit (it does not constrain the buffer pool). Invalid values silently fall back to the default. | Indexing a genuinely huge monorepo whose on-disk graph index approaches 16 GiB. |
| `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` | `8388608` (8 MB) | Per-job byte budget the pool will send to a worker in one `postMessage`. | Very large individual files; mostly diagnostic — bumping past 8 MB risks structured-clone memory pressure. |
| `GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT` | `3` | Max replacement spawns per worker slot before the slot is dropped from the active rotation. Bounds respawn loops on a chronically-crashing slot. | Hosts where a flaky worker should retry more (raise) or fail-fast (lower) before the slot is dropped. |

View file

@ -21,6 +21,9 @@ from workflow_bench.proposer_sandbox import (
MAX_BUNDLE_BYTES,
MAX_EVIDENCE_FILE_BYTES,
SANDBOX_NODE,
SANDBOX_NODE_PREFIX,
VITE_TEMP_DIR,
SANDBOX_PATH,
SANDBOX_PYTHON3,
SANDBOX_SHELL_PREFIX,
SANDBOX_USER_SKILLS,
@ -165,7 +168,7 @@ def test_sandbox_command_has_minimal_mounts_and_no_host_root_bind(tmp_path: Path
check=False,
)
assert probe.returncode == 0, probe.stderr
assert probe.stdout == "/home/agent|/opt/claude:/usr/local/bin:/usr/bin:/bin"
assert probe.stdout == f"/home/agent|{SANDBOX_PATH}"
# The evidence-provenance.mjs plan-writer's PATH-scan trusts a Python 3
# candidate only if it (and its directory) is owned by root or by the
@ -218,12 +221,199 @@ def test_runtime_mounts_bind_the_resolved_node_to_a_fresh_sandbox_path(monkeypat
assert not any(SANDBOX_NODE.startswith(bound + "/") for bound in ("/usr", "/bin", "/lib", "/lib64"))
def test_runtime_mounts_bind_the_node_prefix_so_npx_and_npm_resolve(monkeypatch, tmp_path) -> None:
# npx and npm are not standalone binaries -- they are symlinks into
# ../lib/node_modules/npm/bin/*-cli.js -- so binding the sibling files is
# not enough; the install prefix carrying both bin/ and lib/node_modules
# has to be mounted. Without this, a self-hosted runner (where
# actions/setup-node installs into its own tool cache, outside /usr) gets
# a sandbox with node but no npx, and every task verify command dies with
# "/bin/sh: 1: npx: not found" -- all 18 runs of skill-evolution run
# 29861768554 did exactly that.
prefix = tmp_path / "hostedtoolcache" / "node" / "22.18.0" / "x64"
(prefix / "bin").mkdir(parents=True)
(prefix / "bin" / "node").write_text("#!/bin/sh\nexit 0\n")
(prefix / "lib" / "node_modules" / "npm" / "bin").mkdir(parents=True)
(prefix / "lib" / "node_modules" / "npm" / "bin" / "npx-cli.js").write_text("")
(prefix / "bin" / "npx").symlink_to("../lib/node_modules/npm/bin/npx-cli.js")
monkeypatch.setattr(
"workflow_bench.proposer_sandbox.shutil.which",
lambda name: str(prefix / "bin" / "node") if name == "node" else None,
)
args = _runtime_mount_args()
prefix_index = args.index(str(prefix))
assert args[prefix_index - 1] == "--ro-bind"
assert args[prefix_index + 1] == SANDBOX_NODE_PREFIX
# the single-binary bind stays: sanitized_graph.py and runner_sessions.py
# invoke SANDBOX_NODE directly.
node_index = args.index(str(prefix / "bin" / "node"))
assert args[node_index + 1] == SANDBOX_NODE
# and the prefix's bin/ must actually be on PATH for npx to resolve.
assert f"{SANDBOX_NODE_PREFIX}/bin" in SANDBOX_PATH.split(":")
def test_runtime_mounts_skip_the_prefix_bind_for_an_unrecognized_node_layout(monkeypatch, tmp_path) -> None:
# The prefix is derived from the node binary's path, so it must only be
# trusted when the layout really is <prefix>/bin/node carrying npm.
# Otherwise parent.parent names an unrelated ancestor: /opt/bin/node would
# bind ALL of /opt (every tool cache on a hosted runner) and a bare
# <dir>/node would bind <dir>'s parent -- an over-broad mount into a
# sandbox that runs untrusted model-authored code. The pre-existing
# real-Bubblewrap node canary builds exactly this bare <dir>/node shape.
bare = tmp_path / "toolcache"
bare.mkdir()
(bare / "node").write_text("#!/bin/sh\nexit 0\n")
monkeypatch.setattr(
"workflow_bench.proposer_sandbox.shutil.which",
lambda name: str(bare / "node") if name == "node" else None,
)
args = _runtime_mount_args()
assert SANDBOX_NODE_PREFIX not in args
assert str(tmp_path) not in args
# the node bind itself is unaffected -- SANDBOX_NODE still works.
assert args[args.index(str(bare / "node")) + 1] == SANDBOX_NODE
def test_runtime_mounts_skip_the_prefix_bind_without_npx_beside_node(monkeypatch, tmp_path) -> None:
# Right <prefix>/bin/node shape, but no working npx beside it: binding the
# prefix would widen the mount surface without making npx resolvable.
prefix = tmp_path / "x64"
(prefix / "bin").mkdir(parents=True)
(prefix / "bin" / "node").write_text("#!/bin/sh\nexit 0\n")
monkeypatch.setattr(
"workflow_bench.proposer_sandbox.shutil.which",
lambda name: str(prefix / "bin" / "node") if name == "node" else None,
)
args = _runtime_mount_args()
assert SANDBOX_NODE_PREFIX not in args
def test_runtime_mounts_bind_a_real_tool_cache_layout(monkeypatch, tmp_path) -> None:
# The positive counterpart: a genuine <prefix>/bin/node install carrying
# npm, outside the system trees, is bound so npx resolves.
prefix = tmp_path / "node" / "22.18.0" / "x64"
(prefix / "bin").mkdir(parents=True)
(prefix / "bin" / "node").write_text("#!/bin/sh\nexit 0\n")
(prefix / "lib" / "node_modules" / "npm" / "bin").mkdir(parents=True)
(prefix / "lib" / "node_modules" / "npm" / "bin" / "npx-cli.js").write_text("")
(prefix / "bin" / "npx").symlink_to("../lib/node_modules/npm/bin/npx-cli.js")
monkeypatch.setattr(
"workflow_bench.proposer_sandbox.shutil.which",
lambda name: str(prefix / "bin" / "node") if name == "node" else None,
)
args = _runtime_mount_args()
prefix_index = args.index(SANDBOX_NODE_PREFIX)
assert args[prefix_index - 2] == "--ro-bind"
assert args[prefix_index - 1] == str(prefix)
def test_runtime_mounts_skip_the_prefix_bind_when_it_is_already_bound(monkeypatch) -> None:
# On an image where node genuinely lives in /usr/local/bin, the prefix is
# /usr/local -- already inside the wholesale /usr read-only bind. Binding
# it again would be redundant and would needlessly widen the argv, so the
# containment surface stays minimal.
monkeypatch.setattr(
"workflow_bench.proposer_sandbox.shutil.which",
lambda name: "/usr/local/bin/node" if name == "node" else None,
)
args = _runtime_mount_args()
assert SANDBOX_NODE_PREFIX not in args
assert args[args.index("/usr/local/bin/node") + 1] == SANDBOX_NODE
def test_runtime_mounts_skip_the_node_bind_when_node_is_unresolvable(monkeypatch) -> None:
monkeypatch.setattr("workflow_bench.proposer_sandbox.shutil.which", lambda name: None)
args = _runtime_mount_args()
assert SANDBOX_NODE not in args
def test_node_modules_mounts_get_a_writable_vite_temp_overlay(tmp_path: Path) -> None:
# vite writes <node_modules>/.vite-temp/<config>.timestamp-*.mjs before
# loading a TypeScript config, so a read-only dependency mount makes vitest
# fail with EROFS before any test runs -- and every task verify command and
# every hidden oracle ends in "npx vitest run <test>". Reproduced on the
# self-hosted runner with npx bypassed entirely, proving it is independent
# of the node-prefix mount.
clone = tmp_path / "clone"
clone.mkdir()
deps = tmp_path / "deps"
deps.mkdir()
# task_assets.py captures this directory into the dependency snapshot; the
# overlay is gated on the mount source actually carrying it.
(deps / VITE_TEMP_DIR).mkdir()
executable = tmp_path / "executable"
executable.write_text("#!/bin/sh\nexit 0\n")
executable.chmod(0o755)
with prepare_sandbox(
clone=clone,
claude_bin=executable,
bwrap_bin=executable,
preflight=False,
read_only_mounts=(ReadOnlyMount(source=deps, target="/workspace/gitnexus/node_modules"),),
) as sandbox:
argv = sandbox.command_prefix
bind_index = argv.index("/workspace/gitnexus/node_modules")
assert argv[bind_index - 2 : bind_index + 1] == ["--ro-bind", str(deps), "/workspace/gitnexus/node_modules"]
overlay = f"/workspace/gitnexus/node_modules/{VITE_TEMP_DIR}"
overlay_index = argv.index(overlay)
assert argv[overlay_index - 1] == "--tmpfs"
# the overlay must come AFTER the read-only bind, or the bind would mask it
assert overlay_index > bind_index
def test_node_modules_mount_without_a_captured_vite_temp_gets_no_overlay(tmp_path: Path) -> None:
# The trusted GitNexus runtime mounts /opt/gitnexus/node_modules, whose
# source is the built runtime and does NOT carry a .vite-temp. bwrap cannot
# mkdir a mount point inside a read-only bind, so overlaying it would fail
# with "Can't mkdir .../node_modules/.vite-temp: Read-only file system".
# Regression for that CI failure: the overlay must fire only where the
# source actually contains the directory, not for every node_modules mount.
clone = tmp_path / "clone"
clone.mkdir()
runtime = tmp_path / "runtime-node-modules"
runtime.mkdir() # deliberately no .vite-temp
executable = tmp_path / "executable"
executable.write_text("#!/bin/sh\nexit 0\n")
executable.chmod(0o755)
with prepare_sandbox(
clone=clone,
claude_bin=executable,
bwrap_bin=executable,
preflight=False,
read_only_mounts=(ReadOnlyMount(source=runtime, target="/opt/gitnexus/node_modules"),),
) as sandbox:
argv = sandbox.command_prefix
assert "/opt/gitnexus/node_modules" in argv
assert not any(str(item).endswith(f"/{VITE_TEMP_DIR}") for item in argv)
def test_non_node_modules_mounts_get_no_vite_temp_overlay(tmp_path: Path) -> None:
# Scoped to dependency mounts: a hidden-oracle or skill mount stays wholly
# read-only, with no writable island inside it.
clone = tmp_path / "clone"
clone.mkdir()
other = tmp_path / "oracle"
other.mkdir()
executable = tmp_path / "executable"
executable.write_text("#!/bin/sh\nexit 0\n")
executable.chmod(0o755)
with prepare_sandbox(
clone=clone,
claude_bin=executable,
bwrap_bin=executable,
preflight=False,
read_only_mounts=(ReadOnlyMount(source=other, target="/workspace/.wfbench-oracle-abc"),),
) as sandbox:
argv = sandbox.command_prefix
assert not any(str(item).endswith(f"/{VITE_TEMP_DIR}") for item in argv)
def test_stricter_prefix_freezes_evaluated_skills_and_can_unshare_network(tmp_path: Path) -> None:
clone = tmp_path / "clone"
skill = clone / ".claude" / "skills" / "gitnexus-work"
@ -289,6 +479,41 @@ def test_real_bubblewrap_runs_node_from_outside_the_bound_trees(tmp_path: Path,
assert result.ok, result.stderr_tail
@pytest.mark.skipif(
os.environ.get("GITNEXUS_REQUIRE_BWRAP_CANARY") != "1",
reason="real Bubblewrap canary is mandatory in the named Ubuntu CI job",
)
def test_real_bubblewrap_runs_npx_from_outside_the_bound_trees(tmp_path: Path, monkeypatch) -> None:
# The npx half of the self-hosted-runner failure. Relocating a real node
# INSTALL (bin/ + lib/node_modules, not just the binary) to a fresh path
# outside /usr, /bin, /lib and /lib64 reproduces actions/setup-node's
# tool-cache convention. Every task verify command is
# "cd gitnexus && npx tsc ... && npx vitest ...", so npx must resolve
# inside the sandbox; argv assertions cannot prove a bwrap-level mount
# actually works, only a real invocation can.
real_node = shutil.which("node")
if not real_node:
pytest.skip("no node on PATH to relocate for this canary")
real_prefix = Path(real_node).resolve().parent.parent
if not (real_prefix / "lib" / "node_modules" / "npm").is_dir():
pytest.skip(f"node at {real_node} has no npm under its install prefix")
toolcache = tmp_path / "toolcache" / "node" / "22.18.0" / "x64"
shutil.copytree(real_prefix, toolcache, symlinks=True)
relocated_node = toolcache / "bin" / "node"
assert relocated_node.exists()
real_which = shutil.which
monkeypatch.setattr(
"workflow_bench.proposer_sandbox.shutil.which",
lambda name: str(relocated_node) if name == "node" else real_which(name),
)
clone = tmp_path / "clone"
clone.mkdir()
with prepare_sandbox(clone=clone, claude_bin=Path(sys.executable), preflight=True) as sandbox:
result = sandbox.run(["/bin/sh", "-c", "command -v npx && npx --version"], timeout=60)
assert result.ok, result.stderr_tail
@pytest.mark.skipif(
os.environ.get("GITNEXUS_REQUIRE_BWRAP_CANARY") != "1",
reason="real Bubblewrap canary is mandatory in the named Ubuntu CI job",

View file

@ -296,3 +296,88 @@ def test_phase_workspace_still_rejects_a_genuinely_unauthorized_change(tmp_path)
with pytest.raises(ValueError, match="unauthorized workspace path"):
runner_artifacts.enforce_phase_workspace(tmp_path, before, allowed_artifact=artifact)
def test_phase_workspace_ignores_nested_claude_sandbox_bootstrap_noise(tmp_path):
# Claude Code bootstraps into whatever directory it is running in, not just
# the workspace root. The benchmark's task prompts cd into gitnexus/, so the
# same noise lands one level down -- observed verbatim in skill-evolution run
# 29861768554, where 13 of 18 sessions failed with
# "phase changed unauthorized workspace path(s): gitnexus/.claude/.cc-writes".
nested = tmp_path / "gitnexus" / ".claude"
nested.mkdir(parents=True)
(nested / "settings.local.json").write_text("{}")
before = runner_artifacts.workspace_snapshot(tmp_path)
(nested / ".cc-writes").write_text("{}")
artifact = tmp_path / "review-output.md"
artifact.write_text("new review")
runner_artifacts.enforce_phase_workspace(tmp_path, before, allowed_artifact=artifact)
def test_phase_workspace_does_not_descend_into_nested_bootstrap_directories(tmp_path):
# The exclusion must skip an entry before it is queued for traversal, so
# content created *inside* the ignored directory stays invisible too.
nested = tmp_path / "gitnexus" / ".claude" / ".cc-writes"
nested.mkdir(parents=True)
before = runner_artifacts.workspace_snapshot(tmp_path)
(nested / "pending.json").write_text('{"writes": 1}')
artifact = tmp_path / "review-output.md"
artifact.write_text("new review")
runner_artifacts.enforce_phase_workspace(tmp_path, before, allowed_artifact=artifact)
def test_phase_workspace_still_rejects_nested_real_claude_config(tmp_path):
# gitnexus/.claude/settings.local.json is real tracked repository content.
# Excluding ".claude" wholesale at depth would blind the check to it, so the
# exclusion must name only the entries Claude Code itself creates.
nested = tmp_path / "gitnexus" / ".claude"
nested.mkdir(parents=True)
settings = nested / "settings.local.json"
settings.write_text("{}")
before = runner_artifacts.workspace_snapshot(tmp_path)
settings.write_text('{"permissions": "changed"}')
artifact = tmp_path / "review-output.md"
artifact.write_text("new review")
with pytest.raises(ValueError, match="unauthorized workspace path"):
runner_artifacts.enforce_phase_workspace(tmp_path, before, allowed_artifact=artifact)
def test_phase_workspace_still_rejects_nested_package_json(tmp_path):
# package.json is in WORKSPACE_SNAPSHOT_BOOTSTRAP_NOISE, but only as a
# workspace-root entry: gitnexus/package.json is real tracked content whose
# edits must still be caught.
nested = tmp_path / "gitnexus"
nested.mkdir()
manifest = nested / "package.json"
manifest.write_text("{}")
before = runner_artifacts.workspace_snapshot(tmp_path)
manifest.write_text('{"version": "9.9.9"}')
artifact = tmp_path / "review-output.md"
artifact.write_text("new review")
with pytest.raises(ValueError, match="unauthorized workspace path"):
runner_artifacts.enforce_phase_workspace(tmp_path, before, allowed_artifact=artifact)
def test_phase_workspace_still_sees_writes_under_a_pre_existing_nested_claude_dir(tmp_path):
# Every excluded name is a blind spot. .claude/agents and .claude/commands
# are deliberately NOT excluded at depth: once a .claude directory exists
# (gitnexus/.claude/settings.local.json is tracked), anything written
# underneath an excluded entry is invisible to this check, and Claude Code
# loads .claude/agents relative to its cwd -- which these tasks point at
# gitnexus/. A planning phase must not be able to plant a definition there
# for the later work phase to read.
nested = tmp_path / "gitnexus" / ".claude"
nested.mkdir(parents=True)
(nested / "settings.local.json").write_text("{}")
before = runner_artifacts.workspace_snapshot(tmp_path)
(nested / "agents").mkdir()
(nested / "agents" / "planted.md").write_text("planted agent definition")
artifact = tmp_path / "review-output.md"
artifact.write_text("new review")
with pytest.raises(ValueError, match="unauthorized workspace path"):
runner_artifacts.enforce_phase_workspace(tmp_path, before, allowed_artifact=artifact)

View file

@ -9,7 +9,7 @@ from pathlib import Path
import pytest
from workflow_bench.proposer_sandbox import SandboxError
from workflow_bench.proposer_sandbox import VITE_TEMP_DIR, SandboxError
from workflow_bench.oracle_assets import TaskOracleSnapshot
from workflow_bench.runner_tasks import resolve_task_bindings
from workflow_bench.task_assets import TaskAssetCache, stage_task_assets
@ -410,3 +410,36 @@ def test_resolved_task_binding_carries_dependency_digests_and_rejects_live_drift
(repo / "dependency" / "package.json").write_bytes(b'{"version":2}')
with pytest.raises(ValueError, match="definition drifted"):
resolve_task_bindings([task], [binding], oracle_snapshots=[oracle])
def test_node_modules_dependency_snapshot_captures_the_vite_temp_mount_point(tmp_path: Path) -> None:
# bwrap cannot mkdir a mount point inside an already-read-only bind, so the
# directory vite needs must exist in the captured dependency bytes. It is
# recorded during capture, which puts it inside the manifest and both
# dependency digests rather than leaving it an untracked mutation of a
# digest-bound snapshot.
repo, _ = _repo_and_task(tmp_path, {"dependency/package.json": b'{"version":1}'})
task = {
"sandbox_copy": [],
"sandbox_dependencies": [{"source": "dependency", "target": "gitnexus/node_modules"}],
}
with TaskAssetCache(tmp_path / "cache") as cache:
snapshot = cache.prepare(task, repo=repo, resolved_sha=SHA)
captured = {entry.path.as_posix() for entry in snapshot.dependencies[0].entries}
assert f"payload/{VITE_TEMP_DIR}" in captured
vite_temp = next((snapshot.root / "dependencies").glob(f"*/payload/{VITE_TEMP_DIR}"))
assert vite_temp.is_dir()
def test_non_node_modules_dependency_snapshot_has_no_vite_temp(tmp_path: Path) -> None:
# The capture is scoped to dependency mounts whose target is node_modules;
# an unrelated vendored dependency is captured byte-for-byte as declared.
repo, _ = _repo_and_task(tmp_path, {"dependency/package.json": b'{"version":1}'})
task = {
"sandbox_copy": [],
"sandbox_dependencies": [{"source": "dependency", "target": "vendor/dependency"}],
}
with TaskAssetCache(tmp_path / "cache") as cache:
snapshot = cache.prepare(task, repo=repo, resolved_sha=SHA)
captured = {entry.path.as_posix() for entry in snapshot.dependencies[0].entries}
assert not any(path.endswith(VITE_TEMP_DIR) for path in captured)

View file

@ -28,7 +28,16 @@ SANDBOX_CLAUDE = "/opt/claude/claude"
SANDBOX_SHELL_PREFIX = "/opt/claude/shell-prefix"
SANDBOX_PYTHON3 = "/opt/claude/python3"
SANDBOX_NODE = "/opt/claude/node"
SANDBOX_PATH = "/opt/claude:/usr/local/bin:/usr/bin:/bin"
SANDBOX_NODE_PREFIX = "/opt/claude/nodejs"
# Vite transpiles a TypeScript config into <node_modules>/.vite-temp before it
# loads anything, so a read-only dependency mount makes `vitest` die with EROFS
# before a single test runs -- and every task verify command and every hidden
# oracle ends in `npx vitest run <test>`. bwrap cannot create a mount point
# inside an already-read-only bind, so the directory is captured into the
# dependency snapshot (task_assets.py) and a tmpfs is overlaid on it here.
VITE_TEMP_DIR = ".vite-temp"
DEPENDENCY_MOUNT_BASENAME = "node_modules"
SANDBOX_PATH = f"/opt/claude:{SANDBOX_NODE_PREFIX}/bin:/usr/local/bin:/usr/bin:/bin"
SANDBOX_GITNEXUS = "/opt/gitnexus"
SANDBOX_GITNEXUS_SHARED = "/opt/gitnexus-shared"
SANDBOX_GITNEXUS_REGISTRY = "/opt/gitnexus-registry"
@ -351,7 +360,8 @@ def build_claude_settings() -> str:
def _runtime_mount_args() -> list[str]:
args: list[str] = []
for raw in ("/usr", "/bin", "/lib", "/lib64"):
system_trees = ("/usr", "/bin", "/lib", "/lib64")
for raw in system_trees:
path = Path(raw)
if path.exists():
args += ["--ro-bind", raw, raw]
@ -369,6 +379,39 @@ def _runtime_mount_args() -> list[str]:
node_bin = shutil.which("node")
if node_bin:
args += ["--ro-bind", node_bin, SANDBOX_NODE]
# The single-binary bind above gives SANDBOX_NODE but NOT npm or npx:
# those are symlinks into ../lib/node_modules/npm/bin/*-cli.js, so the
# install prefix carrying both bin/ and lib/node_modules has to be
# mounted for them to resolve at all. When node really lives under a
# system tree (/usr/local/bin on GitHub-hosted images) the prefix is
# already inside the wholesale read-only binds above and npm/npx came
# along for free -- which is exactly why this gap stayed invisible
# until a self-hosted runner put node in actions/setup-node's tool
# cache, outside /usr, and every task verify command
# ("cd gitnexus && npx tsc ... && npx vitest ...") died with
# "/bin/sh: 1: npx: not found". Skip the redundant bind in the
# already-covered case so the mount surface stays minimal.
#
# The prefix is only ever derived from a real <prefix>/bin/node layout
# that actually carries npm. Deriving it as parent.parent unconditionally
# would mount an unrelated ancestor whenever node sits somewhere else:
# /opt/bin/node would bind all of /opt (every tool cache on a hosted
# runner) and a bare <dir>/node would bind <dir>'s parent. This function
# exists to keep the sandbox surface minimal, so an unrecognized layout
# binds nothing extra and simply leaves npx unavailable, exactly as
# before.
node_bin_dir = Path(node_bin).resolve().parent
node_prefix = node_bin_dir.parent
# Test the property actually needed -- a working npx next to node in a
# real bin/ directory -- rather than a proxy like lib/node_modules/npm.
# .exists() follows the symlink, so a dangling npx correctly fails: it
# would not survive the mount either. Requiring the "bin" name keeps
# the parent.parent derivation honest; an npx sitting directly beside
# node in a flat directory would make that derivation name the wrong
# prefix.
provides_npx = node_bin_dir.name == "bin" and (node_bin_dir / "npx").exists()
if provides_npx and not any(node_prefix.is_relative_to(tree) for tree in system_trees):
args += ["--ro-bind", str(node_prefix), SANDBOX_NODE_PREFIX]
for raw in (
"/etc/ssl",
"/etc/hosts",
@ -643,6 +686,20 @@ def _sandbox_command_prefix(
]
for mount in mounts:
args += ["--ro-bind", str(mount.source), mount.target]
# Overlay an empty writable tmpfs on the one path vite must write.
# Everything else in the mount, and the whole workspace, stays
# read-only, and the overlay lives only inside the sandbox -- it never
# reaches the host clone the credited patch is captured from.
#
# Gate on the mount SOURCE actually containing the directory, not on
# the target name: bwrap cannot create a mount point inside an
# already-read-only bind, so a tmpfs can only be overlaid where the
# directory already exists in the bound bytes. task_assets.py captures
# it into dependency-snapshot node_modules; other node_modules mounts
# (e.g. the trusted GitNexus runtime at /opt/gitnexus/node_modules) do
# not carry it, and overlaying them would fail with EROFS.
if PurePosixPath(mount.target).name == DEPENDENCY_MOUNT_BASENAME and (mount.source / VITE_TEMP_DIR).is_dir():
args += ["--tmpfs", f"{mount.target}/{VITE_TEMP_DIR}"]
args += ["--chdir", SANDBOX_WORKSPACE, "--"]
return args

View file

@ -55,6 +55,30 @@ WORKSPACE_SNAPSHOT_BOOTSTRAP_NOISE = frozenset(
}
)
# The set above is matched at the workspace ROOT only, because most of its
# entries (package.json, node_modules, the .env family) are also legitimate
# repository content further down the tree -- gitnexus/package.json and
# gitnexus/.claude/settings.local.json are both tracked files whose edits must
# still be caught. But Claude Code bootstraps into whatever directory it is
# running in, so a task whose prompt cd's into a subdirectory gets the same
# noise one level down. Observed in skill-evolution run 29861768554: 13 of 18
# sessions failed with "phase changed unauthorized workspace path(s):
# gitnexus/.claude/.cc-writes". That entry is matched at ANY depth -- never
# ".claude" itself, which holds real configuration.
#
# Deliberately only .cc-writes. Every excluded name is a blind spot: once a
# .claude directory already exists (gitnexus/.claude/settings.local.json is
# tracked), anything a phase writes underneath an excluded entry becomes
# invisible to this check, and Claude Code loads .claude/agents relative to
# its cwd -- which these tasks point at gitnexus/. Adding "agents" and
# "commands" here on the theory that they might also appear nested would let a
# planning phase plant a definition that the later work phase reads, with no
# evidence in the boundary check. Only .cc-writes was ever observed nested, so
# only .cc-writes is excluded; extend this set from an observed failure, never
# pre-emptively.
CLAUDE_BOOTSTRAP_DIR = ".claude"
CLAUDE_BOOTSTRAP_ENTRIES = frozenset({".cc-writes"})
IMPLEMENTATION_ARMS = frozenset(
{
"workflow",
@ -86,6 +110,15 @@ class VerificationResult:
yield self.output
def _is_bootstrap_noise(relative: PurePosixPath) -> bool:
"""Report whether a walked entry is harness noise rather than workspace change."""
parts = relative.parts
if parts[0] == ".git" or parts[0] in WORKSPACE_SNAPSHOT_BOOTSTRAP_NOISE:
return True
return len(parts) >= 2 and parts[-2] == CLAUDE_BOOTSTRAP_DIR and parts[-1] in CLAUDE_BOOTSTRAP_ENTRIES
def workspace_snapshot(worktree: Path) -> dict[str, str]:
"""Hash the workspace without following links, excluding Git internals
and Claude Code's own sandbox-bootstrap noise (see
@ -110,7 +143,7 @@ def workspace_snapshot(worktree: Path) -> dict[str, str]:
raise ValueError(f"workspace snapshot directory is unreadable: {directory}: {exc}") from exc
for entry in children:
relative = relative_dir / entry.name
if relative.parts[0] == ".git" or relative.parts[0] in WORKSPACE_SNAPSHOT_BOOTSTRAP_NOISE:
if _is_bootstrap_noise(relative):
continue
entry_count += 1
path_bytes += len(relative.as_posix().encode())

View file

@ -25,7 +25,9 @@ from pathlib import Path, PurePosixPath
from typing import Any
from .proposer_sandbox import (
DEPENDENCY_MOUNT_BASENAME,
SANDBOX_WORKSPACE,
VITE_TEMP_DIR,
ReadOnlyMount,
SandboxError,
_prepare_clone_target,
@ -160,9 +162,11 @@ class TaskAssetSnapshot:
source = snapshot_root / Path(*dependency.snapshot_path.parts)
metadata = source.lstat()
expected_directory = dependency.kind == "directory"
if stat.S_ISLNK(metadata.st_mode) or (
expected_directory and not stat.S_ISDIR(metadata.st_mode)
) or (not expected_directory and not stat.S_ISREG(metadata.st_mode)):
if (
stat.S_ISLNK(metadata.st_mode)
or (expected_directory and not stat.S_ISDIR(metadata.st_mode))
or (not expected_directory and not stat.S_ISREG(metadata.st_mode))
):
raise SandboxError(f"dependency snapshot changed: {dependency.source}")
target = PurePosixPath(dependency.target)
_prepare_clone_target(
@ -213,9 +217,7 @@ class TaskAssetCache:
repo_identity = _real_directory(repo, label="task asset repository")
declarations, relative_paths = _sandbox_copy_declarations(task)
dependency_declarations = _sandbox_dependency_declarations(task)
dependency_identity = tuple(
(declaration.source, declaration.target) for declaration in dependency_declarations
)
dependency_identity = tuple((declaration.source, declaration.target) for declaration in dependency_declarations)
definition = (str(repo_identity), resolved_sha, declarations, dependency_identity)
existing = self._by_definition.get(definition)
if existing is not None:
@ -258,6 +260,21 @@ class TaskAssetCache:
dependency_builder.copy_descriptor(descriptor, PurePosixPath("payload"))
finally:
os.close(descriptor)
# vitest cannot start against a read-only node_modules: vite
# writes <node_modules>/.vite-temp/<config>.timestamp-*.mjs
# before loading a TypeScript config. bwrap cannot create
# that mount point inside an already-read-only bind, so the
# empty directory is captured here -- before the manifest and
# both dependency digests are computed, so it is part of the
# snapshot rather than an untracked mutation of it. The
# sandbox overlays a tmpfs on it; see VITE_TEMP_DIR.
payload_entry = dependency_builder.entries.get(PurePosixPath("payload"))
if (
payload_entry is not None
and payload_entry.kind == "directory"
and PurePosixPath(declaration.target).name == DEPENDENCY_MOUNT_BASENAME
):
dependency_builder.ensure_directory(PurePosixPath("payload") / VITE_TEMP_DIR)
dependency_entries = dependency_builder.finished_entries()
_validate_dependency_symlinks(
container,
@ -462,10 +479,14 @@ class _SnapshotBuilder:
destination = self.destination / Path(*relative.parts)
os.symlink(target, destination)
after = os.stat(name, dir_fd=parent_descriptor, follow_symlinks=False)
if _mutation_identity(before) != _mutation_identity(after) or os.readlink(
name,
dir_fd=parent_descriptor,
) != target:
if (
_mutation_identity(before) != _mutation_identity(after)
or os.readlink(
name,
dir_fd=parent_descriptor,
)
!= target
):
raise SandboxError(f"dependency symlink changed while snapshotting: {relative}")
self.total_bytes += len(target_bytes)
self.budget.total_bytes += len(target_bytes)
@ -503,6 +524,15 @@ class _SnapshotBuilder:
self.entries[entry.path] = entry
self.budget.entries += 1
def ensure_directory(self, relative: PurePosixPath) -> None:
"""Record and create one extra directory inside this snapshot.
Used for harness-owned mount points that must exist in the captured
bytes rather than be created against a read-only bind at runtime.
"""
self._record_directory(relative)
def finished_entries(self) -> tuple[AssetManifestEntry, ...]:
return tuple(sorted(self.entries.values(), key=lambda entry: entry.path.as_posix()))
@ -567,9 +597,7 @@ def _sandbox_dependency_declarations(
or declaration.target_path in other.target_path.parents
or other.target_path in declaration.target_path.parents
):
raise SandboxError(
f"sandbox dependency targets overlap: {declaration.target} and {other.target}"
)
raise SandboxError(f"sandbox dependency targets overlap: {declaration.target} and {other.target}")
return tuple(declarations)
@ -651,9 +679,7 @@ def _validate_dependency_symlinks(
)
if sandbox_resolved != sandbox_boundary and sandbox_boundary not in sandbox_resolved.parents:
raise SandboxError(f"dependency symlink escapes the sandbox workspace: {entry.path}")
manifest_resolved = PurePosixPath(
posixpath.normpath((entry.path.parent / target).as_posix())
)
manifest_resolved = PurePosixPath(posixpath.normpath((entry.path.parent / target).as_posix()))
if manifest_resolved != manifest_boundary and manifest_boundary not in manifest_resolved.parents:
continue
link = container / Path(*entry.path.parts)
@ -1021,8 +1047,7 @@ def _dependency_mounts(
snapshot: TaskAssetSnapshot,
) -> list[ReadOnlyMount]:
declarations = tuple(
(declaration.source, declaration.target)
for declaration in _sandbox_dependency_declarations(task)
(declaration.source, declaration.target) for declaration in _sandbox_dependency_declarations(task)
)
if snapshot.dependency_declarations != declarations:
raise SandboxError("task asset snapshot does not match this dependency declaration")

View file

@ -11,7 +11,7 @@
"@langchain/anthropic": "^1.5.1",
"@langchain/core": "^1.2.2",
"@langchain/google-genai": "^2.2.0",
"@langchain/langgraph": "^1.4.7",
"@langchain/langgraph": "^1.4.8",
"@langchain/ollama": "^1.3.0",
"@langchain/openai": "^1.5.3",
"@sigma/edge-curve": "^3.1.0",
@ -29,14 +29,14 @@
"i18next": "^26.3.0",
"i18next-browser-languagedetector": "^8.2.1",
"langchain": "^1.4.6",
"lru-cache": "^11.5.1",
"lru-cache": "^11.5.2",
"lucide-react": "^1.23.0",
"mermaid": "^11.15.0",
"mnemonist": "^0.40.4",
"pandemonium": "^2.4.0",
"react": "^19.2.5",
"react-dom": "^19.2.7",
"react-i18next": "^17.0.8",
"react-i18next": "^17.0.10",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.1",
"react-zoom-pan-pinch": "^4.0.3",
@ -47,7 +47,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@babel/types": "^7.29.0",
"@babel/types": "^8.0.0",
"@playwright/test": "^1.61.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
@ -63,7 +63,7 @@
"jsdom": "^29.1.1",
"tree-sitter-wasms": "^0.1.13",
"typescript": "^5.4.5",
"vite": "^8.1.4",
"vite": "^8.1.5",
"vitest": "^4.1.10",
"wait-on": "^9.0.10"
},
@ -186,13 +186,13 @@
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz",
"integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
"node": "^22.18.0 || >=24.11.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
@ -221,16 +221,17 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"node_modules/@babel/parser/node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": {
"node_modules/@babel/parser/node_modules/@babel/types": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
@ -244,6 +245,39 @@
"node": ">=6.9.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0.tgz",
"integrity": "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^8.0.0",
"@babel/helper-validator-identifier": "^8.0.0"
},
"engines": {
"node": "^22.18.0 || >=24.11.0"
}
},
"node_modules/@babel/types/node_modules/@babel/helper-validator-identifier": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz",
"integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^22.18.0 || >=24.11.0"
}
},
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
@ -1138,13 +1172,13 @@
}
},
"node_modules/@langchain/langgraph": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.4.7.tgz",
"integrity": "sha512-2tcyf3QGC7v89kqSxMCtRvzg/3L/4yHtOaWC49A8KieCciWJs7LGaxHoPB6QRxXyUgyR+Zg9Q1ss/XJIE+JuSQ==",
"version": "1.4.8",
"resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.4.8.tgz",
"integrity": "sha512-DN1Np1XefdBEbp1qBKlt39cwoL743AAGpR5Ipja0gY2YbWvsoQnOTIrjnj/orSAhaUYsdTKS8VSWdFzsHZo6Ig==",
"license": "MIT",
"dependencies": {
"@langchain/langgraph-checkpoint": "^1.1.3",
"@langchain/langgraph-sdk": "~1.9.25",
"@langchain/langgraph-sdk": "~1.9.26",
"@langchain/protocol": "^0.0.18",
"@standard-schema/spec": "1.1.0"
},
@ -1169,9 +1203,9 @@
}
},
"node_modules/@langchain/langgraph-sdk": {
"version": "1.9.25",
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.9.25.tgz",
"integrity": "sha512-mRKW8zyQUaHox+HirRFMRrPqOvNbQI3xeXDt6kkk4PbBg77V92bsO1WzUVNrmJ81zCkvxyOrWSK8D6ioCj0a8A==",
"version": "1.9.28",
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.9.28.tgz",
"integrity": "sha512-4j3XuM0PvtmAbL8mPfBS99ez3+ytRfgbOpAR/nOeaejTRF3Q9dNw2QnaGLGng8wLPtGLoSj+SYgUOVxy9Bv9vg==",
"license": "MIT",
"dependencies": {
"@langchain/protocol": "^0.0.18",
@ -1208,9 +1242,9 @@
"license": "MIT"
},
"node_modules/@langchain/langgraph-sdk/node_modules/p-queue": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.0.tgz",
"integrity": "sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang==",
"version": "9.3.3",
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.3.tgz",
"integrity": "sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^5.0.4",
@ -5672,9 +5706,9 @@
}
},
"node_modules/lru-cache": {
"version": "11.5.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
"version": "11.5.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
@ -5721,6 +5755,30 @@
"source-map-js": "^1.2.1"
}
},
"node_modules/magicast/node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/magicast/node_modules/@babel/types": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
@ -6826,9 +6884,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"funding": [
{
"type": "github",
@ -7216,9 +7274,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
"version": "8.5.22",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz",
"integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==",
"funding": [
{
"type": "opencollective",
@ -7235,7 +7293,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"nanoid": "^3.3.16",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@ -7356,9 +7414,9 @@
}
},
"node_modules/react-i18next": {
"version": "17.0.8",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
"version": "17.0.10",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz",
"integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
@ -7368,7 +7426,7 @@
"peerDependencies": {
"i18next": ">= 26.2.0",
"react": ">= 16.8.0",
"typescript": "^5 || ^6"
"typescript": "^5 || ^6 || ^7"
},
"peerDependenciesMeta": {
"react-dom": {
@ -8296,15 +8354,15 @@
}
},
"node_modules/vite": {
"version": "8.1.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz",
"integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==",
"version": "8.1.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.5",
"postcss": "^8.5.16",
"rolldown": "~1.1.4",
"postcss": "^8.5.17",
"rolldown": "~1.1.5",
"tinyglobby": "^0.2.17"
},
"bin": {

View file

@ -21,7 +21,7 @@
"@langchain/anthropic": "^1.5.1",
"@langchain/core": "^1.2.2",
"@langchain/google-genai": "^2.2.0",
"@langchain/langgraph": "^1.4.7",
"@langchain/langgraph": "^1.4.8",
"@langchain/ollama": "^1.3.0",
"@langchain/openai": "^1.5.3",
"@sigma/edge-curve": "^3.1.0",
@ -39,14 +39,14 @@
"i18next": "^26.3.0",
"i18next-browser-languagedetector": "^8.2.1",
"langchain": "^1.4.6",
"lru-cache": "^11.5.1",
"lru-cache": "^11.5.2",
"lucide-react": "^1.23.0",
"mermaid": "^11.15.0",
"mnemonist": "^0.40.4",
"pandemonium": "^2.4.0",
"react": "^19.2.5",
"react-dom": "^19.2.7",
"react-i18next": "^17.0.8",
"react-i18next": "^17.0.10",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.1",
"react-zoom-pan-pinch": "^4.0.3",
@ -57,7 +57,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@babel/types": "^7.29.0",
"@babel/types": "^8.0.0",
"@playwright/test": "^1.61.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
@ -73,7 +73,7 @@
"jsdom": "^29.1.1",
"tree-sitter-wasms": "^0.1.13",
"typescript": "^5.4.5",
"vite": "^8.1.4",
"vite": "^8.1.5",
"vitest": "^4.1.10",
"wait-on": "^9.0.10"
},

View file

@ -484,7 +484,7 @@ Configure the behavior with these environment variables:
| `GITNEXUS_FTS_CJK_SEGMENTATION` | `none`, `bigram` | `none` | `bigram` inserts overlapping character-bigram boundaries into Chinese/Japanese Han-ideograph spans in `content`/`description` before FTS indexing, so LadybugDB's space-only tokenizer can see sub-phrase word boundaries. Scoped to CJK Unified Ideographs only — Japanese Hiragana/Katakana and Korean Hangul are not currently segmented. Unlike `GITNEXUS_FTS_STEMMER`, this rewrites stored text — enabling it on an already-indexed repo requires a full `gitnexus analyze --force`; neither `--repair-fts` nor a plain incremental `analyze` applies it to previously-indexed files. Set the same value wherever `analyze` and search-serving processes (CLI query, MCP server, web server) run. |
| `GITNEXUS_COMMUNITY_ENGINE` | `graphology`, `icebug`, `auto` | `graphology` | Community-detection engine used during analyze. `graphology` uses the bundled default path. `icebug` and `auto` currently behave identically: both try the experimental Icebug CSR path and fall back to Graphology if the optional native module is unavailable or incompatible. |
| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | integer `>= -1` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold during analyze (bytes). Auto-checkpoint remains enabled; `-1` keeps Ladybug's stock ~16 MiB. Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. |
| `GITNEXUS_LBUG_BUFFER_POOL_SIZE` | integer `>= 0` (bytes) | min(2 GiB, 80% RAM) | LadybugDB buffer-pool ceiling for every GitNexus database (analyze, MCP server, serve, group bridges). Bounded so a long-lived `gitnexus mcp` process or a large incremental `analyze` cannot grow toward LadybugDB's native 80%-of-RAM default and OOM the host (#2557). `0` restores that native unbounded default; invalid values warn and fall back to the default. |
| `GITNEXUS_LBUG_BUFFER_POOL_SIZE` | integer `>= 0` (bytes) | min(2 GiB, 80% RAM) | LadybugDB buffer-pool ceiling for every GitNexus database (analyze, MCP server, serve, group bridges). Bounded so a long-lived `gitnexus mcp` process or a large incremental `analyze` cannot grow toward LadybugDB's native 80%-of-RAM default and OOM the host (#2557). `0` restores that native unbounded default; invalid values warn and fall back to the default. During `analyze` the pool is right-sized to the graph and, on non-4 KiB-page hosts (Apple Silicon 16 KiB, Ascend/aarch64 64 KiB), scaled by the page-size granule ratio up to min(2 GiB × pageSize/4 KiB, 80% RAM) (#2631); this env var overrides all of that as an absolute value. |
| `GITNEXUS_LBUG_MAX_DB_SIZE` | positive integer (bytes) | `17179869184` (16 GiB) | Upper bound for a single LadybugDB database file. This is an mmap/disk-address-space ceiling, not a memory limit — it does not constrain the buffer pool (use `GITNEXUS_LBUG_BUFFER_POOL_SIZE` for that). Raise it when indexing genuinely huge monorepos; invalid values silently fall back to the default. |
```bash

View file

@ -1 +1 @@
a99e69ab2dfb897ed771c6a8e29c5b32843a7f734db701e0699afc07c090e4d5
36e29abc0780bc857b6df6dd180a0b6036c8a28f927ccc2d4fe50eede24d0c99

View file

@ -10,7 +10,7 @@
"hasInstallScript": true,
"license": "PolyForm-Noncommercial-1.0.0",
"dependencies": {
"@ladybugdb/core": "^0.18.0",
"@ladybugdb/core": "^0.18.3",
"@modelcontextprotocol/sdk": "^1.0.0",
"@scarf/scarf": "^1.4.0",
"busboy": "^1.6.0",
@ -24,7 +24,7 @@
"graphology-indices": "^0.17.0",
"graphology-utils": "^2.3.0",
"ignore": "^7.0.5",
"js-yaml": "^4.1.1",
"js-yaml": "^5.0.0",
"jsonc-parser": "^3.3.1",
"mnemonist": "^0.40.3",
"node-addon-api": "^8.0.0",
@ -58,7 +58,6 @@
"@types/cli-progress": "^3.11.6",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.6",
"@types/js-yaml": "^4.0.9",
"@types/node": "^26.0.0",
"@vitest/coverage-v8": "^4.0.18",
"gitnexus-shared": "file:../gitnexus-shared",
@ -1253,9 +1252,9 @@
}
},
"node_modules/@ladybugdb/core": {
"version": "0.18.2",
"resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.18.2.tgz",
"integrity": "sha512-222FjGciEO5Z+/MRQGU+b4IaGAjOgSQzj7fMpOuhMQN4F8nf654kuKRk1iybSiNy6XSw69hIJ0mKwUeBQ8y6Fg==",
"version": "0.18.3",
"resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.18.3.tgz",
"integrity": "sha512-XjpPKW4MrL28D2gYGTZuIjiEcPx12L21lx58QggrdrItw8o/e9Lmg/Ejoo4Kz08lZj+rIcC1Fu9thzIYOTUlJw==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@ -1264,17 +1263,17 @@
"node-addon-api": "^6.0.0"
},
"optionalDependencies": {
"@ladybugdb/core-darwin-arm64": "0.18.2",
"@ladybugdb/core-darwin-x64": "0.18.2",
"@ladybugdb/core-linux-arm64": "0.18.2",
"@ladybugdb/core-linux-x64": "0.18.2",
"@ladybugdb/core-win32-x64": "0.18.2"
"@ladybugdb/core-darwin-arm64": "0.18.3",
"@ladybugdb/core-darwin-x64": "0.18.3",
"@ladybugdb/core-linux-arm64": "0.18.3",
"@ladybugdb/core-linux-x64": "0.18.3",
"@ladybugdb/core-win32-x64": "0.18.3"
}
},
"node_modules/@ladybugdb/core-darwin-arm64": {
"version": "0.18.2",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-arm64/-/core-darwin-arm64-0.18.2.tgz",
"integrity": "sha512-gAwxsdijBFTz4aZ9ITG6zdQw3lAki0eY33hNBLCfKXjKJLvW/8wCvgCVBglqfqBF5WyI7icFUt+wfy/Fbdfl5A==",
"version": "0.18.3",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-arm64/-/core-darwin-arm64-0.18.3.tgz",
"integrity": "sha512-DGZTOlvSS4esEb1vTekY5IDoAvZAeYzR5cXVkECtQj9BVkk05zsvCAdTPo1Rz1BuI0qvqUVF+2WlIerI67iA2g==",
"cpu": [
"arm64"
],
@ -1285,9 +1284,9 @@
]
},
"node_modules/@ladybugdb/core-darwin-x64": {
"version": "0.18.2",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-x64/-/core-darwin-x64-0.18.2.tgz",
"integrity": "sha512-oUjYLc1fW3ntCrO9te55PoPfvhFo8AKeNa/sU66fiQyEAZB7qZJxeHnnLgl/bLueTF2os3RSawq46ZftoD/9Eg==",
"version": "0.18.3",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-x64/-/core-darwin-x64-0.18.3.tgz",
"integrity": "sha512-Qp6j0CM/orBlK6KD0p/s4ofkIhNUwi1hdCgMw+fj81UHugWHkVLiYV4grRBdHhyplw+snchZpTxvfpxFbkG1Cw==",
"cpu": [
"x64"
],
@ -1298,9 +1297,9 @@
]
},
"node_modules/@ladybugdb/core-linux-arm64": {
"version": "0.18.2",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-arm64/-/core-linux-arm64-0.18.2.tgz",
"integrity": "sha512-UppokeTaPl9pN0xOsdMa+hmM68zbN2eKReTZhZNYM16qX0d2OlgbS/NlXi09Wdot+w5qvlZ9Q0iCCPfr7qvPaw==",
"version": "0.18.3",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-arm64/-/core-linux-arm64-0.18.3.tgz",
"integrity": "sha512-F9miYjBuS43I7uNG199FNMqwdHJ98WA6dU3v2SZCeLXmXCdRzmYcuHQWlbNr2Tba9CX58w2XvBZoUaXZKJ/yKQ==",
"cpu": [
"arm64"
],
@ -1311,9 +1310,9 @@
]
},
"node_modules/@ladybugdb/core-linux-x64": {
"version": "0.18.2",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-x64/-/core-linux-x64-0.18.2.tgz",
"integrity": "sha512-GypOxCnP2ix/FWM8YhQ41aQYlS+ruoNMJp7pmaF5laJHhL/a+P/apywNTE+9N41Walsl+Emgg9xwxwTC93slow==",
"version": "0.18.3",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-x64/-/core-linux-x64-0.18.3.tgz",
"integrity": "sha512-AfG5RDp/f/IDctDMpTAT5+2MYNtlWT191xiQNjSaWD4X85DhY3Dzps8Qu5VteIAPih5d6mmoaKGs8q0XIjfkFA==",
"cpu": [
"x64"
],
@ -1324,9 +1323,9 @@
]
},
"node_modules/@ladybugdb/core-win32-x64": {
"version": "0.18.2",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-win32-x64/-/core-win32-x64-0.18.2.tgz",
"integrity": "sha512-hvFwjhTYdwG2sijapx963a27jP9mlLW0ZFv5Yfj19e0B3T/FqD9CaKULPy23mU2XlkISN2LUkY5qdgvCFztl/g==",
"version": "0.18.3",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-win32-x64/-/core-win32-x64-0.18.3.tgz",
"integrity": "sha512-bHuFk0m9cnq0WGd9I4D8or8g6cC/BS58iatMtilqM3JpDPIQIFk6MQl6exL7P4xyWbkLwQgsrv2ToDnyoQNKvg==",
"cpu": [
"x64"
],
@ -1930,13 +1929,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/js-yaml": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/jsesc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz",
@ -3050,9 +3042,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
"funding": [
{
"type": "github",
@ -3590,9 +3582,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.0.0.tgz",
"integrity": "sha512-GSvaPUbk1U+FMZ7rJzF+F8e5YVtu7KnD40et/5rBXXRBv2jCO9L3qCewvIDDdudC0QycTFlf6EAA+h3kxBsuUw==",
"funding": [
{
"type": "github",
@ -3608,7 +3600,7 @@
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
"js-yaml": "bin/js-yaml.mjs"
}
},
"node_modules/jsesc": {

View file

@ -56,7 +56,7 @@
"version": "node scripts/sync-plugin-manifests.mjs"
},
"dependencies": {
"@ladybugdb/core": "^0.18.0",
"@ladybugdb/core": "^0.18.3",
"@modelcontextprotocol/sdk": "^1.0.0",
"@scarf/scarf": "^1.4.0",
"busboy": "^1.6.0",
@ -70,7 +70,7 @@
"graphology-indices": "^0.17.0",
"graphology-utils": "^2.3.0",
"ignore": "^7.0.5",
"js-yaml": "^4.1.1",
"js-yaml": "^5.0.0",
"jsonc-parser": "^3.3.1",
"mnemonist": "^0.40.3",
"node-addon-api": "^8.0.0",
@ -105,7 +105,6 @@
"@types/cli-progress": "^3.11.6",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.6",
"@types/js-yaml": "^4.0.9",
"@types/node": "^26.0.0",
"@vitest/coverage-v8": "^4.0.18",
"gitnexus-shared": "file:../gitnexus-shared",

View file

@ -117,6 +117,12 @@ const LBUG_NATIVE = [
// to a live native DB, rm-then-rename over an existing parked copy) before
// any open — rename semantics are exactly what differs on Windows.
'test/unit/incremental-dirty-recovery.test.ts',
// #2623: the incremental writeback must load VECTOR before the CodeEmbedding
// join-delete, and the blocked path must escalate instead of crashing. The
// win32 VECTOR gate was removed in the same PR, so this ordering must be
// proven on the windows-latest native addon, not just Ubuntu. Budget: ~25s
// on Linux → expect ~2min on the slowest Windows shard.
'test/unit/incremental-vector-extension-ordering.test.ts',
];
// Process spawning and CLI tests — exercise child_process with real

View file

@ -1,6 +1,6 @@
/**
* Install the LadybugDB FTS extension into the shared home (~/.lbdb) up front, so
* every test in a sharded CI run finds it regardless of which shard it lands in.
* Install the LadybugDB FTS and VECTOR extensions into the shared home (~/.lbdb)
* up front, so every test in a sharded CI run finds them regardless of shard.
*
* FTS-dependent tests split two ways: the LOAD-path gate (skipUnlessFtsAvailable)
* self-installs on miss, but the FILE-path gate (requireFtsResourceOrSkip, e.g.
@ -17,13 +17,24 @@
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { initLbug, loadFTSExtension, closeLbug } from '../src/core/lbug/lbug-adapter.js';
import {
initLbug,
loadFTSExtension,
loadVectorExtension,
closeLbug,
} from '../src/core/lbug/lbug-adapter.js';
const dir = mkdtempSync(join(tmpdir(), 'gn-ensure-fts-'));
try {
await initLbug(join(dir, 'ensure-fts.lbug'));
const ok = await loadFTSExtension(undefined, { policy: 'auto' });
console.log(ok ? 'FTS extension ready.' : 'FTS extension unavailable (continuing).');
// VECTOR rides the same pre-install (#2623): the win32 gate is gone, so the
// vector suites genuinely run on Windows/macOS — installing once here means
// every sharded test process LOADs from ~/.lbdb instead of racing its own
// out-of-process INSTALL (bounded 15s each when the server is unreachable).
const vec = await loadVectorExtension(undefined, { policy: 'auto' });
console.log(vec ? 'VECTOR extension ready.' : 'VECTOR extension unavailable (continuing).');
} catch (err) {
console.warn(`ensure-fts: skipped (${err instanceof Error ? err.message : String(err)})`);
} finally {

View file

@ -12,8 +12,16 @@ import {
type EmbeddingRuntimeResolution,
} from '../core/embeddings/runtime-install.js';
import { cudaRedirectDoctorStatus } from '../core/embeddings/onnxruntime-node-resolver.js';
import { checkLbugNative, probeFtsExtensionLoad } from '../core/lbug/native-check.js';
import { getOsPageSize, isPageSizeAwareLadybug } from '../core/lbug/lbug-config.js';
import {
checkLbugNative,
probeFtsExtensionLoad,
probeVectorExtensionLoad,
} from '../core/lbug/native-check.js';
import {
getEffectiveBufferPoolSize,
getOsPageSize,
isPageSizeAwareLadybug,
} from '../core/lbug/lbug-config.js';
import { diagnoseExtensionLoad } from '../core/lbug/extension-load-error.js';
import { getExtensionInstallPolicy } from '../core/lbug/extension-loader.js';
import { t } from './i18n/index.js';
@ -146,6 +154,22 @@ export function pageSizeDoctorLines(
return lines;
}
/**
* The hintless buffer-pool doctor line (#2631) — the pool the next Database
* open in THIS process would get. Same plain-params testable-helper shape as
* pageSizeDoctorLines above. `pool` is getEffectiveBufferPoolSize(): `0` is
* the pass-through sentinel for LadybugDB's native 80%-of-RAM default, never
* printed as "0 MiB". `envRaw` (the raw GITNEXUS_LBUG_BUFFER_POOL_SIZE value)
* marks operator-supplied absolute values as "(env override)" — no scaling
* suffix: the hintless default is deliberately unscaled (#2557), and an env
* value is absolute, so a "×N" note would misdescribe both.
*/
export function poolSizeDoctorLine(pool: number, envRaw: string | undefined): string {
const value = pool === 0 ? 'native 80% of RAM' : `${Math.round(pool / (1024 * 1024))} MiB`;
const envNote = envRaw !== undefined && envRaw.trim().length > 0 ? ' (env override)' : '';
return ` ${padDisplayEnd('pool size', 10)}${value}${envNote}`;
}
export const doctorCommand = async () => {
const fingerprint = getRuntimeFingerprint();
const capabilities = getRuntimeCapabilities();
@ -164,6 +188,11 @@ export const doctorCommand = async () => {
for (const line of pageSizeDoctorLines(getOsPageSize(), fingerprint.ladybugdb)) {
console.log(line);
}
// Hintless buffer pool for the next DB open (#2631). Literal label like
// the page size line above (no i18n key).
console.log(
poolSizeDoctorLine(getEffectiveBufferPoolSize(), process.env.GITNEXUS_LBUG_BUFFER_POOL_SIZE),
);
const nativeCheck = checkLbugNative();
if (nativeCheck.ok) {
console.log(` ${padDisplayEnd('native', 10)}✓ lbugjs.node loaded`);
@ -195,8 +224,32 @@ export const doctorCommand = async () => {
console.log(` ${padDisplayEnd('', 18)}${remedy}`);
}
}
console.log(` ${label('doctor.labels.vectorIndex', 18)}${capabilities.vector}`);
console.log(` ${label('doctor.labels.semanticMode', 18)}${capabilities.semanticMode}`);
// Live LOAD probe for VECTOR too (#2623). The static capability is just
// `platform !== 'win32'`, so it printed "available" on the very machines
// where analyze was failing to load the extension — the same contradiction
// #2374 fixed for FTS above, and exactly what #2623's reporter saw while
// every incremental analyze died on an unloaded VECTOR extension.
const vectorProbe = nativeCheck.ok
? await probeVectorExtensionLoad()
: { loaded: false, reason: 'LadybugDB native module (lbugjs.node) failed to load' };
console.log(
` ${label('doctor.labels.vectorIndex', 18)}${vectorProbe.loaded ? 'available' : 'unavailable'}`,
);
if (!vectorProbe.loaded && vectorProbe.reason) {
console.log(` ${padDisplayEnd('', 18)}${vectorProbe.reason}`);
const { kind, remedy } = diagnoseExtensionLoad(vectorProbe.reason, 'VECTOR');
if (kind !== 'unknown') {
console.log(` ${padDisplayEnd('', 18)}${remedy}`);
}
}
// Semantic mode follows the probe, not the platform: without a loadable
// VECTOR extension the index can be neither built nor queried, so search is
// really on exact scan no matter what the platform would allow.
console.log(
` ${label('doctor.labels.semanticMode', 18)}${
vectorProbe.loaded ? capabilities.semanticMode : 'exact-scan'
}`,
);
// Surface the optional-extension install policy so offline users can see
// whether analyze/query will reach the network (extension.ladybugdb.com).
// Literal label (like the 'native' line) to avoid adding i18n keys.

View file

@ -8,10 +8,9 @@
* 1. **Per-name import statements** — `import a, b` and
* `from m import x, y` decompose to one match per imported name
* (see `import-decomposer.ts`).
* 2. **Receiver type bindings** — each `function_definition` inside a
* class body emits a `@type-binding.self` (or `@type-binding.cls`
* for `@classmethod`) capture so Pass-4 attaches the implicit
* receiver (see `receiver-binding.ts`).
* 2. **Receiver type bindings** — methods emit an implicit `self` / `cls`
* binding, and `__init__` assignments from annotated parameters emit
* class-scoped instance-field bindings (see `receiver-binding.ts`).
*
* Pure given the input source text. No I/O, no globals consulted.
*/
@ -25,7 +24,10 @@ import {
} from '../../utils/ast-helpers.js';
import { splitImportStatement } from './import-decomposer.js';
import { getPythonParser, getPythonScopeQuery } from './query.js';
import { synthesizeReceiverTypeBinding } from './receiver-binding.js';
import {
synthesizeConstructorFieldTypeBindings,
synthesizeReceiverTypeBinding,
} from './receiver-binding.js';
import { synthesizeDependsReferences } from './depends-references.js';
import { computePythonArityMetadata } from './arity-metadata.js';
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
@ -133,6 +135,7 @@ export function emitPythonScopeCaptures(
if (fnNode !== null) {
const synth = synthesizeReceiverTypeBinding(fnNode);
if (synth !== null) out.push(synth);
out.push(...synthesizeConstructorFieldTypeBindings(fnNode));
for (const depRef of synthesizeDependsReferences(fnNode)) out.push(depRef);
}
continue;

View file

@ -119,7 +119,10 @@ export function interpretPythonTypeBinding(captures: CaptureMatch): ParsedTypeBi
// `cls` is a self-like receiver; share the source label so downstream
// `Registry.lookup` Step 2 treats them identically.
else if (captures['@type-binding.cls'] !== undefined) source = 'self';
else if (captures['@type-binding.constructor'] !== undefined) source = 'constructor-inferred';
else if (captures['@type-binding.instance-field'] !== undefined) {
source =
captures['@type-binding.parameter'] !== undefined ? 'parameter-annotation' : 'annotation';
} else if (captures['@type-binding.constructor'] !== undefined) source = 'constructor-inferred';
else if (captures['@type-binding.annotation'] !== undefined) source = 'annotation';
else if (captures['@type-binding.alias'] !== undefined) source = 'assignment-inferred';
else if (captures['@type-binding.return'] !== undefined) source = 'return-annotation';

View file

@ -1,6 +1,6 @@
/**
* Synthesize `@type-binding.self` / `@type-binding.cls` captures for
* methods.
* Synthesize implicit receiver and constructor-assigned field type bindings
* for methods.
*
* Tree-sitter can't easily express "the first parameter of a function
* defined directly inside a class body" via a single static query.
@ -113,3 +113,114 @@ export function synthesizeReceiverTypeBinding(fnNode: SyntaxNode): CaptureMatch
'@type-binding.type': syntheticCapture('@type-binding.type', first, className),
};
}
/**
* Synthesize class-scope field bindings for the common Python constructor
* injection pattern:
*
* def __init__(self, service: Service):
* self.service = service
*
* An explicit field annotation (`self.service: Service = ...`) is also
* accepted and takes precedence over a parameter annotation. Deliberately do
* not infer from arbitrary unannotated RHS expressions: the receiver resolver
* needs a declared type, not a name-only guess.
*/
export function synthesizeConstructorFieldTypeBindings(fnNode: SyntaxNode): CaptureMatch[] {
if (fnNode.childForFieldName('name')?.text !== '__init__') return [];
if (findEnclosingClassDefinition(fnNode) === null) return [];
if (hasDecorator(fnNode, 'staticmethod') || hasDecorator(fnNode, 'classmethod')) return [];
const receiver = synthesizeReceiverTypeBinding(fnNode);
const receiverName = receiver?.['@type-binding.self']?.text;
if (receiverName === undefined) return [];
const parameters = fnNode.childForFieldName('parameters');
const body = fnNode.childForFieldName('body');
if (parameters === null || body === null) return [];
const parameterTypes = new Map<string, string>();
for (let i = 0; i < parameters.namedChildCount; i++) {
const parameter = parameters.namedChild(i);
if (parameter === null) continue;
const name = firstParameterName(parameter);
const annotation = parameter.childForFieldName('type');
if (name !== null && annotation !== null) parameterTypes.set(name, annotation.text);
}
type Candidate = { readonly match: CaptureMatch; readonly explicit: boolean };
const candidates = new Map<string, Candidate>();
const stack: SyntaxNode[] = [body];
while (stack.length > 0) {
const node = stack.pop()!;
if (
node !== body &&
(node.type === 'function_definition' ||
node.type === 'lambda' ||
node.type === 'class_definition' ||
node.type === 'if_statement' ||
node.type === 'for_statement' ||
node.type === 'while_statement' ||
node.type === 'try_statement' ||
node.type === 'match_statement')
) {
continue;
}
if (node.type === 'assignment') {
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
if (left?.type === 'attribute') {
const object = left.childForFieldName('object');
const field = left.childForFieldName('attribute');
if (object?.type === 'identifier' && object.text === receiverName && field !== null) {
const explicitType = node.childForFieldName('type');
const parameterType =
right?.type === 'identifier' ? parameterTypes.get(right.text) : undefined;
const typeName = explicitType?.text ?? parameterType;
if (typeName !== undefined) {
const explicit = explicitType !== null;
const existing = candidates.get(field.text);
if (existing === undefined || explicit || !existing.explicit) {
candidates.set(field.text, {
explicit,
match: {
'@type-binding.name': syntheticCapture('@type-binding.name', field, field.text),
'@type-binding.type': syntheticCapture(
'@type-binding.type',
explicitType ?? right ?? field,
typeName,
),
...(explicit
? {}
: {
'@type-binding.parameter': syntheticCapture(
'@type-binding.parameter',
right ?? field,
'1',
),
}),
'@type-binding.instance-field': syntheticCapture(
'@type-binding.instance-field',
node,
'1',
),
},
});
}
}
}
}
}
// Push in reverse so the LIFO walk visits source order. That keeps Map
// insertion order (and therefore emitted capture order) deterministic.
for (let i = node.namedChildCount - 1; i >= 0; i--) {
const child = node.namedChild(i);
if (child !== null) stack.push(child);
}
}
return [...candidates.values()].map(({ match }) => match);
}

View file

@ -36,15 +36,23 @@ export function pythonFunctionDefinitionLabel(
// ─── bindingScopeFor ──────────────────────────────────────────────────────
/** Python has no block scope, so the central extractor's "innermost
* enclosing scope" default is already correct: `for x in …` creates
* `x` in the enclosing function/module scope (because we never emit a
* `@scope.block` for the for-loop body), comprehension variables stay
* in their expression context, etc. Returns `null` to delegate. */
* enclosing scope" default is already correct for ordinary bindings.
* Constructor-injected instance fields are the exception: their marker is
* anchored inside `__init__`, but compound receiver resolution needs the
* field type on the enclosing Class scope. */
export function pythonBindingScopeFor(
_decl: CaptureMatch,
_innermost: Scope,
_tree: ScopeTree,
decl: CaptureMatch,
innermost: Scope,
tree: ScopeTree,
): ScopeId | null {
if (decl['@type-binding.instance-field'] !== undefined) {
let current: Scope | undefined = innermost;
while (current !== undefined) {
if (current.kind === 'Class') return current.id;
if (current.parent === null) break;
current = tree.getScope(current.parent);
}
}
return null;
}

View file

@ -15,7 +15,7 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { createRequire } from 'node:module';
import type { EventType as YamlEventType, State as YamlState } from 'js-yaml';
import type { Event as YamlEvent } from 'js-yaml';
import { SPRING_CONFIG_DESCRIPTION } from '../frameworks/spring/config-bindings.js';
import { generateId } from '../../../lib/utils.js';
import type { PipelineContext, PipelinePhase, PhaseResult } from './types.js';
@ -24,6 +24,17 @@ import type { StructureOutput } from './structure.js';
const require = createRequire(import.meta.url);
const yaml = require('js-yaml') as typeof import('js-yaml');
// js-yaml 5 dropped DEFAULT_SCHEMA; CORE plus these tags is what it used to be, so
// explicitly tagged values keep parsing instead of throwing (an unknown tag aborts
// the whole file). None of them can execute code.
const SPRING_YAML_SCHEMA = yaml.CORE_SCHEMA.withTags(
yaml.mergeTag,
yaml.timestampTag,
yaml.binaryTag,
yaml.omapTag,
yaml.pairsTag,
yaml.setTag,
);
const MAX_CONFIG_FILE_BYTES = 2 * 1024 * 1024;
const MAX_YAML_TRAVERSAL_DEPTH = 128;
const MAX_YAML_TRAVERSAL_NODES = 100_000;
@ -134,9 +145,9 @@ export function parseSpringProperties(
interface YamlParseEvent {
readonly startLine: number;
kind: string | null;
result: unknown;
tag: string | null;
readonly kind: 'scalar' | 'sequence' | 'mapping' | 'alias' | null;
readonly result: unknown;
readonly aliasOf: YamlParseEvent | undefined;
readonly children: YamlParseEvent[];
}
@ -164,12 +175,10 @@ function isObjectValue(value: unknown): value is object {
return value !== null && typeof value === 'object';
}
function resolveYamlAliasEvent(
event: YamlParseEvent | undefined,
objectEvents: WeakMap<object, YamlParseEvent>,
): YamlParseEvent | undefined {
if (event?.kind !== null || !isObjectValue(event.result)) return event;
return objectEvents.get(event.result) ?? event;
// Aliases are resolved to their anchor event by name while the tree is built,
// so following one here is a single pointer hop.
function resolveYamlAliasEvent(event: YamlParseEvent | undefined): YamlParseEvent | undefined {
return event?.aliasOf ?? event;
}
function yamlMappingPairs(event: YamlParseEvent): Array<{
@ -187,116 +196,257 @@ function yamlMappingPairs(event: YamlParseEvent): Array<{
return pairs;
}
/**
* First match in a pre-order walk of `event`, following sequences and `<<` merge
* chains. Iterative: children are pushed in reverse so the explicit stack pops
* them in declaration order, which is what makes "first match" mean the same
* thing it did when this recursed.
*/
function findYamlMappingLocation(
event: YamlParseEvent | undefined,
key: string,
objectEvents: WeakMap<object, YamlParseEvent>,
traversal: YamlTraversalState,
visited = new Set<YamlParseEvent>(),
depth = 0,
): YamlMappingLocation | undefined {
consumeYamlTraversalBudget(traversal, depth);
const resolved = resolveYamlAliasEvent(event, objectEvents);
if (resolved === undefined || visited.has(resolved)) return undefined;
visited.add(resolved);
const visited = new Set<YamlParseEvent>();
const stack: Array<{ event: YamlParseEvent | undefined; depth: number }> = [{ event, depth: 0 }];
if (resolved.kind === 'sequence') {
for (const child of resolved.children) {
const found = findYamlMappingLocation(
child,
key,
objectEvents,
traversal,
visited,
depth + 1,
);
if (found !== undefined) return found;
while (stack.length > 0) {
const step = stack.pop();
if (step === undefined) break;
consumeYamlTraversalBudget(traversal, step.depth);
const resolved = resolveYamlAliasEvent(step.event);
if (resolved === undefined || visited.has(resolved)) continue;
visited.add(resolved);
if (resolved.kind === 'sequence') {
for (let index = resolved.children.length - 1; index >= 0; index--) {
stack.push({ event: resolved.children[index], depth: step.depth + 1 });
}
continue;
}
return undefined;
}
if (resolved.kind !== 'mapping') return undefined;
if (resolved.kind !== 'mapping') continue;
const pairs = yamlMappingPairs(resolved);
const direct = pairs.find((pair) => pair.key === key);
if (direct !== undefined) {
return { valueEvent: direct.valueEvent, line: direct.keyEvent.startLine };
}
for (const merge of pairs.filter((pair) => pair.key === '<<')) {
const found = findYamlMappingLocation(
merge.valueEvent,
key,
objectEvents,
traversal,
visited,
depth + 1,
);
if (found !== undefined) return found;
const pairs = yamlMappingPairs(resolved);
const direct = pairs.find((pair) => pair.key === key);
if (direct !== undefined) {
return { valueEvent: direct.valueEvent, line: direct.keyEvent.startLine };
}
const merges = pairs.filter((pair) => pair.key === '<<');
for (let index = merges.length - 1; index >= 0; index--) {
stack.push({ event: merges[index].valueEvent, depth: step.depth + 1 });
}
}
return undefined;
}
type YamlFlattenStep =
| {
readonly kind: 'visit';
readonly value: unknown;
readonly event: YamlParseEvent | undefined;
readonly prefix: string;
readonly sourceLine: number;
readonly depth: number;
}
// Pops after every descendant of the object that pushed it, which is where the
// recursive form's `finally` used to release the cycle guard.
| { readonly kind: 'leave'; readonly object: object };
/**
* Flatten a document to `dotted.key -> line`, iteratively. Children are pushed in
* reverse so the stack pops them in declaration order, keeping `out` in the same
* insertion order — and the traversal budget consumed in the same sequence — as
* the recursive walk this replaced.
*/
function flattenYamlValue(
value: unknown,
event: YamlParseEvent | undefined,
prefix: string,
out: Map<string, number>,
objectEvents: WeakMap<object, YamlParseEvent>,
traversal: YamlTraversalState,
sourceLine = event?.startLine ?? 1,
depth = 0,
): void {
consumeYamlTraversalBudget(traversal, depth);
const resolvedEvent = resolveYamlAliasEvent(event, objectEvents);
const trackedObject = isObjectValue(value) ? value : undefined;
if (trackedObject !== undefined && traversal.activeObjects.has(trackedObject)) return;
if (trackedObject !== undefined) traversal.activeObjects.add(trackedObject);
try {
if (Array.isArray(value)) {
if (value.length === 0 && prefix.length > 0 && !out.has(prefix)) out.set(prefix, sourceLine);
value.forEach((item, index) =>
flattenYamlValue(
item,
resolvedEvent?.children[index],
`${prefix}[${index}]`,
out,
objectEvents,
traversal,
sourceLine,
depth + 1,
),
);
return;
const stack: YamlFlattenStep[] = [
{ kind: 'visit', value, event, prefix, sourceLine: event?.startLine ?? 1, depth: 0 },
];
while (stack.length > 0) {
const step = stack.pop();
if (step === undefined) break;
if (step.kind === 'leave') {
traversal.activeObjects.delete(step.object);
continue;
}
const { value: current, prefix: currentPrefix, sourceLine, depth } = step;
consumeYamlTraversalBudget(traversal, depth);
const resolvedEvent = resolveYamlAliasEvent(step.event);
const trackedObject = isObjectValue(current) ? current : undefined;
if (trackedObject !== undefined) {
if (traversal.activeObjects.has(trackedObject)) continue;
traversal.activeObjects.add(trackedObject);
stack.push({ kind: 'leave', object: trackedObject });
}
if (Array.isArray(current)) {
if (current.length === 0 && currentPrefix.length > 0 && !out.has(currentPrefix)) {
out.set(currentPrefix, sourceLine);
}
for (let index = current.length - 1; index >= 0; index--) {
stack.push({
kind: 'visit',
value: current[index],
event: resolvedEvent?.children[index],
prefix: `${currentPrefix}[${index}]`,
sourceLine,
depth: depth + 1,
});
}
continue;
}
if (
value !== null &&
typeof value === 'object' &&
current !== null &&
typeof current === 'object' &&
(resolvedEvent?.kind === 'mapping' || resolvedEvent === undefined)
) {
const entries = Object.entries(value as Record<string, unknown>);
if (entries.length === 0 && prefix.length > 0 && !out.has(prefix))
out.set(prefix, sourceLine);
for (const [key, nested] of entries) {
const next = prefix.length === 0 ? key : `${prefix}.${key}`;
const location = findYamlMappingLocation(resolvedEvent, key, objectEvents, traversal);
flattenYamlValue(
nested,
location?.valueEvent,
next,
out,
objectEvents,
traversal,
location?.line ?? sourceLine,
depth + 1,
);
// js-yaml 5 builds `!!set` as a native Set, whose members are not own
// properties; v4 built a plain `{member: null}` object. Enumerate them so a
// tagged set still contributes one key per member instead of a bare leaf.
const entries: Array<[string, unknown]> =
current instanceof Set
? [...current].map((member) => [String(member), null])
: Object.entries(current as Record<string, unknown>);
if (entries.length === 0 && currentPrefix.length > 0 && !out.has(currentPrefix)) {
out.set(currentPrefix, sourceLine);
}
return;
for (let index = entries.length - 1; index >= 0; index--) {
const [key, nested] = entries[index];
const location = findYamlMappingLocation(resolvedEvent, key, traversal);
stack.push({
kind: 'visit',
value: nested,
event: location?.valueEvent,
prefix: currentPrefix.length === 0 ? key : `${currentPrefix}.${key}`,
sourceLine: location?.line ?? sourceLine,
depth: depth + 1,
});
}
continue;
}
if (prefix.length > 0 && !out.has(prefix)) out.set(prefix, sourceLine);
} finally {
if (trackedObject !== undefined) traversal.activeObjects.delete(trackedObject);
if (currentPrefix.length > 0 && !out.has(currentPrefix)) out.set(currentPrefix, sourceLine);
}
}
// js-yaml 5 reports node positions as source offsets; map them to 1-based lines.
function makeLineResolver(source: string): (offset: number) => number {
const lineStarts = [0];
for (let index = 0; index < source.length; index++) {
if (source[index] === '\n') lineStarts.push(index + 1);
}
return (offset: number): number => {
let low = 0;
let high = lineStarts.length - 1;
let line = 0;
while (low <= high) {
const mid = (low + high) >> 1;
if (lineStarts[mid] <= offset) {
line = mid;
low = mid + 1;
} else {
high = mid - 1;
}
}
return line + 1;
};
}
/**
* Rebuild the parse tree from js-yaml 5's event stream (v4's `listener` option
* was removed). Returns each document's root event, with aliases already
* resolved to their anchor event so merged/aliased keys keep the line where
* they were declared.
*
* One node per event, so this pass is bounded by MAX_CONFIG_FILE_BYTES alone —
* MAX_YAML_TRAVERSAL_NODES governs the later walk, which can revisit a shared
* anchor many times and so needs a budget this linear pass does not.
*/
function buildYamlEventTree(
events: readonly YamlEvent[],
source: string,
): Array<YamlParseEvent | undefined> {
const lineOf = makeLineResolver(source);
const anchors = new Map<string, YamlParseEvent>();
const stack: YamlParseEvent[] = [];
const documentRoots: Array<YamlParseEvent | undefined> = [];
const anchorName = (start: number, end: number): string | null =>
start >= 0 && end > start ? source.slice(start, end) : null;
const attach = (node: YamlParseEvent): void => {
stack[stack.length - 1]?.children.push(node);
};
const register = (name: string | null, node: YamlParseEvent): void => {
if (name !== null) anchors.set(name, node);
};
for (const event of events) {
switch (event.type) {
case yaml.EVENT_DOCUMENT:
// Anchors are document-scoped. constructFromEvents already rejects a
// cross-document alias before we get here, so this only keeps the two
// layers from disagreeing.
anchors.clear();
stack.push({
startLine: 1,
kind: null,
result: undefined,
aliasOf: undefined,
children: [],
});
break;
case yaml.EVENT_MAPPING:
case yaml.EVENT_SEQUENCE: {
const node: YamlParseEvent = {
startLine: lineOf(event.start),
kind: event.type === yaml.EVENT_MAPPING ? 'mapping' : 'sequence',
result: undefined,
aliasOf: undefined,
children: [],
};
register(anchorName(event.anchorStart, event.anchorEnd), node);
attach(node);
stack.push(node);
break;
}
case yaml.EVENT_SCALAR: {
const node: YamlParseEvent = {
startLine: lineOf(event.valueStart),
kind: 'scalar',
result: yaml.getScalarValue(source, event),
aliasOf: undefined,
children: [],
};
register(anchorName(event.anchorStart, event.anchorEnd), node);
attach(node);
break;
}
case yaml.EVENT_ALIAS: {
const target = anchors.get(anchorName(event.anchorStart, event.anchorEnd) ?? '');
attach({ startLine: 1, kind: 'alias', result: undefined, aliasOf: target, children: [] });
break;
}
case yaml.EVENT_POP: {
const done = stack.pop();
// Documents are the only top-level containers, so a pop that empties the
// stack closes a document; its single child is the document's root value.
if (done !== undefined && stack.length === 0) documentRoots.push(done.children[0]);
break;
}
}
}
return documentRoots;
}
/** Parse and flatten YAML leaves without retaining their values. */
export function parseSpringYaml(
content: string,
@ -304,46 +454,21 @@ export function parseSpringYaml(
profile?: string,
): SpringConfigKey[] {
const flattened = new Map<string, number>();
const eventStack: YamlParseEvent[] = [];
const documentEvents: YamlParseEvent[] = [];
const objectEvents = new WeakMap<object, YamlParseEvent>();
const documents: unknown[] = [];
const traversal: YamlTraversalState = {
remainingNodes: MAX_YAML_TRAVERSAL_NODES,
activeObjects: new Set<object>(),
};
yaml.loadAll(content, (document) => documents.push(document), {
schema: yaml.DEFAULT_SCHEMA,
const events = yaml.parseEvents(content, { maxDepth: MAX_YAML_TRAVERSAL_DEPTH });
const documents = yaml.constructFromEvents(events, {
source: content,
schema: SPRING_YAML_SCHEMA,
json: true,
listener: (eventType: YamlEventType, state: YamlState) => {
if (eventType === 'open') {
eventStack.push({
startLine: state.line + 1,
kind: null,
result: undefined,
tag: null,
children: [],
});
return;
}
const event = eventStack.pop();
if (event === undefined) return;
event.kind = state.kind ?? null;
event.result = state.result;
event.tag = (state as YamlState & { tag?: string | null }).tag ?? null;
if (isObjectValue(event.result) && event.kind !== null) {
objectEvents.set(event.result, event);
}
const parent = eventStack[eventStack.length - 1];
if (parent === undefined) documentEvents.push(event);
else parent.children.push(event);
},
});
const documentEvents = buildYamlEventTree(events, content);
documents.forEach((document, index) =>
flattenYamlValue(document, documentEvents[index], '', flattened, objectEvents, traversal),
flattenYamlValue(document, documentEvents[index], '', flattened, traversal),
);
return [...flattened.entries()]
.sort(([left], [right]) => left.localeCompare(right))

View file

@ -982,13 +982,15 @@ function followChainedRef(start: TypeRef, draftById: ReadonlyMap<ScopeId, ScopeD
* name in the same scope. Higher number wins; ties keep the later match
* (last-write-wins preserves historical order within a tier).
*
* Rationale: explicit annotations always beat inferred ones because they
* reflect user intent. `self`/`cls` are treated as strongly as annotations
* because they are language-required receiver types.
* Rationale: explicit variable and field annotations always beat bindings
* derived from parameter annotations or inference because they reflect the
* most specific user intent. `self`/`cls` are treated as strongly as other
* declared types because they are language-required receiver types.
*/
function typeBindingStrength(source: TypeRef['source']): number {
switch (source) {
case 'annotation':
return 3;
case 'parameter-annotation':
case 'return-annotation':
case 'self':

View file

@ -101,18 +101,24 @@ const POSIX_MISSING_DEPENDENCY_SIGNATURES: readonly RegExp[] = [
* display language — the only localized part is the OS-error tail after it. So
* it is the language-independent fallback signal once the specific tails miss: a
* French/German/Japanese Windows 126 has a localized tail we cannot enumerate,
* but it still carries this wrapper. See HEDGED_LOAD_FAILURE_REMEDY.
* but it still carries this wrapper. See hedgedLoadFailureRemedy.
*/
const LOAD_FAILURE_WRAPPER = /failed to load library/i;
const MISSING_FILE_REMEDY =
'The FTS extension is not installed. Re-run with network access and ' +
'GITNEXUS_LBUG_EXTENSION_INSTALL=auto (or `gitnexus analyze --repair-fts`) to download it.';
// Remedies are label-parameterized (#2623 follow-up): doctor now live-probes
// VECTOR through the same classifier, and FTS-specific advice (`--repair-fts`
// repairs FTS indexes only) must not be dispensed for other extensions.
const repairFtsHint = (label: string, lead: string): string =>
label === 'FTS' ? ` (${lead}\`gitnexus analyze --repair-fts\`)` : '';
const CORRUPT_FILE_REMEDY =
'The FTS extension file is present but unreadable (corrupt, truncated, or built for another ' +
'platform). Re-download it with network access and GITNEXUS_LBUG_EXTENSION_INSTALL=auto ' +
'(`gitnexus analyze --repair-fts`).';
const missingFileRemedy = (label: string): string =>
`The ${label} extension is not installed. Re-run with network access and ` +
`GITNEXUS_LBUG_EXTENSION_INSTALL=auto${repairFtsHint(label, 'or ')} to download it.`;
const corruptFileRemedy = (label: string): string =>
`The ${label} extension file is present but unreadable (corrupt, truncated, or built for another ` +
`platform). Re-download it with network access and ` +
`GITNEXUS_LBUG_EXTENSION_INSTALL=auto${repairFtsHint(label, '')}.`;
// Single source of truth for the VC++ runtime-install pointer, shared by the
// Windows-126 and structural missing-dependency remedies so the name/URL cannot
@ -122,15 +128,15 @@ const VC_REDIST_INSTALL_HINT =
'https://aka.ms/vs/17/release/vc_redist.x64.exe';
// MSVC-first per DuckDB's canonical answer for this exact error; OpenSSL second.
const WINDOWS_MISSING_DEPENDENCY_REMEDY =
'The FTS extension is present but a required runtime library is missing (Windows error 126). ' +
const windowsMissingDependencyRemedy = (label: string): string =>
`The ${label} extension is present but a required runtime library is missing (Windows error 126). ` +
'Reinstalling the extension will NOT help. Install ' +
VC_REDIST_INSTALL_HINT +
'; if the error persists, the extension also needs OpenSSL 3 ' +
'(libcrypto-3-x64.dll / libssl-3-x64.dll) on the DLL search path.';
const POSIX_MISSING_DEPENDENCY_REMEDY =
'The FTS extension is present but a shared library it depends on could not be loaded (named in ' +
const posixMissingDependencyRemedy = (label: string): string =>
`The ${label} extension is present but a shared library it depends on could not be loaded (named in ` +
'the error above). Reinstalling the extension will NOT help — install that library or add it to ' +
'your loader search path.';
@ -140,16 +146,18 @@ const POSIX_MISSING_DEPENDENCY_REMEDY =
// branches — rather than confidently prescribing the wrong single fix. The clean
// long-term fix is upstream: have LadybugDB include the numeric GetLastError/errno
// in the message (as it already does elsewhere), so this becomes a code match.
const HEDGED_LOAD_FAILURE_REMEDY =
'The FTS extension file was found but could not be loaded — see the "Error:" text above (shown ' +
const hedgedLoadFailureRemedy = (label: string): string =>
`The ${label} extension file was found but could not be loaded — see the "Error:" text above (shown ` +
"in your system's language). Reinstalling usually will not help. If it names a missing module or " +
'library, install the required runtime (on Windows: the Microsoft Visual C++ 2015-2022 ' +
'Redistributable x64 and OpenSSL 3); if it names a corrupt or invalid file, run ' +
'`gitnexus analyze --repair-fts` to re-download.';
'Redistributable x64 and OpenSSL 3); if it names a corrupt or invalid file, ' +
(label === 'FTS'
? 'run `gitnexus analyze --repair-fts` to re-download.'
: 're-run analyze with network access and GITNEXUS_LBUG_EXTENSION_INSTALL=auto to re-download.');
const UNKNOWN_REMEDY =
'The FTS extension failed to load for an unrecognized reason. Run `gitnexus doctor` for live ' +
'FTS status and verify the extension file and platform.';
const unknownRemedy = (label: string): string =>
`The ${label} extension failed to load for an unrecognized reason. Run \`gitnexus doctor\` for live ` +
`${label} status and verify the extension file and platform.`;
const matchesAny = (reason: string, signatures: readonly RegExp[]): boolean =>
signatures.some((re) => re.test(reason));
@ -162,19 +170,20 @@ const matchesAny = (reason: string, signatures: readonly RegExp[]): boolean =>
*/
export function classifyExtensionLoadError(
reason: string | undefined | null,
label: string = 'FTS',
): ExtensionLoadDiagnosis {
const text = reason ?? '';
if (matchesAny(text, MISSING_FILE_SIGNATURES)) {
return { kind: 'missing_file', remedy: MISSING_FILE_REMEDY };
return { kind: 'missing_file', remedy: missingFileRemedy(label) };
}
if (matchesAny(text, FILE_CORRUPTION_SIGNATURES)) {
return { kind: 'corrupt_file', remedy: CORRUPT_FILE_REMEDY };
return { kind: 'corrupt_file', remedy: corruptFileRemedy(label) };
}
if (matchesAny(text, WINDOWS_MISSING_DEPENDENCY_SIGNATURES)) {
return { kind: 'missing_dependency', remedy: WINDOWS_MISSING_DEPENDENCY_REMEDY };
return { kind: 'missing_dependency', remedy: windowsMissingDependencyRemedy(label) };
}
if (matchesAny(text, POSIX_MISSING_DEPENDENCY_SIGNATURES)) {
return { kind: 'missing_dependency', remedy: POSIX_MISSING_DEPENDENCY_REMEDY };
return { kind: 'missing_dependency', remedy: posixMissingDependencyRemedy(label) };
}
// Language-independent fallback: the extension demonstrably failed to load
// (lbug's English wrapper is present) but the localized OS tail matched no
@ -182,9 +191,9 @@ export function classifyExtensionLoadError(
// remedy — strictly better than the generic `unknown` for non-English hosts,
// and it never prescribes the wrong fix.
if (LOAD_FAILURE_WRAPPER.test(text)) {
return { kind: 'missing_dependency', remedy: HEDGED_LOAD_FAILURE_REMEDY };
return { kind: 'missing_dependency', remedy: hedgedLoadFailureRemedy(label) };
}
return { kind: 'unknown', remedy: UNKNOWN_REMEDY };
return { kind: 'unknown', remedy: unknownRemedy(label) };
}
// ── Language-independent structural layer ────────────────────────────────────
@ -192,8 +201,8 @@ export function classifyExtensionLoadError(
/** Well-formedness of the extension binary for the host platform + arch. */
export type ExtensionBinaryState = 'absent' | 'corrupt' | 'valid' | 'indeterminate';
const STRUCTURAL_MISSING_DEPENDENCY_REMEDY =
'The FTS extension file is valid, so the failure is a missing or incompatible runtime dependency, ' +
const structuralMissingDependencyRemedy = (label: string): string =>
`The ${label} extension file is valid, so the failure is a missing or incompatible runtime dependency, ` +
'not the extension itself — reinstalling will NOT help. On Windows, install ' +
VC_REDIST_INSTALL_HINT +
' and ensure OpenSSL 3 is available; on Linux/macOS install the shared library named in the error above.';
@ -332,13 +341,16 @@ export function inspectExtensionBinary(
* classifier (which still carries the language-independent hedged fallback). This
* is the entry point every surface should call.
*/
export function diagnoseExtensionLoad(reason: string | undefined | null): ExtensionLoadDiagnosis {
export function diagnoseExtensionLoad(
reason: string | undefined | null,
label: string = 'FTS',
): ExtensionLoadDiagnosis {
const text = reason ?? '';
const stringResult = classifyExtensionLoadError(text);
const stringResult = classifyExtensionLoadError(text, label);
const fileState = inspectExtensionBinary(extractExtensionPath(text));
if (fileState === 'corrupt') {
return { kind: 'corrupt_file', remedy: CORRUPT_FILE_REMEDY };
return { kind: 'corrupt_file', remedy: corruptFileRemedy(label) };
}
if (fileState === 'valid') {
// The structural probe only inspects the first BINARY_HEADER_BYTES, so a file
@ -357,7 +369,7 @@ export function diagnoseExtensionLoad(reason: string | undefined | null): Extens
const remedy =
stringResult.kind === 'missing_dependency'
? stringResult.remedy
: STRUCTURAL_MISSING_DEPENDENCY_REMEDY;
: structuralMissingDependencyRemedy(label);
return { kind: 'missing_dependency', remedy };
}
// 'absent' or 'indeterminate' → no positive structural evidence, so defer to the

View file

@ -323,7 +323,7 @@ export class ExtensionManager {
name,
loaded: false,
reason,
diagnosis: diagnoseExtensionLoad(reason),
diagnosis: diagnoseExtensionLoad(reason, label),
});
const key = `${name}:${reason}`;
if (this.warnedKeys.has(key)) return;

View file

@ -23,7 +23,11 @@ import { streamAllCSVsToDisk, type StreamedCSVResult } from './csv-generator.js'
import type { PdgEmitManifest } from './pdg-emit-sink.js';
import { getNodeLabel as deriveNodeLabel, type WriteStreamFactory } from './rel-pair-routing.js';
import { EMBEDDABLE_LABELS, type CachedEmbedding } from '../embeddings/types.js';
import { extensionManager, type ExtensionEnsureOptions } from './extension-loader.js';
import {
extensionManager,
resolveAnalyzeInstallPolicy,
type ExtensionEnsureOptions,
} from './extension-loader.js';
import {
classifyDeleteAllError,
closeLbugConnection,
@ -32,6 +36,7 @@ import {
isDbBusyError,
isOpenRetryExhausted,
isWalCorruptionError,
bufferPoolExhaustionRemedy,
openLbugConnection,
sleep,
toNativeSafePath,
@ -41,6 +46,7 @@ import {
type LbugConnectionHandle,
} from './lbug-config.js';
import {
cleanQuarantinedMissingShadowWals,
finalizeLbugSidecarsAfterClose,
guardWalQuarantine,
isMissingShadowSidecarError,
@ -50,8 +56,8 @@ import {
quarantineWalForMissingShadow,
renameFailureMessage,
shadowSidecarRecoveryMessage,
sidecarPreflightDisabled,
} from './sidecar-recovery.js';
import { isVectorExtensionSupportedByPlatform } from '../platform/capabilities.js';
import { logger } from '../logger.js';
// ---------------------------------------------------------------------------
@ -818,6 +824,30 @@ const doInitLbug = async (dbPath: string, readOnly: boolean = false) => {
// -------------------------------------------------------------------------
const releaseInitLock = await acquireInitLock(dbPath);
try {
// Reclaim missing-shadow WAL quarantines from a PRIOR crash (#2637).
// LadybugDB renames an unrecoverable WAL aside as
// `${dbPath}.wal.missing-shadow.<ts>-<rand>` (quarantineWalForMissingShadow)
// instead of deleting it. Once quarantined it is permanently detached from
// the live store and never reopened, so reclaiming it is safe regardless of
// whether the main DB file exists this run — unlike the orphan-sidecar
// cleanup below, this must NOT be gated on "main DB missing": a quarantine
// event and a healthy main DB are independent facts. Never let a reclaim
// failure (e.g. a transient EBUSY from an antivirus scan) block DB startup.
if (!sidecarPreflightDisabled()) {
try {
const reclaimed = await cleanQuarantinedMissingShadowWals(dbPath);
for (const file of reclaimed) {
logger.warn(
`GitNexus: reclaimed quarantined WAL ${path.basename(file)} from a prior crash`,
);
}
} catch (err) {
logger.warn(
`GitNexus: failed to reclaim missing-shadow WAL quarantines: ${summarizeError(err)}`,
);
}
}
// Crash-recovery cleanup: if the main DB file is missing, stale sidecars
// from an interrupted run can block fresh opens indefinitely.
try {
@ -949,7 +979,14 @@ const copyNodeCSVs = async (
const copyQuery = getCopyQuery(table, normalizeCopyPath(csvPath));
await copyCsvWithRetry(targetConn, copyQuery, (retryErr) => {
const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
throw new Error(`COPY failed for ${table}: ${retryMsg.slice(0, 200)}`);
// Pool exhaustion gets a remedy (#2631): the raw binder text gives the
// operator nothing to act on, and on non-4K-page hosts (Ascend aarch64,
// Apple Silicon) the pool bills up to pageSize/4KiB x faster than the
// sizing was calibrated for — name the knob and the mechanism.
const remedy = bufferPoolExhaustionRemedy(retryMsg);
throw new Error(
`COPY failed for ${table}: ${retryMsg.slice(0, 200)}${remedy ? ` ${remedy}` : ''}`,
);
});
}
};
@ -1121,6 +1158,7 @@ export const loadGraphToLbug = async (
const insertedRels = totalValidRels;
const warnings: string[] = [];
let poolRemedyIssued = false;
if (insertedRels > 0) {
log(`Loading edges: ${insertedRels.toLocaleString()} across ${relsByPair.size} types`);
@ -1147,6 +1185,17 @@ export const loadGraphToLbug = async (
await copyCsvWithRetry(writeConn, copyQuery, (retryErr) => {
const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
warnings.push(`${fromLabel}->${toLabel} (${rows} edges): ${retryMsg.slice(0, 80)}`);
// One remedy per bulk load, not per pair (#2631): pool exhaustion
// repeats for every remaining pair once it starts. logger.warn, not
// just warnings.push — the returned warnings array has no consumer at
// any call site, so a push alone would leave the remedy invisible
// while the row-by-row fallback quietly degrades the load.
const remedy = poolRemedyIssued ? undefined : bufferPoolExhaustionRemedy(retryMsg);
if (remedy) {
poolRemedyIssued = true;
warnings.push(remedy);
logger.warn(remedy);
}
failedPairEdges += rows;
failedPairCsvPaths.add(pairCsvPath);
});
@ -2696,14 +2745,16 @@ export const loadVectorExtension = async (
): Promise<boolean> => {
const useModuleState = targetConn === undefined;
if (useModuleState && vectorExtensionLoaded) return true;
// INSTALL VECTOR crashes with SIGSEGV on Windows: the KuzuDB native extension
// installer has an unhandled error path on Windows that raises a fatal signal
// that JS try/catch cannot intercept. Skip loading — vector/embedding search
// is unavailable but all graph index queries still work. Do NOT set
// vectorExtensionLoaded here: the flag means "successfully loaded", and a
// subsequent call would otherwise short-circuit to `return true` at the top.
if (process.platform === 'win32') return false;
if (!isVectorExtensionSupportedByPlatform()) return false;
// No platform gate. Windows was hard-refused here for years on the strength
// of an early-era report that in-process INSTALL VECTOR could SIGSEGV
// (#1365) — but the extension server ships win_amd64 VECTOR artifacts for
// every 0.18.x extension version (probed live: v0.18.0 and v0.18.1 both
// serve a real PE32+ DLL; the pinned 0.18.2 core resolves its extension
// directory to 0.18.1, strace-verified), and INSTALL now runs in a spawned
// child process (installDuckDbExtensionOutOfProcess), so even a crashing
// installer kills only the child and degrades to `false` here. LOAD of a
// present extension file is an ordinary in-process load whose failures
// surface as catchable errors, exactly like FTS.
const c: lbug.Connection | null = targetConn ?? conn;
if (!c) {
@ -2812,6 +2863,78 @@ export const createVectorIndex = async (): Promise<boolean> => {
}
};
/**
* Make DML against {@link EMBEDDING_TABLE_NAME} legal on the writable
* connection when it can be, and report whether it is.
*
* LadybugDB refuses EVERY mutation of a table carrying an HNSW index while
* the VECTOR extension is not loaded on that connection: `DELETE` fails with
* "Trying to delete from an index on table CodeEmbedding but its extension is
* not loaded", `CREATE` with the matching "insert into an index" variant,
* `DROP TABLE` is refused while the index references it, and `SET` — even on
* a NON-indexed property — segfaults the process outright. Probed against
* @ladybugdb/core 0.18.2 (the lockfile-pinned version) and 0.18.0 — every
* result identical on both (#2623).
*
* Dropping the index is NOT an available recovery: `CALL DROP_VECTOR_INDEX`
* is itself a VECTOR-extension function and resolves to "Catalog exception:
* function DROP_VECTOR_INDEX is not defined" in exactly the state it would
* need to rescue. Loading the extension is the only in-place repair, which is
* why this returns a verdict instead of attempting a fixup.
*
* `true` = embedding-row DML is safe: either VECTOR is now loaded, or the
* table carries no index to trip over. `false` = genuinely blocked (index
* present, extension unloadable); the analyze orchestrator answers that by
* escalating to the wipe-and-rebuild write plan instead of failing
* mid-writeback.
*
* Cheap by construction: one local `SHOW_INDEXES` read settles the common
* "this repo never built an embedding index" case without touching the
* extension machinery at all, so a VECTOR-less machine is not charged a
* bounded INSTALL attempt on every incremental analyze. `SHOW_INDEXES` is
* readable WITHOUT the extension and reports `extension_loaded` per index, so
* no error-string sniffing is needed; it runs through the unprepared
* `conn.query()` path like every other `CALL` procedure here (#2114).
*/
export const ensureEmbeddingRowDmlSafe = async (): Promise<boolean> => {
const targetConn = conn;
if (!targetConn) {
throw new Error('LadybugDB not initialized. Call initLbug first.');
}
// Catalog FIRST. The overwhelmingly common case on a repo that never enabled
// embeddings is "no index at all", and that is provable with one local read
// — no extension needed. Loading first would make every incremental analyze
// on a VECTOR-less machine pay a bounded out-of-process INSTALL attempt (the
// `auto` policy) plus an "extension unavailable" warning, for a repo that
// can never hit this hazard.
let indexRows: any[] | undefined;
try {
indexRows = await withConnLock(async () =>
readQueryRows(await targetConn.query('CALL SHOW_INDEXES() RETURN *')),
);
} catch (err) {
// Fall through to the load attempt: unable to prove the index is absent,
// so the extension is the only thing that can make DML safe.
logger.warn(
{ err },
`Could not read the index catalog to check for a ${EMBEDDING_TABLE_NAME} vector index; ` +
'falling back to loading the VECTOR extension.',
);
}
// Any non-HASH index on the embedding table gates DML. Keyed on index TYPE,
// not name, so an index built under a different name still counts; the
// implicit primary-key HASH index is engine-internal and never gates.
const indexGatesDml =
indexRows === undefined ||
indexRows.some((row) => {
const table = row?.table_name ?? row?.[0];
if (table !== EMBEDDING_TABLE_NAME) return false;
return (row?.index_type ?? row?.[2]) !== 'HASH';
});
if (!indexGatesDml) return true;
return await loadVectorExtension(undefined, { policy: resolveAnalyzeInstallPolicy() });
};
/**
* Lazy-create an FTS index, caching the fact in-process.
*

View file

@ -340,18 +340,84 @@ const parseBufferPoolSize = (raw: string | undefined): number | undefined => {
return Math.floor(parsed);
};
/**
* The buffer-manager frame size compiled into every shipped `@ladybugdb/core`
* binary (`LBUG_PAGE_SIZE_LOG2 = 12` in the engine's CMake) — frames are 4 KiB
* on every platform, independent of the OS page size.
*/
const LBUG_ASSUMED_FRAME_SIZE = 4096;
/**
* How much the OS page size amplifies buffer-pool consumption (#2631).
*
* LadybugDB's VM region charges pool budget per DISCARD GRANULE, not per
* frame: `discardGranuleSize = max(frameSize, osPageSize)` (vm_region.cpp),
* `claimFrame` bills the whole granule when its first 4 KiB frame becomes
* resident, and `releaseFrame` refunds only when the granule's LAST frame
* leaves. On a 64 KiB-page kernel (aarch64 openEuler — Ascend hosts) that is
* 16 frames per granule: scattered access is billed up to 16× its real bytes,
* and whole eviction passes can evict frames yet refund nothing — which is
* exactly the engine's "buffer pool is full and no memory could be freed"
* throw. Apple Silicon macOS (16 KiB pages) is the same mechanism at 4×.
*
* So the ANALYZE-path pool sizes (the per-element estimate, the COPY-safety
* floor, and the cap the hint is clamped against) are scaled by this ratio:
* the budget must cover worst-case granule charging or COPY dies on non-4K
* hosts with a pool that would be ample on x86. The hintless default
* (defaultBufferPoolSize — MCP serve, doctor, native-check) is deliberately
* NOT scaled: the pool is a native eager allocation committed at DB open
* (measured — see POOL_BYTES_PER_ELEMENT below), so scaling the global
* default would revert the #2557 OOM cap on every 16 KiB/64 KiB host. If the
* engine ever charges per-frame (or ships page-size-matched frames), this
* collapses back to 1 and the scaling disappears.
*
* Fail-safe: an undetectable page size (win32 — where the granule mechanism
* is absent anyway — or a failed `getconf`) means ratio 1, i.e. today's
* behavior.
*/
export const granuleRatio = (pageSize: number | undefined = getOsPageSize()): number => {
if (pageSize === undefined || !Number.isFinite(pageSize)) return 1;
return Math.max(1, Math.floor(pageSize / LBUG_ASSUMED_FRAME_SIZE));
};
/**
* Hintless pool default — MCP serve, doctor, native-check, any open without a
* per-run hint. Deliberately UNSCALED (#2557): the pool is an eager native
* allocation at DB open, so a page-size-scaled default would hand a
* long-lived `gitnexus mcp` on a 16 KiB/64 KiB host up to 80% of RAM — the
* exact OOM exposure the 2 GiB cap was added to remove.
*/
const defaultBufferPoolSize = (): number =>
Math.min(DEFAULT_BUFFER_POOL_CAP, Math.max(BUFFER_POOL_FLOOR, Math.floor(os.totalmem() * 0.8)));
/**
* Clamp an adaptive pool request to [ADAPTIVE_POOL_FLOOR, default]. The lower
* bound keeps LadybugDB's COPY viable; the upper bound (defaultBufferPoolSize)
* means the hint can only shrink the pool from today's default and can never
* exceed the 2 GiB / 80%-RAM cap — and on a machine whose default is below the
* COPY floor, the default wins, so the pool is never over-committed.
* Upper bound for the ANALYZE-path (hinted) pool: the #2557 cap scaled by the
* granule ratio, still bounded by 80% of RAM. Scaling only this bound — and
* not defaultBufferPoolSize — is what lets the #2631 fix take effect during
* the bulk COPY without touching hintless opens: with an unscaled cap the
* min() below would clamp the scaled COPY floor straight back to 2 GiB.
*/
const clampBufferPool = (bytes: number): number =>
Math.min(defaultBufferPoolSize(), Math.max(ADAPTIVE_POOL_FLOOR, Math.floor(bytes)));
const scaledAnalyzePoolCap = (pageSize: number | undefined): number =>
Math.min(
DEFAULT_BUFFER_POOL_CAP * granuleRatio(pageSize),
Math.max(BUFFER_POOL_FLOOR, Math.floor(os.totalmem() * 0.8)),
);
/**
* Clamp an adaptive pool request to [ADAPTIVE_POOL_FLOOR × granuleRatio,
* scaledAnalyzePoolCap]. The lower bound keeps LadybugDB's COPY viable
* (scaled because the granule accounting inflates consumption on non-4K
* hosts, see granuleRatio); the upper bound means the hint can never exceed
* the page-size-scaled #2557 cap or 80% of RAM — and on a machine whose cap
* is below the COPY floor, the cap wins, so the pool is never over-committed.
* On 4 KiB hosts (ratio 1) this is byte-identical to clamping against the
* hintless default.
*/
const clampBufferPool = (bytes: number, pageSize: number | undefined = getOsPageSize()): number =>
Math.min(
scaledAnalyzePoolCap(pageSize),
Math.max(ADAPTIVE_POOL_FLOOR * granuleRatio(pageSize), Math.floor(bytes)),
);
/**
* Buffer-pool bytes to provision per graph element (node + relationship).
@ -372,13 +438,22 @@ const POOL_BYTES_PER_ELEMENT = 4 * 1024;
/**
* Size the buffer pool to an estimated graph size (node + relationship count),
* clamped to [ADAPTIVE_POOL_FLOOR, defaultBufferPoolSize()]. The estimate can
* only *shrink* the pool from the default — never above the 2 GiB / 80%-RAM cap,
* never below the COPY-safety floor — so no repo is under-sized or gets more
* than the default it would have today.
* clamped to [ADAPTIVE_POOL_FLOOR, scaledAnalyzePoolCap], with every term
* scaled by granuleRatio (#2631): on non-4K hosts the engine bills pool
* budget per OS-page-sized granule, so the same graph consumes up to
* pageSize/4096 × the budget it needs on x86. On 4 KiB hosts the ratio is 1
* and this is byte-identical to the pre-#2631 behavior. The estimate is never
* above the page-size-scaled #2557 cap bounded by 80% of RAM, never below the
* scaled COPY-safety floor; the hintless default stays unscaled.
*
* `pageSize` is a test seam (the pageSizeDoctorLines convention); production
* callers omit it and get the memoized real OS page size.
*/
export const estimateBufferPool = (graphElementCount: number): number =>
clampBufferPool(graphElementCount * POOL_BYTES_PER_ELEMENT);
export const estimateBufferPool = (
graphElementCount: number,
pageSize: number | undefined = getOsPageSize(),
): number =>
clampBufferPool(graphElementCount * POOL_BYTES_PER_ELEMENT * granuleRatio(pageSize), pageSize);
/**
* Optional per-run buffer-pool size hint (bytes). The analyze orchestrator sets
@ -417,12 +492,64 @@ const resolveBufferManagerSize = (): number => {
if (raw.trim().length > 0) {
logger.warn(
{ rawValue: raw, fallback: defaultBufferPoolSize() },
`Ignoring invalid GITNEXUS_LBUG_BUFFER_POOL_SIZE=${raw}; expected integer >= 0 (bytes; 0 restores the native 80%-of-RAM default); falling back to min(2 GiB, 80% of RAM).`,
`Ignoring invalid GITNEXUS_LBUG_BUFFER_POOL_SIZE=${raw}; expected integer >= 0 (bytes; 0 restores the native 80%-of-RAM default); falling back to the platform default pool size.`,
);
}
return defaultBufferPoolSize();
};
/**
* Doctor-facing view of the pool size the next Database open would get
* (#2631): env override > clamped hint > unscaled hintless default. Read-only;
* doctor prints it next to the page-size lines so support triage sees the
* sizing inputs at a glance. `0` is the pass-through sentinel for LadybugDB's
* native 80%-of-RAM default — callers must label it, not print "0 MiB".
*/
export const getEffectiveBufferPoolSize = (): number => resolveBufferManagerSize();
/**
* Matches the engine's buffer-pool exhaustion throw (buffer_manager.cpp:
* "Unable to allocate memory! The buffer pool is full and no memory could be
* freed!"). Distinct from isLbugPageSizeFrameError above, which matches the
* madvise/frame-release failure class.
*/
const BUFFER_POOL_EXHAUSTION_RE = /buffer pool is full|unable to allocate memory/i;
const formatMiB = (bytes: number): string => `${Math.round(bytes / (1024 * 1024))} MiB`;
/**
* Actionable remedy for a buffer-pool exhaustion error (#2631), or undefined
* when `message` is not that class. Cause → consequence → remedy, the
* diagnoseExtensionLoad convention: names the effective pool, the override
* knob, and — on non-4K hosts — the granule amplification that makes the
* budget exhaust early (the reporter's Ascend/aarch64 64 KiB kernel billed a
* pool up to 16× faster than the same analyze on x86).
*/
export const bufferPoolExhaustionRemedy = (
message: string,
pageSize: number | undefined = getOsPageSize(),
): string | undefined => {
if (!BUFFER_POOL_EXHAUSTION_RE.test(message)) return undefined;
const ratio = granuleRatio(pageSize);
const pool = resolveBufferManagerSize();
// 0 is the pass-through sentinel (GITNEXUS_LBUG_BUFFER_POOL_SIZE=0 →
// LadybugDB's native 80%-of-RAM default) — "0 MiB" would be nonsense in the
// very triage text this remedy exists to provide.
const poolLabel = pool === 0 ? "LadybugDB's native 80%-of-RAM default" : formatMiB(pool);
const pageNote =
ratio > 1
? ` This host's ${(pageSize ?? 0) / 1024} KiB OS page size makes the engine bill pool ` +
`memory in ${(pageSize ?? 0) / 1024} KiB granules — up to ${ratio}× faster budget use ` +
`than a 4 KiB-page host running the same analyze.`
: '';
return (
`The LadybugDB buffer pool (${poolLabel}) was exhausted during the bulk COPY.` +
pageNote +
` Set GITNEXUS_LBUG_BUFFER_POOL_SIZE=<bytes> to raise it (e.g. ${4 * 1024 * 1024 * 1024}` +
` for 4 GiB); 0 restores LadybugDB's native 80%-of-RAM default.`
);
};
/** Matches WAL corruption errors from the LadybugDB engine. */
const WAL_CORRUPTION_RE = /corrupt(ed)?\s+wal|invalid\s+wal\s+record|wal.*corrupt|checksum.*wal/i;
@ -509,8 +636,12 @@ const LBUG_PAGE_COMBO_RE = /unsupported page size combination/i;
* True when `err` looks like the LadybugDB buffer manager failing to release
* frame memory — the failure mode of a 4 KiB page-size assumption on a
* 16 KiB/64 KiB-page kernel (#1231). Deliberately does NOT match the
* generic "buffer pool is full" exhaustion error, which is a sizing
* problem, not a page-size one.
* generic "buffer pool is full" exhaustion error: that one is handled as a
* SIZING problem — though since #2631 we know page size drives sizing too
* (the engine bills pool budget per OS-page-sized discard granule, so non-4K
* hosts exhaust the same budget up to pageSize/4096× earlier; see
* granuleRatio, which scales the pool accordingly, and
* bufferPoolExhaustionRemedy, which explains it to the operator).
*/
export const isLbugPageSizeFrameError = (err: unknown): boolean => {
if (!err) return false;
@ -537,6 +668,16 @@ export const isPageSizeAwareLadybug = (version: string | undefined): boolean =>
// because analyze error paths and doctor may both ask, and getconf forks.
let cachedOsPageSize: number | null | undefined;
/**
* Test seam (the `_captureLogger` convention): pin the memoized OS page size
* so sizing tests are host-independent — without this they would silently
* drift on 16 KiB-page Apple Silicon runners. `number` pins a value, `null`
* pins "undetectable", `undefined` clears the memo so the next call re-probes.
*/
export const _setOsPageSizeForTests = (pageSize: number | null | undefined): void => {
cachedOsPageSize = pageSize;
};
/**
* OS memory page size in bytes, or `undefined` when it cannot be determined
* (Windows, missing getconf, sandboxed exec). Node exposes no page-size API,
@ -575,11 +716,6 @@ export const getOsPageSize = (): number | undefined => {
return cachedOsPageSize ?? undefined;
};
/** Exported only for unit tests — clears the getconf probe cache. */
export const _resetOsPageSizeCacheForTest = (): void => {
cachedOsPageSize = undefined;
};
type LbugModule = typeof lbug;
export interface LbugDatabaseOptions {

View file

@ -96,6 +96,9 @@ export interface FtsProbeResult {
reason?: string;
}
/** Same shape for every optional extension; `FtsProbeResult` is the legacy name. */
export type ExtensionProbeResult = FtsProbeResult;
const DEFAULT_FTS_PROBE_TIMEOUT_MS = 10_000;
/** A LadybugDB query result exposes a synchronous `close()`. */
@ -136,8 +139,39 @@ const closeProbeResults = (result: unknown): void => {
export async function probeFtsExtensionLoad(
timeoutMs: number = DEFAULT_FTS_PROBE_TIMEOUT_MS,
): Promise<FtsProbeResult> {
return await probeExtensionLoad('fts', timeoutMs);
}
/**
* Live-probe `LOAD EXTENSION vector`, the VECTOR counterpart of the FTS probe.
*
* Needed for the same reason #2374 needed the FTS one, and reported the same
* way: #2623's reporter saw `doctor` print `VECTOR index: available` while
* every incremental `analyze` was dying because the extension had not loaded.
* `doctor` derived that line from a static platform capability, so it read
* "available" no matter what the extension file was doing.
*
* Probes for real on every platform, Windows included: the extension server
* ships win_amd64 VECTOR artifacts for every 0.18.x extension version (the
* old blanket Windows refusal was stale, #1365-era). LOAD never touches the
* network and never invokes the installer, so this probe is exactly as safe
* as the FTS one above.
*/
export async function probeVectorExtensionLoad(
timeoutMs: number = DEFAULT_FTS_PROBE_TIMEOUT_MS,
): Promise<ExtensionProbeResult> {
return await probeExtensionLoad('vector', timeoutMs);
}
/**
* Shared LOAD probe. `extension` is a fixed internal literal, never user input.
*/
async function probeExtensionLoad(
extension: 'fts' | 'vector',
timeoutMs: number,
): Promise<ExtensionProbeResult> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<FtsProbeResult>((resolve) => {
const timeout = new Promise<ExtensionProbeResult>((resolve) => {
timer = setTimeout(
() =>
resolve({
@ -148,7 +182,7 @@ export async function probeFtsExtensionLoad(
);
});
const probe = (async (): Promise<FtsProbeResult> => {
const probe = (async (): Promise<ExtensionProbeResult> => {
try {
const { default: lbug } = await import('@ladybugdb/core');
const db = new lbug.Database(':memory:');
@ -156,7 +190,7 @@ export async function probeFtsExtensionLoad(
try {
const conn = new lbug.Connection(db);
try {
const result = await conn.query('LOAD EXTENSION fts');
const result = await conn.query(`LOAD EXTENSION ${extension}`);
closeProbeResults(result);
return { loaded: true };
} finally {

View file

@ -17,7 +17,7 @@
import fs from 'fs/promises';
import lbug from '@ladybugdb/core';
import { isReadOnlyDbError, loadFTSExtension } from './lbug-adapter.js';
import { isReadOnlyDbError, loadFTSExtension, loadVectorExtension } from './lbug-adapter.js';
import { closeQueryResults } from './query-result-utils.js';
import {
createLbugDatabase,
@ -126,6 +126,14 @@ interface SharedDB {
db: lbug.Database;
refCount: number;
ftsLoaded: boolean;
/** VECTOR loaded on this Database. Extension load scope is per-Database
* (probe-verified on @ladybugdb/core 0.18.x): loading on any one
* connection enables QUERY_VECTOR_INDEX on every connection of the same
* Database. Without this load the pool's vector lane raised a Catalog
* exception on every semantic query and silently fell back to the exact
* scan (#2623 follow-up). Optional with `?? false` semantics so the
* construction sites stay minimal. */
vectorLoaded?: boolean;
/** File identity at open — used to detect reuse of a shared read-only handle
* whose on-disk index was rebuilt/swapped since it opened (only reachable
* when a second pool consumer shares this dbPath; #2614 F2). */
@ -358,6 +366,7 @@ function closeOne(repoId: string): void {
// for the same dbPath reuse it instead of hitting a file lock.
shared.refCount = 0;
shared.ftsLoaded = false;
shared.vectorLoaded = false;
} else {
shared.db.close().catch(() => {});
dbCache.delete(entry.dbPath);
@ -810,6 +819,13 @@ async function doInitLbug(repoId: string, dbPath: string): Promise<void> {
if (!shared.ftsLoaded) {
shared.ftsLoaded = await loadFTSExtension(available[0], { policy: 'load-only' });
}
// VECTOR too — extension load scope is per-Database, so this one load
// makes QUERY_VECTOR_INDEX legal on every pooled connection. Same
// load-only contract as FTS above; on failure the semantic-query lane
// falls back to the exact scan with its own diagnostic (#2623 follow-up).
if (!shared.vectorLoaded) {
shared.vectorLoaded = await loadVectorExtension(available[0], { policy: 'load-only' });
}
// Register pool entry only after all connections are pre-warmed and FTS is
// loaded. Concurrent executeQuery calls see either "not initialized"
@ -880,6 +896,11 @@ export async function initLbugWithDb(
if (!shared.ftsLoaded) {
shared.ftsLoaded = await loadFTSExtension(available[0], { policy: 'load-only' });
}
// VECTOR too — same per-Database scope and load-only contract as the
// doInitLbug site above (#2623 follow-up).
if (!shared.vectorLoaded) {
shared.vectorLoaded = await loadVectorExtension(available[0], { policy: 'load-only' });
}
pool.set(repoId, {
db: existingDb,

View file

@ -60,7 +60,7 @@ export const isMissingFsError = (err: unknown): boolean =>
const missing = isMissingFsError;
const sidecarPreflightDisabled = (): boolean =>
export 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> => {

View file

@ -86,23 +86,24 @@ export const getRuntimeFingerprint = (): RuntimeFingerprint => ({
onnxruntime: packageVersion('onnxruntime-node'),
});
export const isVectorExtensionSupportedByPlatform = (
platform: NodeJS.Platform = process.platform,
): boolean => platform !== 'win32';
export const getRuntimeCapabilities = (): RuntimeCapabilities => {
const vector = isVectorExtensionSupportedByPlatform() ? 'available' : 'unavailable';
const exactScanLimit = getExactScanLimit();
// Static PLATFORM capability only. LadybugDB ships the VECTOR extension for
// every platform gitnexus supports — the extension server hosts win_amd64
// artifacts for every 0.18.x extension version (probed: v0.18.0 and v0.18.1
// both return a real 14 MB PE32+ DLL; the pinned 0.18.2 core resolves its
// extension directory to 0.18.1, strace-verified), so the old
// `platform !== 'win32'` gate was stale (#1365-era). Whether the extension
// actually LOADS on a given machine is a runtime question — doctor answers
// it with probeVectorExtensionLoad, and analyze/query degrade to exact scan
// when the load fails.
return {
graph: 'available',
fts: 'available',
vector,
semanticMode: vector === 'available' ? 'vector-index' : 'exact-scan',
vector: 'available',
semanticMode: 'vector-index',
exactScanLimit,
reason:
vector === 'unavailable'
? 'LadybugDB VECTOR is disabled on this platform; semantic search uses exact scan when embeddings exist.'
: undefined,
reason: undefined,
};
};

View file

@ -25,6 +25,7 @@ import {
closeLbugBeforeExit,
loadCachedEmbeddings,
deleteNodesForFiles,
ensureEmbeddingRowDmlSafe,
deleteAllCommunitiesAndProcesses,
deleteAllInterprocTaintPaths,
deleteAllCallSummaries,
@ -1686,7 +1687,44 @@ export async function runFullAnalysis(
// DB write plan changes here; fileHashes/meta bookkeeping is identical.
// Thresholds + the AND-gate live in incremental/escalation-gate.ts.
const writeFraction = effectiveWriteSet.size / Math.max(1, allFilePaths.length);
// VECTOR gate (#2623) — load the extension BEFORE a single embedding row
// is touched. `deleteNodesForFiles` below opens with the CodeEmbedding
// join-delete, and LadybugDB refuses all DML on a table carrying its HNSW
// index unless VECTOR is loaded on this connection; nothing else on this
// path loads it until Phase 4, so every incremental run over a DB that
// already built `code_embedding_idx` died here. Same seam the FTS drop
// occupies at the head of this branch (#2589): index lifecycle first,
// then rows. UNCONDITIONAL — not gated on `shouldGenerateEmbeddings` —
// because a DB carrying the index from an earlier `--embeddings` run hits
// the identical wall on a plain incremental run.
//
// When VECTOR genuinely cannot load, the table is immutable (the index
// cannot be dropped without the extension either), so surgery is
// impossible: fall through to the escalation valve's wipe-and-COPY plan,
// which rebuilds the DB files outright and needs no embedding-row DML.
const embeddingRowDmlSafe = await ensureEmbeddingRowDmlSafe();
if (!embeddingRowDmlSafe && cachedEmbeddings.length === 0) {
// The escalation below WIPES the DB files, and Phase 3.5 restores
// embedding rows from `cachedEmbeddings` — which is only populated when
// `deriveEmbeddingMode` saw `meta.stats.embeddings > 0`. A DB whose meta
// under-reports its embeddings (meta restored from an older run, or a
// count that never got stamped) would therefore have every vector
// silently destroyed by a rebuild it did not ask for. Read them now,
// while the DB is still intact — a plain MATCH, which needs no VECTOR
// extension. Rows whose owning node is gone are dropped by Phase 3.5's
// live-graph filter, exactly as on any other wiped path.
const rescued = await loadCachedEmbeddings();
if (rescued.embeddings.length > 0) {
cachedEmbeddings = rescued.embeddings;
cachedEmbeddingNodeIds = rescued.embeddingNodeIds;
log(
`Preserving ${rescued.embeddings.length} embedding row(s) across the forced rebuild ` +
`(the index metadata did not account for them).`,
);
}
}
if (
!embeddingRowDmlSafe ||
shouldEscalateIncrementalWrite(
filesToDelete.length,
effectiveWriteSet.size,
@ -1695,13 +1733,20 @@ export async function runFullAnalysis(
) {
escalatedFullWrite = true;
log(
`Incremental: effective write set covers ${effectiveWriteSet.size}/${allFilePaths.length} ` +
// Display clamp only (predicate unchanged): BFS-found deleted
// importers can push the numerator past the CURRENT file list, so
// the raw fraction can exceed 1 — see the population-mismatch note
// on shouldEscalateIncrementalWrite (tri-review 4669518496).
`files (${Math.min(100, Math.round(writeFraction * 100))}%) — switching to a full DB write ` +
`(wipe + bulk COPY) for this run; file-level incremental bookkeeping is unaffected.`,
!embeddingRowDmlSafe
? `Incremental: the ${EMBEDDING_TABLE_NAME} vector index exists but the VECTOR ` +
`extension could not be loaded, so embedding rows cannot be rewritten in place — ` +
`switching to a full DB write (wipe + bulk COPY) for this run. Semantic search ` +
`falls back to exact scan until VECTOR is available; run \`gitnexus doctor\` for ` +
`live extension status, or set GITNEXUS_LBUG_EXTENSION_INSTALL=auto to allow one ` +
`bounded install attempt.`
: `Incremental: effective write set covers ${effectiveWriteSet.size}/${allFilePaths.length} ` +
// Display clamp only (predicate unchanged): BFS-found deleted
// importers can push the numerator past the CURRENT file list, so
// the raw fraction can exceed 1 — see the population-mismatch note
// on shouldEscalateIncrementalWrite (tri-review 4669518496).
`files (${Math.min(100, Math.round(writeFraction * 100))}%) — switching to a full DB write ` +
`(wipe + bulk COPY) for this run; file-level incremental bookkeeping is unaffected.`,
);
// toWriteCount: 0 is the established full-path dirty-flag sentinel;
// the real counters ride along for crash diagnostics.
@ -2067,8 +2112,8 @@ export async function runFullAnalysis(
// the case a naive gate would leave index-less again.
// buildVectorIndex carries its own extension-policy gate and
// warn-on-failure; the boolean feeds semanticMode so the finalize stamp
// reflects the DB's ACTUAL state even when recreation fails (win32 /
// extension unavailable → 'exact-scan').
// reflects the DB's ACTUAL state even when recreation fails (extension
// unavailable → 'exact-scan').
const dbWasWiped = !isIncremental || escalatedFullWrite;
if (restoredEmbeddingCount > 0 && dbWasWiped && embeddingSkipped) {
// Re-import at the seam rather than thread a mutable capture from

View file

@ -61,10 +61,7 @@ import {
type ExactEmbeddingRow,
} from '../../core/embeddings/exact-search.js';
import { EMBEDDING_TABLE_NAME, EMBEDDING_INDEX_NAME } from '../../core/lbug/schema.js';
import {
getExactScanLimit,
isVectorExtensionSupportedByPlatform,
} from '../../core/platform/capabilities.js';
import { getExactScanLimit } from '../../core/platform/capabilities.js';
import { PhaseTimer } from '../../core/search/phase-timer.js';
import { ftsDegradedWarning } from '../../core/search/fts-indexes.js';
import {
@ -2419,10 +2416,16 @@ export class LocalBackend {
string,
{ distance: number; chunkIndex: number; startLine: number; endLine: number }
>();
if (isVectorExtensionSupportedByPlatform()) {
try {
bestChunks = await collectBestChunks(limit, async (fetchLimit) => {
const vectorQuery = `
// Always TRY the vector lane — no platform gate. LadybugDB ships the
// VECTOR extension for every supported platform, Windows included
// (#2623 follow-up; the old `platform !== 'win32'` gate was stale), so
// whether the index is queryable is a per-machine runtime fact. The
// catch below is the fallback: any failure (extension unloadable, index
// absent, older DB) degrades to the exact scan with a once-per-backend
// diagnostic instead of being silently swallowed.
try {
bestChunks = await collectBestChunks(limit, async (fetchLimit) => {
const vectorQuery = `
CALL QUERY_VECTOR_INDEX('${EMBEDDING_TABLE_NAME}', '${EMBEDDING_INDEX_NAME}',
CAST(${queryVecStr} AS FLOAT[${dims}]), ${fetchLimit})
YIELD node AS emb, distance
@ -2433,27 +2436,27 @@ export class LocalBackend {
ORDER BY distance
`;
const embResults = await executeQuery(repo.lbugPath, vectorQuery);
return embResults.map((row) => ({
nodeId: row.nodeId ?? row[0],
chunkIndex: row.chunkIndex ?? row[1] ?? 0,
startLine: row.startLine ?? row[2] ?? 0,
endLine: row.endLine ?? row[3] ?? 0,
distance: row.distance ?? row[4],
}));
});
} catch {
bestChunks = new Map();
const embResults = await executeQuery(repo.lbugPath, vectorQuery);
return embResults.map((row) => ({
nodeId: row.nodeId ?? row[0],
chunkIndex: row.chunkIndex ?? row[1] ?? 0,
startLine: row.startLine ?? row[2] ?? 0,
endLine: row.endLine ?? row[3] ?? 0,
distance: row.distance ?? row[4],
}));
});
} catch (err) {
bestChunks = new Map();
if (!this.warnedVectorUnsupported) {
// Rare diagnostic: surface why semantic search fell back to the
// exact scan. Emitted once per `LocalBackend` instance lifetime to
// avoid noisy stderr on hot semantic-search paths (DoD §2.8).
this.warnedVectorUnsupported = true;
logger.warn(
{ err },
'GitNexus [query:vector]: vector index query failed; using exact scan fallback',
);
}
} else if (!this.warnedVectorUnsupported) {
// Rare diagnostic: surface why we fell back to the exact scan path so
// operators can see at a glance that VECTOR is disabled by platform
// policy. Emitted once per `LocalBackend` instance lifetime to avoid
// noisy stderr on hot semantic-search paths (DoD §2.8).
this.warnedVectorUnsupported = true;
logger.warn(
'GitNexus [query:vector]: VECTOR extension not supported on this platform; using exact scan fallback',
);
}
if (bestChunks.size === 0) {

View file

@ -0,0 +1,3 @@
class KnowledgeGraphService:
def extract_and_store_graph(self, text: str) -> None:
pass

View file

@ -0,0 +1,23 @@
from knowledge_graph_service import KnowledgeGraphService
class MemoryService:
def __init__(self, knowledge_graph_service: KnowledgeGraphService):
self.knowledge_graph_service = knowledge_graph_service
def store_memory(self, text: str) -> None:
self.knowledge_graph_service.extract_and_store_graph(text)
def archive_memory(self, text: str) -> None:
self.knowledge_graph_service.extract_and_store_graph(text)
def restore_memory(self, text: str) -> None:
self.knowledge_graph_service.extract_and_store_graph(text)
class ExplicitFieldMemoryService:
def __init__(self, knowledge_graph_service):
self.knowledge_graph_service: KnowledgeGraphService = knowledge_graph_service
def ingest_memory(self, text: str) -> None:
self.knowledge_graph_service.extract_and_store_graph(text)

View file

@ -0,0 +1,6 @@
def extract_and_store_graph(text: str) -> None:
pass
def exercise_decoy(text: str) -> None:
extract_and_store_graph(text)

View file

@ -88,8 +88,8 @@
"digest": "338c3922981604e71ddfc60ad61eba4b17f68ca654644e01add942c729b422cf"
},
"python-call-result-binding/models.py": {
"captureGroups": 16,
"digest": "cbbb5168c28123820a70fe24016b0ed26ab02a6339d21ffe40fbccad940c1d70"
"captureGroups": 17,
"digest": "441e2596001c4eaea4808ae6dd195a031f99d8ffe38c8fd450415c109d3365e2"
},
"python-call-result-binding/service.py": {
"captureGroups": 9,
@ -171,13 +171,25 @@
"captureGroups": 15,
"digest": "201ce01b83b21d729aca89c6299570df55393a95f1899a2eaacd989873950177"
},
"python-constructor-field-receiver/knowledge_graph_service.py": {
"captureGroups": 10,
"digest": "83dcf9f81ac7d0a9e9ed5e467e41acd07e2ec581926d85eea5d4b5e1e4157744"
},
"python-constructor-field-receiver/memory_service.py": {
"captureGroups": 59,
"digest": "ad9c3be5a7c10e112bb20eac20b8603196fc2e739b7567159c58a607639ce192"
},
"python-constructor-field-receiver/test_fixture.py": {
"captureGroups": 13,
"digest": "6f642e752086a5e9337d21accb65ca90ef5af2b3e139239ea12c5cd031844dd2"
},
"python-constructor-type-inference/models/repo.py": {
"captureGroups": 16,
"digest": "ad11823ee187cc3e1efab34a67a5013119b4ada87c5701b080921c0b0be09e62"
"captureGroups": 17,
"digest": "3c400c7a331d7796a730e1ba53b91c4f5ec4799121044b0c160844988fca8662"
},
"python-constructor-type-inference/models/user.py": {
"captureGroups": 16,
"digest": "cbbb5168c28123820a70fe24016b0ed26ab02a6339d21ffe40fbccad940c1d70"
"captureGroups": 17,
"digest": "441e2596001c4eaea4808ae6dd195a031f99d8ffe38c8fd450415c109d3365e2"
},
"python-constructor-type-inference/services/app.py": {
"captureGroups": 13,
@ -192,12 +204,12 @@
"digest": "dd51c32d705934b1384991ad2291869f446327752481abc20600d4ad9f553ea3"
},
"python-dict-items-loop/repo.py": {
"captureGroups": 15,
"digest": "8116cf4cbf4dca377e88f97ca645f40fab648a4e9a8e790b5b3761e4a3e17d7c"
"captureGroups": 16,
"digest": "2d283b4acbc71e318a4520cb7557508b9084213ba53fbdac07457e348a8b24c6"
},
"python-dict-items-loop/user.py": {
"captureGroups": 15,
"digest": "15984fa30be4603f3e47c27342352dd602d104b33ac5224dc911d78a82d87926"
"captureGroups": 16,
"digest": "6568834a7f228e78a11b08282196e138795980aed6c55b9953cc89e87c998e52"
},
"python-django-app-imports/accounts/__init__.py": {
"captureGroups": 0,
@ -288,8 +300,8 @@
"digest": "d1e23831dcae38034b278bfefa2b8c4e21ca722ab2c79bfb3126744338a2a401"
},
"python-enumerate-loop/user.py": {
"captureGroups": 16,
"digest": "cbbb5168c28123820a70fe24016b0ed26ab02a6339d21ffe40fbccad940c1d70"
"captureGroups": 17,
"digest": "441e2596001c4eaea4808ae6dd195a031f99d8ffe38c8fd450415c109d3365e2"
},
"python-field-type-disambig/address.py": {
"captureGroups": 9,
@ -316,8 +328,8 @@
"digest": "5c290b3b34f3f5e9dcdd6ee3ae72ba4223337cd64592330f9a8c6c6f13b2fd2d"
},
"python-for-call-expr/models.py": {
"captureGroups": 39,
"digest": "133a14c0543a41412d9d4fd5485d6270e1f4b0cd247f0ec0d71974850e4918c1"
"captureGroups": 41,
"digest": "e8807a9969732197feb04204d200d5810b895c006f1424a7a6f5f0d5762ef49a"
},
"python-function-local-import-chain/app.py": {
"captureGroups": 9,
@ -464,8 +476,8 @@
"digest": "01fe4805f59723a5f163d26b7be3ed3e456eb3a093e3df3a8f034cecee22ebb9"
},
"python-method-chain-binding/models.py": {
"captureGroups": 45,
"digest": "ea3f745514a330d86447796734faff29f0c88ea49a7ef8781087c537426d29bf"
"captureGroups": 48,
"digest": "f049817034428759193fce03b417ca23c133f6ead87f960e173afba9b79b88e8"
},
"python-method-enrichment/app.py": {
"captureGroups": 13,
@ -680,8 +692,8 @@
"digest": "741f690b6330491303b9b58cb31027a33600973265b59428facfefbabf0cf7e1"
},
"python-return-type-inference/models.py": {
"captureGroups": 16,
"digest": "cbbb5168c28123820a70fe24016b0ed26ab02a6339d21ffe40fbccad940c1d70"
"captureGroups": 17,
"digest": "441e2596001c4eaea4808ae6dd195a031f99d8ffe38c8fd450415c109d3365e2"
},
"python-return-type-inference/service.py": {
"captureGroups": 9,
@ -760,8 +772,8 @@
"digest": "4df7ea089c43552ca4ea5a51f8e985d11d351b86949efb2f7ebefdf6a9ffd689"
},
"python-walrus-operator/models.py": {
"captureGroups": 21,
"digest": "cf014d1bad66ea61e327c146fd1a52159a96faaf264555bb164230a5218e6948"
"captureGroups": 22,
"digest": "9ab1a8a69970e8c875bd103251ecf9c8af2c1464a6bdc1213fe7560c4fa34063"
},
"python-write-access/models.py": {
"captureGroups": 11,
@ -772,7 +784,7 @@
"digest": "6e3690ec68d8de54f376bb6f5f7a29da829a8a6a24001eabae9ec66a327f3409"
},
"synthetic:dao-20": {
"captureGroups": 733,
"digest": "c540f2143882137e6c6f7996bf9b87a0117f41915b1d0989d3d6bb9db5a5a1ab"
"captureGroups": 773,
"digest": "37e047eda37477bbc33f4dd8ba259c3f876580378566c0c14dfe580795a952af"
}
}

View file

@ -0,0 +1,109 @@
/**
* Regression test for #2508 — context()/impact() must return EXACT caller
* identities, never counts alone.
*
* The #2508 failure shape: a target Function with two CALLS edges arriving
* through two different CodeRelation sub-table pairs — a production
* Function→Function caller and a File→Function test-file caller. On affected
* LadybugDB versions (≤0.18.2), `r.type IN [...]` predicates could drop the
* production caller and duplicate the test caller: the boolean-filter
* fallback skipped writing selection buffers for single-row unflat chunks
* (LadybugDB#692). Fixed upstream in LadybugDB#699, shipped in
* @ladybugdb/core 0.18.3. These assertions pin the IN-predicate query paths
* to exact caller IDs so any future predicate regression that drops or
* duplicates a caller fails loudly here.
*/
import { describe, it, expect, beforeAll, vi } from 'vitest';
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
import { listRegisteredRepos } from '../../src/storage/repo-manager.js';
import { withTestLbugDB, type IndexedDBHandle } from '../helpers/test-indexed-db.js';
vi.mock('../../src/storage/repo-manager.js', async (importActual) => ({
...(await importActual<typeof import('../../src/storage/repo-manager.js')>()),
listRegisteredRepos: vi.fn().mockResolvedValue([]),
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
findSiblingClones: vi.fn().mockResolvedValue([]),
}));
type BackendHandle = IndexedDBHandle & { _backend?: LocalBackend };
const TARGET_ID = 'func:classifyOutcome';
const PROD_CALLER_ID = 'func:resilientFetch';
const TEST_CALLER_ID = 'file:resilient-fetch.test';
withTestLbugDB(
'caller-identity-2508',
(handle) => {
describe('caller identity across CodeRelation sub-table pairs (#2508)', () => {
let backend: LocalBackend;
beforeAll(() => {
const ext = handle as BackendHandle;
if (!ext._backend) {
throw new Error('LocalBackend not initialized — afterSetup did not attach _backend');
}
backend = ext._backend;
});
it('context() lists the Function caller and the File caller exactly once each', async () => {
const result = await backend.callTool('context', { name: 'classifyOutcome' });
expect(result).not.toHaveProperty('error');
expect(result.status).toBe('found');
const callerUids = (result.incoming?.calls ?? []).map((c: { uid: string }) => c.uid);
expect(callerUids.sort()).toEqual([TEST_CALLER_ID, PROD_CALLER_ID].sort());
});
it('impact(upstream) returns the production caller by exact id with tests excluded', async () => {
const result = await backend.callTool('impact', {
target: 'classifyOutcome',
direction: 'upstream',
});
expect(result).not.toHaveProperty('error');
expect(result.impactedCount).toBeGreaterThanOrEqual(1);
const directIds = (result.byDepth?.[1] ?? []).map((d: { id: string }) => d.id);
expect(directIds).toContain(PROD_CALLER_ID);
expect(directIds).not.toContain(TEST_CALLER_ID);
});
it('impact(upstream, includeTests) returns both callers by exact id', async () => {
const result = await backend.callTool('impact', {
target: 'classifyOutcome',
direction: 'upstream',
includeTests: true,
});
expect(result).not.toHaveProperty('error');
const directIds = (result.byDepth?.[1] ?? []).map((d: { id: string }) => d.id);
expect(directIds).toContain(PROD_CALLER_ID);
expect(directIds).toContain(TEST_CALLER_ID);
expect(directIds.filter((id: string) => id === TEST_CALLER_ID)).toHaveLength(1);
});
});
},
{
seed: [
`CREATE (t:Function {id: '${TARGET_ID}', name: 'classifyOutcome', filePath: 'src/integrations/resilient-fetch.ts', startLine: 10, endLine: 20, isExported: true, content: 'function classifyOutcome() {}', description: 'classifies fetch outcomes'})`,
`CREATE (p:Function {id: '${PROD_CALLER_ID}', name: 'resilientFetch', filePath: 'src/integrations/resilient-fetch.ts', startLine: 30, endLine: 60, isExported: true, content: 'function resilientFetch() {}', description: 'production caller'})`,
`CREATE (f:File {id: '${TEST_CALLER_ID}', name: 'resilient-fetch.test.ts', filePath: 'test/unit/resilient-fetch.test.ts', content: 'test module'})`,
`MATCH (a:Function), (b:Function) WHERE a.id = '${PROD_CALLER_ID}' AND b.id = '${TARGET_ID}'
CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 0.85, reason: 'direct', step: 0}]->(b)`,
`MATCH (a:File), (b:Function) WHERE a.id = '${TEST_CALLER_ID}' AND b.id = '${TARGET_ID}'
CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 0.9, reason: 'direct', step: 0}]->(b)`,
],
poolAdapter: true,
afterSetup: async (h) => {
vi.mocked(listRegisteredRepos).mockResolvedValue([
{
name: 'caller-identity-repo',
path: '/caller-identity/repo',
storagePath: h.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'abc123',
stats: { files: 2, nodes: 3, communities: 0, processes: 0 },
},
]);
const backend = new LocalBackend();
await backend.init();
(h as BackendHandle)._backend = backend;
},
},
);

View file

@ -18,7 +18,7 @@
* the delete joins `e.nodeId = n.id` through the still-present nodes —
* deleted/quoted files' rows go, survivors' rows stay.
*/
import { describe, it, expect } from 'vitest';
import { describe, it, expect, afterEach, vi } from 'vitest';
import path from 'path';
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
import { buildTestGraph, type TestNodeInput, type TestRelInput } from '../helpers/test-graph.js';
@ -276,3 +276,179 @@ withTestLbugDB('delete-nodes-missing-embedding-table', () => {
}, 120_000);
});
});
/**
* VECTOR-extension gate for embedding-row DML (#2623).
*
* LadybugDB refuses EVERY mutation of a table carrying an HNSW index while
* the VECTOR extension is not loaded on the connection. The surgical
* incremental writeback's FIRST statement is `deleteNodesForFiles`' embedding
* join-delete, and nothing on that path loaded VECTOR until Phase 4 — so an
* incremental analyze over a DB that already had `code_embedding_idx` died
* with "Trying to delete from an index on table CodeEmbedding but its
* extension is not loaded".
*
* `ensureEmbeddingRowDmlSafe` is the seam that answers "is embedding-row DML
* legal right now?" before a single row is touched. Own withTestLbugDB block:
* these cases close and reopen the DB under a different extension-install
* policy, which would wreck the sibling suites' shared connection.
*/
withTestLbugDB('embedding-row-dml-vector-gate', (handle) => {
describe('ensureEmbeddingRowDmlSafe (#2623)', () => {
const FILE_A = 'src/gate-a.ts';
const FILE_B = 'src/gate-b.ts';
const nodeIdFor = (fp: string): string => `Function:${fp}:fn:1`;
/** Reopen the singleton connection under an explicit install policy. */
const reopenWithPolicy = async (policy: string | undefined): Promise<void> => {
const { initLbug, closeLbug } = await import('../../src/core/lbug/lbug-adapter.js');
await closeLbug();
if (policy === undefined) delete process.env.GITNEXUS_LBUG_EXTENSION_INSTALL;
else process.env.GITNEXUS_LBUG_EXTENSION_INSTALL = policy;
await initLbug(handle.dbPath);
};
const seedTwoFilesWithEmbeddings = async (): Promise<void> => {
const { executeQuery, executeWithReusedStatement } =
await import('../../src/core/lbug/lbug-adapter.js');
const { batchInsertEmbeddings } =
await import('../../src/core/embeddings/embedding-pipeline.js');
for (const fp of [FILE_A, FILE_B]) {
await executeQuery(
`CREATE (:Function {id: '${nodeIdFor(fp)}', name: 'fn', filePath: '${fp}', startLine: 1, endLine: 3, isExported: true, content: '', description: ''})`,
);
}
await batchInsertEmbeddings(
executeWithReusedStatement,
[FILE_A, FILE_B].map((fp) => ({
nodeId: nodeIdFor(fp),
chunkIndex: 0,
startLine: 1,
endLine: 3,
embedding: new Array(EMBEDDING_DIMS).fill(0.1),
contentHash: `hash-${fp}`,
})),
);
};
const embeddingCountFor = async (fp: string): Promise<number> => {
const { executeQuery } = await import('../../src/core/lbug/lbug-adapter.js');
const rows = (await executeQuery(
`MATCH (e:${EMBEDDING_TABLE_NAME}) WHERE e.nodeId = '${nodeIdFor(fp)}' RETURN count(e) AS c`,
)) as Array<{ c: number | bigint }>;
return Number(rows[0]?.c ?? 0);
};
const clearSeed = async (): Promise<void> => {
const { executeQuery } = await import('../../src/core/lbug/lbug-adapter.js');
await executeQuery(`MATCH (e:${EMBEDDING_TABLE_NAME}) DELETE e`);
await executeQuery(`MATCH (n:Function) DETACH DELETE n`);
};
afterEach(async () => {
await reopenWithPolicy(undefined);
// Teardown deletes embedding rows, so it is itself subject to #2623 once
// a case has built the index — load VECTOR before clearing.
const { ensureEmbeddingRowDmlSafe } = await import('../../src/core/lbug/lbug-adapter.js');
await ensureEmbeddingRowDmlSafe();
await clearSeed();
});
it('no vector index + VECTOR unavailable → safe, and the delete still works', async () => {
await seedTwoFilesWithEmbeddings();
await reopenWithPolicy('never');
const { ensureEmbeddingRowDmlSafe, deleteNodesForFiles } =
await import('../../src/core/lbug/lbug-adapter.js');
// No HNSW index was ever built, so there is nothing to gate on — the
// degraded path must NOT escalate needlessly.
await expect(ensureEmbeddingRowDmlSafe()).resolves.toBe(true);
await expect(deleteNodesForFiles([FILE_A])).resolves.toBeUndefined();
expect(await embeddingCountFor(FILE_A)).toBe(0);
expect(await embeddingCountFor(FILE_B)).toBe(1);
}, 120_000);
it('vector index present + VECTOR unavailable → blocked, and the raw delete throws', async () => {
await seedTwoFilesWithEmbeddings();
const { createVectorIndex } = await import('../../src/core/lbug/lbug-adapter.js');
const built = await createVectorIndex();
if (!built) return; // VECTOR not installable here — nothing to assert.
await reopenWithPolicy('never');
const { ensureEmbeddingRowDmlSafe, deleteNodesForFiles } =
await import('../../src/core/lbug/lbug-adapter.js');
// The gate must SEE the hazard…
await expect(ensureEmbeddingRowDmlSafe()).resolves.toBe(false);
// …and the hazard must be real: this is the exact #2623 failure.
await expect(deleteNodesForFiles([FILE_A])).rejects.toThrow(/extension is not loaded/);
// Nothing was destroyed by the refused statement.
expect(await embeddingCountFor(FILE_A)).toBe(1);
}, 120_000);
it('vector index present + VECTOR loadable → safe, delete works, index survives', async () => {
await seedTwoFilesWithEmbeddings();
const { createVectorIndex } = await import('../../src/core/lbug/lbug-adapter.js');
const built = await createVectorIndex();
if (!built) return; // VECTOR not installable here — nothing to assert.
// Reopen so the in-process "already loaded" latch cannot mask a missing
// load — this is the state a second `analyze` run actually starts from.
await reopenWithPolicy(undefined);
const { ensureEmbeddingRowDmlSafe, deleteNodesForFiles, executeQuery } =
await import('../../src/core/lbug/lbug-adapter.js');
await expect(ensureEmbeddingRowDmlSafe()).resolves.toBe(true);
await expect(deleteNodesForFiles([FILE_A])).resolves.toBeUndefined();
expect(await embeddingCountFor(FILE_A)).toBe(0);
expect(await embeddingCountFor(FILE_B)).toBe(1);
// The surgical path KEEPS its index (run-analyze relies on HNSW
// self-maintaining across insert/delete) — it must still be there.
const indexes = (await executeQuery('CALL SHOW_INDEXES() RETURN *')) as Array<{
table_name?: string;
index_type?: string;
}>;
expect(
indexes.some((r) => r.table_name === EMBEDDING_TABLE_NAME && r.index_type === 'HNSW'),
).toBe(true);
}, 120_000);
it('catalog read fails → falls back to attempting the extension load (fail-safe)', async () => {
// The one branch where the gate cannot cheaply prove safety: SHOW_INDEXES
// itself errors. It must fall through to loadVectorExtension — in this
// environment the extension IS loadable, so the verdict is still `true`
// and DML proceeds safely despite the unreadable catalog.
await seedTwoFilesWithEmbeddings();
// Reopen so the module-level "already loaded" latch cannot let
// loadVectorExtension return true without issuing a LOAD statement.
await reopenWithPolicy('load-only');
const { ensureEmbeddingRowDmlSafe } = await import('../../src/core/lbug/lbug-adapter.js');
const { default: lbug } = await import('@ladybugdb/core');
const originalQuery = lbug.Connection.prototype.query;
const seen: string[] = [];
const spy = vi.spyOn(lbug.Connection.prototype, 'query').mockImplementation(function (
this: unknown,
sql: string,
...rest: unknown[]
) {
seen.push(sql);
if (sql.includes('SHOW_INDEXES')) {
return Promise.reject(new Error('Catalog exception: forced by test'));
}
return originalQuery.call(this, sql, ...rest);
});
try {
await expect(ensureEmbeddingRowDmlSafe()).resolves.toBe(true);
// The catalog read was attempted and failed…
expect(seen.some((s) => s.includes('SHOW_INDEXES'))).toBe(true);
// …and the fallback really attempted the LOAD instead of guessing.
expect(seen.some((s) => s.toUpperCase().includes('LOAD'))).toBe(true);
} finally {
spy.mockRestore();
}
}, 120_000);
});
});

View file

@ -328,3 +328,140 @@ describe('init lock — single-process ownership contract', () => {
}
});
});
// ---------------------------------------------------------------------------
// Missing-shadow WAL quarantine reclaim (issue #2637)
// ---------------------------------------------------------------------------
const plantMissingShadowQuarantine = async (dbPath: string): Promise<string> => {
const quarantinePath = `${dbPath}.wal.missing-shadow.${Date.now()}-${Math.random()
.toString(36)
.slice(2)}`;
await fs.writeFile(quarantinePath, 'stale-quarantined-wal-bytes');
return quarantinePath;
};
describe('missing-shadow quarantine reclaim — native integration (issue #2637)', () => {
itLbugReopen(
'reclaims a pre-existing missing-shadow WAL quarantine file on write-path init when the main DB is present',
async () => {
const tmp = await createTempDir('gitnexus-lbug-quarantine-');
const dbPath = path.join(tmp.dbPath, 'lbug');
try {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
// Create a real DB first, then close it.
await adapter.initLbug(dbPath);
await adapter.closeLbug();
// Plant a quarantine file left over from an earlier crash.
const quarantinePath = await plantMissingShadowQuarantine(dbPath);
await expect(fs.access(quarantinePath)).resolves.toBeUndefined();
// Re-init with the main DB present — reclaim must fire unconditionally.
await adapter.initLbug(dbPath);
const rows = await adapter.executeQuery('RETURN 1 AS ok');
expect(rows).toEqual([{ ok: 1 }]);
await expect(fs.access(quarantinePath)).rejects.toThrow();
await adapter.closeLbug();
} finally {
await tmp.cleanup();
}
},
);
itLbugReopen(
'reclaims a pre-existing missing-shadow WAL quarantine file when the main DB is ALSO missing (crash-recovery path)',
async () => {
const tmp = await createTempDir('gitnexus-lbug-quarantine-');
const dbPath = path.join(tmp.dbPath, 'lbug');
const shadowPath = `${dbPath}.shadow`;
const walCheckpointPath = `${dbPath}.wal.checkpoint`;
try {
// No main DB file — plant the quarantine file alongside the #1618
// orphan sidecars to prove both cleanup blocks coexist correctly.
const quarantinePath = await plantMissingShadowQuarantine(dbPath);
await fs.writeFile(shadowPath, 'stale-shadow-data');
await fs.writeFile(walCheckpointPath, 'stale-wal-checkpoint-data');
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
await adapter.initLbug(dbPath);
const rows = await adapter.executeQuery('RETURN 1 AS ok');
expect(rows).toEqual([{ ok: 1 }]);
await expect(fs.access(quarantinePath)).rejects.toThrow();
await expect(fs.access(shadowPath)).rejects.toThrow();
await expect(fs.access(walCheckpointPath)).rejects.toThrow();
await adapter.closeLbug();
} finally {
await tmp.cleanup();
}
},
);
itLbugReopen(
'leaves a missing-shadow quarantine file untouched when GITNEXUS_DISABLE_LBUG_SIDECAR_PREFLIGHT=1',
async () => {
const tmp = await createTempDir('gitnexus-lbug-quarantine-');
const dbPath = path.join(tmp.dbPath, 'lbug');
const previousEnv = process.env.GITNEXUS_DISABLE_LBUG_SIDECAR_PREFLIGHT;
try {
const quarantinePath = await plantMissingShadowQuarantine(dbPath);
process.env.GITNEXUS_DISABLE_LBUG_SIDECAR_PREFLIGHT = '1';
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
await adapter.initLbug(dbPath);
const rows = await adapter.executeQuery('RETURN 1 AS ok');
expect(rows).toEqual([{ ok: 1 }]);
// Reclaim was suppressed — the quarantine file survives.
await expect(fs.access(quarantinePath)).resolves.toBeUndefined();
await adapter.closeLbug();
} finally {
if (previousEnv === undefined) {
delete process.env.GITNEXUS_DISABLE_LBUG_SIDECAR_PREFLIGHT;
} else {
process.env.GITNEXUS_DISABLE_LBUG_SIDECAR_PREFLIGHT = previousEnv;
}
await tmp.cleanup();
}
},
);
itLbugReopen(
'does not touch a .dirty-recovery parked sidecar (isolation from the missing-shadow family)',
async () => {
const tmp = await createTempDir('gitnexus-lbug-quarantine-');
const dbPath = path.join(tmp.dbPath, 'lbug');
const dirtyRecoveryPath = `${dbPath}.wal.dirty-recovery`;
try {
await fs.writeFile(dirtyRecoveryPath, 'parked-from-an-interrupted-dirty-recovery-rebuild');
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
await adapter.initLbug(dbPath);
const rows = await adapter.executeQuery('RETURN 1 AS ok');
expect(rows).toEqual([{ ok: 1 }]);
// Different sidecar family, different lifecycle — must survive.
await expect(fs.access(dirtyRecoveryPath)).resolves.toBeUndefined();
await adapter.closeLbug();
} finally {
await tmp.cleanup();
}
},
);
});

View file

@ -315,3 +315,91 @@ withTestLbugDB(
poolAdapter: true,
},
);
/**
* Pool vector lane (#2623 follow-up).
*
* Extension load scope is per-Database, and the pool pre-warm historically
* loaded only FTS — so `CALL QUERY_VECTOR_INDEX` through the pool ALWAYS
* raised `Catalog exception: function QUERY_VECTOR_INDEX is not defined` and
* LocalBackend's semantic lane silently exact-scanned. This block pins that
* the pool's shared Database really can serve the vector lane: rows and the
* HNSW index are built through the core adapter first (the state `analyze
* --embeddings` leaves behind), then the pool opens and must answer a vector
* query. Own withTestLbugDB block: the vector index would leak into the
* sibling suites' shared fixture expectations.
*/
withTestLbugDB(
'lbug-pool-vector-lane',
(handle) => {
describe('pool vector lane (#2623 follow-up)', () => {
afterEach(async () => {
try {
await closeLbug('vec-repo');
} catch {
/* best-effort */
}
});
it('QUERY_VECTOR_INDEX works through the pool once the pre-warm loads VECTOR', async (ctx) => {
const core = await import('../../src/core/lbug/lbug-adapter.js');
const { batchInsertEmbeddings } =
await import('../../src/core/embeddings/embedding-pipeline.js');
const { EMBEDDING_TABLE_NAME, EMBEDDING_INDEX_NAME, EMBEDDING_DIMS } =
await import('../../src/core/lbug/schema.js');
// Seed one embedding row for the fixture Function through the CORE
// adapter (writable), then build the HNSW index — skip visibly when
// VECTOR is unavailable in this environment, matching the
// lbug-vector-extension suite convention.
const embedding = new Array(EMBEDDING_DIMS).fill(0);
embedding[0] = 1;
await batchInsertEmbeddings(core.executeWithReusedStatement, [
{
nodeId: 'func:vec',
chunkIndex: 0,
startLine: 1,
endLine: 3,
embedding,
contentHash: 'vec-hash',
},
]);
const indexBuilt = await core.createVectorIndex();
if (!indexBuilt) {
console.warn('[lbug-pool-vector-lane] Skipping — VECTOR unavailable.');
ctx.skip();
return;
}
// Close the writable core adapter so the pool opens its OWN read-only
// Database. This is what makes the case discriminating: extension
// loads are per-Database, so a shared/injected Database would inherit
// the VECTOR load from createVectorIndex above and pass even without
// the pre-warm fix. A fresh Database has nothing loaded — only the
// pool's own pre-warm can make the vector lane legal.
await core.closeLbug();
// The regression: through the POOL, the vector lane must work without
// any caller loading the extension. Pre-fix this rejects with
// "Catalog exception: function QUERY_VECTOR_INDEX is not defined".
await initLbug('vec-repo', handle.dbPath);
const vec = `CAST([${embedding.join(',')}] AS FLOAT[${EMBEDDING_DIMS}])`;
const rows = (await executeQuery(
'vec-repo',
`CALL QUERY_VECTOR_INDEX('${EMBEDDING_TABLE_NAME}', '${EMBEDDING_INDEX_NAME}', ${vec}, 1)
YIELD node AS emb, distance
RETURN emb.nodeId AS nodeId, distance`,
)) as Array<{ nodeId: string; distance: number }>;
expect(rows.length).toBe(1);
expect(String(rows[0].nodeId)).toBe('func:vec');
expect(Number(rows[0].distance)).toBeLessThan(1e-6);
}, 120_000);
});
},
{
seed: [
`CREATE (fn:Function {id: 'func:vec', name: 'vec', filePath: 'src/vec.ts', startLine: 1, endLine: 3, isExported: true, content: '', description: ''})`,
],
},
);

View file

@ -88,9 +88,11 @@ withTestLbugDB('vector-extension', (handle) => {
* LadybugDB so a revert to the prepared path fails loudly.
*/
withTestLbugDB('vector-index-creation', () => {
// VECTOR is platform-sensitive (skipped on win32 / unsupported platforms,
// and when it cannot be installed offline). Probe once, skip the suite if
// unavailable — mirrors the FTS-skip convention in withTestLbugDB.
// VECTOR is environment-sensitive (skipped when the extension cannot be
// loaded or installed — offline machines without a pre-installed file).
// Probe once, skip the suite if unavailable — mirrors the FTS-skip
// convention in withTestLbugDB. No platform is categorically excluded:
// win_amd64 artifacts ship for every 0.18.x extension version (#2623).
let vectorAvailable = false;
let skipWarned = false;
beforeAll(async () => {

View file

@ -0,0 +1,41 @@
import { beforeAll, describe, expect, it } from 'vitest';
import path from 'node:path';
import { FIXTURES, getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js';
describe('Python calls through constructor-assigned receiver fields', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'python-constructor-field-receiver'),
() => {},
);
}, 60_000);
it('resolves all production callers to the receiver-constrained method', () => {
const productionCalls = getRelationships(result, 'CALLS').filter(
(edge) =>
edge.target === 'extract_and_store_graph' &&
edge.targetFilePath === 'knowledge_graph_service.py',
);
expect(productionCalls.map((edge) => `${edge.sourceFilePath}:${edge.source}`).sort()).toEqual([
'memory_service.py:archive_memory',
'memory_service.py:ingest_memory',
'memory_service.py:restore_memory',
'memory_service.py:store_memory',
]);
expect(productionCalls.every((edge) => edge.rel.confidence >= 0.85)).toBe(true);
});
it('does not redirect production calls to the same-named decoy', () => {
const misresolved = getRelationships(result, 'CALLS').filter(
(edge) =>
edge.sourceFilePath === 'memory_service.py' &&
edge.target === 'extract_and_store_graph' &&
edge.targetFilePath === 'test_fixture.py',
);
expect(misresolved).toEqual([]);
});
});

View file

@ -18,7 +18,7 @@
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { lbugMocks, platformMocks } = vi.hoisted(() => ({
const { lbugMocks } = vi.hoisted(() => ({
lbugMocks: {
initLbug: vi.fn().mockResolvedValue(undefined),
executeQuery: vi.fn().mockResolvedValue([]),
@ -26,9 +26,6 @@ const { lbugMocks, platformMocks } = vi.hoisted(() => ({
closeLbug: vi.fn().mockResolvedValue(undefined),
isLbugReady: vi.fn().mockReturnValue(true),
},
platformMocks: {
isVectorExtensionSupportedByPlatform: vi.fn().mockReturnValue(true),
},
}));
vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => {
@ -66,14 +63,6 @@ vi.mock('../../src/storage/git.js', async (importOriginal) => {
};
});
vi.mock('../../src/core/platform/capabilities.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/core/platform/capabilities.js')>();
return {
...actual,
isVectorExtensionSupportedByPlatform: platformMocks.isVectorExtensionSupportedByPlatform,
};
});
vi.mock('../../src/core/search/bm25-index.js', () => ({
searchFTSFromLbug: vi.fn().mockResolvedValue({ results: [], ftsAvailable: true }),
}));
@ -145,7 +134,6 @@ describe('LocalBackend PDG impact — resolved-callee-id bridge (U6)', () => {
beforeEach(async () => {
vi.clearAllMocks();
platformMocks.isVectorExtensionSupportedByPlatform.mockReturnValue(true);
// READY PDG layer so the dispatch reaches the mode-dispatch / bridge surface.
vi.mocked(loadMeta).mockResolvedValue({
pdg: { maxCdgEdgesPerFunction: 0, maxReachingDefEdgesPerFunction: 0 },

View file

@ -17,7 +17,7 @@ import path from 'path';
// local-backend.ts imports from core/lbug/pool-adapter.js; the mcp/core/lbug-adapter.js
// re-exports from the same module, so we mock the canonical source.
// vi.hoisted runs before vi.mock hoisting, making the fns available to both factories.
const { lbugMocks, platformMocks } = vi.hoisted(() => ({
const { lbugMocks } = vi.hoisted(() => ({
lbugMocks: {
initLbug: vi.fn().mockResolvedValue(undefined),
executeQuery: vi.fn().mockResolvedValue([]),
@ -25,9 +25,6 @@ const { lbugMocks, platformMocks } = vi.hoisted(() => ({
closeLbug: vi.fn().mockResolvedValue(undefined),
isLbugReady: vi.fn().mockReturnValue(true),
},
platformMocks: {
isVectorExtensionSupportedByPlatform: vi.fn().mockReturnValue(true),
},
}));
vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => {
@ -76,14 +73,6 @@ vi.mock('../../src/storage/git.js', async (importOriginal) => {
};
});
vi.mock('../../src/core/platform/capabilities.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/core/platform/capabilities.js')>();
return {
...actual,
isVectorExtensionSupportedByPlatform: platformMocks.isVectorExtensionSupportedByPlatform,
};
});
// Also mock the search modules to avoid loading onnxruntime
vi.mock('../../src/core/search/bm25-index.js', () => ({
searchFTSFromLbug: vi.fn().mockResolvedValue({ results: [], ftsAvailable: true }),
@ -300,7 +289,6 @@ describe('LocalBackend.callTool', () => {
beforeEach(async () => {
vi.clearAllMocks();
platformMocks.isVectorExtensionSupportedByPlatform.mockReturnValue(true);
backend = new LocalBackend();
setupSingleRepo();
await backend.init();
@ -549,11 +537,18 @@ describe('LocalBackend.callTool', () => {
}
});
it('skips vector index query when VECTOR is unsupported by the platform', async () => {
it('falls back to the exact scan with a once-per-backend warning when the vector index query fails', async () => {
// The platform gate is gone (#2623 follow-up): the vector lane is always
// ATTEMPTED, and a runtime failure (extension unloadable, index absent) is
// what routes semantic search onto the exact scan.
const cap = _captureLogger();
platformMocks.isVectorExtensionSupportedByPlatform.mockReturnValue(false);
(executeQuery as any).mockImplementation(async (_repoId: string, cypher: string) => {
if (cypher.includes('COUNT(*) AS cnt')) return [{ cnt: 1 }];
if (cypher.includes('QUERY_VECTOR_INDEX')) {
throw new Error(
'Binder exception: Trying to read from an index on table CodeEmbedding but its extension is not loaded.',
);
}
if (cypher.includes('MATCH (e:CodeEmbedding)')) return [];
return [];
});
@ -565,7 +560,9 @@ describe('LocalBackend.callTool', () => {
const queries = (executeQuery as any).mock.calls.map(
([, cypher]: [string, string]) => cypher,
);
expect(queries.some((cypher: string) => cypher.includes('QUERY_VECTOR_INDEX'))).toBe(false);
// The vector lane was attempted…
expect(queries.some((cypher: string) => cypher.includes('QUERY_VECTOR_INDEX'))).toBe(true);
// …and its failure routed the query onto the exact scan.
expect(
queries.some(
(cypher: string) =>
@ -578,7 +575,7 @@ describe('LocalBackend.callTool', () => {
.records()
.some((r) =>
String(r.msg ?? '').includes(
'GitNexus [query:vector]: VECTOR extension not supported on this platform',
'GitNexus [query:vector]: vector index query failed; using exact scan fallback',
),
),
).toBe(true);
@ -588,7 +585,6 @@ describe('LocalBackend.callTool', () => {
});
it('issues vector index query when VECTOR is supported by the platform', async () => {
platformMocks.isVectorExtensionSupportedByPlatform.mockReturnValue(true);
(executeQuery as any).mockImplementation(async (_repoId: string, cypher: string) => {
if (cypher.includes('COUNT(*) AS cnt')) return [{ cnt: 1 }];
return [];
@ -605,7 +601,6 @@ describe('LocalBackend.callTool', () => {
});
it('threads GITNEXUS_VECTOR_MAX_DISTANCE into the vector index WHERE clause', async () => {
platformMocks.isVectorExtensionSupportedByPlatform.mockReturnValue(true);
vi.mocked(executeQuery).mockImplementation(async (_repoId: string, cypher: string) => {
if (cypher.includes('COUNT(*) AS cnt')) return [{ cnt: 1 }];
return [];
@ -1924,7 +1919,6 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => {
beforeEach(async () => {
vi.clearAllMocks();
platformMocks.isVectorExtensionSupportedByPlatform.mockReturnValue(true);
// U2: stamp a READY PDG layer (both caps) so the layer-presence probe in
// `_impactImpl` falls THROUGH to the mode-dispatch surface these tests pin
// (the `_runImpactPDG` delegate / the ambiguous fan-out under `mode:'pdg'`).
@ -3338,7 +3332,6 @@ describe('LocalBackend.listReposPage / callTool list_repos pagination (#2119)',
beforeEach(async () => {
vi.clearAllMocks();
platformMocks.isVectorExtensionSupportedByPlatform.mockReturnValue(true);
backend = new LocalBackend();
});

View file

@ -5,6 +5,7 @@ import {
localEmbeddingDoctorStatus,
padDisplayEnd,
pageSizeDoctorLines,
poolSizeDoctorLine,
} from '../../src/cli/doctor.js';
describe('doctor output formatting', () => {
@ -164,6 +165,28 @@ describe('doctor page-size lines (#1231, #2424 review)', () => {
});
});
describe('doctor pool-size line (#2631)', () => {
const MiB = 1024 * 1024;
it('prints the hintless pool in MiB with no env note when the env var is unset', () => {
expect(poolSizeDoctorLine(2048 * MiB, undefined)).toBe(
` ${padDisplayEnd('pool size', 10)}2048 MiB`,
);
});
it('marks an operator-supplied absolute value as an env override, with no scaling suffix', () => {
expect(poolSizeDoctorLine(4096 * MiB, String(4096 * MiB))).toBe(
` ${padDisplayEnd('pool size', 10)}4096 MiB (env override)`,
);
});
it('labels the 0 sentinel as the native default instead of "0 MiB"', () => {
expect(poolSizeDoctorLine(0, '0')).toBe(
` ${padDisplayEnd('pool size', 10)}native 80% of RAM (env override)`,
);
});
});
describe('doctor survives a malformed GITNEXUS_EMBEDDING_DIMS (#2385)', () => {
const ENV_KEYS = [
'GITNEXUS_EMBEDDING_URL',

View file

@ -92,8 +92,9 @@ describe('partitionManifestWindows (issue #2189 windowed resolution)', () => {
// ── Surface 2: real-pool residency bound through syncGroup ───────────────────
const { loadFTSExtensionMock, openCounter } = vi.hoisted(() => ({
const { loadFTSExtensionMock, loadVectorExtensionMock, openCounter } = vi.hoisted(() => ({
loadFTSExtensionMock: vi.fn(),
loadVectorExtensionMock: vi.fn().mockResolvedValue(false),
openCounter: { live: 0, peak: 0 },
}));
@ -122,6 +123,7 @@ vi.mock('@ladybugdb/core', () => ({
vi.mock('../../../src/core/lbug/lbug-adapter.js', () => ({
isReadOnlyDbError: vi.fn(() => false),
loadFTSExtension: loadFTSExtensionMock,
loadVectorExtension: loadVectorExtensionMock,
}));
vi.mock('../../../src/core/lbug/lbug-config.js', () => ({

View file

@ -931,8 +931,9 @@ describe('runFullAnalysis — incremental orchestration', () => {
* and boot a real embedder in CI. This run stays preserve-only (no force).
*
* Skip-gated on VECTOR availability (the lbug-vector-extension.test.ts
* pattern): hard-false on win32; statically linked on linux-x64, so the
* assertions genuinely run in CI — and on win32 the honest stamp is
* pattern): skipped only where the extension genuinely cannot load —
* no platform is categorically excluded any more (#2623 follow-up) — and
* where it cannot, the honest stamp is
* 'exact-scan', which the unit-level wiring pin in
* run-analyze-fts-repair.test.ts covers platform-independently.
*/

View file

@ -0,0 +1,267 @@
/**
* #2623: the incremental writeback must have the VECTOR extension loaded
* BEFORE `deleteNodesForFiles` runs — its very first statement is the
* `CodeEmbedding` join-delete, and LadybugDB refuses every mutation of a
* table carrying an HNSW index while the extension is unloaded:
*
* Binder exception: Trying to delete from an index on table CodeEmbedding
* but its extension is not loaded.
*
* Nothing on that path loaded VECTOR until Phase 4, so any repo that had
* built `code_embedding_idx` crashed on the next content change — on machines
* where VECTOR loads perfectly well. This is the sibling of the #2589 FTS
* drop-before-delete ordering test and deliberately mirrors its shape: drive
* the real `runFullAnalysis` incremental path against a real git repo and a
* real LadybugDB, and assert the index state at the exact moment
* `deleteNodesForFiles` is invoked.
*/
import { readFile, writeFile } from 'fs/promises';
import { execSync } from 'child_process';
import path from 'path';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { setupMiniRepo } from '../helpers/mini-repo.js';
import { seedEmbeddingsForFiles, stampEmbeddingCount } from '../helpers/embedding-seed.js';
import { getStoragePaths } from '../../src/storage/repo-manager.js';
import { createTempDir } from '../helpers/test-db.js';
import { EMBEDDING_TABLE_NAME } from '../../src/core/lbug/schema.js';
import { resolveAnalyzeInstallPolicy } from '../../src/core/lbug/extension-loader.js';
const vectorMustBeAvailable = process.env.GITNEXUS_REQUIRE_VECTOR === '1';
const commitAll = (cwd: string, message: string): void => {
execSync('git -c user.name=test -c user.email=t@t -c commit.gpgsign=false add -A', {
cwd,
stdio: 'pipe',
});
execSync(
`git -c user.name=test -c user.email=t@t -c commit.gpgsign=false commit -q -m "${message}"`,
{ cwd, stdio: 'pipe' },
);
};
describe('runFullAnalysis incremental writeback — VECTOR loaded before embedding-row DML (#2623)', () => {
let vectorAvailable = true;
let skipWarned = false;
beforeAll(async () => {
const lbugAdapter = await import('../../src/core/lbug/lbug-adapter.js');
// Cheap standalone probe, matching the #2589 suite's convention: settle
// availability once, up front, not inside the expensive test body.
const probe = await createTempDir('gitnexus-2623-vector-probe-');
try {
await lbugAdapter.initLbug(probe.dbPath);
vectorAvailable = await lbugAdapter.loadVectorExtension(undefined, {
policy: resolveAnalyzeInstallPolicy(),
});
} finally {
await lbugAdapter.closeLbug();
await probe.cleanup();
}
}, 120_000);
// Skip VISIBLY: a silent `return` would report a false pass and hide an
// ordering regression in exactly the environments least likely to notice.
beforeEach((ctx) => {
if (!vectorAvailable) {
if (vectorMustBeAvailable) {
throw new Error(
'GITNEXUS_REQUIRE_VECTOR=1 but the VECTOR extension is unavailable — cannot verify the #2623 ordering fix.',
);
}
if (!skipWarned) {
skipWarned = true;
console.warn(
'[incremental-vector-extension-ordering] Skipping — the LadybugDB VECTOR extension is unavailable.',
);
}
ctx.skip();
}
});
afterEach(() => {
vi.doUnmock('../../src/core/lbug/lbug-adapter.js');
vi.resetModules();
});
it('completes the surgical incremental run with the HNSW index present, and VECTOR is loaded by the time deleteNodesForFiles runs', async () => {
const lbugAdapter = await import('../../src/core/lbug/lbug-adapter.js');
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
const repo = await setupMiniRepo('gitnexus-2623-vector-order-');
try {
// First run: full rebuild, real graph.
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
// Seed real embedding rows for two files, then build the HNSW index —
// the state a prior `analyze --embeddings` leaves behind. Zero vectors
// need no extension for the TABLE; only the index is extension-gated.
// POSIX literals, NOT path.join: the graph stores repo-relative
// filePaths with forward slashes on every OS, and a Windows backslash
// inside the seed helper's single-quoted Cypher literal is a parser
// error ("Invalid input <... n.filePath = '>"). path.join stays only
// for real filesystem access below.
const changedFile = 'src/handler.ts';
const untouchedFile = 'src/validator.ts';
const seeded = await seedEmbeddingsForFiles(repo.dbPath, [changedFile, untouchedFile], 2);
const changedIds = seeded.get(changedFile) ?? [];
const untouchedIds = seeded.get(untouchedFile) ?? [];
expect(changedIds.length).toBeGreaterThan(0);
expect(untouchedIds.length).toBeGreaterThan(0);
const { lbugPath, storagePath } = getStoragePaths(repo.dbPath);
// Without this, deriveEmbeddingMode sees a repo with no embeddings, the
// Phase 3.5 restore never engages, and rows deleted by the importer-BFS
// write-set expansion simply never come back — which would make the
// preservation assertion below measure the wrong thing.
await stampEmbeddingCount(storagePath, changedIds.length + untouchedIds.length);
const readEmbeddingIndexRows = async (): Promise<Array<Record<string, unknown>>> => {
const rows = (await lbugAdapter.executeQuery('CALL SHOW_INDEXES() RETURN *')) as Array<
Record<string, unknown>
>;
return rows.filter((r) => r.table_name === EMBEDDING_TABLE_NAME && r.index_type !== 'HASH');
};
await lbugAdapter.initLbug(lbugPath);
const indexBuilt = await lbugAdapter.createVectorIndex();
const indexRowsBefore = await readEmbeddingIndexRows();
await lbugAdapter.closeLbug();
// The beforeEach gate already proved VECTOR loads here, so a failure to
// build the index is a real bug, not an environment gap.
expect(indexBuilt).toBe(true);
expect(indexRowsBefore.length).toBeGreaterThan(0);
// Record the index's extension state at the exact moment the embedding
// join-delete is about to run. Pre-fix this is `false` and the run then
// throws; post-fix the gate has already loaded VECTOR.
let embeddingIndexAtDeleteTime: Array<Record<string, unknown>> | undefined;
const originalDeleteNodesForFiles = lbugAdapter.deleteNodesForFiles;
vi.spyOn(lbugAdapter, 'deleteNodesForFiles').mockImplementation(async (filePaths, opts) => {
embeddingIndexAtDeleteTime = await readEmbeddingIndexRows();
return originalDeleteNodesForFiles(filePaths, opts);
});
// One-file change keeps this well under the 50-file escalation
// threshold on a 7-file repo, so it takes the surgical branch.
const handlerPath = path.join(repo.dbPath, changedFile);
await writeFile(
handlerPath,
(await readFile(handlerPath, 'utf-8')) + '\n// #2623 ordering-test touch\n',
'utf-8',
);
commitAll(repo.dbPath, '#2623 ordering touch');
// THE regression: before the fix this rejects with
// "Trying to delete from an index on table CodeEmbedding".
await expect(
runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }),
).resolves.toBeDefined();
// Ordering proof: the index was still there AND its extension was
// loaded when the delete ran — the fix loads VECTOR rather than
// dropping the index (run-analyze relies on HNSW self-maintaining
// across a surgical run).
expect(embeddingIndexAtDeleteTime).toBeDefined();
expect(embeddingIndexAtDeleteTime!.length).toBeGreaterThan(0);
for (const row of embeddingIndexAtDeleteTime!) {
expect(row.extension_loaded).toBe(true);
}
// Data outcome: the untouched file's rows survive, and nothing is
// duplicated. (The changed file's rows are removed by the join-delete
// and restored by Phase 3.5, so their count must stay at exactly one
// per nodeId — a PK duplicate would mean the delete silently no-op'd.)
await lbugAdapter.initLbug(lbugPath);
try {
const perNode = (await lbugAdapter.executeQuery(
`MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN e.nodeId AS nodeId, count(e) AS c`,
)) as Array<{ nodeId: string; c: number | bigint }>;
const counts = new Map(perNode.map((r) => [String(r.nodeId), Number(r.c)]));
for (const id of untouchedIds) {
expect(counts.get(id)).toBe(1);
}
for (const [, c] of counts) {
expect(c).toBe(1);
}
// The index is still there — the surgical path keeps it.
expect((await readEmbeddingIndexRows()).length).toBeGreaterThan(0);
} finally {
await lbugAdapter.closeLbug();
}
} finally {
await repo.cleanup();
}
}, 300_000);
it('escalates to a full DB write instead of crashing when VECTOR cannot be loaded', async () => {
const lbugAdapter = await import('../../src/core/lbug/lbug-adapter.js');
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
const repo = await setupMiniRepo('gitnexus-2623-vector-blocked-');
const previousPolicy = process.env.GITNEXUS_LBUG_EXTENSION_INSTALL;
try {
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
// POSIX literal for the graph-side path (see the note in the first case).
const seeded = await seedEmbeddingsForFiles(repo.dbPath, ['src/handler.ts'], 2);
const seededIds = seeded.get('src/handler.ts') ?? [];
expect(seededIds.length).toBeGreaterThan(0);
// Deliberately NOT stampEmbeddingCount: this pins the case where the DB
// holds embedding rows that meta does not account for. The escalation
// wipes the DB, so without an explicit rescue read those rows would be
// destroyed silently — the run would still "succeed" and the loss would
// be invisible.
const { lbugPath } = getStoragePaths(repo.dbPath);
await lbugAdapter.initLbug(lbugPath);
const indexBuilt = await lbugAdapter.createVectorIndex();
await lbugAdapter.closeLbug();
expect(indexBuilt).toBe(true);
const handlerPath = path.join(repo.dbPath, 'src', 'handler.ts');
await writeFile(
handlerPath,
(await readFile(handlerPath, 'utf-8')) + '\n// #2623 blocked-path touch\n',
'utf-8',
);
commitAll(repo.dbPath, '#2623 blocked touch');
// VECTOR becomes unloadable for this run. The table is now immutable
// (the index cannot be dropped without the extension either), so the
// run must abandon surgery rather than fail mid-writeback.
process.env.GITNEXUS_LBUG_EXTENSION_INSTALL = 'never';
const logs: string[] = [];
await expect(
runFullAnalysis(
repo.dbPath,
{ skipAgentsMd: true },
{ onProgress: () => {}, onLog: (m: string) => logs.push(m) },
),
).resolves.toBeDefined();
expect(logs.some((m) => m.includes('full DB write'))).toBe(true);
expect(logs.some((m) => m.includes('VECTOR'))).toBe(true);
// The forced rebuild must NOT eat the embeddings it never asked to touch.
await lbugAdapter.initLbug(lbugPath);
try {
const surviving = (await lbugAdapter.executeQuery(
`MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN e.nodeId AS nodeId`,
)) as Array<{ nodeId: string }>;
const survivingIds = new Set(surviving.map((r) => String(r.nodeId)));
for (const id of seededIds) {
expect(survivingIds.has(id)).toBe(true);
}
// …and exactly once each — the restore must not double-insert.
expect(surviving.length).toBe(survivingIds.size);
} finally {
await lbugAdapter.closeLbug();
}
expect(logs.some((m) => m.includes('Preserving'))).toBe(true);
} finally {
if (previousPolicy === undefined) delete process.env.GITNEXUS_LBUG_EXTENSION_INSTALL;
else process.env.GITNEXUS_LBUG_EXTENSION_INSTALL = previousPolicy;
await repo.cleanup();
}
}, 300_000);
});

View file

@ -44,6 +44,7 @@ function makeFsMock(dbPath: string) {
rename: vi.fn(async () => {}),
mkdir: vi.fn(async () => {}),
open: makeOpenMock(),
readdir: vi.fn(async () => []),
},
};
}
@ -586,6 +587,7 @@ function makeFsMockWithWalSize(
rename: vi.fn(async () => {}),
mkdir: vi.fn(async () => {}),
open: makeOpenMock(),
readdir: vi.fn(async () => []),
},
};
}

View file

@ -45,6 +45,7 @@ const mockFsForInit = (dbPath: string) => {
unlink: vi.fn(async () => {}),
mkdir: vi.fn(async () => {}),
open: makeOpenMock(),
readdir: vi.fn(async () => []),
},
}));
};
@ -85,6 +86,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => {
unlink: unlinkMock,
mkdir: vi.fn(async () => {}),
open: makeOpenMock(),
readdir: vi.fn(async () => []),
},
}));
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
@ -156,6 +158,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => {
unlink: unlinkMock,
mkdir: vi.fn(async () => {}),
open: makeOpenMock(),
readdir: vi.fn(async () => []),
},
}));
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
@ -220,6 +223,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => {
unlink: unlinkMock,
mkdir: vi.fn(async () => {}),
open: makeOpenMock(),
readdir: vi.fn(async () => []),
},
}));
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
@ -285,6 +289,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => {
unlink: unlinkMock,
mkdir: vi.fn(async () => {}),
open: makeOpenMock(),
readdir: vi.fn(async () => []),
},
}));
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
@ -345,6 +350,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => {
unlink: unlinkMock,
mkdir: vi.fn(async () => {}),
open: makeOpenMock(),
readdir: vi.fn(async () => []),
},
}));
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
@ -415,6 +421,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => {
unlink: unlinkMock,
mkdir: vi.fn(async () => {}),
open: makeOpenMock(),
readdir: vi.fn(async () => []),
},
}));
const openLbugConnectionMock = vi.fn(async () => ({ db, conn }));

View file

@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { execFileSync } from 'child_process';
import {
_resetOsPageSizeCacheForTest,
_setOsPageSizeForTests,
getOsPageSize,
isLbugPageSizeFrameError,
isPageSizeAwareLadybug,
@ -92,7 +92,7 @@ describe('isPageSizeAwareLadybug', () => {
describe('getOsPageSize', () => {
afterEach(() => {
_resetOsPageSizeCacheForTest();
_setOsPageSizeForTests(undefined);
execFileSyncSpy.mockClear();
});
@ -155,7 +155,7 @@ describe('getOsPageSize', () => {
it.skipIf(onWindows)('probes at most once per process (cached)', () => {
expect(getOsPageSize()).toBe(getOsPageSize());
expect(execFileSyncSpy).toHaveBeenCalledTimes(1);
_resetOsPageSizeCacheForTest();
_setOsPageSizeForTests(undefined);
getOsPageSize();
expect(execFileSyncSpy).toHaveBeenCalledTimes(2);
});

View file

@ -1,11 +1,13 @@
import os from 'os';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
createLbugDatabase,
estimateBufferPool,
isLbugCheckpointIoError,
isWalCorruptionError,
setBufferPoolSizeHint,
_setOsPageSizeForTests,
bufferPoolExhaustionRemedy,
} from '../../src/core/lbug/lbug-config.js';
import { _captureLogger } from '../../src/core/logger.js';
@ -166,6 +168,12 @@ describe('createLbugDatabase WAL replay option', () => {
describe('createLbugDatabase buffer pool size (#2557)', () => {
const GiB = 1024 * 1024 * 1024;
// Pin a 4 KiB page so every expectation below is host-independent — on a
// 16 KiB-page Apple Silicon runner the #2631 granule scaling would
// otherwise multiply them by 4.
beforeEach(() => _setOsPageSizeForTests(4096));
afterEach(() => _setOsPageSizeForTests(undefined));
const bufferPoolArg = (Database: ReturnType<typeof vi.fn>): unknown => Database.mock.calls[0][1];
it.each([
@ -259,7 +267,11 @@ describe('adaptive buffer pool hint', () => {
const MiB = 1024 * 1024;
const bufferPoolArg = (Database: ReturnType<typeof vi.fn>): unknown => Database.mock.calls[0][1];
afterEach(() => setBufferPoolSizeHint(undefined));
beforeEach(() => _setOsPageSizeForTests(4096));
afterEach(() => {
setBufferPoolSizeHint(undefined);
_setOsPageSizeForTests(undefined);
});
describe('estimateBufferPool', () => {
it.each([
@ -355,3 +367,138 @@ describe('isLbugCheckpointIoError', () => {
expect(isLbugCheckpointIoError(undefined)).toBe(false);
});
});
// ─── #2631: page-size-scaled pool sizing (granule accounting) ───────────────
describe('page-size-scaled buffer pool sizing (#2631)', () => {
const MiB = 1024 * 1024;
const GiB = 1024 * MiB;
const bufferPoolArg = (Database: ReturnType<typeof vi.fn>): unknown => Database.mock.calls[0][1];
afterEach(() => {
setBufferPoolSizeHint(undefined);
_setOsPageSizeForTests(undefined);
vi.unstubAllEnvs();
});
it.each([
['64 KiB pages scale the floor ×16', 65536, 41, 16 * 256 * MiB],
[
'64 KiB pages scale the estimate ×16 (100k × 4 KiB × 16 = 6.4 GB)',
65536,
100_000,
100_000 * 4 * 1024 * 16,
],
['16 KiB pages (Apple Silicon) scale the floor ×4', 16384, 41, 4 * 256 * MiB],
['4 KiB pages are byte-identical to the unscaled behavior', 4096, 100_000, 100_000 * 4 * 1024],
])('%s', (_label, pageSize, elements, expected) => {
const totalmemSpy = vi.spyOn(os, 'totalmem').mockReturnValue(32 * GiB);
try {
_setOsPageSizeForTests(pageSize);
expect(estimateBufferPool(elements)).toBe(expected);
} finally {
totalmemSpy.mockRestore();
}
});
it('the scaled cap is still bounded by 80% of RAM (64 KiB pages, huge graph)', () => {
const totalmemSpy = vi.spyOn(os, 'totalmem').mockReturnValue(32 * GiB);
try {
_setOsPageSizeForTests(65536);
// min(2 GiB × 16, 0.8 × 32 GiB) = min(32 GiB, 25.6 GiB) = 25.6 GiB
expect(estimateBufferPool(100_000_000)).toBe(Math.floor(0.8 * 32 * GiB));
} finally {
totalmemSpy.mockRestore();
}
});
it('an undetectable page size behaves exactly like 4 KiB (ratio 1)', () => {
const totalmemSpy = vi.spyOn(os, 'totalmem').mockReturnValue(32 * GiB);
try {
_setOsPageSizeForTests(null);
expect(estimateBufferPool(100_000)).toBe(100_000 * 4 * 1024);
} finally {
totalmemSpy.mockRestore();
}
});
it('the hintless default passed to the Database ctor stays at the unscaled #2557 cap on 64 KiB hosts', () => {
// The guard for the #2557 OOM protection: MCP serve / doctor / any open
// without a per-run hint must NOT inherit the page-size-scaled budget —
// the pool is an eager allocation at DB open.
const totalmemSpy = vi.spyOn(os, 'totalmem').mockReturnValue(32 * GiB);
try {
_setOsPageSizeForTests(65536);
const Database = vi.fn(function (this: any) {});
createLbugDatabase({ Database } as any, '/tmp/lbug-pool-64k');
expect(bufferPoolArg(Database)).toBe(2 * GiB);
} finally {
totalmemSpy.mockRestore();
}
});
it('the analyze hint path DOES scale on 64 KiB hosts (scaled floor, bounded by 80% RAM)', () => {
const totalmemSpy = vi.spyOn(os, 'totalmem').mockReturnValue(32 * GiB);
try {
_setOsPageSizeForTests(65536);
setBufferPoolSizeHint(estimateBufferPool(41));
const Database = vi.fn(function (this: any) {});
createLbugDatabase({ Database } as any, '/tmp/lbug-pool-64k-hint');
// 41 elements → below the scaled COPY floor → 16 × 256 MiB = 4 GiB
expect(bufferPoolArg(Database)).toBe(16 * 256 * MiB);
} finally {
totalmemSpy.mockRestore();
}
});
it('GITNEXUS_LBUG_BUFFER_POOL_SIZE stays absolute on 64 KiB hosts (incl. 0 = native default)', () => {
_setOsPageSizeForTests(65536);
vi.stubEnv('GITNEXUS_LBUG_BUFFER_POOL_SIZE', String(512 * MiB));
const Database = vi.fn(function (this: any) {});
createLbugDatabase({ Database } as any, '/tmp/lbug-pool-64k-env');
expect(bufferPoolArg(Database)).toBe(512 * MiB);
});
});
// ─── #2631: actionable pool-exhaustion remedy ───────────────────────────────
describe('bufferPoolExhaustionRemedy (#2631)', () => {
afterEach(() => _setOsPageSizeForTests(undefined));
const EXHAUSTION =
'Buffer manager exception: Unable to allocate memory! The buffer pool is full and no memory could be freed!';
it('names the override knob for the exhaustion error', () => {
_setOsPageSizeForTests(4096);
const remedy = bufferPoolExhaustionRemedy(EXHAUSTION);
expect(remedy).toContain('GITNEXUS_LBUG_BUFFER_POOL_SIZE');
expect(remedy).toContain('buffer pool');
// ratio 1 → no page-size amplification note
expect(remedy).not.toContain('OS page size');
});
it('explains the granule amplification on a 64 KiB-page host', () => {
_setOsPageSizeForTests(65536);
const remedy = bufferPoolExhaustionRemedy(EXHAUSTION);
expect(remedy).toContain('64 KiB OS page size');
expect(remedy).toContain('16×');
expect(remedy).toContain('GITNEXUS_LBUG_BUFFER_POOL_SIZE');
});
it('is silent for non-exhaustion errors', () => {
_setOsPageSizeForTests(65536);
expect(
bufferPoolExhaustionRemedy('Binder exception: Table CodeEmbedding does not exist.'),
).toBeUndefined();
});
it('labels the 0 sentinel as the native default instead of "0 MiB"', () => {
_setOsPageSizeForTests(4096);
vi.stubEnv('GITNEXUS_LBUG_BUFFER_POOL_SIZE', '0');
try {
const remedy = bufferPoolExhaustionRemedy(EXHAUSTION);
expect(remedy).toContain('native 80%-of-RAM default');
expect(remedy).not.toContain('(0 MiB)');
} finally {
vi.unstubAllEnvs();
}
});
});

View file

@ -1,7 +1,8 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
const { loadFTSExtensionMock } = vi.hoisted(() => ({
const { loadFTSExtensionMock, loadVectorExtensionMock } = vi.hoisted(() => ({
loadFTSExtensionMock: vi.fn(),
loadVectorExtensionMock: vi.fn(),
}));
vi.mock('@ladybugdb/core', () => ({
@ -16,6 +17,7 @@ vi.mock('@ladybugdb/core', () => ({
vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
isReadOnlyDbError: vi.fn(() => false),
loadFTSExtension: loadFTSExtensionMock,
loadVectorExtension: loadVectorExtensionMock,
}));
vi.mock('../../src/core/lbug/lbug-config.js', () => ({
@ -31,10 +33,13 @@ describe('read-pool FTS loading', () => {
afterEach(async () => {
await closeLbug().catch(() => {});
loadFTSExtensionMock.mockReset();
loadVectorExtensionMock.mockReset();
loadVectorExtensionMock.mockResolvedValue(false);
});
it('loads FTS with load-only policy and caches a successful load', async () => {
loadFTSExtensionMock.mockResolvedValue(true);
loadVectorExtensionMock.mockResolvedValue(true);
const db = {} as any;
await initLbugWithDb('repo-a', db, '/tmp/shared-fts-db');
@ -46,6 +51,7 @@ describe('read-pool FTS loading', () => {
it('does not fake a successful load when FTS is unavailable', async () => {
loadFTSExtensionMock.mockResolvedValue(false);
loadVectorExtensionMock.mockResolvedValue(false);
const db = {} as any;
await initLbugWithDb('repo-a', db, '/tmp/shared-fts-db');
@ -59,4 +65,29 @@ describe('read-pool FTS loading', () => {
policy: 'load-only',
});
});
it('loads VECTOR with load-only policy and caches a successful load (#2623 follow-up)', async () => {
loadFTSExtensionMock.mockResolvedValue(true);
loadVectorExtensionMock.mockResolvedValue(true);
const db = {} as any;
await initLbugWithDb('repo-a', db, '/tmp/shared-vec-db');
await initLbugWithDb('repo-b', db, '/tmp/shared-vec-db');
expect(loadVectorExtensionMock).toHaveBeenCalledTimes(1);
expect(loadVectorExtensionMock).toHaveBeenCalledWith(expect.anything(), {
policy: 'load-only',
});
});
it('retries the VECTOR load on the next open when it was unavailable', async () => {
loadFTSExtensionMock.mockResolvedValue(true);
loadVectorExtensionMock.mockResolvedValue(false);
const db = {} as any;
await initLbugWithDb('repo-a', db, '/tmp/shared-vec-db');
await initLbugWithDb('repo-b', db, '/tmp/shared-vec-db');
expect(loadVectorExtensionMock).toHaveBeenCalledTimes(2);
});
});

View file

@ -14,8 +14,9 @@ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
// repo resident through deferred manifest/workspace resolution. Pinning makes
// that resident set survive automatic (LRU + idle) eviction.
const { loadFTSExtensionMock } = vi.hoisted(() => ({
const { loadFTSExtensionMock, loadVectorExtensionMock } = vi.hoisted(() => ({
loadFTSExtensionMock: vi.fn(),
loadVectorExtensionMock: vi.fn().mockResolvedValue(false),
}));
vi.mock('@ladybugdb/core', () => ({
@ -36,6 +37,7 @@ vi.mock('@ladybugdb/core', () => ({
vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
isReadOnlyDbError: vi.fn(() => false),
loadFTSExtension: loadFTSExtensionMock,
loadVectorExtension: loadVectorExtensionMock,
}));
vi.mock('../../src/core/lbug/lbug-config.js', () => ({

View file

@ -3,7 +3,7 @@
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { lbugMocks, platformMocks, repoMocks } = vi.hoisted(() => ({
const { lbugMocks, repoMocks } = vi.hoisted(() => ({
lbugMocks: {
initLbug: vi.fn().mockResolvedValue(undefined),
executeQuery: vi.fn(),
@ -11,9 +11,6 @@ const { lbugMocks, platformMocks, repoMocks } = vi.hoisted(() => ({
closeLbug: vi.fn().mockResolvedValue(undefined),
isLbugReady: vi.fn().mockReturnValue(true),
},
platformMocks: {
isVectorExtensionSupportedByPlatform: vi.fn().mockReturnValue(true),
},
repoMocks: {
listRegisteredRepos: vi.fn(),
},
@ -40,14 +37,6 @@ vi.mock('../../src/core/git-staleness.js', () => ({
checkCwdMatch: vi.fn().mockResolvedValue({ match: 'none' }),
}));
vi.mock('../../src/core/platform/capabilities.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/core/platform/capabilities.js')>();
return {
...actual,
isVectorExtensionSupportedByPlatform: platformMocks.isVectorExtensionSupportedByPlatform,
};
});
vi.mock('../../src/core/search/bm25-index.js', () => ({
searchFTSFromLbug: vi.fn().mockResolvedValue([]),
}));

View file

@ -29,7 +29,10 @@ vi.mock('@ladybugdb/core', () => {
return { default: { Database, Connection } };
});
import { probeFtsExtensionLoad } from '../../src/core/lbug/native-check.js';
import {
probeFtsExtensionLoad,
probeVectorExtensionLoad,
} from '../../src/core/lbug/native-check.js';
const closeable = () => ({ close: vi.fn() });
@ -90,3 +93,29 @@ describe('probeFtsExtensionLoad (#2374)', () => {
await expect(probeFtsExtensionLoad()).resolves.toEqual({ loaded: true });
});
});
describe('probeVectorExtensionLoad (#2623 follow-up)', () => {
it('issues LOAD EXTENSION vector and reports loaded on success — no platform short-circuit', async () => {
h.query.mockResolvedValue(closeable());
await expect(probeVectorExtensionLoad()).resolves.toEqual({ loaded: true });
// The probe must really attempt the LOAD (the old code refused Windows
// before ever touching the engine; the artifact ships for win_amd64 too).
expect(h.query).toHaveBeenCalledWith('LOAD EXTENSION vector');
});
it('reports the collapsed reason when LOAD fails', async () => {
h.query.mockRejectedValue(new Error('IO exception:\n extension file not found'));
await expect(probeVectorExtensionLoad()).resolves.toMatchObject({
loaded: false,
reason: 'IO exception: extension file not found',
});
});
it('times out instead of hanging when the native call never settles', async () => {
h.query.mockReturnValue(new Promise<unknown>(() => undefined));
await expect(probeVectorExtensionLoad(20)).resolves.toMatchObject({
loaded: false,
reason: expect.stringContaining('timed out'),
});
});
});

View file

@ -1,17 +1,19 @@
import { describe, expect, it } from 'vitest';
import {
getRuntimeCapabilities,
getRuntimeFingerprint,
isVectorExtensionSupportedByPlatform,
} from '../../src/core/platform/capabilities.js';
describe('platform capabilities', () => {
it('keeps Ladybug VECTOR disabled by default on Windows', () => {
expect(isVectorExtensionSupportedByPlatform('win32')).toBe(false);
});
it('allows VECTOR probing on Linux and macOS', () => {
expect(isVectorExtensionSupportedByPlatform('linux')).toBe(true);
expect(isVectorExtensionSupportedByPlatform('darwin')).toBe(true);
it('reports VECTOR as platform-available everywhere, Windows included (#2623 follow-up)', () => {
// LadybugDB ships win_amd64 VECTOR artifacts for every 0.18.x extension
// version, so no platform is categorically excluded any more. Whether the
// extension actually LOADS on a machine is a runtime question answered by
// probeVectorExtensionLoad (doctor) and loadVectorExtension (analyze).
const caps = getRuntimeCapabilities();
expect(caps.vector).toBe('available');
expect(caps.semanticMode).toBe('vector-index');
expect(caps.reason).toBeUndefined();
});
it('resolves the LadybugDB version even though @ladybugdb/core exports omit ./package.json (#2374)', () => {

View file

@ -31,6 +31,7 @@ vi.mock('@ladybugdb/core', () => ({
vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
loadFTSExtension: vi.fn().mockResolvedValue(true),
loadVectorExtension: vi.fn().mockResolvedValue(true),
}));
vi.mock('../../src/core/lbug/lbug-config.js', () => ({

View file

@ -0,0 +1,249 @@
import { describe, expect, it } from 'vitest';
import type { CaptureMatch } from 'gitnexus-shared';
import {
emitPythonScopeCaptures,
interpretPythonTypeBinding,
} from '../../../../src/core/ingestion/languages/python/index.js';
import { extractParsedFile } from '../../../../src/core/ingestion/scope-extractor-bridge.js';
import { pythonProvider } from '../../../../src/core/ingestion/languages/python.js';
function constructorFieldBindings(source: string): CaptureMatch[] {
return emitPythonScopeCaptures(source, 'fixture.py').filter(
(match) => match['@type-binding.instance-field'] !== undefined,
);
}
function interpretedBindings(source: string): Array<{
boundName: string;
rawTypeName: string;
source: string;
}> {
return constructorFieldBindings(source).map((match) => {
const binding = interpretPythonTypeBinding(match);
expect(binding).not.toBeNull();
return binding!;
});
}
describe('Python constructor field type bindings', () => {
it.each([
{
name: 'annotated constructor parameter',
source: `
class Facade:
def __init__(self, service: Service):
self.service = service
`,
},
{
name: 'typed default parameter with a nullable forward reference',
source: `
class Facade:
def __init__(self, service: "Service | None" = None):
self.service = service
`,
},
{
name: 'custom receiver name',
source: `
class Facade:
def __init__(this, service: Service):
this.service = service
`,
},
])('synthesizes a parameter-derived class field for $name', ({ source }) => {
expect(interpretedBindings(source)).toEqual([
{ boundName: 'service', rawTypeName: 'Service', source: 'parameter-annotation' },
]);
});
it('preserves explicit field-annotation provenance', () => {
const source = `
class Facade:
def __init__(self, service):
self.service: Service = service
`;
expect(interpretedBindings(source)).toEqual([
{ boundName: 'service', rawTypeName: 'Service', source: 'annotation' },
]);
});
it.each([
{
name: 'an unannotated constructor parameter',
source: `
class Facade:
def __init__(self, service):
self.service = service
`,
},
{
name: 'an assignment on a different receiver',
source: `
class Facade:
def __init__(self, service: Service):
other.service = service
`,
},
{
name: 'a non-constructor method',
source: `
class Facade:
def configure(self, service: Service):
self.service = service
`,
},
{
name: 'a static constructor-shaped method',
source: `
class Facade:
@staticmethod
def __init__(self, service: Service):
self.service = service
`,
},
{
name: 'an annotation inside a nested function',
source: `
class Facade:
def __init__(self, value):
def configure():
self.service: Service = value
`,
},
{
name: 'an annotation inside a nested class',
source: `
class Facade:
def __init__(self, value):
class Nested:
self.service: Service = value
`,
},
{
name: 'an assignment inside an if branch',
source: `
class Facade:
def __init__(self, service: Service, enabled: bool):
if enabled:
self.service = service
`,
},
{
name: 'an assignment inside a for loop',
source: `
class Facade:
def __init__(self, services: list[Service]):
for service in services:
self.service: Service = service
`,
},
{
name: 'an assignment inside a while loop',
source: `
class Facade:
def __init__(self, service: Service, enabled: bool):
while enabled:
self.service = service
`,
},
{
name: 'an assignment inside a try statement',
source: `
class Facade:
def __init__(self, service: Service):
try:
self.service = service
except RuntimeError:
pass
`,
},
])('does not synthesize a binding for $name', ({ source }) => {
expect(constructorFieldBindings(source)).toEqual([]);
});
it('uses the final inferred assignment for a repeatedly assigned field', () => {
const source = `
class Facade:
def __init__(self, primary: PrimaryService, fallback: FallbackService):
self.service = primary
self.service = fallback
`;
expect(interpretedBindings(source)).toEqual([
{
boundName: 'service',
rawTypeName: 'FallbackService',
source: 'parameter-annotation',
},
]);
});
it('prefers an explicit field annotation over the parameter annotation', () => {
const source = `
class Facade:
def __init__(self, service: Protocol):
self.service: ConcreteService = service
`;
expect(interpretedBindings(source)).toEqual([
{ boundName: 'service', rawTypeName: 'ConcreteService', source: 'annotation' },
]);
});
it('hoists only the synthesized field binding to the enclosing class scope', () => {
const parsed = extractParsedFile(
pythonProvider,
`
class Facade:
def __init__(self, service: Service):
self.service = service
`,
'fixture.py',
);
expect(parsed).toBeDefined();
const classScope = parsed!.scopes.find((scope) => scope.kind === 'Class');
const constructorScope = parsed!.scopes.find((scope) => scope.kind === 'Function');
expect(classScope?.typeBindings.get('service')).toMatchObject({
rawName: 'Service',
source: 'parameter-annotation',
});
expect(constructorScope?.typeBindings.get('service')).toMatchObject({
rawName: 'Service',
source: 'parameter-annotation',
});
});
it('does not override a class-body field annotation with constructor inference', () => {
const parsed = extractParsedFile(
pythonProvider,
`
class Facade:
service: ServiceProtocol
def __init__(self, service: ConcreteService):
self.service = service
`,
'fixture.py',
);
expect(parsed).toBeDefined();
const classScope = parsed!.scopes.find((scope) => scope.kind === 'Class');
expect(classScope?.typeBindings.get('service')).toMatchObject({
rawName: 'ServiceProtocol',
source: 'annotation',
});
});
it('is deterministic across repeated capture runs', () => {
const source = `
class Facade:
def __init__(self, service: Service):
self.service = service
`;
expect(constructorFieldBindings(source)).toEqual(constructorFieldBindings(source));
});
});

View file

@ -79,6 +79,110 @@ describe('Spring configuration parsing', () => {
expect(keys.some((entry) => entry.key.includes('<<'))).toBe(false);
});
it('flattens every document of a multi-document file and ignores empty ones', () => {
expect(
parseSpringYaml(
'server:\n port: 8080\n---\nservice:\n name: demo\n',
'application.yml',
).map((entry) => [entry.key, entry.line]),
).toEqual([
['server.port', 2],
['service.name', 5],
]);
expect(parseSpringYaml('', 'application.yml')).toEqual([]);
expect(parseSpringYaml('# only a comment\n\n', 'application.yml')).toEqual([]);
expect(parseSpringYaml('---\n', 'application.yml')).toEqual([]);
// A bare top-level scalar has no key to attribute, so it contributes nothing.
expect(parseSpringYaml('just-a-scalar\n', 'application.yml')).toEqual([]);
// Anchors are document-scoped: an alias may not reach into a previous document.
expect(() =>
parseSpringYaml(
'base: &base\n timeout: 30\n---\nservice:\n <<: *base\n',
'application.yml',
),
).toThrow('unidentified alias');
});
it('resolves sequence-form merge keys and explicitly tagged values', () => {
expect(
parseSpringYaml('a: &a\n x: 1\nb: &b\n y: 2\nc:\n <<: [*a, *b]\n', 'application.yml').map(
(entry) => [entry.key, entry.line],
),
).toEqual([
['a.x', 2],
['b.y', 4],
['c.x', 2],
['c.y', 4],
]);
// js-yaml 5's CORE schema alone rejects these tags; the file-level catch would
// then drop every key in the file, so the schema must keep carrying them.
// `!!set` constructs a native Set in v5 (a plain object in v4), so its members
// are only reachable by enumerating the Set itself.
const tagged = parseSpringYaml(
[
'when: !!timestamp 2001-12-14',
'blob: !!binary "R0lGODlh"',
'flags: !!set\n ? a\n ? b',
'ordered: !!omap\n - first: 1',
'listed: !!pairs\n - dup: 1\n - dup: 2',
].join('\n'),
'application.yml',
);
expect(tagged.map((entry) => [entry.key, entry.line])).toEqual([
['blob', 2],
['flags.a', 4],
['flags.b', 5],
// `!!pairs` keeps both `dup` entries instead of collapsing them, which is the
// point of the tag. Nested sequence items inherit their parent's line here,
// as they did under v4 — the mapping lookup that refines a line has no
// equivalent for a bare array index.
['listed[0][0]', 8],
['listed[0][1]', 8],
['listed[1][0]', 8],
['listed[1][1]', 8],
['ordered[0].first', 7],
['when', 1],
]);
expect(JSON.stringify(tagged)).not.toContain('R0lGODlh');
});
it('resolves an alias to the nearest preceding anchor when a name is reused', () => {
// v4 keyed aliases on constructed-object identity; v5 keys them by anchor
// name, so redeclaring a name is a case the old scheme could not express.
expect(
parseSpringYaml(
'first: &shared\n a: 1\nsecond: &shared\n b: 2\nthird: *shared\n',
'application.yml',
).map((entry) => [entry.key, entry.line]),
).toEqual([
['first.a', 2],
['second.b', 4],
['third.b', 4],
]);
});
it('keeps document and event streams aligned across marker-only documents', () => {
// The value tree and the line tree are built from the same DOCUMENT events but
// zipped by index, so a leading empty document must consume a slot in both.
expect(
parseSpringYaml('---\n---\nfoo: 1\n', 'application.yml').map((entry) => [
entry.key,
entry.line,
]),
).toEqual([['foo', 3]]);
expect(
parseSpringYaml('a: 1\n---\n---\nb: 2\n', 'application.yml').map((entry) => [
entry.key,
entry.line,
]),
).toEqual([
['a', 1],
['b', 4],
]);
});
it('terminates cyclic YAML aliases and bounds deeply nested expansion', () => {
expect(
parseSpringYaml('cycle: &cycle { self: *cycle }\nhealthy: true\n', 'application.yml'),

View file

@ -6,7 +6,7 @@
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { lbugMocks, platformMocks } = vi.hoisted(() => ({
const { lbugMocks } = vi.hoisted(() => ({
lbugMocks: {
initLbug: vi.fn().mockResolvedValue(undefined),
executeQuery: vi.fn().mockResolvedValue([]),
@ -14,9 +14,6 @@ const { lbugMocks, platformMocks } = vi.hoisted(() => ({
closeLbug: vi.fn().mockResolvedValue(undefined),
isLbugReady: vi.fn().mockReturnValue(true),
},
platformMocks: {
isVectorExtensionSupportedByPlatform: vi.fn().mockReturnValue(true),
},
}));
vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => {
@ -59,11 +56,6 @@ vi.mock('../../src/storage/git.js', async (importOriginal) => {
return { ...actual, getGitRoot: vi.fn().mockReturnValue(null) };
});
vi.mock('../../src/core/platform/capabilities.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/core/platform/capabilities.js')>();
return { ...actual, ...platformMocks };
});
vi.mock('../../src/core/search/bm25-index.js', () => ({
searchFTSFromLbug: vi.fn().mockResolvedValue({ results: [], ftsAvailable: true }),
}));