graph-indexer 1.1.0 → 1.2.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/PROMPT.md +182 -143
- package/README.md +338 -80
- package/config.mjs +118 -0
- package/core-engine.mjs +631 -188
- package/enrichment.mjs +347 -0
- package/indexer.mjs +101 -71
- package/init.mjs +4 -2
- package/mcp-server.mjs +83 -569
- package/mcp-tools.mjs +545 -0
- package/package.json +16 -7
- package/parser-utils.mjs +319 -10
- package/search-core.mjs +416 -0
- package/sqlite-store.mjs +768 -0
- package/storage.mjs +43 -0
- package/watch-daemon.mjs +156 -67
package/parser-utils.mjs
CHANGED
|
@@ -26,6 +26,48 @@ import { truncateForEmbedding } from './core-engine.mjs';
|
|
|
26
26
|
export const MAX_FILE_SIZE_BYTES = 500000;
|
|
27
27
|
export const OLLAMA_HOST = process.env.OLLAMA_HOST || "http://localhost:11434";
|
|
28
28
|
|
|
29
|
+
// Resolves the Ollama host at call time so callers that set ollamaHost via
|
|
30
|
+
// .graph-indexer.json don't have to pass it through every call chain.
|
|
31
|
+
// Priority: caller override → OLLAMA_HOST env var → PROJECT .graph-indexer.json
|
|
32
|
+
// (MCP_PROJECT_ROOT or cwd — NOT the package directory: when graph-indexer is
|
|
33
|
+
// installed as a dependency, the user's config lives in their project root) →
|
|
34
|
+
// default. Mirrors config.mjs precedence so every entry point agrees.
|
|
35
|
+
//
|
|
36
|
+
// Note: OLLAMA_HOST in the shell is Ollama's binding address (e.g. "0.0.0.0:11435"), not an
|
|
37
|
+
// HTTP client URL. We normalise bare "host:port" strings by adding http:// and translating
|
|
38
|
+
// 0.0.0.0 → localhost so fetches work in both formats.
|
|
39
|
+
let _cachedHost = null;
|
|
40
|
+
let _cachedEmbedModel = null;
|
|
41
|
+
function _normalizeOllamaHost(raw) {
|
|
42
|
+
if (!raw) return null;
|
|
43
|
+
if (raw.startsWith('http://') || raw.startsWith('https://')) return raw;
|
|
44
|
+
return 'http://' + raw.replace(/^0\.0\.0\.0/, 'localhost');
|
|
45
|
+
}
|
|
46
|
+
function _readProjectConfig() {
|
|
47
|
+
const root = process.env.MCP_PROJECT_ROOT || process.cwd();
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(fs.readFileSync(path.join(root, '.graph-indexer.json'), 'utf8'));
|
|
50
|
+
} catch { return null; }
|
|
51
|
+
}
|
|
52
|
+
function _resolveOllamaHost(override) {
|
|
53
|
+
if (override) return _normalizeOllamaHost(override) || 'http://localhost:11434';
|
|
54
|
+
if (_cachedHost) return _cachedHost;
|
|
55
|
+
if (process.env.OLLAMA_HOST) {
|
|
56
|
+
_cachedHost = _normalizeOllamaHost(process.env.OLLAMA_HOST) || 'http://localhost:11434';
|
|
57
|
+
return _cachedHost;
|
|
58
|
+
}
|
|
59
|
+
const cfg = _readProjectConfig();
|
|
60
|
+
_cachedHost = _normalizeOllamaHost(cfg?.ollamaHost) || 'http://localhost:11434';
|
|
61
|
+
return _cachedHost;
|
|
62
|
+
}
|
|
63
|
+
function _resolveEmbedModel(override) {
|
|
64
|
+
if (override) return override;
|
|
65
|
+
if (_cachedEmbedModel) return _cachedEmbedModel;
|
|
66
|
+
const cfg = _readProjectConfig();
|
|
67
|
+
_cachedEmbedModel = cfg?.embedModel || 'nomic-embed-text';
|
|
68
|
+
return _cachedEmbedModel;
|
|
69
|
+
}
|
|
70
|
+
|
|
29
71
|
// ─── Dynamic Language Loading ─────────────────────────────────────────────────
|
|
30
72
|
|
|
31
73
|
function _loadProjectConfig() {
|
|
@@ -300,6 +342,30 @@ export function extractImportsFromAST(rootNode, ext) {
|
|
|
300
342
|
return Array.from(imports);
|
|
301
343
|
}
|
|
302
344
|
|
|
345
|
+
// ─── God-class defence ────────────────────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Token-safe skeleton for a class that exceeds GOD_CLASS_LINES.
|
|
349
|
+
*
|
|
350
|
+
* The agent's PROMPT.md promises get_chunk() costs ~300 tokens. A 2 000-line
|
|
351
|
+
* "god class" stored as one chunk violates that contract. This skeleton keeps
|
|
352
|
+
* only the first HEADER_LINES of the class (signature + opening brace) and
|
|
353
|
+
* appends a one-line summary — enough for name resolution and embeddings, while
|
|
354
|
+
* the real bodies are reachable via the individual method chunks that the
|
|
355
|
+
* god-class split produces.
|
|
356
|
+
*/
|
|
357
|
+
function buildGodClassSkeleton(classNode) {
|
|
358
|
+
const allLines = classNode.text.split('\n');
|
|
359
|
+
const HEADER_LINES = 15;
|
|
360
|
+
const header = allLines.slice(0, HEADER_LINES).join('\n');
|
|
361
|
+
return (
|
|
362
|
+
`${header}\n` +
|
|
363
|
+
` // ⚠ [Large class: ${allLines.length} lines — ` +
|
|
364
|
+
`methods are indexed as individual searchable chunks. ` +
|
|
365
|
+
`Use search_code() to find specific methods.]\n}`
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
303
369
|
export function extractSemanticChunks(rootNode, relPath, sourceCode, ext) {
|
|
304
370
|
const chunks = [];
|
|
305
371
|
const parser = getParserForFile(ext);
|
|
@@ -329,6 +395,49 @@ export function extractSemanticChunks(rootNode, relPath, sourceCode, ext) {
|
|
|
329
395
|
const matches = query.matches(rootNode);
|
|
330
396
|
const processedNodes = new Set();
|
|
331
397
|
|
|
398
|
+
// ── God-class pre-pass ─────────────────────────────────────────────────
|
|
399
|
+
// Identify class-type container nodes whose line count exceeds the threshold.
|
|
400
|
+
// Their methods are allowed through the isNested filter below so each method
|
|
401
|
+
// becomes its own independent, searchable chunk; the class node itself gets a
|
|
402
|
+
// compact skeleton instead of a truncated body dump.
|
|
403
|
+
//
|
|
404
|
+
// Cross-language: class_declaration (TS/JS/Java/C#), class_definition (Python),
|
|
405
|
+
// impl_item (Rust impl blocks), class (Ruby), object_declaration (Kotlin).
|
|
406
|
+
// TS special case: exported classes live inside export_statement — we mark the
|
|
407
|
+
// inner class_declaration so the method-isNested check works correctly.
|
|
408
|
+
const GOD_CLASS_LINES = 200;
|
|
409
|
+
const GOD_CLASS_NODE_TYPES = new Set([
|
|
410
|
+
'class_declaration', 'class_definition',
|
|
411
|
+
'impl_item', // Rust impl blocks
|
|
412
|
+
'class', // Ruby
|
|
413
|
+
'object_declaration', // Kotlin
|
|
414
|
+
]);
|
|
415
|
+
const oversizedClassIds = new Set(); // node IDs whose direct methods are un-nested
|
|
416
|
+
|
|
417
|
+
for (const match of matches) {
|
|
418
|
+
for (const capture of match.captures) {
|
|
419
|
+
if (capture.name !== 'chunk') continue;
|
|
420
|
+
const n = capture.node;
|
|
421
|
+
const nLines = n.endPosition.row - n.startPosition.row + 1;
|
|
422
|
+
if (nLines <= GOD_CLASS_LINES) continue;
|
|
423
|
+
if (GOD_CLASS_NODE_TYPES.has(n.type)) {
|
|
424
|
+
oversizedClassIds.add(n.id);
|
|
425
|
+
}
|
|
426
|
+
// TS: large export_statement wrapping a class — mark the inner class_declaration
|
|
427
|
+
// so method_definition.parent chain finds the oversized class node, not the
|
|
428
|
+
// export_statement (which isn't what the isNested walk stops at).
|
|
429
|
+
if (n.type === 'export_statement') {
|
|
430
|
+
for (let ci = 0; ci < n.namedChildCount; ci++) {
|
|
431
|
+
const c = n.namedChild(ci);
|
|
432
|
+
if (GOD_CLASS_NODE_TYPES.has(c.type)) {
|
|
433
|
+
oversizedClassIds.add(c.id);
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
332
441
|
for (const match of matches) {
|
|
333
442
|
let chunkNode = null;
|
|
334
443
|
for (const capture of match.captures) {
|
|
@@ -346,11 +455,16 @@ export function extractSemanticChunks(rootNode, relPath, sourceCode, ext) {
|
|
|
346
455
|
// Python root = 'module', JS root = 'program', Go root = 'source_file', Ruby root = 'program'.
|
|
347
456
|
// Stopping at 'program' alone was falsely marking top-level Python classes as nested
|
|
348
457
|
// because Ruby's 'module' keyword (also in CONTAINERS) shares the name with Python's root.
|
|
458
|
+
//
|
|
459
|
+
// God-class exception: if the first CONTAINERS ancestor is an oversized class, the
|
|
460
|
+
// node is a direct method of that class — allow it through as its own chunk.
|
|
349
461
|
let isNested = false;
|
|
350
462
|
let currentParent = chunkNode.parent;
|
|
351
463
|
while (currentParent && currentParent.parent !== null) {
|
|
352
464
|
if (CONTAINERS.has(currentParent.type)) {
|
|
353
|
-
|
|
465
|
+
if (!oversizedClassIds.has(currentParent.id)) {
|
|
466
|
+
isNested = true;
|
|
467
|
+
}
|
|
354
468
|
break;
|
|
355
469
|
}
|
|
356
470
|
currentParent = currentParent.parent;
|
|
@@ -400,8 +514,25 @@ export function extractSemanticChunks(rootNode, relPath, sourceCode, ext) {
|
|
|
400
514
|
} else if (assignExp) {
|
|
401
515
|
nameText = assignExp.childForFieldName?.("left")?.text || "anonymous";
|
|
402
516
|
}
|
|
517
|
+
} else if (chunkNode.type === "type_declaration") {
|
|
518
|
+
// Go: `type X struct {…}` / `type X interface {…}` / `type X = Y`.
|
|
519
|
+
// The identifier lives on the nested type_spec / type_alias node, not on
|
|
520
|
+
// the type_declaration itself, so childForFieldName("name") above is null.
|
|
521
|
+
// Without this branch every Go struct/interface collapses to the useless
|
|
522
|
+
// synthetic name `<file>_type_declaration`, making core types (e.g. Gin's
|
|
523
|
+
// RouterGroup, Engine, Context) unsearchable by name and invisible to the
|
|
524
|
+
// 2.0× name-boost in searchHybrid.
|
|
525
|
+
const spec = chunkNode.namedChildren?.find(c => c.type === "type_spec" || c.type === "type_alias");
|
|
526
|
+
nameText = spec?.childForFieldName?.("name")?.text
|
|
527
|
+
|| spec?.children?.find(c => c.type === "type_identifier")?.text
|
|
528
|
+
|| "anonymous";
|
|
403
529
|
} else {
|
|
404
|
-
|
|
530
|
+
// Generic fallback: search direct children for an identifier-like node.
|
|
531
|
+
// Includes type_identifier (Go/TS), constant (Ruby class/module names) and
|
|
532
|
+
// field_identifier so nested-name grammars don't fall through to anonymous.
|
|
533
|
+
const idNode = chunkNode.children.find(c =>
|
|
534
|
+
c.type === "identifier" || c.type === "name" || c.type === "property_identifier"
|
|
535
|
+
|| c.type === "type_identifier" || c.type === "constant" || c.type === "field_identifier");
|
|
405
536
|
nameText = idNode?.text || "anonymous";
|
|
406
537
|
}
|
|
407
538
|
|
|
@@ -421,7 +552,26 @@ export function extractSemanticChunks(rootNode, relPath, sourceCode, ext) {
|
|
|
421
552
|
docstring = `[File Context]: ${fileDocstring}`;
|
|
422
553
|
}
|
|
423
554
|
|
|
424
|
-
|
|
555
|
+
// For oversized class containers — or TS export_statements wrapping one —
|
|
556
|
+
// store a compact skeleton so get_chunk() returns a bounded token count.
|
|
557
|
+
// All metadata fields (calls, params, type_refs, decorators, extends) are
|
|
558
|
+
// still extracted from the FULL AST node, so ranking and topology are intact.
|
|
559
|
+
const isOversizedClass = oversizedClassIds.has(chunkNode.id);
|
|
560
|
+
const wrapsOversizedClass = !isOversizedClass && chunkNode.type === 'export_statement' && (() => {
|
|
561
|
+
for (let ci = 0; ci < chunkNode.namedChildCount; ci++) {
|
|
562
|
+
if (oversizedClassIds.has(chunkNode.namedChild(ci).id)) return true;
|
|
563
|
+
}
|
|
564
|
+
return false;
|
|
565
|
+
})();
|
|
566
|
+
const snippet = (isOversizedClass || wrapsOversizedClass)
|
|
567
|
+
? buildGodClassSkeleton(isOversizedClass ? chunkNode : (() => {
|
|
568
|
+
for (let ci = 0; ci < chunkNode.namedChildCount; ci++) {
|
|
569
|
+
const c = chunkNode.namedChild(ci);
|
|
570
|
+
if (oversizedClassIds.has(c.id)) return c;
|
|
571
|
+
}
|
|
572
|
+
return chunkNode;
|
|
573
|
+
})())
|
|
574
|
+
: chunkNode.text.slice(0, 3000);
|
|
425
575
|
const hash = generateChunkHash(docstring + snippet);
|
|
426
576
|
const outgoingCalls = extractCalls(chunkNode);
|
|
427
577
|
|
|
@@ -430,6 +580,8 @@ export function extractSemanticChunks(rootNode, relPath, sourceCode, ext) {
|
|
|
430
580
|
const returnType = extractReturnType(chunkNode, ext);
|
|
431
581
|
const classContext = extractClassContext(chunkNode);
|
|
432
582
|
const typeRefs = extractTypeAnnotations(chunkNode, ext);
|
|
583
|
+
const decorators = extractDecorators(chunkNode);
|
|
584
|
+
const heritage = extractHeritage(chunkNode, ext);
|
|
433
585
|
|
|
434
586
|
const id = createHash('sha256')
|
|
435
587
|
.update(`${relPath}::${chunkNode.startPosition.row}::${chunkNode.startPosition.column}`)
|
|
@@ -441,13 +593,47 @@ export function extractSemanticChunks(rootNode, relPath, sourceCode, ext) {
|
|
|
441
593
|
start_line: chunkNode.startPosition.row + 1, end_line: chunkNode.endPosition.row + 1,
|
|
442
594
|
calls: outgoingCalls,
|
|
443
595
|
params, return_type: returnType, class_context: classContext,
|
|
444
|
-
type_refs: typeRefs,
|
|
596
|
+
type_refs: typeRefs, decorators, extends: heritage,
|
|
445
597
|
});
|
|
446
598
|
}
|
|
447
599
|
} catch (e) {
|
|
448
600
|
// Visible protective log for developers
|
|
449
601
|
process.stderr.write(`\n[parser-utils] 💥 Query Error in ${relPath}: ${e.message}\n`);
|
|
450
602
|
}
|
|
603
|
+
|
|
604
|
+
// ── Python public re-exports ────────────────────────────────────────────────
|
|
605
|
+
// PEP 484 convention: `from starlette.background import BackgroundTasks as
|
|
606
|
+
// BackgroundTasks` re-exports a symbol as public API. Files like
|
|
607
|
+
// fastapi/background.py consist ONLY of such lines and previously produced
|
|
608
|
+
// zero chunks — resolve_symbol('BackgroundTasks') found nothing and agents
|
|
609
|
+
// hit a dead end. Each explicit re-export becomes a small chunk that names
|
|
610
|
+
// the symbol and points at its source module. (JS/TS barrels already chunk
|
|
611
|
+
// via the export_statement capture.)
|
|
612
|
+
if (ext === '.py') {
|
|
613
|
+
for (const node of rootNode.children) {
|
|
614
|
+
if (node.type !== 'import_from_statement') continue;
|
|
615
|
+
const moduleName = node.childForFieldName?.('module_name')?.text || '';
|
|
616
|
+
for (const child of node.children) {
|
|
617
|
+
if (child.type !== 'aliased_import') continue;
|
|
618
|
+
const orig = child.childForFieldName?.('name')?.text || '';
|
|
619
|
+
const alias = child.childForFieldName?.('alias')?.text || '';
|
|
620
|
+
if (!alias || orig.split('.').pop() !== alias) continue; // only `import X as X`
|
|
621
|
+
const snippet = node.text.slice(0, 300);
|
|
622
|
+
const docstring = `Public re-export of ${alias} from ${moduleName}.`;
|
|
623
|
+
const id = createHash('sha256')
|
|
624
|
+
.update(`${relPath}::${node.startPosition.row}::${child.startPosition.column}`)
|
|
625
|
+
.digest('hex').slice(0, 24);
|
|
626
|
+
chunks.push({
|
|
627
|
+
id, file_path: relPath, node_type: 're_export', name: alias,
|
|
628
|
+
docstring, code_snippet: snippet,
|
|
629
|
+
content_hash: generateChunkHash(docstring + snippet),
|
|
630
|
+
start_line: node.startPosition.row + 1, end_line: node.endPosition.row + 1,
|
|
631
|
+
calls: [], params: [], return_type: '', class_context: '',
|
|
632
|
+
type_refs: [], decorators: [], extends: [],
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
451
637
|
return chunks;
|
|
452
638
|
}
|
|
453
639
|
|
|
@@ -658,7 +844,16 @@ export function buildEmbeddingPayload(chunk, depRelPaths = []) {
|
|
|
658
844
|
const topologicalContext = neighbors.length
|
|
659
845
|
? `This code architectural neighborhood connects with: ${neighbors.join(', ')}.`
|
|
660
846
|
: '';
|
|
847
|
+
// NOTE: decorators and inheritance edges are NOT added here (A/B-tested: neutral
|
|
848
|
+
// on vector, regression on BM25 — surfaced as metadata only).
|
|
849
|
+
// LLM summary leads the payload when available: declarative voice aligns with
|
|
850
|
+
// nomic-embed-text's search_document: training objective and anchors the embedding
|
|
851
|
+
// toward developer query vocabulary. Questions/hyde are intentionally excluded from
|
|
852
|
+
// the vector payload — they add stopword noise and dilute the code's semantic
|
|
853
|
+
// fingerprint. Concept keywords (chunk.hyde = concepts.join(' ')) go to BM25 only
|
|
854
|
+
// via buildLexicalDocument, keeping both retrieval channels clean.
|
|
661
855
|
return [
|
|
856
|
+
chunk.summary || '', // semantic lead: LLM-generated declarative summary (opt-in)
|
|
662
857
|
`File Location: ${chunk.file_path}`,
|
|
663
858
|
`Symbol Name: ${chunk.node_type} -> ${chunk.name}`,
|
|
664
859
|
chunk.docstring ? `Developer Documentation: ${chunk.docstring}` : '',
|
|
@@ -669,15 +864,17 @@ export function buildEmbeddingPayload(chunk, depRelPaths = []) {
|
|
|
669
864
|
].filter(Boolean).join('\n');
|
|
670
865
|
}
|
|
671
866
|
|
|
672
|
-
export async function getLocalEmbedding(text, graceful = true) {
|
|
867
|
+
export async function getLocalEmbedding(text, graceful = true, { ollamaHost, model } = {}) {
|
|
673
868
|
if (process.env.INDEXER_EMBEDDINGS === 'off') return null; // lexical-only mode
|
|
869
|
+
const host = _resolveOllamaHost(ollamaHost);
|
|
870
|
+
const embedModel = _resolveEmbedModel(model);
|
|
674
871
|
const MAX_RETRIES = 3;
|
|
675
872
|
const safeText = "search_query: " + truncateForEmbedding(text);
|
|
676
873
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
677
874
|
try {
|
|
678
|
-
const res = await fetch(`${
|
|
875
|
+
const res = await fetch(`${host}/api/embeddings`, {
|
|
679
876
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
680
|
-
body: JSON.stringify({ model:
|
|
877
|
+
body: JSON.stringify({ model: embedModel, prompt: safeText }),
|
|
681
878
|
signal: AbortSignal.timeout(15000),
|
|
682
879
|
});
|
|
683
880
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
@@ -691,17 +888,19 @@ export async function getLocalEmbedding(text, graceful = true) {
|
|
|
691
888
|
return null;
|
|
692
889
|
}
|
|
693
890
|
|
|
694
|
-
export async function getLocalEmbeddingsBatch(texts, graceful = true) {
|
|
891
|
+
export async function getLocalEmbeddingsBatch(texts, graceful = true, { ollamaHost, model } = {}) {
|
|
695
892
|
if (!texts || texts.length === 0) return [];
|
|
696
893
|
if (process.env.INDEXER_EMBEDDINGS === 'off') return null; // lexical-only mode
|
|
894
|
+
const host = _resolveOllamaHost(ollamaHost);
|
|
895
|
+
const embedModel = _resolveEmbedModel(model);
|
|
697
896
|
const MAX_RETRIES = 3;
|
|
698
897
|
const safeTexts = texts.map(t => "search_document: " + (t.length > 8000 ? t.slice(0, 8000) : t));
|
|
699
898
|
|
|
700
899
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
701
900
|
try {
|
|
702
|
-
const res = await fetch(`${
|
|
901
|
+
const res = await fetch(`${host}/api/embed`, {
|
|
703
902
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
704
|
-
body: JSON.stringify({ model:
|
|
903
|
+
body: JSON.stringify({ model: embedModel, input: safeTexts }),
|
|
705
904
|
signal: AbortSignal.timeout(60000),
|
|
706
905
|
});
|
|
707
906
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
@@ -774,6 +973,116 @@ export function extractClassContext(chunkNode) {
|
|
|
774
973
|
return '';
|
|
775
974
|
}
|
|
776
975
|
|
|
976
|
+
/**
|
|
977
|
+
* Extract decorator / annotation names applied to a chunk (and, for class chunks,
|
|
978
|
+
* to the methods inside it). Decorators encode what a symbol *is* in modern
|
|
979
|
+
* frameworks — `@Controller`, `@Injectable`, `@Get`, `@Entity` (TS: NestJS,
|
|
980
|
+
* Angular, TypeORM) and `@app.route`, `@pytest.fixture`, `@dataclass`,
|
|
981
|
+
* `@property` (Python) — yet as raw snippet text they are diluted to a single
|
|
982
|
+
* low-weight token inside a large class body. Surfacing them as a dedicated field
|
|
983
|
+
* lets a class annotated `@Controller` be retrieved by "controller" and a method
|
|
984
|
+
* annotated `@Get` by "get/route", independent of language.
|
|
985
|
+
*
|
|
986
|
+
* Generalises by node type only (the tree-sitter `decorator` node is shared across
|
|
987
|
+
* TS/JS/Python grammars) — no framework-specific names are hardcoded. Callee
|
|
988
|
+
* arguments are stripped: `@Controller('cats')` -> 'Controller',
|
|
989
|
+
* `@app.route('/x')` -> 'app.route', `@UseGuards(AuthGuard)` -> 'UseGuards'.
|
|
990
|
+
*
|
|
991
|
+
* @returns {string[]} unique decorator callee names (max 24)
|
|
992
|
+
*/
|
|
993
|
+
export function extractDecorators(chunkNode) {
|
|
994
|
+
const names = new Set();
|
|
995
|
+
const addDecorator = (decoNode) => {
|
|
996
|
+
let t = (decoNode.text || '').trim().replace(/^@/, '');
|
|
997
|
+
t = t.split('(')[0]; // drop call arguments: @Get(':id') -> Get
|
|
998
|
+
t = t.split(/[\s\n{]/)[0].trim(); // first token only
|
|
999
|
+
if (t && t.length <= 64) names.add(t);
|
|
1000
|
+
};
|
|
1001
|
+
|
|
1002
|
+
// (1) Decorators that PRECEDE the chunk as siblings. Python wraps a decorated
|
|
1003
|
+
// symbol in `decorated_definition` ([decorator…, def]); some grammars place
|
|
1004
|
+
// class decorators as leading siblings rather than children.
|
|
1005
|
+
let prev = chunkNode.previousSibling;
|
|
1006
|
+
while (prev) {
|
|
1007
|
+
if (prev.type === 'decorator') addDecorator(prev);
|
|
1008
|
+
else if (prev.isNamed && prev.type !== 'comment') break;
|
|
1009
|
+
prev = prev.previousSibling;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// (2) Decorators within the chunk's subtree. A captured TS class chunk (the
|
|
1013
|
+
// enclosing export_statement / class_declaration) carries its own class
|
|
1014
|
+
// decorators plus the @Get/@Post/@Inject decorators on its methods.
|
|
1015
|
+
// Bounded traversal so a large class body cannot inflate indexing time.
|
|
1016
|
+
let budget = 800;
|
|
1017
|
+
const stack = [chunkNode];
|
|
1018
|
+
while (stack.length && budget-- > 0) {
|
|
1019
|
+
const n = stack.pop();
|
|
1020
|
+
for (let i = 0; i < n.namedChildCount; i++) {
|
|
1021
|
+
const child = n.namedChild(i);
|
|
1022
|
+
if (child.type === 'decorator') addDecorator(child);
|
|
1023
|
+
else stack.push(child);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
return Array.from(names).slice(0, 24);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* Extract the base classes and implemented interfaces of a class chunk — the
|
|
1032
|
+
* inheritance edge that links a concept to its implementations
|
|
1033
|
+
* (`class ValidationPipe extends BasePipe implements PipeTransform`). Surfacing
|
|
1034
|
+
* this lets an agent move from an abstract type to the concrete classes that
|
|
1035
|
+
* realise it, and feeds the semantic embedding so an implementation is retrievable
|
|
1036
|
+
* by the interface it fulfils.
|
|
1037
|
+
*
|
|
1038
|
+
* Generalises across `extends`/`implements` (TS/JS) and base-class argument lists
|
|
1039
|
+
* (Python) by node type. Returns parent type names, e.g.
|
|
1040
|
+
* ['BasePipe', 'PipeTransform', 'OnInit'].
|
|
1041
|
+
*
|
|
1042
|
+
* @returns {string[]} base/interface names (max 12)
|
|
1043
|
+
*/
|
|
1044
|
+
export function extractHeritage(chunkNode, ext) {
|
|
1045
|
+
const bases = new Set();
|
|
1046
|
+
const JS_LIKE = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'];
|
|
1047
|
+
|
|
1048
|
+
const addTypeNames = (node) => {
|
|
1049
|
+
const stack = [node];
|
|
1050
|
+
let budget = 200;
|
|
1051
|
+
while (stack.length && budget-- > 0) {
|
|
1052
|
+
const n = stack.pop();
|
|
1053
|
+
if ((n.type === 'type_identifier' || n.type === 'identifier') && /^[A-Za-z_$]/.test(n.text)) {
|
|
1054
|
+
bases.add(n.text);
|
|
1055
|
+
}
|
|
1056
|
+
for (let i = 0; i < n.namedChildCount; i++) stack.push(n.namedChild(i));
|
|
1057
|
+
}
|
|
1058
|
+
};
|
|
1059
|
+
|
|
1060
|
+
if (JS_LIKE.includes(ext)) {
|
|
1061
|
+
// Walk the chunk subtree for extends/implements clauses, but never descend
|
|
1062
|
+
// into class_body — base types of the class only, not its method internals.
|
|
1063
|
+
const stack = [chunkNode];
|
|
1064
|
+
let budget = 400;
|
|
1065
|
+
while (stack.length && budget-- > 0) {
|
|
1066
|
+
const n = stack.pop();
|
|
1067
|
+
if (n.type === 'extends_clause' || n.type === 'implements_clause') { addTypeNames(n); continue; }
|
|
1068
|
+
for (let i = 0; i < n.namedChildCount; i++) {
|
|
1069
|
+
const c = n.namedChild(i);
|
|
1070
|
+
if (c.type !== 'class_body' && c.type !== 'statement_block') stack.push(c);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
} else if (ext === '.py') {
|
|
1074
|
+
const sc = chunkNode.childForFieldName?.('superclasses');
|
|
1075
|
+
if (sc) {
|
|
1076
|
+
for (let i = 0; i < sc.namedChildCount; i++) {
|
|
1077
|
+
const c = sc.namedChild(i);
|
|
1078
|
+
// skip keyword args like metaclass=… (keyword_argument node)
|
|
1079
|
+
if (c.type === 'identifier' || c.type === 'attribute') bases.add(c.text);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
return Array.from(bases).slice(0, 12);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
777
1086
|
/**
|
|
778
1087
|
* Frontier 2: Extract TypeScript/Python type annotation names from a chunk node.
|
|
779
1088
|
* Returns simple type names (e.g. ['User', 'AuthToken', 'PaymentService'])
|