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 +8 -0
- package/dist/cli/tool.d.ts +5 -0
- package/dist/cli/tool.js +42 -0
- package/dist/core/group/extractors/http-patterns/php.js +126 -18
- package/dist/core/search/bm25-index.d.ts +1 -0
- package/dist/core/search/bm25-index.js +19 -9
- package/dist/mcp/local/local-backend.js +16 -6
- package/package.json +1 -1
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')
|
package/dist/cli/tool.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
11
|
-
|
|
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(
|
|
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
|
};
|
|
@@ -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
|
-
//
|
|
122
|
-
const
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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