gitnexus 1.3.6 → 1.3.8
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/ai-context.js +77 -23
- package/dist/cli/analyze.js +4 -11
- package/dist/cli/eval-server.d.ts +7 -0
- package/dist/cli/eval-server.js +16 -7
- package/dist/cli/index.js +2 -20
- package/dist/cli/mcp.js +2 -0
- package/dist/cli/setup.js +6 -1
- package/dist/config/supported-languages.d.ts +1 -0
- package/dist/config/supported-languages.js +1 -0
- package/dist/core/ingestion/call-processor.d.ts +5 -1
- package/dist/core/ingestion/call-processor.js +78 -0
- package/dist/core/ingestion/framework-detection.d.ts +1 -0
- package/dist/core/ingestion/framework-detection.js +49 -2
- package/dist/core/ingestion/import-processor.js +90 -39
- package/dist/core/ingestion/parsing-processor.d.ts +12 -1
- package/dist/core/ingestion/parsing-processor.js +92 -51
- package/dist/core/ingestion/pipeline.js +21 -2
- package/dist/core/ingestion/process-processor.js +0 -1
- package/dist/core/ingestion/tree-sitter-queries.d.ts +1 -0
- package/dist/core/ingestion/tree-sitter-queries.js +80 -0
- package/dist/core/ingestion/utils.d.ts +5 -0
- package/dist/core/ingestion/utils.js +20 -0
- package/dist/core/ingestion/workers/parse-worker.d.ts +11 -0
- package/dist/core/ingestion/workers/parse-worker.js +473 -51
- package/dist/core/kuzu/csv-generator.d.ts +4 -0
- package/dist/core/kuzu/csv-generator.js +23 -9
- package/dist/core/kuzu/kuzu-adapter.js +9 -3
- package/dist/core/tree-sitter/parser-loader.d.ts +1 -0
- package/dist/core/tree-sitter/parser-loader.js +3 -0
- package/dist/mcp/core/kuzu-adapter.d.ts +4 -3
- package/dist/mcp/core/kuzu-adapter.js +79 -16
- package/dist/mcp/local/local-backend.d.ts +13 -0
- package/dist/mcp/local/local-backend.js +148 -105
- package/dist/mcp/server.js +26 -11
- package/dist/storage/git.js +4 -1
- package/dist/storage/repo-manager.js +16 -2
- package/hooks/claude/gitnexus-hook.cjs +28 -8
- package/hooks/claude/pre-tool-use.sh +2 -1
- package/package.json +11 -3
- package/dist/cli/claude-hooks.d.ts +0 -22
- package/dist/cli/claude-hooks.js +0 -97
- package/dist/cli/view.d.ts +0 -13
- package/dist/cli/view.js +0 -59
- package/dist/core/graph/html-graph-viewer.d.ts +0 -15
- package/dist/core/graph/html-graph-viewer.js +0 -542
- package/dist/core/graph/html-graph-viewer.test.d.ts +0 -1
- package/dist/core/graph/html-graph-viewer.test.js +0 -67
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import fs from 'fs/promises';
|
|
9
9
|
import path from 'path';
|
|
10
|
-
import { initKuzu, executeQuery, closeKuzu, isKuzuReady } from '../core/kuzu-adapter.js';
|
|
10
|
+
import { initKuzu, executeQuery, executeParameterized, closeKuzu, isKuzuReady } from '../core/kuzu-adapter.js';
|
|
11
11
|
// Embedding imports are lazy (dynamic import) to avoid loading onnxruntime-node
|
|
12
12
|
// at MCP server startup — crashes on unsupported Node ABI versions (#89)
|
|
13
13
|
// git utilities available if needed
|
|
@@ -19,7 +19,7 @@ import { listRegisteredRepos, } from '../../storage/repo-manager.js';
|
|
|
19
19
|
* Quick test-file detection for filtering impact results.
|
|
20
20
|
* Matches common test file patterns across all supported languages.
|
|
21
21
|
*/
|
|
22
|
-
function isTestFilePath(filePath) {
|
|
22
|
+
export function isTestFilePath(filePath) {
|
|
23
23
|
const p = filePath.toLowerCase().replace(/\\/g, '/');
|
|
24
24
|
return (p.includes('.test.') || p.includes('.spec.') ||
|
|
25
25
|
p.includes('__tests__/') || p.includes('__mocks__/') ||
|
|
@@ -29,12 +29,25 @@ function isTestFilePath(filePath) {
|
|
|
29
29
|
p.includes('/test_') || p.includes('/conftest.'));
|
|
30
30
|
}
|
|
31
31
|
/** Valid KuzuDB node labels for safe Cypher query construction */
|
|
32
|
-
const VALID_NODE_LABELS = new Set([
|
|
32
|
+
export const VALID_NODE_LABELS = new Set([
|
|
33
33
|
'File', 'Folder', 'Function', 'Class', 'Interface', 'Method', 'CodeElement',
|
|
34
34
|
'Community', 'Process', 'Struct', 'Enum', 'Macro', 'Typedef', 'Union',
|
|
35
35
|
'Namespace', 'Trait', 'Impl', 'TypeAlias', 'Const', 'Static', 'Property',
|
|
36
36
|
'Record', 'Delegate', 'Annotation', 'Constructor', 'Template', 'Module',
|
|
37
37
|
]);
|
|
38
|
+
/** Valid relation types for impact analysis filtering */
|
|
39
|
+
export const VALID_RELATION_TYPES = new Set(['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']);
|
|
40
|
+
/** Regex to detect write operations in user-supplied Cypher queries */
|
|
41
|
+
export const CYPHER_WRITE_RE = /\b(CREATE|DELETE|SET|MERGE|REMOVE|DROP|ALTER|COPY|DETACH)\b/i;
|
|
42
|
+
/** Check if a Cypher query contains write operations */
|
|
43
|
+
export function isWriteQuery(query) {
|
|
44
|
+
return CYPHER_WRITE_RE.test(query);
|
|
45
|
+
}
|
|
46
|
+
/** Structured error logging for query failures — replaces empty catch blocks */
|
|
47
|
+
function logQueryError(context, err) {
|
|
48
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
49
|
+
console.error(`GitNexus [${context}]: ${msg}`);
|
|
50
|
+
}
|
|
38
51
|
export class LocalBackend {
|
|
39
52
|
repos = new Map();
|
|
40
53
|
contextCache = new Map();
|
|
@@ -319,44 +332,49 @@ export class LocalBackend {
|
|
|
319
332
|
});
|
|
320
333
|
continue;
|
|
321
334
|
}
|
|
322
|
-
const escaped = sym.nodeId.replace(/'/g, "''");
|
|
323
335
|
// Find processes this symbol participates in
|
|
324
336
|
let processRows = [];
|
|
325
337
|
try {
|
|
326
|
-
processRows = await
|
|
327
|
-
MATCH (n {id:
|
|
338
|
+
processRows = await executeParameterized(repo.id, `
|
|
339
|
+
MATCH (n {id: $nodeId})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
|
|
328
340
|
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
|
|
329
|
-
|
|
341
|
+
`, { nodeId: sym.nodeId });
|
|
342
|
+
}
|
|
343
|
+
catch (e) {
|
|
344
|
+
logQueryError('query:process-lookup', e);
|
|
330
345
|
}
|
|
331
|
-
catch { /* symbol might not be in any process */ }
|
|
332
346
|
// Get cluster membership + cohesion (cohesion used as internal ranking signal)
|
|
333
347
|
let cohesion = 0;
|
|
334
348
|
let module;
|
|
335
349
|
try {
|
|
336
|
-
const cohesionRows = await
|
|
337
|
-
MATCH (n {id:
|
|
350
|
+
const cohesionRows = await executeParameterized(repo.id, `
|
|
351
|
+
MATCH (n {id: $nodeId})-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
|
|
338
352
|
RETURN c.cohesion AS cohesion, c.heuristicLabel AS module
|
|
339
353
|
LIMIT 1
|
|
340
|
-
|
|
354
|
+
`, { nodeId: sym.nodeId });
|
|
341
355
|
if (cohesionRows.length > 0) {
|
|
342
356
|
cohesion = (cohesionRows[0].cohesion ?? cohesionRows[0][0]) || 0;
|
|
343
357
|
module = cohesionRows[0].module ?? cohesionRows[0][1];
|
|
344
358
|
}
|
|
345
359
|
}
|
|
346
|
-
catch {
|
|
360
|
+
catch (e) {
|
|
361
|
+
logQueryError('query:cluster-info', e);
|
|
362
|
+
}
|
|
347
363
|
// Optionally fetch content
|
|
348
364
|
let content;
|
|
349
365
|
if (includeContent) {
|
|
350
366
|
try {
|
|
351
|
-
const contentRows = await
|
|
352
|
-
MATCH (n {id:
|
|
367
|
+
const contentRows = await executeParameterized(repo.id, `
|
|
368
|
+
MATCH (n {id: $nodeId})
|
|
353
369
|
RETURN n.content AS content
|
|
354
|
-
|
|
370
|
+
`, { nodeId: sym.nodeId });
|
|
355
371
|
if (contentRows.length > 0) {
|
|
356
372
|
content = contentRows[0].content ?? contentRows[0][0];
|
|
357
373
|
}
|
|
358
374
|
}
|
|
359
|
-
catch {
|
|
375
|
+
catch (e) {
|
|
376
|
+
logQueryError('query:content-fetch', e);
|
|
377
|
+
}
|
|
360
378
|
}
|
|
361
379
|
const symbolEntry = {
|
|
362
380
|
id: sym.nodeId,
|
|
@@ -456,13 +474,12 @@ export class LocalBackend {
|
|
|
456
474
|
for (const bm25Result of bm25Results) {
|
|
457
475
|
const fullPath = bm25Result.filePath;
|
|
458
476
|
try {
|
|
459
|
-
const
|
|
460
|
-
MATCH (n)
|
|
461
|
-
WHERE n.filePath =
|
|
477
|
+
const symbols = await executeParameterized(repo.id, `
|
|
478
|
+
MATCH (n)
|
|
479
|
+
WHERE n.filePath = $filePath
|
|
462
480
|
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
|
|
463
481
|
LIMIT 3
|
|
464
|
-
|
|
465
|
-
const symbols = await executeQuery(repo.id, symbolQuery);
|
|
482
|
+
`, { filePath: fullPath });
|
|
466
483
|
if (symbols.length > 0) {
|
|
467
484
|
for (const sym of symbols) {
|
|
468
485
|
results.push({
|
|
@@ -533,11 +550,10 @@ export class LocalBackend {
|
|
|
533
550
|
if (!VALID_NODE_LABELS.has(label))
|
|
534
551
|
continue;
|
|
535
552
|
try {
|
|
536
|
-
const escapedId = nodeId.replace(/'/g, "''");
|
|
537
553
|
const nodeQuery = label === 'File'
|
|
538
|
-
? `MATCH (n:File {id:
|
|
539
|
-
: `MATCH (n:\`${label}\` {id:
|
|
540
|
-
const nodeRows = await
|
|
554
|
+
? `MATCH (n:File {id: $nodeId}) RETURN n.name AS name, n.filePath AS filePath`
|
|
555
|
+
: `MATCH (n:\`${label}\` {id: $nodeId}) RETURN n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine`;
|
|
556
|
+
const nodeRows = await executeParameterized(repo.id, nodeQuery, { nodeId });
|
|
541
557
|
if (nodeRows.length > 0) {
|
|
542
558
|
const nodeRow = nodeRows[0];
|
|
543
559
|
results.push({
|
|
@@ -569,6 +585,10 @@ export class LocalBackend {
|
|
|
569
585
|
if (!isKuzuReady(repo.id)) {
|
|
570
586
|
return { error: 'KuzuDB not ready. Index may be corrupted.' };
|
|
571
587
|
}
|
|
588
|
+
// Block write operations (defense-in-depth — DB is already read-only)
|
|
589
|
+
if (CYPHER_WRITE_RE.test(params.query)) {
|
|
590
|
+
return { error: 'Write operations (CREATE, DELETE, SET, MERGE, REMOVE, DROP, ALTER, COPY, DETACH) are not allowed. The knowledge graph is read-only.' };
|
|
591
|
+
}
|
|
572
592
|
try {
|
|
573
593
|
const result = await executeQuery(repo.id, params.query);
|
|
574
594
|
return result;
|
|
@@ -710,32 +730,33 @@ export class LocalBackend {
|
|
|
710
730
|
// Step 1: Find the symbol
|
|
711
731
|
let symbols;
|
|
712
732
|
if (uid) {
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
MATCH (n {id: '${escaped}'})
|
|
733
|
+
symbols = await executeParameterized(repo.id, `
|
|
734
|
+
MATCH (n {id: $uid})
|
|
716
735
|
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${include_content ? ', n.content AS content' : ''}
|
|
717
736
|
LIMIT 1
|
|
718
|
-
|
|
737
|
+
`, { uid });
|
|
719
738
|
}
|
|
720
739
|
else {
|
|
721
|
-
const escaped = name.replace(/'/g, "''");
|
|
722
740
|
const isQualified = name.includes('/') || name.includes(':');
|
|
723
741
|
let whereClause;
|
|
742
|
+
let queryParams;
|
|
724
743
|
if (file_path) {
|
|
725
|
-
|
|
726
|
-
|
|
744
|
+
whereClause = `WHERE n.name = $symName AND n.filePath CONTAINS $filePath`;
|
|
745
|
+
queryParams = { symName: name, filePath: file_path };
|
|
727
746
|
}
|
|
728
747
|
else if (isQualified) {
|
|
729
|
-
whereClause = `WHERE n.id =
|
|
748
|
+
whereClause = `WHERE n.id = $symName OR n.name = $symName`;
|
|
749
|
+
queryParams = { symName: name };
|
|
730
750
|
}
|
|
731
751
|
else {
|
|
732
|
-
whereClause = `WHERE n.name =
|
|
752
|
+
whereClause = `WHERE n.name = $symName`;
|
|
753
|
+
queryParams = { symName: name };
|
|
733
754
|
}
|
|
734
|
-
symbols = await
|
|
755
|
+
symbols = await executeParameterized(repo.id, `
|
|
735
756
|
MATCH (n) ${whereClause}
|
|
736
757
|
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${include_content ? ', n.content AS content' : ''}
|
|
737
758
|
LIMIT 10
|
|
738
|
-
|
|
759
|
+
`, queryParams);
|
|
739
760
|
}
|
|
740
761
|
if (symbols.length === 0) {
|
|
741
762
|
return { error: `Symbol '${name || uid}' not found` };
|
|
@@ -756,30 +777,32 @@ export class LocalBackend {
|
|
|
756
777
|
}
|
|
757
778
|
// Step 3: Build full context
|
|
758
779
|
const sym = symbols[0];
|
|
759
|
-
const symId =
|
|
780
|
+
const symId = sym.id || sym[0];
|
|
760
781
|
// Categorized incoming refs
|
|
761
|
-
const incomingRows = await
|
|
762
|
-
MATCH (caller)-[r:CodeRelation]->(n {id:
|
|
782
|
+
const incomingRows = await executeParameterized(repo.id, `
|
|
783
|
+
MATCH (caller)-[r:CodeRelation]->(n {id: $symId})
|
|
763
784
|
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']
|
|
764
785
|
RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind
|
|
765
786
|
LIMIT 30
|
|
766
|
-
|
|
787
|
+
`, { symId });
|
|
767
788
|
// Categorized outgoing refs
|
|
768
|
-
const outgoingRows = await
|
|
769
|
-
MATCH (n {id:
|
|
789
|
+
const outgoingRows = await executeParameterized(repo.id, `
|
|
790
|
+
MATCH (n {id: $symId})-[r:CodeRelation]->(target)
|
|
770
791
|
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']
|
|
771
792
|
RETURN r.type AS relType, target.id AS uid, target.name AS name, target.filePath AS filePath, labels(target)[0] AS kind
|
|
772
793
|
LIMIT 30
|
|
773
|
-
|
|
794
|
+
`, { symId });
|
|
774
795
|
// Process participation
|
|
775
796
|
let processRows = [];
|
|
776
797
|
try {
|
|
777
|
-
processRows = await
|
|
778
|
-
MATCH (n {id:
|
|
798
|
+
processRows = await executeParameterized(repo.id, `
|
|
799
|
+
MATCH (n {id: $symId})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
|
|
779
800
|
RETURN p.id AS pid, p.heuristicLabel AS label, r.step AS step, p.stepCount AS stepCount
|
|
780
|
-
|
|
801
|
+
`, { symId });
|
|
802
|
+
}
|
|
803
|
+
catch (e) {
|
|
804
|
+
logQueryError('context:process-participation', e);
|
|
781
805
|
}
|
|
782
|
-
catch { /* no process info */ }
|
|
783
806
|
// Helper to categorize refs
|
|
784
807
|
const categorize = (rows) => {
|
|
785
808
|
const cats = {};
|
|
@@ -829,13 +852,11 @@ export class LocalBackend {
|
|
|
829
852
|
return this.context(repo, { name });
|
|
830
853
|
}
|
|
831
854
|
if (type === 'cluster') {
|
|
832
|
-
const
|
|
833
|
-
const clusterQuery = `
|
|
855
|
+
const clusters = await executeParameterized(repo.id, `
|
|
834
856
|
MATCH (c:Community)
|
|
835
|
-
WHERE c.label =
|
|
857
|
+
WHERE c.label = $clusterName OR c.heuristicLabel = $clusterName
|
|
836
858
|
RETURN c.id AS id, c.label AS label, c.heuristicLabel AS heuristicLabel, c.cohesion AS cohesion, c.symbolCount AS symbolCount
|
|
837
|
-
|
|
838
|
-
const clusters = await executeQuery(repo.id, clusterQuery);
|
|
859
|
+
`, { clusterName: name });
|
|
839
860
|
if (clusters.length === 0)
|
|
840
861
|
return { error: `Cluster '${name}' not found` };
|
|
841
862
|
const rawClusters = clusters.map((c) => ({
|
|
@@ -848,12 +869,12 @@ export class LocalBackend {
|
|
|
848
869
|
totalSymbols += s;
|
|
849
870
|
weightedCohesion += (c.cohesion || 0) * s;
|
|
850
871
|
}
|
|
851
|
-
const members = await
|
|
872
|
+
const members = await executeParameterized(repo.id, `
|
|
852
873
|
MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
|
|
853
|
-
WHERE c.label =
|
|
874
|
+
WHERE c.label = $clusterName OR c.heuristicLabel = $clusterName
|
|
854
875
|
RETURN DISTINCT n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
|
|
855
876
|
LIMIT 30
|
|
856
|
-
|
|
877
|
+
`, { clusterName: name });
|
|
857
878
|
return {
|
|
858
879
|
cluster: {
|
|
859
880
|
id: rawClusters[0].id,
|
|
@@ -869,21 +890,21 @@ export class LocalBackend {
|
|
|
869
890
|
};
|
|
870
891
|
}
|
|
871
892
|
if (type === 'process') {
|
|
872
|
-
const processes = await
|
|
893
|
+
const processes = await executeParameterized(repo.id, `
|
|
873
894
|
MATCH (p:Process)
|
|
874
|
-
WHERE p.label =
|
|
895
|
+
WHERE p.label = $processName OR p.heuristicLabel = $processName
|
|
875
896
|
RETURN p.id AS id, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount
|
|
876
897
|
LIMIT 1
|
|
877
|
-
|
|
898
|
+
`, { processName: name });
|
|
878
899
|
if (processes.length === 0)
|
|
879
900
|
return { error: `Process '${name}' not found` };
|
|
880
901
|
const proc = processes[0];
|
|
881
902
|
const procId = proc.id || proc[0];
|
|
882
|
-
const steps = await
|
|
883
|
-
MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p {id:
|
|
903
|
+
const steps = await executeParameterized(repo.id, `
|
|
904
|
+
MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p {id: $procId})
|
|
884
905
|
RETURN n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, r.step AS step
|
|
885
906
|
ORDER BY r.step
|
|
886
|
-
|
|
907
|
+
`, { procId });
|
|
887
908
|
return {
|
|
888
909
|
process: {
|
|
889
910
|
id: procId, label: proc.label || proc[1], heuristicLabel: proc.heuristicLabel || proc[2],
|
|
@@ -941,13 +962,13 @@ export class LocalBackend {
|
|
|
941
962
|
// Map changed files to indexed symbols
|
|
942
963
|
const changedSymbols = [];
|
|
943
964
|
for (const file of changedFiles) {
|
|
944
|
-
const
|
|
965
|
+
const normalizedFile = file.replace(/\\/g, '/');
|
|
945
966
|
try {
|
|
946
|
-
const symbols = await
|
|
947
|
-
MATCH (n) WHERE n.filePath CONTAINS
|
|
967
|
+
const symbols = await executeParameterized(repo.id, `
|
|
968
|
+
MATCH (n) WHERE n.filePath CONTAINS $filePath
|
|
948
969
|
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
|
|
949
970
|
LIMIT 20
|
|
950
|
-
|
|
971
|
+
`, { filePath: normalizedFile });
|
|
951
972
|
for (const sym of symbols) {
|
|
952
973
|
changedSymbols.push({
|
|
953
974
|
id: sym.id || sym[0],
|
|
@@ -958,17 +979,18 @@ export class LocalBackend {
|
|
|
958
979
|
});
|
|
959
980
|
}
|
|
960
981
|
}
|
|
961
|
-
catch {
|
|
982
|
+
catch (e) {
|
|
983
|
+
logQueryError('detect-changes:file-symbols', e);
|
|
984
|
+
}
|
|
962
985
|
}
|
|
963
986
|
// Find affected processes
|
|
964
987
|
const affectedProcesses = new Map();
|
|
965
988
|
for (const sym of changedSymbols) {
|
|
966
|
-
const escaped = sym.id.replace(/'/g, "''");
|
|
967
989
|
try {
|
|
968
|
-
const procs = await
|
|
969
|
-
MATCH (n {id:
|
|
990
|
+
const procs = await executeParameterized(repo.id, `
|
|
991
|
+
MATCH (n {id: $nodeId})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
|
|
970
992
|
RETURN p.id AS pid, p.heuristicLabel AS label, p.processType AS processType, p.stepCount AS stepCount, r.step AS step
|
|
971
|
-
|
|
993
|
+
`, { nodeId: sym.id });
|
|
972
994
|
for (const proc of procs) {
|
|
973
995
|
const pid = proc.pid || proc[0];
|
|
974
996
|
if (!affectedProcesses.has(pid)) {
|
|
@@ -986,7 +1008,9 @@ export class LocalBackend {
|
|
|
986
1008
|
});
|
|
987
1009
|
}
|
|
988
1010
|
}
|
|
989
|
-
catch {
|
|
1011
|
+
catch (e) {
|
|
1012
|
+
logQueryError('detect-changes:process-lookup', e);
|
|
1013
|
+
}
|
|
990
1014
|
}
|
|
991
1015
|
const processCount = affectedProcesses.size;
|
|
992
1016
|
const risk = processCount === 0 ? 'low' : processCount <= 5 ? 'medium' : processCount <= 15 ? 'high' : 'critical';
|
|
@@ -1013,6 +1037,14 @@ export class LocalBackend {
|
|
|
1013
1037
|
if (!params.symbol_name && !params.symbol_uid) {
|
|
1014
1038
|
return { error: 'Either symbol_name or symbol_uid is required.' };
|
|
1015
1039
|
}
|
|
1040
|
+
/** Guard: ensure a file path resolves within the repo root (prevents path traversal) */
|
|
1041
|
+
const assertSafePath = (filePath) => {
|
|
1042
|
+
const full = path.resolve(repo.repoPath, filePath);
|
|
1043
|
+
if (!full.startsWith(repo.repoPath + path.sep) && full !== repo.repoPath) {
|
|
1044
|
+
throw new Error(`Path traversal blocked: ${filePath}`);
|
|
1045
|
+
}
|
|
1046
|
+
return full;
|
|
1047
|
+
};
|
|
1016
1048
|
// Step 1: Find the target symbol (reuse context's lookup)
|
|
1017
1049
|
const lookupResult = await this.context(repo, {
|
|
1018
1050
|
name: params.symbol_name,
|
|
@@ -1041,14 +1073,17 @@ export class LocalBackend {
|
|
|
1041
1073
|
// The definition itself
|
|
1042
1074
|
if (sym.filePath && sym.startLine) {
|
|
1043
1075
|
try {
|
|
1044
|
-
const content = await fs.readFile(
|
|
1076
|
+
const content = await fs.readFile(assertSafePath(sym.filePath), 'utf-8');
|
|
1045
1077
|
const lines = content.split('\n');
|
|
1046
1078
|
const lineIdx = sym.startLine - 1;
|
|
1047
1079
|
if (lineIdx >= 0 && lineIdx < lines.length && lines[lineIdx].includes(oldName)) {
|
|
1048
|
-
|
|
1080
|
+
const defRegex = new RegExp(`\\b${oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g');
|
|
1081
|
+
addEdit(sym.filePath, sym.startLine, lines[lineIdx].trim(), lines[lineIdx].replace(defRegex, new_name).trim(), 'graph');
|
|
1049
1082
|
}
|
|
1050
1083
|
}
|
|
1051
|
-
catch {
|
|
1084
|
+
catch (e) {
|
|
1085
|
+
logQueryError('rename:read-definition', e);
|
|
1086
|
+
}
|
|
1052
1087
|
}
|
|
1053
1088
|
// All incoming refs from graph (callers, importers, etc.)
|
|
1054
1089
|
const allIncoming = [
|
|
@@ -1062,7 +1097,7 @@ export class LocalBackend {
|
|
|
1062
1097
|
if (!ref.filePath)
|
|
1063
1098
|
continue;
|
|
1064
1099
|
try {
|
|
1065
|
-
const content = await fs.readFile(
|
|
1100
|
+
const content = await fs.readFile(assertSafePath(ref.filePath), 'utf-8');
|
|
1066
1101
|
const lines = content.split('\n');
|
|
1067
1102
|
for (let i = 0; i < lines.length; i++) {
|
|
1068
1103
|
if (lines[i].includes(oldName)) {
|
|
@@ -1072,7 +1107,9 @@ export class LocalBackend {
|
|
|
1072
1107
|
}
|
|
1073
1108
|
}
|
|
1074
1109
|
}
|
|
1075
|
-
catch {
|
|
1110
|
+
catch (e) {
|
|
1111
|
+
logQueryError('rename:read-ref', e);
|
|
1112
|
+
}
|
|
1076
1113
|
}
|
|
1077
1114
|
// Step 3: Text search for refs the graph might have missed
|
|
1078
1115
|
let astSearchEdits = 0;
|
|
@@ -1082,7 +1119,7 @@ export class LocalBackend {
|
|
|
1082
1119
|
const { execFileSync } = await import('child_process');
|
|
1083
1120
|
const rgArgs = [
|
|
1084
1121
|
'-l',
|
|
1085
|
-
'--type-add', 'code:*.{ts,tsx,js,jsx,py,go,rs,java}',
|
|
1122
|
+
'--type-add', 'code:*.{ts,tsx,js,jsx,py,go,rs,java,c,h,cpp,cc,cxx,hpp,hxx,hh,cs,php,swift}',
|
|
1086
1123
|
'-t', 'code',
|
|
1087
1124
|
`\\b${oldName}\\b`,
|
|
1088
1125
|
'.',
|
|
@@ -1094,21 +1131,26 @@ export class LocalBackend {
|
|
|
1094
1131
|
if (graphFiles.has(normalizedFile))
|
|
1095
1132
|
continue; // already covered by graph
|
|
1096
1133
|
try {
|
|
1097
|
-
const content = await fs.readFile(
|
|
1134
|
+
const content = await fs.readFile(assertSafePath(normalizedFile), 'utf-8');
|
|
1098
1135
|
const lines = content.split('\n');
|
|
1099
1136
|
const regex = new RegExp(`\\b${oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g');
|
|
1100
1137
|
for (let i = 0; i < lines.length; i++) {
|
|
1138
|
+
regex.lastIndex = 0;
|
|
1101
1139
|
if (regex.test(lines[i])) {
|
|
1140
|
+
regex.lastIndex = 0;
|
|
1102
1141
|
addEdit(normalizedFile, i + 1, lines[i].trim(), lines[i].replace(regex, new_name).trim(), 'text_search');
|
|
1103
1142
|
astSearchEdits++;
|
|
1104
|
-
regex.lastIndex = 0; // reset regex
|
|
1105
1143
|
}
|
|
1106
1144
|
}
|
|
1107
1145
|
}
|
|
1108
|
-
catch {
|
|
1146
|
+
catch (e) {
|
|
1147
|
+
logQueryError('rename:text-search-read', e);
|
|
1148
|
+
}
|
|
1109
1149
|
}
|
|
1110
1150
|
}
|
|
1111
|
-
catch {
|
|
1151
|
+
catch (e) {
|
|
1152
|
+
logQueryError('rename:ripgrep', e);
|
|
1153
|
+
}
|
|
1112
1154
|
// Step 4: Apply or preview
|
|
1113
1155
|
const allChanges = Array.from(changes.values());
|
|
1114
1156
|
const totalEdits = allChanges.reduce((sum, c) => sum + c.edits.length, 0);
|
|
@@ -1116,13 +1158,15 @@ export class LocalBackend {
|
|
|
1116
1158
|
// Apply edits to files
|
|
1117
1159
|
for (const change of allChanges) {
|
|
1118
1160
|
try {
|
|
1119
|
-
const fullPath =
|
|
1161
|
+
const fullPath = assertSafePath(change.file_path);
|
|
1120
1162
|
let content = await fs.readFile(fullPath, 'utf-8');
|
|
1121
1163
|
const regex = new RegExp(`\\b${oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g');
|
|
1122
1164
|
content = content.replace(regex, new_name);
|
|
1123
1165
|
await fs.writeFile(fullPath, content, 'utf-8');
|
|
1124
1166
|
}
|
|
1125
|
-
catch {
|
|
1167
|
+
catch (e) {
|
|
1168
|
+
logQueryError('rename:apply-edit', e);
|
|
1169
|
+
}
|
|
1126
1170
|
}
|
|
1127
1171
|
}
|
|
1128
1172
|
return {
|
|
@@ -1141,20 +1185,20 @@ export class LocalBackend {
|
|
|
1141
1185
|
await this.ensureInitialized(repo.id);
|
|
1142
1186
|
const { target, direction } = params;
|
|
1143
1187
|
const maxDepth = params.maxDepth || 3;
|
|
1144
|
-
const
|
|
1145
|
-
? params.relationTypes
|
|
1188
|
+
const rawRelTypes = params.relationTypes && params.relationTypes.length > 0
|
|
1189
|
+
? params.relationTypes.filter(t => VALID_RELATION_TYPES.has(t))
|
|
1146
1190
|
: ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS'];
|
|
1191
|
+
const relationTypes = rawRelTypes.length > 0 ? rawRelTypes : ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS'];
|
|
1147
1192
|
const includeTests = params.includeTests ?? false;
|
|
1148
1193
|
const minConfidence = params.minConfidence ?? 0;
|
|
1149
1194
|
const relTypeFilter = relationTypes.map(t => `'${t}'`).join(', ');
|
|
1150
1195
|
const confidenceFilter = minConfidence > 0 ? ` AND r.confidence >= ${minConfidence}` : '';
|
|
1151
|
-
const
|
|
1196
|
+
const targets = await executeParameterized(repo.id, `
|
|
1152
1197
|
MATCH (n)
|
|
1153
|
-
WHERE n.name =
|
|
1198
|
+
WHERE n.name = $targetName
|
|
1154
1199
|
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
|
|
1155
1200
|
LIMIT 1
|
|
1156
|
-
|
|
1157
|
-
const targets = await executeQuery(repo.id, targetQuery);
|
|
1201
|
+
`, { targetName: target });
|
|
1158
1202
|
if (targets.length === 0)
|
|
1159
1203
|
return { error: `Target '${target}' not found` };
|
|
1160
1204
|
const sym = targets[0];
|
|
@@ -1191,7 +1235,9 @@ export class LocalBackend {
|
|
|
1191
1235
|
}
|
|
1192
1236
|
}
|
|
1193
1237
|
}
|
|
1194
|
-
catch {
|
|
1238
|
+
catch (e) {
|
|
1239
|
+
logQueryError('impact:depth-traversal', e);
|
|
1240
|
+
}
|
|
1195
1241
|
frontier = nextFrontier;
|
|
1196
1242
|
}
|
|
1197
1243
|
const grouped = {};
|
|
@@ -1342,13 +1388,11 @@ export class LocalBackend {
|
|
|
1342
1388
|
async queryClusterDetail(name, repoName) {
|
|
1343
1389
|
const repo = await this.resolveRepo(repoName);
|
|
1344
1390
|
await this.ensureInitialized(repo.id);
|
|
1345
|
-
const
|
|
1346
|
-
const clusterQuery = `
|
|
1391
|
+
const clusters = await executeParameterized(repo.id, `
|
|
1347
1392
|
MATCH (c:Community)
|
|
1348
|
-
WHERE c.label =
|
|
1393
|
+
WHERE c.label = $clusterName OR c.heuristicLabel = $clusterName
|
|
1349
1394
|
RETURN c.id AS id, c.label AS label, c.heuristicLabel AS heuristicLabel, c.cohesion AS cohesion, c.symbolCount AS symbolCount
|
|
1350
|
-
|
|
1351
|
-
const clusters = await executeQuery(repo.id, clusterQuery);
|
|
1395
|
+
`, { clusterName: name });
|
|
1352
1396
|
if (clusters.length === 0)
|
|
1353
1397
|
return { error: `Cluster '${name}' not found` };
|
|
1354
1398
|
const rawClusters = clusters.map((c) => ({
|
|
@@ -1361,12 +1405,12 @@ export class LocalBackend {
|
|
|
1361
1405
|
totalSymbols += s;
|
|
1362
1406
|
weightedCohesion += (c.cohesion || 0) * s;
|
|
1363
1407
|
}
|
|
1364
|
-
const members = await
|
|
1408
|
+
const members = await executeParameterized(repo.id, `
|
|
1365
1409
|
MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
|
|
1366
|
-
WHERE c.label =
|
|
1410
|
+
WHERE c.label = $clusterName OR c.heuristicLabel = $clusterName
|
|
1367
1411
|
RETURN DISTINCT n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
|
|
1368
1412
|
LIMIT 30
|
|
1369
|
-
|
|
1413
|
+
`, { clusterName: name });
|
|
1370
1414
|
return {
|
|
1371
1415
|
cluster: {
|
|
1372
1416
|
id: rawClusters[0].id,
|
|
@@ -1388,22 +1432,21 @@ export class LocalBackend {
|
|
|
1388
1432
|
async queryProcessDetail(name, repoName) {
|
|
1389
1433
|
const repo = await this.resolveRepo(repoName);
|
|
1390
1434
|
await this.ensureInitialized(repo.id);
|
|
1391
|
-
const
|
|
1392
|
-
const processes = await executeQuery(repo.id, `
|
|
1435
|
+
const processes = await executeParameterized(repo.id, `
|
|
1393
1436
|
MATCH (p:Process)
|
|
1394
|
-
WHERE p.label =
|
|
1437
|
+
WHERE p.label = $processName OR p.heuristicLabel = $processName
|
|
1395
1438
|
RETURN p.id AS id, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount
|
|
1396
1439
|
LIMIT 1
|
|
1397
|
-
|
|
1440
|
+
`, { processName: name });
|
|
1398
1441
|
if (processes.length === 0)
|
|
1399
1442
|
return { error: `Process '${name}' not found` };
|
|
1400
1443
|
const proc = processes[0];
|
|
1401
1444
|
const procId = proc.id || proc[0];
|
|
1402
|
-
const steps = await
|
|
1403
|
-
MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p {id:
|
|
1445
|
+
const steps = await executeParameterized(repo.id, `
|
|
1446
|
+
MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p {id: $procId})
|
|
1404
1447
|
RETURN n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, r.step AS step
|
|
1405
1448
|
ORDER BY r.step
|
|
1406
|
-
|
|
1449
|
+
`, { procId });
|
|
1407
1450
|
return {
|
|
1408
1451
|
process: {
|
|
1409
1452
|
id: procId, label: proc.label || proc[1], heuristicLabel: proc.heuristicLabel || proc[2],
|
package/dist/mcp/server.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* Tools: list_repos, query, cypher, context, impact, detect_changes, rename
|
|
11
11
|
* Resources: repos, repo/{name}/context, repo/{name}/clusters, ...
|
|
12
12
|
*/
|
|
13
|
+
import { createRequire } from 'module';
|
|
13
14
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
14
15
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
15
16
|
import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, ListResourceTemplatesRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
@@ -59,9 +60,11 @@ function getNextStepHint(toolName, args) {
|
|
|
59
60
|
* Transport-agnostic — caller connects the desired transport.
|
|
60
61
|
*/
|
|
61
62
|
export function createMCPServer(backend) {
|
|
63
|
+
const require = createRequire(import.meta.url);
|
|
64
|
+
const pkgVersion = require('../../package.json').version;
|
|
62
65
|
const server = new Server({
|
|
63
66
|
name: 'gitnexus',
|
|
64
|
-
version:
|
|
67
|
+
version: pkgVersion,
|
|
65
68
|
}, {
|
|
66
69
|
capabilities: {
|
|
67
70
|
tools: {},
|
|
@@ -237,15 +240,27 @@ export async function startMCPServer(backend) {
|
|
|
237
240
|
// Connect to stdio transport
|
|
238
241
|
const transport = new StdioServerTransport();
|
|
239
242
|
await server.connect(transport);
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
243
|
+
// Graceful shutdown helper
|
|
244
|
+
let shuttingDown = false;
|
|
245
|
+
const shutdown = async () => {
|
|
246
|
+
if (shuttingDown)
|
|
247
|
+
return;
|
|
248
|
+
shuttingDown = true;
|
|
249
|
+
try {
|
|
250
|
+
await backend.disconnect();
|
|
251
|
+
}
|
|
252
|
+
catch { }
|
|
253
|
+
try {
|
|
254
|
+
await server.close();
|
|
255
|
+
}
|
|
256
|
+
catch { }
|
|
249
257
|
process.exit(0);
|
|
250
|
-
}
|
|
258
|
+
};
|
|
259
|
+
// Handle graceful shutdown
|
|
260
|
+
process.on('SIGINT', shutdown);
|
|
261
|
+
process.on('SIGTERM', shutdown);
|
|
262
|
+
// Handle stdio errors — stdin close means the parent process is gone
|
|
263
|
+
process.stdin.on('end', shutdown);
|
|
264
|
+
process.stdin.on('error', () => shutdown());
|
|
265
|
+
process.stdout.on('error', () => shutdown());
|
|
251
266
|
}
|
package/dist/storage/git.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execSync } from 'child_process';
|
|
2
|
+
import path from 'path';
|
|
2
3
|
// Git utilities for repository detection, commit tracking, and diff analysis
|
|
3
4
|
export const isGitRepo = (repoPath) => {
|
|
4
5
|
try {
|
|
@@ -22,9 +23,11 @@ export const getCurrentCommit = (repoPath) => {
|
|
|
22
23
|
*/
|
|
23
24
|
export const getGitRoot = (fromPath) => {
|
|
24
25
|
try {
|
|
25
|
-
|
|
26
|
+
const raw = execSync('git rev-parse --show-toplevel', { cwd: fromPath })
|
|
26
27
|
.toString()
|
|
27
28
|
.trim();
|
|
29
|
+
// On Windows, git returns /d/Projects/Foo — path.resolve normalizes to D:\Projects\Foo
|
|
30
|
+
return path.resolve(raw);
|
|
28
31
|
}
|
|
29
32
|
catch {
|
|
30
33
|
return null;
|