gitnexus 1.6.7-rc.4 → 1.6.7-rc.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -47,7 +47,7 @@ import type { ResolutionOutcomeRecorder } from '../resolution-outcome.js';
47
47
  /** Subset of `ScopeResolver` consumed by this pass. Accepting the
48
48
  * subset rather than the full provider keeps tests and partial
49
49
  * refactors lighter — callers only need to populate what we read. */
50
- type ReceiverBoundProviderSubset = Pick<ScopeResolver, 'isSuperReceiver' | 'isSuperReceiverInContext' | 'fieldFallbackOnMethodLookup' | 'collapseMemberCallsByCallerTarget' | 'unwrapCollectionAccessor' | 'hoistTypeBindingsToModule' | 'resolveQualifiedReceiverMember' | 'resolveThisViaEnclosingClass' | 'conversionRankFn' | 'constraintCompatibility' | 'isStaticOnly'>;
50
+ type ReceiverBoundProviderSubset = Pick<ScopeResolver, 'isSuperReceiver' | 'isSuperReceiverInContext' | 'fieldFallbackOnMethodLookup' | 'collapseMemberCallsByCallerTarget' | 'unwrapCollectionAccessor' | 'hoistTypeBindingsToModule' | 'resolveQualifiedReceiverMember' | 'resolveReceiverMember' | 'resolveThisViaEnclosingClass' | 'conversionRankFn' | 'constraintCompatibility' | 'isStaticOnly'>;
51
51
  export declare function emitReceiverBoundCalls(graph: KnowledgeGraph, scopes: ScopeResolutionIndexes, parsedFiles: readonly ParsedFile[], nodeLookup: GraphNodeLookup, handledSites: Set<string>, provider: ReceiverBoundProviderSubset, index: WorkspaceResolutionIndex, model: SemanticModel, options?: {
52
52
  readonly recordResolutionOutcome?: ResolutionOutcomeRecorder;
53
53
  }): number;
@@ -274,6 +274,34 @@ export function emitReceiverBoundCalls(graph, scopes, parsedFiles, nodeLookup, h
274
274
  if (provider.resolveThisViaEnclosingClass === true && receiverName === 'this') {
275
275
  const enclosingClass = findEnclosingClassDef(site.inScope, scopes);
276
276
  if (enclosingClass !== undefined) {
277
+ const languageResolution = provider.resolveReceiverMember?.(enclosingClass, memberName, site, scopes, model);
278
+ if (languageResolution?.kind === 'ambiguous') {
279
+ options.recordResolutionOutcome?.({
280
+ kind: 'suppressed',
281
+ phase: 'receiver-bound-calls',
282
+ filePath: parsed.filePath,
283
+ name: site.name,
284
+ range: site.atRange,
285
+ reason: 'member-lookup-ambiguous',
286
+ candidateIds: languageResolution.candidateIds,
287
+ });
288
+ handledSites.add(siteKey);
289
+ continue;
290
+ }
291
+ if (languageResolution?.kind === 'resolved') {
292
+ const memberDef = languageResolution.definition;
293
+ const reason = site.kind === 'write' || site.kind === 'read'
294
+ ? site.kind
295
+ : memberDef.filePath !== parsed.filePath
296
+ ? 'import-resolved'
297
+ : 'global';
298
+ const confidence = site.kind === 'write' || site.kind === 'read' ? 1.0 : 0.85;
299
+ const ok = tryEmitEdge(graph, scopes, nodeLookup, site, memberDef, reason, seen, confidence, collapse);
300
+ if (ok)
301
+ emitted++;
302
+ handledSites.add(siteKey);
303
+ continue;
304
+ }
277
305
  const chain = [
278
306
  enclosingClass.nodeId,
279
307
  ...scopes.methodDispatch.mroFor(enclosingClass.nodeId),
@@ -530,6 +558,34 @@ export function emitReceiverBoundCalls(graph, scopes, parsedFiles, nodeLookup, h
530
558
  ownerDef = resolveCompoundReceiverClass(receiverName, site.inScope, scopes, index, compoundOpts);
531
559
  }
532
560
  if (ownerDef !== undefined) {
561
+ const languageResolution = provider.resolveReceiverMember?.(ownerDef, memberName, site, scopes, model);
562
+ if (languageResolution?.kind === 'ambiguous') {
563
+ options.recordResolutionOutcome?.({
564
+ kind: 'suppressed',
565
+ phase: 'receiver-bound-calls',
566
+ filePath: parsed.filePath,
567
+ name: site.name,
568
+ range: site.atRange,
569
+ reason: 'member-lookup-ambiguous',
570
+ candidateIds: languageResolution.candidateIds,
571
+ });
572
+ handledSites.add(siteKey);
573
+ continue;
574
+ }
575
+ if (languageResolution?.kind === 'resolved') {
576
+ const memberDef = languageResolution.definition;
577
+ const reason = site.kind === 'write' || site.kind === 'read'
578
+ ? site.kind
579
+ : memberDef.filePath !== parsed.filePath
580
+ ? 'import-resolved'
581
+ : 'global';
582
+ const confidence = site.kind === 'write' || site.kind === 'read' ? 1.0 : 0.85;
583
+ const ok = tryEmitEdge(graph, scopes, nodeLookup, site, memberDef, reason, seen, confidence, collapse);
584
+ if (ok)
585
+ emitted++;
586
+ handledSites.add(siteKey);
587
+ continue;
588
+ }
533
589
  const chain = [ownerDef.nodeId, ...scopes.methodDispatch.mroFor(ownerDef.nodeId)];
534
590
  let memberDef;
535
591
  let ambiguous = false;
@@ -1,5 +1,5 @@
1
1
  import type { Range } from '../../../_shared/index.js';
2
- export type ResolutionSuppressionReason = 'adl-ordinary-lookup-blocked' | 'conversion-rank-tied' | 'inline-ns-ambiguous' | 'overload-ambiguous' | 'overload-ambiguous-normalization';
2
+ export type ResolutionSuppressionReason = 'adl-ordinary-lookup-blocked' | 'conversion-rank-tied' | 'inline-ns-ambiguous' | 'member-lookup-ambiguous' | 'overload-ambiguous' | 'overload-ambiguous-normalization';
3
3
  export type ResolutionOutcome = {
4
4
  readonly kind: 'resolved';
5
5
  readonly targetId: string;
@@ -39,6 +39,17 @@ const MAX_POOL_SIZE = 5;
39
39
  const IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
40
40
  /** Max connections per repo (caps concurrent queries per repo) */
41
41
  const MAX_CONNS_PER_REPO = 8;
42
+ // Behavior-neutral RSS tracing for the FTS evict→reload memory repro
43
+ // (gitnexus/scripts/bench/fts-evict-reload-rss.mjs). Two invariants keep it safe
44
+ // in the pool init/close hot path: it writes ONLY to stderr (stdout is the MCP
45
+ // JSON-RPC channel), and the GITNEXUS_POOL_RSS_TRACE gate makes it a no-op — one
46
+ // env-var compare per call, nothing else — unless a harness explicitly enables it.
47
+ function traceRss(event, repoId) {
48
+ if (process.env.GITNEXUS_POOL_RSS_TRACE !== '1')
49
+ return;
50
+ const rssMb = Math.round(process.memoryUsage().rss / (1024 * 1024));
51
+ process.stderr.write(`[pool-rss] ${event} repo=${repoId} pool=${pool.size} dbCache=${dbCache.size} rssMB=${rssMb}\n`);
52
+ }
42
53
  let idleTimer = null;
43
54
  // Stdout-capture state lives in `gitnexus/src/mcp/stdio-capture.ts` — a leaf
44
55
  // module with zero non-`node:` imports. We re-export the same symbols here
@@ -166,6 +177,7 @@ function closeOne(repoId) {
166
177
  // Isolate listener failures — teardown must complete.
167
178
  }
168
179
  }
180
+ traceRss('close', repoId);
169
181
  }
170
182
  /**
171
183
  * Create a new Connection from a repo's Database.
@@ -511,6 +523,7 @@ async function doInitLbug(repoId, dbPath) {
511
523
  closed: false,
512
524
  });
513
525
  ensureIdleTimer();
526
+ traceRss('init', repoId);
514
527
  }
515
528
  /**
516
529
  * Initialize a pool entry from a pre-existing Database object.
@@ -565,6 +578,7 @@ export async function initLbugWithDb(repoId, existingDb, dbPath) {
565
578
  closed: false,
566
579
  });
567
580
  ensureIdleTimer();
581
+ traceRss('init', repoId);
568
582
  }
569
583
  /**
570
584
  * Checkout a connection from the pool.
@@ -143,6 +143,18 @@ function logQueryError(context, err) {
143
143
  const msg = err instanceof Error ? err.message : String(err);
144
144
  logger.error({ context, err: msg }, 'GitNexus query failed');
145
145
  }
146
+ /**
147
+ * A "missing table/label/relation" prepare error is benign for the query tool's
148
+ * best-effort enrichment: a repo analyzed without processes or communities simply
149
+ * has no `Process`/`Community` tables, so the `STEP_IN_PROCESS` / `MEMBER_OF`
150
+ * enrichment queries fail to prepare. That is a normal configuration, NOT a
151
+ * degraded result — it must not raise the `partial` flag (which callers would
152
+ * then learn to ignore). Real failures (timeouts, locks, native faults) do.
153
+ */
154
+ function isBenignMissingTableError(err) {
155
+ const msg = err instanceof Error ? err.message : String(err ?? '');
156
+ return /does not exist|no such (table|label|rel)|unknown (table|label)|not (defined|found)/i.test(msg);
157
+ }
146
158
  const isReadOnlyDbError = (err) => {
147
159
  // Walk the `cause` chain (bounded) so a wrapped read-only error (e.g. the
148
160
  // pool adapter's `{ cause }` wrapper) is still detected here — this is the
@@ -925,61 +937,117 @@ export class LocalBackend {
925
937
  timer.start('symbol_lookup');
926
938
  const processMap = new Map();
927
939
  const definitions = []; // standalone symbols not in any process
928
- for (const [_, item] of merged) {
929
- const sym = item.data;
930
- if (!sym.nodeId) {
931
- // File-level results go to definitions
932
- definitions.push({
933
- name: sym.name,
934
- type: sym.type || 'File',
935
- filePath: sym.filePath,
936
- });
937
- continue;
938
- }
939
- // Find processes this symbol participates in
940
- let processRows = [];
940
+ // Batch-fetch process participation, cohesion, and (optionally) content for
941
+ // ALL matched symbols in 2-3 graph queries instead of 2-3 *per symbol*. The
942
+ // previous per-symbol loop issued up to 3N sequential pool round-trips
943
+ // (searchLimit symbols × {STEP_IN_PROCESS, MEMBER_OF, content}); on a warm
944
+ // repo the IPC + query-setup overhead of those round-trips dominated query
945
+ // latency. Collapsing to `WHERE n.id IN $nodeIds` preserves identical output
946
+ // (the aggregation loop below is unchanged) while cutting the round-trips.
947
+ // Array params bind through the pool exactly as bm25Search's
948
+ // `WHERE n.id IN $nodeIds` already does. (Ported from gitnexus-enterprise
949
+ // PR #222 — N+1 → 2-3 batched queries.)
950
+ const nodeIds = merged.map(([, m]) => m.data?.nodeId).filter((id) => !!id);
951
+ const processRowsByNode = new Map();
952
+ const cohesionByNode = new Map();
953
+ const contentByNode = new Map();
954
+ // Set when a batched enrichment query throws a REAL failure (timeout, lock,
955
+ // native fault) — NOT the benign "no Process/Community table" case, which is
956
+ // a normal config (a repo analyzed without processes/communities) and must
957
+ // not raise a `partial` flag callers would learn to ignore. See
958
+ // isBenignMissingTableError + the response build below.
959
+ let enrichmentDegraded = false;
960
+ // Chunk the IN-list like the impact path (CHUNK_SIZE=100) so a large result
961
+ // set never builds an unbounded `IN` parameter. Default batch is
962
+ // processLimit*maxSymbolsPerProcess (≤ one chunk), but chunk for robustness.
963
+ const QUERY_CHUNK_SIZE = 100;
964
+ for (let i = 0; i < nodeIds.length; i += QUERY_CHUNK_SIZE) {
965
+ const ids = nodeIds.slice(i, i + QUERY_CHUNK_SIZE);
966
+ // Processes each symbol participates in. `n.id AS nodeId` is prepended as
967
+ // column 0 so rows from many symbols can be re-associated to their symbol.
941
968
  try {
942
- processRows = await executeParameterized(repo.lbugPath, `
943
- MATCH (n {id: $nodeId})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
944
- RETURN p.id AS pid, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount, r.step AS step
945
- `, { nodeId: sym.nodeId });
969
+ const rows = await executeParameterized(repo.lbugPath, `
970
+ MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
971
+ WHERE n.id IN $nodeIds
972
+ RETURN n.id AS nodeId, p.id AS pid, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount, r.step AS step
973
+ `, { nodeIds: ids });
974
+ for (const row of rows) {
975
+ const nid = row.nodeId ?? row[0];
976
+ let list = processRowsByNode.get(nid);
977
+ if (!list)
978
+ processRowsByNode.set(nid, (list = []));
979
+ list.push(row);
980
+ }
946
981
  }
947
982
  catch (e) {
948
983
  logQueryError('query:process-lookup', e);
984
+ if (!isBenignMissingTableError(e))
985
+ enrichmentDegraded = true;
949
986
  }
950
- // Get cluster membership + cohesion (cohesion used as internal ranking signal)
951
- let cohesion = 0;
952
- let module;
987
+ // Cluster membership + cohesion. Keep the FIRST community row per node to
988
+ // mirror the prior per-symbol `LIMIT 1` (each symbol keeps ITS community,
989
+ // not one community for the whole batch).
953
990
  try {
954
- const cohesionRows = await executeParameterized(repo.lbugPath, `
955
- MATCH (n {id: $nodeId})-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
956
- RETURN c.cohesion AS cohesion, c.heuristicLabel AS module
957
- LIMIT 1
958
- `, { nodeId: sym.nodeId });
959
- if (cohesionRows.length > 0) {
960
- cohesion = (cohesionRows[0].cohesion ?? cohesionRows[0][0]) || 0;
961
- module = cohesionRows[0].module ?? cohesionRows[0][1];
991
+ const rows = await executeParameterized(repo.lbugPath, `
992
+ MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
993
+ WHERE n.id IN $nodeIds
994
+ RETURN n.id AS nodeId, c.cohesion AS cohesion, c.heuristicLabel AS module
995
+ `, { nodeIds: ids });
996
+ for (const row of rows) {
997
+ const nid = row.nodeId ?? row[0];
998
+ if (!cohesionByNode.has(nid)) {
999
+ cohesionByNode.set(nid, {
1000
+ cohesion: (row.cohesion ?? row[1]) || 0,
1001
+ module: row.module ?? row[2],
1002
+ });
1003
+ }
962
1004
  }
963
1005
  }
964
1006
  catch (e) {
965
1007
  logQueryError('query:cluster-info', e);
1008
+ if (!isBenignMissingTableError(e))
1009
+ enrichmentDegraded = true;
966
1010
  }
967
- // Optionally fetch content
968
- let content;
1011
+ // Optionally fetch content for every matched symbol.
969
1012
  if (includeContent) {
970
1013
  try {
971
- const contentRows = await executeParameterized(repo.lbugPath, `
972
- MATCH (n {id: $nodeId})
973
- RETURN n.content AS content
974
- `, { nodeId: sym.nodeId });
975
- if (contentRows.length > 0) {
976
- content = contentRows[0].content ?? contentRows[0][0];
1014
+ const rows = await executeParameterized(repo.lbugPath, `
1015
+ MATCH (n)
1016
+ WHERE n.id IN $nodeIds
1017
+ RETURN n.id AS nodeId, n.content AS content
1018
+ `, { nodeIds: ids });
1019
+ for (const row of rows) {
1020
+ const nid = row.nodeId ?? row[0];
1021
+ contentByNode.set(nid, row.content ?? row[1]);
977
1022
  }
978
1023
  }
979
1024
  catch (e) {
980
1025
  logQueryError('query:content-fetch', e);
1026
+ if (!isBenignMissingTableError(e))
1027
+ enrichmentDegraded = true;
981
1028
  }
982
1029
  }
1030
+ }
1031
+ // Aggregation is unchanged from the per-symbol version — it now reads the
1032
+ // pre-fetched maps instead of issuing a query per symbol. Iterating `merged`
1033
+ // in the same (sorted) order preserves processMap insertion order, the
1034
+ // definitions order, and the item.score association exactly.
1035
+ for (const [_, item] of merged) {
1036
+ const sym = item.data;
1037
+ if (!sym.nodeId) {
1038
+ // File-level results go to definitions
1039
+ definitions.push({
1040
+ name: sym.name,
1041
+ type: sym.type || 'File',
1042
+ filePath: sym.filePath,
1043
+ });
1044
+ continue;
1045
+ }
1046
+ const processRows = processRowsByNode.get(sym.nodeId) ?? [];
1047
+ const coh = cohesionByNode.get(sym.nodeId);
1048
+ const cohesion = coh?.cohesion ?? 0;
1049
+ const module = coh?.module;
1050
+ const content = includeContent ? contentByNode.get(sym.nodeId) : undefined;
983
1051
  const symbolEntry = {
984
1052
  id: sym.nodeId,
985
1053
  name: sym.name,
@@ -997,12 +1065,13 @@ export class LocalBackend {
997
1065
  else {
998
1066
  // Add to each process it belongs to
999
1067
  for (const row of processRows) {
1000
- const pid = row.pid ?? row[0];
1001
- const label = row.label ?? row[1];
1002
- const hLabel = row.heuristicLabel ?? row[2];
1003
- const pType = row.processType ?? row[3];
1004
- const stepCount = row.stepCount ?? row[4];
1005
- const step = row.step ?? row[5];
1068
+ // Positional fallbacks shift +1 because `n.id AS nodeId` is column 0.
1069
+ const pid = row.pid ?? row[1];
1070
+ const label = row.label ?? row[2];
1071
+ const hLabel = row.heuristicLabel ?? row[3];
1072
+ const pType = row.processType ?? row[4];
1073
+ const stepCount = row.stepCount ?? row[5];
1074
+ const step = row.step ?? row[6];
1006
1075
  if (!processMap.has(pid)) {
1007
1076
  processMap.set(pid, {
1008
1077
  id: pid,
@@ -1066,14 +1135,24 @@ export class LocalBackend {
1066
1135
  timer.mark('wall', performance.now() - wallStart);
1067
1136
  const timing = timer.summary();
1068
1137
  logQueryTiming(searchQuery, timing);
1138
+ // Compose a single `warning` from all degraded conditions (FTS-missing
1139
+ // and/or a real enrichment failure) so neither overwrites the other, and
1140
+ // flag `partial` when enrichment was lost. Both are omitted on the clean
1141
+ // path, leaving the success-path response shape byte-identical.
1142
+ const warnings = [];
1143
+ if (!ftsUsed) {
1144
+ warnings.push('FTS indexes missing — keyword search degraded. Run: gitnexus analyze --repair-fts (or gitnexus analyze --force) to rebuild indexes.');
1145
+ }
1146
+ if (enrichmentDegraded) {
1147
+ warnings.push('Symbol enrichment partially failed — some process/cohesion/content data may be missing from these results (see server logs).');
1148
+ }
1069
1149
  return {
1070
1150
  processes,
1071
1151
  process_symbols: dedupedSymbols,
1072
1152
  definitions: definitions.slice(0, 20), // cap standalone definitions
1073
1153
  timing,
1074
- ...(!ftsUsed && {
1075
- warning: 'FTS indexes missing — keyword search degraded. Run: gitnexus analyze --repair-fts (or gitnexus analyze --force) to rebuild indexes.',
1076
- }),
1154
+ ...(warnings.length > 0 && { warning: warnings.join(' ') }),
1155
+ ...(enrichmentDegraded && { partial: true }),
1077
1156
  };
1078
1157
  }
1079
1158
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.7-rc.4",
3
+ "version": "1.6.7-rc.6",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",