fix(ruby): model block-taking class factories (#3376)

* fix(ruby): model block-taking class factories

* fix(ruby): cover braced factory blocks

* handle brace factory blocks

* fix nested Ruby factory ownership

* test(ruby): pin factory ownership by node id

* test(ruby): cover brace factory variants

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
This commit is contained in:
Parafee41 2026-09-25 19:47:25 +08:00 • committed by GitHub
parent ad5c7364e1
commit 233ca28492
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 327 additions and 5 deletions

View file

@ -1,13 +1,62 @@
// gitnexus/src/core/ingestion/class-extractors/configs/ruby.ts
import { SupportedLanguages } from 'gitnexus-shared';
import type { ClassExtractionConfig } from '../../class-types.js';
import type { ClassLikeNodeLabel, ClassExtractionConfig } from '../../class-types.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
export type RubyFactoryType = ClassLikeNodeLabel | 'Trait';
/**
* Return the type synthesized by Ruby's allowlisted class factories.
*
* `Const = Struct.new(...) do ... end` and its siblings all use the same
* assignment/call/block grammar shape as arbitrary block-taking calls, so
* the receiver and method must both be checked before treating the block as a
* type container.
*/
export const rubyFactoryType = (node: SyntaxNode): RubyFactoryType | undefined => {
if (node.type !== 'do_block' && node.type !== 'block') return undefined;
const call = node.parent;
if (call?.type !== 'call' || call.childForFieldName?.('block') !== node) return undefined;
const receiver = call.childForFieldName?.('receiver')?.text;
const method = call.childForFieldName?.('method')?.text;
if (method !== 'new' && method !== 'define') return undefined;
if (receiver === 'Struct' && method === 'new') return 'Struct';
if (receiver === 'Data' && method === 'define') return 'Class';
if (receiver === 'Class' && method === 'new') return 'Class';
if (receiver === 'Module' && method === 'new') return 'Trait';
return undefined;
};
export const rubyFactoryBindingName = (node: SyntaxNode): string | undefined => {
if (rubyFactoryType(node) === undefined) return undefined;
const assignment = node.parent?.parent;
if (
assignment?.type !== 'assignment' ||
assignment.childForFieldName?.('right') !== node.parent
) {
return undefined;
}
const left = assignment.childForFieldName?.('left');
return left?.type === 'constant' ? left.text : undefined;
};
export const rubyClassConfig: ClassExtractionConfig = {
language: SupportedLanguages.Ruby,
typeDeclarationNodes: ['class'],
ancestorScopeNodeTypes: ['module', 'class'],
typeDeclarationNodes: ['class', 'do_block', 'block'],
ancestorScopeNodeTypes: ['module', 'class', 'do_block', 'block'],
// #1978: key nested-type nodes by their fully-qualified path (Outer.Inner) so
// same-tail classes nested under different modules stay distinct.
qualifiedNodeId: true,
extractScopeSegments: (node) => {
const factoryName = rubyFactoryBindingName(node);
return factoryName ? [factoryName] : undefined;
},
extractName: (node) => rubyFactoryBindingName(node),
extractType: (node) => {
const type = rubyFactoryType(node);
return type === 'Trait' ? undefined : type;
},
};

View file

@ -10,7 +10,11 @@
import { SupportedLanguages } from 'gitnexus-shared';
import type { NodeLabel } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { rubyClassConfig } from '../class-extractors/configs/ruby.js';
import {
rubyClassConfig,
rubyFactoryBindingName,
rubyFactoryType,
} from '../class-extractors/configs/ruby.js';
import { defineLanguage } from '../language-provider.js';
import type { AstFrameworkPatternConfig } from '../language-provider.js';
import { createLeadingDocDescriptionExtractor, type SyntaxNode } from '../utils/ast-helpers.js';
@ -142,6 +146,9 @@ const BUILT_INS: ReadonlySet<string> = new Set([
* All other container types are returned as-is.
*/
const rubyResolveEnclosingOwner = (node: SyntaxNode): SyntaxNode | null => {
if (node.type === 'do_block' || node.type === 'block') {
return rubyFactoryType(node) === undefined ? null : node;
}
if (node.type === 'singleton_class') {
let ancestor = node.parent;
while (ancestor) {
@ -155,6 +162,21 @@ const rubyResolveEnclosingOwner = (node: SyntaxNode): SyntaxNode | null => {
return node; // use as-is for all other container types
};
const rubyClassExtractor = createClassExtractor(rubyClassConfig);
const rubyResolveContainerTypeOwner = (
node: SyntaxNode,
): { readonly name: string; readonly label: NodeLabel } | null => {
const name = rubyFactoryBindingName(node);
const label = rubyFactoryType(node);
if (name === undefined || label === undefined) return null;
const qualifiedName =
label === 'Trait'
? rubyClassExtractor.qualifyScopeName?.(node, name)
: rubyClassExtractor.extractQualifiedName(node, name);
return { name: qualifiedName ?? name, label };
};
export const rubyProvider = defineLanguage({
id: SupportedLanguages.Ruby,
extensions: ['.rb', '.rake', '.gemspec'],
@ -190,13 +212,14 @@ export const rubyProvider = defineLanguage({
callRouter: routeRubyCall,
callExtractor: createCallExtractor(rubyCallConfig),
resolveEnclosingOwner: rubyResolveEnclosingOwner,
resolveContainerTypeOwner: rubyResolveContainerTypeOwner,
fieldExtractor: createFieldExtractor(rubyFieldConfig),
methodExtractor: createMethodExtractor({
...rubyMethodConfig,
extractFunctionName: rubyExtractFunctionName,
}),
variableExtractor: createVariableExtractor(rubyVariableConfig),
classExtractor: createClassExtractor(rubyClassConfig),
classExtractor: rubyClassExtractor,
// ── Leading `#` comments (RDoc/YARD) → description (issue #2270). Magic
// comments and the shebang are not documentation. ──
descriptionExtractor: createLeadingDocDescriptionExtractor({

View file

@ -2227,6 +2227,80 @@ export const RUBY_QUERIES = `
(class
name: (scope_resolution) @name) @definition.class
; Ruby class-like factories. Keep this allowlist exact: arbitrary block-taking
; calls use the same AST shape and must remain ordinary lexical blocks.
((assignment
left: (constant) @name
right: (call
receiver: (constant) @_factory.receiver
method: (identifier) @_factory.method
block: (do_block) @definition.struct))
(#eq? @_factory.receiver "Struct")
(#eq? @_factory.method "new"))
((assignment
left: (constant) @name
right: (call
receiver: (constant) @_factory.receiver
method: (identifier) @_factory.method
block: (block) @definition.struct))
(#eq? @_factory.receiver "Struct")
(#eq? @_factory.method "new"))
((assignment
left: (constant) @name
right: (call
receiver: (constant) @_factory.receiver
method: (identifier) @_factory.method
block: (do_block) @definition.class))
(#eq? @_factory.receiver "Data")
(#eq? @_factory.method "define"))
((assignment
left: (constant) @name
right: (call
receiver: (constant) @_factory.receiver
method: (identifier) @_factory.method
block: (block) @definition.class))
(#eq? @_factory.receiver "Data")
(#eq? @_factory.method "define"))
((assignment
left: (constant) @name
right: (call
receiver: (constant) @_factory.receiver
method: (identifier) @_factory.method
block: (do_block) @definition.class))
(#eq? @_factory.receiver "Class")
(#eq? @_factory.method "new"))
((assignment
left: (constant) @name
right: (call
receiver: (constant) @_factory.receiver
method: (identifier) @_factory.method
block: (block) @definition.class))
(#eq? @_factory.receiver "Class")
(#eq? @_factory.method "new"))
((assignment
left: (constant) @name
right: (call
receiver: (constant) @_factory.receiver
method: (identifier) @_factory.method
block: (do_block))) @definition.module
(#eq? @_factory.receiver "Module")
(#eq? @_factory.method "new"))
((assignment
left: (constant) @name
right: (call
receiver: (constant) @_factory.receiver
method: (identifier) @_factory.method
block: (block))) @definition.module
(#eq? @_factory.receiver "Module")
(#eq? @_factory.method "new"))
; ── Instance methods ─────────────────────────────────────────────────────────
(method
name: (identifier) @name) @definition.method

View file

@ -1010,6 +1010,24 @@ export const findEnclosingClassInfo = (
};
}
}
// Ruby's class factories wrap their members in a `do_block`, which is also
// the shape of ordinary iteration/callback blocks and therefore cannot join
// the global container set. Let the Ruby hooks jointly opt an exact factory
// shape in; arbitrary blocks fall through to their lexical owner unchanged.
if (
(current.type === 'do_block' || current.type === 'block') &&
resolveEnclosingOwner !== undefined &&
resolveContainerTypeOwner !== undefined &&
resolveEnclosingOwner(current) === current
) {
const syntheticOwner = resolveContainerTypeOwner(current, filePath);
if (syntheticOwner !== null) {
return {
classId: generateId(syntheticOwner.label, `${filePath}:${syntheticOwner.name}`),
className: syntheticOwner.name,
};
}
}
if (CLASS_CONTAINER_TYPES.has(current.type)) {
// Delegate language-specific container remapping to the provider hook.
if (resolveEnclosingOwner) {

View file

@ -0,0 +1,158 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import {
getRelationships,
runPipelineFromRepo,
writeFixtureRepo,
type PipelineResult,
} from './resolvers/helpers.js';
describe('Ruby block-taking factory ownership', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-ruby-factory-owner-'));
let result: PipelineResult;
beforeAll(async () => {
writeFixtureRepo(root, {
'app.rb': `
class Outer
StructType = Struct.new(:value) do
def struct_method = value
end
DataType = Data.define(:value) do
def data_method = value
end
ClassType = Class.new do
def class_method = 1
end
ModuleType = Module.new do
def module_method = 1
end
BraceStructType = Struct.new(:value) {
def brace_struct_method = value
}
BraceDataType = Data.define(:value) {
def brace_data_method = value
}
BraceClassType = Class.new {
def brace_class_method = 1
}
BraceModuleType = Module.new {
def brace_module_method = 1
}
ArbitraryType = Builder.make do
def arbitrary_method = 1
end
module Nested
ClassType = Class.new do
def nested_class_method = 1
end
end
First = Class.new do
Item = Class.new do
def first_item_method = 1
end
end
Second = Class.new do
Item = Class.new do
def second_item_method = 1
end
end
end
`,
});
result = await runPipelineFromRepo(root, () => {}, { skipGraphPhases: true });
}, 120_000);
afterAll(() => {
fs.rmSync(root, { recursive: true, force: true });
});
it.each([
['StructType', 'Struct', 'struct_method', 'Outer.StructType'],
['DataType', 'Class', 'data_method', 'Outer.DataType'],
['ClassType', 'Class', 'class_method', 'Outer.ClassType'],
['ModuleType', 'Trait', 'module_method', 'Outer.ModuleType'],
['BraceStructType', 'Struct', 'brace_struct_method', 'Outer.BraceStructType'],
['BraceDataType', 'Class', 'brace_data_method', 'Outer.BraceDataType'],
['BraceClassType', 'Class', 'brace_class_method', 'Outer.BraceClassType'],
['BraceModuleType', 'Trait', 'brace_module_method', 'Outer.BraceModuleType'],
])(
'materializes %s as a %s and attributes its factory-block method',
(owner, ownerLabel, method, qualifiedOwner) => {
const ownerNode = result.graph.nodes.find(
(node) =>
node.label === ownerLabel &&
node.properties.name === owner &&
node.id.includes(qualifiedOwner),
);
const ownership = getRelationships(result, 'HAS_METHOD').filter(
(edge) => edge.target === method,
);
expect(ownerNode).toBeDefined();
expect(ownership.map((edge) => edge.rel.sourceId)).toEqual([ownerNode?.id]);
},
);
it('does not treat an arbitrary block-taking call as a class factory', () => {
const syntheticOwner = result.graph.nodes.find(
(node) =>
['Class', 'Struct', 'Trait'].includes(node.label) &&
node.properties.name === 'ArbitraryType',
);
const ownership = getRelationships(result, 'HAS_METHOD').filter(
(edge) => edge.target === 'arbitrary_method',
);
expect(syntheticOwner).toBeUndefined();
expect(ownership.map((edge) => edge.source)).toEqual(['Outer']);
});
it('keeps same-tail factory constants distinct across nested lexical scopes', () => {
const owners = result.graph.nodes.filter(
(node) => node.label === 'Class' && node.properties.name === 'ClassType',
);
const ownership = getRelationships(result, 'HAS_METHOD').filter(
(edge) => edge.target === 'nested_class_method',
);
expect(owners).toHaveLength(2);
expect(new Set(owners.map((node) => node.id)).size).toBe(2);
const nestedOwner = owners.find((node) => node.id.includes('Outer.Nested.ClassType'));
expect(nestedOwner).toBeDefined();
expect(ownership.map((edge) => edge.rel.sourceId)).toEqual([nestedOwner?.id]);
});
it('keeps same-tail factories distinct when nested inside factory blocks', () => {
const owners = result.graph.nodes.filter(
(node) => node.label === 'Class' && node.properties.name === 'Item',
);
const firstOwner = owners.find((node) => node.id.includes('Outer.First.Item'));
const secondOwner = owners.find((node) => node.id.includes('Outer.Second.Item'));
const firstOwnership = getRelationships(result, 'HAS_METHOD').filter(
(edge) => edge.target === 'first_item_method',
);
const secondOwnership = getRelationships(result, 'HAS_METHOD').filter(
(edge) => edge.target === 'second_item_method',
);
expect(owners).toHaveLength(2);
expect(firstOwner).toBeDefined();
expect(secondOwner).toBeDefined();
expect(firstOwnership.map((edge) => edge.rel.sourceId)).toEqual([firstOwner?.id]);
expect(secondOwnership.map((edge) => edge.rel.sourceId)).toEqual([secondOwner?.id]);
});
});