gitnexus 1.6.3-rc.23 → 1.6.3-rc.25

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.
package/dist/cli/index.js CHANGED
@@ -111,6 +111,14 @@ program
111
111
  .description('Execute raw Cypher query against the knowledge graph')
112
112
  .option('-r, --repo <name>', 'Target repository')
113
113
  .action(createLazyAction(() => import('./tool.js'), 'cypherCommand'));
114
+ program
115
+ .command('detect-changes')
116
+ .alias('detect_changes')
117
+ .description('Map git diff hunks to indexed symbols and affected execution flows')
118
+ .option('-s, --scope <scope>', 'What to analyze: unstaged, staged, all, or compare', 'unstaged')
119
+ .option('-b, --base-ref <ref>', 'Branch/commit for compare scope (e.g. main)')
120
+ .option('-r, --repo <name>', 'Target repository')
121
+ .action(createLazyAction(() => import('./tool.js'), 'detectChangesCommand'));
114
122
  // ─── Eval Server (persistent daemon for SWE-bench) ─────────────────
115
123
  program
116
124
  .command('eval-server')
@@ -36,3 +36,8 @@ export declare function impactCommand(target: string, options?: {
36
36
  export declare function cypherCommand(query: string, options?: {
37
37
  repo?: string;
38
38
  }): Promise<void>;
39
+ export declare function detectChangesCommand(options?: {
40
+ scope?: string;
41
+ baseRef?: string;
42
+ repo?: string;
43
+ }): Promise<void>;
package/dist/cli/tool.js CHANGED
@@ -124,3 +124,45 @@ export async function cypherCommand(query, options) {
124
124
  });
125
125
  output(result);
126
126
  }
127
+ function formatDetectChangesResult(result) {
128
+ if (result?.error)
129
+ return `Error: ${result.error}`;
130
+ const summary = result?.summary || {};
131
+ if ((summary.changed_count || 0) === 0) {
132
+ return 'No changes detected.';
133
+ }
134
+ const lines = [];
135
+ lines.push(`Changes: ${summary.changed_files || 0} files, ${summary.changed_count || 0} symbols`);
136
+ lines.push(`Affected processes: ${summary.affected_count || 0}`);
137
+ lines.push(`Risk level: ${summary.risk_level || 'unknown'}`);
138
+ lines.push('');
139
+ const changed = result?.changed_symbols || [];
140
+ if (changed.length > 0) {
141
+ lines.push('Changed symbols:');
142
+ for (const symbol of changed.slice(0, 15)) {
143
+ lines.push(` ${symbol.type} ${symbol.name} → ${symbol.filePath}`);
144
+ }
145
+ if (changed.length > 15) {
146
+ lines.push(` ... and ${changed.length - 15} more`);
147
+ }
148
+ lines.push('');
149
+ }
150
+ const affected = result?.affected_processes || [];
151
+ if (affected.length > 0) {
152
+ lines.push('Affected execution flows:');
153
+ for (const processInfo of affected.slice(0, 10)) {
154
+ const steps = (processInfo.changed_steps || []).map((s) => s.symbol).join(', ');
155
+ lines.push(` • ${processInfo.name} (${processInfo.step_count} steps) — changed: ${steps}`);
156
+ }
157
+ }
158
+ return lines.join('\n').trim();
159
+ }
160
+ export async function detectChangesCommand(options) {
161
+ const backend = await getBackend();
162
+ const result = await backend.callTool('detect_changes', {
163
+ scope: options?.scope || 'unstaged',
164
+ base_ref: options?.baseRef,
165
+ repo: options?.repo,
166
+ });
167
+ output(formatDetectChangesResult(result));
168
+ }
@@ -55,17 +55,17 @@ const FUNCTION_LIKE_TYPES = new Set([
55
55
  * numbers don't apply.
56
56
  */
57
57
  export const findFunctionNode = (root) => {
58
- if (FUNCTION_LIKE_TYPES.has(root.type))
59
- return root;
60
- for (let i = 0; i < root.namedChildCount; i++) {
61
- const child = root.namedChild(i);
62
- if (!child)
63
- continue;
64
- if (FUNCTION_LIKE_TYPES.has(child.type))
65
- return child;
66
- const found = findFunctionNode(child);
67
- if (found)
68
- return found;
58
+ // Iterative DFS — avoids stack overflow on deeply nested ASTs.
59
+ const stack = [root];
60
+ while (stack.length > 0) {
61
+ const node = stack.pop();
62
+ if (FUNCTION_LIKE_TYPES.has(node.type))
63
+ return node;
64
+ for (let i = node.namedChildCount - 1; i >= 0; i--) {
65
+ const child = node.namedChild(i);
66
+ if (child)
67
+ stack.push(child);
68
+ }
69
69
  }
70
70
  return null;
71
71
  };
@@ -89,17 +89,17 @@ export const findDeclarationNode = (root) => {
89
89
  'object_declaration', // Kotlin: object
90
90
  'impl_item', // Rust: impl
91
91
  ]);
92
- if (CLASS_LIKE_TYPES.has(root.type))
93
- return root;
94
- for (let i = 0; i < root.namedChildCount; i++) {
95
- const child = root.namedChild(i);
96
- if (!child)
97
- continue;
98
- if (CLASS_LIKE_TYPES.has(child.type))
99
- return child;
100
- const found = findDeclarationNode(child);
101
- if (found)
102
- return found;
92
+ // Iterative DFS — avoids stack overflow on deeply nested ASTs.
93
+ const stack = [root];
94
+ while (stack.length > 0) {
95
+ const node = stack.pop();
96
+ if (CLASS_LIKE_TYPES.has(node.type))
97
+ return node;
98
+ for (let i = node.namedChildCount - 1; i >= 0; i--) {
99
+ const child = node.namedChild(i);
100
+ if (child)
101
+ stack.push(child);
102
+ }
103
103
  }
104
104
  return null;
105
105
  };
@@ -14,6 +14,7 @@ export interface BM25SearchResult {
14
14
  filePath: string;
15
15
  score: number;
16
16
  rank: number;
17
+ nodeIds?: string[];
17
18
  }
18
19
  /**
19
20
  * Search using LadybugDB's built-in FTS (always fresh, reads from disk)
@@ -69,6 +69,7 @@ async function queryFTSViaExecutor(executor, tableName, indexName, query, limit)
69
69
  return {
70
70
  filePath: node.filePath || '',
71
71
  score: typeof score === 'number' ? score : parseFloat(score) || 0,
72
+ nodeId: node.nodeId || node.id || '',
72
73
  };
73
74
  });
74
75
  }
@@ -118,17 +119,13 @@ export const searchFTSFromLbug = async (query, limit = 20, repoId) => {
118
119
  methodResults = await queryFTS('Method', 'method_fts', query, limit, false).catch(() => []);
119
120
  interfaceResults = await queryFTS('Interface', 'interface_fts', query, limit, false).catch(() => []);
120
121
  }
121
- // Merge results by filePath, summing scores for same file
122
- const merged = new Map();
122
+ // Collect all node scores per filePath to track which nodes actually matched
123
+ const fileNodeScores = new Map();
123
124
  const addResults = (results) => {
124
125
  for (const r of results) {
125
- const existing = merged.get(r.filePath);
126
- if (existing) {
127
- existing.score += r.score;
128
- }
129
- else {
130
- merged.set(r.filePath, { filePath: r.filePath, score: r.score });
131
- }
126
+ if (!fileNodeScores.has(r.filePath))
127
+ fileNodeScores.set(r.filePath, []);
128
+ fileNodeScores.get(r.filePath).push({ score: r.score, nodeId: r.nodeId });
132
129
  }
133
130
  };
134
131
  addResults(fileResults);
@@ -136,6 +133,18 @@ export const searchFTSFromLbug = async (query, limit = 20, repoId) => {
136
133
  addResults(classResults);
137
134
  addResults(methodResults);
138
135
  addResults(interfaceResults);
136
+ // Sum the top-3 highest-scoring nodes per file and collect their nodeIds.
137
+ // Summing all nodes naively inflates scores for files with many mediocre
138
+ // matches (e.g. test files) over files with a single highly-relevant symbol.
139
+ const merged = new Map();
140
+ for (const [filePath, entries] of fileNodeScores) {
141
+ const top3 = [...entries].sort((a, b) => b.score - a.score).slice(0, 3);
142
+ merged.set(filePath, {
143
+ filePath,
144
+ score: top3.reduce((acc, e) => acc + e.score, 0),
145
+ nodeIds: top3.map((e) => e.nodeId).filter((id) => id),
146
+ });
147
+ }
139
148
  // Sort by score descending and add rank
140
149
  const sorted = Array.from(merged.values())
141
150
  .sort((a, b) => b.score - a.score)
@@ -144,5 +153,6 @@ export const searchFTSFromLbug = async (query, limit = 20, repoId) => {
144
153
  filePath: r.filePath,
145
154
  score: r.score,
146
155
  rank: index + 1,
156
+ nodeIds: r.nodeIds,
147
157
  }));
148
158
  };
@@ -707,12 +707,22 @@ export class LocalBackend {
707
707
  for (const bm25Result of bm25Results) {
708
708
  const fullPath = bm25Result.filePath;
709
709
  try {
710
- const symbols = await executeParameterized(repo.id, `
711
- MATCH (n)
712
- WHERE n.filePath = $filePath
713
- RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine
714
- LIMIT 3
715
- `, { filePath: fullPath });
710
+ // Prefer direct nodeId lookup (exact FTS-matched nodes) over filePath fallback.
711
+ // Without this, LIMIT 3 on filePath returns arbitrary symbols rather than
712
+ // the nodes that actually scored highest in the BM25 index.
713
+ const nodeIds = bm25Result.nodeIds?.length ? bm25Result.nodeIds : null;
714
+ const symbols = nodeIds
715
+ ? await executeParameterized(repo.id, `
716
+ MATCH (n)
717
+ WHERE n.id IN $nodeIds
718
+ RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine
719
+ `, { nodeIds })
720
+ : await executeParameterized(repo.id, `
721
+ MATCH (n)
722
+ WHERE n.filePath = $filePath
723
+ RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine
724
+ LIMIT 3
725
+ `, { filePath: fullPath });
716
726
  if (symbols.length > 0) {
717
727
  for (const sym of symbols) {
718
728
  results.push({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.3-rc.23",
3
+ "version": "1.6.3-rc.25",
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",