gitnexus 1.6.3-rc.24 → 1.6.3-rc.26

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
+ }
@@ -1,27 +1,71 @@
1
1
  import PHP from 'tree-sitter-php';
2
2
  import { compilePatterns, runCompiledPatterns, unquoteLiteral, } from '../tree-sitter-scanner.js';
3
3
  /**
4
- * PHP HTTP plugin — Laravel `Route::get/post/...` declarations.
4
+ * PHP HTTP plugin.
5
+ *
6
+ * Providers:
7
+ * - Laravel `Route::get/post/...`
8
+ *
9
+ * Consumers (string-literal URLs only):
10
+ * - Laravel HTTP client: `Http::get/post/put/delete/patch($url)`
11
+ * - Guzzle / generic object method: `$client->get/post/...($url)`
12
+ * - `file_get_contents($url)`
5
13
  *
6
14
  * The pipeline already uses `PHP.php_only` for ingesting plain `.php`
7
15
  * files (see `core/tree-sitter/parser-loader.ts`), and we do the same
8
16
  * here so Laravel route files are parsed with the right grammar dialect.
17
+ *
18
+ * Scope notes: consumer patterns match string literals only. URLs built
19
+ * via binary concatenation (`$base . '/path'`), `sprintf`, or config
20
+ * lookup (`config('services.foo.base').'/path'`) are intentionally left
21
+ * for a follow-up — they require constant-folding the surrounding
22
+ * scope to be meaningful.
9
23
  */
10
- const LARAVEL_PATTERNS = compilePatterns({
11
- name: 'php-laravel',
24
+ const LARAVEL_ROUTE_SPEC = {
25
+ meta: {},
26
+ query: `
27
+ (scoped_call_expression
28
+ scope: (name) @scope (#eq? @scope "Route")
29
+ name: (name) @method (#match? @method "^(get|post|put|delete|patch)$")
30
+ arguments: (arguments . (argument (string) @path)))
31
+ `,
32
+ };
33
+ const HTTP_FACADE_SPEC = {
34
+ meta: {},
35
+ query: `
36
+ (scoped_call_expression
37
+ scope: (name) @scope (#eq? @scope "Http")
38
+ name: (name) @method (#match? @method "^(get|post|put|delete|patch)$")
39
+ arguments: (arguments . (argument (string) @path)))
40
+ `,
41
+ };
42
+ const GUZZLE_MEMBER_SPEC = {
43
+ meta: {},
44
+ query: `
45
+ (member_call_expression
46
+ name: (name) @method (#match? @method "^(get|post|put|delete|patch)$")
47
+ arguments: (arguments . (argument (string) @path)))
48
+ `,
49
+ };
50
+ const FILE_GET_CONTENTS_SPEC = {
51
+ meta: {},
52
+ query: `
53
+ (function_call_expression
54
+ function: (name) @fn (#eq? @fn "file_get_contents")
55
+ arguments: (arguments . (argument (string) @path)))
56
+ `,
57
+ };
58
+ const mk = (spec, suffix) => compilePatterns({
59
+ name: `php-${suffix}`,
12
60
  language: PHP.php_only,
13
- patterns: [
14
- {
15
- meta: {},
16
- query: `
17
- (scoped_call_expression
18
- scope: (name) @scope (#eq? @scope "Route")
19
- name: (name) @method (#match? @method "^(get|post|put|delete|patch)$")
20
- arguments: (arguments . (argument (string) @path)))
21
- `,
22
- },
23
- ],
61
+ patterns: [spec],
24
62
  });
63
+ const PHP_PATTERNS = {
64
+ laravelRoute: mk(LARAVEL_ROUTE_SPEC, 'laravel-route'),
65
+ httpFacade: mk(HTTP_FACADE_SPEC, 'http-facade'),
66
+ guzzleMember: mk(GUZZLE_MEMBER_SPEC, 'guzzle-member'),
67
+ fileGetContents: mk(FILE_GET_CONTENTS_SPEC, 'file-get-contents'),
68
+ };
25
69
  /**
26
70
  * Extract the inner text of a PHP `string` node. The tree-sitter-php
27
71
  * grammar wraps single / double-quoted literals differently depending
@@ -30,12 +74,9 @@ const LARAVEL_PATTERNS = compilePatterns({
30
74
  * child nodes.
31
75
  */
32
76
  function phpStringText(node) {
33
- // Most single-quoted strings expose their inner content through the
34
- // full node text (including quotes), which unquoteLiteral strips.
35
77
  const direct = unquoteLiteral(node.text);
36
78
  if (direct !== null && direct !== node.text)
37
79
  return direct;
38
- // Fall back to child string_content / string_value node if present.
39
80
  for (const child of node.children) {
40
81
  if (child.type === 'string_content' || child.type === 'string_value') {
41
82
  return child.text;
@@ -43,12 +84,29 @@ function phpStringText(node) {
43
84
  }
44
85
  return direct;
45
86
  }
87
+ /**
88
+ * HTTP client helpers (`Http::`, Guzzle) are almost always called with
89
+ * a path relative to a configured base URL, or a full URL. File paths
90
+ * are rare. Accept both relative (`/api/...`) and absolute (`http(s)://`).
91
+ */
92
+ function isHttpClientPath(path) {
93
+ return path.startsWith('/') || path.startsWith('http://') || path.startsWith('https://');
94
+ }
95
+ /**
96
+ * `file_get_contents` is used for both HTTP and filesystem reads. Only
97
+ * emit a consumer contract when the URL is an absolute HTTP(S) URL to
98
+ * avoid false positives for local file paths and stream wrappers
99
+ * (`php://input`, `file://`, `data:`, ...).
100
+ */
101
+ function isHttpUrlLiteral(path) {
102
+ return path.startsWith('http://') || path.startsWith('https://');
103
+ }
46
104
  export const PHP_HTTP_PLUGIN = {
47
105
  name: 'php-http',
48
106
  language: PHP.php_only,
49
107
  scan(tree) {
50
108
  const out = [];
51
- for (const match of runCompiledPatterns(LARAVEL_PATTERNS, tree)) {
109
+ for (const match of runCompiledPatterns(PHP_PATTERNS.laravelRoute, tree)) {
52
110
  const methodNode = match.captures.method;
53
111
  const pathNode = match.captures.path;
54
112
  if (!methodNode || !pathNode)
@@ -65,6 +123,56 @@ export const PHP_HTTP_PLUGIN = {
65
123
  confidence: 0.8,
66
124
  });
67
125
  }
126
+ for (const match of runCompiledPatterns(PHP_PATTERNS.httpFacade, tree)) {
127
+ const methodNode = match.captures.method;
128
+ const pathNode = match.captures.path;
129
+ if (!methodNode || !pathNode)
130
+ continue;
131
+ const path = phpStringText(pathNode);
132
+ if (path === null || !isHttpClientPath(path))
133
+ continue;
134
+ out.push({
135
+ role: 'consumer',
136
+ framework: 'laravel-http',
137
+ method: methodNode.text.toUpperCase(),
138
+ path,
139
+ name: null,
140
+ confidence: 0.7,
141
+ });
142
+ }
143
+ for (const match of runCompiledPatterns(PHP_PATTERNS.guzzleMember, tree)) {
144
+ const methodNode = match.captures.method;
145
+ const pathNode = match.captures.path;
146
+ if (!methodNode || !pathNode)
147
+ continue;
148
+ const path = phpStringText(pathNode);
149
+ if (path === null || !isHttpClientPath(path))
150
+ continue;
151
+ out.push({
152
+ role: 'consumer',
153
+ framework: 'guzzle',
154
+ method: methodNode.text.toUpperCase(),
155
+ path,
156
+ name: null,
157
+ confidence: 0.7,
158
+ });
159
+ }
160
+ for (const match of runCompiledPatterns(PHP_PATTERNS.fileGetContents, tree)) {
161
+ const pathNode = match.captures.path;
162
+ if (!pathNode)
163
+ continue;
164
+ const path = phpStringText(pathNode);
165
+ if (path === null || !isHttpUrlLiteral(path))
166
+ continue;
167
+ out.push({
168
+ role: 'consumer',
169
+ framework: 'file-get-contents',
170
+ method: 'GET',
171
+ path,
172
+ name: null,
173
+ confidence: 0.7,
174
+ });
175
+ }
68
176
  return out;
69
177
  },
70
178
  };
@@ -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.24",
3
+ "version": "1.6.3-rc.26",
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",