gitnexus 1.3.6 → 1.3.7

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.
Files changed (47) hide show
  1. package/dist/cli/ai-context.js +77 -23
  2. package/dist/cli/analyze.js +0 -5
  3. package/dist/cli/eval-server.d.ts +7 -0
  4. package/dist/cli/eval-server.js +16 -7
  5. package/dist/cli/index.js +2 -20
  6. package/dist/cli/mcp.js +2 -0
  7. package/dist/cli/setup.js +6 -1
  8. package/dist/config/supported-languages.d.ts +1 -0
  9. package/dist/config/supported-languages.js +1 -0
  10. package/dist/core/ingestion/call-processor.d.ts +5 -1
  11. package/dist/core/ingestion/call-processor.js +78 -0
  12. package/dist/core/ingestion/framework-detection.d.ts +1 -0
  13. package/dist/core/ingestion/framework-detection.js +49 -2
  14. package/dist/core/ingestion/import-processor.js +90 -39
  15. package/dist/core/ingestion/parsing-processor.d.ts +12 -1
  16. package/dist/core/ingestion/parsing-processor.js +92 -51
  17. package/dist/core/ingestion/pipeline.js +21 -2
  18. package/dist/core/ingestion/process-processor.js +0 -1
  19. package/dist/core/ingestion/tree-sitter-queries.d.ts +1 -0
  20. package/dist/core/ingestion/tree-sitter-queries.js +80 -0
  21. package/dist/core/ingestion/utils.d.ts +5 -0
  22. package/dist/core/ingestion/utils.js +20 -0
  23. package/dist/core/ingestion/workers/parse-worker.d.ts +11 -0
  24. package/dist/core/ingestion/workers/parse-worker.js +473 -51
  25. package/dist/core/kuzu/csv-generator.d.ts +4 -0
  26. package/dist/core/kuzu/csv-generator.js +23 -9
  27. package/dist/core/kuzu/kuzu-adapter.js +9 -3
  28. package/dist/core/tree-sitter/parser-loader.d.ts +1 -0
  29. package/dist/core/tree-sitter/parser-loader.js +3 -0
  30. package/dist/mcp/core/kuzu-adapter.d.ts +4 -3
  31. package/dist/mcp/core/kuzu-adapter.js +79 -16
  32. package/dist/mcp/local/local-backend.d.ts +13 -0
  33. package/dist/mcp/local/local-backend.js +148 -105
  34. package/dist/mcp/server.js +26 -11
  35. package/dist/storage/git.js +4 -1
  36. package/dist/storage/repo-manager.js +16 -2
  37. package/hooks/claude/gitnexus-hook.cjs +28 -8
  38. package/hooks/claude/pre-tool-use.sh +2 -1
  39. package/package.json +11 -3
  40. package/dist/cli/claude-hooks.d.ts +0 -22
  41. package/dist/cli/claude-hooks.js +0 -97
  42. package/dist/cli/view.d.ts +0 -13
  43. package/dist/cli/view.js +0 -59
  44. package/dist/core/graph/html-graph-viewer.d.ts +0 -15
  45. package/dist/core/graph/html-graph-viewer.js +0 -542
  46. package/dist/core/graph/html-graph-viewer.test.d.ts +0 -1
  47. 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 executeQuery(repo.id, `
327
- MATCH (n {id: '${escaped}'})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
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 executeQuery(repo.id, `
337
- MATCH (n {id: '${escaped}'})-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
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 { /* no cluster info */ }
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 executeQuery(repo.id, `
352
- MATCH (n {id: '${escaped}'})
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 { /* skip */ }
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 symbolQuery = `
460
- MATCH (n)
461
- WHERE n.filePath = '${fullPath.replace(/'/g, "''")}'
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: '${escapedId}'}) RETURN n.name AS name, n.filePath AS filePath`
539
- : `MATCH (n:\`${label}\` {id: '${escapedId}'}) RETURN n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine`;
540
- const nodeRows = await executeQuery(repo.id, nodeQuery);
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
- const escaped = uid.replace(/'/g, "''");
714
- symbols = await executeQuery(repo.id, `
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
- const fpEscaped = file_path.replace(/'/g, "''");
726
- whereClause = `WHERE n.name = '${escaped}' AND n.filePath CONTAINS '${fpEscaped}'`;
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 = '${escaped}' OR n.name = '${escaped}'`;
748
+ whereClause = `WHERE n.id = $symName OR n.name = $symName`;
749
+ queryParams = { symName: name };
730
750
  }
731
751
  else {
732
- whereClause = `WHERE n.name = '${escaped}'`;
752
+ whereClause = `WHERE n.name = $symName`;
753
+ queryParams = { symName: name };
733
754
  }
734
- symbols = await executeQuery(repo.id, `
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 = (sym.id || sym[0]).replace(/'/g, "''");
780
+ const symId = sym.id || sym[0];
760
781
  // Categorized incoming refs
761
- const incomingRows = await executeQuery(repo.id, `
762
- MATCH (caller)-[r:CodeRelation]->(n {id: '${symId}'})
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 executeQuery(repo.id, `
769
- MATCH (n {id: '${symId}'})-[r:CodeRelation]->(target)
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 executeQuery(repo.id, `
778
- MATCH (n {id: '${symId}'})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
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 escaped = name.replace(/'/g, "''");
833
- const clusterQuery = `
855
+ const clusters = await executeParameterized(repo.id, `
834
856
  MATCH (c:Community)
835
- WHERE c.label = '${escaped}' OR c.heuristicLabel = '${escaped}'
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 executeQuery(repo.id, `
872
+ const members = await executeParameterized(repo.id, `
852
873
  MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
853
- WHERE c.label = '${escaped}' OR c.heuristicLabel = '${escaped}'
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 executeQuery(repo.id, `
893
+ const processes = await executeParameterized(repo.id, `
873
894
  MATCH (p:Process)
874
- WHERE p.label = '${name.replace(/'/g, "''")}' OR p.heuristicLabel = '${name.replace(/'/g, "''")}'
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 executeQuery(repo.id, `
883
- MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p {id: '${procId}'})
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 escaped = file.replace(/\\/g, '/').replace(/'/g, "''");
965
+ const normalizedFile = file.replace(/\\/g, '/');
945
966
  try {
946
- const symbols = await executeQuery(repo.id, `
947
- MATCH (n) WHERE n.filePath CONTAINS '${escaped}'
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 { /* skip */ }
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 executeQuery(repo.id, `
969
- MATCH (n {id: '${escaped}'})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
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 { /* skip */ }
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(path.join(repo.repoPath, sym.filePath), 'utf-8');
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
- addEdit(sym.filePath, sym.startLine, lines[lineIdx].trim(), lines[lineIdx].replace(oldName, new_name).trim(), 'graph');
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 { /* skip */ }
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(path.join(repo.repoPath, ref.filePath), 'utf-8');
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 { /* skip */ }
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(path.join(repo.repoPath, normalizedFile), 'utf-8');
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 { /* skip */ }
1146
+ catch (e) {
1147
+ logQueryError('rename:text-search-read', e);
1148
+ }
1109
1149
  }
1110
1150
  }
1111
- catch { /* rg not available or no additional matches */ }
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 = path.join(repo.repoPath, change.file_path);
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 { /* skip failed files */ }
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 relationTypes = params.relationTypes && params.relationTypes.length > 0
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 targetQuery = `
1196
+ const targets = await executeParameterized(repo.id, `
1152
1197
  MATCH (n)
1153
- WHERE n.name = '${target.replace(/'/g, "''")}'
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 { /* query failed for this depth level */ }
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 escaped = name.replace(/'/g, "''");
1346
- const clusterQuery = `
1391
+ const clusters = await executeParameterized(repo.id, `
1347
1392
  MATCH (c:Community)
1348
- WHERE c.label = '${escaped}' OR c.heuristicLabel = '${escaped}'
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 executeQuery(repo.id, `
1408
+ const members = await executeParameterized(repo.id, `
1365
1409
  MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
1366
- WHERE c.label = '${escaped}' OR c.heuristicLabel = '${escaped}'
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 escaped = name.replace(/'/g, "''");
1392
- const processes = await executeQuery(repo.id, `
1435
+ const processes = await executeParameterized(repo.id, `
1393
1436
  MATCH (p:Process)
1394
- WHERE p.label = '${escaped}' OR p.heuristicLabel = '${escaped}'
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 executeQuery(repo.id, `
1403
- MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p {id: '${procId}'})
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],
@@ -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: '1.1.9',
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
- // Handle graceful shutdown
241
- process.on('SIGINT', async () => {
242
- await backend.disconnect();
243
- await server.close();
244
- process.exit(0);
245
- });
246
- process.on('SIGTERM', async () => {
247
- await backend.disconnect();
248
- await server.close();
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
  }
@@ -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
- return execSync('git rev-parse --show-toplevel', { cwd: fromPath })
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;