graph-indexer 1.0.3 → 1.1.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.
package/README.md CHANGED
@@ -26,6 +26,7 @@
26
26
  - [Best practices](#best-practices)
27
27
  - [Troubleshooting](#troubleshooting)
28
28
  - [Development](#development)
29
+ - [Security](#security)
29
30
  - [License](#license)
30
31
 
31
32
  ---
@@ -350,7 +351,9 @@ When you run `npm run mcp:index`:
350
351
  4. **Creates two indexes**: a BM25 inverted index (lexical) and per-chunk float32 embeddings via Ollama `nomic-embed-text` (optional).
351
352
  5. **Serializes to disk**: `code-index.json` + `code-index.embeddings.bin`.
352
353
 
353
- A background watcher daemon re-indexes changed files automatically.
354
+ A background watcher daemon re-indexes changed files automatically. It respects
355
+ `.gitignore` and skips `node_modules`, build output, and dot-directories, so it
356
+ never traverses (or exhausts OS file-watcher limits on) dependency trees.
354
357
 
355
358
  ### Search pipeline
356
359
 
@@ -420,6 +423,18 @@ npm run mcp:start # start MCP server
420
423
 
421
424
  ---
422
425
 
426
+ ## Security
427
+
428
+ graph-indexer runs locally and is air-gapped by default — its only outbound call
429
+ is to a local Ollama endpoint for embeddings (skipped entirely with
430
+ `INDEXER_EMBEDDINGS=off`). It never executes the code it indexes, and the index
431
+ artifacts contain source snippets, so keep them git-ignored (as `init` configures).
432
+
433
+ See [SECURITY.md](SECURITY.md) for the full threat model and how to report a
434
+ vulnerability.
435
+
436
+ ---
437
+
423
438
  ## License
424
439
 
425
440
  Released under the [MIT License](LICENSE). Copyright (c) 2026 MaquinaTech.
package/indexer.mjs CHANGED
@@ -19,7 +19,7 @@ import fs from 'fs';
19
19
  import path from 'path';
20
20
  import {
21
21
  MAX_FILE_SIZE_BYTES, EXTENSIONS, getParserForFile, buildIgnoreFilter, getLocalEmbeddingsBatch,
22
- extractImportsFromAST, extractSemanticChunks, resolveLocalImports
22
+ extractImportsFromAST, extractSemanticChunks, resolveLocalImports, buildEmbeddingPayload
23
23
  } from './parser-utils.mjs';
24
24
  import { MemoryGraphIndex, writeEmbeddingBinary } from './core-engine.mjs';
25
25
 
@@ -113,27 +113,9 @@ async function main() {
113
113
  console.time("Embedding Generation Duration");
114
114
 
115
115
  const worker = async (batch) => {
116
- const textsToEmbed = batch.map(c => {
117
- const dependencies = indexData.graph.dependencies[c.file_path] || [];
118
- const cleanDeps = dependencies.map(d => {
119
- const depsFile = path.relative(PROJECT_ROOT, d);
120
- const chunkMatches = indexData.chunks.filter(c => c.file_path === depsFile);
121
- return chunkMatches.map(c => c.name).join(' ');
122
- });
123
- const topologicalContext = cleanDeps.length > 0
124
- ? `This code architectural neighborhood connects with: ${cleanDeps.join(', ')}.`
125
- : '';
126
-
127
- return [
128
- `File Location: ${c.file_path}`,
129
- `Symbol Name: ${c.node_type} -> ${c.name}`,
130
- c.docstring ? `Developer Documentation: ${c.docstring}` : '',
131
- c.type_refs?.length ? `Type References: ${c.type_refs.join(', ')}` : '',
132
- topologicalContext,
133
- `--- Source Code ---`,
134
- c.code_snippet
135
- ].filter(Boolean).join('\n');
136
- });
116
+ const textsToEmbed = batch.map(c =>
117
+ buildEmbeddingPayload(c, indexData.graph.dependencies[c.file_path] || [])
118
+ );
137
119
 
138
120
  const embeddingsMatrix = await getLocalEmbeddingsBatch(textsToEmbed, true);
139
121
  if (embeddingsMatrix && embeddingsMatrix.length === batch.length) {
package/mcp-server.mjs CHANGED
@@ -21,6 +21,15 @@ const PROJECT_ROOT = process.env.MCP_PROJECT_ROOT || process.cwd();
21
21
  const INDEX_PATH = resolve(PROJECT_ROOT, "code-index.json");
22
22
  const PID_FILE = resolve(PROJECT_ROOT, ".idx-daemon.pid");
23
23
 
24
+ // Single source of truth for the version: graph-indexer's own package.json
25
+ // (next to this file), never the consumer project's package.json.
26
+ const PACKAGE_DIR = path.dirname(fileURLToPath(import.meta.url));
27
+ function readPackageVersion() {
28
+ try {
29
+ return JSON.parse(fs.readFileSync(path.join(PACKAGE_DIR, "package.json"), "utf-8")).version || "0.0.0";
30
+ } catch { return "0.0.0"; }
31
+ }
32
+
24
33
  // ─── Daemon Orchestration ─────────────────────────────────────────────────────
25
34
 
26
35
  function ensureDaemonRunning() {
@@ -100,7 +109,7 @@ function pruneBodyByQuery(codeSnippet, queryTokens, maxLines = 40) {
100
109
  // ─── Server Initialization ────────────────────────────────────────────────────
101
110
 
102
111
  ensureDaemonRunning();
103
- const version = "1.0.3";
112
+ const version = readPackageVersion();
104
113
  const server = new McpServer({ name: "graph-indexer", version });
105
114
 
106
115
  // Use lazy (disk-backed) vector loading for large corpora — cacheEmbeddings:false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graph-indexer",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "description": "Zero-DB, Air-gapped, In-Memory Graph Indexer MCP Server with Hybrid RRF Search and Cross-Language Topology",
5
5
  "type": "module",
6
6
  "main": "core-engine.mjs",
@@ -26,6 +26,7 @@
26
26
  "scripts": {
27
27
  "start": "node ./mcp-server.mjs",
28
28
  "test": "node test/run.mjs",
29
+ "test:unit": "node test/unit.mjs",
29
30
  "test:setup": "node test/setup.mjs",
30
31
  "test:run": "node test/run.mjs",
31
32
  "test:embeddings": "OLLAMA_HOST=http://localhost:11435 node test/run-embeddings.mjs",
package/parser-utils.mjs CHANGED
@@ -637,6 +637,38 @@ export function resolveLocalImports(rawImports, fromFileRelPath, projectRoot) {
637
637
  return resolved;
638
638
  }
639
639
 
640
+ /**
641
+ * Build the text payload sent to the embedding model for a chunk.
642
+ *
643
+ * Shared by the bootstrap indexer (indexer.mjs) and the watch daemon
644
+ * (watch-daemon.mjs) so a chunk yields the SAME embedding regardless of which
645
+ * path embedded it first. This matters because the cache key (content_hash) is
646
+ * derived from code + docstring only — it does NOT include this payload — so two
647
+ * divergent payloads for the same hash would silently produce inconsistent
648
+ * embeddings across a full re-index vs. an incremental update.
649
+ *
650
+ * @param {object} chunk Semantic chunk: { file_path, node_type, name, docstring, type_refs, code_snippet }.
651
+ * @param {string[]} depRelPaths Resolved local imports of the chunk's file (project-relative paths).
652
+ * @returns {string}
653
+ */
654
+ export function buildEmbeddingPayload(chunk, depRelPaths = []) {
655
+ const neighbors = depRelPaths
656
+ .map(d => path.basename(d, path.extname(d)))
657
+ .filter(Boolean);
658
+ const topologicalContext = neighbors.length
659
+ ? `This code architectural neighborhood connects with: ${neighbors.join(', ')}.`
660
+ : '';
661
+ return [
662
+ `File Location: ${chunk.file_path}`,
663
+ `Symbol Name: ${chunk.node_type} -> ${chunk.name}`,
664
+ chunk.docstring ? `Developer Documentation: ${chunk.docstring}` : '',
665
+ chunk.type_refs?.length ? `Type References: ${chunk.type_refs.join(', ')}` : '',
666
+ topologicalContext,
667
+ `--- Source Code ---`,
668
+ chunk.code_snippet,
669
+ ].filter(Boolean).join('\n');
670
+ }
671
+
640
672
  export async function getLocalEmbedding(text, graceful = true) {
641
673
  if (process.env.INDEXER_EMBEDDINGS === 'off') return null; // lexical-only mode
642
674
  const MAX_RETRIES = 3;
package/watch-daemon.mjs CHANGED
@@ -22,7 +22,8 @@ import chokidar from 'chokidar';
22
22
  import { MemoryGraphIndex } from './core-engine.mjs';
23
23
  import {
24
24
  MAX_FILE_SIZE_BYTES, EXTENSIONS, getParserForFile, buildIgnoreFilter,
25
- extractImportsFromAST, extractSemanticChunks, resolveLocalImports, getLocalEmbeddingsBatch
25
+ extractImportsFromAST, extractSemanticChunks, resolveLocalImports, getLocalEmbeddingsBatch,
26
+ buildEmbeddingPayload
26
27
  } from './parser-utils.mjs';
27
28
 
28
29
  const PROJECT_ROOT = process.env.MCP_PROJECT_ROOT || process.cwd();
@@ -79,23 +80,11 @@ async function processFileChange(absolutePath) {
79
80
  const chunksToEmbed = newChunks.filter(c => !db.embeddingCache.has(c.content_hash));
80
81
 
81
82
  if (chunksToEmbed.length > 0) {
82
- const textsToEmbed = chunksToEmbed.map(c => {
83
- const dependencies = db.graph.dependencies[c.file_path] || [];
84
- const cleanDeps = dependencies.map(d => path.basename(d, path.extname(d)));
85
- const topologicalContext = cleanDeps.length > 0
86
- ? `This code architectural neighborhood connects with: ${cleanDeps.join(', ')}.`
87
- : '';
88
-
89
- // 🥇 STRICT PAYLOAD PARITY: Eliminate real-time bleed
90
- return [
91
- `File Location: ${c.file_path}`,
92
- `Symbol Name: ${c.node_type} -> ${c.name}`,
93
- c.docstring ? `Developer Documentation: ${c.docstring}` : '',
94
- topologicalContext,
95
- `--- Source Code ---`,
96
- c.code_snippet
97
- ].filter(Boolean).join('\n');
98
- });
83
+ // 🥇 STRICT PAYLOAD PARITY: identical embedding payload to indexer.mjs
84
+ // (shared helper) so incremental updates match the bootstrap index.
85
+ const textsToEmbed = chunksToEmbed.map(c =>
86
+ buildEmbeddingPayload(c, db.graph.dependencies[c.file_path] || [])
87
+ );
99
88
 
100
89
  const embeddingsMatrix = await getLocalEmbeddingsBatch(textsToEmbed, true);
101
90
  if (embeddingsMatrix) {
@@ -137,8 +126,23 @@ async function processFileChange(absolutePath) {
137
126
  }
138
127
 
139
128
  process.stderr.write(`🚀 Chokidar Watcher Daemon started in: ${PROJECT_ROOT}\n`);
129
+
130
+ // Only watch source files we can actually index. Critically, this prevents
131
+ // chokidar from descending into node_modules / dist / .git / .gitignored dirs —
132
+ // watching those would exhaust the OS file-watcher limit (ENOSPC on Linux) and
133
+ // waste CPU/memory on large projects. The ignore decision happens at the watcher
134
+ // level, not just in processFileChange, so ignored trees are never traversed.
135
+ function shouldIgnore(absPath) {
136
+ const rel = path.relative(PROJECT_ROOT, absPath).replace(/\\/g, '/');
137
+ if (!rel) return false; // the project root itself
138
+ if (path.basename(absPath).startsWith('.')) return true; // dotfiles / dot-dirs
139
+ try { return ignoreFilter.ignores(rel); } // node_modules, dist, .gitignore, …
140
+ catch { return false; }
141
+ }
142
+
140
143
  const watcher = chokidar.watch(PROJECT_ROOT, {
141
- ignored: /(^|[\/\\])\../, persistent: true,
144
+ ignored: shouldIgnore,
145
+ persistent: true,
142
146
  awaitWriteFinish: { stabilityThreshold: 300, pollInterval: 100 }
143
147
  });
144
148
  watcher.on('add', processFileChange).on('change', processFileChange).on('unlink', (p) => processFileChange(p));