graph-indexer 2.0.0 → 2.1.1

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.
@@ -1,86 +1,250 @@
1
1
  /**
2
2
  * @file parse/languages.mjs
3
- * @description Language registry: dynamic Tree-sitter grammar loading, LANGUAGE_MAP,
4
- * EXTENSIONS, and getParserForFile.
3
+ * @description Language registry: Tree-sitter grammar loading, LANGUAGE_MAP,
4
+ * EXTENSIONS, and getParserForFile. Grammar packages are NOT bundled
5
+ * dependencies of graph-indexer (they were pulled via npm's
6
+ * `optionalDependencies`, which installs eagerly regardless of the
7
+ * name — that shipped all 16 grammars, ~250 MB, to every install even
8
+ * when a repo only uses one or two languages). Instead, `GRAMMAR_PACKAGES`
9
+ * is the single-sourced registry of what each language needs, and
10
+ * `ensureLanguagesReady()` resolves each package: ambient node_modules
11
+ * first (covers this repo's own devDependencies, or a project that
12
+ * added a grammar directly), then a scoped `.graph-indexer/node_modules`
13
+ * install dir (works for ANY project — Node or not, npx-run or not,
14
+ * since it's keyed off `--repo`, not ambient resolution), auto-
15
+ * installing missing packages there on first use.
5
16
  * @author MaquinaTech <https://github.com/MaquinaTech>
6
17
  * @copyright (c) 2026 MaquinaTech. All rights reserved.
7
18
  * @license MIT
8
19
  */
9
20
  import fs from 'fs';
10
21
  import path from 'path';
11
- import ignore from 'ignore';
22
+ import { createRequire } from 'module';
23
+ import { pathToFileURL } from 'url';
24
+ import { spawnSync } from 'child_process';
12
25
  import Parser from 'tree-sitter';
26
+ import { dataDir } from '../layout.mjs';
13
27
 
14
28
  export const OLLAMA_HOST = process.env.OLLAMA_HOST || "http://localhost:11434";
15
29
 
16
- // ─── Dynamic Language Loading ─────────────────────────────────────────────────
30
+ // ─── Grammar registry (single source of truth for versions) ───────────────────
31
+ // Keyed by the same language keys used by init.mjs's LANGUAGES list and
32
+ // `.graph-indexer.json`'s `languages` array. 'css' covers two packages (CSS and
33
+ // SCSS share one user-facing "CSS / SCSS" selection).
34
+ export const GRAMMAR_PACKAGES = {
35
+ typescript: [{ pkg: 'tree-sitter-typescript', version: '0.21.2' }],
36
+ javascript: [{ pkg: 'tree-sitter-javascript', version: '0.21.4' }],
37
+ css: [
38
+ { pkg: 'tree-sitter-css', version: '0.21.1' },
39
+ { pkg: 'tree-sitter-scss', version: '1.0.0' },
40
+ ],
41
+ python: [{ pkg: 'tree-sitter-python', version: '0.21.0' }],
42
+ rust: [{ pkg: 'tree-sitter-rust', version: '0.21.0' }],
43
+ go: [{ pkg: 'tree-sitter-go', version: '0.21.0' }],
44
+ php: [{ pkg: 'tree-sitter-php', version: '0.23.1' }],
45
+ java: [{ pkg: 'tree-sitter-java', version: '0.21.0' }],
46
+ kotlin: [{ pkg: 'tree-sitter-kotlin', version: '0.3.8' }],
47
+ csharp: [{ pkg: 'tree-sitter-c-sharp', version: '0.21.3' }],
48
+ ruby: [{ pkg: 'tree-sitter-ruby', version: '0.23.1' }],
49
+ c: [{ pkg: 'tree-sitter-c', version: '0.21.4' }],
50
+ bash: [{ pkg: 'tree-sitter-bash', version: '0.23.3' }],
51
+ swift: [{ pkg: 'tree-sitter-swift', version: '0.5.0' }],
52
+ };
17
53
 
18
- function _loadProjectConfig() {
19
- const configPath = path.join(process.env.MCP_PROJECT_ROOT || process.cwd(), '.graph-indexer.json');
54
+ function _loadProjectConfig(projectRoot) {
55
+ const configPath = path.join(projectRoot || process.env.MCP_PROJECT_ROOT || process.cwd(), '.graph-indexer.json');
20
56
  try {
21
57
  if (fs.existsSync(configPath)) return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
22
58
  } catch { /* ignore */ }
23
59
  return null;
24
60
  }
25
61
 
26
- async function _tryLang(pkg, enabledLangs, key) {
27
- if (enabledLangs && !enabledLangs.includes(key)) return null;
62
+ // ─── Package resolution ────────────────────────────────────────────────────────
63
+
64
+ /** Ambient resolution: relative to THIS module's own location (covers devDependencies
65
+ * in this repo's checkout, or a grammar a project installed directly). */
66
+ async function _tryAmbient(pkg) {
67
+ try { return (await import(pkg)).default; }
68
+ catch { return null; }
69
+ }
70
+
71
+ /** Resolve `pkg` from a scoped install dir (its own node_modules), without relying
72
+ * on ambient ancestor-directory resolution — required for the npx/global-install
73
+ * case, where graph-indexer's own location shares no node_modules ancestry with
74
+ * the target `--repo`. */
75
+ async function _importFrom(pkg, dir) {
28
76
  try {
29
- return (await import(pkg)).default;
30
- } catch {
31
- process.stderr.write(`[graph-indexer] WARNING: ${pkg} not installed — ${key} files will be skipped\n`);
32
- return null;
77
+ const req = createRequire(path.join(dir, '__grammar_resolve__.js'));
78
+ const resolved = req.resolve(pkg);
79
+ return (await import(pathToFileURL(resolved).href)).default;
80
+ } catch { return null; }
81
+ }
82
+
83
+ // A platform-incompatible grammar (e.g. no Xcode toolchain for a native binding)
84
+ // would otherwise re-attempt its slow, doomed build on every single `idx-index`
85
+ // run. Once a spec fails, it's remembered here so later runs skip straight to the
86
+ // "not installed — skipped" warning instead of re-running node-gyp. Delete this
87
+ // file (or the whole node_modules dir) to retry after fixing the toolchain.
88
+ function _failedCachePath(dir) { return path.join(dir, '.install-failed.json'); }
89
+ function _readFailedCache(dir) {
90
+ try { return new Set(JSON.parse(fs.readFileSync(_failedCachePath(dir), 'utf-8'))); }
91
+ catch { return new Set(); }
92
+ }
93
+ function _writeFailedCache(dir, failedSet) {
94
+ try { fs.writeFileSync(_failedCachePath(dir), JSON.stringify([...failedSet])); } catch { /* best effort */ }
95
+ }
96
+
97
+ // Installed ONE package per `npm install` call — not batched. A single native
98
+ // build failure (e.g. no Xcode toolchain for a grammar with a compiled binding)
99
+ // must only skip THAT language, not roll back the whole batch: npm treats a
100
+ // multi-spec `install` as one transaction, so one failing package would otherwise
101
+ // take every other requested language down with it (this is exactly the
102
+ // per-package graceful-degradation `optionalDependencies` used to give us for
103
+ // free). Returns the subset of `specs` that failed (including previously-known
104
+ // failures, which are skipped without a retry).
105
+ function _installGrammars(dir, specs, log) {
106
+ fs.mkdirSync(dir, { recursive: true });
107
+ const pkgPath = path.join(dir, 'package.json');
108
+ if (!fs.existsSync(pkgPath)) fs.writeFileSync(pkgPath, JSON.stringify({ name: 'graph-indexer-grammars', private: true }) + '\n');
109
+ const npmBin = process.platform === 'win32' ? 'npm.cmd' : 'npm';
110
+ const knownBad = _readFailedCache(dir);
111
+ const failed = [];
112
+ let knownBadChanged = false;
113
+ for (const spec of specs) {
114
+ if (knownBad.has(spec)) {
115
+ log(`[graph-indexer] WARNING: ${spec} previously failed to install — skipping retry (delete ${path.relative(process.cwd(), dir) || dir}/.install-failed.json to retry)`);
116
+ failed.push(spec);
117
+ continue;
118
+ }
119
+ log(`[graph-indexer] installing ${spec} (first use only, cached in ${path.relative(process.cwd(), dir) || dir}/node_modules)…`);
120
+ const res = spawnSync(npmBin, ['install', '--no-audit', '--no-fund', '--no-package-lock', spec], { cwd: dir, stdio: 'inherit' });
121
+ if (res.status !== 0) {
122
+ log(`[graph-indexer] WARNING: ${spec} failed to install (exit ${res.status}) — this platform may not support it; skipping`);
123
+ failed.push(spec);
124
+ knownBad.add(spec);
125
+ knownBadChanged = true;
126
+ }
33
127
  }
128
+ if (knownBadChanged) _writeFailedCache(dir, knownBad);
129
+ return failed;
34
130
  }
35
131
 
36
- const _cfg = _loadProjectConfig();
37
- const _enabled = _cfg?.languages ?? null; // null = all languages
38
-
39
- const [
40
- TypeScript, JavaScript, CSS, SCSS, Python, Rust,
41
- Go, PHP, Java, Kotlin, CSharp, Ruby, C, Bash, Swift
42
- ] = await Promise.all([
43
- _tryLang('tree-sitter-typescript', _enabled, 'typescript'),
44
- _tryLang('tree-sitter-javascript', _enabled, 'javascript'),
45
- _tryLang('tree-sitter-css', _enabled, 'css'),
46
- // SCSS shares the 'css' enable-key — init records .css and .scss under one
47
- // 'css' language, so the SCSS grammar must load whenever CSS is enabled.
48
- _tryLang('tree-sitter-scss', _enabled, (_enabled && _enabled.includes('css')) ? 'css' : 'scss'),
49
- _tryLang('tree-sitter-python', _enabled, 'python'),
50
- _tryLang('tree-sitter-rust', _enabled, 'rust'),
51
- _tryLang('tree-sitter-go', _enabled, 'go'),
52
- _tryLang('tree-sitter-php', _enabled, 'php'),
53
- _tryLang('tree-sitter-java', _enabled, 'java'),
54
- _tryLang('tree-sitter-kotlin', _enabled, 'kotlin'),
55
- _tryLang('tree-sitter-c-sharp', _enabled, 'csharp'),
56
- _tryLang('tree-sitter-ruby', _enabled, 'ruby'),
57
- _tryLang('tree-sitter-c', _enabled, 'c'),
58
- _tryLang('tree-sitter-bash', _enabled, 'bash'),
59
- _tryLang('tree-sitter-swift', _enabled, 'swift'),
60
- ]);
61
-
62
- export const LANGUAGE_MAP = {
63
- ...(TypeScript ? { '.ts': TypeScript.typescript, '.tsx': TypeScript.tsx } : {}),
64
- ...(JavaScript ? { '.js': JavaScript, '.jsx': JavaScript, '.mjs': JavaScript, '.cjs': JavaScript } : {}),
65
- ...(CSS ? { '.css': CSS } : {}),
66
- ...(SCSS ? { '.scss': SCSS } : (CSS ? { '.scss': CSS } : {})),
67
- ...(Python ? { '.py': Python } : {}),
68
- ...(Rust ? { '.rs': Rust } : {}),
69
- ...(Go ? { '.go': Go } : {}),
70
- ...(PHP ? { '.php': PHP.php } : {}),
71
- ...(Java ? { '.java': Java } : {}),
72
- ...(Kotlin ? { '.kt': Kotlin, '.kts': Kotlin } : {}),
73
- ...(CSharp ? { '.cs': CSharp } : {}),
74
- ...(Ruby ? { '.rb': Ruby } : {}),
75
- ...(C ? { '.c': C, '.h': C } : {}),
76
- // Shebang-only extensionless scripts are not keyed.
77
- ...(Bash ? { '.sh': Bash, '.bash': Bash } : {}),
78
- ...(Swift ? { '.swift': Swift } : {}),
79
- };
132
+ // ─── Lazy, memoized language loading ───────────────────────────────────────────
133
+ // LANGUAGE_MAP/EXTENSIONS are mutated IN PLACE (not reassigned) so every consumer
134
+ // that imported these bindings sees the same populated object/set once
135
+ // ensureLanguagesReady() resolves — no need to re-import or re-bind anything.
136
+ export const LANGUAGE_MAP = {};
137
+ export const EXTENSIONS = new Set();
138
+
139
+ let _readyPromise = null;
140
+
141
+ function _applyLanguageMap(modules) {
142
+ const primary = (key) => modules[key]?.[0]?.mod || null;
143
+ const byPkg = (key, pkg) => modules[key]?.find(m => m.pkg === pkg)?.mod || null;
144
+
145
+ const TypeScript = primary('typescript');
146
+ const JavaScript = primary('javascript');
147
+ const CSS = byPkg('css', 'tree-sitter-css');
148
+ const SCSS = byPkg('css', 'tree-sitter-scss');
149
+ const Python = primary('python');
150
+ const Rust = primary('rust');
151
+ const Go = primary('go');
152
+ const PHP = primary('php');
153
+ const Java = primary('java');
154
+ const Kotlin = primary('kotlin');
155
+ const CSharp = primary('csharp');
156
+ const Ruby = primary('ruby');
157
+ const C = primary('c');
158
+ const Bash = primary('bash');
159
+ const Swift = primary('swift');
160
+
161
+ const map = {
162
+ ...(TypeScript ? { '.ts': TypeScript.typescript, '.tsx': TypeScript.tsx } : {}),
163
+ ...(JavaScript ? { '.js': JavaScript, '.jsx': JavaScript, '.mjs': JavaScript, '.cjs': JavaScript } : {}),
164
+ ...(CSS ? { '.css': CSS } : {}),
165
+ ...(SCSS ? { '.scss': SCSS } : (CSS ? { '.scss': CSS } : {})),
166
+ ...(Python ? { '.py': Python } : {}),
167
+ ...(Rust ? { '.rs': Rust } : {}),
168
+ ...(Go ? { '.go': Go } : {}),
169
+ ...(PHP ? { '.php': PHP.php } : {}),
170
+ ...(Java ? { '.java': Java } : {}),
171
+ ...(Kotlin ? { '.kt': Kotlin, '.kts': Kotlin } : {}),
172
+ ...(CSharp ? { '.cs': CSharp } : {}),
173
+ ...(Ruby ? { '.rb': Ruby } : {}),
174
+ ...(C ? { '.c': C, '.h': C } : {}),
175
+ // Shebang-only extensionless scripts are not keyed.
176
+ ...(Bash ? { '.sh': Bash, '.bash': Bash } : {}),
177
+ ...(Swift ? { '.swift': Swift } : {}),
178
+ };
179
+ for (const k of Object.keys(LANGUAGE_MAP)) delete LANGUAGE_MAP[k];
180
+ Object.assign(LANGUAGE_MAP, map);
181
+ EXTENSIONS.clear();
182
+ for (const k of Object.keys(map)) EXTENSIONS.add(k);
183
+ }
80
184
 
81
- export const EXTENSIONS = new Set(Object.keys(LANGUAGE_MAP));
185
+ async function _doLoadLanguages({ projectRoot, enabledLangs, autoInstall, log }) {
186
+ const grammarsRoot = dataDir(projectRoot); // packages land in <grammarsRoot>/node_modules
187
+ const keys = enabledLangs && enabledLangs.length ? enabledLangs : Object.keys(GRAMMAR_PACKAGES);
82
188
 
189
+ const modules = {};
190
+ let missing = [];
191
+ for (const key of keys) {
192
+ const specs = GRAMMAR_PACKAGES[key];
193
+ if (!specs) continue;
194
+ modules[key] = [];
195
+ for (const { pkg, version } of specs) {
196
+ const mod = (await _tryAmbient(pkg)) || (await _importFrom(pkg, grammarsRoot));
197
+ if (mod) modules[key].push({ pkg, mod });
198
+ else missing.push({ key, pkg, version });
199
+ }
200
+ }
201
+
202
+ if (missing.length && autoInstall) {
203
+ const specs = [...new Set(missing.map(m => `${m.pkg}@${m.version}`))];
204
+ const failedSpecs = new Set(_installGrammars(grammarsRoot, specs, log));
205
+ const stillMissing = [];
206
+ for (const m of missing) {
207
+ if (failedSpecs.has(`${m.pkg}@${m.version}`)) { stillMissing.push(m); continue; }
208
+ const mod = await _importFrom(m.pkg, grammarsRoot);
209
+ if (mod) modules[m.key].push({ pkg: m.pkg, mod });
210
+ else stillMissing.push(m);
211
+ }
212
+ missing = stillMissing;
213
+ }
214
+
215
+ for (const m of missing) {
216
+ log(`[graph-indexer] WARNING: ${m.pkg} not installed — ${m.key} files will be skipped`);
217
+ }
218
+
219
+ _applyLanguageMap(modules);
220
+ return { missing };
221
+ }
222
+
223
+ /**
224
+ * Resolve (and, unless `autoInstall` is false, install) every requested language's
225
+ * grammar, then populate LANGUAGE_MAP/EXTENSIONS. Idempotent + memoized per process
226
+ * — the first call's options win; call this once, early, before any getParserForFile.
227
+ * `enabledLangs` defaults to `.graph-indexer.json`'s `languages` field (null = all 14).
228
+ */
229
+ export function ensureLanguagesReady({
230
+ projectRoot = process.env.MCP_PROJECT_ROOT || process.cwd(),
231
+ enabledLangs = _loadProjectConfig(projectRoot)?.languages ?? null,
232
+ autoInstall = true,
233
+ log = (msg) => process.stderr.write(msg + '\n'),
234
+ } = {}) {
235
+ if (!_readyPromise) _readyPromise = _doLoadLanguages({ projectRoot, enabledLangs, autoInstall, log });
236
+ return _readyPromise;
237
+ }
238
+
239
+ /** Test-only: forces the next ensureLanguagesReady() call to reload from scratch. */
240
+ export function _resetLanguagesReady() { _readyPromise = null; }
241
+
242
+ let _warnedNotReady = false;
83
243
  export function getParserForFile(ext) {
244
+ if (!_readyPromise && !_warnedNotReady) {
245
+ _warnedNotReady = true;
246
+ process.stderr.write('[graph-indexer] WARNING: getParserForFile() called before ensureLanguagesReady() — no grammars loaded\n');
247
+ }
84
248
  const language = LANGUAGE_MAP[ext];
85
249
  if (!language) return null;
86
250
  const parser = new Parser();
@@ -534,6 +534,52 @@ function _inferReceiverType(objNode, bindings) {
534
534
  return _classifyValueNode(objNode); // inline: new Repo().save(), getStore().save()
535
535
  }
536
536
 
537
+ const _FN_NODE_TYPES = new Set([
538
+ 'function_declaration', 'function_definition', 'method_definition', 'method_declaration',
539
+ 'arrow_function', 'function', 'function_expression', 'lambda', 'method', 'singleton_method',
540
+ 'generator_function', 'generator_function_declaration',
541
+ ]);
542
+
543
+ /**
544
+ * Classify what a function chunk RETURNS, for the opt-in inter-procedural fixpoint
545
+ * (parse/interprocedural.mjs). Looks at this function's own return expression(s):
546
+ * • { type } — returns `new Repo()` (a concrete type, no annotation needed).
547
+ * • { viaCall } — returns a bare factory call `makeRepo()` whose type is resolved
548
+ * later by propagating along the call graph.
549
+ * • null — nothing safe (no return, mixed returns, opaque expression).
550
+ * Does NOT descend into nested functions/lambdas (only THIS function's returns).
551
+ * This is a transient signal (attached as `_return_via`) consumed at index time and
552
+ * stripped before serialization — it never persists in the index.
553
+ */
554
+ export function extractReturnVia(chunkNode) {
555
+ const found = [];
556
+ const consider = (expr) => { const v = _classifyValueNode(expr); if (v) found.push(v); };
557
+
558
+ // Expression-body arrow / lambda: `() => new Repo()` has no return_statement.
559
+ if (chunkNode.type === 'arrow_function' || chunkNode.type === 'lambda') {
560
+ const body = chunkNode.childForFieldName?.('body');
561
+ if (body && body.type !== 'statement_block' && body.type !== 'block') consider(body);
562
+ }
563
+
564
+ (function walk(node, depth) {
565
+ for (const child of node.children) {
566
+ if (depth > 0 && _FN_NODE_TYPES.has(child.type)) continue; // skip nested functions
567
+ if (child.type === 'return_statement') {
568
+ consider(child.namedChildren?.[0] || child.childForFieldName?.('argument'));
569
+ }
570
+ walk(child, depth + 1);
571
+ }
572
+ })(chunkNode, 0);
573
+
574
+ if (found.length === 0) return null;
575
+ const types = new Set(found.filter(f => f.type).map(f => f.type));
576
+ const vias = new Set(found.filter(f => f.viaCall).map(f => f.viaCall));
577
+ // Conservative: only a single unambiguous classification is usable.
578
+ if (types.size === 1 && vias.size === 0) return { type: [...types][0] };
579
+ if (vias.size === 1 && types.size === 0) return { viaCall: [...vias][0] };
580
+ return null;
581
+ }
582
+
537
583
  /**
538
584
  * Walk a subtree and collect every call site as { name, recv } (receiver hint), plus
539
585
  * an optional `recv_type` / `recv_via_call` when the receiver's type is recoverable