mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-01 02:01:24 +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
|
||||
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
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