graph-indexer 1.0.3 → 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/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
- isNested = true;
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
- const idNode = chunkNode.children.find(c => c.type === "identifier" || c.type === "name" || c.type === "property_identifier");
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
- const snippet = chunkNode.text.slice(0, 3000);
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
 
@@ -637,15 +823,58 @@ export function resolveLocalImports(rawImports, fromFileRelPath, projectRoot) {
637
823
  return resolved;
638
824
  }
639
825
 
640
- export async function getLocalEmbedding(text, graceful = true) {
826
+ /**
827
+ * Build the text payload sent to the embedding model for a chunk.
828
+ *
829
+ * Shared by the bootstrap indexer (indexer.mjs) and the watch daemon
830
+ * (watch-daemon.mjs) so a chunk yields the SAME embedding regardless of which
831
+ * path embedded it first. This matters because the cache key (content_hash) is
832
+ * derived from code + docstring only — it does NOT include this payload — so two
833
+ * divergent payloads for the same hash would silently produce inconsistent
834
+ * embeddings across a full re-index vs. an incremental update.
835
+ *
836
+ * @param {object} chunk Semantic chunk: { file_path, node_type, name, docstring, type_refs, code_snippet }.
837
+ * @param {string[]} depRelPaths Resolved local imports of the chunk's file (project-relative paths).
838
+ * @returns {string}
839
+ */
840
+ export function buildEmbeddingPayload(chunk, depRelPaths = []) {
841
+ const neighbors = depRelPaths
842
+ .map(d => path.basename(d, path.extname(d)))
843
+ .filter(Boolean);
844
+ const topologicalContext = neighbors.length
845
+ ? `This code architectural neighborhood connects with: ${neighbors.join(', ')}.`
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.
855
+ return [
856
+ chunk.summary || '', // semantic lead: LLM-generated declarative summary (opt-in)
857
+ `File Location: ${chunk.file_path}`,
858
+ `Symbol Name: ${chunk.node_type} -> ${chunk.name}`,
859
+ chunk.docstring ? `Developer Documentation: ${chunk.docstring}` : '',
860
+ chunk.type_refs?.length ? `Type References: ${chunk.type_refs.join(', ')}` : '',
861
+ topologicalContext,
862
+ `--- Source Code ---`,
863
+ chunk.code_snippet,
864
+ ].filter(Boolean).join('\n');
865
+ }
866
+
867
+ export async function getLocalEmbedding(text, graceful = true, { ollamaHost, model } = {}) {
641
868
  if (process.env.INDEXER_EMBEDDINGS === 'off') return null; // lexical-only mode
869
+ const host = _resolveOllamaHost(ollamaHost);
870
+ const embedModel = _resolveEmbedModel(model);
642
871
  const MAX_RETRIES = 3;
643
872
  const safeText = "search_query: " + truncateForEmbedding(text);
644
873
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
645
874
  try {
646
- const res = await fetch(`${OLLAMA_HOST}/api/embeddings`, {
875
+ const res = await fetch(`${host}/api/embeddings`, {
647
876
  method: "POST", headers: { "Content-Type": "application/json" },
648
- body: JSON.stringify({ model: "nomic-embed-text", prompt: safeText }),
877
+ body: JSON.stringify({ model: embedModel, prompt: safeText }),
649
878
  signal: AbortSignal.timeout(15000),
650
879
  });
651
880
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -659,17 +888,19 @@ export async function getLocalEmbedding(text, graceful = true) {
659
888
  return null;
660
889
  }
661
890
 
662
- export async function getLocalEmbeddingsBatch(texts, graceful = true) {
891
+ export async function getLocalEmbeddingsBatch(texts, graceful = true, { ollamaHost, model } = {}) {
663
892
  if (!texts || texts.length === 0) return [];
664
893
  if (process.env.INDEXER_EMBEDDINGS === 'off') return null; // lexical-only mode
894
+ const host = _resolveOllamaHost(ollamaHost);
895
+ const embedModel = _resolveEmbedModel(model);
665
896
  const MAX_RETRIES = 3;
666
897
  const safeTexts = texts.map(t => "search_document: " + (t.length > 8000 ? t.slice(0, 8000) : t));
667
898
 
668
899
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
669
900
  try {
670
- const res = await fetch(`${OLLAMA_HOST}/api/embed`, {
901
+ const res = await fetch(`${host}/api/embed`, {
671
902
  method: "POST", headers: { "Content-Type": "application/json" },
672
- body: JSON.stringify({ model: "nomic-embed-text", input: safeTexts }),
903
+ body: JSON.stringify({ model: embedModel, input: safeTexts }),
673
904
  signal: AbortSignal.timeout(60000),
674
905
  });
675
906
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -742,6 +973,116 @@ export function extractClassContext(chunkNode) {
742
973
  return '';
743
974
  }
744
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
+
745
1086
  /**
746
1087
  * Frontier 2: Extract TypeScript/Python type annotation names from a chunk node.
747
1088
  * Returns simple type names (e.g. ['User', 'AuthToken', 'PaymentService'])