diff --git a/gitnexus/src/core/ingestion/languages/ruby.ts b/gitnexus/src/core/ingestion/languages/ruby.ts index e5f71ee56..1441abb59 100644 --- a/gitnexus/src/core/ingestion/languages/ruby.ts +++ b/gitnexus/src/core/ingestion/languages/ruby.ts @@ -33,6 +33,15 @@ import { rubyHeritageConfig } from '../heritage-extractors/configs/ruby.js'; import { maybeRewriteRubyBareCallToSelf } from '../utils/ruby-self-call.js'; import { findEnclosingClassInfo } from '../utils/ast-helpers.js'; import type { DispatchDecision, ImplicitReceiverOverride } from '../call-types.js'; +import { + emitRubyScopeCaptures, + rubyArityCompatibility, + rubyBindingScopeFor, + rubyImportOwningScope, + rubyReceiverBinding, + interpretRubyImport, + interpretRubyTypeBinding, +} from './ruby/index.js'; /** * Ruby label override. Applied to: @@ -258,4 +267,12 @@ export const rubyProvider = defineLanguage({ }, builtInNames: BUILT_INS, + // ── RFC #909 Ring 3: scope-based resolution hooks ────────── + emitScopeCaptures: emitRubyScopeCaptures, + interpretImport: interpretRubyImport, + interpretTypeBinding: interpretRubyTypeBinding, + bindingScopeFor: rubyBindingScopeFor, + importOwningScope: rubyImportOwningScope, + receiverBinding: rubyReceiverBinding, + arityCompatibility: rubyArityCompatibility, }); diff --git a/gitnexus/src/core/ingestion/languages/ruby/arity.ts b/gitnexus/src/core/ingestion/languages/ruby/arity.ts new file mode 100644 index 000000000..3320b2b1e --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/arity.ts @@ -0,0 +1,45 @@ +/** + * Ruby arity check, accommodating `*args`, `**kwargs`, and defaults. + * + * The `def` metadata we care about (set by the existing Ruby method/ + * function extractor): + * - `parameterCount` — total positional + keyword params + * - `requiredParameterCount` — min required (excludes defaults / `*args` / `**kwargs`) + * - `parameterTypes` — present when types are known; we also use it + * as a "we have varargs" hint (`'*args'`, + * `'**kwargs'` literals appear in the array). + * + * Verdicts: + * - `'compatible'` — `requiredParameterCount <= argCount <= parameterCount`, + * OR the def takes `*args` (then any `argCount >= required` ok). + * - `'incompatible'` — argCount is below required, OR above max with no `*args`. + * - `'unknown'` — def metadata is absent / incomplete. + * + * `'incompatible'` is a soft signal in `Registry.lookup` (penalized but + * still considered when no compatible candidate exists), per RFC 4. + */ + +import type { Callsite, SymbolDefinition } from 'gitnexus-shared'; + +export function rubyArityCompatibility( + def: SymbolDefinition, + callsite: Callsite, +): 'compatible' | 'unknown' | 'incompatible' { + const max = def.parameterCount; + const min = def.requiredParameterCount; + if (max === undefined && min === undefined) return 'unknown'; + + const argCount = callsite.arity; + if (!Number.isFinite(argCount) || argCount < 0) return 'unknown'; + + // Detect varargs/kwargs from parameterTypes if present (the Ruby + // method extractor stores `'*args'`/`'**kwargs'` in this list). + const hasVarArgs = + def.parameterTypes !== undefined && + def.parameterTypes.some((t) => t === '*args' || t === '**kwargs' || t.startsWith('*')); + + if (min !== undefined && argCount < min) return 'incompatible'; + if (max !== undefined && argCount > max && !hasVarArgs) return 'incompatible'; + + return 'compatible'; +} diff --git a/gitnexus/src/core/ingestion/languages/ruby/cache-stats.ts b/gitnexus/src/core/ingestion/languages/ruby/cache-stats.ts new file mode 100644 index 000000000..de341c074 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/cache-stats.ts @@ -0,0 +1,18 @@ +let hits = 0; +let misses = 0; + +export function recordRubyCacheHit(): void { + hits++; +} +export function recordRubyCacheMiss(): void { + misses++; +} + +export function getRubyCaptureCacheStats(): { readonly hits: number; readonly misses: number } { + return { hits, misses }; +} + +export function resetRubyCaptureCacheStats(): void { + hits = 0; + misses = 0; +} diff --git a/gitnexus/src/core/ingestion/languages/ruby/captures.ts b/gitnexus/src/core/ingestion/languages/ruby/captures.ts new file mode 100644 index 000000000..577d7b6b5 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/captures.ts @@ -0,0 +1,619 @@ +import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import { + findNodeAtRange, + nodeToCapture, + syntheticCapture, + type SyntaxNode, +} from '../../utils/ast-helpers.js'; +import { getRubyParser, getRubyScopeQuery } from './query.js'; +import { recordRubyCacheHit, recordRubyCacheMiss } from './cache-stats.js'; +import { synthesizeRubyReceiverBinding, findEnclosingClassOrModule } from './receiver-binding.js'; +import { getTreeSitterBufferSize } from '../../constants.js'; +import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; + +const FUNCTION_NODE_TYPES = ['method', 'singleton_method'] as const; +const HERITAGE_CALL_NAMES: ReadonlySet = new Set(['include', 'extend', 'prepend']); +const ATTR_CALL_NAMES: ReadonlySet = new Set([ + 'attr_accessor', + 'attr_reader', + 'attr_writer', +]); + +export function emitRubyScopeCaptures( + sourceText: string, + _filePath: string, + cachedTree?: unknown, +): readonly CaptureMatch[] { + let tree = cachedTree as ReturnType['parse']> | undefined; + if (tree === undefined) { + try { + tree = parseSourceSafe(getRubyParser(), sourceText, undefined, { + bufferSize: getTreeSitterBufferSize(sourceText), + }); + } catch (err) { + throw scopeExtractionError('parse', _filePath, err); + } + recordRubyCacheMiss(); + } else { + recordRubyCacheHit(); + } + + let rawMatches: ReturnType['matches']>; + try { + rawMatches = getRubyScopeQuery().matches(tree.rootNode); + } catch (err) { + throw scopeExtractionError('scope query', _filePath, err); + } + + const out: CaptureMatch[] = []; + + for (const m of rawMatches) { + const grouped: Record = {}; + for (const c of m.captures) { + const tag = '@' + c.name; + if (tag.startsWith('@_')) continue; + grouped[tag] = nodeToCapture(tag, c.node); + } + if (Object.keys(grouped).length === 0) continue; + + // Decompose require/require_relative/load into import captures + if (grouped['@import.statement'] !== undefined) { + const anchor = grouped['@import.statement']!; + const callNode = findNodeAtRange(tree.rootNode, anchor.range, 'call'); + if (callNode !== null) { + const decomposed = decomposeRubyImport(callNode, anchor); + if (decomposed !== null) { + out.push(decomposed); + continue; + } + } + out.push(grouped); + continue; + } + + // Synthesize self receiver bindings for methods inside class/module + if (grouped['@scope.function'] !== undefined) { + const scopeCap = grouped['@scope.function']!; + const fnNode = findFunctionNode(tree.rootNode, scopeCap.range); + if (fnNode !== null) { + const enclosingNode = findEnclosingClassOrModule(fnNode); + const receiver = synthesizeRubyReceiverBinding(fnNode, enclosingNode); + if (receiver !== null) out.push(receiver); + } + out.push(grouped); + continue; + } + + // Reclassify declaration.function as declaration.method + attach arity + if (grouped['@declaration.function'] !== undefined) { + const anchorCap = grouped['@declaration.function']!; + const fnNode = findFunctionNode(tree.rootNode, anchorCap.range); + if (fnNode !== null) { + const enclosingNode = findEnclosingClassOrModule(fnNode); + if (enclosingNode !== null) { + const nameCap = grouped['@declaration.name']; + delete (grouped as Record)['@declaration.function']; + grouped['@declaration.method'] = syntheticCapture( + '@declaration.method', + fnNode, + fnNode.text, + ); + if (nameCap !== undefined) { + grouped['@declaration.name'] = nameCap; + } + } + + const arity = computeRubyDeclarationArity(fnNode); + if (arity.parameterCount !== undefined) { + grouped['@declaration.parameter-count'] = syntheticCapture( + '@declaration.parameter-count', + fnNode, + String(arity.parameterCount), + ); + } + if (arity.requiredParameterCount !== undefined) { + grouped['@declaration.required-parameter-count'] = syntheticCapture( + '@declaration.required-parameter-count', + fnNode, + String(arity.requiredParameterCount), + ); + } + if (arity.parameterTypes !== undefined) { + grouped['@declaration.parameter-types'] = syntheticCapture( + '@declaration.parameter-types', + fnNode, + JSON.stringify(arity.parameterTypes), + ); + } + } + out.push(grouped); + continue; + } + + // Intercept heritage calls (include/extend/prepend) — encode as + // special imports so emitHeritageEdges can emit IMPLEMENTS edges. + if (grouped['@reference.call.free'] !== undefined && grouped['@reference.name'] !== undefined) { + const callName = grouped['@reference.name']!.text; + if (HERITAGE_CALL_NAMES.has(callName)) { + const callNode = findNodeAtRange( + tree.rootNode, + grouped['@reference.call.free']!.range, + 'call', + ); + if (callNode !== null) { + const enclosing = findEnclosingClassOrModule(callNode); + const ownerName = enclosing?.childForFieldName('name')?.text; + if (ownerName) { + const argList = callNode.childForFieldName('arguments'); + if (argList !== null) { + for (let ai = 0; ai < argList.namedChildCount; ai++) { + const arg = argList.namedChild(ai); + if (arg !== null && (arg.type === 'constant' || arg.type === 'scope_resolution')) { + out.push({ + '@import.statement': grouped['@reference.call.free']!, + '@import.kind': syntheticCapture('@import.kind', callNode, 'namespace'), + '@import.source': syntheticCapture( + '@import.source', + callNode, + `__heritage__:${callName}:${arg.text}:${ownerName}`, + ), + '@import.name': syntheticCapture('@import.name', callNode, arg.text), + }); + } + } + } + } + } + continue; + } + + // Intercept attr_accessor/attr_reader/attr_writer — encode as special + // imports so emitHeritageEdges can create Property nodes + HAS_PROPERTY. + // Also emit @declaration.property captures so each property ends up in + // localDefs and gets reconciled into model.fields, enabling write-access + // resolution via receiver-bound-calls (Case 4 → findOwnedMember). + if (ATTR_CALL_NAMES.has(callName)) { + const callNode = findNodeAtRange( + tree.rootNode, + grouped['@reference.call.free']!.range, + 'call', + ); + if (callNode !== null) { + const enclosing = findEnclosingClassOrModule(callNode); + const ownerName = enclosing?.childForFieldName('name')?.text; + if (ownerName) { + const argList = callNode.childForFieldName('arguments'); + if (argList !== null) { + for (let ai = 0; ai < argList.namedChildCount; ai++) { + const arg = argList.namedChild(ai); + if (arg !== null && (arg.type === 'simple_symbol' || arg.type === 'symbol')) { + const propName = arg.text.replace(/^:/, ''); + out.push({ + '@import.statement': grouped['@reference.call.free']!, + '@import.kind': syntheticCapture('@import.kind', callNode, 'namespace'), + '@import.source': syntheticCapture( + '@import.source', + callNode, + `__property__:${callName}:${propName}:${ownerName}`, + ), + '@import.name': syntheticCapture('@import.name', callNode, propName), + }); + // Emit a property declaration so the property flows into + // localDefs → model.fields for receiver-bound write access. + out.push({ + '@declaration.property': syntheticCapture( + '@declaration.property', + arg, + propName, + ), + '@declaration.name': syntheticCapture('@declaration.name', arg, propName), + }); + } + } + } + } + } + continue; + } + } + + // Attach call arity for call expressions + const callTag = (['@reference.call.free', '@reference.call.member'] as const).find( + (t) => grouped[t] !== undefined, + ); + if (callTag !== undefined && grouped['@reference.arity'] === undefined) { + const anchor = grouped[callTag]!; + const callNode = findNodeAtRange(tree.rootNode, anchor.range, 'call'); + if (callNode !== null) { + const arity = computeRubyCallArity(callNode); + grouped['@reference.arity'] = syntheticCapture('@reference.arity', callNode, String(arity)); + } + } + + out.push(grouped); + } + + // Second pass: member-call-return type bindings + // Synthesize compound type bindings for `x = recv.method(...)` assignments. + // The query-level @type-binding.call-return pattern only captures free calls + // (!receiver). For member calls we need `x → recv.method()` so the compound + // receiver resolver can chain-follow through the receiver's class scope. + for (const assignNode of tree.rootNode.descendantsOfType('assignment')) { + const left = assignNode.childForFieldName('left'); + const right = assignNode.childForFieldName('right'); + if (left === null || right === null) continue; + if (left.type !== 'identifier' && left.type !== 'constant') continue; + if (right.type !== 'call') continue; + const recvNode = right.childForFieldName('receiver'); + const methodNode = right.childForFieldName('method'); + if (recvNode === null || methodNode === null) continue; + // Skip .new calls — already handled by the constructor-inference query patterns + if (methodNode.text === 'new') continue; + const compoundName = `${recvNode.text}.${methodNode.text}()`; + out.push({ + '@type-binding.call-return': syntheticCapture( + '@type-binding.call-return', + assignNode, + assignNode.text, + ), + '@type-binding.name': syntheticCapture('@type-binding.name', assignNode, left.text), + '@type-binding.type': syntheticCapture('@type-binding.type', assignNode, compoundName), + }); + } + + // Third pass: YARD comment annotations (@param, @return, @type) + for (const comment of tree.rootNode.descendantsOfType('comment')) { + const text = comment.text; + + // @param name [Type] + const paramMatch = text.match(/@param\s+(\w+)\s+\[([^\]]+)\]/); + if (paramMatch) { + const [, paramName, typeName] = paramMatch; + const methodNode = findFollowingMethod(comment); + if (methodNode !== null && paramName && typeName) { + out.push({ + '@type-binding.parameter': syntheticCapture('@type-binding.parameter', methodNode, text), + '@type-binding.name': syntheticCapture('@type-binding.name', methodNode, paramName), + '@type-binding.type': syntheticCapture( + '@type-binding.type', + methodNode, + normalizeYardType(typeName), + ), + }); + } + } + + // @param [Type] name (alternate YARD order) + const paramAltMatch = text.match(/@param\s+\[([^\]]+)\]\s+(\w+)/); + if (!paramMatch && paramAltMatch) { + const [, typeName, paramName] = paramAltMatch; + const methodNode = findFollowingMethod(comment); + if (methodNode !== null && paramName && typeName) { + out.push({ + '@type-binding.parameter': syntheticCapture('@type-binding.parameter', methodNode, text), + '@type-binding.name': syntheticCapture('@type-binding.name', methodNode, paramName), + '@type-binding.type': syntheticCapture( + '@type-binding.type', + methodNode, + normalizeYardType(typeName), + ), + }); + } + } + + // @return [Type] + const returnMatch = text.match(/@return\s+\[([^\]]+)\]/); + if (returnMatch) { + const [, typeName] = returnMatch; + const methodNode = findFollowingMethod(comment); + if (methodNode !== null && typeName) { + const methodName = methodNode.childForFieldName('name')?.text; + if (methodName) { + out.push({ + '@type-binding.return': syntheticCapture('@type-binding.return', methodNode, text), + '@type-binding.name': syntheticCapture('@type-binding.name', methodNode, methodName), + '@type-binding.type': syntheticCapture( + '@type-binding.type', + methodNode, + normalizeYardType(typeName), + ), + }); + } + } else if (typeName) { + // YARD @return before attr_accessor/attr_reader/attr_writer: the + // comment precedes a `call` node (not a method). Extract the + // property name from the attr call's arguments and bind it to + // the annotated return type. This enables field-type chains + // like `user.address.save → Address#save`. + const attrNode = findFollowingAttrCall(comment); + if (attrNode !== null) { + const argList = attrNode.childForFieldName('arguments'); + if (argList !== null) { + for (let ai = 0; ai < argList.namedChildCount; ai++) { + const arg = argList.namedChild(ai); + if (arg !== null && (arg.type === 'simple_symbol' || arg.type === 'symbol')) { + const propName = arg.text.replace(/^:/, ''); + out.push({ + '@type-binding.return': syntheticCapture('@type-binding.return', attrNode, text), + '@type-binding.name': syntheticCapture('@type-binding.name', attrNode, propName), + '@type-binding.type': syntheticCapture( + '@type-binding.type', + attrNode, + normalizeYardType(typeName), + ), + }); + } + } + } + } + } + } + + // @type [Type] + const typeMatch = text.match(/@type\s+\[([^\]]+)\]/); + if (typeMatch) { + const [, typeName] = typeMatch; + const methodNode = findFollowingMethod(comment); + if (methodNode !== null && typeName) { + out.push({ + '@type-binding.parameter': syntheticCapture('@type-binding.parameter', methodNode, text), + '@type-binding.name': syntheticCapture('@type-binding.name', methodNode, ''), + '@type-binding.type': syntheticCapture( + '@type-binding.type', + methodNode, + normalizeYardType(typeName), + ), + }); + } + } + } + + // Fourth pass: constructor-return inference for methods. + // When a method's body ends with `ClassName.new(...)`, synthesize a + // return-type binding `methodName → ClassName` on the method node. + // This enables cross-file return-type propagation for factory methods + // like `def self.get_user; User.new; end` → `get_user → User`. + for (const methodNode of [ + ...tree.rootNode.descendantsOfType('method'), + ...tree.rootNode.descendantsOfType('singleton_method'), + ]) { + const methodName = methodNode.childForFieldName('name')?.text; + if (methodName === undefined) continue; + // Skip if a YARD @return already created a return binding for this method + if ( + out.some( + (m) => + m['@type-binding.return'] !== undefined && + m['@type-binding.name']?.text === methodName && + m['@type-binding.return']?.range.startLine === methodNode.startPosition.row, + ) + ) { + continue; + } + const body = methodNode.childForFieldName('body'); + if (body === null) continue; + // Find the last expression in the method body + const lastChild = body.namedChildCount > 0 ? body.namedChild(body.namedChildCount - 1) : null; + if (lastChild === null) continue; + // Check if the last expression is a `ClassName.new(...)` call + if (lastChild.type === 'call') { + const recv = lastChild.childForFieldName('receiver'); + const meth = lastChild.childForFieldName('method'); + if ( + recv !== null && + meth !== null && + meth.text === 'new' && + (recv.type === 'constant' || recv.type === 'scope_resolution') + ) { + out.push({ + '@type-binding.return': syntheticCapture( + '@type-binding.return', + methodNode, + `constructor-return: ${recv.text}.new`, + ), + '@type-binding.name': syntheticCapture('@type-binding.name', methodNode, methodName), + '@type-binding.type': syntheticCapture('@type-binding.type', methodNode, recv.text), + }); + } + } + } + + return out; +} + +function decomposeRubyImport(callNode: SyntaxNode, anchor: Capture): CaptureMatch | null { + const methodNode = callNode.childForFieldName('method'); + if (methodNode === null) return null; + const methodName = methodNode.text; + if (methodName !== 'require' && methodName !== 'require_relative' && methodName !== 'load') { + return null; + } + + const argsNode = callNode.childForFieldName('arguments'); + const argNode = argsNode !== null ? argsNode.namedChild(0) : callNode.namedChild(1); + if (argNode === null) return null; + + let sourcePath: string; + if (argNode.type === 'string') { + const contentChild = argNode.namedChild(0); + sourcePath = + contentChild !== null && contentChild.type === 'string_content' + ? contentChild.text + : argNode.text.replace(/^['"]|['"]$/g, ''); + } else { + return null; + } + + if (sourcePath === '') return null; + + const segments = sourcePath.replace(/\\/g, '/').split('/'); + const lastSegment = segments[segments.length - 1]!; + const moduleName = lastSegment.replace(/\.rb$/, ''); + + return { + '@import.statement': anchor, + '@import.kind': syntheticCapture('@import.kind', callNode, 'wildcard'), + '@import.source': syntheticCapture('@import.source', callNode, sourcePath), + '@import.name': syntheticCapture('@import.name', callNode, moduleName), + }; +} + +function computeRubyDeclarationArity(fnNode: SyntaxNode): { + parameterCount?: number; + requiredParameterCount?: number; + parameterTypes?: string[]; +} { + const params = fnNode.childForFieldName('parameters'); + if (params === null) return { parameterCount: 0, requiredParameterCount: 0 }; + + let totalCount = 0; + let requiredCount = 0; + const paramTypes: string[] = []; + + for (let i = 0; i < params.namedChildCount; i++) { + const child = params.namedChild(i); + if (child === null) continue; + + switch (child.type) { + case 'identifier': + totalCount++; + requiredCount++; + paramTypes.push(''); + break; + case 'optional_parameter': + totalCount++; + paramTypes.push(''); + break; + case 'splat_parameter': + totalCount++; + paramTypes.push('*args'); + break; + case 'hash_splat_parameter': + totalCount++; + paramTypes.push('**kwargs'); + break; + case 'block_parameter': + // &block not counted in arity + break; + case 'keyword_parameter': { + totalCount++; + const hasDefault = child.childForFieldName('value') !== null; + if (!hasDefault) requiredCount++; + paramTypes.push(''); + break; + } + default: + totalCount++; + requiredCount++; + paramTypes.push(''); + break; + } + } + + return { + parameterCount: totalCount, + requiredParameterCount: requiredCount, + parameterTypes: paramTypes.length > 0 ? paramTypes : undefined, + }; +} + +function computeRubyCallArity(callNode: SyntaxNode): number { + const argList = callNode.childForFieldName('arguments'); + if (argList === null) return 0; + + let count = 0; + for (let i = 0; i < argList.namedChildCount; i++) { + const child = argList.namedChild(i); + if (child !== null && child.type !== 'block') count++; + } + return count; +} + +function findFunctionNode(rootNode: SyntaxNode, range: Capture['range']): SyntaxNode | null { + for (const nodeType of FUNCTION_NODE_TYPES) { + const n = findNodeAtRange(rootNode, range, nodeType); + if (n !== null) return n; + } + return null; +} + +function scopeExtractionError(stage: string, filePath: string, err: unknown): Error { + const reason = err instanceof Error ? err.message : String(err); + return new Error( + `[ruby] tree-sitter ${stage} failed for ${filePath}: ${reason}; skipping scope extraction for this file`, + ); +} + +/** + * Walk forward from a comment node, skipping consecutive comments, + * and return the next `method` or `singleton_method` node (if any). + */ +function findFollowingMethod(commentNode: SyntaxNode): SyntaxNode | null { + let sibling = commentNode.nextNamedSibling; + while (sibling !== null && sibling.type === 'comment') { + sibling = sibling.nextNamedSibling; + } + if (sibling === null) return null; + if (sibling.type === 'method' || sibling.type === 'singleton_method') return sibling; + // In tree-sitter-ruby, YARD comments before a method inside a class body + // are children of `class`, while the method is inside `body_statement`. + // Walk into body_statement to find the method. + if (sibling.type === 'body_statement') { + const first = sibling.firstNamedChild; + if (first !== null && (first.type === 'method' || first.type === 'singleton_method')) { + return first; + } + } + return null; +} + +/** + * Walk forward from a comment node, skipping consecutive comments, + * and return the next `call` node whose method is attr_accessor / + * attr_reader / attr_writer (if any). Used to attach YARD `@return` + * annotations to property declarations. + */ +function findFollowingAttrCall(commentNode: SyntaxNode): SyntaxNode | null { + let sibling = commentNode.nextNamedSibling; + while (sibling !== null && sibling.type === 'comment') { + sibling = sibling.nextNamedSibling; + } + if (sibling === null) return null; + if (sibling.type === 'call') { + const methodNode = sibling.childForFieldName('method'); + if (methodNode !== null && ATTR_CALL_NAMES.has(methodNode.text)) { + return sibling; + } + } + return null; +} + +/** + * Normalize a YARD type string: for single-parameter generics like + * `Array` or `Array[User]` keep the inner type; for multi-param + * generics like `Hash` strip the generic and return the + * outer type name only. + */ +function normalizeYardType(raw: string): string { + const trimmed = raw.trim(); + + // Check for angle-bracket generics: Type or Type + const angleMatch = trimmed.match(/^(\w+)<(.+)>$/); + if (angleMatch) { + const inner = angleMatch[2]!; + // Single-param generic → return the inner type + if (!inner.includes(',')) return inner.trim(); + // Multi-param generic → return the outer type + return angleMatch[1]!; + } + + // Check for bracket generics: Type[Inner] (YARD sometimes uses this) + const bracketMatch = trimmed.match(/^(\w+)\[(.+)\]$/); + if (bracketMatch) { + const inner = bracketMatch[2]!; + if (!inner.includes(',')) return inner.trim(); + return bracketMatch[1]!; + } + + return trimmed; +} diff --git a/gitnexus/src/core/ingestion/languages/ruby/import-target.ts b/gitnexus/src/core/ingestion/languages/ruby/import-target.ts new file mode 100644 index 000000000..a8dcd323c --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/import-target.ts @@ -0,0 +1,107 @@ +/** + * Resolve a Ruby require/require_relative import path to a repo-relative file. + * + * Ruby import resolution rules: + * - `require_relative './foo'` → resolve relative to the importing file's dir + * - `require 'foo'` → suffix-match via the existing Ruby import resolver + * - External gems → null (unresolvable within the repo) + */ + +import { resolveRubyImportInternal } from '../../import-resolvers/ruby.js'; +import { buildSuffixIndex } from '../../import-resolvers/utils.js'; + +export interface RubyResolveContext { + readonly fromFile: string; + readonly allFilePaths: ReadonlySet; +} + +// ─── resolveRubyImportTarget ────────────────────────────────────────────── + +/** + * ScopeResolver-shaped adapter: + * `(targetRaw, fromFile, allFilePaths, resolutionConfig?) → string | string[] | null` + * + * For relative paths (`./` or `../` — require_relative semantics), resolves + * against the importing file's directory, trying `.rb` and `/index.rb` + * suffixes. + * + * For bare requires (gem-style like `'json'`, `'serializable'`), delegates + * to the existing `resolveRubyImportInternal` which uses suffix matching. + * + * Returns `null` for external gems that have no matching file in the repo. + */ +export function resolveRubyImportTarget( + targetRaw: string, + fromFile: string, + allFilePaths: ReadonlySet, + _resolutionConfig?: unknown, +): string | readonly string[] | null { + if (!targetRaw) return null; + if (targetRaw.startsWith('__heritage__:') || targetRaw.startsWith('__property__:')) return null; + + const fromNormalized = fromFile.replace(/\\/g, '/'); + const fromDir = fromNormalized.includes('/') + ? fromNormalized.slice(0, fromNormalized.lastIndexOf('/')) + : ''; + + // ── require_relative: relative path resolution ────────────────────── + if (targetRaw.startsWith('./') || targetRaw.startsWith('../')) { + const resolved = resolveRelative(targetRaw, fromDir, allFilePaths); + return resolved; + } + + // ── require: bare/gem-style suffix matching ───────────────────────── + return resolveBare(targetRaw, allFilePaths); +} + +// ─── internal helpers ───────────────────────────────────────────────────── + +/** + * Resolve a relative require path (`./foo`, `../bar`) against `fromDir`. + * Tries `${resolved}.rb` then `${resolved}/index.rb`. + */ +function resolveRelative( + targetRaw: string, + fromDir: string, + allFilePaths: ReadonlySet, +): string | null { + // Resolve `./` and `../` segments manually against fromDir + const segments = (fromDir ? fromDir + '/' + targetRaw : targetRaw).split('/'); + const resolved: string[] = []; + + for (const seg of segments) { + if (seg === '' || seg === '.') continue; + if (seg === '..') { + resolved.pop(); + } else { + resolved.push(seg); + } + } + + const resolvedPath = resolved.join('/'); + + // Try direct .rb file + const rbFile = `${resolvedPath}.rb`; + if (allFilePaths.has(rbFile)) return rbFile; + + // Try index.rb inside directory + const indexFile = `${resolvedPath}/index.rb`; + if (allFilePaths.has(indexFile)) return indexFile; + + // The path might already include .rb extension + if (resolvedPath.endsWith('.rb') && allFilePaths.has(resolvedPath)) return resolvedPath; + + return null; +} + +/** + * Resolve a bare require path (`'serializable'`, `'json'`, `'net/http'`) + * via suffix matching using the existing Ruby import resolver. + */ +function resolveBare(targetRaw: string, allFilePaths: ReadonlySet): string | null { + const normalizedFileList = [...allFilePaths].map((f) => f.replace(/\\/g, '/')); + const allFileList = [...allFilePaths]; + const index = buildSuffixIndex(normalizedFileList, allFileList); + + return resolveRubyImportInternal(targetRaw, normalizedFileList, allFileList, index); +} diff --git a/gitnexus/src/core/ingestion/languages/ruby/index.ts b/gitnexus/src/core/ingestion/languages/ruby/index.ts new file mode 100644 index 000000000..67c3678e8 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/index.ts @@ -0,0 +1,20 @@ +/** + * Ruby scope-resolution hooks (RFC #909 Ring 3). + */ +export { emitRubyScopeCaptures } from './captures.js'; +export { getRubyCaptureCacheStats, resetRubyCaptureCacheStats } from './cache-stats.js'; +export { + interpretRubyImport, + interpretRubyTypeBinding, + normalizeRubyTypeName, +} from './interpret.js'; +export { rubyArityCompatibility } from './arity.js'; +export { rubyMergeBindings } from './merge-bindings.js'; +export { synthesizeRubyReceiverBinding, findEnclosingClassOrModule } from './receiver-binding.js'; +export { + rubyBindingScopeFor, + rubyImportOwningScope, + rubyReceiverBinding, + rubyFunctionDefinitionLabel, +} from './simple-hooks.js'; +export { resolveRubyImportTarget, type RubyResolveContext } from './import-target.js'; diff --git a/gitnexus/src/core/ingestion/languages/ruby/interpret.ts b/gitnexus/src/core/ingestion/languages/ruby/interpret.ts new file mode 100644 index 000000000..2cee5a1d2 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/interpret.ts @@ -0,0 +1,117 @@ +import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared'; + +// ─── interpretImport ────────────────────────────────────────────────────── + +/** + * Interpret a pre-decomposed Ruby import capture into a `ParsedImport`. + * + * Ruby `require` / `require_relative` / `load` bring everything from the + * target file into scope (wildcard semantics). The captures layer (U5) + * pre-decomposes the raw tree-sitter match so that this function receives: + * - `@import.kind` — always `'wildcard'` for Ruby + * - `@import.source` — the string argument (e.g. `'./user'`, `'serializable'`) + * - `@import.name` — derived module name (informational) + */ +export function interpretRubyImport(captures: CaptureMatch): ParsedImport | null { + const kind = captures['@import.kind']?.text; + if (kind === undefined) return null; + + const source = captures['@import.source']?.text; + if (source === undefined) return null; + + // Heritage-encoded imports (__heritage__:include:Serializable:User) + // are stored as namespace imports so emitHeritageEdges can read them. + if (source.startsWith('__heritage__:') || source.startsWith('__property__:')) { + const name = captures['@import.name']?.text ?? source; + return { kind: 'namespace', localName: name, importedName: name, targetRaw: source }; + } + + // Ruby imports are always wildcard — everything in the required file + // becomes visible in the importing scope. + return { kind: 'wildcard', targetRaw: source }; +} + +// ─── interpretTypeBinding ───────────────────────────────────────────────── + +/** + * Interpret a Ruby type-binding capture into a `ParsedTypeBinding`. + * + * Type information in Ruby comes from YARD/RBS annotations, `.new` calls, + * and assignment inference. The captures layer tags each match with one of + * several sub-captures (`@type-binding.self`, `@type-binding.constructor`, + * etc.) so this function can determine the `source`. + */ +export function interpretRubyTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null { + const name = captures['@type-binding.name']?.text; + const type = captures['@type-binding.type']?.text; + if (name === undefined || type === undefined) return null; + + let source: TypeRef['source']; + let normalizedType: string; + + if (captures['@type-binding.self'] !== undefined) { + source = 'self'; + normalizedType = normalizeRubyTypeName(type); + } else if (captures['@type-binding.constructor'] !== undefined) { + source = 'constructor-inferred'; + normalizedType = normalizeRubyConstructorType(type); + } else if (captures['@type-binding.call-return'] !== undefined) { + source = 'constructor-inferred'; + normalizedType = normalizeRubyTypeName(type); + } else if (captures['@type-binding.return'] !== undefined) { + source = 'return-annotation'; + normalizedType = normalizeRubyTypeName(type); + } else if (captures['@type-binding.parameter'] !== undefined) { + source = 'parameter-annotation'; + normalizedType = normalizeRubyTypeName(type); + } else if (captures['@type-binding.alias'] !== undefined) { + source = 'assignment-inferred'; + normalizedType = normalizeRubyTypeName(type); + } else { + source = 'annotation'; + normalizedType = normalizeRubyTypeName(type); + } + + return { boundName: name, rawTypeName: normalizedType, source }; +} + +// ─── normalizeRubyTypeName ──────────────────────────────────────────────── + +/** + * Normalize a Ruby type name to its simple form: + * 1. Strip leading `::` (root-qualified) + * 2. Take last segment of qualified paths (`Foo::Bar::Baz` → `Baz`) + * 3. Strip generic angle brackets for consistency (`Array` → `Array`) + * 4. Trim whitespace + */ +export function normalizeRubyTypeName(text: string): string { + let t = text.trim(); + + // Strip leading root-qualifier + if (t.startsWith('::')) t = t.slice(2); + + // Strip generic angle brackets (e.g. `Array` → `Array`) + const angleBracket = t.indexOf('<'); + if (angleBracket !== -1) t = t.slice(0, angleBracket); + + // Take last segment of qualified paths (Foo::Bar::Baz → Baz) + const lastColon = t.lastIndexOf('::'); + if (lastColon !== -1) t = t.slice(lastColon + 2); + + return t.trim(); +} + +// ─── internal helpers ───────────────────────────────────────────────────── + +/** + * Normalize a constructor-inferred type from a `.new` call. + * Handles `Foo::Bar.new` → `Bar` and plain `Foo.new` → `Foo`. + */ +function normalizeRubyConstructorType(text: string): string { + let t = text.trim(); + + // Strip `.new` suffix if present (e.g. `Foo::Bar.new` → `Foo::Bar`) + if (t.endsWith('.new')) t = t.slice(0, -4); + + return normalizeRubyTypeName(t); +} diff --git a/gitnexus/src/core/ingestion/languages/ruby/merge-bindings.ts b/gitnexus/src/core/ingestion/languages/ruby/merge-bindings.ts new file mode 100644 index 000000000..136d96654 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/merge-bindings.ts @@ -0,0 +1,27 @@ +import type { BindingRef } from 'gitnexus-shared'; + +const TIER: Record = { + local: 0, + namespace: 1, + import: 2, + reexport: 3, + wildcard: 4, +}; + +export function rubyMergeBindings( + existing: readonly BindingRef[], + incoming: readonly BindingRef[], + _scopeId: string, +): BindingRef[] { + const seen = new Set(); + return [...existing, ...incoming] + .sort( + (a, b) => + (TIER[a.origin] ?? 99) - (TIER[b.origin] ?? 99) || a.def.nodeId.localeCompare(b.def.nodeId), + ) + .filter((binding) => { + if (seen.has(binding.def.nodeId)) return false; + seen.add(binding.def.nodeId); + return true; + }); +} diff --git a/gitnexus/src/core/ingestion/languages/ruby/query.ts b/gitnexus/src/core/ingestion/languages/ruby/query.ts new file mode 100644 index 000000000..36a4e36b3 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/query.ts @@ -0,0 +1,221 @@ +/** + * Tree-sitter query for Ruby scope captures (U1 scope-resolution migration). + * + * Captures the structural skeleton the generic scope-resolution pipeline + * consumes: scopes (program/class/module/method/block), declarations + * (class, module, method, singleton_method, variable), imports + * (require/require_relative/load), type bindings (constructor-inferred + * locals via `.new`), and references (free calls, member calls). + * + * Ruby specifics that shape this query: + * + * - Ruby modules are class-like scopes (they hold methods and can be + * mixed in via include/extend/prepend). + * + * - `singleton_method` (`def self.foo`) is a class-level method + * declaration, captured as @scope.function + @declaration.function. + * + * - Ruby has no static type annotations. Constructor inference via + * `x = User.new` is handled here; YARD `@param`/`@return` comments + * are handled programmatically in captures.ts. + * + * - In Ruby, field access IS a method call (attr_reader generates + * methods). Member calls cover both method calls and field reads. + * + * - `require`, `require_relative`, and `load` are plain method calls + * in the grammar — matched by name via `#match?`. + * + * - `do_block` and `block` (`{ }`) are both block scopes that can + * introduce closures. + * + * Exposes lazy `Parser` and `Query` singletons so callers don't pay + * tree-sitter init cost per file. + */ + +import Parser from 'tree-sitter'; +import Ruby from 'tree-sitter-ruby'; + +const RUBY_SCOPE_QUERY = ` +;; ── Scopes ─────────────────────────────────────────────────────────────── + +(program) @scope.module + +(class) @scope.class +(module) @scope.class + +(method) @scope.function +(singleton_method) @scope.function + +(do_block) @scope.block +(block) @scope.block + +;; ── Declarations — class ───────────────────────────────────────────────── + +(class + name: (constant) @declaration.name) @declaration.class + +;; ── Declarations — module (labeled Trait for class-like registry lookup) ─ + +(module + name: (constant) @declaration.name) @declaration.trait + +;; ── Declarations — method (instance) ───────────────────────────────────── + +(method + name: (identifier) @declaration.name) @declaration.function + +;; ── Declarations — singleton method (class-level: def self.foo) ────────── + +(singleton_method + name: (identifier) @declaration.name) @declaration.function + +;; ── Declarations — variable assignment ─────────────────────────────────── + +(assignment + left: (identifier) @declaration.name) @declaration.variable + +;; ── Imports — require / require_relative / load ────────────────────────── +;; +;; All three are plain \`call\` nodes in tree-sitter-ruby with no receiver. +;; The import-decomposer in captures.ts fans out the argument to a path. + +(call + method: (identifier) @_method + (#match? @_method "^(require|require_relative|load)$")) @import.statement + +;; ── Type bindings — constructor inference: x = User.new ────────────────── +;; +;; tree-sitter-ruby parses \`x = User.new\` as: +;; (assignment +;; left: (identifier) ;; "x" +;; right: (call +;; receiver: (constant) ;; "User" +;; method: (identifier))) ;; "new" +;; +;; Captures the receiver constant as the type. + +(assignment + left: (identifier) @type-binding.name + right: (call + receiver: (constant) @type-binding.type + method: (identifier) @_new_method + (#eq? @_new_method "new"))) @type-binding.constructor + +;; Qualified constructor: x = Foo::Bar.new (scope_resolution receiver) + +(assignment + left: (identifier) @type-binding.name + right: (call + receiver: (scope_resolution) @type-binding.type + method: (identifier) @_new_method2 + (#eq? @_new_method2 "new"))) @type-binding.constructor + +;; Constant constructor: SERVICE = UserService.new (left is constant, not identifier) + +(assignment + left: (constant) @type-binding.name + right: (call + receiver: (constant) @type-binding.type + method: (identifier) @_new_method3 + (#eq? @_new_method3 "new"))) @type-binding.constructor + +(assignment + left: (constant) @type-binding.name + right: (call + receiver: (scope_resolution) @type-binding.type + method: (identifier) @_new_method4 + (#eq? @_new_method4 "new"))) @type-binding.constructor + +;; Call-return inference: x = build_service() (factory pattern) + +(assignment + left: (identifier) @type-binding.name + right: (call + !receiver + method: (identifier) @type-binding.type)) @type-binding.call-return + +;; Constant call-return: SERVICE = build_service() + +(assignment + left: (constant) @type-binding.name + right: (call + !receiver + method: (identifier) @type-binding.type)) @type-binding.call-return + +;; ── Type bindings — for-in loop: for x in collection ───────────────────── +;; +;; The loop variable \`x\` gets the element type of the collection. +;; We bind \`x → collection\` as an alias; the chain-follow pass +;; resolves \`collection → ElementType\` via YARD \`@param\` annotations. +;; tree-sitter-ruby wraps the collection in an \`in\` node: +;; (for pattern: (identifier) value: (in (identifier))) + +(for + pattern: (identifier) @type-binding.name + value: (in + (identifier) @type-binding.type)) @type-binding.alias + +;; ── Type bindings — variable alias: x = y ──────────────────────────────── + +(assignment + left: (identifier) @type-binding.name + right: (identifier) @type-binding.type) @type-binding.alias + +;; ── References — free calls (no receiver) ──────────────────────────────── + +(call + !receiver + method: (identifier) @reference.name) @reference.call.free + +;; ── References — bare calls (zero-arity calls without parentheses) ────── +;; +;; Ruby allows calling methods without parentheses. When no arguments are +;; passed, tree-sitter-ruby parses them as plain \`identifier\` nodes inside +;; \`body_statement\`. This mirrors the legacy query pattern. The scope- +;; resolution pipeline filters false positives via builtInNames and +;; arity-based overload narrowing. + +(body_statement + (identifier) @reference.name) @reference.call.free + +;; ── References — member calls (with receiver): obj.method() ────────────── + +(call + receiver: (_) @reference.receiver + method: (identifier) @reference.name) @reference.call.member + +;; ── References — field writes: obj.field = value ───────────────────────── +;; +;; Ruby setter syntax: \`obj.name = x\` is an assignment whose left-hand +;; side is a \`call\` node with a receiver. + +(assignment + left: (call + receiver: (_) @reference.receiver + method: (identifier) @reference.name)) @reference.write + +;; ── References — field writes (compound assignment: obj.field += value) ── + +(operator_assignment + left: (call + receiver: (_) @reference.receiver + method: (identifier) @reference.name)) @reference.write +`; + +let _parser: Parser | null = null; +let _query: Parser.Query | null = null; + +export function getRubyParser(): Parser { + if (_parser === null) { + _parser = new Parser(); + _parser.setLanguage(Ruby as Parameters[0]); + } + return _parser; +} + +export function getRubyScopeQuery(): Parser.Query { + if (_query === null) { + _query = new Parser.Query(Ruby as Parameters[0], RUBY_SCOPE_QUERY); + } + return _query; +} diff --git a/gitnexus/src/core/ingestion/languages/ruby/receiver-binding.ts b/gitnexus/src/core/ingestion/languages/ruby/receiver-binding.ts new file mode 100644 index 000000000..fe06f87d5 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/receiver-binding.ts @@ -0,0 +1,72 @@ +/** + * Synthesize `@type-binding.self` captures for Ruby methods. + * + * Every instance method and singleton method inside a class or module body + * gets `self` bound to the enclosing class/module name. Ruby has no + * `@staticmethod` concept — even `def self.foo` dispatches on the class. + * + * For `class << self` (singleton_class) blocks, we walk past the + * singleton_class to the real owning class/module, matching the + * `rubyResolveEnclosingOwner` logic from the legacy provider. + */ + +import type { CaptureMatch } from 'gitnexus-shared'; +import { syntheticCapture } from '../../utils/ast-helpers.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +/** + * Walk up the parent chain from `node` to find the first enclosing + * `class` or `module` ancestor. + * + * - `singleton_class` (`class << self`) is not itself a real type, so we + * skip past it and continue walking to find the true owner. + * - Stops at `program` (never walks past the file root). + * - Returns `null` when no enclosing class/module is found. + */ +export function findEnclosingClassOrModule(node: SyntaxNode): SyntaxNode | null { + let cur: SyntaxNode | null = node.parent; + while (cur !== null) { + if (cur.type === 'program') return null; + if (cur.type === 'class' || cur.type === 'module') return cur; + // singleton_class (`class << self`) is not the real owner — + // keep walking to find the enclosing class/module. + cur = cur.parent; + } + return null; +} + +/** + * Extract the class/module name from the `name` field of a `class` or + * `module` node. The field holds a `constant` node whose text is the + * simple name (e.g. "MyClass"). + */ +function extractClassName(classOrModuleNode: SyntaxNode): string | null { + const nameNode = classOrModuleNode.childForFieldName('name'); + if (nameNode === null) return null; + return nameNode.text; +} + +/** + * Given a method node (`method` or `singleton_method`) and its enclosing + * `class`/`module` node (or null for top-level defs), synthesize a + * `@type-binding.self` capture that binds `self` to the class/module name. + * + * Returns `null` when: + * - `enclosingNode` is null (top-level def has no implicit receiver) + * - the enclosing node's name cannot be extracted + */ +export function synthesizeRubyReceiverBinding( + fnNode: SyntaxNode, + enclosingNode: SyntaxNode | null, +): CaptureMatch | null { + if (enclosingNode === null) return null; + + const className = extractClassName(enclosingNode); + if (className === null) return null; + + return { + '@type-binding.self': syntheticCapture('@type-binding.self', fnNode, 'self'), + '@type-binding.name': syntheticCapture('@type-binding.name', fnNode, 'self'), + '@type-binding.type': syntheticCapture('@type-binding.type', fnNode, className), + }; +} diff --git a/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts new file mode 100644 index 000000000..b10c78b74 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts @@ -0,0 +1,235 @@ +import type { ParsedFile, ScopeId } from 'gitnexus-shared'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js'; +import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; +import { rubyProvider } from '../ruby.js'; +import { rubyArityCompatibility, rubyMergeBindings, resolveRubyImportTarget } from './index.js'; +import { populateClassOwnedMembers, isClassLike } from '../../scope-resolution/scope/walkers.js'; +import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js'; +import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js'; +import type { KnowledgeGraph } from '../../../graph/types.js'; +import { generateId } from '../../../../lib/utils.js'; + +const HERITAGE_PREFIX = '__heritage__:'; + +function emitRubyMixinEdges( + graph: KnowledgeGraph, + parsedFiles: readonly ParsedFile[], + nodeLookup: GraphNodeLookup, +): void { + const graphIdByName = new Map(); + for (const parsed of parsedFiles) { + for (const def of parsed.localDefs) { + if (!isClassLike(def.type)) continue; + const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup); + if (graphId !== undefined) { + const simpleName = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; + graphIdByName.set(simpleName, graphId); + } + } + } + + const emitted = new Set(); + // Pre-seed with existing IMPLEMENTS edges to avoid duplicates when the + // parse-worker path already produced heritage (worker path for repos + // with >= 15 files). + for (const rel of graph.iterRelationshipsByType('IMPLEMENTS')) { + emitted.add(`${rel.sourceId}->${rel.targetId}:${rel.reason}`); + } + + for (const parsed of parsedFiles) { + for (const imp of parsed.parsedImports) { + if (!imp.targetRaw.startsWith(HERITAGE_PREFIX)) continue; + const parts = imp.targetRaw.slice(HERITAGE_PREFIX.length).split(':'); + if (parts.length < 3) continue; + const [kind, mixinName, className] = parts; + const classGraphId = graphIdByName.get(className!); + const mixinGraphId = graphIdByName.get(mixinName!); + if (classGraphId === undefined || mixinGraphId === undefined) continue; + const edgeKey = `${classGraphId}->${mixinGraphId}:${kind}`; + if (emitted.has(edgeKey)) continue; + emitted.add(edgeKey); + graph.addRelationship({ + id: generateId('IMPLEMENTS', edgeKey), + sourceId: classGraphId, + targetId: mixinGraphId, + type: 'IMPLEMENTS', + confidence: 0.85, + reason: kind!, + }); + } + } + + // Emit Property nodes + HAS_PROPERTY edges from __property__:... imports. + // Skip if the parse-worker already created the property (worker path merges + // Property nodes into the graph before scope-resolution runs). + const existingProps = new Set(); + for (const rel of graph.iterRelationshipsByType('HAS_PROPERTY')) { + const targetNode = graph.getNode(rel.targetId); + if (targetNode !== undefined) { + existingProps.add(`${rel.sourceId}->prop:${targetNode.properties.name}`); + } + } + + const PROPERTY_PREFIX = '__property__:'; + for (const parsed of parsedFiles) { + for (const imp of parsed.parsedImports) { + if (!imp.targetRaw.startsWith(PROPERTY_PREFIX)) continue; + const parts = imp.targetRaw.slice(PROPERTY_PREFIX.length).split(':'); + if (parts.length < 3) continue; + const [_attrKind, propName, className] = parts; + const classGraphId = graphIdByName.get(className!); + if (classGraphId === undefined || propName === undefined) continue; + + const edgeKey = `${classGraphId}->prop:${propName}`; + if (emitted.has(edgeKey) || existingProps.has(edgeKey)) continue; + emitted.add(edgeKey); + + const propId = generateId('Property', `${parsed.filePath}:${className}.${propName}`); + graph.addNode({ + id: propId, + label: 'Property', + properties: { name: propName, filePath: parsed.filePath }, + }); + graph.addRelationship({ + id: generateId('HAS_PROPERTY', edgeKey), + sourceId: classGraphId, + targetId: propId, + type: 'HAS_PROPERTY', + confidence: 0.9, + reason: 'attr', + }); + } + } +} + +function buildRubyMro( + graph: Parameters[0], + parsedFiles: readonly ParsedFile[], + nodeLookup: Parameters[2], +): Map { + // Step 1: EXTENDS chain via the generic MRO builder (direct class inheritance). + const baseMro = buildMro(graph, parsedFiles, nodeLookup, defaultLinearize); + + // Step 2: Build defId ↔ graphId bridge for class-like defs. + const defIdByGraphId = new Map(); + for (const parsed of parsedFiles) { + for (const def of parsed.localDefs) { + if (!isClassLike(def.type)) continue; + const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup); + if (graphId !== undefined) defIdByGraphId.set(graphId, def.nodeId); + } + } + + // Step 3: Collect IMPLEMENTS edges, partitioned by reason. + const prependByChild = new Map(); + const includeByChild = new Map(); + + for (const rel of graph.iterRelationshipsByType('IMPLEMENTS')) { + const childDefId = defIdByGraphId.get(rel.sourceId); + const parentDefId = defIdByGraphId.get(rel.targetId); + if (childDefId === undefined || parentDefId === undefined) continue; + + const reason = rel.reason; + if (reason === 'prepend') { + let list = prependByChild.get(childDefId); + if (list === undefined) { + list = []; + prependByChild.set(childDefId, list); + } + list.push(parentDefId); + } else if (reason === 'include') { + let list = includeByChild.get(childDefId); + if (list === undefined) { + list = []; + includeByChild.set(childDefId, list); + } + list.push(parentDefId); + } + } + + // Step 4: Reorder MRO per Ruby semantics. + // Order: prepend (reversed) → direct extends chain → include (reversed). + // `extend` is excluded — it belongs to singleton dispatch only (legacy + // `getInstanceAncestry` in heritage-map.ts explicitly drops extend entries). + // Reversed because Ruby declaration order means last-declared wins + // (prepend B; prepend A → B checked before A). + for (const defId of defIdByGraphId.values()) { + const extendsChain = baseMro.get(defId) ?? []; + const prepends = prependByChild.get(defId); + const includes = includeByChild.get(defId); + + if (prepends === undefined && includes === undefined) continue; + + const reordered: string[] = []; + if (prepends !== undefined) { + for (let i = prepends.length - 1; i >= 0; i--) reordered.push(prepends[i]); + } + reordered.push(...extendsChain); + if (includes !== undefined) { + for (let i = includes.length - 1; i >= 0; i--) reordered.push(includes[i]); + } + baseMro.set(defId, reordered); + } + + return baseMro; +} + +/** + * Enumerate all names exported from a target module scope's file. + * Ruby's `require` / `require_relative` are wildcard imports — they bring + * every top-level def (class, module, method, constant) from the target + * file into the importer's scope. Without this hook the finalize pass + * cannot materialize individual bindings from wildcard imports, which + * blocks `propagateImportedReturnTypes` from mirroring return-type + * typeBindings across files. + */ +function expandRubyWildcardNames( + targetModuleScope: ScopeId, + parsedFiles: readonly ParsedFile[], +): readonly string[] { + const target = parsedFiles.find((p) => p.moduleScope === targetModuleScope); + if (target === undefined) return []; + + const seen = new Set(); + const names: string[] = []; + for (const def of target.localDefs) { + const qn = def.qualifiedName; + if (qn === undefined || qn.length === 0) continue; + const name = qn.split('.').pop() ?? qn; + if (name === '') continue; + if (seen.has(name)) continue; + seen.add(name); + names.push(name); + } + return names; +} + +export const rubyScopeResolver: ScopeResolver = { + language: SupportedLanguages.Ruby, + languageProvider: rubyProvider, + importEdgeReason: 'ruby-scope: import', + + resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) => + resolveRubyImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig), + + expandsWildcardTo: (targetModuleScope, parsedFiles) => + expandRubyWildcardNames(targetModuleScope, parsedFiles), + + mergeBindings: (existing, incoming, scopeId) => rubyMergeBindings(existing, incoming, scopeId), + + arityCompatibility: (callsite, def) => rubyArityCompatibility(def, callsite), + + buildMro: (graph, parsedFiles, nodeLookup) => buildRubyMro(graph, parsedFiles, nodeLookup), + + populateOwners: (parsed) => populateClassOwnedMembers(parsed), + + isSuperReceiver: (text) => text.trim() === 'super', + + emitHeritageEdges: (graph, parsedFiles, nodeLookup) => + emitRubyMixinEdges(graph, parsedFiles, nodeLookup), + + fieldFallbackOnMethodLookup: true, + propagatesReturnTypesAcrossImports: true, + allowGlobalFreeCallFallback: true, +}; diff --git a/gitnexus/src/core/ingestion/languages/ruby/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/ruby/simple-hooks.ts new file mode 100644 index 000000000..b09bd2616 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/simple-hooks.ts @@ -0,0 +1,63 @@ +import type { + CaptureMatch, + ParsedImport, + Scope, + ScopeId, + ScopeTree, + TypeRef, + NodeLabel, +} from 'gitnexus-shared'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +export function rubyBindingScopeFor( + decl: CaptureMatch, + innermost: Scope, + _tree: ScopeTree, +): ScopeId | null { + // Keep self typeBindings in the method's Function scope so + // populateClassOwnedMembers can match Method defs to their receiver types. + if (decl['@type-binding.self'] !== undefined) { + return innermost.id; + } + return null; +} + +/** + * Ruby `require` / `include` inside a function or class body should attach + * at that scope, not module scope. + */ +export function rubyImportOwningScope( + _imp: ParsedImport, + innermost: Scope, + _tree: ScopeTree, +): ScopeId | null { + if (innermost.kind === 'Function' || innermost.kind === 'Class') { + return innermost.id; + } + return null; +} + +export function rubyReceiverBinding(functionScope: Scope): TypeRef | null { + if (functionScope.kind !== 'Function') return null; + return functionScope.typeBindings.get('self') ?? null; +} + +/** + * Reclassify top-level `def` as `'Method'` when it appears inside a + * `class` or `module` body. Stand-alone defs remain `'Function'`. + */ +export function rubyFunctionDefinitionLabel( + functionNode: SyntaxNode, + defaultLabel: NodeLabel, +): NodeLabel { + if (defaultLabel !== 'Function') return defaultLabel; + let ancestor: SyntaxNode | null = functionNode.parent; + while (ancestor) { + if (ancestor.type === 'program') break; + if (ancestor.type === 'class' || ancestor.type === 'module') { + return 'Method'; + } + ancestor = ancestor.parent; + } + return 'Function'; +} diff --git a/gitnexus/src/core/ingestion/registry-primary-flag.ts b/gitnexus/src/core/ingestion/registry-primary-flag.ts index 72e5f5b3e..c31c54a17 100644 --- a/gitnexus/src/core/ingestion/registry-primary-flag.ts +++ b/gitnexus/src/core/ingestion/registry-primary-flag.ts @@ -79,6 +79,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet = new Set Map; + /** + * Optional pre-MRO hook to emit heritage edges (IMPLEMENTS) that the + * generic `preEmitInheritanceEdges` pass cannot produce. Runs AFTER + * `preEmitInheritanceEdges` (which emits EXTENDS from `@reference.inherits` + * sites) and BEFORE `buildMro` (which reads the graph for EXTENDS + + * IMPLEMENTS). Languages whose heritage declarations are syntactic method + * calls rather than grammar-level heritage clauses (e.g., Ruby + * `include`/`extend`/`prepend`) use this hook to emit IMPLEMENTS edges + * from parsed import or reference data. + * + * Receives the graph (writable), parsedFiles, and nodeLookup — same + * surface as `buildMro`. Must be idempotent (the orchestrator may call + * it more than once during re-resolution). + * + * Default: undefined (no extra heritage edges needed). + */ + readonly emitHeritageEdges?: ( + graph: KnowledgeGraph, + parsedFiles: readonly ParsedFile[], + nodeLookup: GraphNodeLookup, + ) => void; + /** * Mutate `parsed.localDefs[i].ownerId` to point at the structural * owner. Python's rule: methods (Function defs whose parent scope diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts index b89551689..b1ee3d01b 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts @@ -30,9 +30,14 @@ import { } from '../scope/walkers.js'; /** Max depth for compound-receiver chain resolution (`a().b().c().d()`). - * Practical code rarely exceeds 3-4 hops; the cap prevents - * pathological recursion if the receiver text is malformed. */ -const COMPOUND_RECEIVER_MAX_DEPTH = 4; + * Practical code rarely exceeds 3-4 _syntactic_ hops, but languages + * with type-binding-mediated chains (Ruby's `x = obj.method()` binds + * `x → obj.method()` and recurses through the compound resolver) can + * triple the depth count because each intermediate step contributes + * two recursions (bare-ident → compound rawName → call-expr parse). + * 8 covers 3-level chains with headroom while still capping + * pathological recursion. */ +const COMPOUND_RECEIVER_MAX_DEPTH = 8; const MAP_TUPLE_SENTINEL_RE = /^__MAP_TUPLE_(\d+)__:(.+)$/; @@ -144,6 +149,23 @@ export function resolveCompoundReceiverClass( ); if (callAlias !== undefined) return callAlias; } + + // Compound member-call alias: rawName has both `.` and `()` + // (`user = Factory.get_user()` → rawName `Factory.get_user()`). + // Recurse into the compound resolver with the raw compound + // expression so the mixed-chain parser can split at top-level + // `.` and resolve the receiver + method return type. + if (tb.rawName.includes('.') && tb.rawName.includes('(')) { + const compound = resolveCompoundReceiverClass( + tb.rawName, + inScope, + scopes, + index, + options, + depth + 1, + ); + if (compound !== undefined) return compound; + } } return findClassBindingInScope(inScope, text, scopes); } diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index ef1d4a0eb..a7f5c9f85 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -620,15 +620,21 @@ export function emitReceiverBoundCalls( } // ── Case 3b: chain-typebinding (`city → user.get_city`) ────── + // Also handles compound member-call rawNames (`city → addr.get_city()`) + // where the rawName includes both `.` and `()` — Ruby's + // member-call-return captures produce this shape. const chainHead = - typeRef !== undefined && typeRef.rawName.includes('.') && !typeRef.rawName.includes('(') + typeRef !== undefined && typeRef.rawName.includes('.') ? (typeRef.rawName.split('.', 1)[0] ?? '') : undefined; if (typeRef !== undefined && chainHead !== undefined && !namespaceTargets.has(chainHead)) { // Try the plain dotted-field walk first — covers property / // collection-accessor shapes (`.Values`, Kotlin `.size`) and // field chains. Fall back to call-form (`x()`) which treats - // the last segment as a method invocation. + // the last segment as a method invocation. For rawNames that + // already contain `()` (Ruby member-call-return captures), + // pass through directly — the compound resolver handles the + // full expression including the call syntax. let ownerDef = resolveCompoundReceiverClass( typeRef.rawName, typeRef.declaredAtScope, @@ -636,7 +642,7 @@ export function emitReceiverBoundCalls( index, compoundOpts, ); - if (ownerDef === undefined) { + if (ownerDef === undefined && !typeRef.rawName.includes('(')) { ownerDef = resolveCompoundReceiverClass( typeRef.rawName + '()', typeRef.declaredAtScope, diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts index cd98caea7..b7c1dc5c6 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts @@ -22,6 +22,7 @@ import { phpScopeResolver } from '../../languages/php/scope-resolver.js'; import { rustScopeResolver } from '../../languages/rust/scope-resolver.js'; import { javascriptScopeResolver } from '../../languages/javascript/scope-resolver.js'; import { kotlinScopeResolver } from '../../languages/kotlin/scope-resolver.js'; +import { rubyScopeResolver } from '../../languages/ruby/scope-resolver.js'; /** Map of `SupportedLanguages` → `ScopeResolver`. The phase iterates * this map intersected with `MIGRATED_LANGUAGES` (the per-language @@ -42,4 +43,5 @@ export const SCOPE_RESOLVERS: ReadonlyMap = n [SupportedLanguages.Rust, rustScopeResolver], [SupportedLanguages.JavaScript, javascriptScopeResolver], [SupportedLanguages.Kotlin, kotlinScopeResolver], + [SupportedLanguages.Ruby, rubyScopeResolver], ]); diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 21bf45e40..a088f3266 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -308,8 +308,26 @@ export function runScopeResolution( }, }); const preEmittedInheritanceSites = preEmitInheritanceEdges(graph, finalized, nodeLookup); - const mroByClassDefId = provider.buildMro(graph, parsedFiles, nodeLookup); - const extendsOnlyMroByClassDefId = provider.buildExtendsOnlyMro?.(graph, parsedFiles, nodeLookup); + // Call-based heritage hook (e.g., Ruby include/extend/prepend) — emits + // IMPLEMENTS edges that `preEmitInheritanceEdges` cannot produce because + // the heritage declarations are syntactic method calls, not grammar-level + // heritage clauses. Must run BEFORE `buildMro` so MRO construction sees + // the freshly-emitted IMPLEMENTS edges. + provider.emitHeritageEdges?.(graph, parsedFiles, nodeLookup); + // Rebuild the node lookup after heritage-edge emission. Languages like + // Ruby create Property graph nodes inside `emitHeritageEdges`; those + // nodes must be visible to downstream passes (`emitReceiverBoundCalls` + // resolves write-access targets via `resolveDefGraphId` which consults + // `nodeLookup`). Without this rebuild, Property nodes added by the + // heritage hook are invisible and ACCESSES edges silently fail to emit. + const postHeritageNodeLookup = + provider.emitHeritageEdges !== undefined ? buildGraphNodeLookup(graph) : nodeLookup; + const mroByClassDefId = provider.buildMro(graph, parsedFiles, postHeritageNodeLookup); + const extendsOnlyMroByClassDefId = provider.buildExtendsOnlyMro?.( + graph, + parsedFiles, + postHeritageNodeLookup, + ); // Replace the empty MethodDispatchIndex that finalizeScopeModel // builds by design with the populated one derived from the @@ -395,7 +413,7 @@ export function runScopeResolution( graph, indexes, parsedFiles, - nodeLookup, + postHeritageNodeLookup, handledSites, provider, workspaceIndex, @@ -410,7 +428,7 @@ export function runScopeResolution( graph, indexes, parsedFiles, - nodeLookup, + postHeritageNodeLookup, handledSites, readonlyModel, ) @@ -419,7 +437,7 @@ export function runScopeResolution( graph, indexes, parsedFiles, - nodeLookup, + postHeritageNodeLookup, referenceIndex, handledSites, readonlyModel, @@ -438,7 +456,7 @@ export function runScopeResolution( graph, indexes, referenceIndex, - nodeLookup, + postHeritageNodeLookup, handledSites, ); const importsEmitted = emitImportEdges( diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index 26c586ad1..c8a126977 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -200,6 +200,12 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly([ + // Ruby scope-resolution currently achieves 89/127 parity. + // Tests listed here are scope-resolver-only correctness wins + // (pass under registry-primary, fail under legacy). Currently + // empty — all 127 tests pass under legacy mode. + ]), cpp: new Set([ // The legacy DAG path has no scope-aware filtering on the global // free-call fallback, so `#include`d headers still leak class diff --git a/gitnexus/test/integration/resolvers/ruby-scope.test.ts b/gitnexus/test/integration/resolvers/ruby-scope.test.ts new file mode 100644 index 000000000..ef44acf6a --- /dev/null +++ b/gitnexus/test/integration/resolvers/ruby-scope.test.ts @@ -0,0 +1,481 @@ +/** + * Ruby scope-resolution integration tests (U8). + * + * These tests run with REGISTRY_PRIMARY_RUBY=true to exercise the + * scope-based resolution path. They validate class methods, module mixins, + * singleton methods, require_relative imports, constructor inference, + * block scope, class inheritance, and super resolution. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import path from 'path'; +import fs from 'node:fs'; +import os from 'node:os'; +import { + getRelationships, + getNodesByLabel, + runPipelineFromRepo, + type PipelineResult, +} from './helpers.js'; + +function writeFixtureRepo(root: string, files: Record): void { + for (const [rel, content] of Object.entries(files)) { + const abs = path.join(root, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, content, 'utf8'); + } +} + +let savedEnv: string | undefined; + +beforeAll(() => { + savedEnv = process.env['REGISTRY_PRIMARY_RUBY']; + process.env['REGISTRY_PRIMARY_RUBY'] = 'true'; +}); + +afterAll(() => { + if (savedEnv === undefined) delete process.env['REGISTRY_PRIMARY_RUBY']; + else process.env['REGISTRY_PRIMARY_RUBY'] = savedEnv; +}); + +// --------------------------------------------------------------------------- +// 1. Basic class method resolution +// --------------------------------------------------------------------------- + +describe('Ruby scope: basic class method resolution', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-basic-')); + writeFixtureRepo(tmpDir, { + 'models/user.rb': ` +class User + def save + true + end + + def greet + "hello" + end +end +`, + 'app.rb': ` +require_relative 'models/user' + +def main + u = User.new + u.save + u.greet +end +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('detects User class', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + }); + + it('detects save and greet as Method nodes', () => { + const methods = getNodesByLabel(result, 'Method'); + expect(methods).toContain('save'); + expect(methods).toContain('greet'); + }); + + it('emits HAS_METHOD edges from User to save and greet', () => { + const edges = getRelationships(result, 'HAS_METHOD'); + const userSave = edges.find((e) => e.source === 'User' && e.target === 'save'); + const userGreet = edges.find((e) => e.source === 'User' && e.target === 'greet'); + expect(userSave).toBeDefined(); + expect(userGreet).toBeDefined(); + }); + + it('resolves main → u.save() as CALLS edge', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find( + (c) => c.target === 'save' && c.source === 'main' && c.targetFilePath?.includes('user.rb'), + ); + expect(saveCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 2. Module mixin with include +// --------------------------------------------------------------------------- + +describe('Ruby scope: module mixin with include', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-mixin-')); + writeFixtureRepo(tmpDir, { + 'serializable.rb': ` +module Serializable + def serialize + to_json + end +end +`, + 'user.rb': ` +require_relative 'serializable' + +class User + include Serializable + + def save + serialize + end +end +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('detects User as Class and Serializable as Trait', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Trait')).toContain('Serializable'); + }); + + it('emits IMPLEMENTS edge from User to Serializable', () => { + const impls = getRelationships(result, 'IMPLEMENTS'); + const edge = impls.find((e) => e.source === 'User' && e.target === 'Serializable'); + expect(edge).toBeDefined(); + }); + + it('resolves save → serialize as CALLS edge', () => { + const calls = getRelationships(result, 'CALLS'); + const serializeCall = calls.find((c) => c.target === 'serialize' && c.source === 'save'); + expect(serializeCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 3. Singleton method (def self.foo) +// --------------------------------------------------------------------------- + +describe('Ruby scope: singleton method (def self.foo)', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-singleton-')); + writeFixtureRepo(tmpDir, { + 'config.rb': ` +class Config + def self.load + new + end + + def validate + true + end +end +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('detects Config class', () => { + expect(getNodesByLabel(result, 'Class')).toContain('Config'); + }); + + it('detects load (singleton) and validate (instance) as Method nodes', () => { + const methods = getNodesByLabel(result, 'Method'); + expect(methods).toContain('load'); + expect(methods).toContain('validate'); + }); + + it('emits HAS_METHOD edges from Config to both methods', () => { + const edges = getRelationships(result, 'HAS_METHOD'); + const configLoad = edges.find((e) => e.source === 'Config' && e.target === 'load'); + const configValidate = edges.find((e) => e.source === 'Config' && e.target === 'validate'); + expect(configLoad).toBeDefined(); + expect(configValidate).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 4. Require/require_relative import resolution +// --------------------------------------------------------------------------- + +describe('Ruby scope: require_relative import resolution', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-imports-')); + writeFixtureRepo(tmpDir, { + 'lib/utils.rb': ` +class Utils + def format(text) + text.strip + end +end +`, + 'app.rb': ` +require_relative 'lib/utils' + +def run + u = Utils.new + u.format("hello") +end +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('emits IMPORTS edge from app.rb to lib/utils.rb', () => { + const imports = getRelationships(result, 'IMPORTS'); + const imp = imports.find( + (e) => e.sourceFilePath?.includes('app.rb') && e.targetFilePath?.includes('utils.rb'), + ); + expect(imp).toBeDefined(); + }); + + it('resolves run → u.format() as CALLS edge to utils.rb', () => { + const calls = getRelationships(result, 'CALLS'); + const formatCall = calls.find( + (c) => c.target === 'format' && c.source === 'run' && c.targetFilePath?.includes('utils.rb'), + ); + expect(formatCall).toBeDefined(); + }); + + it('detects Utils class and format method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('Utils'); + expect(getNodesByLabel(result, 'Method')).toContain('format'); + }); +}); + +// --------------------------------------------------------------------------- +// 5. Constructor inference (User.new) +// --------------------------------------------------------------------------- + +describe('Ruby scope: constructor inference via .new', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-ctor-')); + writeFixtureRepo(tmpDir, { + 'formatter.rb': ` +class Formatter + def format(text) + text.upcase + end +end + +def main + f = Formatter.new + f.format("hello") +end +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('detects Formatter class and format method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('Formatter'); + expect(getNodesByLabel(result, 'Method')).toContain('format'); + }); + + it('emits HAS_METHOD edge from Formatter to format', () => { + const edges = getRelationships(result, 'HAS_METHOD'); + const fmtEdge = edges.find((e) => e.source === 'Formatter' && e.target === 'format'); + expect(fmtEdge).toBeDefined(); + }); + + it('resolves main → f.format() to Formatter#format via constructor inference', () => { + const calls = getRelationships(result, 'CALLS'); + const formatCall = calls.find( + (c) => + c.target === 'format' && c.source === 'main' && c.targetFilePath?.includes('formatter.rb'), + ); + expect(formatCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 6. Block scope (do...end with params) +// --------------------------------------------------------------------------- + +describe('Ruby scope: block scope with do...end', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-block-')); + writeFixtureRepo(tmpDir, { + 'processor.rb': ` +class Processor + def run + items = [1, 2, 3] + items.each do |item| + process(item) + end + end + + def process(x) + x * 2 + end +end +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('detects Processor class', () => { + expect(getNodesByLabel(result, 'Class')).toContain('Processor'); + }); + + it('detects run and process as Method nodes on Processor', () => { + const methods = getNodesByLabel(result, 'Method'); + expect(methods).toContain('run'); + expect(methods).toContain('process'); + }); + + it('emits HAS_METHOD edges from Processor to run and process', () => { + const edges = getRelationships(result, 'HAS_METHOD'); + const procRun = edges.find((e) => e.source === 'Processor' && e.target === 'run'); + const procProcess = edges.find((e) => e.source === 'Processor' && e.target === 'process'); + expect(procRun).toBeDefined(); + expect(procProcess).toBeDefined(); + }); + + it('resolves run → process() as CALLS edge inside block scope', () => { + const calls = getRelationships(result, 'CALLS'); + const processCall = calls.find((c) => c.target === 'process' && c.source === 'run'); + expect(processCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 7. Class inheritance (EXTENDS) +// --------------------------------------------------------------------------- + +describe('Ruby scope: class inheritance via <', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-inherit-')); + writeFixtureRepo(tmpDir, { + 'animals.rb': ` +class Animal + def speak + "..." + end +end + +class Dog < Animal + def bark + speak + end +end +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('detects Animal and Dog as Class nodes', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('Animal'); + expect(classes).toContain('Dog'); + }); + + it('emits EXTENDS edge from Dog to Animal', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + const edge = extends_.find((e) => e.source === 'Dog' && e.target === 'Animal'); + expect(edge).toBeDefined(); + }); + + it('resolves bark → speak as CALLS edge', () => { + const calls = getRelationships(result, 'CALLS'); + const speakCall = calls.find((c) => c.target === 'speak' && c.source === 'bark'); + expect(speakCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 8. Super resolution +// --------------------------------------------------------------------------- + +describe('Ruby scope: super resolution in subclass', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-super-')); + writeFixtureRepo(tmpDir, { + 'hierarchy.rb': ` +class Base + def greet + "hello" + end +end + +class Child < Base + def greet + super + end +end + +def main + c = Child.new + c.greet +end +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('detects Base and Child as Class nodes', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('Base'); + expect(classes).toContain('Child'); + }); + + it('emits EXTENDS edge from Child to Base', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + const edge = extends_.find((e) => e.source === 'Child' && e.target === 'Base'); + expect(edge).toBeDefined(); + }); + + it('resolves main → c.greet() as CALLS edge', () => { + const calls = getRelationships(result, 'CALLS'); + const greetCall = calls.find((c) => c.target === 'greet' && c.source === 'main'); + expect(greetCall).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/ruby-sequential-mixin.test.ts b/gitnexus/test/integration/resolvers/ruby-sequential-mixin.test.ts index c3e105a15..36957fc09 100644 --- a/gitnexus/test/integration/resolvers/ruby-sequential-mixin.test.ts +++ b/gitnexus/test/integration/resolvers/ruby-sequential-mixin.test.ts @@ -102,8 +102,15 @@ function resolvedMethodOwners( describe('Ruby mixin heritage: sequential vs worker parity', () => { let sequential: PipelineResult; let workers: PipelineResult; + let savedEnv: string | undefined; beforeAll(async () => { + // Force legacy mode — these tests exercise inferImplicitReceiver + + // selectDispatch hooks which live in the legacy call-processor. + // With Ruby in MIGRATED_LANGUAGES the call-processor is gated off + // under registry-primary, so pin legacy for this suite. + savedEnv = process.env['REGISTRY_PRIMARY_RUBY']; + process.env['REGISTRY_PRIMARY_RUBY'] = '0'; sequential = await runMode({ skipWorkers: true }); // Force the worker pool to spawn even though the fixture is tiny. // Without this override, the pipeline's MIN_FILES_FOR_WORKERS / MIN_BYTES_FOR_WORKERS @@ -115,6 +122,11 @@ describe('Ruby mixin heritage: sequential vs worker parity', () => { }); }, 120000); + afterAll(() => { + if (savedEnv === undefined) delete process.env['REGISTRY_PRIMARY_RUBY']; + else process.env['REGISTRY_PRIMARY_RUBY'] = savedEnv; + }); + it('exercises both pipeline paths (sequential and worker)', () => { // If either of these assertions fails, every downstream parity check // below is meaningless — both modes would be running the same path. diff --git a/gitnexus/test/integration/resolvers/ruby.test.ts b/gitnexus/test/integration/resolvers/ruby.test.ts index 82806aac5..c60fe25b1 100644 --- a/gitnexus/test/integration/resolvers/ruby.test.ts +++ b/gitnexus/test/integration/resolvers/ruby.test.ts @@ -3,9 +3,10 @@ * calls, member calls, ambiguous disambiguation, local shadow, * constructor-inferred type resolution */ -import { describe, it, expect, beforeAll } from 'vitest'; +import { describe, expect, beforeAll } from 'vitest'; import path from 'path'; import { + createResolverParityIt, FIXTURES, CROSS_FILE_FIXTURES, getRelationships, @@ -16,6 +17,8 @@ import { type PipelineResult, } from './helpers.js'; +const pit = createResolverParityIt('ruby'); + // --------------------------------------------------------------------------- // Heritage: require_relative imports + include heritage + attr_* properties + calls // --------------------------------------------------------------------------- @@ -29,11 +32,11 @@ describe('Ruby require_relative, heritage & property resolution', () => { // --- Node detection --- - it('detects 3 classes', () => { + pit('detects 3 classes', () => { expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'User', 'UserService']); }); - it('detects 3 modules (labeled as Trait for class-like registry lookup)', () => { + pit('detects 3 modules (labeled as Trait for class-like registry lookup)', () => { // Ruby `module` declarations are relabeled to `Trait` during ingestion so // they participate in `lookupClassByName` and `buildHeritageMap`. This is // the single source of truth for Ruby module detection in the graph. @@ -41,7 +44,7 @@ describe('Ruby require_relative, heritage & property resolution', () => { expect(getNodesByLabel(result, 'Module')).toEqual([]); }); - it('detects methods on classes and modules', () => { + pit('detects methods on classes and modules', () => { const methods = getNodesByLabel(result, 'Method'); expect(methods).toContain('persist'); expect(methods).toContain('run_validations'); @@ -50,12 +53,12 @@ describe('Ruby require_relative, heritage & property resolution', () => { expect(methods).toContain('create_user'); }); - it('detects singleton method (def self.factory) as Method', () => { + pit('detects singleton method (def self.factory) as Method', () => { const methods = getNodesByLabel(result, 'Method'); expect(methods).toContain('factory'); }); - it('emits CALLS from singleton method: factory → run_validations', () => { + pit('emits CALLS from singleton method: factory → run_validations', () => { const calls = getRelationships(result, 'CALLS').filter( (e) => e.source === 'factory' && e.target === 'run_validations', ); @@ -65,7 +68,7 @@ describe('Ruby require_relative, heritage & property resolution', () => { // --- Import resolution via require_relative --- - it('resolves 5 require_relative imports to IMPORTS edges', () => { + pit('resolves 5 require_relative imports to IMPORTS edges', () => { const imports = getRelationships(result, 'IMPORTS'); const importEdges = edgeSet(imports); expect(importEdges).toContain('user.rb → base_model.rb'); @@ -75,7 +78,7 @@ describe('Ruby require_relative, heritage & property resolution', () => { expect(importEdges).toContain('service.rb → user.rb'); }); - it('resolves bare require to IMPORTS edge', () => { + pit('resolves bare require to IMPORTS edge', () => { const imports = getRelationships(result, 'IMPORTS'); const bareRequire = imports.find( (e) => @@ -86,21 +89,21 @@ describe('Ruby require_relative, heritage & property resolution', () => { // --- Heritage: include → IMPLEMENTS --- - it('emits IMPLEMENTS edge for include Serializable with reason "include"', () => { + pit('emits IMPLEMENTS edge for include Serializable with reason "include"', () => { const implements_ = getRelationships(result, 'IMPLEMENTS'); const edge = implements_.find((e) => e.source === 'User' && e.target === 'Serializable'); expect(edge).toBeDefined(); expect(edge!.rel.reason).toBe('include'); }); - it('emits IMPLEMENTS edge for extend Loggable with reason "extend"', () => { + pit('emits IMPLEMENTS edge for extend Loggable with reason "extend"', () => { const implements_ = getRelationships(result, 'IMPLEMENTS'); const edge = implements_.find((e) => e.source === 'User' && e.target === 'Loggable'); expect(edge).toBeDefined(); expect(edge!.rel.reason).toBe('extend'); }); - it('emits IMPLEMENTS edge for prepend Cacheable with reason "prepend"', () => { + pit('emits IMPLEMENTS edge for prepend Cacheable with reason "prepend"', () => { const implements_ = getRelationships(result, 'IMPLEMENTS'); const edge = implements_.find((e) => e.source === 'User' && e.target === 'Cacheable'); expect(edge).toBeDefined(); @@ -109,7 +112,7 @@ describe('Ruby require_relative, heritage & property resolution', () => { // --- Extends: class inheritance --- - it('emits EXTENDS edge: User → BaseModel', () => { + pit('emits EXTENDS edge: User → BaseModel', () => { const extends_ = getRelationships(result, 'EXTENDS'); expect(extends_.length).toBe(1); const edges = edgeSet(extends_); @@ -118,25 +121,25 @@ describe('Ruby require_relative, heritage & property resolution', () => { // --- Property nodes: attr_accessor, attr_reader, attr_writer --- - it('creates Property nodes for attr_accessor :id and :created_at', () => { + pit('creates Property nodes for attr_accessor :id and :created_at', () => { const props = getNodesByLabel(result, 'Property'); expect(props).toContain('id'); expect(props).toContain('created_at'); }); - it('creates Property nodes for attr_reader :name and attr_writer :email', () => { + pit('creates Property nodes for attr_reader :name and attr_writer :email', () => { const props = getNodesByLabel(result, 'Property'); expect(props).toContain('name'); expect(props).toContain('email'); }); - it('emits HAS_PROPERTY from User to attr_reader :name', () => { + pit('emits HAS_PROPERTY from User to attr_reader :name', () => { const hasProperty = getRelationships(result, 'HAS_PROPERTY'); const edge = hasProperty.find((e) => e.source === 'User' && e.target === 'name'); expect(edge).toBeDefined(); }); - it('emits HAS_PROPERTY from BaseModel to attr_accessor :id', () => { + pit('emits HAS_PROPERTY from BaseModel to attr_accessor :id', () => { const hasProperty = getRelationships(result, 'HAS_PROPERTY'); const edge = hasProperty.find((e) => e.source === 'BaseModel' && e.target === 'id'); expect(edge).toBeDefined(); @@ -144,7 +147,7 @@ describe('Ruby require_relative, heritage & property resolution', () => { // --- Call resolution: method-level attribution --- - it('emits method-level CALLS: create_user → persist (member call)', () => { + pit('emits method-level CALLS: create_user → persist (member call)', () => { const calls = getRelationships(result, 'CALLS').filter( (e) => e.source === 'create_user' && e.target === 'persist', ); @@ -153,7 +156,7 @@ describe('Ruby require_relative, heritage & property resolution', () => { expect(calls[0].targetLabel).toBe('Method'); }); - it('emits method-level CALLS: create_user → greet_user (member call)', () => { + pit('emits method-level CALLS: create_user → greet_user (member call)', () => { const calls = getRelationships(result, 'CALLS').filter( (e) => e.source === 'create_user' && e.target === 'greet_user', ); @@ -162,21 +165,21 @@ describe('Ruby require_relative, heritage & property resolution', () => { expect(calls[0].targetLabel).toBe('Method'); }); - it('emits method-level CALLS: greet_user → persist (bare call)', () => { + pit('emits method-level CALLS: greet_user → persist (bare call)', () => { const calls = getRelationships(result, 'CALLS').filter( (e) => e.source === 'greet_user' && e.target === 'persist', ); expect(calls.length).toBe(1); }); - it('emits method-level CALLS: greet_user → serialize_data (bare call)', () => { + pit('emits method-level CALLS: greet_user → serialize_data (bare call)', () => { const calls = getRelationships(result, 'CALLS').filter( (e) => e.source === 'greet_user' && e.target === 'serialize_data', ); expect(calls.length).toBe(1); }); - it('emits method-level CALLS: persist → run_validations (bare call)', () => { + pit('emits method-level CALLS: persist → run_validations (bare call)', () => { const calls = getRelationships(result, 'CALLS').filter( (e) => e.source === 'persist' && e.target === 'run_validations', ); @@ -185,7 +188,7 @@ describe('Ruby require_relative, heritage & property resolution', () => { // --- Heritage edges point to real graph nodes --- - it('all heritage edges point to real graph nodes', () => { + pit('all heritage edges point to real graph nodes', () => { for (const edge of [ ...getRelationships(result, 'EXTENDS'), ...getRelationships(result, 'IMPLEMENTS'), @@ -197,7 +200,7 @@ describe('Ruby require_relative, heritage & property resolution', () => { // --- No OVERRIDES edges target Property nodes --- - it('no OVERRIDES edges target Property nodes', () => { + pit('no OVERRIDES edges target Property nodes', () => { const overrides = getRelationships(result, 'METHOD_OVERRIDES'); for (const edge of overrides) { const target = result.graph.getNode(edge.rel.targetId); @@ -218,7 +221,7 @@ describe('Ruby call resolution with arity filtering', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-calls'), () => {}); }, 60000); - it('resolves run_task → write_audit to one_arg.rb via arity narrowing', () => { + pit('resolves run_task → write_audit to one_arg.rb via arity narrowing', () => { const calls = getRelationships(result, 'CALLS'); const auditCall = calls.find((c) => c.target === 'write_audit'); expect(auditCall).toBeDefined(); @@ -239,7 +242,7 @@ describe('Ruby member-call resolution', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-member-calls'), () => {}); }, 60000); - it('resolves process_user → persist_record as a member call on User', () => { + pit('resolves process_user → persist_record as a member call on User', () => { const calls = getRelationships(result, 'CALLS'); const saveCall = calls.find((c) => c.target === 'persist_record'); expect(saveCall).toBeDefined(); @@ -247,12 +250,12 @@ describe('Ruby member-call resolution', () => { expect(saveCall!.targetFilePath).toContain('user.rb'); }); - it('detects User class and persist_record method', () => { + pit('detects User class and persist_record method', () => { expect(getNodesByLabel(result, 'Class')).toContain('User'); expect(getNodesByLabel(result, 'Method')).toContain('persist_record'); }); - it('emits HAS_METHOD edge from User to persist_record', () => { + pit('emits HAS_METHOD edge from User to persist_record', () => { const hasMethod = getRelationships(result, 'HAS_METHOD'); const edge = hasMethod.find((e) => e.source === 'User' && e.target === 'persist_record'); expect(edge).toBeDefined(); @@ -266,7 +269,7 @@ describe('Ruby qualified class names', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-qualified-types'), () => {}); }, 60000); - it('stores distinct qualified names for same-named classes across modules', () => { + pit('stores distinct qualified names for same-named classes across modules', () => { const users = getNodesByLabelFull(result, 'Class').filter((node) => node.name === 'User'); expect(users).toHaveLength(2); expect(users.map((node) => node.properties.qualifiedName).sort()).toEqual([ @@ -287,13 +290,13 @@ describe('Ruby ambiguous symbol resolution', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-ambiguous'), () => {}); }, 60000); - it('detects 2 Handler classes', () => { + pit('detects 2 Handler classes', () => { const classes = getNodesByLabel(result, 'Class'); expect(classes.filter((n) => n === 'Handler').length).toBe(2); expect(classes).toContain('UserHandler'); }); - it('resolves EXTENDS to models/handler.rb (not other/handler.rb)', () => { + pit('resolves EXTENDS to models/handler.rb (not other/handler.rb)', () => { const extends_ = getRelationships(result, 'EXTENDS'); expect(extends_.length).toBe(1); expect(extends_[0].source).toBe('UserHandler'); @@ -301,13 +304,13 @@ describe('Ruby ambiguous symbol resolution', () => { expect(extends_[0].targetFilePath).toBe('models/handler.rb'); }); - it('import edge points to models/ not other/', () => { + pit('import edge points to models/ not other/', () => { const imports = getRelationships(result, 'IMPORTS'); expect(imports.length).toBe(1); expect(imports[0].targetFilePath).toBe('models/handler.rb'); }); - it('all heritage edges point to real graph nodes', () => { + pit('all heritage edges point to real graph nodes', () => { for (const edge of getRelationships(result, 'EXTENDS')) { const target = result.graph.getNode(edge.rel.targetId); expect(target).toBeDefined(); @@ -326,7 +329,7 @@ describe('Ruby local definition shadows import', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-local-shadow'), () => {}); }, 60000); - it('resolves run_app → do_work to same-file definition, not the imported one', () => { + pit('resolves run_app → do_work to same-file definition, not the imported one', () => { const calls = getRelationships(result, 'CALLS'); const doWorkCall = calls.find((c) => c.target === 'do_work' && c.source === 'run_app'); expect(doWorkCall).toBeDefined(); @@ -348,19 +351,19 @@ describe('Ruby constructor-inferred type resolution', () => { ); }, 60000); - it('detects User, Repo, and AppService classes', () => { + pit('detects User, Repo, and AppService classes', () => { expect(getNodesByLabel(result, 'Class')).toContain('User'); expect(getNodesByLabel(result, 'Class')).toContain('Repo'); expect(getNodesByLabel(result, 'Class')).toContain('AppService'); }); - it('detects save on User and Repo, cleanup on all three', () => { + pit('detects save on User and Repo, cleanup on all three', () => { const methods = getNodesByLabel(result, 'Method'); expect(methods.filter((m) => m === 'save').length).toBe(2); expect(methods.filter((m) => m === 'cleanup').length).toBe(3); }); - it('resolves user.save to models/user.rb via constructor-inferred type', () => { + pit('resolves user.save to models/user.rb via constructor-inferred type', () => { const calls = getRelationships(result, 'CALLS'); const userSave = calls.find( (c) => c.target === 'save' && c.targetFilePath === 'models/user.rb', @@ -369,7 +372,7 @@ describe('Ruby constructor-inferred type resolution', () => { expect(userSave!.source).toBe('process_entities'); }); - it('resolves repo.save to models/repo.rb via constructor-inferred type', () => { + pit('resolves repo.save to models/repo.rb via constructor-inferred type', () => { const calls = getRelationships(result, 'CALLS'); const repoSave = calls.find( (c) => c.target === 'save' && c.targetFilePath === 'models/repo.rb', @@ -378,20 +381,20 @@ describe('Ruby constructor-inferred type resolution', () => { expect(repoSave!.source).toBe('process_entities'); }); - it('emits exactly 2 save CALLS edges (one per receiver type)', () => { + pit('emits exactly 2 save CALLS edges (one per receiver type)', () => { const calls = getRelationships(result, 'CALLS'); const saveCalls = calls.filter((c) => c.target === 'save'); expect(saveCalls.length).toBe(2); }); - it('resolves self.process_entities to services/app.rb (unique method)', () => { + pit('resolves self.process_entities to services/app.rb (unique method)', () => { const calls = getRelationships(result, 'CALLS'); const selfCall = calls.find((c) => c.source === 'greet' && c.target === 'process_entities'); expect(selfCall).toBeDefined(); expect(selfCall!.targetFilePath).toContain('app.rb'); }); - it('resolves self.cleanup to services/app.rb, not models/user.rb or models/repo.rb', () => { + pit('resolves self.cleanup to services/app.rb, not models/user.rb or models/repo.rb', () => { const calls = getRelationships(result, 'CALLS'); const selfCleanup = calls.find((c) => c.source === 'greet' && c.target === 'cleanup'); expect(selfCleanup).toBeDefined(); @@ -410,13 +413,13 @@ describe('Ruby self resolution', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-self-this-resolution'), () => {}); }, 60000); - it('detects User and Repo classes, each with a save method', () => { + pit('detects User and Repo classes, each with a save method', () => { expect(getNodesByLabel(result, 'Class')).toEqual(['Repo', 'User']); const saveMethods = getNodesByLabel(result, 'Method').filter((m) => m === 'save'); expect(saveMethods.length).toBe(2); }); - it('resolves self.save inside User#process to User#save, not Repo#save', () => { + pit('resolves self.save inside User#process to User#save, not Repo#save', () => { const calls = getRelationships(result, 'CALLS'); const saveCall = calls.find((c) => c.target === 'save' && c.source === 'process'); expect(saveCall).toBeDefined(); @@ -435,20 +438,20 @@ describe('Ruby parent resolution', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-parent-resolution'), () => {}); }, 60000); - it('detects BaseModel and User classes plus Serializable module (Trait)', () => { + pit('detects BaseModel and User classes plus Serializable module (Trait)', () => { expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'User']); // Ruby modules are labeled Trait — see the "detects 3 modules" test above. expect(getNodesByLabel(result, 'Trait')).toEqual(['Serializable']); }); - it('emits EXTENDS edge: User < BaseModel', () => { + pit('emits EXTENDS edge: User < BaseModel', () => { const extends_ = getRelationships(result, 'EXTENDS'); expect(extends_.length).toBe(1); expect(extends_[0].source).toBe('User'); expect(extends_[0].target).toBe('BaseModel'); }); - it('emits IMPLEMENTS edge: User includes Serializable', () => { + pit('emits IMPLEMENTS edge: User includes Serializable', () => { const implements_ = getRelationships(result, 'IMPLEMENTS'); const includeEdge = implements_.find((e) => e.source === 'User' && e.target === 'Serializable'); expect(includeEdge).toBeDefined(); @@ -467,18 +470,18 @@ describe('Ruby super resolution', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-super-resolution'), () => {}); }, 60000); - it('detects BaseModel, User, and Repo classes', () => { + pit('detects BaseModel, User, and Repo classes', () => { expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'Repo', 'User']); }); - it('emits EXTENDS edge: User < BaseModel', () => { + pit('emits EXTENDS edge: User < BaseModel', () => { const extends_ = getRelationships(result, 'EXTENDS'); expect(extends_.length).toBe(1); expect(extends_[0].source).toBe('User'); expect(extends_[0].target).toBe('BaseModel'); }); - it('detects save methods on all three classes', () => { + pit('detects save methods on all three classes', () => { const saveMethods = getNodesByLabel(result, 'Method').filter((m) => m === 'save'); expect(saveMethods.length).toBe(3); }); @@ -495,13 +498,13 @@ describe('Ruby constant constructor binding resolution', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-constant-constructor'), () => {}); }, 60000); - it('detects UserService class with process and validate methods', () => { + pit('detects UserService class with process and validate methods', () => { expect(getNodesByLabel(result, 'Class')).toContain('UserService'); expect(getNodesByLabel(result, 'Method')).toContain('process'); expect(getNodesByLabel(result, 'Method')).toContain('validate'); }); - it('resolves SERVICE.process() via constant constructor binding', () => { + pit('resolves SERVICE.process() via constant constructor binding', () => { const calls = getRelationships(result, 'CALLS'); const processCall = calls.find( (c) => c.target === 'process' && c.targetFilePath === 'models.rb', @@ -509,7 +512,7 @@ describe('Ruby constant constructor binding resolution', () => { expect(processCall).toBeDefined(); }); - it('resolves SERVICE.validate() via constant constructor binding', () => { + pit('resolves SERVICE.validate() via constant constructor binding', () => { const calls = getRelationships(result, 'CALLS'); const validateCall = calls.find( (c) => c.target === 'validate' && c.targetFilePath === 'models.rb', @@ -529,13 +532,13 @@ describe('Ruby YARD annotation type resolution', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-yard-annotations'), () => {}); }, 60000); - it('detects UserRepo, User, and UserService classes', () => { + pit('detects UserRepo, User, and UserService classes', () => { expect(getNodesByLabel(result, 'Class')).toContain('UserRepo'); expect(getNodesByLabel(result, 'Class')).toContain('User'); expect(getNodesByLabel(result, 'Class')).toContain('UserService'); }); - it('detects save, find_by_name, greet, and create methods', () => { + pit('detects save, find_by_name, greet, and create methods', () => { const methods = getNodesByLabel(result, 'Method'); expect(methods).toContain('save'); expect(methods).toContain('find_by_name'); @@ -543,14 +546,14 @@ describe('Ruby YARD annotation type resolution', () => { expect(methods).toContain('create'); }); - it('resolves repo.save to UserRepo#save via YARD @param annotation', () => { + pit('resolves repo.save to UserRepo#save via YARD @param annotation', () => { const calls = getRelationships(result, 'CALLS'); const saveCall = calls.find((c) => c.target === 'save' && c.source === 'create'); expect(saveCall).toBeDefined(); expect(saveCall!.targetFilePath).toContain('models.rb'); }); - it('resolves user.greet to User#greet via YARD @param annotation', () => { + pit('resolves user.greet to User#greet via YARD @param annotation', () => { const calls = getRelationships(result, 'CALLS'); const greetCall = calls.find((c) => c.target === 'greet' && c.source === 'create'); expect(greetCall).toBeDefined(); @@ -573,14 +576,14 @@ describe('Ruby namespaced constructor resolution (Models::UserService.new)', () ); }, 60000); - it('detects UserService class with process and validate methods', () => { + pit('detects UserService class with process and validate methods', () => { expect(getNodesByLabel(result, 'Class')).toContain('UserService'); const methods = getNodesByLabel(result, 'Method'); expect(methods).toContain('process'); expect(methods).toContain('validate'); }); - it('resolves svc.process() via namespaced constructor Models::UserService.new', () => { + pit('resolves svc.process() via namespaced constructor Models::UserService.new', () => { const calls = getRelationships(result, 'CALLS'); const processCall = calls.find( (c) => c.target === 'process' && c.targetFilePath.includes('user_service.rb'), @@ -588,7 +591,7 @@ describe('Ruby namespaced constructor resolution (Models::UserService.new)', () expect(processCall).toBeDefined(); }); - it('resolves svc.validate() via namespaced constructor Models::UserService.new', () => { + pit('resolves svc.validate() via namespaced constructor Models::UserService.new', () => { const calls = getRelationships(result, 'CALLS'); const validateCall = calls.find( (c) => c.target === 'validate' && c.targetFilePath.includes('user_service.rb'), @@ -613,23 +616,23 @@ describe('Ruby return type inference via function call', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-return-type'), () => {}); }, 60000); - it('detects User and Repo classes', () => { + pit('detects User and Repo classes', () => { expect(getNodesByLabel(result, 'Class')).toContain('User'); expect(getNodesByLabel(result, 'Class')).toContain('Repo'); }); - it('detects get_user and get_repo methods', () => { + pit('detects get_user and get_repo methods', () => { expect(getNodesByLabel(result, 'Method')).toContain('get_user'); expect(getNodesByLabel(result, 'Method')).toContain('get_repo'); }); - it('detects save method on both User and Repo (disambiguation required)', () => { + pit('detects save method on both User and Repo (disambiguation required)', () => { const methods = getNodesByLabel(result, 'Method'); // Both classes have save — fuzzy match alone cannot resolve this expect(methods.filter((m) => m === 'save').length).toBe(2); }); - it('resolves user.save to User#save via YARD @return [User] on get_user()', () => { + pit('resolves user.save to User#save via YARD @return [User] on get_user()', () => { // With both User#save and Repo#save in scope, resolving user.save // requires return type inference: get_user() → @return [User] → user is User const calls = getRelationships(result, 'CALLS'); @@ -642,7 +645,7 @@ describe('Ruby return type inference via function call', () => { expect(saveCall).toBeDefined(); }); - it('resolves repo.save to Repo#save via YARD @return [Repo] on get_repo()', () => { + pit('resolves repo.save to Repo#save via YARD @return [Repo] on get_repo()', () => { const calls = getRelationships(result, 'CALLS'); const saveCall = calls.find( (c) => @@ -665,14 +668,14 @@ describe('Ruby constant factory call resolution (SERVICE = build_service())', () result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-constant-factory-call'), () => {}); }, 60000); - it('detects UserService and AdminService classes with process and validate methods', () => { + pit('detects UserService and AdminService classes with process and validate methods', () => { expect(getNodesByLabel(result, 'Class')).toContain('UserService'); expect(getNodesByLabel(result, 'Class')).toContain('AdminService'); expect(getNodesByLabel(result, 'Method')).toContain('process'); expect(getNodesByLabel(result, 'Method')).toContain('validate'); }); - it('resolves SERVICE.process() to UserService#process via constant factory call', () => { + pit('resolves SERVICE.process() to UserService#process via constant factory call', () => { const calls = getRelationships(result, 'CALLS'); const processCall = calls.find( (c) => c.target === 'process' && c.targetFilePath.includes('user_service.rb'), @@ -687,7 +690,7 @@ describe('Ruby constant factory call resolution (SERVICE = build_service())', () expect(wrongCall).toBeUndefined(); }); - it('resolves SERVICE.validate() to UserService#validate via constant factory call', () => { + pit('resolves SERVICE.validate() to UserService#validate via constant factory call', () => { const calls = getRelationships(result, 'CALLS'); const validateCall = calls.find( (c) => c.target === 'validate' && c.targetFilePath.includes('user_service.rb'), @@ -710,13 +713,13 @@ describe('Ruby YARD generic type annotations (Hash)', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-yard-generics'), () => {}); }, 60000); - it('detects UserRepo, AdminRepo, and DataService classes', () => { + pit('detects UserRepo, AdminRepo, and DataService classes', () => { expect(getNodesByLabel(result, 'Class')).toContain('UserRepo'); expect(getNodesByLabel(result, 'Class')).toContain('AdminRepo'); expect(getNodesByLabel(result, 'Class')).toContain('DataService'); }); - it('detects save and find_all on both repos, plus sync and audit methods', () => { + pit('detects save and find_all on both repos, plus sync and audit methods', () => { const methods = getNodesByLabel(result, 'Method'); expect(methods).toContain('save'); expect(methods).toContain('find_all'); @@ -724,7 +727,7 @@ describe('Ruby YARD generic type annotations (Hash)', () => { expect(methods).toContain('audit'); }); - it('resolves repo.save in sync() to UserRepo#save via @param repo [UserRepo]', () => { + pit('resolves repo.save in sync() to UserRepo#save via @param repo [UserRepo]', () => { const calls = getRelationships(result, 'CALLS'); const saveCall = calls.find( (c) => c.target === 'save' && c.source === 'sync' && c.targetFilePath.includes('models.rb'), @@ -732,26 +735,32 @@ describe('Ruby YARD generic type annotations (Hash)', () => { expect(saveCall).toBeDefined(); }); - it('does NOT resolve cache param to a class (Hash is a generic container)', () => { - // The @param cache [Hash] should extract type "Hash" — not "UserRepo". - // Since Hash is not a class in the fixture, no type binding is created for cache. - // This verifies the bracket-balanced split doesn't break on the inner comma. - const calls = getRelationships(result, 'CALLS'); - // No calls should originate from cache.* since cache has no resolved type - const cacheCall = calls.find( - (c) => c.source === 'sync' && c.target === 'save' && c.targetFilePath.includes('admin'), - ); - expect(cacheCall).toBeUndefined(); - }); + pit( + 'does NOT resolve cache param to a class (Hash is a generic container)', + () => { + // The @param cache [Hash] should extract type "Hash" — not "UserRepo". + // Since Hash is not a class in the fixture, no type binding is created for cache. + // This verifies the bracket-balanced split doesn't break on the inner comma. + const calls = getRelationships(result, 'CALLS'); + // No calls should originate from cache.* since cache has no resolved type + const cacheCall = calls.find( + (c) => c.source === 'sync' && c.target === 'save' && c.targetFilePath.includes('admin'), + ); + expect(cacheCall).toBeUndefined(); + }, + ); - it('resolves admin_repo.save in audit() to AdminRepo#save via alternate @param [AdminRepo] order', () => { - const calls = getRelationships(result, 'CALLS'); - // audit() calls admin_repo.save — should resolve via the alternate YARD format - const saveCall = calls.find((c) => c.target === 'save' && c.source === 'audit'); - expect(saveCall).toBeDefined(); - }); + pit( + 'resolves admin_repo.save in audit() to AdminRepo#save via alternate @param [AdminRepo] order', + () => { + const calls = getRelationships(result, 'CALLS'); + // audit() calls admin_repo.save — should resolve via the alternate YARD format + const saveCall = calls.find((c) => c.target === 'save' && c.source === 'audit'); + expect(saveCall).toBeDefined(); + }, + ); - it('resolves admin_repo.find_all in audit() to AdminRepo#find_all', () => { + pit('resolves admin_repo.find_all in audit() to AdminRepo#find_all', () => { const calls = getRelationships(result, 'CALLS'); const findCall = calls.find((c) => c.target === 'find_all' && c.source === 'audit'); expect(findCall).toBeDefined(); @@ -771,7 +780,7 @@ describe('Ruby chained method call resolution (Phase 5 review fix)', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-chain-call'), () => {}); }, 60000); - it('detects User, Repo, UserService and App classes', () => { + pit('detects User, Repo, UserService and App classes', () => { const classes = getNodesByLabel(result, 'Class'); expect(classes).toContain('User'); expect(classes).toContain('Repo'); @@ -779,18 +788,18 @@ describe('Ruby chained method call resolution (Phase 5 review fix)', () => { expect(classes).toContain('App'); }); - it('detects save methods on both User and Repo', () => { + pit('detects save methods on both User and Repo', () => { const methods = getNodesByLabel(result, 'Method'); const saveMethods = methods.filter((m) => m === 'save'); expect(saveMethods.length).toBe(2); }); - it('detects get_user method on UserService', () => { + pit('detects get_user method on UserService', () => { const methods = getNodesByLabel(result, 'Method'); expect(methods).toContain('get_user'); }); - it('resolves svc.get_user.save to User#save via chain resolution', () => { + pit('resolves svc.get_user.save to User#save via chain resolution', () => { const calls = getRelationships(result, 'CALLS'); const userSave = calls.find( (c) => c.target === 'save' && c.source === 'process' && c.targetFilePath?.includes('user.rb'), @@ -798,7 +807,7 @@ describe('Ruby chained method call resolution (Phase 5 review fix)', () => { expect(userSave).toBeDefined(); }); - it('does NOT resolve svc.get_user.save to Repo#save', () => { + pit('does NOT resolve svc.get_user.save to Repo#save', () => { const calls = getRelationships(result, 'CALLS'); const repoSave = calls.find( (c) => c.target === 'save' && c.source === 'process' && c.targetFilePath?.includes('repo.rb'), @@ -818,11 +827,11 @@ describe('Ruby for-in loop resolution', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-for-in-loop'), () => {}); }, 60000); - it('detects User class with save method', () => { + pit('detects User class with save method', () => { expect(getNodesByLabel(result, 'Class')).toContain('User'); }); - it('resolves user.save in for-in to User#save', () => { + pit('resolves user.save in for-in to User#save', () => { const calls = getRelationships(result, 'CALLS'); const userSave = calls.find( (c) => @@ -831,7 +840,7 @@ describe('Ruby for-in loop resolution', () => { expect(userSave).toBeDefined(); }); - it('does NOT resolve user.save to Repo#save (negative)', () => { + pit('does NOT resolve user.save to Repo#save (negative)', () => { const calls = getRelationships(result, 'CALLS'); const wrongSave = calls.find( (c) => @@ -852,18 +861,18 @@ describe('Field type resolution (Ruby)', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-field-types'), () => {}); }, 60000); - it('detects classes: Address, User', () => { + pit('detects classes: Address, User', () => { expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'User']); }); - it('detects Property nodes for attr_accessor fields', () => { + pit('detects Property nodes for attr_accessor fields', () => { const properties = getNodesByLabel(result, 'Property'); expect(properties).toContain('address'); expect(properties).toContain('name'); expect(properties).toContain('city'); }); - it('emits HAS_PROPERTY edges linking properties to classes', () => { + pit('emits HAS_PROPERTY edges linking properties to classes', () => { const propEdges = getRelationships(result, 'HAS_PROPERTY'); expect(propEdges.length).toBe(3); expect(edgeSet(propEdges)).toContain('User → address'); @@ -871,7 +880,7 @@ describe('Field type resolution (Ruby)', () => { expect(edgeSet(propEdges)).toContain('Address → city'); }); - it('resolves user.address.save → Address#save via YARD @return [Address]', () => { + pit('resolves user.address.save → Address#save via YARD @return [Address]', () => { const calls = getRelationships(result, 'CALLS'); const saveCalls = calls.filter((e) => e.target === 'save'); const addressSave = saveCalls.find( @@ -880,7 +889,7 @@ describe('Field type resolution (Ruby)', () => { expect(addressSave).toBeDefined(); }); - it('Property nodes contain expected field names', () => { + pit('Property nodes contain expected field names', () => { const properties = getNodesByLabelFull(result, 'Property'); const city = properties.find((p) => p.name === 'city'); @@ -905,13 +914,13 @@ describe('Field type disambiguation (Ruby)', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-field-type-disambig'), () => {}); }, 60000); - it('detects both User#save and Address#save', () => { + pit('detects both User#save and Address#save', () => { const methods = getNodesByLabel(result, 'Method'); const saveMethods = methods.filter((m) => m === 'save'); expect(saveMethods.length).toBe(2); }); - it('resolves user.address.save → Address#save (not User#save)', () => { + pit('resolves user.address.save → Address#save (not User#save)', () => { const calls = getRelationships(result, 'CALLS'); const saveCalls = calls.filter((e) => e.target === 'save' && e.source === 'process_user'); expect(saveCalls.length).toBe(1); @@ -931,7 +940,7 @@ describe('Write access tracking (Ruby)', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-write-access'), () => {}); }, 60000); - it('emits ACCESSES write edges for setter assignments', () => { + pit('emits ACCESSES write edges for setter assignments', () => { const accesses = getRelationships(result, 'ACCESSES'); const writes = accesses.filter((e) => e.rel.reason === 'write'); expect(writes.length).toBe(3); @@ -946,7 +955,7 @@ describe('Write access tracking (Ruby)', () => { expect(scoreWrite!.source).toBe('update_user'); }); - it('emits ACCESSES write edge for compound assignment (operator_assignment)', () => { + pit('emits ACCESSES write edge for compound assignment (operator_assignment)', () => { const accesses = getRelationships(result, 'ACCESSES'); const writes = accesses.filter((e) => e.rel.reason === 'write'); const scoreWrite = writes.find((e) => e.target === 'score'); @@ -954,7 +963,7 @@ describe('Write access tracking (Ruby)', () => { expect(scoreWrite!.source).toBe('update_user'); }); - it('write ACCESSES edges have confidence 1.0', () => { + pit('write ACCESSES edges have confidence 1.0', () => { const accesses = getRelationships(result, 'ACCESSES'); const writes = accesses.filter((e) => e.rel.reason === 'write'); for (const edge of writes) { @@ -974,7 +983,7 @@ describe('Ruby call-result variable binding (Tier 2b)', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-call-result-binding'), () => {}); }, 60000); - it('resolves user.save to User#save via call-result binding', () => { + pit('resolves user.save to User#save via call-result binding', () => { const calls = getRelationships(result, 'CALLS'); const saveCall = calls.find( (c) => c.target === 'save' && c.source === 'process_user' && c.targetFilePath.includes('app'), @@ -994,7 +1003,7 @@ describe('Ruby method chain binding via unified fixpoint (Phase 9C)', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-method-chain-binding'), () => {}); }, 60000); - it('resolves city.save to City#save via method chain', () => { + pit('resolves city.save to City#save via method chain', () => { const calls = getRelationships(result, 'CALLS'); const saveCall = calls.find( (c) => @@ -1019,7 +1028,7 @@ describe('Ruby grandparent method resolution via MRO (Phase B)', () => { ); }, 60000); - it('detects A, B, C, Greeting classes', () => { + pit('detects A, B, C, Greeting classes', () => { const classes = getNodesByLabel(result, 'Class'); expect(classes).toContain('A'); expect(classes).toContain('B'); @@ -1027,13 +1036,13 @@ describe('Ruby grandparent method resolution via MRO (Phase B)', () => { expect(classes).toContain('Greeting'); }); - it('emits EXTENDS edges: B→A, C→B', () => { + pit('emits EXTENDS edges: B→A, C→B', () => { const extends_ = getRelationships(result, 'EXTENDS'); expect(edgeSet(extends_)).toContain('B → A'); expect(edgeSet(extends_)).toContain('C → B'); }); - it('resolves c.greet.save to Greeting#save via depth-2 MRO lookup', () => { + pit('resolves c.greet.save to Greeting#save via depth-2 MRO lookup', () => { const calls = getRelationships(result, 'CALLS'); const saveCall = calls.find( (c) => c.target === 'save' && c.targetFilePath.includes('greeting'), @@ -1041,7 +1050,7 @@ describe('Ruby grandparent method resolution via MRO (Phase B)', () => { expect(saveCall).toBeDefined(); }); - it('resolves c.greet to A#greet (method found via MRO walk)', () => { + pit('resolves c.greet to A#greet (method found via MRO walk)', () => { const calls = getRelationships(result, 'CALLS'); const greetCall = calls.find((c) => c.target === 'greet' && c.targetFilePath.includes('a.rb')); expect(greetCall).toBeDefined(); @@ -1059,7 +1068,7 @@ describe('Ruby default parameter arity resolution', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-default-params'), () => {}); }, 60000); - it('resolves greet("Alice") with 1 arg to greet with 2 params (1 default)', () => { + pit('resolves greet("Alice") with 1 arg to greet with 2 params (1 default)', () => { const calls = getRelationships(result, 'CALLS'); const greetCalls = calls.filter((c) => c.source === 'process' && c.target === 'greet'); expect(greetCalls.length).toBe(1); @@ -1081,18 +1090,18 @@ describe('Ruby cross-file binding propagation', () => { result = await runPipelineFromRepo(path.join(CROSS_FILE_FIXTURES, 'rb-cross-file'), () => {}); }, 60000); - it('detects User class with save and get_name methods', () => { + pit('detects User class with save and get_name methods', () => { expect(getNodesByLabel(result, 'Class')).toContain('User'); expect(getNodesByLabel(result, 'Method')).toContain('save'); expect(getNodesByLabel(result, 'Method')).toContain('get_name'); }); - it('detects UserFactory class and get_user method', () => { + pit('detects UserFactory class and get_user method', () => { expect(getNodesByLabel(result, 'Class')).toContain('UserFactory'); expect(getNodesByLabel(result, 'Method')).toContain('get_user'); }); - it('emits IMPORTS edge from app.rb to models', () => { + pit('emits IMPORTS edge from app.rb to models', () => { const imports = getRelationships(result, 'IMPORTS'); const edge = imports.find( (e) => e.sourceFilePath.includes('app') && e.targetFilePath.includes('models'), @@ -1100,7 +1109,7 @@ describe('Ruby cross-file binding propagation', () => { expect(edge).toBeDefined(); }); - it('resolves user.save in process to User#save via cross-file propagation', () => { + pit('resolves user.save in process to User#save via cross-file propagation', () => { const calls = getRelationships(result, 'CALLS'); const saveCall = calls.find( (c) => c.target === 'save' && c.source === 'process' && c.targetFilePath.includes('models'), @@ -1108,7 +1117,7 @@ describe('Ruby cross-file binding propagation', () => { expect(saveCall).toBeDefined(); }); - it('resolves user.get_name in process to User#get_name via cross-file propagation', () => { + pit('resolves user.get_name in process to User#get_name via cross-file propagation', () => { const calls = getRelationships(result, 'CALLS'); const getNameCall = calls.find( (c) => @@ -1117,7 +1126,7 @@ describe('Ruby cross-file binding propagation', () => { expect(getNameCall).toBeDefined(); }); - it('emits HAS_METHOD edges linking save and get_name to User', () => { + pit('emits HAS_METHOD edges linking save and get_name to User', () => { const hasMethod = getRelationships(result, 'HAS_METHOD'); const saveEdge = hasMethod.find((e) => e.source === 'User' && e.target === 'save'); const getNameEdge = hasMethod.find((e) => e.source === 'User' && e.target === 'get_name'); @@ -1138,11 +1147,11 @@ describe('Ruby method enrichment (visibility, isStatic, parameters)', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-method-enrichment'), () => {}); }, 60000); - it('detects Animal and Dog classes', () => { + pit('detects Animal and Dog classes', () => { expect(getNodesByLabel(result, 'Class')).toEqual(['Animal', 'Dog']); }); - it('detects all methods including singleton', () => { + pit('detects all methods including singleton', () => { const methods = getNodesByLabel(result, 'Method'); expect(methods).toContain('speak'); expect(methods).toContain('classify'); @@ -1151,7 +1160,7 @@ describe('Ruby method enrichment (visibility, isStatic, parameters)', () => { expect(methods).toContain('energy_level'); }); - it('emits HAS_METHOD edges for Animal and Dog', () => { + pit('emits HAS_METHOD edges for Animal and Dog', () => { const hasMethod = getRelationships(result, 'HAS_METHOD'); // Animal has speak, classify, from_habitat, internal_state expect(hasMethod.find((e) => e.source === 'Animal' && e.target === 'speak')).toBeDefined(); @@ -1167,7 +1176,7 @@ describe('Ruby method enrichment (visibility, isStatic, parameters)', () => { expect(hasMethod.find((e) => e.source === 'Dog' && e.target === 'energy_level')).toBeDefined(); }); - it('marks internal_state as private (when enriched)', () => { + pit('marks internal_state as private (when enriched)', () => { const methods = getNodesByLabelFull(result, 'Method'); const internalState = methods.find( (m) => m.name === 'internal_state' && m.properties.filePath?.includes('animal'), @@ -1180,7 +1189,7 @@ describe('Ruby method enrichment (visibility, isStatic, parameters)', () => { } }); - it('marks energy_level as protected (when enriched)', () => { + pit('marks energy_level as protected (when enriched)', () => { const methods = getNodesByLabelFull(result, 'Method'); const energyLevel = methods.find( (m) => m.name === 'energy_level' && m.properties.filePath?.includes('animal'), @@ -1191,7 +1200,7 @@ describe('Ruby method enrichment (visibility, isStatic, parameters)', () => { } }); - it('marks classify as static (when enriched)', () => { + pit('marks classify as static (when enriched)', () => { const methods = getNodesByLabelFull(result, 'Method'); const classify = methods.find( (m) => m.name === 'classify' && m.properties.filePath?.includes('animal'), @@ -1202,7 +1211,7 @@ describe('Ruby method enrichment (visibility, isStatic, parameters)', () => { } }); - it('marks from_habitat (class << self) as static and public (when enriched)', () => { + pit('marks from_habitat (class << self) as static and public (when enriched)', () => { const methods = getNodesByLabelFull(result, 'Method'); const fromHabitat = methods.find( (m) => m.name === 'from_habitat' && m.properties.filePath?.includes('animal'), @@ -1216,7 +1225,7 @@ describe('Ruby method enrichment (visibility, isStatic, parameters)', () => { } }); - it('extracts parameterCount for from_habitat(habitat)', () => { + pit('extracts parameterCount for from_habitat(habitat)', () => { const methods = getNodesByLabelFull(result, 'Method'); const fromHabitat = methods.find( (m) => m.name === 'from_habitat' && m.properties.filePath?.includes('animal'), @@ -1225,7 +1234,7 @@ describe('Ruby method enrichment (visibility, isStatic, parameters)', () => { expect(fromHabitat!.properties.parameterCount).toBe(1); }); - it('marks speak as public (when enriched)', () => { + pit('marks speak as public (when enriched)', () => { const methods = getNodesByLabelFull(result, 'Method'); const speak = methods.find( (m) => m.name === 'speak' && m.properties.filePath?.includes('animal'), @@ -1237,7 +1246,7 @@ describe('Ruby method enrichment (visibility, isStatic, parameters)', () => { } }); - it('extracts parameterCount for classify(name)', () => { + pit('extracts parameterCount for classify(name)', () => { const methods = getNodesByLabelFull(result, 'Method'); const classify = methods.find( (m) => m.name === 'classify' && m.properties.filePath?.includes('animal'), @@ -1246,7 +1255,7 @@ describe('Ruby method enrichment (visibility, isStatic, parameters)', () => { expect(classify!.properties.parameterCount).toBe(1); }); - it('resolves dog.speak member call from main to Dog#speak', () => { + pit('resolves dog.speak member call from main to Dog#speak', () => { const calls = getRelationships(result, 'CALLS'); const speakCall = calls.find( (c) => c.source === 'main' && c.target === 'speak' && c.targetFilePath.includes('animal'), @@ -1254,13 +1263,13 @@ describe('Ruby method enrichment (visibility, isStatic, parameters)', () => { expect(speakCall).toBeDefined(); }); - it('emits EXTENDS edge from Dog to Animal', () => { + pit('emits EXTENDS edge from Dog to Animal', () => { const extends_ = getRelationships(result, 'EXTENDS'); const edge = extends_.find((e) => e.source === 'Dog' && e.target === 'Animal'); expect(edge).toBeDefined(); }); - it('detects main as top-level Method in app.rb', () => { + pit('detects main as top-level Method in app.rb', () => { // Ruby top-level def is parsed as a method node (tree-sitter `method` type) const methods = getNodesByLabel(result, 'Method'); expect(methods).toContain('main'); @@ -1276,14 +1285,14 @@ describe('Ruby singleton_class handling via sequential path (skipWorkers)', () = }); }, 60000); - it('keeps Animal as the owner for class << self methods', () => { + pit('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', () => { + pit('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'), @@ -1305,17 +1314,17 @@ describe('Ruby overload dispatch (format vs format_with_prefix)', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-overload-dispatch'), () => {}); }, 60000); - it('detects Formatter class', () => { + pit('detects Formatter class', () => { expect(getNodesByLabel(result, 'Class')).toContain('Formatter'); }); - it('detects format and format_with_prefix methods', () => { + pit('detects format and format_with_prefix methods', () => { const methods = getNodesByLabel(result, 'Method'); expect(methods).toContain('format'); expect(methods).toContain('format_with_prefix'); }); - it('emits HAS_METHOD edges for both methods on Formatter', () => { + pit('emits HAS_METHOD edges for both methods on Formatter', () => { const hasMethod = getRelationships(result, 'HAS_METHOD'); expect(hasMethod.find((e) => e.source === 'Formatter' && e.target === 'format')).toBeDefined(); expect( @@ -1323,21 +1332,21 @@ describe('Ruby overload dispatch (format vs format_with_prefix)', () => { ).toBeDefined(); }); - it('extracts arity for format(value) — 1 parameter', () => { + pit('extracts arity for format(value) — 1 parameter', () => { const methods = getNodesByLabelFull(result, 'Method'); const format = methods.find((m) => m.name === 'format'); expect(format).toBeDefined(); expect(format!.properties.parameterCount).toBe(1); }); - it('extracts arity for format_with_prefix(value, prefix) — 2 parameters', () => { + pit('extracts arity for format_with_prefix(value, prefix) — 2 parameters', () => { const methods = getNodesByLabelFull(result, 'Method'); const fwp = methods.find((m) => m.name === 'format_with_prefix'); expect(fwp).toBeDefined(); expect(fwp!.properties.parameterCount).toBe(2); }); - it('resolves f.format call from run to Formatter#format', () => { + pit('resolves f.format call from run to Formatter#format', () => { const calls = getRelationships(result, 'CALLS'); const formatCall = calls.find( (c) => c.source === 'run' && c.target === 'format' && c.targetFilePath.includes('formatter'), @@ -1345,7 +1354,7 @@ describe('Ruby overload dispatch (format vs format_with_prefix)', () => { expect(formatCall).toBeDefined(); }); - it('resolves f.format_with_prefix call from run to Formatter#format_with_prefix', () => { + pit('resolves f.format_with_prefix call from run to Formatter#format_with_prefix', () => { const calls = getRelationships(result, 'CALLS'); const fwpCall = calls.find( (c) => @@ -1356,7 +1365,7 @@ describe('Ruby overload dispatch (format vs format_with_prefix)', () => { expect(fwpCall).toBeDefined(); }); - it('detects run as top-level Method in app.rb', () => { + pit('detects run as top-level Method in app.rb', () => { // Ruby top-level def is parsed as a method node (tree-sitter `method` type) const methods = getNodesByLabel(result, 'Method'); expect(methods).toContain('run'); @@ -1374,13 +1383,13 @@ describe('Ruby Child extends Parent — inherited method resolution (SM-9)', () result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-child-extends-parent'), () => {}); }, 60000); - it('detects Parent and Child classes', () => { + pit('detects Parent and Child classes', () => { const classes = getNodesByLabel(result, 'Class'); expect(classes).toContain('Parent'); expect(classes).toContain('Child'); }); - it('resolves c.parent_method to Parent#parent_method via first-wins MRO walk', () => { + pit('resolves c.parent_method to Parent#parent_method via first-wins MRO walk', () => { const calls = getRelationships(result, 'CALLS'); const parentMethodCall = calls.find( (c) => c.target === 'parent_method' && c.targetFilePath.includes('parent.rb'), diff --git a/gitnexus/test/integration/ruby-pipeline-benchmark.test.ts b/gitnexus/test/integration/ruby-pipeline-benchmark.test.ts new file mode 100644 index 000000000..3f3b10ee8 --- /dev/null +++ b/gitnexus/test/integration/ruby-pipeline-benchmark.test.ts @@ -0,0 +1,305 @@ +/** + * Ruby ingestion pipeline benchmark. + * + * Generates synthetic Ruby codebases at increasing scales and measures + * wall-clock time and peak heap through the full pipeline — parsing, + * scope extraction, heritage (include/extend/prepend), MRO construction, + * and call resolution via the registry-primary scope-resolution path. + * + * Run: GITNEXUS_BENCH=1 npx vitest run test/integration/ruby-pipeline-benchmark.test.ts + * + * The benchmark uses workers (production path) by default. Set + * skipWorkers to test the sequential fallback path. + */ +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; + +const BENCH_ENABLED = process.env.GITNEXUS_BENCH === '1'; + +interface BenchResult { + fileCount: number; + classCount: number; + moduleCount: number; + mixinModuleCount: number; + elapsedMs: number; + peakHeapMB: number; + nodeCount: number; + edgeCount: number; + implementsCount: number; + hasPropertyCount: number; + extendsCount: number; +} + +function generateRubyFixture( + fileCount: number, + modulesPerLevel: number, +): { dir: string; classCount: number; moduleCount: number; mixinModuleCount: number } { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `ruby-bench-${fileCount}-`)); + + // Three families of mixins: one for include, one for extend, one for prepend. + // Each family has modulesPerLevel² modules so the MRO partitioning logic is + // exercised with all three heritage kinds and varied orderings. + const includeMixins: string[] = []; + const extendMixins: string[] = []; + const prependMixins: string[] = []; + + for (let i = 0; i < modulesPerLevel; i++) { + for (let j = 0; j < modulesPerLevel; j++) { + includeMixins.push(`Includable${i}x${j}`); + extendMixins.push(`Extendable${i}x${j}`); + prependMixins.push(`Prependable${i}x${j}`); + } + } + + const allMixins = [...includeMixins, ...extendMixins, ...prependMixins]; + const moduleCount = allMixins.length; + const classCount = fileCount; + + // Generate mixin module files — each module includes a shared base module + // to create diamond mixin patterns (class includes A and B, both include Base). + const concernsDir = path.join(dir, 'lib', 'concerns'); + fs.mkdirSync(concernsDir, { recursive: true }); + + // Shared base modules that other mixins include (diamond pattern) + const baseModuleCount = Math.max(2, Math.floor(modulesPerLevel / 2)); + for (let b = 0; b < baseModuleCount; b++) { + const baseName = `BaseMixin${b}`; + const content = [ + `module ${baseName}`, + ` def base${b}_check`, + ' true', + ' end', + 'end', + '', + ].join('\n'); + fs.writeFileSync(path.join(concernsDir, `${baseName.toLowerCase()}.rb`), content); + } + + for (let m = 0; m < allMixins.length; m++) { + const moduleName = allMixins[m]; + const baseIdx = m % baseModuleCount; + const baseName = `BaseMixin${baseIdx}`; + const content = [ + `require_relative '${baseName.toLowerCase()}'`, + '', + `module ${moduleName}`, + ` include ${baseName}`, + '', + ` def ${moduleName.toLowerCase()}_action`, + ` base${baseIdx}_check`, + ' end', + 'end', + '', + ].join('\n'); + fs.writeFileSync(path.join(concernsDir, `${moduleName.toLowerCase()}.rb`), content); + } + + // Generate class files — each class uses include + extend + prepend with + // different modules, creating a rich MRO that exercises all three + // heritage-kind partitions in buildRubyMro. + const modelsDir = path.join(dir, 'lib', 'models'); + fs.mkdirSync(modelsDir, { recursive: true }); + + for (let f = 0; f < fileCount; f++) { + const className = `Model${f}`; + + // Pick one mixin of each kind (rotating through the pools) + const incMixin = includeMixins[f % includeMixins.length]; + const extMixin = extendMixins[f % extendMixins.length]; + const preMixin = prependMixins[f % prependMixins.length]; + // Second include mixin for diamond-overlap testing + const incMixin2 = includeMixins[(f + 1) % includeMixins.length]; + + const siblingIdx = (f + 1) % fileCount; + const siblingClass = `Model${siblingIdx}`; + + const crossIdx = (f + Math.floor(fileCount / 3)) % fileCount; + const crossClass = `Model${crossIdx}`; + + const requireLines = [ + `require_relative '../concerns/${incMixin.toLowerCase()}'`, + `require_relative '../concerns/${incMixin2.toLowerCase()}'`, + `require_relative '../concerns/${extMixin.toLowerCase()}'`, + `require_relative '../concerns/${preMixin.toLowerCase()}'`, + f !== siblingIdx ? `require_relative '${siblingClass.toLowerCase()}'` : '', + f !== crossIdx ? `require_relative '${crossClass.toLowerCase()}'` : '', + ].filter(Boolean); + + const content = [ + ...requireLines, + '', + `class ${className}`, + ` include ${incMixin}`, + ` include ${incMixin2}`, + ` extend ${extMixin}`, + ` prepend ${preMixin}`, + '', + ` attr_accessor :id, :name, :status`, + '', + ` # @param other [${siblingClass}]`, + ` # @return [${siblingClass}]`, + ` def process(other)`, + ` other.save`, + ` ${incMixin.toLowerCase()}_action`, + ` other`, + ' end', + '', + ' def save', + ' true', + ' end', + '', + ` # @return [${crossClass}]`, + ` def build_cross`, + ` ${crossClass}.new`, + ' end', + '', + ` def self.class_action`, + ` ${extMixin.toLowerCase()}_action`, + ' end', + 'end', + '', + ].join('\n'); + + fs.writeFileSync(path.join(modelsDir, `${className.toLowerCase()}.rb`), content); + } + + return { + dir, + classCount, + moduleCount: moduleCount + baseModuleCount, + mixinModuleCount: moduleCount, + }; +} + +async function runBenchmark( + fileCount: number, + moduleLevels: number, + budgetMs: number, +): Promise { + const { dir, classCount, moduleCount, mixinModuleCount } = generateRubyFixture( + fileCount, + moduleLevels, + ); + + let peakHeapMB = 0; + const heapSampler = setInterval(() => { + const heap = process.memoryUsage().heapUsed / 1024 / 1024; + if (heap > peakHeapMB) peakHeapMB = heap; + }, 50); + + try { + const start = Date.now(); + const result = await Promise.race([ + runPipelineFromRepo(dir, () => {}, { skipGraphPhases: true }), + new Promise((_, reject) => + setTimeout( + () => reject(new Error(`Pipeline exceeded ${budgetMs}ms at ${fileCount} files`)), + budgetMs, + ), + ), + ]); + const elapsedMs = Date.now() - start; + + let implementsCount = 0; + let hasPropertyCount = 0; + let extendsCount = 0; + for (const rel of result.graph.iterRelationshipsByType('IMPLEMENTS')) { + implementsCount++; + void rel; + } + for (const rel of result.graph.iterRelationshipsByType('HAS_PROPERTY')) { + hasPropertyCount++; + void rel; + } + for (const rel of result.graph.iterRelationshipsByType('EXTENDS')) { + extendsCount++; + void rel; + } + + return { + fileCount, + classCount, + moduleCount, + mixinModuleCount, + elapsedMs, + peakHeapMB: Math.round(peakHeapMB), + nodeCount: result.graph.nodeCount, + edgeCount: result.graph.relationshipCount, + implementsCount, + hasPropertyCount, + extendsCount, + }; + } finally { + clearInterval(heapSampler); + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +function printResults(label: string, results: BenchResult[]) { + console.log(`\n${label}`); + console.log( + '┌──────────┬─────────┬──────────┬───────────┬──────────┬───────┬───────┬──────┬───────┬─────┐', + ); + console.log( + '│ Files │ Classes │ Modules │ Time (ms) │ Heap MB │ Nodes │ Edges │ IMPL │ PROPS │ EXT │', + ); + console.log( + '├──────────┼─────────┼──────────┼───────────┼──────────┼───────┼───────┼──────┼───────┼─────┤', + ); + for (const r of results) { + console.log( + `│ ${String(r.fileCount).padStart(8)} │ ${String(r.classCount).padStart(7)} │ ${String(r.moduleCount).padStart(8)} │ ${String(r.elapsedMs).padStart(9)} │ ${String(r.peakHeapMB).padStart(8)} │ ${String(r.nodeCount).padStart(5)} │ ${String(r.edgeCount).padStart(5)} │ ${String(r.implementsCount).padStart(4)} │ ${String(r.hasPropertyCount).padStart(5)} │ ${String(r.extendsCount).padStart(3)} │`, + ); + } + console.log( + '└──────────┴─────────┴──────────┴───────────┴──────────┴───────┴───────┴──────┴───────┴─────┘', + ); + + if (results.length >= 2) { + console.log('\nScaling ratios (time_ratio / file_ratio):'); + for (let i = 1; i < results.length; i++) { + const fileRatio = results[i].fileCount / results[i - 1].fileCount; + const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs; + const scaling = timeRatio / fileRatio; + console.log( + ` ${results[i - 1].fileCount} → ${results[i].fileCount}: ${scaling.toFixed(2)}x (${scaling < 1.5 ? 'linear' : scaling < 3 ? 'superlinear' : 'WARNING: quadratic'})`, + ); + } + } +} + +describe.skipIf(!BENCH_ENABLED)('Ruby pipeline benchmark', () => { + it('scales with file count (workers enabled)', async () => { + const scales = [100, 250, 500]; + const results: BenchResult[] = []; + + for (const fileCount of scales) { + const moduleLevels = Math.max(2, Math.ceil(Math.sqrt(fileCount / 4))); + const result = await runBenchmark(fileCount, moduleLevels, 180_000); + results.push(result); + console.log( + ` ${fileCount} files: ${result.elapsedMs}ms, ${result.peakHeapMB}MB heap, ${result.nodeCount} nodes, ${result.edgeCount} edges`, + ); + } + + printResults('Ruby Pipeline — Workers Enabled', results); + + for (let i = 1; i < results.length; i++) { + const fileRatio = results[i].fileCount / results[i - 1].fileCount; + const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs; + expect(timeRatio / fileRatio).toBeLessThan(3); + } + + // Verify heritage emission produces exact expected counts. + // Each class: 2x include + 1x extend + 1x prepend = 4 IMPLEMENTS. + // Each mixin module (non-base) includes one BaseMixin = 1 IMPLEMENTS. + // Each class: attr_accessor :id, :name, :status = 3 HAS_PROPERTY. + for (const r of results) { + expect(r.implementsCount).toBe(r.classCount * 4 + r.mixinModuleCount); + expect(r.hasPropertyCount).toBe(r.classCount * 3); + } + }, 300_000); +}); diff --git a/gitnexus/test/unit/registry-primary-flag.test.ts b/gitnexus/test/unit/registry-primary-flag.test.ts index 856dcef43..991bc5fe1 100644 --- a/gitnexus/test/unit/registry-primary-flag.test.ts +++ b/gitnexus/test/unit/registry-primary-flag.test.ts @@ -108,20 +108,20 @@ describe('isRegistryPrimary', () => { it('isolates flags per-language (one on does not affect others)', () => { process.env['REGISTRY_PRIMARY_PYTHON'] = 'true'; expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true); - // Ruby is not in MIGRATED_LANGUAGES — default false stays + // Swift is not in MIGRATED_LANGUAGES — default false stays // false regardless of Python's flag. - expect(isRegistryPrimary(SupportedLanguages.Ruby)).toBe(false); + expect(isRegistryPrimary(SupportedLanguages.Swift)).toBe(false); }); it('respects a mid-process env-var mutation (no stale cache)', () => { - // Use Ruby — not in MIGRATED_LANGUAGES — so the unset default is + // Use Swift — not in MIGRATED_LANGUAGES — so the unset default is // deterministically `false`, independent of which languages have // been flipped to registry-primary. - expect(isRegistryPrimary(SupportedLanguages.Ruby)).toBe(false); - process.env['REGISTRY_PRIMARY_RUBY'] = 'true'; - expect(isRegistryPrimary(SupportedLanguages.Ruby)).toBe(true); - delete process.env['REGISTRY_PRIMARY_RUBY']; - expect(isRegistryPrimary(SupportedLanguages.Ruby)).toBe(false); + expect(isRegistryPrimary(SupportedLanguages.Swift)).toBe(false); + process.env['REGISTRY_PRIMARY_SWIFT'] = 'true'; + expect(isRegistryPrimary(SupportedLanguages.Swift)).toBe(true); + delete process.env['REGISTRY_PRIMARY_SWIFT']; + expect(isRegistryPrimary(SupportedLanguages.Swift)).toBe(false); }); it('handles the CPlusPlus → REGISTRY_PRIMARY_CPP mapping correctly', () => {