gitnexus 1.2.8 → 1.3.0

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 (59) hide show
  1. package/README.md +194 -186
  2. package/dist/cli/ai-context.js +71 -71
  3. package/dist/cli/analyze.js +69 -28
  4. package/dist/cli/index.js +20 -0
  5. package/dist/cli/setup.js +8 -1
  6. package/dist/cli/view.d.ts +13 -0
  7. package/dist/cli/view.js +59 -0
  8. package/dist/core/augmentation/engine.js +20 -20
  9. package/dist/core/embeddings/embedding-pipeline.js +26 -26
  10. package/dist/core/graph/graph.js +5 -0
  11. package/dist/core/graph/html-graph-viewer.d.ts +15 -0
  12. package/dist/core/graph/html-graph-viewer.js +542 -0
  13. package/dist/core/graph/html-graph-viewer.test.d.ts +1 -0
  14. package/dist/core/graph/html-graph-viewer.test.js +67 -0
  15. package/dist/core/graph/types.d.ts +12 -1
  16. package/dist/core/ingestion/call-processor.js +52 -32
  17. package/dist/core/ingestion/cluster-enricher.js +16 -16
  18. package/dist/core/ingestion/community-processor.js +75 -40
  19. package/dist/core/ingestion/filesystem-walker.d.ts +23 -0
  20. package/dist/core/ingestion/filesystem-walker.js +38 -3
  21. package/dist/core/ingestion/import-processor.d.ts +11 -3
  22. package/dist/core/ingestion/import-processor.js +27 -11
  23. package/dist/core/ingestion/parsing-processor.js +2 -4
  24. package/dist/core/ingestion/pipeline.js +142 -135
  25. package/dist/core/ingestion/process-processor.js +12 -11
  26. package/dist/core/ingestion/workers/parse-worker.js +67 -6
  27. package/dist/core/ingestion/workers/worker-pool.d.ts +3 -9
  28. package/dist/core/ingestion/workers/worker-pool.js +39 -18
  29. package/dist/core/kuzu/csv-generator.d.ts +15 -8
  30. package/dist/core/kuzu/csv-generator.js +258 -196
  31. package/dist/core/kuzu/kuzu-adapter.d.ts +1 -4
  32. package/dist/core/kuzu/kuzu-adapter.js +84 -72
  33. package/dist/core/kuzu/schema.d.ts +1 -1
  34. package/dist/core/kuzu/schema.js +266 -256
  35. package/dist/core/search/bm25-index.js +5 -5
  36. package/dist/core/search/hybrid-search.js +3 -3
  37. package/dist/core/wiki/graph-queries.js +52 -52
  38. package/dist/core/wiki/html-viewer.js +192 -192
  39. package/dist/core/wiki/prompts.js +82 -82
  40. package/dist/mcp/core/embedder.js +8 -4
  41. package/dist/mcp/local/local-backend.d.ts +6 -0
  42. package/dist/mcp/local/local-backend.js +224 -117
  43. package/dist/mcp/resources.js +42 -42
  44. package/dist/mcp/server.js +16 -16
  45. package/dist/mcp/tools.js +86 -77
  46. package/dist/server/api.d.ts +4 -2
  47. package/dist/server/api.js +253 -83
  48. package/dist/types/pipeline.d.ts +6 -2
  49. package/dist/types/pipeline.js +6 -4
  50. package/hooks/claude/gitnexus-hook.cjs +135 -135
  51. package/hooks/claude/pre-tool-use.sh +78 -78
  52. package/hooks/claude/session-start.sh +42 -42
  53. package/package.json +82 -82
  54. package/skills/debugging.md +85 -85
  55. package/skills/exploring.md +75 -75
  56. package/skills/impact-analysis.md +94 -94
  57. package/skills/refactoring.md +113 -113
  58. package/vendor/leiden/index.cjs +355 -355
  59. package/vendor/leiden/utils.cjs +392 -392
@@ -4,6 +4,8 @@
4
4
  * Indexes a repository and stores the knowledge graph in .gitnexus/
5
5
  */
6
6
  import path from 'path';
7
+ import { execFileSync } from 'child_process';
8
+ import v8 from 'v8';
7
9
  import cliProgress from 'cli-progress';
8
10
  import { runPipelineFromRepo } from '../core/ingestion/pipeline.js';
9
11
  import { initKuzu, loadGraphToKuzu, getKuzuStats, executeQuery, executeWithReusedStatement, closeKuzu, createFTSIndex, loadCachedEmbeddings } from '../core/kuzu/kuzu-adapter.js';
@@ -14,6 +16,27 @@ import { getCurrentCommit, isGitRepo, getGitRoot } from '../storage/git.js';
14
16
  import { generateAIContextFiles } from './ai-context.js';
15
17
  import fs from 'fs/promises';
16
18
  import { registerClaudeHook } from './claude-hooks.js';
19
+ const HEAP_MB = 8192;
20
+ const HEAP_FLAG = `--max-old-space-size=${HEAP_MB}`;
21
+ /** Re-exec the process with an 8GB heap if we're currently below that. */
22
+ function ensureHeap() {
23
+ const nodeOpts = process.env.NODE_OPTIONS || '';
24
+ if (nodeOpts.includes('--max-old-space-size'))
25
+ return false;
26
+ const v8Heap = v8.getHeapStatistics().heap_size_limit;
27
+ if (v8Heap >= HEAP_MB * 1024 * 1024 * 0.9)
28
+ return false;
29
+ try {
30
+ execFileSync(process.execPath, [HEAP_FLAG, ...process.argv.slice(1)], {
31
+ stdio: 'inherit',
32
+ env: { ...process.env, NODE_OPTIONS: `${nodeOpts} ${HEAP_FLAG}`.trim() },
33
+ });
34
+ }
35
+ catch (e) {
36
+ process.exitCode = e.status ?? 1;
37
+ }
38
+ return true;
39
+ }
17
40
  /** Threshold: auto-skip embeddings for repos with more nodes than this */
18
41
  const EMBEDDING_NODE_LIMIT = 50_000;
19
42
  const PHASE_LABELS = {
@@ -32,6 +55,8 @@ const PHASE_LABELS = {
32
55
  done: 'Done',
33
56
  };
34
57
  export const analyzeCommand = async (inputPath, options) => {
58
+ if (ensureHeap())
59
+ return;
35
60
  console.log('\n GitNexus Analyzer\n');
36
61
  let repoPath;
37
62
  if (inputPath) {
@@ -70,18 +95,47 @@ export const analyzeCommand = async (inputPath, options) => {
70
95
  stopOnComplete: false,
71
96
  }, cliProgress.Presets.shades_grey);
72
97
  bar.start(100, 0, { phase: 'Initializing...' });
98
+ // Graceful SIGINT handling — clean up resources and exit
99
+ let aborted = false;
100
+ const sigintHandler = () => {
101
+ if (aborted)
102
+ process.exit(1); // Second Ctrl-C: force exit
103
+ aborted = true;
104
+ bar.stop();
105
+ console.log('\n Interrupted — cleaning up...');
106
+ closeKuzu().catch(() => { }).finally(() => process.exit(130));
107
+ };
108
+ process.on('SIGINT', sigintHandler);
73
109
  // Route all console output through bar.log() so the bar doesn't stamp itself
74
110
  // multiple times when other code writes to stdout/stderr mid-render.
75
111
  const origLog = console.log.bind(console);
76
112
  const origWarn = console.warn.bind(console);
77
113
  const origError = console.error.bind(console);
78
- const barLog = (...args) => bar.log(args.map(a => (typeof a === 'string' ? a : String(a))).join(' '));
114
+ const barLog = (...args) => {
115
+ // Clear the bar line, print the message, then let the next bar.update redraw
116
+ process.stdout.write('\x1b[2K\r');
117
+ origLog(args.map(a => (typeof a === 'string' ? a : String(a))).join(' '));
118
+ };
79
119
  console.log = barLog;
80
120
  console.warn = barLog;
81
121
  console.error = barLog;
82
- // Show elapsed seconds for phases that run longer than 3s
122
+ // Track elapsed time per phase both updateBar and the interval use the
123
+ // same format so they don't flicker against each other.
83
124
  let lastPhaseLabel = 'Initializing...';
84
125
  let phaseStart = Date.now();
126
+ /** Update bar with phase label + elapsed seconds (shown after 3s). */
127
+ const updateBar = (value, phaseLabel) => {
128
+ if (phaseLabel !== lastPhaseLabel) {
129
+ lastPhaseLabel = phaseLabel;
130
+ phaseStart = Date.now();
131
+ }
132
+ const elapsed = Math.round((Date.now() - phaseStart) / 1000);
133
+ const display = elapsed >= 3 ? `${phaseLabel} (${elapsed}s)` : phaseLabel;
134
+ bar.update(value, { phase: display });
135
+ };
136
+ // Tick elapsed seconds for phases with infrequent progress callbacks
137
+ // (e.g. CSV streaming, FTS indexing). Uses the same display format as
138
+ // updateBar so there's no flickering.
85
139
  const elapsedTimer = setInterval(() => {
86
140
  const elapsed = Math.round((Date.now() - phaseStart) / 1000);
87
141
  if (elapsed >= 3) {
@@ -94,7 +148,7 @@ export const analyzeCommand = async (inputPath, options) => {
94
148
  let cachedEmbeddings = [];
95
149
  if (options?.embeddings && existingMeta && !options?.force) {
96
150
  try {
97
- bar.update(0, { phase: 'Caching embeddings...' });
151
+ updateBar(0, 'Caching embeddings...');
98
152
  await initKuzu(kuzuPath);
99
153
  const cached = await loadCachedEmbeddings();
100
154
  cachedEmbeddingNodeIds = cached.embeddingNodeIds;
@@ -112,16 +166,10 @@ export const analyzeCommand = async (inputPath, options) => {
112
166
  const pipelineResult = await runPipelineFromRepo(repoPath, (progress) => {
113
167
  const phaseLabel = PHASE_LABELS[progress.phase] || progress.phase;
114
168
  const scaled = Math.round(progress.percent * 0.6);
115
- if (phaseLabel !== lastPhaseLabel) {
116
- lastPhaseLabel = phaseLabel;
117
- phaseStart = Date.now();
118
- }
119
- bar.update(scaled, { phase: phaseLabel });
169
+ updateBar(scaled, phaseLabel);
120
170
  });
121
171
  // ── Phase 2: KuzuDB (60–85%) ──────────────────────────────────────
122
- lastPhaseLabel = 'Loading into KuzuDB...';
123
- phaseStart = Date.now();
124
- bar.update(60, { phase: lastPhaseLabel });
172
+ updateBar(60, 'Loading into KuzuDB...');
125
173
  await closeKuzu();
126
174
  const kuzuFiles = [kuzuPath, `${kuzuPath}.wal`, `${kuzuPath}.lock`];
127
175
  for (const f of kuzuFiles) {
@@ -133,17 +181,15 @@ export const analyzeCommand = async (inputPath, options) => {
133
181
  const t0Kuzu = Date.now();
134
182
  await initKuzu(kuzuPath);
135
183
  let kuzuMsgCount = 0;
136
- const kuzuResult = await loadGraphToKuzu(pipelineResult.graph, pipelineResult.fileContents, storagePath, (msg) => {
184
+ const kuzuResult = await loadGraphToKuzu(pipelineResult.graph, pipelineResult.repoPath, storagePath, (msg) => {
137
185
  kuzuMsgCount++;
138
186
  const progress = Math.min(84, 60 + Math.round((kuzuMsgCount / (kuzuMsgCount + 10)) * 24));
139
- bar.update(progress, { phase: msg });
187
+ updateBar(progress, msg);
140
188
  });
141
189
  const kuzuTime = ((Date.now() - t0Kuzu) / 1000).toFixed(1);
142
190
  const kuzuWarnings = kuzuResult.warnings;
143
191
  // ── Phase 3: FTS (85–90%) ─────────────────────────────────────────
144
- lastPhaseLabel = 'Creating search indexes...';
145
- phaseStart = Date.now();
146
- bar.update(85, { phase: lastPhaseLabel });
192
+ updateBar(85, 'Creating search indexes...');
147
193
  const t0Fts = Date.now();
148
194
  try {
149
195
  await createFTSIndex('File', 'file_fts', ['name', 'content']);
@@ -158,7 +204,7 @@ export const analyzeCommand = async (inputPath, options) => {
158
204
  const ftsTime = ((Date.now() - t0Fts) / 1000).toFixed(1);
159
205
  // ── Phase 3.5: Re-insert cached embeddings ────────────────────────
160
206
  if (cachedEmbeddings.length > 0) {
161
- bar.update(88, { phase: `Restoring ${cachedEmbeddings.length} cached embeddings...` });
207
+ updateBar(88, `Restoring ${cachedEmbeddings.length} cached embeddings...`);
162
208
  const EMBED_BATCH = 200;
163
209
  for (let i = 0; i < cachedEmbeddings.length; i += EMBED_BATCH) {
164
210
  const batch = cachedEmbeddings.slice(i, i + EMBED_BATCH);
@@ -183,29 +229,23 @@ export const analyzeCommand = async (inputPath, options) => {
183
229
  }
184
230
  }
185
231
  if (!embeddingSkipped) {
186
- lastPhaseLabel = 'Loading embedding model...';
187
- phaseStart = Date.now();
188
- bar.update(90, { phase: lastPhaseLabel });
232
+ updateBar(90, 'Loading embedding model...');
189
233
  const t0Emb = Date.now();
190
234
  await runEmbeddingPipeline(executeQuery, executeWithReusedStatement, (progress) => {
191
235
  const scaled = 90 + Math.round((progress.percent / 100) * 8);
192
236
  const label = progress.phase === 'loading-model' ? 'Loading embedding model...' : `Embedding ${progress.nodesProcessed || 0}/${progress.totalNodes || '?'}`;
193
- if (label !== lastPhaseLabel) {
194
- lastPhaseLabel = label;
195
- phaseStart = Date.now();
196
- }
197
- bar.update(scaled, { phase: label });
237
+ updateBar(scaled, label);
198
238
  }, {}, cachedEmbeddingNodeIds.size > 0 ? cachedEmbeddingNodeIds : undefined);
199
239
  embeddingTime = ((Date.now() - t0Emb) / 1000).toFixed(1);
200
240
  }
201
241
  // ── Phase 5: Finalize (98–100%) ───────────────────────────────────
202
- bar.update(98, { phase: 'Saving metadata...' });
242
+ updateBar(98, 'Saving metadata...');
203
243
  const meta = {
204
244
  repoPath,
205
245
  lastCommit: currentCommit,
206
246
  indexedAt: new Date().toISOString(),
207
247
  stats: {
208
- files: pipelineResult.fileContents.size,
248
+ files: pipelineResult.totalFileCount,
209
249
  nodes: stats.nodes,
210
250
  edges: stats.edges,
211
251
  communities: pipelineResult.communityResult?.stats.totalCommunities,
@@ -227,7 +267,7 @@ export const analyzeCommand = async (inputPath, options) => {
227
267
  aggregatedClusterCount = Array.from(groups.values()).filter(count => count >= 5).length;
228
268
  }
229
269
  const aiContext = await generateAIContextFiles(repoPath, storagePath, projectName, {
230
- files: pipelineResult.fileContents.size,
270
+ files: pipelineResult.totalFileCount,
231
271
  nodes: stats.nodes,
232
272
  edges: stats.edges,
233
273
  communities: pipelineResult.communityResult?.stats.totalCommunities,
@@ -240,6 +280,7 @@ export const analyzeCommand = async (inputPath, options) => {
240
280
  // Since the process exits immediately after, Node.js reclaims everything.
241
281
  const totalTime = ((Date.now() - t0Global) / 1000).toFixed(1);
242
282
  clearInterval(elapsedTimer);
283
+ process.removeListener('SIGINT', sigintHandler);
243
284
  console.log = origLog;
244
285
  console.warn = origWarn;
245
286
  console.error = origError;
package/dist/cli/index.js CHANGED
@@ -1,4 +1,24 @@
1
1
  #!/usr/bin/env node
2
+ // Raise Node heap limit for large repos (e.g. Linux kernel).
3
+ // Must run before any heavy allocation. If already set by the user, respect it.
4
+ if (!process.env.NODE_OPTIONS?.includes('--max-old-space-size')) {
5
+ const execArgv = process.execArgv.join(' ');
6
+ if (!execArgv.includes('--max-old-space-size')) {
7
+ // Re-spawn with a larger heap (8 GB)
8
+ const { execFileSync } = await import('node:child_process');
9
+ try {
10
+ execFileSync(process.execPath, ['--max-old-space-size=8192', ...process.argv.slice(1)], {
11
+ stdio: 'inherit',
12
+ env: { ...process.env, NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim() },
13
+ });
14
+ process.exit(0);
15
+ }
16
+ catch (e) {
17
+ // If the child exited with an error code, propagate it
18
+ process.exit(e.status ?? 1);
19
+ }
20
+ }
21
+ }
2
22
  import { Command } from 'commander';
3
23
  import { analyzeCommand } from './analyze.js';
4
24
  import { serveCommand } from './serve.js';
package/dist/cli/setup.js CHANGED
@@ -13,9 +13,16 @@ import { getGlobalDir } from '../storage/repo-manager.js';
13
13
  const __filename = fileURLToPath(import.meta.url);
14
14
  const __dirname = path.dirname(__filename);
15
15
  /**
16
- * The MCP server entry for all editors
16
+ * The MCP server entry for all editors.
17
+ * On Windows, npx must be invoked via cmd /c since it's a .cmd script.
17
18
  */
18
19
  function getMcpEntry() {
20
+ if (process.platform === 'win32') {
21
+ return {
22
+ command: 'cmd',
23
+ args: ['/c', 'npx', '-y', 'gitnexus@latest', 'mcp'],
24
+ };
25
+ }
19
26
  return {
20
27
  command: 'npx',
21
28
  args: ['-y', 'gitnexus@latest', 'mcp'],
@@ -0,0 +1,13 @@
1
+ /**
2
+ * View Command
3
+ *
4
+ * Generates a self-contained graph.html from the KuzuDB index and
5
+ * opens it in the default browser.
6
+ *
7
+ * Usage: gitnexus view [path] [--no-open]
8
+ */
9
+ export interface ViewCommandOptions {
10
+ noOpen?: boolean;
11
+ output?: string;
12
+ }
13
+ export declare const viewCommand: (inputPath?: string, options?: ViewCommandOptions) => Promise<void>;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * View Command
3
+ *
4
+ * Generates a self-contained graph.html from the KuzuDB index and
5
+ * opens it in the default browser.
6
+ *
7
+ * Usage: gitnexus view [path] [--no-open]
8
+ */
9
+ import path from 'path';
10
+ import fs from 'fs/promises';
11
+ import { exec } from 'child_process';
12
+ import { findRepo } from '../storage/repo-manager.js';
13
+ import { initKuzu } from '../core/kuzu/kuzu-adapter.js';
14
+ import { buildGraph } from '../server/api.js';
15
+ import { generateHTMLGraphViewer } from '../core/graph/html-graph-viewer.js';
16
+ import { getCurrentCommit } from '../storage/git.js';
17
+ function openInBrowser(filePath) {
18
+ const url = `file://${filePath}`;
19
+ let cmd;
20
+ if (process.platform === 'darwin') {
21
+ cmd = `open "${url}"`;
22
+ }
23
+ else if (process.platform === 'win32') {
24
+ cmd = `start "" "${url}"`;
25
+ }
26
+ else {
27
+ cmd = `xdg-open "${url}"`;
28
+ }
29
+ exec(cmd, (err) => {
30
+ if (err)
31
+ console.error('Failed to open browser:', err.message);
32
+ });
33
+ }
34
+ export const viewCommand = async (inputPath, options) => {
35
+ console.log('⚠ Experimental: gitnexus view is under active development.\n');
36
+ const repoPath = inputPath ? path.resolve(inputPath) : process.cwd();
37
+ const repo = await findRepo(repoPath);
38
+ if (!repo) {
39
+ console.error('No index found. Run: gitnexus analyze');
40
+ process.exit(1);
41
+ }
42
+ const currentCommit = getCurrentCommit(repo.repoPath);
43
+ if (currentCommit !== repo.meta.lastCommit) {
44
+ console.warn('Index is stale — showing last indexed state. Run: gitnexus analyze\n');
45
+ }
46
+ await initKuzu(repo.kuzuPath);
47
+ const { nodes, relationships } = await buildGraph();
48
+ const projectName = path.basename(repo.repoPath);
49
+ const outputPath = options?.output
50
+ ? path.resolve(options.output)
51
+ : path.join(repo.storagePath, 'graph.html');
52
+ const html = generateHTMLGraphViewer(nodes, relationships, projectName);
53
+ await fs.writeFile(outputPath, html, 'utf-8');
54
+ console.log(`Graph written to: ${outputPath}`);
55
+ console.log(`Nodes: ${nodes.length} Edges: ${relationships.length}`);
56
+ if (!options?.noOpen) {
57
+ openInBrowser(outputPath);
58
+ }
59
+ };
@@ -98,11 +98,11 @@ export async function augment(pattern, cwd) {
98
98
  for (const result of bm25Results.slice(0, 5)) {
99
99
  const escaped = result.filePath.replace(/'/g, "''");
100
100
  try {
101
- const symbols = await executeQuery(repoId, `
102
- MATCH (n) WHERE n.filePath = '${escaped}'
103
- AND n.name CONTAINS '${pattern.replace(/'/g, "''").split(/\s+/)[0]}'
104
- RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
105
- LIMIT 3
101
+ const symbols = await executeQuery(repoId, `
102
+ MATCH (n) WHERE n.filePath = '${escaped}'
103
+ AND n.name CONTAINS '${pattern.replace(/'/g, "''").split(/\s+/)[0]}'
104
+ RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
105
+ LIMIT 3
106
106
  `);
107
107
  for (const sym of symbols) {
108
108
  symbolMatches.push({
@@ -130,10 +130,10 @@ export async function augment(pattern, cwd) {
130
130
  // Callers
131
131
  let callers = [];
132
132
  try {
133
- const rows = await executeQuery(repoId, `
134
- MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(n {id: '${escaped}'})
135
- RETURN caller.name AS name
136
- LIMIT 3
133
+ const rows = await executeQuery(repoId, `
134
+ MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(n {id: '${escaped}'})
135
+ RETURN caller.name AS name
136
+ LIMIT 3
137
137
  `);
138
138
  callers = rows.map((r) => r.name || r[0]).filter(Boolean);
139
139
  }
@@ -141,10 +141,10 @@ export async function augment(pattern, cwd) {
141
141
  // Callees
142
142
  let callees = [];
143
143
  try {
144
- const rows = await executeQuery(repoId, `
145
- MATCH (n {id: '${escaped}'})-[:CodeRelation {type: 'CALLS'}]->(callee)
146
- RETURN callee.name AS name
147
- LIMIT 3
144
+ const rows = await executeQuery(repoId, `
145
+ MATCH (n {id: '${escaped}'})-[:CodeRelation {type: 'CALLS'}]->(callee)
146
+ RETURN callee.name AS name
147
+ LIMIT 3
148
148
  `);
149
149
  callees = rows.map((r) => r.name || r[0]).filter(Boolean);
150
150
  }
@@ -152,9 +152,9 @@ export async function augment(pattern, cwd) {
152
152
  // Processes
153
153
  let processes = [];
154
154
  try {
155
- const rows = await executeQuery(repoId, `
156
- MATCH (n {id: '${escaped}'})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
157
- RETURN p.heuristicLabel AS label, r.step AS step, p.stepCount AS stepCount
155
+ const rows = await executeQuery(repoId, `
156
+ MATCH (n {id: '${escaped}'})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
157
+ RETURN p.heuristicLabel AS label, r.step AS step, p.stepCount AS stepCount
158
158
  `);
159
159
  processes = rows.map((r) => {
160
160
  const label = r.label || r[0];
@@ -167,10 +167,10 @@ export async function augment(pattern, cwd) {
167
167
  // Cluster cohesion (internal ranking signal)
168
168
  let cohesion = 0;
169
169
  try {
170
- const rows = await executeQuery(repoId, `
171
- MATCH (n {id: '${escaped}'})-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
172
- RETURN c.cohesion AS cohesion
173
- LIMIT 1
170
+ const rows = await executeQuery(repoId, `
171
+ MATCH (n {id: '${escaped}'})-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
172
+ RETURN c.cohesion AS cohesion
173
+ LIMIT 1
174
174
  `);
175
175
  if (rows.length > 0) {
176
176
  cohesion = (rows[0].cohesion ?? rows[0][0]) || 0;
@@ -24,19 +24,19 @@ const queryEmbeddableNodes = async (executeQuery) => {
24
24
  let query;
25
25
  if (label === 'File') {
26
26
  // File nodes don't have startLine/endLine
27
- query = `
28
- MATCH (n:File)
29
- RETURN n.id AS id, n.name AS name, 'File' AS label,
30
- n.filePath AS filePath, n.content AS content
27
+ query = `
28
+ MATCH (n:File)
29
+ RETURN n.id AS id, n.name AS name, 'File' AS label,
30
+ n.filePath AS filePath, n.content AS content
31
31
  `;
32
32
  }
33
33
  else {
34
34
  // Code elements have startLine/endLine
35
- query = `
36
- MATCH (n:${label})
37
- RETURN n.id AS id, n.name AS name, '${label}' AS label,
38
- n.filePath AS filePath, n.content AS content,
39
- n.startLine AS startLine, n.endLine AS endLine
35
+ query = `
36
+ MATCH (n:${label})
37
+ RETURN n.id AS id, n.name AS name, '${label}' AS label,
38
+ n.filePath AS filePath, n.content AS content,
39
+ n.startLine AS startLine, n.endLine AS endLine
40
40
  `;
41
41
  }
42
42
  const rows = await executeQuery(query);
@@ -77,8 +77,8 @@ const batchInsertEmbeddings = async (executeWithReusedStatement, updates) => {
77
77
  * Now indexes the separate CodeEmbedding table
78
78
  */
79
79
  const createVectorIndex = async (executeQuery) => {
80
- const cypher = `
81
- CALL CREATE_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx', 'embedding', metric := 'cosine')
80
+ const cypher = `
81
+ CALL CREATE_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx', 'embedding', metric := 'cosine')
82
82
  `;
83
83
  try {
84
84
  await executeQuery(cypher);
@@ -240,14 +240,14 @@ export const semanticSearch = async (executeQuery, query, k = 10, maxDistance =
240
240
  const queryVec = embeddingToArray(queryEmbedding);
241
241
  const queryVecStr = `[${queryVec.join(',')}]`;
242
242
  // Query the vector index on CodeEmbedding to get nodeIds and distances
243
- const vectorQuery = `
244
- CALL QUERY_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx',
245
- CAST(${queryVecStr} AS FLOAT[384]), ${k})
246
- YIELD node AS emb, distance
247
- WITH emb, distance
248
- WHERE distance < ${maxDistance}
249
- RETURN emb.nodeId AS nodeId, distance
250
- ORDER BY distance
243
+ const vectorQuery = `
244
+ CALL QUERY_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx',
245
+ CAST(${queryVecStr} AS FLOAT[384]), ${k})
246
+ YIELD node AS emb, distance
247
+ WITH emb, distance
248
+ WHERE distance < ${maxDistance}
249
+ RETURN emb.nodeId AS nodeId, distance
250
+ ORDER BY distance
251
251
  `;
252
252
  const embResults = await executeQuery(vectorQuery);
253
253
  if (embResults.length === 0) {
@@ -266,16 +266,16 @@ export const semanticSearch = async (executeQuery, query, k = 10, maxDistance =
266
266
  try {
267
267
  let nodeQuery;
268
268
  if (label === 'File') {
269
- nodeQuery = `
270
- MATCH (n:File {id: '${nodeId.replace(/'/g, "''")}'})
271
- RETURN n.name AS name, n.filePath AS filePath
269
+ nodeQuery = `
270
+ MATCH (n:File {id: '${nodeId.replace(/'/g, "''")}'})
271
+ RETURN n.name AS name, n.filePath AS filePath
272
272
  `;
273
273
  }
274
274
  else {
275
- nodeQuery = `
276
- MATCH (n:${label} {id: '${nodeId.replace(/'/g, "''")}'})
277
- RETURN n.name AS name, n.filePath AS filePath,
278
- n.startLine AS startLine, n.endLine AS endLine
275
+ nodeQuery = `
276
+ MATCH (n:${label} {id: '${nodeId.replace(/'/g, "''")}'})
277
+ RETURN n.name AS name, n.filePath AS filePath,
278
+ n.startLine AS startLine, n.endLine AS endLine
279
279
  `;
280
280
  }
281
281
  const nodeRows = await executeQuery(nodeQuery);
@@ -46,6 +46,11 @@ export const createKnowledgeGraph = () => {
46
46
  get relationships() {
47
47
  return Array.from(relationshipMap.values());
48
48
  },
49
+ iterNodes: () => nodeMap.values(),
50
+ iterRelationships: () => relationshipMap.values(),
51
+ forEachNode(fn) { nodeMap.forEach(fn); },
52
+ forEachRelationship(fn) { relationshipMap.forEach(fn); },
53
+ getNode: (id) => nodeMap.get(id),
49
54
  // O(1) count getters - avoid creating arrays just for length
50
55
  get nodeCount() {
51
56
  return nodeMap.size;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * HTML Graph Viewer Generator
3
+ *
4
+ * Produces a self-contained graph.html that renders the knowledge graph
5
+ * using Sigma.js v2 + graphology (both from CDN).
6
+ *
7
+ * Critical: node `content` fields are stripped before embedding to prevent
8
+ * </script> injection from source code breaking the HTML parser.
9
+ */
10
+ import { GraphNode, GraphRelationship } from './types.js';
11
+ /**
12
+ * Generate a self-contained HTML file that renders the knowledge graph.
13
+ * Strips large/unsafe fields from nodes before embedding.
14
+ */
15
+ export declare function generateHTMLGraphViewer(nodes: GraphNode[], relationships: GraphRelationship[], projectName: string): string;