mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
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:
parent
ad5c7364e1
commit
233ca28492
5 changed files with 327 additions and 5 deletions
|
|
@ -1,13 +1,62 @@
|
||||||
// gitnexus/src/core/ingestion/class-extractors/configs/ruby.ts
|
// gitnexus/src/core/ingestion/class-extractors/configs/ruby.ts
|
||||||
|
|
||||||
import { SupportedLanguages } from 'gitnexus-shared';
|
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 = {
|
export const rubyClassConfig: ClassExtractionConfig = {
|
||||||
language: SupportedLanguages.Ruby,
|
language: SupportedLanguages.Ruby,
|
||||||
typeDeclarationNodes: ['class'],
|
typeDeclarationNodes: ['class', 'do_block', 'block'],
|
||||||
ancestorScopeNodeTypes: ['module', 'class'],
|
ancestorScopeNodeTypes: ['module', 'class', 'do_block', 'block'],
|
||||||
// #1978: key nested-type nodes by their fully-qualified path (Outer.Inner) so
|
// #1978: key nested-type nodes by their fully-qualified path (Outer.Inner) so
|
||||||
// same-tail classes nested under different modules stay distinct.
|
// same-tail classes nested under different modules stay distinct.
|
||||||
qualifiedNodeId: true,
|
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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,11 @@
|
||||||
import { SupportedLanguages } from 'gitnexus-shared';
|
import { SupportedLanguages } from 'gitnexus-shared';
|
||||||
import type { NodeLabel } from 'gitnexus-shared';
|
import type { NodeLabel } from 'gitnexus-shared';
|
||||||
import { createClassExtractor } from '../class-extractors/generic.js';
|
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 { defineLanguage } from '../language-provider.js';
|
||||||
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
||||||
import { createLeadingDocDescriptionExtractor, type SyntaxNode } from '../utils/ast-helpers.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.
|
* All other container types are returned as-is.
|
||||||
*/
|
*/
|
||||||
const rubyResolveEnclosingOwner = (node: SyntaxNode): SyntaxNode | null => {
|
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') {
|
if (node.type === 'singleton_class') {
|
||||||
let ancestor = node.parent;
|
let ancestor = node.parent;
|
||||||
while (ancestor) {
|
while (ancestor) {
|
||||||
|
|
@ -155,6 +162,21 @@ const rubyResolveEnclosingOwner = (node: SyntaxNode): SyntaxNode | null => {
|
||||||
return node; // use as-is for all other container types
|
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({
|
export const rubyProvider = defineLanguage({
|
||||||
id: SupportedLanguages.Ruby,
|
id: SupportedLanguages.Ruby,
|
||||||
extensions: ['.rb', '.rake', '.gemspec'],
|
extensions: ['.rb', '.rake', '.gemspec'],
|
||||||
|
|
@ -190,13 +212,14 @@ export const rubyProvider = defineLanguage({
|
||||||
callRouter: routeRubyCall,
|
callRouter: routeRubyCall,
|
||||||
callExtractor: createCallExtractor(rubyCallConfig),
|
callExtractor: createCallExtractor(rubyCallConfig),
|
||||||
resolveEnclosingOwner: rubyResolveEnclosingOwner,
|
resolveEnclosingOwner: rubyResolveEnclosingOwner,
|
||||||
|
resolveContainerTypeOwner: rubyResolveContainerTypeOwner,
|
||||||
fieldExtractor: createFieldExtractor(rubyFieldConfig),
|
fieldExtractor: createFieldExtractor(rubyFieldConfig),
|
||||||
methodExtractor: createMethodExtractor({
|
methodExtractor: createMethodExtractor({
|
||||||
...rubyMethodConfig,
|
...rubyMethodConfig,
|
||||||
extractFunctionName: rubyExtractFunctionName,
|
extractFunctionName: rubyExtractFunctionName,
|
||||||
}),
|
}),
|
||||||
variableExtractor: createVariableExtractor(rubyVariableConfig),
|
variableExtractor: createVariableExtractor(rubyVariableConfig),
|
||||||
classExtractor: createClassExtractor(rubyClassConfig),
|
classExtractor: rubyClassExtractor,
|
||||||
// ── Leading `#` comments (RDoc/YARD) → description (issue #2270). Magic
|
// ── Leading `#` comments (RDoc/YARD) → description (issue #2270). Magic
|
||||||
// comments and the shebang are not documentation. ──
|
// comments and the shebang are not documentation. ──
|
||||||
descriptionExtractor: createLeadingDocDescriptionExtractor({
|
descriptionExtractor: createLeadingDocDescriptionExtractor({
|
||||||
|
|
|
||||||
|
|
@ -2227,6 +2227,80 @@ export const RUBY_QUERIES = `
|
||||||
(class
|
(class
|
||||||
name: (scope_resolution) @name) @definition.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 ─────────────────────────────────────────────────────────
|
; ── Instance methods ─────────────────────────────────────────────────────────
|
||||||
(method
|
(method
|
||||||
name: (identifier) @name) @definition.method
|
name: (identifier) @name) @definition.method
|
||||||
|
|
|
||||||
|
|
@ -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)) {
|
if (CLASS_CONTAINER_TYPES.has(current.type)) {
|
||||||
// Delegate language-specific container remapping to the provider hook.
|
// Delegate language-specific container remapping to the provider hook.
|
||||||
if (resolveEnclosingOwner) {
|
if (resolveEnclosingOwner) {
|
||||||
|
|
|
||||||
158
gitnexus/test/integration/ruby-factory-block-owner.test.ts
Normal file
158
gitnexus/test/integration/ruby-factory-block-owner.test.ts
Normal 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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue