mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-06 02:49:56 +00:00
Merge branch 'fix/csharp-namespace-scope-oom' into fix/1881-csharp-unrelated-edges
This commit is contained in:
commit
df0c2315ba
23 changed files with 1067 additions and 56 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 })}`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 = `
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
27
gitnexus/test/fixtures/lang-resolution/javascript-array-method-callback/src/index.js
vendored
Normal file
27
gitnexus/test/fixtures/lang-resolution/javascript-array-method-callback/src/index.js
vendored
Normal 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 };
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue