[cli] Preserve Ruby singleton_class context in sequential parsing (#774)

* fix(parsing): preserve ruby singleton class context

* refactor(parsing): clarify singleton class helpers
This commit is contained in:
Deepak Chauhan 2026-04-13 17:42:52 +05:30 committed by GitHub
parent a6421b3b1b
commit d786e692af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 60 additions and 15 deletions

View file

@ -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;
}
}
}

View file

@ -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
// ---------------------------------------------------------------------------