diff --git a/gitnexus/src/core/ingestion/class-extractors/configs/ruby.ts b/gitnexus/src/core/ingestion/class-extractors/configs/ruby.ts index 13f1fdd43..bc57d8926 100644 --- a/gitnexus/src/core/ingestion/class-extractors/configs/ruby.ts +++ b/gitnexus/src/core/ingestion/class-extractors/configs/ruby.ts @@ -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; + }, }; diff --git a/gitnexus/src/core/ingestion/languages/ruby.ts b/gitnexus/src/core/ingestion/languages/ruby.ts index 6b2a4b59d..63bd9b3a6 100644 --- a/gitnexus/src/core/ingestion/languages/ruby.ts +++ b/gitnexus/src/core/ingestion/languages/ruby.ts @@ -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 = 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({ diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index fbde18729..efc733844 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index 3af6d29f4..f78ba4f64 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -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) { diff --git a/gitnexus/test/integration/ruby-factory-block-owner.test.ts b/gitnexus/test/integration/ruby-factory-block-owner.test.ts new file mode 100644 index 000000000..2140ab6a6 --- /dev/null +++ b/gitnexus/test/integration/ruby-factory-block-owner.test.ts @@ -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]); + }); +});