gitnexus 1.2.7 → 1.2.9
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/README.md +9 -1
- package/dist/cli/analyze.d.ts +1 -1
- package/dist/cli/analyze.js +59 -15
- package/dist/cli/index.js +1 -1
- package/dist/cli/setup.js +8 -1
- package/dist/cli/view.d.ts +13 -0
- package/dist/cli/view.js +59 -0
- package/dist/core/embeddings/embedder.js +1 -0
- package/dist/core/graph/html-graph-viewer.d.ts +15 -0
- package/dist/core/graph/html-graph-viewer.js +542 -0
- package/dist/core/graph/html-graph-viewer.test.d.ts +1 -0
- package/dist/core/graph/html-graph-viewer.test.js +67 -0
- package/dist/core/ingestion/tree-sitter-queries.js +282 -282
- package/dist/mcp/core/embedder.js +8 -4
- package/dist/mcp/local/local-backend.d.ts +6 -0
- package/dist/mcp/local/local-backend.js +113 -6
- package/dist/mcp/tools.js +12 -3
- package/dist/server/api.d.ts +4 -2
- package/dist/server/api.js +253 -83
- package/package.json +1 -1
|
@@ -228,8 +228,10 @@ export class LocalBackend {
|
|
|
228
228
|
switch (method) {
|
|
229
229
|
case 'query':
|
|
230
230
|
return this.query(repo, params);
|
|
231
|
-
case 'cypher':
|
|
232
|
-
|
|
231
|
+
case 'cypher': {
|
|
232
|
+
const raw = await this.cypher(repo, params);
|
|
233
|
+
return this.formatCypherAsMarkdown(raw);
|
|
234
|
+
}
|
|
233
235
|
case 'context':
|
|
234
236
|
return this.context(repo, params);
|
|
235
237
|
case 'impact':
|
|
@@ -326,16 +328,18 @@ export class LocalBackend {
|
|
|
326
328
|
`);
|
|
327
329
|
}
|
|
328
330
|
catch { /* symbol might not be in any process */ }
|
|
329
|
-
// Get cluster cohesion as internal ranking signal
|
|
331
|
+
// Get cluster membership + cohesion (cohesion used as internal ranking signal)
|
|
330
332
|
let cohesion = 0;
|
|
333
|
+
let module;
|
|
331
334
|
try {
|
|
332
335
|
const cohesionRows = await executeQuery(repo.id, `
|
|
333
336
|
MATCH (n {id: '${escaped}'})-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
|
|
334
|
-
RETURN c.cohesion AS cohesion
|
|
337
|
+
RETURN c.cohesion AS cohesion, c.heuristicLabel AS module
|
|
335
338
|
LIMIT 1
|
|
336
339
|
`);
|
|
337
340
|
if (cohesionRows.length > 0) {
|
|
338
341
|
cohesion = (cohesionRows[0].cohesion ?? cohesionRows[0][0]) || 0;
|
|
342
|
+
module = cohesionRows[0].module ?? cohesionRows[0][1];
|
|
339
343
|
}
|
|
340
344
|
}
|
|
341
345
|
catch { /* no cluster info */ }
|
|
@@ -360,6 +364,7 @@ export class LocalBackend {
|
|
|
360
364
|
filePath: sym.filePath,
|
|
361
365
|
startLine: sym.startLine,
|
|
362
366
|
endLine: sym.endLine,
|
|
367
|
+
...(module ? { module } : {}),
|
|
363
368
|
...(includeContent && content ? { content } : {}),
|
|
364
369
|
};
|
|
365
370
|
if (processRows.length === 0) {
|
|
@@ -497,6 +502,10 @@ export class LocalBackend {
|
|
|
497
502
|
*/
|
|
498
503
|
async semanticSearch(repo, query, limit) {
|
|
499
504
|
try {
|
|
505
|
+
// Check if embedding table exists before loading the model (avoids heavy model init when embeddings are off)
|
|
506
|
+
const tableCheck = await executeQuery(repo.id, `MATCH (e:CodeEmbedding) RETURN COUNT(*) AS cnt LIMIT 1`);
|
|
507
|
+
if (!tableCheck.length || (tableCheck[0].cnt ?? tableCheck[0][0]) === 0)
|
|
508
|
+
return [];
|
|
500
509
|
const queryVec = await embedQuery(query);
|
|
501
510
|
const dims = getEmbeddingDims();
|
|
502
511
|
const queryVecStr = `[${queryVec.join(',')}]`;
|
|
@@ -544,11 +553,15 @@ export class LocalBackend {
|
|
|
544
553
|
}
|
|
545
554
|
return results;
|
|
546
555
|
}
|
|
547
|
-
catch
|
|
548
|
-
|
|
556
|
+
catch {
|
|
557
|
+
// Expected when embeddings are disabled — silently fall back to BM25-only
|
|
549
558
|
return [];
|
|
550
559
|
}
|
|
551
560
|
}
|
|
561
|
+
async executeCypher(repoName, query) {
|
|
562
|
+
const repo = await this.resolveRepo(repoName);
|
|
563
|
+
return this.cypher(repo, { query });
|
|
564
|
+
}
|
|
552
565
|
async cypher(repo, params) {
|
|
553
566
|
await this.ensureInitialized(repo.id);
|
|
554
567
|
if (!isKuzuReady(repo.id)) {
|
|
@@ -562,6 +575,34 @@ export class LocalBackend {
|
|
|
562
575
|
return { error: err.message || 'Query failed' };
|
|
563
576
|
}
|
|
564
577
|
}
|
|
578
|
+
/**
|
|
579
|
+
* Format raw Cypher result rows as a markdown table for LLM readability.
|
|
580
|
+
* Falls back to raw result if rows aren't tabular objects.
|
|
581
|
+
*/
|
|
582
|
+
formatCypherAsMarkdown(result) {
|
|
583
|
+
if (!Array.isArray(result) || result.length === 0)
|
|
584
|
+
return result;
|
|
585
|
+
const firstRow = result[0];
|
|
586
|
+
if (typeof firstRow !== 'object' || firstRow === null)
|
|
587
|
+
return result;
|
|
588
|
+
const keys = Object.keys(firstRow);
|
|
589
|
+
if (keys.length === 0)
|
|
590
|
+
return result;
|
|
591
|
+
const header = '| ' + keys.join(' | ') + ' |';
|
|
592
|
+
const separator = '| ' + keys.map(() => '---').join(' | ') + ' |';
|
|
593
|
+
const dataRows = result.map((row) => '| ' + keys.map(k => {
|
|
594
|
+
const v = row[k];
|
|
595
|
+
if (v === null || v === undefined)
|
|
596
|
+
return '';
|
|
597
|
+
if (typeof v === 'object')
|
|
598
|
+
return JSON.stringify(v);
|
|
599
|
+
return String(v);
|
|
600
|
+
}).join(' | ') + ' |');
|
|
601
|
+
return {
|
|
602
|
+
markdown: [header, separator, ...dataRows].join('\n'),
|
|
603
|
+
row_count: result.length,
|
|
604
|
+
};
|
|
605
|
+
}
|
|
565
606
|
/**
|
|
566
607
|
* Aggregate same-named clusters: group by heuristicLabel, sum symbols,
|
|
567
608
|
* weighted-average cohesion, filter out tiny clusters (<5 symbols).
|
|
@@ -1151,6 +1192,64 @@ export class LocalBackend {
|
|
|
1151
1192
|
grouped[item.depth] = [];
|
|
1152
1193
|
grouped[item.depth].push(item);
|
|
1153
1194
|
}
|
|
1195
|
+
// ── Enrichment: affected processes, modules, risk ──────────────
|
|
1196
|
+
const directCount = (grouped[1] || []).length;
|
|
1197
|
+
let affectedProcesses = [];
|
|
1198
|
+
let affectedModules = [];
|
|
1199
|
+
if (impacted.length > 0) {
|
|
1200
|
+
const allIds = impacted.map(i => `'${i.id.replace(/'/g, "''")}'`).join(', ');
|
|
1201
|
+
const d1Ids = (grouped[1] || []).map((i) => `'${i.id.replace(/'/g, "''")}'`).join(', ');
|
|
1202
|
+
// Affected processes: which execution flows are broken and at which step
|
|
1203
|
+
const [processRows, moduleRows, directModuleRows] = await Promise.all([
|
|
1204
|
+
executeQuery(repo.id, `
|
|
1205
|
+
MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
|
|
1206
|
+
WHERE s.id IN [${allIds}]
|
|
1207
|
+
RETURN p.heuristicLabel AS name, COUNT(DISTINCT s.id) AS hits, MIN(r.step) AS minStep, p.stepCount AS stepCount
|
|
1208
|
+
ORDER BY hits DESC
|
|
1209
|
+
LIMIT 20
|
|
1210
|
+
`).catch(() => []),
|
|
1211
|
+
executeQuery(repo.id, `
|
|
1212
|
+
MATCH (s)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
|
|
1213
|
+
WHERE s.id IN [${allIds}]
|
|
1214
|
+
RETURN c.heuristicLabel AS name, COUNT(DISTINCT s.id) AS hits
|
|
1215
|
+
ORDER BY hits DESC
|
|
1216
|
+
LIMIT 20
|
|
1217
|
+
`).catch(() => []),
|
|
1218
|
+
d1Ids ? executeQuery(repo.id, `
|
|
1219
|
+
MATCH (s)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
|
|
1220
|
+
WHERE s.id IN [${d1Ids}]
|
|
1221
|
+
RETURN DISTINCT c.heuristicLabel AS name
|
|
1222
|
+
`).catch(() => []) : Promise.resolve([]),
|
|
1223
|
+
]);
|
|
1224
|
+
affectedProcesses = processRows.map((r) => ({
|
|
1225
|
+
name: r.name || r[0],
|
|
1226
|
+
hits: r.hits || r[1],
|
|
1227
|
+
broken_at_step: r.minStep ?? r[2],
|
|
1228
|
+
step_count: r.stepCount ?? r[3],
|
|
1229
|
+
}));
|
|
1230
|
+
const directModuleSet = new Set(directModuleRows.map((r) => r.name || r[0]));
|
|
1231
|
+
affectedModules = moduleRows.map((r) => {
|
|
1232
|
+
const name = r.name || r[0];
|
|
1233
|
+
return {
|
|
1234
|
+
name,
|
|
1235
|
+
hits: r.hits || r[1],
|
|
1236
|
+
impact: directModuleSet.has(name) ? 'direct' : 'indirect',
|
|
1237
|
+
};
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
// Risk scoring
|
|
1241
|
+
const processCount = affectedProcesses.length;
|
|
1242
|
+
const moduleCount = affectedModules.length;
|
|
1243
|
+
let risk = 'LOW';
|
|
1244
|
+
if (directCount >= 30 || processCount >= 5 || moduleCount >= 5 || impacted.length >= 200) {
|
|
1245
|
+
risk = 'CRITICAL';
|
|
1246
|
+
}
|
|
1247
|
+
else if (directCount >= 15 || processCount >= 3 || moduleCount >= 3 || impacted.length >= 100) {
|
|
1248
|
+
risk = 'HIGH';
|
|
1249
|
+
}
|
|
1250
|
+
else if (directCount >= 5 || impacted.length >= 30) {
|
|
1251
|
+
risk = 'MEDIUM';
|
|
1252
|
+
}
|
|
1154
1253
|
return {
|
|
1155
1254
|
target: {
|
|
1156
1255
|
id: symId,
|
|
@@ -1160,6 +1259,14 @@ export class LocalBackend {
|
|
|
1160
1259
|
},
|
|
1161
1260
|
direction,
|
|
1162
1261
|
impactedCount: impacted.length,
|
|
1262
|
+
risk,
|
|
1263
|
+
summary: {
|
|
1264
|
+
direct: directCount,
|
|
1265
|
+
processes_affected: processCount,
|
|
1266
|
+
modules_affected: moduleCount,
|
|
1267
|
+
},
|
|
1268
|
+
affected_processes: affectedProcesses,
|
|
1269
|
+
affected_modules: affectedModules,
|
|
1163
1270
|
byDepth: grouped,
|
|
1164
1271
|
};
|
|
1165
1272
|
}
|
package/dist/mcp/tools.js
CHANGED
|
@@ -32,7 +32,7 @@ AFTER THIS: Use context() on a specific symbol for 360-degree view (callers, cal
|
|
|
32
32
|
|
|
33
33
|
Returns results grouped by process (execution flow):
|
|
34
34
|
- processes: ranked execution flows with relevance priority
|
|
35
|
-
- process_symbols: all symbols in those flows with file locations
|
|
35
|
+
- process_symbols: all symbols in those flows with file locations and module (functional area)
|
|
36
36
|
- definitions: standalone types/interfaces not in any process
|
|
37
37
|
|
|
38
38
|
Hybrid ranking: BM25 keyword + semantic vector search, ranked by Reciprocal Rank Fusion.`,
|
|
@@ -74,6 +74,8 @@ EXAMPLES:
|
|
|
74
74
|
• Trace a process:
|
|
75
75
|
MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process) WHERE p.heuristicLabel = "UserLogin" RETURN s.name, r.step ORDER BY r.step
|
|
76
76
|
|
|
77
|
+
OUTPUT: Returns { markdown, row_count } — results formatted as a Markdown table for easy reading.
|
|
78
|
+
|
|
77
79
|
TIPS:
|
|
78
80
|
- All relationships use single CodeRelation table — filter with {type: 'CALLS'} etc.
|
|
79
81
|
- Community = auto-detected functional area (Leiden algorithm)
|
|
@@ -155,10 +157,17 @@ Each edit is tagged with confidence:
|
|
|
155
157
|
{
|
|
156
158
|
name: 'impact',
|
|
157
159
|
description: `Analyze the blast radius of changing a code symbol.
|
|
158
|
-
Returns
|
|
160
|
+
Returns affected symbols grouped by depth, plus risk assessment, affected execution flows, and affected modules.
|
|
159
161
|
|
|
160
162
|
WHEN TO USE: Before making code changes — especially refactoring, renaming, or modifying shared code. Shows what would break.
|
|
161
|
-
AFTER THIS: Review d=1 items (WILL BREAK).
|
|
163
|
+
AFTER THIS: Review d=1 items (WILL BREAK). Use context() on high-risk symbols.
|
|
164
|
+
|
|
165
|
+
Output includes:
|
|
166
|
+
- risk: LOW / MEDIUM / HIGH / CRITICAL
|
|
167
|
+
- summary: direct callers, processes affected, modules affected
|
|
168
|
+
- affected_processes: which execution flows break and at which step
|
|
169
|
+
- affected_modules: which functional areas are hit (direct vs indirect)
|
|
170
|
+
- byDepth: all affected symbols grouped by traversal depth
|
|
162
171
|
|
|
163
172
|
Depth groups:
|
|
164
173
|
- d=1: WILL BREAK (direct callers/importers)
|
package/dist/server/api.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* HTTP API Server
|
|
2
|
+
* HTTP API Server (Multi-Repo)
|
|
3
3
|
*
|
|
4
|
-
* REST API for browser-based clients to query
|
|
4
|
+
* REST API for browser-based clients to query indexed repositories.
|
|
5
|
+
* Uses LocalBackend for multi-repo support via the global registry —
|
|
6
|
+
* the same backend the MCP server uses.
|
|
5
7
|
*/
|
|
6
8
|
export declare const createServer: (port: number) => Promise<void>;
|
package/dist/server/api.js
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* HTTP API Server
|
|
2
|
+
* HTTP API Server (Multi-Repo)
|
|
3
3
|
*
|
|
4
|
-
* REST API for browser-based clients to query
|
|
4
|
+
* REST API for browser-based clients to query indexed repositories.
|
|
5
|
+
* Uses LocalBackend for multi-repo support via the global registry —
|
|
6
|
+
* the same backend the MCP server uses.
|
|
5
7
|
*/
|
|
6
8
|
import express from 'express';
|
|
7
9
|
import cors from 'cors';
|
|
8
10
|
import path from 'path';
|
|
9
11
|
import fs from 'fs/promises';
|
|
10
|
-
import {
|
|
11
|
-
import { initKuzu, executeQuery } from '../core/kuzu/kuzu-adapter.js';
|
|
12
|
+
import { LocalBackend } from '../mcp/local/local-backend.js';
|
|
12
13
|
import { NODE_TABLES } from '../core/kuzu/schema.js';
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const buildGraph = async () => {
|
|
14
|
+
/**
|
|
15
|
+
* Build the full knowledge graph for a repo by querying each node table
|
|
16
|
+
* and all relationships via the backend's cypher tool.
|
|
17
|
+
*/
|
|
18
|
+
const buildGraph = async (backend, repoName) => {
|
|
18
19
|
const nodes = [];
|
|
19
20
|
for (const table of NODE_TABLES) {
|
|
20
21
|
try {
|
|
@@ -34,7 +35,9 @@ const buildGraph = async () => {
|
|
|
34
35
|
else {
|
|
35
36
|
query = `MATCH (n:${table}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine, n.content AS content`;
|
|
36
37
|
}
|
|
37
|
-
const
|
|
38
|
+
const result = await backend.executeCypher(repoName, query);
|
|
39
|
+
// cypher returns the rows directly (array), or { error } on failure
|
|
40
|
+
const rows = Array.isArray(result) ? result : [];
|
|
38
41
|
for (const row of rows) {
|
|
39
42
|
nodes.push({
|
|
40
43
|
id: row.id ?? row[0],
|
|
@@ -62,95 +65,262 @@ const buildGraph = async () => {
|
|
|
62
65
|
}
|
|
63
66
|
}
|
|
64
67
|
const relationships = [];
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
68
|
+
try {
|
|
69
|
+
const relResult = await backend.executeCypher(repoName, `MATCH (a)-[r:CodeRelation]->(b) RETURN a.id AS sourceId, b.id AS targetId, r.type AS type, r.confidence AS confidence, r.reason AS reason, r.step AS step`);
|
|
70
|
+
const relRows = Array.isArray(relResult) ? relResult : [];
|
|
71
|
+
for (const row of relRows) {
|
|
72
|
+
relationships.push({
|
|
73
|
+
id: `${row.sourceId}_${row.type}_${row.targetId}`,
|
|
74
|
+
type: row.type,
|
|
75
|
+
sourceId: row.sourceId,
|
|
76
|
+
targetId: row.targetId,
|
|
77
|
+
confidence: row.confidence,
|
|
78
|
+
reason: row.reason,
|
|
79
|
+
step: row.step,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
console.warn('GitNexus: relationship query failed:', err?.message);
|
|
76
85
|
}
|
|
77
86
|
return { nodes, relationships };
|
|
78
87
|
};
|
|
88
|
+
const httpStatus = (err) => {
|
|
89
|
+
const msg = err?.message ?? '';
|
|
90
|
+
if (msg.includes('not found') || msg.includes('No indexed'))
|
|
91
|
+
return 404;
|
|
92
|
+
if (msg.includes('Multiple repositories'))
|
|
93
|
+
return 400;
|
|
94
|
+
return 500;
|
|
95
|
+
};
|
|
79
96
|
export const createServer = async (port) => {
|
|
97
|
+
const backend = new LocalBackend();
|
|
98
|
+
const hasRepos = await backend.init();
|
|
99
|
+
if (!hasRepos) {
|
|
100
|
+
console.warn('GitNexus: No indexed repositories found. The server will start but most endpoints will return errors.');
|
|
101
|
+
console.warn('Run "gitnexus analyze" in a repository to index it first.');
|
|
102
|
+
}
|
|
80
103
|
const app = express();
|
|
81
|
-
app.use(cors(
|
|
104
|
+
app.use(cors({
|
|
105
|
+
origin: (origin, callback) => {
|
|
106
|
+
// Allow requests with no origin (curl, server-to-server), localhost, and the deployed site.
|
|
107
|
+
// The server binds to 127.0.0.1 so only the local machine can reach it — CORS just gates
|
|
108
|
+
// which browser-tab origins may issue the request.
|
|
109
|
+
if (!origin
|
|
110
|
+
|| origin.startsWith('http://localhost:')
|
|
111
|
+
|| origin.startsWith('http://127.0.0.1:')
|
|
112
|
+
|| origin === 'https://gitnexus.vercel.app') {
|
|
113
|
+
callback(null, true);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
callback(new Error('Not allowed by CORS'));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}));
|
|
82
120
|
app.use(express.json({ limit: '10mb' }));
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
121
|
+
// ─── GET /api/repos ─────────────────────────────────────────────
|
|
122
|
+
// List all indexed repositories
|
|
123
|
+
app.get('/api/repos', async (_req, res) => {
|
|
124
|
+
try {
|
|
125
|
+
const repos = await backend.listRepos();
|
|
126
|
+
res.json(repos);
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
res.status(500).json({ error: err.message || 'Failed to list repos' });
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
// ─── GET /api/repo?repo=X ──────────────────────────────────────
|
|
133
|
+
// Get metadata for a specific repo
|
|
134
|
+
app.get('/api/repo', async (req, res) => {
|
|
135
|
+
try {
|
|
136
|
+
const repoName = req.query.repo;
|
|
137
|
+
const repo = await backend.resolveRepo(repoName);
|
|
138
|
+
res.json({
|
|
139
|
+
name: repo.name,
|
|
140
|
+
path: repo.repoPath,
|
|
141
|
+
indexedAt: repo.indexedAt,
|
|
142
|
+
lastCommit: repo.lastCommit,
|
|
143
|
+
stats: repo.stats || {},
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
res.status(httpStatus(err))
|
|
148
|
+
.json({ error: err.message || 'Repository not found' });
|
|
149
|
+
}
|
|
95
150
|
});
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
151
|
+
// ─── GET /api/graph?repo=X ─────────────────────────────────────
|
|
152
|
+
// Full knowledge graph (all nodes + relationships)
|
|
153
|
+
app.get('/api/graph', async (req, res) => {
|
|
154
|
+
try {
|
|
155
|
+
const repoName = req.query.repo;
|
|
156
|
+
// Resolve repo to validate it exists and get the name
|
|
157
|
+
const repo = await backend.resolveRepo(repoName);
|
|
158
|
+
const graph = await buildGraph(backend, repo.name);
|
|
159
|
+
res.json(graph);
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
res.status(httpStatus(err))
|
|
163
|
+
.json({ error: err.message || 'Failed to build graph' });
|
|
164
|
+
}
|
|
106
165
|
});
|
|
107
|
-
//
|
|
166
|
+
// ─── POST /api/query ───────────────────────────────────────────
|
|
167
|
+
// Execute a raw Cypher query.
|
|
168
|
+
// This endpoint is intentionally unrestricted (no query validation) because
|
|
169
|
+
// the server binds to 127.0.0.1 only — it exposes full graph query
|
|
170
|
+
// capabilities to local clients by design.
|
|
108
171
|
app.post('/api/query', async (req, res) => {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
172
|
+
try {
|
|
173
|
+
const repoName = (req.body.repo ?? req.query.repo);
|
|
174
|
+
const cypher = req.body.cypher;
|
|
175
|
+
if (!cypher) {
|
|
176
|
+
res.status(400).json({ error: 'Missing "cypher" in request body' });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const result = await backend.executeCypher(repoName, cypher);
|
|
180
|
+
if (result && !Array.isArray(result) && result.error) {
|
|
181
|
+
res.status(500).json({ error: result.error });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
res.json({ result });
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
res.status(httpStatus(err))
|
|
188
|
+
.json({ error: err.message || 'Query failed' });
|
|
189
|
+
}
|
|
117
190
|
});
|
|
118
|
-
//
|
|
191
|
+
// ─── POST /api/search ──────────────────────────────────────────
|
|
192
|
+
// Process-grouped semantic search
|
|
119
193
|
app.post('/api/search', async (req, res) => {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
194
|
+
try {
|
|
195
|
+
const repoName = (req.body.repo ?? req.query.repo);
|
|
196
|
+
const query = (req.body.query ?? '').trim();
|
|
197
|
+
const limit = req.body.limit;
|
|
198
|
+
if (!query) {
|
|
199
|
+
res.status(400).json({ error: 'Missing "query" in request body' });
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const results = await backend.callTool('query', {
|
|
203
|
+
repo: repoName,
|
|
204
|
+
query,
|
|
205
|
+
limit,
|
|
206
|
+
});
|
|
130
207
|
res.json({ results });
|
|
131
|
-
return;
|
|
132
208
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
209
|
+
catch (err) {
|
|
210
|
+
res.status(httpStatus(err))
|
|
211
|
+
.json({ error: err.message || 'Search failed' });
|
|
212
|
+
}
|
|
136
213
|
});
|
|
137
|
-
//
|
|
214
|
+
// ─── GET /api/file?repo=X&path=Y ──────────────────────────────
|
|
215
|
+
// Read a file from a resolved repo path on disk
|
|
138
216
|
app.get('/api/file', async (req, res) => {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
217
|
+
try {
|
|
218
|
+
const repoName = req.query.repo;
|
|
219
|
+
const filePath = req.query.path;
|
|
220
|
+
if (!filePath) {
|
|
221
|
+
res.status(400).json({ error: 'Missing "path" query parameter' });
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const repo = await backend.resolveRepo(repoName);
|
|
225
|
+
// Resolve the full path and validate it stays within the repo root
|
|
226
|
+
const repoRoot = path.resolve(repo.repoPath);
|
|
227
|
+
const fullPath = path.resolve(repoRoot, filePath);
|
|
228
|
+
if (!fullPath.startsWith(repoRoot + path.sep) && fullPath !== repoRoot) {
|
|
229
|
+
res.status(403).json({ error: 'Path traversal denied: path escapes repo root' });
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const content = await fs.readFile(fullPath, 'utf-8');
|
|
233
|
+
res.json({ content });
|
|
234
|
+
}
|
|
235
|
+
catch (err) {
|
|
236
|
+
if (err.code === 'ENOENT') {
|
|
237
|
+
res.status(404).json({ error: 'File not found' });
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
res.status(httpStatus(err))
|
|
241
|
+
.json({ error: err.message || 'Failed to read file' });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
// ─── GET /api/processes?repo=X ─────────────────────────────────
|
|
246
|
+
// List all processes for a repo
|
|
247
|
+
app.get('/api/processes', async (req, res) => {
|
|
248
|
+
try {
|
|
249
|
+
const repoName = req.query.repo;
|
|
250
|
+
const result = await backend.queryProcesses(repoName);
|
|
251
|
+
res.json(result);
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
res.status(httpStatus(err))
|
|
255
|
+
.json({ error: err.message || 'Failed to query processes' });
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
// ─── GET /api/process?repo=X&name=Y ───────────────────────────
|
|
259
|
+
// Get detailed process info including steps
|
|
260
|
+
app.get('/api/process', async (req, res) => {
|
|
261
|
+
try {
|
|
262
|
+
const repoName = req.query.repo;
|
|
263
|
+
const name = req.query.name;
|
|
264
|
+
if (!name) {
|
|
265
|
+
res.status(400).json({ error: 'Missing "name" query parameter' });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const result = await backend.queryProcessDetail(name, repoName);
|
|
269
|
+
if (result.error) {
|
|
270
|
+
res.status(404).json({ error: result.error });
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
res.json(result);
|
|
274
|
+
}
|
|
275
|
+
catch (err) {
|
|
276
|
+
res.status(httpStatus(err))
|
|
277
|
+
.json({ error: err.message || 'Failed to query process detail' });
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
// ─── GET /api/clusters?repo=X ─────────────────────────────────
|
|
281
|
+
// List all clusters for a repo
|
|
282
|
+
app.get('/api/clusters', async (req, res) => {
|
|
283
|
+
try {
|
|
284
|
+
const repoName = req.query.repo;
|
|
285
|
+
const result = await backend.queryClusters(repoName);
|
|
286
|
+
res.json(result);
|
|
287
|
+
}
|
|
288
|
+
catch (err) {
|
|
289
|
+
res.status(httpStatus(err))
|
|
290
|
+
.json({ error: err.message || 'Failed to query clusters' });
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
// ─── GET /api/cluster?repo=X&name=Y ───────────────────────────
|
|
294
|
+
// Get detailed cluster info including members
|
|
295
|
+
app.get('/api/cluster', async (req, res) => {
|
|
296
|
+
try {
|
|
297
|
+
const repoName = req.query.repo;
|
|
298
|
+
const name = req.query.name;
|
|
299
|
+
if (!name) {
|
|
300
|
+
res.status(400).json({ error: 'Missing "name" query parameter' });
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const result = await backend.queryClusterDetail(name, repoName);
|
|
304
|
+
if (result.error) {
|
|
305
|
+
res.status(404).json({ error: result.error });
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
res.json(result);
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
res.status(httpStatus(err))
|
|
312
|
+
.json({ error: err.message || 'Failed to query cluster detail' });
|
|
313
|
+
}
|
|
152
314
|
});
|
|
153
|
-
app.listen(port, () => {
|
|
315
|
+
const server = app.listen(port, '127.0.0.1', () => {
|
|
154
316
|
console.log(`GitNexus server running on http://localhost:${port}`);
|
|
317
|
+
console.log(`Serving ${hasRepos ? 'all indexed repositories' : 'no repositories (run gitnexus analyze first)'}`);
|
|
155
318
|
});
|
|
319
|
+
const shutdown = async () => {
|
|
320
|
+
server.close();
|
|
321
|
+
await backend.disconnect();
|
|
322
|
+
process.exit(0);
|
|
323
|
+
};
|
|
324
|
+
process.once('SIGINT', shutdown);
|
|
325
|
+
process.once('SIGTERM', shutdown);
|
|
156
326
|
};
|
package/package.json
CHANGED