gitnexus 1.3.10 → 1.3.11

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.
@@ -96,6 +96,24 @@ Before completing any code modification task, verify:
96
96
  3. \`gitnexus_detect_changes()\` confirms changes match expected scope
97
97
  4. All d=1 (WILL BREAK) dependents were updated
98
98
 
99
+ ## Keeping the Index Fresh
100
+
101
+ After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
102
+
103
+ \`\`\`bash
104
+ npx gitnexus analyze
105
+ \`\`\`
106
+
107
+ If the index previously included embeddings, preserve them by adding \`--embeddings\`:
108
+
109
+ \`\`\`bash
110
+ npx gitnexus analyze --embeddings
111
+ \`\`\`
112
+
113
+ To check whether embeddings exist, inspect \`.gitnexus/meta.json\` — the \`stats.embeddings\` field shows the count (0 means no embeddings). **Running analyze without \`--embeddings\` will delete any previously generated embeddings.**
114
+
115
+ > Claude Code users: A PostToolUse hook handles this automatically after \`git commit\` and \`git merge\`.
116
+
99
117
  ## CLI
100
118
 
101
119
  - Re-index: \`npx gitnexus analyze\`
@@ -242,6 +242,13 @@ export const analyzeCommand = async (inputPath, options) => {
242
242
  }
243
243
  // ── Phase 5: Finalize (98–100%) ───────────────────────────────────
244
244
  updateBar(98, 'Saving metadata...');
245
+ // Count embeddings in the index (cached + newly generated)
246
+ let embeddingCount = 0;
247
+ try {
248
+ const embResult = await executeQuery(`MATCH (e:CodeEmbedding) RETURN count(e) AS cnt`);
249
+ embeddingCount = embResult?.[0]?.cnt ?? 0;
250
+ }
251
+ catch { /* table may not exist if embeddings never ran */ }
245
252
  const meta = {
246
253
  repoPath,
247
254
  lastCommit: currentCommit,
@@ -252,6 +259,7 @@ export const analyzeCommand = async (inputPath, options) => {
252
259
  edges: stats.edges,
253
260
  communities: pipelineResult.communityResult?.stats.totalCommunities,
254
261
  processes: pipelineResult.processResult?.stats.totalProcesses,
262
+ embeddings: embeddingCount,
255
263
  },
256
264
  };
257
265
  await saveMeta(storagePath, meta);
package/dist/cli/setup.js CHANGED
@@ -147,36 +147,34 @@ async function installClaudeCodeHooks(result) {
147
147
  // even when it's no longer inside the npm package tree
148
148
  const resolvedCli = path.join(__dirname, '..', 'cli', 'index.js');
149
149
  const normalizedCli = path.resolve(resolvedCli).replace(/\\/g, '/');
150
- content = content.replace("let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');", `let cliPath = '${normalizedCli}';`);
150
+ const jsonCli = JSON.stringify(normalizedCli);
151
+ content = content.replace("let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');", `let cliPath = ${jsonCli};`);
151
152
  await fs.writeFile(dest, content, 'utf-8');
152
153
  }
153
154
  catch {
154
155
  // Script not found in source — skip
155
156
  }
156
- const hookCmd = `node "${path.join(destHooksDir, 'gitnexus-hook.cjs').replace(/\\/g, '/')}"`;
157
+ const hookPath = path.join(destHooksDir, 'gitnexus-hook.cjs').replace(/\\/g, '/');
158
+ const hookCmd = `node "${hookPath.replace(/"/g, '\\"')}"`;
157
159
  // Merge hook config into ~/.claude/settings.json
158
160
  const existing = await readJsonFile(settingsPath) || {};
159
161
  if (!existing.hooks)
160
162
  existing.hooks = {};
161
- // NOTE: SessionStart hooks are broken on Windows (Claude Code bug #23576).
162
- // Session context is delivered via CLAUDE.md / skills instead.
163
- // Add PreToolUse hook if not already present
164
- if (!existing.hooks.PreToolUse)
165
- existing.hooks.PreToolUse = [];
166
- const hasPreToolHook = existing.hooks.PreToolUse.some((h) => h.hooks?.some((hh) => hh.command?.includes('gitnexus')));
167
- if (!hasPreToolHook) {
168
- existing.hooks.PreToolUse.push({
169
- matcher: 'Grep|Glob|Bash',
170
- hooks: [{
171
- type: 'command',
172
- command: hookCmd,
173
- timeout: 8000,
174
- statusMessage: 'Enriching with GitNexus graph context...',
175
- }],
176
- });
163
+ function ensureHookEntry(eventName, matcher, timeout, statusMessage) {
164
+ if (!existing.hooks[eventName])
165
+ existing.hooks[eventName] = [];
166
+ const hasHook = existing.hooks[eventName].some((h) => h.hooks?.some(hh => hh.command?.includes('gitnexus-hook')));
167
+ if (!hasHook) {
168
+ existing.hooks[eventName].push({
169
+ matcher,
170
+ hooks: [{ type: 'command', command: hookCmd, timeout, statusMessage }],
171
+ });
172
+ }
177
173
  }
174
+ ensureHookEntry('PreToolUse', 'Grep|Glob|Bash', 10, 'Enriching with GitNexus graph context...');
175
+ ensureHookEntry('PostToolUse', 'Bash', 10, 'Checking GitNexus index freshness...');
178
176
  await writeJsonFile(settingsPath, existing);
179
- result.configured.push('Claude Code hooks (PreToolUse)');
177
+ result.configured.push('Claude Code hooks (PreToolUse, PostToolUse)');
180
178
  }
181
179
  catch (err) {
182
180
  result.errors.push(`Claude Code hooks: ${err.message}`);
@@ -12,6 +12,9 @@ import { walkRepositoryPaths, readFileContents } from './filesystem-walker.js';
12
12
  import { getLanguageFromFilename } from './utils.js';
13
13
  import { isLanguageAvailable } from '../tree-sitter/parser-loader.js';
14
14
  import { createWorkerPool } from './workers/worker-pool.js';
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { fileURLToPath, pathToFileURL } from 'node:url';
15
18
  const isDev = process.env.NODE_ENV === 'development';
16
19
  /** Max bytes of source content to load per parse chunk. Each chunk's source +
17
20
  * parsed ASTs + extracted records + worker serialization overhead all live in
@@ -124,11 +127,21 @@ export const runPipelineFromRepo = async (repoPath, onProgress) => {
124
127
  // Create worker pool once, reuse across chunks
125
128
  let workerPool;
126
129
  try {
127
- const workerUrl = new URL('./workers/parse-worker.js', import.meta.url);
130
+ let workerUrl = new URL('./workers/parse-worker.js', import.meta.url);
131
+ // When running under vitest, import.meta.url points to src/ where no .js exists.
132
+ // Fall back to the compiled dist/ worker so the pool can spawn real worker threads.
133
+ const thisDir = fileURLToPath(new URL('.', import.meta.url));
134
+ if (!fs.existsSync(fileURLToPath(workerUrl))) {
135
+ const distWorker = path.resolve(thisDir, '..', '..', '..', 'dist', 'core', 'ingestion', 'workers', 'parse-worker.js');
136
+ if (fs.existsSync(distWorker)) {
137
+ workerUrl = pathToFileURL(distWorker);
138
+ }
139
+ }
128
140
  workerPool = createWorkerPool(workerUrl);
129
141
  }
130
142
  catch (err) {
131
- // Worker pool creation failed — sequential fallback
143
+ if (isDev)
144
+ console.warn('Worker pool creation failed, using sequential fallback:', err.message);
132
145
  }
133
146
  let filesParsedSoFar = 0;
134
147
  // AST cache sized for one chunk (sequential fallback uses it for import/call/heritage)
@@ -1,5 +1,7 @@
1
1
  import { Worker } from 'node:worker_threads';
2
2
  import os from 'node:os';
3
+ import fs from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
3
5
  /**
4
6
  * Max files to send to a worker in a single postMessage.
5
7
  * Keeps structured-clone memory bounded per sub-batch.
@@ -12,6 +14,12 @@ const SUB_BATCH_TIMEOUT_MS = 30_000;
12
14
  * Create a pool of worker threads.
13
15
  */
14
16
  export const createWorkerPool = (workerUrl, poolSize) => {
17
+ // Validate worker script exists before spawning to prevent uncaught
18
+ // MODULE_NOT_FOUND crashes in worker threads (e.g. when running from src/ via vitest)
19
+ const workerPath = fileURLToPath(workerUrl);
20
+ if (!fs.existsSync(workerPath)) {
21
+ throw new Error(`Worker script not found: ${workerPath}`);
22
+ }
15
23
  const size = poolSize ?? Math.min(8, Math.max(1, os.cpus().length - 1));
16
24
  const workers = [];
17
25
  for (let i = 0; i < size; i++) {
@@ -711,8 +711,8 @@ export const queryFTS = async (tableName, indexName, query, limit = 20, conjunct
711
711
  if (!conn) {
712
712
  throw new Error('KuzuDB not initialized. Call initKuzu first.');
713
713
  }
714
- // Escape single quotes in query
715
- const escapedQuery = query.replace(/'/g, "''");
714
+ // Escape backslashes and single quotes to prevent Cypher injection
715
+ const escapedQuery = query.replace(/\\/g, '\\\\').replace(/'/g, "''");
716
716
  const cypher = `
717
717
  CALL QUERY_FTS_INDEX('${tableName}', '${indexName}', '${escapedQuery}', conjunctive := ${conjunctive})
718
718
  RETURN node, score
@@ -10,7 +10,8 @@ import { queryFTS } from '../kuzu/kuzu-adapter.js';
10
10
  * Returns the same shape as core queryFTS.
11
11
  */
12
12
  async function queryFTSViaExecutor(executor, tableName, indexName, query, limit) {
13
- const escapedQuery = query.replace(/'/g, "''");
13
+ // Escape single quotes and backslashes to prevent Cypher injection
14
+ const escapedQuery = query.replace(/\\/g, '\\\\').replace(/'/g, "''");
14
15
  const cypher = `
15
16
  CALL QUERY_FTS_INDEX('${tableName}', '${indexName}', '${escapedQuery}', conjunctive := false)
16
17
  RETURN node, score
@@ -64,26 +64,14 @@ function evictLRU() {
64
64
  }
65
65
  }
66
66
  /**
67
- * Close all connections for a repo and remove it from the pool
67
+ * Remove a repo from the pool without calling native close methods.
68
+ *
69
+ * KuzuDB's native .closeSync() triggers N-API destructor hooks that
70
+ * segfault on Linux/macOS. Pool databases are opened read-only, so
71
+ * there is no WAL to flush — just deleting the pool entry and letting
72
+ * the GC (or process exit) reclaim native resources is safe.
68
73
  */
69
74
  function closeOne(repoId) {
70
- const entry = pool.get(repoId);
71
- if (!entry)
72
- return;
73
- for (const conn of entry.available) {
74
- try {
75
- conn.close();
76
- }
77
- catch (e) {
78
- console.error('GitNexus [pool:close-conn]:', e instanceof Error ? e.message : e);
79
- }
80
- }
81
- try {
82
- entry.db.close();
83
- }
84
- catch (e) {
85
- console.error('GitNexus [pool:close-db]:', e instanceof Error ? e.message : e);
86
- }
87
75
  pool.delete(repoId);
88
76
  }
89
77
  /**
@@ -2,8 +2,10 @@
2
2
  /**
3
3
  * GitNexus Claude Code Hook
4
4
  *
5
- * PreToolUse handler — intercepts Grep/Glob/Bash searches
6
- * and augments with graph context from the GitNexus index.
5
+ * PreToolUse — intercepts Grep/Glob/Bash searches and augments
6
+ * with graph context from the GitNexus index.
7
+ * PostToolUse — detects stale index after git mutations and notifies
8
+ * the agent to reindex.
7
9
  *
8
10
  * NOTE: SessionStart hooks are broken on Windows (Claude Code bug).
9
11
  * Session context is injected via CLAUDE.md / skills instead.
@@ -11,7 +13,7 @@
11
13
 
12
14
  const fs = require('fs');
13
15
  const path = require('path');
14
- const { execFileSync } = require('child_process');
16
+ const { spawnSync } = require('child_process');
15
17
 
16
18
  /**
17
19
  * Read JSON input from stdin synchronously.
@@ -26,19 +28,19 @@ function readInput() {
26
28
  }
27
29
 
28
30
  /**
29
- * Check if a directory (or ancestor) has a .gitnexus index.
31
+ * Find the .gitnexus directory by walking up from startDir.
32
+ * Returns the path to .gitnexus/ or null if not found.
30
33
  */
31
- function findGitNexusIndex(startDir) {
34
+ function findGitNexusDir(startDir) {
32
35
  let dir = startDir || process.cwd();
33
36
  for (let i = 0; i < 5; i++) {
34
- if (fs.existsSync(path.join(dir, '.gitnexus'))) {
35
- return true;
36
- }
37
+ const candidate = path.join(dir, '.gitnexus');
38
+ if (fs.existsSync(candidate)) return candidate;
37
39
  const parent = path.dirname(dir);
38
40
  if (parent === dir) break;
39
41
  dir = parent;
40
42
  }
41
- return false;
43
+ return null;
42
44
  }
43
45
 
44
46
  /**
@@ -83,72 +85,153 @@ function extractPattern(toolName, toolInput) {
83
85
  return null;
84
86
  }
85
87
 
86
- function main() {
87
- try {
88
- const input = readInput();
89
- const hookEvent = input.hook_event_name || '';
90
-
91
- if (hookEvent !== 'PreToolUse') return;
88
+ /**
89
+ * Resolve the gitnexus CLI path.
90
+ * 1. Relative path (works when script is inside npm package)
91
+ * 2. require.resolve (works when gitnexus is globally installed)
92
+ * 3. Fall back to npx (returns empty string)
93
+ */
94
+ function resolveCliPath() {
95
+ let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');
96
+ if (!fs.existsSync(cliPath)) {
97
+ try {
98
+ cliPath = require.resolve('gitnexus/dist/cli/index.js');
99
+ } catch {
100
+ cliPath = '';
101
+ }
102
+ }
103
+ return cliPath;
104
+ }
92
105
 
93
- const cwd = input.cwd || process.cwd();
94
- if (!findGitNexusIndex(cwd)) return;
106
+ /**
107
+ * Spawn a gitnexus CLI command synchronously.
108
+ * Returns the stderr output (KuzuDB captures stdout at OS level).
109
+ */
110
+ function runGitNexusCli(cliPath, args, cwd, timeout) {
111
+ const isWin = process.platform === 'win32';
112
+ if (cliPath) {
113
+ return spawnSync(
114
+ process.execPath,
115
+ [cliPath, ...args],
116
+ { encoding: 'utf-8', timeout, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
117
+ );
118
+ }
119
+ // On Windows, invoke npx.cmd directly (no shell needed)
120
+ return spawnSync(
121
+ isWin ? 'npx.cmd' : 'npx',
122
+ ['-y', 'gitnexus', ...args],
123
+ { encoding: 'utf-8', timeout: timeout + 5000, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
124
+ );
125
+ }
95
126
 
96
- const toolName = input.tool_name || '';
97
- const toolInput = input.tool_input || {};
127
+ /**
128
+ * PreToolUse handler augment searches with graph context.
129
+ */
130
+ function handlePreToolUse(input) {
131
+ const cwd = input.cwd || process.cwd();
132
+ if (!path.isAbsolute(cwd)) return;
133
+ if (!findGitNexusDir(cwd)) return;
98
134
 
99
- if (toolName !== 'Grep' && toolName !== 'Glob' && toolName !== 'Bash') return;
135
+ const toolName = input.tool_name || '';
136
+ const toolInput = input.tool_input || {};
100
137
 
101
- const pattern = extractPattern(toolName, toolInput);
102
- if (!pattern || pattern.length < 3) return;
138
+ if (toolName !== 'Grep' && toolName !== 'Glob' && toolName !== 'Bash') return;
103
139
 
104
- // Resolve CLI path — try multiple strategies:
105
- // 1. Relative path (works when script is inside npm package)
106
- // 2. require.resolve (works when gitnexus is globally installed)
107
- // 3. Fall back to npx (works when neither is available)
108
- let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');
109
- if (!fs.existsSync(cliPath)) {
110
- try {
111
- cliPath = require.resolve('gitnexus/dist/cli/index.js');
112
- } catch {
113
- cliPath = ''; // will use npx fallback
114
- }
115
- }
140
+ const pattern = extractPattern(toolName, toolInput);
141
+ if (!pattern || pattern.length < 3) return;
116
142
 
117
- // augment CLI writes result to stderr (KuzuDB's native module captures
118
- // stdout fd at OS level, making it unusable in subprocess contexts).
119
- const { spawnSync } = require('child_process');
120
- let result = '';
121
- try {
122
- let child;
123
- if (cliPath) {
124
- child = spawnSync(
125
- process.execPath,
126
- [cliPath, 'augment', pattern],
127
- { encoding: 'utf-8', timeout: 8000, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
128
- );
129
- } else {
130
- // npx fallback
131
- const cmd = process.platform === 'win32' ? 'npx.cmd' : 'npx';
132
- child = spawnSync(
133
- cmd,
134
- ['-y', 'gitnexus', 'augment', pattern],
135
- { encoding: 'utf-8', timeout: 15000, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
136
- );
137
- }
143
+ const cliPath = resolveCliPath();
144
+ let result = '';
145
+ try {
146
+ const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000);
147
+ if (!child.error && child.status === 0) {
138
148
  result = child.stderr || '';
139
- } catch { /* graceful failure */ }
140
-
141
- if (result && result.trim()) {
142
- console.log(JSON.stringify({
143
- hookSpecificOutput: {
144
- hookEventName: 'PreToolUse',
145
- additionalContext: result.trim()
146
- }
147
- }));
148
149
  }
150
+ } catch { /* graceful failure */ }
151
+
152
+ if (result && result.trim()) {
153
+ sendHookResponse('PreToolUse', result.trim());
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Emit a PostToolUse hook response with additional context for the agent.
159
+ */
160
+ function sendHookResponse(hookEventName, message) {
161
+ console.log(JSON.stringify({
162
+ hookSpecificOutput: { hookEventName, additionalContext: message }
163
+ }));
164
+ }
165
+
166
+ /**
167
+ * PostToolUse handler — detect index staleness after git mutations.
168
+ *
169
+ * Instead of spawning a full `gitnexus analyze` synchronously (which blocks
170
+ * the agent for up to 120s and risks KuzuDB corruption on timeout), we do a
171
+ * lightweight staleness check: compare `git rev-parse HEAD` against the
172
+ * lastCommit stored in `.gitnexus/meta.json`. If they differ, notify the
173
+ * agent so it can decide when to reindex.
174
+ */
175
+ function handlePostToolUse(input) {
176
+ const toolName = input.tool_name || '';
177
+ if (toolName !== 'Bash') return;
178
+
179
+ const command = (input.tool_input || {}).command || '';
180
+ if (!/\bgit\s+(commit|merge|rebase|cherry-pick|pull)(\s|$)/.test(command)) return;
181
+
182
+ // Only proceed if the command succeeded
183
+ const toolOutput = input.tool_output || {};
184
+ if (toolOutput.exit_code !== undefined && toolOutput.exit_code !== 0) return;
185
+
186
+ const cwd = input.cwd || process.cwd();
187
+ if (!path.isAbsolute(cwd)) return;
188
+ const gitNexusDir = findGitNexusDir(cwd);
189
+ if (!gitNexusDir) return;
190
+
191
+ // Compare HEAD against last indexed commit — skip if unchanged
192
+ let currentHead = '';
193
+ try {
194
+ const headResult = spawnSync('git', ['rev-parse', 'HEAD'], {
195
+ encoding: 'utf-8', timeout: 3000, cwd, stdio: ['pipe', 'pipe', 'pipe'],
196
+ });
197
+ currentHead = (headResult.stdout || '').trim();
198
+ } catch { return; }
199
+
200
+ if (!currentHead) return;
201
+
202
+ let lastCommit = '';
203
+ let hadEmbeddings = false;
204
+ try {
205
+ const meta = JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'meta.json'), 'utf-8'));
206
+ lastCommit = meta.lastCommit || '';
207
+ hadEmbeddings = (meta.stats && meta.stats.embeddings > 0);
208
+ } catch { /* no meta — treat as stale */ }
209
+
210
+ // If HEAD matches last indexed commit, no reindex needed
211
+ if (currentHead && currentHead === lastCommit) return;
212
+
213
+ const analyzeCmd = `npx gitnexus analyze${hadEmbeddings ? ' --embeddings' : ''}`;
214
+ sendHookResponse('PostToolUse',
215
+ `GitNexus index is stale (last indexed: ${lastCommit ? lastCommit.slice(0, 7) : 'never'}). ` +
216
+ `Run \`${analyzeCmd}\` to update the knowledge graph.`
217
+ );
218
+ }
219
+
220
+ // Dispatch map for hook events
221
+ const handlers = {
222
+ PreToolUse: handlePreToolUse,
223
+ PostToolUse: handlePostToolUse,
224
+ };
225
+
226
+ function main() {
227
+ try {
228
+ const input = readInput();
229
+ const handler = handlers[input.hook_event_name || ''];
230
+ if (handler) handler(input);
149
231
  } catch (err) {
150
- // Graceful failure — log to stderr for debugging
151
- console.error('GitNexus hook error:', err.message);
232
+ if (process.env.GITNEXUS_DEBUG) {
233
+ console.error('GitNexus hook error:', (err.message || '').slice(0, 200));
234
+ }
152
235
  }
153
236
  }
154
237
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.3.10",
3
+ "version": "1.3.11",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",
@@ -22,7 +22,7 @@ Run from the project root. This parses all source files, builds the knowledge gr
22
22
  | `--force` | Force full re-index even if up to date |
23
23
  | `--embeddings` | Enable embedding generation for semantic search (off by default) |
24
24
 
25
- **When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale.
25
+ **When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook runs `analyze` automatically after `git commit` and `git merge`, preserving embeddings if previously generated.
26
26
 
27
27
  ### status — Check index freshness
28
28