graph-indexer 2.0.0 → 2.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/CHANGELOG.md +442 -0
- package/README.md +54 -11
- package/SECURITY.md +17 -0
- package/colbert.mjs +168 -0
- package/config.mjs +246 -21
- package/embeddings.mjs +90 -8
- package/engine/binary.mjs +38 -0
- package/engine/memory.mjs +214 -1
- package/engine/sqlite.mjs +278 -5
- package/enrichment.mjs +95 -0
- package/indexer.mjs +254 -9
- package/init.mjs +25 -2
- package/mcp/centrality.mjs +117 -0
- package/mcp/resolver.mjs +116 -0
- package/mcp/symbolgraph.mjs +137 -0
- package/mcp/taint.mjs +225 -0
- package/mcp/tools.mjs +444 -19
- package/mcp/topology.mjs +138 -13
- package/mcp-server.mjs +26 -2
- package/package.json +29 -21
- package/parse/extractor.mjs +17 -6
- package/parse/interprocedural.mjs +134 -0
- package/parse/languages.mjs +221 -57
- package/parse/metadata.mjs +46 -0
- package/parse/scip.mjs +391 -0
- package/parse/taint-patterns.mjs +119 -0
- package/prompts/CORE.md +9 -4
- package/prompts/frameworks/FASTAPI_DJANGO.md +1 -0
- package/prompts/frameworks/NODE_EXPRESS_NESTJS.md +1 -0
- package/prompts/frameworks/REACT.md +1 -1
- package/prompts/frameworks/SPRING_BOOT.md +1 -0
- package/prompts/languages/GO.md +1 -0
- package/prompts/languages/JAVA.md +1 -1
- package/prompts/languages/PYTHON.md +1 -1
- package/prompts/languages/RUST.md +1 -1
- package/seal.mjs +400 -0
- package/search-core.mjs +151 -1
- package/search-sparse.mjs +183 -0
- package/storage.mjs +5 -0
- package/watch-daemon.mjs +7 -2
package/mcp/topology.mjs
CHANGED
|
@@ -339,27 +339,36 @@ export function classifyCallers(db, targetFunction, { targetClass = null } = {})
|
|
|
339
339
|
let typeMatch = null;
|
|
340
340
|
if (typeMatchSet.size) {
|
|
341
341
|
for (const s of sites) {
|
|
342
|
+
// Precedence: intra-procedural receiver type → the opt-in inter-procedural
|
|
343
|
+
// fixpoint result (recv_resolved_type, written at index time) → the 1-hop
|
|
344
|
+
// query-time factory return-type fallback. The fixpoint resolves multi-hop /
|
|
345
|
+
// unannotated factory chains the 1-hop path cannot.
|
|
342
346
|
const toks = s.recv_type ? _typeMatchTokens(s.recv_type)
|
|
343
|
-
: s.
|
|
347
|
+
: s.recv_resolved_type ? _typeMatchTokens(s.recv_resolved_type)
|
|
348
|
+
: s.recv_via_call ? [...calleeReturnTokens(s.recv_via_call)] : null;
|
|
344
349
|
const m = toks && toks.find(t => typeMatchSet.has(t));
|
|
345
350
|
if (m) { typeMatch = m; break; }
|
|
346
351
|
}
|
|
347
352
|
}
|
|
348
353
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
else if (
|
|
356
|
-
else if (
|
|
354
|
+
// `proven` (A1): the binding is UNAMBIGUOUS — a sole definition, or pinned by the
|
|
355
|
+
// receiver's TYPE (typed receiver / A3 fixpoint / this-in-class / explicit target
|
|
356
|
+
// class). The precise resolver lifts these to the `resolved` edge tier. Import- and
|
|
357
|
+
// proximity-based reasons are likely-but-not-proven → stay `high`.
|
|
358
|
+
let reason = '', proven = false;
|
|
359
|
+
if (uniqueTarget && !classFiltered) { reason = 'sole definition'; proven = true; }
|
|
360
|
+
else if (recvs.has('this') && callerClass && targetClasses.has(callerClass)) { reason = `this.${targetFunction}()`; proven = true; }
|
|
361
|
+
else if (recvs.has('') && targetIsFreeFn) { reason = `${targetFunction}()`; proven = false; }
|
|
362
|
+
else if (typeMatch) { reason = `${classDisplay.get(typeMatch) || typeMatch}.${targetFunction}()`; proven = true; }
|
|
363
|
+
else if (deps.some(d => targetFiles.has(d))) { reason = 'imports definition'; proven = false; }
|
|
364
|
+
else if (targetFiles.has(caller.file_path)) { reason = 'same file'; proven = false; }
|
|
365
|
+
else if (tcl && [...recvs].some(r => r && r.toLowerCase() === tcl)) { reason = `${targetClass}.${targetFunction}()`; proven = true; }
|
|
357
366
|
|
|
358
367
|
const recvHint = [...recvs].map(r =>
|
|
359
368
|
r === '' ? `${targetFunction}()` : r === 'this' ? `this.${targetFunction}()` : `${r}.${targetFunction}()`
|
|
360
369
|
).join(', ');
|
|
361
370
|
|
|
362
|
-
(reason ? high : nameOnly).push({ chunk: caller, reason, recvHint });
|
|
371
|
+
(reason ? high : nameOnly).push({ chunk: caller, reason, recvHint, proven: reason ? proven : false });
|
|
363
372
|
}
|
|
364
373
|
|
|
365
374
|
return { high, nameOnly, targetDefs, ambiguous, hasSiteData, classFiltered };
|
|
@@ -403,15 +412,40 @@ export function findReferences(db, symbol, { targetClass = null } = {}) {
|
|
|
403
412
|
: imports ? 'imports definition'
|
|
404
413
|
: sameFile ? 'same file' : '';
|
|
405
414
|
const confidence = reason ? 'high' : 'name-only';
|
|
406
|
-
|
|
407
|
-
|
|
415
|
+
// proven (A1): a sole definition is an unambiguous binding → eligible for `resolved`.
|
|
416
|
+
const proven = uniqueTarget;
|
|
417
|
+
if ((ref.extends || []).some(t => t.toLowerCase() === key)) inherits.push({ chunk: ref, confidence, reason, proven });
|
|
418
|
+
if ((ref.type_refs || []).some(t => t.toLowerCase() === key)) types.push({ chunk: ref, confidence, reason, proven });
|
|
408
419
|
}
|
|
409
420
|
const order = (a, b) => (a.confidence === b.confidence
|
|
410
421
|
? a.chunk.file_path.localeCompare(b.chunk.file_path)
|
|
411
422
|
: a.confidence === 'high' ? -1 : 1);
|
|
412
423
|
inherits.sort(order); types.sort(order);
|
|
413
424
|
|
|
414
|
-
|
|
425
|
+
// A2 v2: precise cross-file references from a locally-generated SCIP index (--resolver scip).
|
|
426
|
+
// SCIP occurrences are kind-agnostic ("symbol used here"), so this is the binding-precise,
|
|
427
|
+
// cross-file "used by" set: it both DISAMBIGUATES same-named symbols (each referer is attached
|
|
428
|
+
// to the one definition SCIP bound it to) and RECOVERS references the AST/name heuristic above
|
|
429
|
+
// misses entirely. Confidence is `resolved` — trustworthy, not a name guess. Empty (and so
|
|
430
|
+
// byte-identical) on every index built without a SCIP resolver. Isolated artifact → parity-free.
|
|
431
|
+
const references = [];
|
|
432
|
+
if (db.hasScipRefs?.()) {
|
|
433
|
+
const seen = new Set();
|
|
434
|
+
for (const def of targetDefs) {
|
|
435
|
+
for (const refId of db.getScipReferers(def.id)) {
|
|
436
|
+
if (seen.has(refId)) continue;
|
|
437
|
+
seen.add(refId);
|
|
438
|
+
const chunk = db.getChunk(refId);
|
|
439
|
+
if (chunk) references.push({ chunk, confidence: 'resolved', reason: 'scip' });
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
references.sort((a, b) =>
|
|
443
|
+
a.chunk.file_path.localeCompare(b.chunk.file_path)
|
|
444
|
+
|| ((a.chunk.start_line || 0) - (b.chunk.start_line || 0))
|
|
445
|
+
|| (a.chunk.id < b.chunk.id ? -1 : a.chunk.id > b.chunk.id ? 1 : 0));
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return { symbol, targetDefs, ambiguous, calls, inherits, types, references };
|
|
415
449
|
}
|
|
416
450
|
|
|
417
451
|
// ─── HTTP route → handler resolution ──────────────────────────────────────────────
|
|
@@ -549,3 +583,94 @@ export function buildSubgraph(db, seed, { maxNodes = 12, maxDepth = 2, tokenBudg
|
|
|
549
583
|
}));
|
|
550
584
|
return { seed, found: true, truncated, nodes: nodeList, edges };
|
|
551
585
|
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Reverse-direction transitive blast radius (C4): from a set of seed chunks (the symbols /
|
|
589
|
+
* files about to change), the transitively-affected REFERRERS — callers, subclasses, and type
|
|
590
|
+
* users — bounded by hop depth and node count.
|
|
591
|
+
*
|
|
592
|
+
* Uses the persistent symbol graph (getEdges) when present — precise and chunk-level — and
|
|
593
|
+
* otherwise falls back to query-time classifyCallers / findReferences. HIGH-confidence
|
|
594
|
+
* referrers drive the transitive closure (a precise blast radius that does not explode on
|
|
595
|
+
* ambiguous names); the DIRECT name_only referrers of the seed are surfaced separately for the
|
|
596
|
+
* agent to verify. Fully deterministic (every list is sorted: depth, file, line, id).
|
|
597
|
+
*
|
|
598
|
+
* @param {object} db
|
|
599
|
+
* @param {object[]} seedChunks The chunks being changed.
|
|
600
|
+
* @param {object} [opts]
|
|
601
|
+
* @param {number} [opts.maxDepth] Transitive hops to follow (default 3).
|
|
602
|
+
* @param {number} [opts.maxNodes] Cap on impacted chunks (default 200).
|
|
603
|
+
* @returns {{ impacted:Array<{chunk,depth,kind}>, ambiguous:Array<{chunk,kind}>,
|
|
604
|
+
* truncated:boolean, usedGraph:boolean }}
|
|
605
|
+
*/
|
|
606
|
+
export function buildImpact(db, seedChunks, { maxDepth = 3, maxNodes = 200, precision = 'standard' } = {}) {
|
|
607
|
+
const hasGraph = typeof db.hasSymbolGraph === 'function' && db.hasSymbolGraph();
|
|
608
|
+
const seedIds = new Set(seedChunks.map(c => c.id));
|
|
609
|
+
|
|
610
|
+
// A1 precision dial. `standard` follows the high-confidence closure (resolved + high);
|
|
611
|
+
// `strict` follows ONLY provably-unambiguous bindings (the `resolved` edge tier, or — with
|
|
612
|
+
// no graph — the `proven` query-time callers) for a false-positive-free blast radius.
|
|
613
|
+
const strict = precision === 'strict';
|
|
614
|
+
const keepEdge = strict ? (c) => c === 'resolved' : (c) => c === 'high' || c === 'resolved';
|
|
615
|
+
const keepRef = (proven) => !strict || Boolean(proven);
|
|
616
|
+
|
|
617
|
+
const highReferrers = (chunk) => {
|
|
618
|
+
const out = [];
|
|
619
|
+
if (hasGraph) {
|
|
620
|
+
for (const e of db.getEdges(chunk.id, { direction: 'in' }))
|
|
621
|
+
if (e.chunk && keepEdge(e.confidence)) out.push({ chunk: e.chunk, kind: e.kind });
|
|
622
|
+
return out;
|
|
623
|
+
}
|
|
624
|
+
if (!chunk.name || chunk.name === 'anonymous') return out;
|
|
625
|
+
const tcl = chunk.class_context || null;
|
|
626
|
+
const seen = new Set();
|
|
627
|
+
for (const h of classifyCallers(db, chunk.name, { targetClass: tcl }).high)
|
|
628
|
+
if (keepRef(h.proven) && !seen.has(h.chunk.id)) { seen.add(h.chunk.id); out.push({ chunk: h.chunk, kind: 'calls' }); }
|
|
629
|
+
const refs = findReferences(db, chunk.name, { targetClass: tcl });
|
|
630
|
+
for (const r of refs.inherits) if (r.confidence === 'high' && keepRef(r.proven) && !seen.has(r.chunk.id)) { seen.add(r.chunk.id); out.push({ chunk: r.chunk, kind: 'extends' }); }
|
|
631
|
+
for (const r of refs.types) if (r.confidence === 'high' && keepRef(r.proven) && !seen.has(r.chunk.id)) { seen.add(r.chunk.id); out.push({ chunk: r.chunk, kind: 'type' }); }
|
|
632
|
+
return out;
|
|
633
|
+
};
|
|
634
|
+
const nameOnlyReferrers = (chunk) => {
|
|
635
|
+
const out = [];
|
|
636
|
+
if (hasGraph) {
|
|
637
|
+
for (const e of db.getEdges(chunk.id, { direction: 'in' }))
|
|
638
|
+
if (e.chunk && e.confidence === 'name_only') out.push({ chunk: e.chunk, kind: e.kind });
|
|
639
|
+
return out;
|
|
640
|
+
}
|
|
641
|
+
if (!chunk.name || chunk.name === 'anonymous') return out;
|
|
642
|
+
for (const n of classifyCallers(db, chunk.name, { targetClass: chunk.class_context || null }).nameOnly)
|
|
643
|
+
out.push({ chunk: n.chunk, kind: 'calls' });
|
|
644
|
+
return out;
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
const ambiguous = new Map();
|
|
648
|
+
for (const s of seedChunks) for (const r of nameOnlyReferrers(s))
|
|
649
|
+
if (!seedIds.has(r.chunk.id) && !ambiguous.has(r.chunk.id)) ambiguous.set(r.chunk.id, r);
|
|
650
|
+
|
|
651
|
+
const impacted = new Map();
|
|
652
|
+
const visited = new Set(seedIds);
|
|
653
|
+
let truncated = false;
|
|
654
|
+
let frontier = seedChunks.map(c => ({ chunk: c }));
|
|
655
|
+
for (let depth = 1; depth <= maxDepth && !truncated; depth++) {
|
|
656
|
+
const next = [];
|
|
657
|
+
for (const { chunk } of frontier) {
|
|
658
|
+
for (const r of highReferrers(chunk)) {
|
|
659
|
+
if (visited.has(r.chunk.id)) continue;
|
|
660
|
+
if (impacted.size >= maxNodes) { truncated = true; break; }
|
|
661
|
+
visited.add(r.chunk.id);
|
|
662
|
+
impacted.set(r.chunk.id, { chunk: r.chunk, depth, kind: r.kind });
|
|
663
|
+
next.push({ chunk: r.chunk });
|
|
664
|
+
}
|
|
665
|
+
if (truncated) break;
|
|
666
|
+
}
|
|
667
|
+
if (next.length === 0) break;
|
|
668
|
+
frontier = next;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const byPlace = (a, b) => (a.chunk.file_path < b.chunk.file_path ? -1 : a.chunk.file_path > b.chunk.file_path ? 1 : 0)
|
|
672
|
+
|| (a.chunk.start_line - b.chunk.start_line) || (a.chunk.id < b.chunk.id ? -1 : 1);
|
|
673
|
+
const impactedList = [...impacted.values()].sort((a, b) => (a.depth - b.depth) || byPlace(a, b));
|
|
674
|
+
const ambiguousList = [...ambiguous.values()].sort(byPlace);
|
|
675
|
+
return { impacted: impactedList, ambiguous: ambiguousList, truncated, usedGraph: hasGraph, precision };
|
|
676
|
+
}
|
package/mcp-server.mjs
CHANGED
|
@@ -21,10 +21,22 @@ import { ensureDataDir, migrateLegacyLayout } from './layout.mjs';
|
|
|
21
21
|
import { daemonStatus } from './daemon-lock.mjs';
|
|
22
22
|
import { createStore } from './storage.mjs';
|
|
23
23
|
import { registerTools } from './mcp/tools.mjs';
|
|
24
|
+
import { ensureLanguagesReady } from './parse/languages.mjs';
|
|
24
25
|
import { createEmbedder, readEmbedMeta } from './embeddings.mjs';
|
|
25
26
|
import { loadGitSignals } from './git-signals.mjs';
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
import { installEgressGuard } from './seal.mjs';
|
|
28
|
+
|
|
29
|
+
let config;
|
|
30
|
+
try {
|
|
31
|
+
config = resolveConfig();
|
|
32
|
+
} catch (err) {
|
|
33
|
+
if (err && err.name === 'SealViolation') { process.stderr.write(`🔒 ${err.message}\n`); process.exit(2); }
|
|
34
|
+
throw err;
|
|
35
|
+
}
|
|
36
|
+
// F1: install the deny-by-default egress guard before the store / embedder / providers load.
|
|
37
|
+
if (config.sealed !== 'off') {
|
|
38
|
+
installEgressGuard({ allow: config.sealed === 'local' ? ['loopback'] : [] });
|
|
39
|
+
}
|
|
28
40
|
const PROJECT_ROOT = config.projectRoot;
|
|
29
41
|
const PID_FILE = config.pidFile;
|
|
30
42
|
const PACKAGE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -32,6 +44,16 @@ const PACKAGE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
|
32
44
|
ensureDataDir(PROJECT_ROOT);
|
|
33
45
|
migrateLegacyLayout(PROJECT_ROOT);
|
|
34
46
|
|
|
47
|
+
// get_file_skeleton parses on demand at query time — grammars must be resolved
|
|
48
|
+
// (and, outside sealed mode, installed if this server is starting before the
|
|
49
|
+
// first `idx-index` run) before the transport connects and starts serving tools.
|
|
50
|
+
await ensureLanguagesReady({
|
|
51
|
+
projectRoot: PROJECT_ROOT,
|
|
52
|
+
enabledLangs: config.languages,
|
|
53
|
+
autoInstall: config.sealed === 'off',
|
|
54
|
+
log: (msg) => process.stderr.write(msg + '\n'),
|
|
55
|
+
});
|
|
56
|
+
|
|
35
57
|
function readPackageVersion() {
|
|
36
58
|
try {
|
|
37
59
|
return JSON.parse(fs.readFileSync(path.join(PACKAGE_DIR, 'package.json'), 'utf-8')).version || '0.0.0';
|
|
@@ -166,6 +188,8 @@ registerTools(server, db, {
|
|
|
166
188
|
mlxLmHost: config.mlxLmHost,
|
|
167
189
|
gitSignals,
|
|
168
190
|
gitRankBoost: config.gitRankBoost,
|
|
191
|
+
sealed: config.sealed,
|
|
192
|
+
ranker: config.ranker,
|
|
169
193
|
});
|
|
170
194
|
|
|
171
195
|
const transport = new StdioServerTransport();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "graph-indexer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Air-gapped code-retrieval MCP server: AST chunking, hybrid RRF search and cross-language call graph, with an optional zero-dependency SQLite backend for monorepo scale and optional local-LLM semantic enrichment.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "engine/memory.mjs",
|
|
@@ -43,14 +43,21 @@
|
|
|
43
43
|
"test:mcp": "node test/mcp.mjs",
|
|
44
44
|
"test:scale": "node test/scale.mjs",
|
|
45
45
|
"test:callgraph": "node test/callgraph.mjs",
|
|
46
|
+
"test:edges": "node test/edges.mjs",
|
|
46
47
|
"test:references": "node test/references.mjs",
|
|
47
48
|
"test:routes": "node test/routes.mjs",
|
|
48
49
|
"test:jsonout": "node test/json-output.mjs",
|
|
49
50
|
"test:gitsignals": "node test/git-signals.mjs",
|
|
50
51
|
"test:security": "node test/security.mjs",
|
|
52
|
+
"test:seal": "node test/seal.mjs",
|
|
53
|
+
"test:taint": "node test/taint.mjs",
|
|
54
|
+
"test:scip": "node test/scip.mjs",
|
|
55
|
+
"test:ranker": "node test/ranker.mjs",
|
|
56
|
+
"test:sparse": "node test/sparse.mjs",
|
|
57
|
+
"test:colbert": "node test/colbert.mjs",
|
|
51
58
|
"test:embed": "node test/embeddings.mjs",
|
|
52
59
|
"test:languages": "node test/languages.mjs",
|
|
53
|
-
"test:all": "node test/unit.mjs && node test/sqlite.mjs && node test/enrichment.mjs && node test/mcp.mjs && node test/scale.mjs && node test/callgraph.mjs && node test/references.mjs && node test/routes.mjs && node test/json-output.mjs && node test/git-signals.mjs && node test/security.mjs && node test/embeddings.mjs && node test/languages.mjs",
|
|
60
|
+
"test:all": "node test/unit.mjs && node test/sqlite.mjs && node test/enrichment.mjs && node test/mcp.mjs && node test/scale.mjs && node test/callgraph.mjs && node test/edges.mjs && node test/references.mjs && node test/routes.mjs && node test/json-output.mjs && node test/git-signals.mjs && node test/security.mjs && node test/seal.mjs && node test/taint.mjs && node test/scip.mjs && node test/ranker.mjs && node test/sparse.mjs && node test/colbert.mjs && node test/embeddings.mjs && node test/languages.mjs",
|
|
54
61
|
"test:eval": "node test/evaluate.mjs",
|
|
55
62
|
"test:eval:embeddings": "OLLAMA_HOST=http://localhost:11434 node test/evaluate.mjs --embeddings",
|
|
56
63
|
"test:setup": "node test/setup.mjs",
|
|
@@ -61,6 +68,7 @@
|
|
|
61
68
|
"test:json": "node test/run.mjs --json",
|
|
62
69
|
"test:reset": "rm -rf test/fixtures && node test/setup.mjs",
|
|
63
70
|
"embed:setup:mlx": "node embedders/setup-mlx.mjs",
|
|
71
|
+
"embed:setup:local": "npm install @huggingface/transformers",
|
|
64
72
|
"mcp:index": "idx-index --repo .",
|
|
65
73
|
"mcp:index:sqlite": "node indexer.mjs --repo . --use-sqlite",
|
|
66
74
|
"mcp:index:enriched": "node indexer.mjs --repo . --enrichment",
|
|
@@ -80,6 +88,25 @@
|
|
|
80
88
|
"tree-sitter": "0.21.1",
|
|
81
89
|
"zod": "^3.25.76"
|
|
82
90
|
},
|
|
91
|
+
"devDependencies": {
|
|
92
|
+
"@huggingface/transformers": "3.8.1",
|
|
93
|
+
"hnswlib-node": "3.0.0",
|
|
94
|
+
"tree-sitter-bash": "0.23.3",
|
|
95
|
+
"tree-sitter-c": "0.21.4",
|
|
96
|
+
"tree-sitter-c-sharp": "0.21.3",
|
|
97
|
+
"tree-sitter-css": "0.21.1",
|
|
98
|
+
"tree-sitter-go": "0.21.0",
|
|
99
|
+
"tree-sitter-java": "0.21.0",
|
|
100
|
+
"tree-sitter-javascript": "0.21.4",
|
|
101
|
+
"tree-sitter-kotlin": "0.3.8",
|
|
102
|
+
"tree-sitter-php": "0.23.1",
|
|
103
|
+
"tree-sitter-python": "0.21.0",
|
|
104
|
+
"tree-sitter-ruby": "0.23.1",
|
|
105
|
+
"tree-sitter-rust": "0.21.0",
|
|
106
|
+
"tree-sitter-scss": "1.0.0",
|
|
107
|
+
"tree-sitter-swift": "0.5.0",
|
|
108
|
+
"tree-sitter-typescript": "0.21.2"
|
|
109
|
+
},
|
|
83
110
|
"keywords": [
|
|
84
111
|
"mcp",
|
|
85
112
|
"model-context-protocol",
|
|
@@ -111,24 +138,5 @@
|
|
|
111
138
|
},
|
|
112
139
|
"publishConfig": {
|
|
113
140
|
"access": "public"
|
|
114
|
-
},
|
|
115
|
-
"optionalDependencies": {
|
|
116
|
-
"@huggingface/transformers": "3.8.1",
|
|
117
|
-
"hnswlib-node": "3.0.0",
|
|
118
|
-
"tree-sitter-bash": "0.23.3",
|
|
119
|
-
"tree-sitter-c": "0.21.4",
|
|
120
|
-
"tree-sitter-c-sharp": "0.21.3",
|
|
121
|
-
"tree-sitter-css": "0.21.1",
|
|
122
|
-
"tree-sitter-go": "0.21.0",
|
|
123
|
-
"tree-sitter-java": "0.21.0",
|
|
124
|
-
"tree-sitter-javascript": "0.21.4",
|
|
125
|
-
"tree-sitter-kotlin": "0.3.8",
|
|
126
|
-
"tree-sitter-php": "0.23.1",
|
|
127
|
-
"tree-sitter-python": "0.21.0",
|
|
128
|
-
"tree-sitter-ruby": "0.23.1",
|
|
129
|
-
"tree-sitter-rust": "0.21.0",
|
|
130
|
-
"tree-sitter-scss": "1.0.0",
|
|
131
|
-
"tree-sitter-swift": "0.5.0",
|
|
132
|
-
"tree-sitter-typescript": "0.21.2"
|
|
133
141
|
}
|
|
134
142
|
}
|
package/parse/extractor.mjs
CHANGED
|
@@ -16,12 +16,14 @@ const { Query } = Parser;
|
|
|
16
16
|
import { getParserForFile, LANGUAGE_MAP, EXTENSIONS } from './languages.mjs';
|
|
17
17
|
import {
|
|
18
18
|
extractParams, extractReturnType, extractClassContext,
|
|
19
|
-
extractDecorators, extractHeritage, extractTypeAnnotations, extractCallSites,
|
|
19
|
+
extractDecorators, extractHeritage, extractTypeAnnotations, extractCallSites, extractReturnVia,
|
|
20
20
|
} from './metadata.mjs';
|
|
21
21
|
|
|
22
22
|
export const MAX_FILE_SIZE_BYTES = 500000;
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
// Evaluated lazily (not at module load) — LANGUAGE_MAP populates asynchronously
|
|
25
|
+
// via ensureLanguagesReady(), which callers await before extractSemanticChunks runs.
|
|
26
|
+
function _hasSCSS() { return Boolean(LANGUAGE_MAP['.scss']) && LANGUAGE_MAP['.scss'] !== LANGUAGE_MAP['.css']; }
|
|
25
27
|
|
|
26
28
|
const LANGUAGE_QUERIES = {
|
|
27
29
|
ts: `
|
|
@@ -330,7 +332,7 @@ function _cDeclaratorName(node) {
|
|
|
330
332
|
return null;
|
|
331
333
|
}
|
|
332
334
|
|
|
333
|
-
export function extractSemanticChunks(rootNode, relPath, sourceCode, ext) {
|
|
335
|
+
export function extractSemanticChunks(rootNode, relPath, sourceCode, ext, { interprocedural = false } = {}) {
|
|
334
336
|
const chunks = [];
|
|
335
337
|
const parser = getParserForFile(ext);
|
|
336
338
|
if (!parser) return chunks;
|
|
@@ -342,7 +344,7 @@ export function extractSemanticChunks(rootNode, relPath, sourceCode, ext) {
|
|
|
342
344
|
// .scss uses the SCSS grammar+query when installed; otherwise it parses
|
|
343
345
|
// with the CSS grammar, so it MUST use the css query (the scss query
|
|
344
346
|
// references node types the CSS grammar doesn't have).
|
|
345
|
-
'.scss': _hasSCSS ? 'scss' : 'css',
|
|
347
|
+
'.scss': _hasSCSS() ? 'scss' : 'css',
|
|
346
348
|
// C headers share the C grammar/query; .sh and .bash share the bash query.
|
|
347
349
|
// (.c and .swift resolve via the ext.slice(1) fallback below.)
|
|
348
350
|
'.h': 'c', '.sh': 'bash', '.bash': 'bash',
|
|
@@ -567,14 +569,23 @@ export function extractSemanticChunks(rootNode, relPath, sourceCode, ext) {
|
|
|
567
569
|
.update(`${relPath}::${chunkNode.startPosition.row}::${chunkNode.startPosition.column}`)
|
|
568
570
|
.digest('hex').slice(0, 24);
|
|
569
571
|
|
|
570
|
-
|
|
572
|
+
const chunk = {
|
|
571
573
|
id, file_path: relPath, node_type: chunkNode.type, name: nameText,
|
|
572
574
|
docstring: docstring, code_snippet: snippet, content_hash: hash,
|
|
573
575
|
start_line: chunkNode.startPosition.row + 1, end_line: chunkNode.endPosition.row + 1,
|
|
574
576
|
calls: outgoingCalls, call_sites: callSites,
|
|
575
577
|
params, return_type: returnType, class_context: classContext,
|
|
576
578
|
type_refs: typeRefs, decorators, extends: heritage,
|
|
577
|
-
}
|
|
579
|
+
};
|
|
580
|
+
// Opt-in inter-procedural fixpoint: capture what this function returns so the
|
|
581
|
+
// indexer can propagate return types along factory chains. Transient — stripped
|
|
582
|
+
// before serialization (parse/interprocedural.mjs::applyInterprocedural). Off by
|
|
583
|
+
// default → the chunk is byte-identical to before.
|
|
584
|
+
if (interprocedural) {
|
|
585
|
+
const rv = extractReturnVia(chunkNode);
|
|
586
|
+
if (rv) chunk._return_via = rv;
|
|
587
|
+
}
|
|
588
|
+
chunks.push(chunk);
|
|
578
589
|
}
|
|
579
590
|
} catch (e) {
|
|
580
591
|
process.stderr.write(`\n[parse/extractor] 💥 Query Error in ${relPath}: ${e.message}\n`);
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file parse/interprocedural.mjs
|
|
3
|
+
* @description Bounded, deterministic INTER-procedural return-type fixpoint (opt-in).
|
|
4
|
+
*
|
|
5
|
+
* The intra-procedural inference in parse/metadata.mjs already resolves a
|
|
6
|
+
* call-site receiver one hop: `const r = makeRepo(); r.save()` records
|
|
7
|
+
* `recv_via_call = 'makeRepo'`, and classifyCallers reads makeRepo's recorded
|
|
8
|
+
* `return_type` at query time. That misses MULTI-hop factory chains and
|
|
9
|
+
* unannotated factories: `makeRepoIndirect()` returns `makeRepo()` returns
|
|
10
|
+
* `new OrderRepo()` — the indirect factory has no return type to read.
|
|
11
|
+
*
|
|
12
|
+
* This pass propagates return types along factory return-call edges with a
|
|
13
|
+
* monotone worklist fixpoint, so `makeRepoIndirect` resolves transitively to
|
|
14
|
+
* `OrderRepo`. It runs ONCE at index time over all chunks and writes the result
|
|
15
|
+
* into each call site as `recv_resolved_type`; because call_sites is serialized
|
|
16
|
+
* identically by both backends, the resolved data is parity-free. classifyCallers
|
|
17
|
+
* prefers `recv_resolved_type` and keeps the 1-hop `recv_via_call` fallback, so an
|
|
18
|
+
* index built without this pass (or a per-file daemon update) is never worse.
|
|
19
|
+
*
|
|
20
|
+
* Determinism: iteration is over SORTED symbol/edge keys and the lattice is
|
|
21
|
+
* monotone (each step only adds the single concrete type or transitions to an
|
|
22
|
+
* absorbing CONFLICT), so the result is independent of map insertion order — a
|
|
23
|
+
* hard requirement for memory↔sqlite parity.
|
|
24
|
+
* @author MaquinaTech <https://github.com/MaquinaTech>
|
|
25
|
+
* @copyright (c) 2026 MaquinaTech. All rights reserved.
|
|
26
|
+
* @license MIT
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
// Hard safety net against pathological/cyclic graphs. Each iteration propagates types one
|
|
30
|
+
// extra hop; real factory chains are 1–3 hops, so the `changed` flag normally converges well
|
|
31
|
+
// before this — the bound only guarantees termination, it is not the expected stop condition.
|
|
32
|
+
const MAX_ITERS = 8;
|
|
33
|
+
|
|
34
|
+
// Absorbing "two distinct concrete types" state — contributes nothing (conservative DROP,
|
|
35
|
+
// mirroring _inferLocalBindings' conflict handling in parse/metadata.mjs).
|
|
36
|
+
const CONFLICT = Symbol('conflict');
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The single concrete type NAME a string denotes, or null when it is not exactly one
|
|
40
|
+
* type identifier (generics / unions / primitives are ambiguous → conservatively dropped).
|
|
41
|
+
* 'OrderRepo' → 'OrderRepo'; 'Promise<Order>' → null; 'number' → null.
|
|
42
|
+
*/
|
|
43
|
+
function singleTypeName(str) {
|
|
44
|
+
if (!str) return null;
|
|
45
|
+
const ids = String(str).match(/[A-Za-z_$][A-Za-z0-9_$]*/g) || [];
|
|
46
|
+
const types = ids.filter(t => /^[A-Z]/.test(t)); // PascalCase = a type (matches _typeMatchTokens)
|
|
47
|
+
return types.length === 1 ? types[0] : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve, per function symbol (lowercased name), the single concrete return TYPE it
|
|
52
|
+
* yields — directly (annotation or `return new X()`) or transitively through factory
|
|
53
|
+
* return-call edges. Symbols whose chain yields ≥2 distinct types (or stays unresolved)
|
|
54
|
+
* are omitted.
|
|
55
|
+
*
|
|
56
|
+
* @param {object[]} chunks Chunks carrying `name`, `return_type`, and the transient
|
|
57
|
+
* `_return_via` ({type}|{viaCall}) from extractReturnVia.
|
|
58
|
+
* @returns {Map<string,string>} nameLower → resolved type name.
|
|
59
|
+
*/
|
|
60
|
+
export function resolveReturnTypes(chunks) {
|
|
61
|
+
const symRet = new Map(); // nameLower → typeName | CONFLICT
|
|
62
|
+
const edges = new Map(); // nameLower → Set<calleeLower> (only for symbols with no direct type)
|
|
63
|
+
|
|
64
|
+
const seed = (nm, type) => {
|
|
65
|
+
const prev = symRet.get(nm);
|
|
66
|
+
if (prev === CONFLICT) return;
|
|
67
|
+
if (prev === undefined) symRet.set(nm, type);
|
|
68
|
+
else if (prev !== type) symRet.set(nm, CONFLICT);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
for (const c of chunks) {
|
|
72
|
+
if (!c.name || c.name === 'anonymous') continue;
|
|
73
|
+
const nm = c.name.toLowerCase();
|
|
74
|
+
const via = c._return_via;
|
|
75
|
+
const annot = singleTypeName(c.return_type);
|
|
76
|
+
// Direct type wins over the edge: annotation and/or `return new X()`.
|
|
77
|
+
let directType = annot || (via && via.type) || null;
|
|
78
|
+
if (annot && via && via.type && annot !== via.type) { symRet.set(nm, CONFLICT); directType = null; }
|
|
79
|
+
if (directType) {
|
|
80
|
+
seed(nm, directType);
|
|
81
|
+
} else if (via && via.viaCall) {
|
|
82
|
+
const callee = via.viaCall.toLowerCase();
|
|
83
|
+
if (!edges.has(nm)) edges.set(nm, new Set());
|
|
84
|
+
edges.get(nm).add(callee);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const sortedEdgeKeys = [...edges.keys()].sort();
|
|
89
|
+
for (let iter = 0; iter < MAX_ITERS; iter++) {
|
|
90
|
+
let changed = false;
|
|
91
|
+
for (const sym of sortedEdgeKeys) {
|
|
92
|
+
if (symRet.has(sym)) continue; // already resolved (or conflict)
|
|
93
|
+
let resolved = null, conflict = false;
|
|
94
|
+
for (const callee of [...edges.get(sym)].sort()) {
|
|
95
|
+
const ct = symRet.get(callee);
|
|
96
|
+
if (ct === undefined || ct === CONFLICT) continue;
|
|
97
|
+
if (resolved === null) resolved = ct;
|
|
98
|
+
else if (resolved !== ct) conflict = true;
|
|
99
|
+
}
|
|
100
|
+
if (conflict) { symRet.set(sym, CONFLICT); changed = true; }
|
|
101
|
+
else if (resolved !== null) { symRet.set(sym, resolved); changed = true; }
|
|
102
|
+
}
|
|
103
|
+
if (!changed) break;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const out = new Map();
|
|
107
|
+
for (const [nm, t] of symRet) if (t !== CONFLICT && typeof t === 'string') out.set(nm, t);
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Run the fixpoint over a chunk list and annotate each factory-receiver call site with the
|
|
113
|
+
* resolved `recv_resolved_type`; strip the transient `_return_via` so it never serializes.
|
|
114
|
+
* Mutates chunks in place and returns them.
|
|
115
|
+
*
|
|
116
|
+
* @param {object[]} chunks
|
|
117
|
+
* @returns {object[]} the same array
|
|
118
|
+
*/
|
|
119
|
+
export function applyInterprocedural(chunks) {
|
|
120
|
+
const symRet = resolveReturnTypes(chunks);
|
|
121
|
+
for (const c of chunks) {
|
|
122
|
+
if (Array.isArray(c.call_sites)) {
|
|
123
|
+
for (const s of c.call_sites) {
|
|
124
|
+
// Only fill in what intra-procedural inference left unresolved.
|
|
125
|
+
if (s && !s.recv_type && s.recv_via_call) {
|
|
126
|
+
const t = symRet.get(String(s.recv_via_call).toLowerCase());
|
|
127
|
+
if (t) s.recv_resolved_type = t;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if ('_return_via' in c) delete c._return_via;
|
|
132
|
+
}
|
|
133
|
+
return chunks;
|
|
134
|
+
}
|