Merge branch 'fix/csharp-namespace-scope-oom' into fix/1881-csharp-unrelated-edges

This commit is contained in:
Gergő Magyar 2026-05-29 20:09:16 +01:00 • committed by GitHub
commit df0c2315ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1067 additions and 56 deletions

View file

@ -1096,6 +1096,17 @@ const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions):
);
console.log(` ${repoPath}`);
// Persistent (non-scrolling) warning when FTS indexing was skipped — the
// progress-bar log() that fired mid-run has already scrolled away, so the
// degraded-search state must also appear in the final summary (#1161).
if (result.ftsSkipped) {
console.log(
`\n Warning: full-text/BM25 search is disabled — the LadybugDB FTS extension was unavailable.\n` +
` Install it once with network access (GITNEXUS_LBUG_EXTENSION_INSTALL=auto) then rerun, or\n` +
` run \`gitnexus analyze --repair-fts\` when connected. Run \`gitnexus doctor\` for details.`,
);
}
try {
await fs.access(getGlobalRegistryPath());
} catch {

View file

@ -2,6 +2,7 @@ import { getRuntimeCapabilities, getRuntimeFingerprint } from '../core/platform/
import { resolveEmbeddingConfig } from '../core/embeddings/config.js';
import { isHttpMode } from '../core/embeddings/http-client.js';
import { checkLbugNative } from '../core/lbug/native-check.js';
import { getExtensionInstallPolicy } from '../core/lbug/extension-loader.js';
import { t } from './i18n/index.js';
function isCombiningMark(codePoint: number): boolean {
@ -74,6 +75,17 @@ export const doctorCommand = async () => {
console.log(` ${label('doctor.labels.fullTextSearch', 18)}${capabilities.fts}`);
console.log(` ${label('doctor.labels.vectorIndex', 18)}${capabilities.vector}`);
console.log(` ${label('doctor.labels.semanticMode', 18)}${capabilities.semanticMode}`);
// 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.
const installPolicy = getExtensionInstallPolicy();
const policyHint =
installPolicy === 'load-only'
? ' (offline; load only, no network install)'
: installPolicy === 'never'
? ' (optional extensions disabled)'
: ' (installs missing extensions over network)';
console.log(` ${padDisplayEnd('Ext install:', 18)}${installPolicy}${policyHint}`);
console.log(
` ${label('doctor.labels.exactScanLimit', 18)}${t('doctor.chunks', { count: capabilities.exactScanLimit })}`,
);

View file

@ -43,20 +43,38 @@ import {
STALE_HASH_SENTINEL,
} from '../lbug/schema.js';
import { loadVectorExtension } from '../lbug/lbug-adapter.js';
import type { ExtensionInstallPolicy } from '../lbug/extension-loader.js';
import { getExactScanLimit } from '../platform/capabilities.js';
import { logger } from '../logger.js';
const isDev = process.env.NODE_ENV === 'development';
const vectorUnavailableMessage =
'VECTOR extension is unavailable for this LadybugDB runtime; semantic search will use exact scan when embeddings exist.';
'VECTOR extension unavailable; semantic embeddings fall back to exact scan. ' +
'To enable vector search, install it once with network access ' +
'(GITNEXUS_LBUG_EXTENSION_INSTALL=auto), or pre-install it for offline use. ' +
'Set GITNEXUS_LBUG_EXTENSION_INSTALL=never to skip installs and silence this.';
/**
* Resolve the extension-install policy for the embedding WRITE path (analyze).
*
* Generating embeddings is an explicit opt-in to a feature that requires the
* VECTOR extension, so when the operator has NOT pinned a policy we default to
* `auto` (one bounded, out-of-process INSTALL) — matching the documented
* "auto = default for analyze" intent in extension-loader.ts. An explicit
* GITNEXUS_LBUG_EXTENSION_INSTALL=load-only|never|auto always wins, so an
* offline or locked-down operator is never silently forced onto the network
* (the #1153 regression caused by hard-coding `auto` here). Read on every call
* (not memoized) so test env stubbing works.
*/
export const resolveEmbeddingInstallPolicy = (): ExtensionInstallPolicy => {
const raw = process.env.GITNEXUS_LBUG_EXTENSION_INSTALL;
if (raw === 'load-only' || raw === 'never' || raw === 'auto') return raw;
return 'auto';
};
const ensureVectorExtensionAvailable = async (): Promise<boolean> => {
const vectorReady = await loadVectorExtension();
if (!vectorReady) {
return false;
}
return true;
return loadVectorExtension(undefined, { policy: resolveEmbeddingInstallPolicy() });
};
/**
* Bump this when the embedding text template changes in a way that should
@ -257,7 +275,7 @@ export const runEmbeddingPipeline = async (
try {
const vectorAvailable = await ensureVectorExtensionAvailable();
if (!vectorAvailable && isDev) {
if (!vectorAvailable) {
logger.warn(vectorUnavailableMessage);
}
@ -584,7 +602,11 @@ export const semanticSearch = async (
string,
{ distance: number; chunkIndex: number; startLine: number; endLine: number }
>();
if (await loadVectorExtension()) {
// Query/read path: NEVER spawn a network INSTALL on a user query. If the
// VECTOR extension was not pre-installed, fall back to exact scan rather than
// blocking the query on a download (offline-first; see extension-loader.ts
// "load-only" — used by all serve/MCP query paths).
if (await loadVectorExtension(undefined, { policy: 'load-only' })) {
try {
bestChunks = await collectBestChunks(k, async (fetchLimit) => {
const vectorQuery = `

View file

@ -38,6 +38,7 @@ import { splitImportStatement } from '../typescript/import-decomposer.js';
import { getJsParser, getJsScopeQuery, jsCachedTreeMatchesGrammar } from './query.js';
import { computeTsArityMetadata } from '../typescript/arity-metadata.js';
import { synthesizeTsReceiverBinding } from '../typescript/receiver-binding.js';
import { isArrayMethodCallbackArrow } from '../typescript/array-callback.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
@ -640,6 +641,21 @@ export function emitJsScopeCaptures(
}
}
// #1876: drop @declaration.function for array higher-order-method
// callbacks (`const x = arr.map(a => …)`). The HOC-wrapped-arrow
// pattern matches them, but the binding holds a value, not a callable.
// The binding keeps its separate @declaration.const / .variable match,
// and the arrow's own @scope.function match (a different pattern) is
// untouched, so inner-call attribution falls through to the enclosing
// scope instead of a phantom Function.
const fnDeclAnchor = grouped['@declaration.function'];
if (fnDeclAnchor !== undefined) {
const arrowNode = findFunctionNode(tree.rootNode, fnDeclAnchor.range);
if (arrowNode !== null && isArrayMethodCallbackArrow(arrowNode)) {
continue;
}
}
// Synthesize arity metadata on function-like declarations.
const declAnchor = pickFirstDefined(grouped, FUNCTION_DECL_TAGS);
if (declAnchor !== undefined) {

View file

@ -148,6 +148,12 @@ const JAVASCRIPT_SCOPE_QUERY = `
;; HOC-wrapped variable declarations: const X = HOC((args) => { ... }).
;; Covers React.forwardRef, memo, useCallback, useMemo, observer,
;; debounce, and any user-defined HOC factory.
;;
;; #1876: this shape also matches array higher-order-method callbacks
;; (const x = arr.map(a => ...)), where x is a value, not a function.
;; Those are filtered out emit-side in captures.ts via
;; isArrayMethodCallbackArrow (member-expression callee whose property
;; is a known Array method), so only the @declaration.const survives.
(lexical_declaration
(variable_declarator
name: (identifier) @declaration.name

View file

@ -0,0 +1,98 @@
/**
* Array higher-order-method callback detection (issue #1876).
*
* The HOC-wrapped-arrow declaration pattern in the JS/TS scope queries
* (`const X = call((args) => …)`) was added for React idioms
* (`forwardRef` / `memo` / `useCallback`). It has the same AST shape as
* an array higher-order-method call (`const x = arr.map(a => …)`), so
* those callbacks also match and produce a spurious `@declaration.function`
* named after the binding — duplicating the `@declaration.const` /
* `@declaration.variable` def that the same binding already gets.
*
* For an array-method callback the binding holds a *value* (the method's
* result), not a callable, so the `Function` def is semantically wrong.
* `isArrayMethodCallbackArrow` lets the emitter (`captures.ts`) drop that
* `@declaration.function` match, leaving only the value def.
*
* Shared by both the JavaScript and TypeScript capture emitters — the
* relevant grammar nodes (`arrow_function`, `function_expression`,
* `arguments`, `call_expression`, `member_expression`,
* `property_identifier`) are identical across `tree-sitter-javascript`
* and `tree-sitter-typescript`.
*
* Pure given the input node. No I/O, no globals.
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
/**
* Array prototype higher-order methods whose result is a value, not a
* function. A callback passed to one of these is an anonymous callback,
* never a top-level function definition. Identifier-callee HOCs
* (`forwardRef(...)`, `useCallback(...)`, custom factories) are
* deliberately NOT listed — they keep their `Function` classification.
*
* Trade-off (unchanged from before #1876): a custom *fluent-API* member
* call with a callback whose method name is not in this set
* (`qb.where(x => …)`) still classifies as `Function`. There is no clean
* syntactic line beyond the well-known Array surface, so the set is
* intentionally closed and easy to extend.
*
* Receiver-blind, by design: the match keys on the method NAME only, never
* the receiver type (tree-sitter has no type information here). So an in-set
* name on a NON-array receiver — `Map`/`Set` `.forEach`, an RxJS
* `observable.map(…)`, a query builder `.sort(…)`, a lodash chain
* `.filter(…)` — is ALSO treated as a callback and has its
* `@declaration.function` dropped. This is an accepted limitation, not a
* regression: those bindings hold the call's *result value*, not a callable,
* so a value def is the correct classification anyway. The only genuine loss
* is a bespoke DSL whose in-set-named method returns something callable —
* rare enough to accept rather than guard with type inference. Pinned by the
* "in-set method on a non-array receiver" case in `*-captures.test.ts`.
*/
export const ARRAY_CALLBACK_METHODS: ReadonlySet<string> = new Set([
'map',
'filter',
'find',
'findIndex',
'findLast',
'findLastIndex',
'forEach',
'reduce',
'reduceRight',
'some',
'every',
'flatMap',
'sort',
]);
/**
* True when `node` (an `arrow_function` / `function_expression`) is the
* callback argument of an array higher-order-method call, i.e. the
* enclosing call's callee is a `member_expression` whose property is one
* of {@link ARRAY_CALLBACK_METHODS}.
*
* Returns false for direct assignments (`const fn = () => {}` — parent is
* `variable_declarator`, not `arguments`) and for identifier-callee HOCs
* (`forwardRef(() => …)` — callee is an `identifier`, not a
* `member_expression`), so neither is ever suppressed.
*
* Intentional non-suppressing gaps (preserve current behavior, no
* regression): parenthesized callee `(arr.map)(cb)` (`parenthesized_expression`)
* and computed callee `arr['map'](cb)` (`subscript_expression`).
*/
export function isArrayMethodCallbackArrow(node: SyntaxNode): boolean {
const args = node.parent;
if (args === null || args.type !== 'arguments') return false;
const call = args.parent;
if (call === null || call.type !== 'call_expression') return false;
const callee = call.childForFieldName('function');
if (callee === null || callee.type !== 'member_expression') return false;
const property = callee.childForFieldName('property');
if (property === null || property.type !== 'property_identifier') return false;
return ARRAY_CALLBACK_METHODS.has(property.text);
}

View file

@ -37,6 +37,7 @@ import { getTsParser, getTsScopeQuery, tsCachedTreeMatchesGrammar } from './quer
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
import { synthesizeTsReceiverBinding } from './receiver-binding.js';
import { computeTsArityMetadata } from './arity-metadata.js';
import { isArrayMethodCallbackArrow } from './array-callback.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
@ -252,6 +253,25 @@ export function emitTsScopeCaptures(
}
}
// #1876: drop @declaration.function for array higher-order-method
// callbacks (`const x = arr.map(a => …)`). The HOC-wrapped-arrow
// pattern matches them, but the binding holds a value, not a callable.
// The binding keeps its separate @declaration.const / .variable match,
// and the arrow's own @scope.function match (a different pattern) is
// untouched, so inner-call attribution falls through to the enclosing
// scope instead of a phantom Function.
const fnDeclAnchor = grouped['@declaration.function'];
if (fnDeclAnchor !== undefined) {
const arrowNode = findFunctionNode(
tree.rootNode,
fnDeclAnchor.range,
groupedNodes['@declaration.function'],
);
if (arrowNode !== null && isArrayMethodCallbackArrow(arrowNode)) {
continue;
}
}
// Synthesize arity metadata on function-like declaration anchors
// before pushing the match. The registry uses these to narrow
// overloads — TypeScript supports overload signatures via

View file

@ -250,20 +250,22 @@ const TYPESCRIPT_SCOPE_QUERY = `
;; that promotes the binding to the parent scope (where \`const X\`
;; lives).
;;
;; Trade-off — chained array-method form: \`const x = arr.find((y) => p(y))\`
;; has the same syntactic shape and would also match, naming the
;; \`.find\` callback as \`x\`. The resulting \`Function:x\` is mostly
;; harmless: \`x\` is consumed as a value (\`if (x) { ... }\`), never
;; invoked as a function, so it gets zero incoming \`CALLS\` edges. The
;; one outgoing edge \`Function:x → p\` is a minor mis-attribution that
;; could in principle be fixed by adding a \`function: [(identifier)
;; (member_expression)]\` predicate that excludes property-identifiers
;; matching a known array-method blocklist (\`map\` / \`filter\` / \`find\`
;; / \`reduce\` / \`forEach\` / \`some\` / \`every\`). We don't do that here
;; because (a) the false-positive cost is negligible, (b) the blocklist
;; would need maintenance, and (c) any user-defined fluent-API method
;; with a callback argument would still false-positive — there's no
;; clean syntactic line.
;; #1876 — chained array-method form: \`const x = arr.find((y) => p(y))\`
;; has the same syntactic shape and matches here too, naming the
;; \`.find\` callback as \`x\`. Because \`x\` holds a value (the method
;; result), not a callable, the spurious \`Function:x\` def is dropped
;; emit-side in captures.ts: \`isArrayMethodCallbackArrow\` skips any
;; \`@declaration.function\` whose enclosing call has a member-expression
;; callee with a known Array-method property (\`ARRAY_CALLBACK_METHODS\`:
;; \`map\` / \`filter\` / \`find\` / \`reduce\` / \`forEach\` / \`some\` /
;; \`every\` / …). Only the \`@declaration.variable\` survives, so the
;; binding is a single value def and calls inside the callback attribute
;; to the enclosing scope rather than \`Function:x\`.
;;
;; Residual (intentional): a user-defined fluent-API method with a
;; callback (\`qb.where(x => …)\`) is NOT in the blocklist and still
;; classifies as \`Function\` — there's no clean syntactic line beyond
;; the well-known Array surface, so the set is closed and easy to extend.
;;
;; Trade-off — multi-arrow arguments: \`const x = call(arrow1, arrow2)\`
;; would emit TWO matches with the same name \`x\`. tree-sitter-query

View file

@ -750,6 +750,61 @@ function normalizeNodeLabel(kindStr: string): SymbolDefinition['type'] | undefin
}
}
/** Function-like labels: callable defs that must keep incoming CALLS edges. */
const NODE_BEARING_FUNCTION_LABELS: ReadonlySet<SymbolDefinition['type']> = new Set([
'Function',
'Method',
'Constructor',
]);
/** Value labels: non-callable bindings (a `const`/`let`/`var` holds a value). */
const NODE_BEARING_VALUE_LABELS: ReadonlySet<SymbolDefinition['type']> = new Set([
'Const',
'Variable',
]);
/**
* Collapse rule for the deferred node-creation migration (#1876).
*
* When graph-node creation moves from the legacy DAG onto the
* registry-primary path, a single source binding can carry more than one
* `SymbolDefinition` for the same name in the same scope — e.g. a direct
* arrow `const fn = () => {}` is classified BOTH as a `Function` (the
* arrow) and a `Variable` (the binding). Emitting one graph node per def
* would reproduce exactly the duplicate-node bug this issue tracks.
*
* `selectNodeBearingDef` picks the ONE def that should bear the graph node
* for such a binding group:
*
* 1. a function-like def (`Function` / `Method` / `Constructor`) if any —
* the binding is callable and must keep incoming `CALLS` edges;
* 2. otherwise a value def (`Const` / `Variable`) — the binding holds a
* value (e.g. an array-method result after the U1/U2 narrowing);
* 3. otherwise the first def — deterministic fallback for label sets this
* rule does not rank.
*
* INPUT CONTRACT: `group` must be the defs bound to ONE name within ONE
* scope (a binding group). It deliberately does NOT dedup by range —
* `SymbolDefinition` carries no range and `makeDefId` encodes only the
* start position, so containment is uncomputable here; the caller forms the
* group (e.g. from a scope's `ownedDefs` keyed by name) before calling.
*
* Pure. No production call site yet — this dead export is intentional and
* tracked by #1876 (the deferred node-creation migration); it is the
* executable contract that follow-up will consume, pinned today by the
* scope-extractor unit test.
*/
export function selectNodeBearingDef(
group: readonly SymbolDefinition[],
): SymbolDefinition | undefined {
if (group.length === 0) return undefined;
const functionLike = group.find((def) => NODE_BEARING_FUNCTION_LABELS.has(def.type));
if (functionLike !== undefined) return functionLike;
const value = group.find((def) => NODE_BEARING_VALUE_LABELS.has(def.type));
if (value !== undefined) return value;
return group[0];
}
function makeDefId(
filePath: string,
range: Range,

View file

@ -51,6 +51,28 @@ const alreadyAvailable = (message: string): boolean =>
message.includes('already exists');
const resolvePolicyFromEnv = (): ExtensionInstallPolicy => {
const raw = process.env.GITNEXUS_LBUG_EXTENSION_INSTALL;
if (raw === 'load-only' || raw === 'never' || raw === 'auto') return raw;
return 'load-only';
};
export const getExtensionInstallPolicy = (): ExtensionInstallPolicy => resolvePolicyFromEnv();
/**
* Install policy for the **analyze (write) path**.
*
* The global default (`resolvePolicyFromEnv`) is `load-only` so serve/query
* read paths never require outbound network access (PR #1161, offline-first).
* The analyze path is different: it owns building the search indexes, so it
* defaults to `auto` — LOAD the extension if present, otherwise attempt one
* bounded out-of-process INSTALL. This keeps FTS symmetric with the
* VECTOR/embeddings path (which already defaults to `auto`) and matches the
* #726 contract. An explicit `GITNEXUS_LBUG_EXTENSION_INSTALL` value still
* wins, so operators can force `load-only`/`never` for fully offline analyze;
* `auto` LOADs-first, so offline machines still degrade gracefully when the
* INSTALL cannot reach the network.
*/
export const resolveAnalyzeInstallPolicy = (): ExtensionInstallPolicy => {
const raw = process.env.GITNEXUS_LBUG_EXTENSION_INSTALL;
if (raw === 'load-only' || raw === 'never' || raw === 'auto') return raw;
return 'auto';
@ -148,7 +170,7 @@ export const installDuckDbExtensionOutOfProcess = async (
* subsequent analyze or query calls.
*
* Policy precedence (most specific wins):
* per-call `opts.policy` → constructor `options.policy` → env → `auto`
* per-call `opts.policy` → constructor `options.policy` → env → `load-only`
*/
export class ExtensionManager {
private readonly capabilities = new Map<string, ExtensionCapability>();

View file

@ -24,8 +24,10 @@ import {
deleteNodesForFile,
deleteAllCommunitiesAndProcesses,
queryImporters,
loadFTSExtension,
} from './lbug/lbug-adapter.js';
import { createSearchFTSIndexes, verifySearchFTSIndexes } from './search/fts-indexes.js';
import { resolveAnalyzeInstallPolicy } from './lbug/extension-loader.js';
import {
startWalCheckpointDriver,
type WalCheckpointDriver,
@ -144,8 +146,26 @@ export interface AnalyzeResult {
pipelineResult?: any;
/** True when analyze only repaired FTS indexes and skipped pipeline re-analysis. */
ftsRepairedOnly?: boolean;
/**
* True when the FTS extension was unavailable so search-index creation was
* skipped (offline-first degradation). The graph is fully queryable; only
* full-text/BM25 search is disabled. Lets callers (CLI summary, server) and
* the persisted meta surface the degraded state instead of reporting healthy.
*/
ftsSkipped?: boolean;
}
/**
* Logged when the optional FTS extension cannot be loaded or installed during
* a full analyze. Kept as a named constant so the env-var/command guidance
* stays in one place (mirrors the VECTOR message in embedding-pipeline.ts).
*/
const FTS_UNAVAILABLE_MESSAGE =
'FTS extension unavailable; skipping search-index creation. ' +
'Full-text/BM25 search will be disabled until the LadybugDB FTS extension is ' +
'installed once with network access (GITNEXUS_LBUG_EXTENSION_INSTALL=auto) or ' +
'pre-installed for offline use. Run `gitnexus doctor` for details.';
// Re-export the pure flag-derivation helper so external callers (and tests)
// keep importing from this module's stable surface.
export { deriveEmbeddingMode, DEFAULT_EMBEDDING_NODE_LIMIT } from './embedding-mode.js';
@ -684,23 +704,41 @@ export async function runFullAnalysis(
}
// ── Phase 3: FTS (85–90%) ─────────────────────────────────────────
// The analyze (write) path owns building the search indexes, so it uses
// the `auto` install policy (LOAD-first, then one bounded INSTALL) —
// symmetric with the VECTOR/embeddings path below and consistent with the
// #726 contract. The global `load-only` default (PR #1161) governs the
// serve/query read paths, not this one. When the extension still cannot be
// loaded (genuinely offline + not pre-installed, or policy forced to
// load-only/never), degrade gracefully — exactly like the VECTOR path — so
// analyze still produces a fully queryable graph; only full-text/BM25
// search falls back. `--repair-fts` (whose sole job is FTS) still fails
// loudly on its own path above.
progress('fts', 85, 'Creating search indexes...');
await createSearchFTSIndexes({
onIndexStart: options.verbose
? (table, indexName) => log(`FTS: creating ${table}.${indexName}`)
: undefined,
onIndexReady: options.verbose
? (table, indexName) => log(`FTS: ready ${table}.${indexName}`)
: undefined,
const ftsAvailable = await loadFTSExtension(undefined, {
policy: resolveAnalyzeInstallPolicy(),
});
const missingIndexNames = await verifySearchFTSIndexes(executeQuery);
if (missingIndexNames.length > 0) {
throw new Error(
`FTS verification failed - missing indexes after analyze: ${missingIndexNames.join(', ')}. ` +
'Check FTS extension availability, then retry `gitnexus analyze --force` for a full rebuild.',
);
if (ftsAvailable) {
await createSearchFTSIndexes({
onIndexStart: options.verbose
? (table, indexName) => log(`FTS: creating ${table}.${indexName}`)
: undefined,
onIndexReady: options.verbose
? (table, indexName) => log(`FTS: ready ${table}.${indexName}`)
: undefined,
});
const missingIndexNames = await verifySearchFTSIndexes(executeQuery);
if (missingIndexNames.length > 0) {
throw new Error(
`FTS verification failed - missing indexes after analyze: ${missingIndexNames.join(', ')}. ` +
'Check FTS extension availability, then retry `gitnexus analyze --force` for a full rebuild.',
);
}
progress('fts', 90, 'Search indexes ready');
} else {
log(FTS_UNAVAILABLE_MESSAGE);
progress('fts', 90, 'Search indexes skipped (FTS unavailable)');
}
progress('fts', 90, 'Search indexes ready');
// ── Phase 3.5: Re-insert cached embeddings ────────────────────────
// Runs on BOTH the full-rebuild path and the incremental path:
@ -889,7 +927,14 @@ export async function runFullAnalysis(
},
capabilities: {
graph: { provider: 'ladybugdb', status: runtimeCapabilities.graph },
fts: { provider: 'ladybugdb-fts', status: runtimeCapabilities.fts },
// Reflect what this analyze run actually produced: when the FTS
// extension was unavailable the indexes were skipped, so record
// 'unavailable' rather than the static runtime default. Keeps
// meta.json / `gitnexus doctor` honest about degraded search.
fts: {
provider: 'ladybugdb-fts',
status: ftsAvailable ? runtimeCapabilities.fts : 'unavailable',
},
vectorSearch: {
provider: effectiveSemanticMode === 'vector-index' ? 'ladybugdb-vector' : 'exact-scan',
status: embeddingCount > 0 ? effectiveSemanticMode : 'unavailable',
@ -989,6 +1034,7 @@ export async function runFullAnalysis(
repoPath,
stats: meta.stats,
pipelineResult,
ftsSkipped: !ftsAvailable,
};
} catch (err) {
// Ensure LadybugDB is closed even on error. Stop the driver first

View file

@ -284,6 +284,37 @@ Follow these steps:
/**
* Start the MCP server on stdio transport (for CLI use).
*/
/** Force-exit fallback budget if graceful shutdown cleanup hangs. */
const SHUTDOWN_FORCE_EXIT_MS = 5_000;
/** Conventional 128 + signal-number exit codes for graceful termination. */
export const SHUTDOWN_EXIT_CODES = { SIGINT: 130, SIGTERM: 143 } as const;
type SignalRegistrar = (
event: 'SIGINT' | 'SIGTERM',
listener: (...args: unknown[]) => void,
) => void;
/**
* Wire SIGINT/SIGTERM to a graceful shutdown using NUMERIC exit codes.
*
* Node invokes signal listeners with the signal NAME string as the first
* argument, so registering an `(exitCode = 0) => process.exit(exitCode)`
* shutdown directly passes `'SIGTERM'` into `process.exit()` and crashes with
* `ERR_INVALID_ARG_TYPE` (#1132). These wrappers discard the signal argument
* and pass the conventional 128+signal code instead. `on` is injectable so the
* mapping can be unit-tested without touching the real process.
*/
export function installSignalShutdown(
shutdown: (exitCode?: number) => unknown,
on: SignalRegistrar = (event, listener) => {
process.on(event, listener);
},
): void {
on('SIGINT', () => void shutdown(SHUTDOWN_EXIT_CODES.SIGINT));
on('SIGTERM', () => void shutdown(SHUTDOWN_EXIT_CODES.SIGTERM));
}
export async function startMCPServer(backend: LocalBackend): Promise<void> {
const server = createMCPServer(backend);
@ -321,6 +352,11 @@ export async function startMCPServer(backend: LocalBackend): Promise<void> {
const shutdown = async (exitCode = 0) => {
if (shuttingDown) return;
shuttingDown = true;
// Safety net: if backend.disconnect()/server.close() hangs, still exit so a
// SIGINT/SIGTERM reliably terminates the process. Unref'd so the timer alone
// never keeps the event loop alive.
const forceExit = setTimeout(() => process.exit(exitCode), SHUTDOWN_FORCE_EXIT_MS);
forceExit.unref();
try {
await backend.disconnect();
} catch {}
@ -329,12 +365,16 @@ export async function startMCPServer(backend: LocalBackend): Promise<void> {
} catch {}
const { flushLoggerSync } = await import('../core/logger.js');
flushLoggerSync();
clearTimeout(forceExit);
process.exit(exitCode);
};
// Handle graceful shutdown
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
// Handle graceful shutdown. Node invokes signal listeners with the signal
// NAME (e.g. 'SIGTERM') as the first argument; registering `shutdown`
// directly passed that string to process.exit() and crashed with
// ERR_INVALID_ARG_TYPE (#1132). Map each signal to its conventional
// 128+signal exit code instead.
installSignalShutdown(shutdown);
// Log crashes to stderr so they aren't silently lost.
// uncaughtException is fatal — shut down.
@ -342,14 +382,16 @@ export async function startMCPServer(backend: LocalBackend): Promise<void> {
// killing the server for one missed catch would be worse than logging it.
process.on('uncaughtException', (err) => {
process.stderr.write(`GitNexus MCP uncaughtException: ${err?.stack || err}\n`);
shutdown(1);
void shutdown(1);
});
process.on('unhandledRejection', (reason: any) => {
process.stderr.write(`GitNexus MCP unhandledRejection: ${reason?.stack || reason}\n`);
});
// Handle stdio errors — stdin close means the parent process is gone
process.stdin.on('end', shutdown);
process.stdin.on('error', () => shutdown());
process.stdout.on('error', () => shutdown());
// Handle stdio errors — stdin close means the parent process is gone.
// Wrap so the event payload (e.g. an Error for 'error') can never reach
// process.exit() as a non-numeric exit code, and void the returned promise.
process.stdin.on('end', () => void shutdown(0));
process.stdin.on('error', () => void shutdown(0));
process.stdout.on('error', () => void shutdown(0));
}

View file

@ -0,0 +1,27 @@
function transform(account) {
return account.id;
}
function predicate(account) {
return account.active;
}
// Control: a normal named function whose body calls `transform` directly.
// Proves the registry-primary resolver wires same-file free calls for this
// fixture, so the callback assertions below are not vacuous.
function run(account) {
return transform(account);
}
const accountsList = [];
// #1876: array higher-order-method callbacks at module scope. Pre-fix the JS
// scope model emitted a phantom `Function:exportData` / `Function:firstActive`
// for these callbacks (they match the HOC-wrapped-arrow declaration pattern),
// and calls INSIDE the callbacks (`transform`, `predicate`) attributed to that
// phantom Function. Post-fix the callback is no longer a `Function` def, so the
// inner calls fall through to the enclosing File scope.
const exportData = accountsList.map((account) => transform(account));
const firstActive = accountsList.find((account) => predicate(account));
module.exports = { run, exportData, firstActive };

View file

@ -9,7 +9,8 @@
* Seed data is NOT included — each test provides its own via options.seed.
*/
import path from 'path';
import { describe, beforeAll, afterAll } from 'vitest';
import { describe, beforeAll, beforeEach, afterAll } from 'vitest';
import { resolveAnalyzeInstallPolicy } from '../../src/core/lbug/extension-loader.js';
import { createTempDir, type TestDBHandle } from './test-db.js';
import { NODE_TABLES, EMBEDDING_TABLE_NAME } from '../../src/core/lbug/schema.js';
@ -73,6 +74,15 @@ export function withTestLbugDB(
// init on Windows CI regularly exceeds 30s due to native resource setup.
const timeout = options?.timeout ?? 120_000;
// Suites that seed FTS indexes need the optional FTS extension. It is not
// guaranteed on every machine (e.g. the macOS platform-sensitive CI runner,
// where it is neither pre-installed nor installable). Track availability so
// setup can skip FTS seeding instead of throwing, and so every test in the
// suite is skipped rather than failing against a missing index. (PR #1161.)
const ftsRequired = !!options?.ftsIndexes?.length;
let ftsAvailable = true;
let ftsSkipWarned = false;
const setup = async () => {
const tmpHandle = await createTempDir('gitnexus-lbug-');
const dbPath = path.join(tmpHandle.dbPath, 'lbug');
@ -84,6 +94,16 @@ export function withTestLbugDB(
// already open for this dbPath (no new native objects created).
await adapter.initLbug(dbPath);
// 1b. Probe the FTS extension for suites that need it, mirroring the
// analyze write path (`auto`: LOAD-first, then one bounded INSTALL).
// When it still cannot load, the suite is skipped (see beforeEach)
// and FTS seeding below is bypassed so setup never throws.
if (ftsRequired) {
ftsAvailable = await adapter.loadFTSExtension(undefined, {
policy: resolveAnalyzeInstallPolicy(),
});
}
// 2. Drop stale FTS indexes from previous test file
if (options?.ftsIndexes?.length) {
for (const idx of options.ftsIndexes) {
@ -108,8 +128,9 @@ export function withTestLbugDB(
}
}
// 5. Create FTS indexes on fresh data
if (options?.ftsIndexes?.length) {
// 5. Create FTS indexes on fresh data (only when the extension loaded;
// otherwise the suite is skipped via beforeEach below).
if (options?.ftsIndexes?.length && ftsAvailable) {
for (const idx of options.ftsIndexes) {
await adapter.createFTSIndex(idx.table, idx.indexName, idx.columns);
}
@ -166,6 +187,21 @@ export function withTestLbugDB(
// collisions when multiple withTestLbugDB calls share the same file.
describe(`withTestLbugDB(${prefix})`, () => {
beforeAll(setup, timeout);
// Skip FTS-dependent suites when the extension could not be loaded or
// installed on this machine. Without this, tests would assert against a
// missing index and fail. Warn once so the skip is visible, not silent.
beforeEach((ctx) => {
if (ftsRequired && !ftsAvailable) {
if (!ftsSkipWarned) {
ftsSkipWarned = true;
console.warn(
`[withTestLbugDB(${prefix})] Skipping FTS-dependent tests — the LadybugDB ` +
`FTS extension is unavailable (not pre-installed and could not be installed).`,
);
}
ctx.skip();
}
});
// Explicit timeout: KuzuDB's C++ destructor can hang on Windows during
// native resource cleanup. The vitest hookTimeout (120s) should apply
// automatically, but some vitest versions fall back to testTimeout (30s)

View file

@ -0,0 +1,81 @@
/**
* JavaScript: CALLS-edge attribution for calls inside array higher-order-
* method callbacks (issue #1876).
*
* `const exportData = accountsList.map(account => transform(account))` matches
* the HOC-wrapped-arrow declaration pattern, so before this fix the JS scope
* model emitted a phantom `Function:exportData` for the `.map` callback (on
* top of the value binding). Calls nested in the callback (`transform`) then
* attributed to that phantom `Function` instead of the enclosing scope.
*
* U1 drops the `@declaration.function` for array-method callbacks, so the
* binding is value-only and the inner call falls through to the File scope —
* exactly the Zustand module-level-call behavior already pinned for TS.
*
* SCOPE: this asserts the registry-primary CALLS-edge ATTRIBUTION change only.
* The duplicate *graph node* (`Function:exportData`) is created by the legacy
* parse-worker node path, which this change does not touch; collapsing it is
* the deferred node-creation migration. Accordingly this file makes NO node-
* count assertion.
*
* Registry-primary-only correctness win: under the forced-legacy parity flag
* (`REGISTRY_PRIMARY_JAVASCRIPT=0`) the legacy DAG still emits the phantom
* attribution, so the suite is skipped there (mirrors the per-language
* expected-failure handling in `resolvers/helpers.ts`).
*/
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import {
FIXTURES,
getRelationships,
isLegacyResolverParityRun,
runPipelineFromRepo,
type PipelineResult,
} from './resolvers/helpers.js';
describe.skipIf(isLegacyResolverParityRun('javascript'))(
'JavaScript array-method-callback CALLS attribution (#1876)',
() => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'javascript-array-method-callback'),
() => {},
);
}, 60000);
it('control: run() body calls transform directly (resolver is wired)', () => {
const calls = getRelationships(result, 'CALLS').filter((c) => c.target === 'transform');
expect(calls.map((c) => `${c.source} → ${c.target}`)).toContain('run → transform');
});
it('call inside .map callback attributes to File, not a phantom Function:exportData', () => {
const calls = getRelationships(result, 'CALLS').filter((c) => c.target === 'transform');
const fromExportData = calls.filter((c) => c.source === 'exportData');
expect(
fromExportData,
'transform must NOT be attributed to exportData (phantom Function)',
).toEqual([]);
const fromFile = calls.filter((c) => c.sourceLabel === 'File');
expect(
fromFile,
'the .map callback call to transform must source from the File node (exactly once)',
).toHaveLength(1);
});
it('call inside .find callback attributes to File, not a phantom Function:firstActive', () => {
const calls = getRelationships(result, 'CALLS').filter((c) => c.target === 'predicate');
const fromFirstActive = calls.filter((c) => c.source === 'firstActive');
expect(
fromFirstActive,
'predicate must NOT be attributed to firstActive (phantom Function)',
).toEqual([]);
const fromFile = calls.filter((c) => c.sourceLabel === 'File');
expect(
fromFile,
'the .find callback call to predicate must source from the File node (exactly once)',
).toHaveLength(1);
});
},
);

View file

@ -23,6 +23,26 @@ import { withTestLbugDB } from '../helpers/test-indexed-db.js';
*/
const itLbugReopen = process.platform === 'win32' ? it.skip : it;
/**
* The FTS extension is optional and defaults to a `load-only` install policy
* (PR #1161 — offline-first), so on a machine where it was never pre-installed
* it cannot load. The tests below exercise the FTS *primitives* directly and
* have nothing to assert without the extension — skip them rather than fail.
* Graceful degradation when FTS is unavailable is covered at the analyze /
* query layer (see run-analyze.ts and the BM25 fallback tests).
*/
const FTS_UNAVAILABLE_NOTE =
'FTS extension unavailable (load-only policy; not pre-installed on this machine)';
/**
* Dynamically skip an FTS-primitive test when the extension cannot load.
* `ctx.skip()` aborts the test, so callers should `await` this first thing.
*/
const skipUnlessFtsAvailable = async (ctx: { skip: (note?: string) => void }): Promise<void> => {
const { loadFTSExtension } = await import('../../src/core/lbug/lbug-adapter.js');
if (!(await loadFTSExtension())) ctx.skip(FTS_UNAVAILABLE_NOTE);
};
// ─── Core LadybugDB Adapter ─────────────────────────────────────────────
withTestLbugDB(
@ -47,7 +67,8 @@ withTestLbugDB(
expect(folderRows).toHaveLength(1);
});
it('createFTSIndex: creates FTS index on Function table without error', async () => {
it('createFTSIndex: creates FTS index on Function table without error', async (ctx) => {
await skipUnlessFtsAvailable(ctx);
const { createFTSIndex } = await import('../../src/core/lbug/lbug-adapter.js');
await expect(
@ -55,7 +76,8 @@ withTestLbugDB(
).resolves.toBeUndefined();
});
it('loadFTSExtension(conn): loads on an explicit connection and returns true', async () => {
it('loadFTSExtension(conn): loads on an explicit connection and returns true', async (ctx) => {
await skipUnlessFtsAvailable(ctx);
const lbug = (await import('@ladybugdb/core')).default;
const { loadFTSExtension, getDatabase } =
await import('../../src/core/lbug/lbug-adapter.js');
@ -119,7 +141,8 @@ withTestLbugDB(
});
describe('error handling', () => {
it('createFTSIndex handles already-existing index gracefully', async () => {
it('createFTSIndex handles already-existing index gracefully', async (ctx) => {
await skipUnlessFtsAvailable(ctx);
const { createFTSIndex } = await import('../../src/core/lbug/lbug-adapter.js');
// First call creates the index (may already exist from earlier test)
@ -131,7 +154,8 @@ withTestLbugDB(
).resolves.toBeUndefined();
});
it('ensureFTSIndex is idempotent and caches across writable calls (#1224)', async () => {
it('ensureFTSIndex is idempotent and caches across writable calls (#1224)', async (ctx) => {
await skipUnlessFtsAvailable(ctx);
const { ensureFTSIndex } = await import('../../src/core/lbug/lbug-adapter.js');
// First call creates the index. Second call must short-circuit on the
@ -174,7 +198,8 @@ withTestLbugDB(
itLbugReopen(
'initLbug loads FTS so reopened HTTP-style sessions can query existing indexes',
async () => {
async (ctx) => {
await skipUnlessFtsAvailable(ctx);
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
const indexName = 'function_fts_init_probe';

View file

@ -3,6 +3,7 @@ import { createHash } from 'crypto';
import {
contentHashForNode,
EMBEDDING_TEXT_VERSION,
resolveEmbeddingInstallPolicy,
} from '../../src/core/embeddings/embedding-pipeline.js';
import { generateEmbeddingText } from '../../src/core/embeddings/text-generator.js';
import type { EmbeddableNode, EmbeddingProgress } from '../../src/core/embeddings/types.js';
@ -12,6 +13,55 @@ import { STALE_HASH_SENTINEL } from '../../src/core/lbug/schema.js';
const CLASS_CHUNK_SIZE = 90;
const CLASS_OVERLAP = 10;
// ────────────────────────────────────────────────────────────────────────────
// resolveEmbeddingInstallPolicy (offline-first, #1153)
// ────────────────────────────────────────────────────────────────────────────
describe('resolveEmbeddingInstallPolicy (#1153)', () => {
const ENV = 'GITNEXUS_LBUG_EXTENSION_INSTALL';
const original = process.env[ENV];
const restore = () => {
if (original === undefined) delete process.env[ENV];
else process.env[ENV] = original;
};
it('defaults to auto when unset (embeddings are an explicit network-capable opt-in)', () => {
delete process.env[ENV];
try {
expect(resolveEmbeddingInstallPolicy()).toBe('auto');
} finally {
restore();
}
});
it('honors an explicit load-only override (offline operator is not forced onto the network)', () => {
process.env[ENV] = 'load-only';
try {
expect(resolveEmbeddingInstallPolicy()).toBe('load-only');
} finally {
restore();
}
});
it('honors an explicit never override', () => {
process.env[ENV] = 'never';
try {
expect(resolveEmbeddingInstallPolicy()).toBe('never');
} finally {
restore();
}
});
it('falls back to auto for invalid values', () => {
process.env[ENV] = 'bogus';
try {
expect(resolveEmbeddingInstallPolicy()).toBe('auto');
} finally {
restore();
}
});
});
// ────────────────────────────────────────────────────────────────────────────
// contentHashForNode
// ────────────────────────────────────────────────────────────────────────────

View file

@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
import {
ExtensionManager,
getExtensionInstallChildProcessArgs,
getExtensionInstallPolicy,
getExtensionInstallTimeoutMs,
type ExtensionInstallResult,
} from '../../src/core/lbug/extension-loader.js';
@ -222,6 +223,64 @@ describe('installDuckDbExtensionOutOfProcess child process', () => {
});
});
describe('getExtensionInstallPolicy', () => {
it('defaults to load-only when env var is unset', () => {
const original = process.env.GITNEXUS_LBUG_EXTENSION_INSTALL;
delete process.env.GITNEXUS_LBUG_EXTENSION_INSTALL;
try {
expect(getExtensionInstallPolicy()).toBe('load-only');
} finally {
if (original === undefined) {
delete process.env.GITNEXUS_LBUG_EXTENSION_INSTALL;
} else {
process.env.GITNEXUS_LBUG_EXTENSION_INSTALL = original;
}
}
});
it('returns auto when env var is set to auto', () => {
const original = process.env.GITNEXUS_LBUG_EXTENSION_INSTALL;
process.env.GITNEXUS_LBUG_EXTENSION_INSTALL = 'auto';
try {
expect(getExtensionInstallPolicy()).toBe('auto');
} finally {
if (original === undefined) {
delete process.env.GITNEXUS_LBUG_EXTENSION_INSTALL;
} else {
process.env.GITNEXUS_LBUG_EXTENSION_INSTALL = original;
}
}
});
it('returns never when env var is set to never', () => {
const original = process.env.GITNEXUS_LBUG_EXTENSION_INSTALL;
process.env.GITNEXUS_LBUG_EXTENSION_INSTALL = 'never';
try {
expect(getExtensionInstallPolicy()).toBe('never');
} finally {
if (original === undefined) {
delete process.env.GITNEXUS_LBUG_EXTENSION_INSTALL;
} else {
process.env.GITNEXUS_LBUG_EXTENSION_INSTALL = original;
}
}
});
it('falls back to load-only for invalid env var values', () => {
const original = process.env.GITNEXUS_LBUG_EXTENSION_INSTALL;
process.env.GITNEXUS_LBUG_EXTENSION_INSTALL = 'bogus';
try {
expect(getExtensionInstallPolicy()).toBe('load-only');
} finally {
if (original === undefined) {
delete process.env.GITNEXUS_LBUG_EXTENSION_INSTALL;
} else {
process.env.GITNEXUS_LBUG_EXTENSION_INSTALL = original;
}
}
});
});
describe('getExtensionInstallTimeoutMs', () => {
it('reads a positive override from the environment', () => {
const original = process.env.GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS;

View file

@ -19,6 +19,7 @@ describe('runFullAnalysis FTS repair and verification failure paths', () => {
vi.doUnmock('../../src/core/lbug/lbug-adapter.js');
vi.doUnmock('../../src/core/search/fts-indexes.js');
vi.doUnmock('../../src/core/ingestion/pipeline.js');
vi.doUnmock('../../src/storage/repo-manager.js');
vi.resetModules();
vi.clearAllMocks();
});
@ -211,6 +212,8 @@ describe('runFullAnalysis FTS repair and verification failure paths', () => {
deleteNodesForFile: vi.fn(async () => undefined),
deleteAllCommunitiesAndProcesses: vi.fn(async () => undefined),
queryImporters: vi.fn(async () => []),
// FTS extension loads → analyze proceeds to create + verify indexes.
loadFTSExtension: vi.fn(async () => true),
}));
vi.doMock('../../src/core/search/fts-indexes.js', () => ({
createSearchFTSIndexes: vi.fn(async () => undefined),
@ -240,4 +243,66 @@ describe('runFullAnalysis FTS repair and verification failure paths', () => {
await tmpRepo.cleanup();
}
});
it('full analyze degrades gracefully (no throw, warns, skips index creation) when FTS extension is unavailable', async () => {
// Offline-first degradation: when loadFTSExtension() returns false, the
// analyze path must NOT call createSearchFTSIndexes / verifySearchFTSIndexes
// and must NOT throw — it logs a warning and completes (#1161).
const createSearchFTSIndexes = vi.fn(async () => undefined);
const verifySearchFTSIndexes = vi.fn(async () => []);
vi.doMock('../../src/core/lbug/lbug-adapter.js', () => ({
initLbug: vi.fn(async () => undefined),
loadGraphToLbug: vi.fn(async () => undefined),
getLbugStats: vi.fn(async () => ({ nodes: 1, edges: 0, communities: 0, processes: 0 })),
executeQuery: vi.fn(async () => []),
executeWithReusedStatement: vi.fn(async () => []),
closeLbug: vi.fn(async () => undefined),
loadCachedEmbeddings: vi.fn(async () => ({ embeddingNodeIds: new Set(), embeddings: [] })),
deleteNodesForFile: vi.fn(async () => undefined),
deleteAllCommunitiesAndProcesses: vi.fn(async () => undefined),
queryImporters: vi.fn(async () => []),
// FTS extension cannot load (offline + not pre-installed, or policy forced).
loadFTSExtension: vi.fn(async () => false),
}));
vi.doMock('../../src/core/search/fts-indexes.js', () => ({
createSearchFTSIndexes,
verifySearchFTSIndexes,
}));
vi.doMock('../../src/core/ingestion/pipeline.js', () => ({
runPipelineFromRepo: vi.fn(async (repoPath: string) => ({
repoPath,
totalFileCount: 1,
graph: { forEachNode: () => undefined },
})),
}));
// Avoid touching the global registry / repo .gitnexusignore from a unit test.
vi.doMock('../../src/storage/repo-manager.js', async (importActual) => ({
...(await importActual<typeof import('../../src/storage/repo-manager.js')>()),
registerRepo: vi.fn(async () => 'degraded-repo'),
ensureGitNexusIgnored: vi.fn(async () => undefined),
}));
const tmpRepo = await createTempDir('gitnexus-run-analyze-fts-degrade-');
try {
const logs: string[] = [];
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
const result = await runFullAnalysis(
tmpRepo.dbPath,
{ force: true },
{ onProgress: () => {}, onLog: (msg: string) => logs.push(msg) },
);
expect(result.ftsSkipped).toBe(true);
expect(createSearchFTSIndexes).not.toHaveBeenCalled();
expect(verifySearchFTSIndexes).not.toHaveBeenCalled();
expect(logs.join('\n')).toMatch(/FTS extension unavailable; skipping search-index creation/i);
// The degraded state is persisted so meta.json / doctor stay honest.
const { storagePath } = getStoragePaths(tmpRepo.dbPath);
const meta = JSON.parse(await fs.readFile(`${storagePath}/meta.json`, 'utf-8'));
expect(meta.capabilities.fts.status).toBe('unavailable');
} finally {
await tmpRepo.cleanup();
}
});
});

View file

@ -0,0 +1,124 @@
/**
* Coverage for the JavaScript scope-captures orchestrator, focused on the
* #1876 array-method-callback narrowing.
*
* `const x = arr.map(a => …)` must NOT produce a `@declaration.function`
* named `x` (the binding holds a value, not a callable) — only the
* `@declaration.const`. Identifier-callee HOCs (`forwardRef`, `useMemo`)
* and direct arrow assignments keep their `@declaration.function`.
*
* Runs against tree-sitter-javascript so it catches grammar drift before
* the integration parity gate.
*/
import { describe, it, expect } from 'vitest';
import { emitJsScopeCaptures } from '../../../../src/core/ingestion/languages/javascript/captures.js';
function matchesFor(src: string) {
return emitJsScopeCaptures(src, 'test.js');
}
/** True when some match carries `tag` and its @declaration.name is `name`. */
function hasDecl(src: string, tag: string, name: string): boolean {
return matchesFor(src).some((m) => m[tag] !== undefined && m['@declaration.name']?.text === name);
}
/** Count matches carrying `tag` (any name). */
function countTag(src: string, tag: string): number {
return matchesFor(src).filter((m) => m[tag] !== undefined).length;
}
describe('emitJsScopeCaptures — #1876 array-method-callback narrowing', () => {
it('does not emit @declaration.function for `const x = arr.map(a => …)`', () => {
const src = 'const exportData = accountsList.map(account => ({ id: account.id }));';
expect(hasDecl(src, '@declaration.const', 'exportData')).toBe(true);
expect(hasDecl(src, '@declaration.function', 'exportData')).toBe(false);
// Exactly one binding-bearing declaration for the name.
expect(countTag(src, '@declaration.function')).toBe(0);
});
// Every method in ARRAY_CALLBACK_METHODS except `map` (covered above).
it.each([
'filter',
'find',
'findIndex',
'findLast',
'findLastIndex',
'reduce',
'reduceRight',
'forEach',
'some',
'every',
'flatMap',
'sort',
])('suppresses the Function def for array method .%s()', (method) => {
const src = `const x = arr.${method}((a) => a);`;
expect(hasDecl(src, '@declaration.function', 'x')).toBe(false);
expect(hasDecl(src, '@declaration.const', 'x')).toBe(true);
});
it('keeps @declaration.function for an identifier-callee HOC (forwardRef)', () => {
const src = 'const Button = forwardRef((props, ref) => null);';
expect(hasDecl(src, '@declaration.function', 'Button')).toBe(true);
});
it('keeps @declaration.function for useMemo (identifier callee, unchanged this round)', () => {
const src = 'const value = useMemo(() => compute(), []);';
expect(hasDecl(src, '@declaration.function', 'value')).toBe(true);
});
it('keeps dual classification for a direct arrow `const fn = () => {}`', () => {
const src = 'const fn = () => { doThing(); };';
expect(hasDecl(src, '@declaration.function', 'fn')).toBe(true);
expect(hasDecl(src, '@declaration.const', 'fn')).toBe(true);
});
it('keeps @declaration.function for a non-array fluent-API member call (accepted limitation)', () => {
const src = 'const q = qb.where((row) => row.ok);';
expect(hasDecl(src, '@declaration.function', 'q')).toBe(true);
});
it('suppresses an in-set method name on a NON-array receiver (accepted receiver-blind limitation)', () => {
// The predicate keys on the method NAME only, never the receiver type —
// tree-sitter has no type info. So `.map` on an RxJS observable (or
// Map/Set `.forEach`, a query builder `.sort`, a lodash chain `.filter`)
// is also treated as a callback and loses its Function def. Accepted: the
// binding holds the call's result value, so a value def is correct anyway.
const src = 'const stream = source$.map((event) => handle(event));';
expect(hasDecl(src, '@declaration.function', 'stream')).toBe(false);
expect(hasDecl(src, '@declaration.const', 'stream')).toBe(true);
});
it('suppresses the outer .map() callback in a chained array call', () => {
const src = 'const x = arr.filter((a) => a).map((b) => b);';
expect(hasDecl(src, '@declaration.function', 'x')).toBe(false);
expect(hasDecl(src, '@declaration.const', 'x')).toBe(true);
});
it('suppresses through an export_statement wrapper', () => {
const src = 'export const x = arr.map((a) => a);';
expect(hasDecl(src, '@declaration.function', 'x')).toBe(false);
expect(hasDecl(src, '@declaration.const', 'x')).toBe(true);
});
it('suppresses a function_expression callback', () => {
const src = 'const x = arr.map(function (a) { return a; });';
expect(hasDecl(src, '@declaration.function', 'x')).toBe(false);
expect(hasDecl(src, '@declaration.const', 'x')).toBe(true);
});
it('suppresses an optional-chained array call `arr?.map(...)`', () => {
const src = 'const x = arr?.map((a) => a);';
expect(hasDecl(src, '@declaration.function', 'x')).toBe(false);
});
it('does NOT suppress a parenthesized callee `(arr.map)(cb)` (intentional gap)', () => {
const src = 'const x = (arr.map)((a) => a);';
expect(hasDecl(src, '@declaration.function', 'x')).toBe(true);
});
it('does NOT suppress a computed callee `arr["map"](cb)` (intentional gap)', () => {
const src = 'const x = arr["map"]((a) => a);';
expect(hasDecl(src, '@declaration.function', 'x')).toBe(true);
});
});

View file

@ -16,8 +16,13 @@ import type {
ReferenceKind,
Scope,
ScopeKind,
SymbolDefinition,
} from 'gitnexus-shared';
import { extract, type ScopeExtractorHooks } from '../../../src/core/ingestion/scope-extractor.js';
import {
extract,
selectNodeBearingDef,
type ScopeExtractorHooks,
} from '../../../src/core/ingestion/scope-extractor.js';
// ─── Synthetic-capture helpers ──────────────────────────────────────────────
@ -550,3 +555,54 @@ describe('end-to-end fixture (all 5 passes together)', () => {
expect(result.moduleScope).toBe(mod.id);
});
});
describe('selectNodeBearingDef — #1876 one-node-per-binding collapse rule', () => {
const def = (type: SymbolDefinition['type'], name = 'x'): SymbolDefinition => ({
nodeId: `def:test.ts#1:0:${type}:${name}`,
filePath: 'test.ts',
type,
qualifiedName: name,
});
it('returns undefined for an empty group', () => {
expect(selectNodeBearingDef([])).toBeUndefined();
});
it('returns the only def for a single-element group', () => {
const only = def('Variable');
expect(selectNodeBearingDef([only])).toBe(only);
});
it('prefers a Function over a co-bound Variable (direct arrow / HOC)', () => {
const fn = def('Function');
const variable = def('Variable');
// Order-independent: function-like wins regardless of position.
expect(selectNodeBearingDef([variable, fn])).toBe(fn);
expect(selectNodeBearingDef([fn, variable])).toBe(fn);
});
it('prefers a Method over a co-bound value def', () => {
const method = def('Method');
const variable = def('Variable');
expect(selectNodeBearingDef([variable, method])).toBe(method);
});
it('returns the value def when no function-like def is present (array-method result)', () => {
const constDef = def('Const');
expect(selectNodeBearingDef([constDef])).toBe(constDef);
const variable = def('Variable');
expect(selectNodeBearingDef([variable])).toBe(variable);
});
it('prefers a value def even when an unranked label appears first', () => {
const cls = def('Class');
const variable = def('Variable');
expect(selectNodeBearingDef([cls, variable])).toBe(variable);
});
it('falls back to the first def for label sets the rule does not rank', () => {
const cls = def('Class');
const iface = def('Interface');
expect(selectNodeBearingDef([cls, iface])).toBe(cls);
});
});

View file

@ -565,3 +565,100 @@ describe('emitTsScopeCaptures — edge cases', () => {
expect(() => emitTsScopeCaptures('', 'test.ts')).not.toThrow();
});
});
describe('emitTsScopeCaptures — #1876 array-method-callback narrowing', () => {
// True when some match carries `tag` and its @declaration.name is `name`.
const declWithName = (src: string, tag: string, name: string): boolean =>
emitTsScopeCaptures(src, 'test.ts').some(
(m) => m[tag] !== undefined && m['@declaration.name']?.text === name,
);
it('does not emit @declaration.function for `const x = arr.map(a => …)`', () => {
const src = 'const exportData = accountsList.map((account) => ({ id: account.id }));';
expect(declWithName(src, '@declaration.variable', 'exportData')).toBe(true);
expect(declWithName(src, '@declaration.function', 'exportData')).toBe(false);
});
// Every method in ARRAY_CALLBACK_METHODS except `map` (covered above).
it.each([
'filter',
'find',
'findIndex',
'findLast',
'findLastIndex',
'reduce',
'reduceRight',
'forEach',
'some',
'every',
'flatMap',
'sort',
])('suppresses the Function def for array method .%s()', (method) => {
const src = `const x = arr.${method}((a) => a);`;
expect(declWithName(src, '@declaration.function', 'x')).toBe(false);
expect(declWithName(src, '@declaration.variable', 'x')).toBe(true);
});
it('keeps @declaration.function for an identifier-callee HOC (forwardRef)', () => {
const src = 'const Button = forwardRef((props, ref) => null);';
expect(declWithName(src, '@declaration.function', 'Button')).toBe(true);
});
it('keeps @declaration.function for useCallback (identifier callee, unchanged this round)', () => {
const src = 'const cb = useCallback(() => doThing(), []);';
expect(declWithName(src, '@declaration.function', 'cb')).toBe(true);
});
it('keeps dual classification for a direct arrow `const fn = () => {}`', () => {
const src = 'const fn = () => { doThing(); };';
expect(declWithName(src, '@declaration.function', 'fn')).toBe(true);
expect(declWithName(src, '@declaration.variable', 'fn')).toBe(true);
});
it('keeps @declaration.function for a non-array fluent-API member call (accepted limitation)', () => {
const src = 'const q = qb.where((row) => row.ok);';
expect(declWithName(src, '@declaration.function', 'q')).toBe(true);
});
it('suppresses an in-set method name on a NON-array receiver (accepted receiver-blind limitation)', () => {
// Receiver-blind by design — see array-callback.ts. An in-set method name
// on a non-array receiver (RxJS observable, Map/Set, query builder) also
// loses its Function def. Accepted: the binding holds a value, not a callable.
const src = 'const stream = source$.map((event) => handle(event));';
expect(declWithName(src, '@declaration.function', 'stream')).toBe(false);
expect(declWithName(src, '@declaration.variable', 'stream')).toBe(true);
});
it('suppresses the outer .map() callback in a chained array call', () => {
const src = 'const x = arr.filter((a) => a).map((b) => b);';
expect(declWithName(src, '@declaration.function', 'x')).toBe(false);
expect(declWithName(src, '@declaration.variable', 'x')).toBe(true);
});
it('suppresses through an export_statement wrapper', () => {
const src = 'export const x = arr.map((a) => a);';
expect(declWithName(src, '@declaration.function', 'x')).toBe(false);
expect(declWithName(src, '@declaration.variable', 'x')).toBe(true);
});
it('suppresses a function_expression callback', () => {
const src = 'const x = arr.map(function (a) { return a; });';
expect(declWithName(src, '@declaration.function', 'x')).toBe(false);
expect(declWithName(src, '@declaration.variable', 'x')).toBe(true);
});
it('suppresses an optional-chained array call `arr?.map(...)`', () => {
const src = 'const x = arr?.map((a) => a);';
expect(declWithName(src, '@declaration.function', 'x')).toBe(false);
});
it('does NOT suppress a parenthesized callee `(arr.map)(cb)` (intentional gap)', () => {
const src = 'const x = (arr.map)((a) => a);';
expect(declWithName(src, '@declaration.function', 'x')).toBe(true);
});
it('does NOT suppress a computed callee `arr["map"](cb)` (intentional gap)', () => {
const src = 'const x = arr["map"]((a) => a);';
expect(declWithName(src, '@declaration.function', 'x')).toBe(true);
});
});

View file

@ -15,7 +15,11 @@
import { describe, it, expect, vi } from 'vitest';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { createMCPServer } from '../../src/mcp/server.js';
import {
createMCPServer,
installSignalShutdown,
SHUTDOWN_EXIT_CODES,
} from '../../src/mcp/server.js';
import { GITNEXUS_TOOLS } from '../../src/mcp/tools.js';
// ─── Mock backend ──────────────────────────────────────────────────
@ -125,3 +129,38 @@ describe('prompt registration', () => {
expect(server).toBeDefined();
});
});
// ─── Graceful shutdown signal handling (#1132) ────────────────────────
describe('installSignalShutdown (#1132)', () => {
it('maps SIGINT→130 / SIGTERM→143 and never passes the signal name to shutdown', () => {
// Node invokes signal listeners with the signal NAME string as the first
// argument. The old code registered `shutdown` directly, so that string
// reached process.exit() and crashed with ERR_INVALID_ARG_TYPE. Reproduce
// that exact invocation and assert a numeric code is used instead.
const received: unknown[] = [];
let onSigint: ((...args: unknown[]) => void) | undefined;
let onSigterm: ((...args: unknown[]) => void) | undefined;
installSignalShutdown(
(code) => received.push(code),
(event, listener) => {
if (event === 'SIGINT') onSigint = listener;
if (event === 'SIGTERM') onSigterm = listener;
},
);
expect(onSigint).toBeTypeOf('function');
expect(onSigterm).toBeTypeOf('function');
// Invoke exactly as Node does — with the signal name string as the arg.
onSigint?.('SIGINT');
onSigterm?.('SIGTERM');
expect(received).toEqual([SHUTDOWN_EXIT_CODES.SIGINT, SHUTDOWN_EXIT_CODES.SIGTERM]);
expect(received).toEqual([130, 143]);
for (const code of received) {
expect(typeof code).toBe('number');
}
});
});