Merge branch 'main' into codex/spring-config-bindings-2412

This commit is contained in:
Gergő Magyar 2026-07-21 06:19:19 +01:00 • committed by GitHub
commit ce6cefe696
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 464 additions and 37 deletions

View file

@ -167,6 +167,56 @@ def test_run_claude_forwards_the_named_model_to_every_session(monkeypatch, tmp_p
assert captured[captured.index("--model") + 1] == "claude-sonnet-4-20250514"
def test_run_claude_restricts_tools_via_tools_flag_outside_bare(monkeypatch, tmp_path):
# Outside --bare, the built-in toolset defaults to everything (subagents,
# WebFetch, Task, ...) and --allowedTools only pre-approves within that —
# it does not narrow it. --tools is what actually restricts the set, so a
# non-bare arm session must pass it or it silently gets a far wider
# toolset than intended.
captured: list[str] = []
def fake_run(command, **kwargs):
captured.extend(command)
return fake_cli_result(VALID_REPORT)
monkeypatch.setattr(runner_sessions, "run_managed", fake_run)
runner.run_claude(
"task",
tmp_path,
claude_bin="claude",
timeout=5,
bare=False,
allowed_tools=["Read", "Edit", "Bash", "Skill"],
)
tools_idx = captured.index("--tools")
assert captured[tools_idx + 1 : tools_idx + 5] == ["Read", "Edit", "Bash", "Skill"]
allowed_idx = captured.index("--allowedTools")
assert captured[allowed_idx + 1 : allowed_idx + 5] == ["Read", "Edit", "Bash", "Skill"]
def test_run_claude_omits_tools_flag_under_bare(monkeypatch, tmp_path):
# --bare already hard-restricts to Bash/Edit/Read on its own (a Claude
# Code design choice, not something --tools/--allowedTools can widen or
# narrow further), so bare sessions must not also pass --tools.
captured: list[str] = []
def fake_run(command, **kwargs):
captured.extend(command)
return fake_cli_result(VALID_REPORT)
monkeypatch.setattr(runner_sessions, "run_managed", fake_run)
runner.run_claude(
"task",
tmp_path,
claude_bin="claude",
timeout=5,
bare=True,
allowed_tools=["Read", "Edit", "Bash", "Skill"],
)
assert "--tools" not in captured
assert "--allowedTools" in captured
@pytest.mark.parametrize(
("proc", "expected_kind"),
[
@ -282,6 +332,15 @@ def test_agent_tool_grants_are_exact_and_nomcp_has_no_graph_tools(monkeypatch, t
assert captured[3]["mcp_config_json"] == '{"mcpServers":{}}'
assert captured[3]["disallowed_tools"] == ["Skill", "mcp__gitnexus"]
# --bare hard-disables the Skill tool and every mcp__* tool regardless of
# --allowedTools (a Claude Code design choice, not something the harness
# can override) -- every arm here except baseline_nomcp needs Skill
# and/or MCP tools, so only baseline_nomcp may still run under --bare.
assert captured[0]["bare"] is False # workflow: planning session
assert captured[1]["bare"] is False # review
assert captured[2]["bare"] is False # workflow_direct
assert captured[3]["bare"] is True # baseline_nomcp
def test_mcp_config_uses_only_the_minimal_pinned_harness_runtime(monkeypatch, tmp_path):
runtime = tmp_path / "gitnexus"

View file

@ -402,6 +402,13 @@ def run_arm(
auth_token=args.auth_token,
base_url=args.base_url,
)
# --bare hard-disables the Skill tool and every mcp__* tool — by Claude
# Code design, not a bug (--allowedTools can't restore what --bare
# removes). Every arm except baseline_nomcp needs Skill and/or MCP tools,
# so only baseline_nomcp can keep --bare's tighter isolation; the rest
# rely on ANTHROPIC_API_KEY alone (the sandboxed HOME has no OAuth/
# keychain state to conflict with it).
bare = arm == "baseline_nomcp"
common = {
"claude_bin": sandbox.claude_bin,
"timeout": args.timeout,
@ -412,7 +419,7 @@ def run_arm(
read_only_paths=_evaluated_skill_roots(worktree, arm),
),
"require_pid_namespace": True,
"bare": True,
"bare": bare,
"settings_json": sandbox.settings_json,
"strict_mcp_config": True,
"mcp_config_json": sandbox_mcp_config(),

View file

@ -377,6 +377,13 @@ def run_claude(
if strict_mcp_config:
cmd += ["--strict-mcp-config", "--mcp-config", mcp_config_json or '{"mcpServers":{}}']
if allowed_tools:
# --bare's own hard-coded Bash/Edit/Read ceiling already scopes bare
# sessions; outside --bare the built-in toolset defaults to
# everything (subagents, WebFetch, Task, ...), so --tools is needed
# to actually restrict it — --allowedTools only pre-approves within
# whatever set is available, it does not narrow that set.
if not bare:
cmd += ["--tools", *allowed_tools]
cmd += ["--allowedTools", *allowed_tools]
if disable_slash_commands:
cmd.append("--disable-slash-commands")

View file

@ -284,6 +284,7 @@ Set these env vars to use a remote OpenAI-compatible `/v1/embeddings` endpoint i
export GITNEXUS_EMBEDDING_URL=http://your-server:8080/v1
export GITNEXUS_EMBEDDING_MODEL=BAAI/bge-large-en-v1.5
export GITNEXUS_EMBEDDING_DIMS=1024 # optional, default 384
export GITNEXUS_EMBEDDING_REQUEST_DIMS=omit # optional: omit "dimensions", or an integer to override it
export GITNEXUS_EMBEDDING_API_KEY=your-key # optional, default: "unused"
export GITNEXUS_EMBEDDING_MAX_ATTEMPTS=3 # optional, total attempts (1-20)
export GITNEXUS_EMBEDDING_RETRY_CAP_MS=5000 # optional, maximum retry delay
@ -291,6 +292,15 @@ export GITNEXUS_EMBEDDING_MIN_INTERVAL_MS=0 # optional, minimum request spacing
gitnexus analyze . --embeddings
```
`GITNEXUS_EMBEDDING_REQUEST_DIMS` controls only the `dimensions` field sent in
the request body, independently of `GITNEXUS_EMBEDDING_DIMS` (which still
validates the returned vector's length):
- `omit` (or `none`, `off`, `false`, `0`) — do not send `dimensions` at all, for
strict backends that return the right vector size but reject the field.
- a positive integer — send that value instead of `GITNEXUS_EMBEDDING_DIMS`.
- unset — send `GITNEXUS_EMBEDDING_DIMS` (the previous behavior).
Works with Infinity, vLLM, TEI, llama.cpp, Ollama, LM Studio, or OpenAI. Retry and pacing settings are provider-neutral; provider-specific limits should be supplied through configuration. When unset, local embeddings are used unchanged.
## Multi-Repo Support

View file

@ -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",
@ -59,7 +59,7 @@
"@types/cors": "^2.8.17",
"@types/express": "^5.0.6",
"@types/js-yaml": "^4.0.9",
"@types/node": "^25.6.0",
"@types/node": "^26.0.0",
"@types/uuid": "^11.0.0",
"@vitest/coverage-v8": "^4.0.18",
"gitnexus-shared": "file:../gitnexus-shared",
@ -1946,13 +1946,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.9.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz",
"integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==",
"version": "26.0.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.0.tgz",
"integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
"undici-types": "~8.3.0"
}
},
"node_modules/@types/qs": {
@ -3589,9 +3589,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",
@ -3607,7 +3607,7 @@
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
"js-yaml": "bin/js-yaml.mjs"
}
},
"node_modules/jsesc": {
@ -5510,9 +5510,9 @@
}
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"devOptional": true,
"license": "MIT"
},

View file

@ -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",
@ -106,7 +106,7 @@
"@types/cors": "^2.8.17",
"@types/express": "^5.0.6",
"@types/js-yaml": "^4.0.9",
"@types/node": "^25.6.0",
"@types/node": "^26.0.0",
"@types/uuid": "^11.0.0",
"@vitest/coverage-v8": "^4.0.18",
"gitnexus-shared": "file:../gitnexus-shared",

View file

@ -29,6 +29,7 @@ interface HttpConfig {
maxAttempts: number;
retryCapMs: number;
minIntervalMs: number;
requestDimensions?: number;
}
export interface EmbeddingRequestOptions {
@ -106,20 +107,26 @@ const paceHttpRequest = async (minIntervalMs: number, signal?: AbortSignal): Pro
};
/**
* Stable lead of the {@link readConfig} malformed-`GITNEXUS_EMBEDDING_DIMS`
* error. `readConfig` throws a plain `Error` (not an {@link HttpEmbeddingError})
* because this is a *config* mistake, not an endpoint failure — so the CLI
* recognizes it by this lead ({@link isHttpEmbeddingDimsError}) and prints a
* clean config message instead of a raw stack dump. See #2385.
* Stable lead of a {@link readConfig} malformed dims-env error. `readConfig`
* throws a plain `Error` (not an {@link HttpEmbeddingError}) for a malformed
* `GITNEXUS_EMBEDDING_DIMS` or `GITNEXUS_EMBEDDING_REQUEST_DIMS` because it's a
* *config* mistake, not an endpoint failure — so the CLI recognizes it by this
* lead ({@link isHttpEmbeddingDimsError}) and prints a clean config message
* instead of a raw stack dump. Each var names itself so the message points the
* operator at the variable they actually set, not a sibling. See #2385.
*/
const EMBEDDING_DIMS_ENV_ERROR_LEAD = 'GITNEXUS_EMBEDDING_DIMS must be a positive integer';
const dimsEnvErrorLead = (name: string): string => `${name} must be a positive integer`;
const EMBEDDING_DIMS_ENV_ERROR_LEAD = dimsEnvErrorLead('GITNEXUS_EMBEDDING_DIMS');
const EMBEDDING_REQUEST_DIMS_ENV_ERROR_LEAD = dimsEnvErrorLead('GITNEXUS_EMBEDDING_REQUEST_DIMS');
/**
* @internal Exported for the CLI analyze error handler. True when `message` is
* the {@link readConfig} malformed-DIMS config error (a plain `Error`).
* @internal Exported for the CLI analyze error handler. True when `message` is a
* {@link readConfig} malformed dims-env config error (a plain `Error`) — for
* either `GITNEXUS_EMBEDDING_DIMS` or `GITNEXUS_EMBEDDING_REQUEST_DIMS`.
*/
export const isHttpEmbeddingDimsError = (message: string): boolean =>
message.includes(EMBEDDING_DIMS_ENV_ERROR_LEAD);
message.includes(EMBEDDING_DIMS_ENV_ERROR_LEAD) ||
message.includes(EMBEDDING_REQUEST_DIMS_ENV_ERROR_LEAD);
/**
* Build config from the current process.env snapshot.
@ -147,6 +154,23 @@ const readConfig = (): HttpConfig | null => {
dimensions = parsed;
}
const rawRequestDims = process.env.GITNEXUS_EMBEDDING_REQUEST_DIMS?.trim();
let requestDimensions = dimensions;
if (rawRequestDims) {
if (/^(omit|none|off|false|0)$/i.test(rawRequestDims)) {
requestDimensions = undefined;
} else {
if (!/^\d+$/.test(rawRequestDims)) {
throw new Error(`${EMBEDDING_REQUEST_DIMS_ENV_ERROR_LEAD}, got "${rawRequestDims}"`);
}
const parsed = parseInt(rawRequestDims, 10);
if (parsed <= 0) {
throw new Error(`${EMBEDDING_REQUEST_DIMS_ENV_ERROR_LEAD}, got "${rawRequestDims}"`);
}
requestDimensions = parsed;
}
}
return {
baseUrl: baseUrl.replace(/\/+$/, ''),
model,
@ -163,6 +187,7 @@ const readConfig = (): HttpConfig | null => {
300_000,
),
minIntervalMs: parseNonNegativeIntegerEnv('GITNEXUS_EMBEDDING_MIN_INTERVAL_MS', 0, 300_000),
requestDimensions,
};
};
@ -283,9 +308,9 @@ const isEmbeddingItem = (item: unknown): item is EmbeddingItem =>
* the `dimensions` field in the request body. Endpoints that implement
* Matryoshka truncation (OpenAI text-embedding-3-*, Cohere embed-v3,
* Voyage) return a truncated vector at that size; endpoints that do not
* recognise the field may ignore it or return 400. Leave
* `GITNEXUS_EMBEDDING_DIMS` unset for strict backends that reject
* unknown fields.
* recognise the field may ignore it or return 400. Set
* `GITNEXUS_EMBEDDING_REQUEST_DIMS=omit` for strict backends while keeping
* `GITNEXUS_EMBEDDING_DIMS` set to the returned vector size.
*/
const httpEmbedBatch = async (
url: string,
@ -434,7 +459,7 @@ export const httpEmbed = async (
config.model,
config.apiKey,
batchIndex,
config.dimensions,
config.requestDimensions,
requestOptions,
config.maxAttempts,
config.retryCapMs,
@ -491,7 +516,7 @@ export const httpEmbedQuery = async (
config.model,
config.apiKey,
0,
config.dimensions,
config.requestDimensions,
requestOptions,
config.maxAttempts,
config.retryCapMs,

View file

@ -25,6 +25,17 @@ export interface ProcessesOutput {
processResult: ProcessDetectionResult;
}
/**
* Compute the dynamic max-processes budget from the symbol count.
*
* Scales proportionally (symbolCount / 10) with a floor of 20.
* Prior to #2198 this was capped at 300 via `Math.min(300, …)`,
* silently truncating process detection on large repositories.
*/
export function computeDynamicMaxProcesses(symbolCount: number): number {
return Math.max(20, Math.round(symbolCount / 10));
}
export const processesPhase: PipelinePhase<ProcessesOutput> = {
name: 'processes',
// `structure` supplies `totalFiles` (progress counter) without the spurious
@ -53,7 +64,7 @@ export const processesPhase: PipelinePhase<ProcessesOutput> = {
ctx.graph.forEachNode((n) => {
if (n.label !== 'File') symbolCount++;
});
const dynamicMaxProcesses = Math.max(20, Math.min(300, Math.round(symbolCount / 10)));
const dynamicMaxProcesses = computeDynamicMaxProcesses(symbolCount);
const processResult = await processProcesses(
ctx.graph,

View file

@ -321,6 +321,16 @@ const resolveCheckpointThreshold = (): number => {
const DEFAULT_BUFFER_POOL_CAP = 2 * 1024 * 1024 * 1024;
const BUFFER_POOL_FLOOR = 64 * 1024 * 1024;
// COPY-safety floor for the adaptive hint (below). LadybugDB's bulk COPY needs
// working buffer-pool memory that scales with the repo: a 64 MiB pool fails
// ("buffer pool is full and no memory could be freed") on any non-trivial repo,
// and even the ~1800-file GitNexus checkout needs ≥256 MiB. So the adaptive
// size never drops a repo below this — a distinct, higher floor than
// BUFFER_POOL_FLOOR, which only guards defaultBufferPoolSize on tiny-RAM
// machines. It is still clamped up to defaultBufferPoolSize, so a machine whose
// default is below this floor keeps its default rather than over-committing.
const ADAPTIVE_POOL_FLOOR = 256 * 1024 * 1024;
const parseBufferPoolSize = (raw: string | undefined): number | undefined => {
if (raw === undefined) return undefined;
const normalized = raw.trim();
@ -333,16 +343,73 @@ const parseBufferPoolSize = (raw: string | undefined): number | undefined => {
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.
*/
const clampBufferPool = (bytes: number): number =>
Math.min(defaultBufferPoolSize(), Math.max(ADAPTIVE_POOL_FLOOR, Math.floor(bytes)));
/**
* Buffer-pool bytes to provision per graph element (node + relationship).
*
* The fixed 2 GiB default is far larger than most repos' working set, and
* LadybugDB eagerly commits the pool at DB open — measured: a full
* `analyze --force` of the GitNexus checkout takes ~51 s with the 2 GiB pool
* vs ~35 s with the ~414 MiB this factor yields (31% faster; the oversized
* pool's commit dominates). The pool is a page cache over the on-disk index,
* which scales with node/edge count, so a per-element budget sizes it to the
* repo. Kept generous so the whole index stays resident (no COPY thrash) and
* always clamped to at least ADAPTIVE_POOL_FLOOR; tuned by timing a real
* large-repo `analyze --force` at this factor vs a forced 2 GiB pool (the pool
* is a native eager allocation, measured with a real analyze, not a build-free
* bench — see the emit-path COPY timing note in bench/emit-persistence).
*/
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.
*/
export const estimateBufferPool = (graphElementCount: number): number =>
clampBufferPool(graphElementCount * POOL_BYTES_PER_ELEMENT);
/**
* Optional per-run buffer-pool size hint (bytes). The analyze orchestrator sets
* it once the graph size is known (after the pipeline, before the DB open) so
* the pool is sized to the repo instead of the fixed 2 GiB default, and clears
* it at run end. Non-analyze opens (MCP serve, `native-check` `:memory:`) never
* set it and keep the default.
*/
let bufferPoolSizeHint: number | undefined;
/** Set (bytes) or clear (`undefined`) the per-run buffer-pool size hint. */
export const setBufferPoolSizeHint = (bytes: number | undefined): void => {
bufferPoolSizeHint = bytes;
};
/**
* Resolve the `bufferManagerSize` passed to every `new lbug.Database(...)`.
* `GITNEXUS_LBUG_BUFFER_POOL_SIZE` (bytes) overrides the default; `0` is a
* `GITNEXUS_LBUG_BUFFER_POOL_SIZE` (bytes) overrides everything; `0` is a
* deliberate escape hatch that restores LadybugDB's native unbounded
* 80%-of-RAM default. Resolved at call time (not module load) so tests can
* stub the env var and `os.totalmem`.
* 80%-of-RAM default. With no env override, a per-run `setBufferPoolSizeHint`
* (clamped to [floor, default]) sizes the pool to the repo; otherwise the
* default. Resolved at call time (not module load) so tests can stub the env
* var, the hint, and `os.totalmem`.
*/
const resolveBufferManagerSize = (): number => {
const raw = process.env.GITNEXUS_LBUG_BUFFER_POOL_SIZE;
if (raw === undefined) return defaultBufferPoolSize();
if (raw === undefined) {
return bufferPoolSizeHint !== undefined
? clampBufferPool(bufferPoolSizeHint)
: defaultBufferPoolSize();
}
const parsed = parseBufferPoolSize(raw);
if (parsed !== undefined) return parsed;
// Non-empty but unparseable input: warn the operator and fall back —

View file

@ -34,6 +34,7 @@ import {
LbugWipeError,
DELETE_FILES_CHUNK_SIZE,
} from './lbug/lbug-adapter.js';
import { estimateBufferPool, setBufferPoolSizeHint } from './lbug/lbug-config.js';
import { escapeCypherString } from './lbug/cypher-escape.js';
import {
buildSearchIndexesOrDegrade,
@ -647,6 +648,11 @@ export async function runFullAnalysis(
// and are shared across branches (#2106 KTD7).
const { storagePath } = getStoragePaths(repoPath);
// Start each analyze with a clean buffer-pool hint: any pre-pipeline DB open
// (e.g. the embeddings-cache open) falls back to the default until the hint is
// set from the built graph below, so a prior run's size can't leak in.
setBufferPoolSizeHint(undefined);
// Clean up stale KuzuDB files from before the LadybugDB migration.
const kuzuResult = await cleanupOldKuzuFiles(storagePath);
if (kuzuResult.found && kuzuResult.needsReindex) {
@ -1376,6 +1382,16 @@ export async function runFullAnalysis(
await wipeLbugDbFiles(lbugPath);
}
// Size the buffer pool to the graph just built by the pipeline (a page cache
// over the on-disk index, which scales with node/edge count) instead of the
// fixed 2 GiB default, whose eager commit dominates large-repo analyze. The
// size is clamped to [COPY-safety floor, default], so it only ever shrinks
// the pool; env override / no-hint paths are unchanged. See
// resolveBufferManagerSize / estimateBufferPool.
setBufferPoolSizeHint(
estimateBufferPool(pipelineResult.graph.nodeCount + pipelineResult.graph.relationshipCount),
);
await initLbug(lbugPath);
// Manual WAL checkpoint driver (#1741): periodically drain the WAL

View file

@ -2371,7 +2371,13 @@ export function createEntry(level: string, msg: string) {
});
result1 = runSkillsCli(tmpDir);
result2 = runSkillsCli(tmpDir);
}, 90000);
// 120s to match the other describe hooks in this file. This hook runs
// runSkillsCli TWICE, each capped at 45s, so a 90s budget has no headroom
// over two worst-case analyzes plus fixture setup and git init — it times
// out the *hook* on slow Windows runners (the test below already tolerates
// an individual analyze hitting its own 45s timeout via status === null,
// but a hook timeout fails before that tolerance can apply).
}, 120000);
afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });

View file

@ -9,6 +9,7 @@ const ENV_KEYS = [
'GITNEXUS_EMBEDDING_MAX_ATTEMPTS',
'GITNEXUS_EMBEDDING_RETRY_CAP_MS',
'GITNEXUS_EMBEDDING_MIN_INTERVAL_MS',
'GITNEXUS_EMBEDDING_REQUEST_DIMS',
] as const;
/** 384d mock vector matching the default schema dimensions. */
@ -166,6 +167,30 @@ describe('HTTP embedding backend', () => {
expect(result.length).toBe(1024);
});
it('can validate custom dims without forwarding dimensions to strict backends', async () => {
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
process.env.GITNEXUS_EMBEDDING_MODEL = 'bge-m3';
process.env.GITNEXUS_EMBEDDING_DIMS = '1024';
process.env.GITNEXUS_EMBEDDING_REQUEST_DIMS = 'omit';
const vec1024 = Array.from({ length: 1024 }, (_, i) => i / 1024);
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ data: [{ embedding: vec1024 }] }),
}),
);
const { embedText } = await import('../../src/core/embeddings/embedder.js');
const result = await embedText('test text');
const body = JSON.parse((fetch as any).mock.calls[0][1].body);
expect('dimensions' in body).toBe(false);
expect(body.model).toBe('bge-m3');
expect(result.length).toBe(1024);
});
it('forwards dimensions on the single-query path', async () => {
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
process.env.GITNEXUS_EMBEDDING_MODEL = 'text-embedding-3-large';
@ -188,6 +213,93 @@ describe('HTTP embedding backend', () => {
expect(result.length).toBe(512);
});
it('can omit dimensions on the single-query path while validating custom dims', async () => {
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
process.env.GITNEXUS_EMBEDDING_MODEL = 'bge-m3';
process.env.GITNEXUS_EMBEDDING_DIMS = '1024';
process.env.GITNEXUS_EMBEDDING_REQUEST_DIMS = 'omit';
const vec1024 = Array.from({ length: 1024 }, (_, i) => i / 1024);
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ data: [{ embedding: vec1024 }] }),
}),
);
const mod = await import('../../src/mcp/core/embedder.js');
const result = await mod.embedQuery('query text');
const body = JSON.parse((fetch as any).mock.calls[0][1].body);
expect('dimensions' in body).toBe(false);
expect(result.length).toBe(1024);
});
it.each(['none', 'off', 'false', '0'])(
'treats GITNEXUS_EMBEDDING_REQUEST_DIMS=%s as omit and drops the request dimensions field',
async (alias) => {
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
process.env.GITNEXUS_EMBEDDING_MODEL = 'bge-m3';
process.env.GITNEXUS_EMBEDDING_DIMS = '1024';
process.env.GITNEXUS_EMBEDDING_REQUEST_DIMS = alias;
const vec1024 = Array.from({ length: 1024 }, (_, i) => i / 1024);
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ data: [{ embedding: vec1024 }] }),
}),
);
const { embedText } = await import('../../src/core/embeddings/embedder.js');
const result = await embedText('test text');
const body = JSON.parse((fetch as any).mock.calls[0][1].body);
expect('dimensions' in body).toBe(false);
expect(result.length).toBe(1024);
},
);
it('sends REQUEST_DIMS as the request dimensions while DIMS validates the response', async () => {
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
process.env.GITNEXUS_EMBEDDING_MODEL = 'text-embedding-3-large';
process.env.GITNEXUS_EMBEDDING_DIMS = '1024';
process.env.GITNEXUS_EMBEDDING_REQUEST_DIMS = '512';
// Response keeps the DIMS-validated length; only the outgoing request differs.
const vec1024 = Array.from({ length: 1024 }, (_, i) => i / 1024);
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ data: [{ embedding: vec1024 }] }),
}),
);
const { embedText } = await import('../../src/core/embeddings/embedder.js');
const result = await embedText('test text');
const body = JSON.parse((fetch as any).mock.calls[0][1].body);
expect(body.dimensions).toBe(512);
expect(result.length).toBe(1024);
});
it('rejects a malformed GITNEXUS_EMBEDDING_REQUEST_DIMS with an error naming that var', async () => {
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
process.env.GITNEXUS_EMBEDDING_REQUEST_DIMS = 'garbage';
const { embedText } = await import('../../src/core/embeddings/embedder.js');
const { isHttpEmbeddingDimsError } = await import('../../src/core/embeddings/http-client.js');
const err = await embedText('test').catch((e: unknown) => e);
// Recognizable as a config error so the CLI prints a clean message...
expect(isHttpEmbeddingDimsError(String(err))).toBe(true);
// ...and it points the operator at the var they set, not GITNEXUS_EMBEDDING_DIMS.
expect(String(err)).toContain('GITNEXUS_EMBEDDING_REQUEST_DIMS must be a positive integer');
});
it('retries on server error', async () => {
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';

View file

@ -1,9 +1,11 @@
import os from 'os';
import { describe, expect, it, vi } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
createLbugDatabase,
estimateBufferPool,
isLbugCheckpointIoError,
isWalCorruptionError,
setBufferPoolSizeHint,
} from '../../src/core/lbug/lbug-config.js';
import { _captureLogger } from '../../src/core/logger.js';
@ -252,6 +254,74 @@ describe('createLbugDatabase buffer pool size (#2557)', () => {
});
});
describe('adaptive buffer pool hint', () => {
const GiB = 1024 * 1024 * 1024;
const MiB = 1024 * 1024;
const bufferPoolArg = (Database: ReturnType<typeof vi.fn>): unknown => Database.mock.calls[0][1];
afterEach(() => setBufferPoolSizeHint(undefined));
describe('estimateBufferPool', () => {
it.each([
['tiny graph clamps up to the 256 MiB COPY-safety floor', 41, 256 * MiB],
['a graph under the floor still clamps up to 256 MiB', 40_000, 256 * MiB],
['mid graph scales linearly (100k elements * 4 KiB = 400 MiB)', 100_000, 100_000 * 4 * 1024],
['huge graph caps at the 2 GiB / 80%-RAM default', 10_000_000, 2 * GiB],
])('%s', (_label, elements, expected) => {
const totalmemSpy = vi.spyOn(os, 'totalmem').mockReturnValue(32 * GiB);
try {
expect(estimateBufferPool(elements)).toBe(expected);
} finally {
totalmemSpy.mockRestore();
}
});
});
it.each([
['a hint within range passes through', 512 * MiB, 512 * MiB],
['a hint below the COPY-safety floor clamps up to 256 MiB', 100 * MiB, 256 * MiB],
['a hint above the default clamps down to the 2 GiB cap', 8 * GiB, 2 * GiB],
])(
'createLbugDatabase uses the clamped hint when no env override is set: %s',
(_label, hint, expected) => {
const totalmemSpy = vi.spyOn(os, 'totalmem').mockReturnValue(32 * GiB);
try {
setBufferPoolSizeHint(hint);
const Database = vi.fn(function (this: any) {});
createLbugDatabase({ Database } as any, '/tmp/lbug-hint');
expect(bufferPoolArg(Database)).toBe(expected);
} finally {
totalmemSpy.mockRestore();
}
},
);
it('env override wins over the hint (including 0 = native default)', () => {
try {
setBufferPoolSizeHint(128 * MiB);
vi.stubEnv('GITNEXUS_LBUG_BUFFER_POOL_SIZE', '0');
const Database = vi.fn(function (this: any) {});
createLbugDatabase({ Database } as any, '/tmp/lbug-hint-env');
expect(bufferPoolArg(Database)).toBe(0);
} finally {
vi.unstubAllEnvs();
}
});
it('falls back to the default when the hint is cleared', () => {
const totalmemSpy = vi.spyOn(os, 'totalmem').mockReturnValue(32 * GiB);
try {
setBufferPoolSizeHint(128 * MiB);
setBufferPoolSizeHint(undefined);
const Database = vi.fn(function (this: any) {});
createLbugDatabase({ Database } as any, '/tmp/lbug-hint-cleared');
expect(bufferPoolArg(Database)).toBe(2 * GiB);
} finally {
totalmemSpy.mockRestore();
}
});
});
// ─── Finding 8: strict + permissive checkpoint IO matchers ─────────────────
describe('isLbugCheckpointIoError', () => {
it.each([

View file

@ -3,6 +3,7 @@ import {
processProcesses,
type ProcessDetectionConfig,
} from '../../src/core/ingestion/process-processor.js';
import { computeDynamicMaxProcesses } from '../../src/core/ingestion/pipeline-phases/processes.js';
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
import type { CommunityMembership } from '../../src/core/ingestion/community-processor.js';
@ -522,4 +523,40 @@ describe('processProcesses', () => {
expect(result.processes.length).toBeLessThanOrEqual(3);
expect(result.stats.totalProcesses).toBeLessThanOrEqual(3);
});
// Regression for #2198: the processesPhase dynamic sizing used to cap at
// Math.min(300, symbolCount/10). On large repos (>3000 symbols) that silently
// truncated the process index. The cap was removed by extracting
// computeDynamicMaxProcesses() — this test exercises the helper directly
// so it fails if someone reintroduces the 300 ceiling.
describe('computeDynamicMaxProcesses (#2198)', () => {
it('returns at least the floor of 20 for tiny repos', () => {
expect(computeDynamicMaxProcesses(0)).toBe(20);
expect(computeDynamicMaxProcesses(50)).toBe(20); // 50/10 = 5, floored to 20
expect(computeDynamicMaxProcesses(199)).toBe(20); // 199/10 ≈ 20
});
it('scales linearly within the old 0–3000 range', () => {
expect(computeDynamicMaxProcesses(500)).toBe(50);
expect(computeDynamicMaxProcesses(1000)).toBe(100);
expect(computeDynamicMaxProcesses(2999)).toBe(300);
});
it('grows past 300 for large repos — the regression that #2198 fixes', () => {
// 3001 symbols → 300 (just at the boundary)
expect(computeDynamicMaxProcesses(3001)).toBe(300);
// 3100 symbols → 310 — would have been capped to 300 before the fix
expect(computeDynamicMaxProcesses(3100)).toBe(310);
// 5000 symbols → 500
expect(computeDynamicMaxProcesses(5000)).toBe(500);
// 28000 symbols (real-world large repo) → 2800
expect(computeDynamicMaxProcesses(28000)).toBe(2800);
});
it('does NOT cap at 300 — fails if Math.min(300, ...) is reintroduced', () => {
const largeRepo = computeDynamicMaxProcesses(10000);
expect(largeRepo).toBe(1000);
expect(largeRepo).toBeGreaterThan(300);
});
});
});