From d786e692afe79ebd023eb4f3b65a1d01039895c1 Mon Sep 17 00:00:00 2001 From: Deepak Chauhan <91007260+ideepakchauhan7@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:42:52 +0530 Subject: [PATCH] [cli] Preserve Ruby singleton_class context in sequential parsing (#774) * fix(parsing): preserve ruby singleton class context * refactor(parsing): clarify singleton class helpers --- .../src/core/ingestion/parsing-processor.ts | 48 +++++++++++++------ .../test/integration/resolvers/ruby.test.ts | 27 +++++++++++ 2 files changed, 60 insertions(+), 15 deletions(-) diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 9cf8394fe..6eba41869 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -242,9 +242,14 @@ function seqFindEnclosingClassNode(node: SyntaxNode): SyntaxNode | null { let current = node.parent; while (current) { if (CLASS_CONTAINER_TYPES.has(current.type)) { - // Return singleton_class directly so the method extractor sees it as - // the owner node and correctly marks methods as static. Name resolution - // for qualified names is handled separately by findEnclosingClassInfo. + // Ruby singleton_class (class << self) has no name field, so owner/class + // resolution should skip it and return the enclosing class/module instead. + // A file-root `class << self` has no enclosing class/module, so this + // intentionally falls through to null rather than synthesizing an owner. + if (current.type === 'singleton_class') { + current = current.parent; + continue; + } return current; } current = current.parent; @@ -252,11 +257,22 @@ function seqFindEnclosingClassNode(node: SyntaxNode): SyntaxNode | null { return null; } -/** Minimal no-op SymbolTable stub for FieldExtractorContext (sequential - * path has a real SymbolTable, but it's incomplete at this stage — use - * the stub for safety). Implements the full {@link SymbolTableReader} - * surface so future extractor additions don't silently fall off an - * `as unknown as` cast. */ +/** Raw enclosing container lookup for extractor-only context. + * Unlike seqFindEnclosingClassNode(), this intentionally returns + * `singleton_class` so Ruby `class << self` methods preserve static context. */ +function seqFindRawEnclosingContainerNode(node: SyntaxNode): SyntaxNode | null { + let current = node.parent; + while (current) { + if (CLASS_CONTAINER_TYPES.has(current.type)) return current; + current = current.parent; + } + return null; +} + +/** Minimal no-op SymbolTable stub for sequential extractor contexts. The real + * SymbolTable is not fully populated yet at this stage, so use the stub for safety. + * Implements the full {@link SymbolTableReader} surface so future extractor additions + * don't silently fall off an `as unknown as` cast. */ const NOOP_SYMBOL_TABLE_SEQ: SymbolTableReader = { lookupExact: () => undefined, lookupExactFull: () => undefined, @@ -446,22 +462,24 @@ const processParsingSequential = async ( let enriched = false; if (provider.methodExtractor) { - // Try class-based extraction (method inside a class/struct/trait body) - const classNode = seqFindEnclosingClassNode(definitionNode); - if (classNode) { + // Try class-based extraction (method inside a class/struct/trait body). + // Ruby `class << self` needs the singleton_class node for `isStatic`, + // while owner/class resolution still skips it elsewhere. + const methodOwnerNode = seqFindRawEnclosingContainerNode(definitionNode); + if (methodOwnerNode) { // Cache extract() results per class node to avoid re-traversing the // same class body for every method it contains (O(N) -> O(1) per hit). let result: | { ownerName: string | undefined; methods: MethodInfo[] } | null - | undefined = seqMethodExtractCache.get(classNode.id); + | undefined = seqMethodExtractCache.get(methodOwnerNode.id); if (result === undefined) { result = - provider.methodExtractor.extract(classNode, { + provider.methodExtractor.extract(methodOwnerNode, { filePath: file.path, language, }) ?? null; - seqMethodExtractCache.set(classNode.id, result); + seqMethodExtractCache.set(methodOwnerNode.id, result); } if (result?.methods?.length) { const defLine = definitionNode.startPosition.row + 1; @@ -472,7 +490,7 @@ const processParsingSequential = async ( methodProps = buildMethodProps(info); seqDefMethodInfo = info; seqDefMethods = result.methods; - seqClassNodeId = classNode.id; + seqClassNodeId = methodOwnerNode.id; } } } diff --git a/gitnexus/test/integration/resolvers/ruby.test.ts b/gitnexus/test/integration/resolvers/ruby.test.ts index 7e508e756..8cb7a29e3 100644 --- a/gitnexus/test/integration/resolvers/ruby.test.ts +++ b/gitnexus/test/integration/resolvers/ruby.test.ts @@ -1262,6 +1262,33 @@ describe('Ruby method enrichment (visibility, isStatic, parameters)', () => { }); }); +describe('Ruby singleton_class handling via sequential path (skipWorkers)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-method-enrichment'), () => {}, { + skipWorkers: true, + }); + }, 60000); + + it('keeps Animal as the owner for class << self methods', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + expect( + hasMethod.find((e) => e.source === 'Animal' && e.target === 'from_habitat'), + ).toBeDefined(); + }); + + it('marks from_habitat as static in the sequential path', () => { + const methods = getNodesByLabelFull(result, 'Method'); + const fromHabitat = methods.find( + (m) => m.name === 'from_habitat' && m.properties.filePath?.includes('animal'), + ); + expect(fromHabitat).toBeDefined(); + expect(fromHabitat!.properties.isStatic).toBe(true); + expect(fromHabitat!.properties.parameterCount).toBe(1); + }); +}); + // --------------------------------------------------------------------------- // Overload Dispatch: methods with different arity resolve via receiver type // ---------------------------------------------------------------------------