diff --git a/gitnexus/src/core/ingestion/languages/java/captures.ts b/gitnexus/src/core/ingestion/languages/java/captures.ts index 73ea605fe..5ca470025 100644 --- a/gitnexus/src/core/ingestion/languages/java/captures.ts +++ b/gitnexus/src/core/ingestion/languages/java/captures.ts @@ -38,10 +38,6 @@ function shouldEmitReadMember(memberNode: SyntaxNode): boolean { if (parent === null) return true; switch (parent.type) { - case 'method_invocation': - // Don't emit read.member when the field_access is the object of a method_invocation - // (the method call already handles this relationship) - return parent.childForFieldName('object')?.id !== memberNode.id; case 'assignment_expression': return parent.childForFieldName('left')?.id !== memberNode.id; default: @@ -185,13 +181,137 @@ export function emitJavaScopeCaptures( callNode, JSON.stringify(argTypes), ); + + const argNames = args.map((a) => (a!.type === 'identifier' ? a!.text : '')); + if (argNames.some((n) => n !== '')) { + grouped['@reference.arg-names'] = syntheticCapture( + '@reference.arg-names', + callNode, + JSON.stringify(argNames), + ); + } } } out.push(grouped); } - return out; + return resolveVarTypeBindings(out); +} + +function resolveVarTypeBindings(matches: CaptureMatch[]): CaptureMatch[] { + const returnTypes = new Map(); + const varTypes = new Map(); + const ambiguousReturns = new Set(); + const ambiguousVars = new Set(); + + for (const m of matches) { + if ( + m['@type-binding.return'] !== undefined && + m['@type-binding.type'] !== undefined && + m['@type-binding.name'] !== undefined + ) { + const name = m['@type-binding.name'].text; + const type = m['@type-binding.type'].text; + const existing = returnTypes.get(name); + if (existing !== undefined && existing !== type) { + ambiguousReturns.add(name); + returnTypes.delete(name); + } else if (!ambiguousReturns.has(name)) { + returnTypes.set(name, type); + } + } + if ( + m['@type-binding.annotation'] !== undefined && + m['@type-binding.type'] !== undefined && + m['@type-binding.name'] !== undefined + ) { + const name = m['@type-binding.name'].text; + const t = m['@type-binding.type'].text; + if (t !== 'var') { + const existing = varTypes.get(name); + if (existing !== undefined && existing !== t) { + ambiguousVars.add(name); + varTypes.delete(name); + } else if (!ambiguousVars.has(name)) { + varTypes.set(name, t); + } + } + } + if ( + m['@type-binding.constructor'] !== undefined && + m['@type-binding.type'] !== undefined && + m['@type-binding.name'] !== undefined + ) { + const name = m['@type-binding.name'].text; + const type = m['@type-binding.type'].text; + const existing = varTypes.get(name); + if (existing !== undefined && existing !== type) { + ambiguousVars.add(name); + varTypes.delete(name); + } else if (!ambiguousVars.has(name)) { + varTypes.set(name, type); + } + } + } + + const resolved: CaptureMatch[] = []; + for (const m of matches) { + if (m['@type-binding.call-result'] !== undefined && m['@type-binding.type'] !== undefined) { + const methodName = m['@type-binding.type'].text; + const resolvedType = returnTypes.get(methodName); + if (resolvedType !== undefined) { + const patched: Record = { ...m }; + patched['@type-binding.type'] = { ...m['@type-binding.type']!, text: resolvedType }; + patched['@type-binding.annotation'] = m['@type-binding.call-result']!; + delete patched['@type-binding.call-result']; + resolved.push(patched); + continue; + } + } + if (m['@type-binding.alias'] !== undefined && m['@type-binding.type'] !== undefined) { + const sourceName = m['@type-binding.type'].text; + const resolvedType = varTypes.get(sourceName); + if (resolvedType !== undefined) { + const patched: Record = { ...m }; + patched['@type-binding.type'] = { ...m['@type-binding.type']!, text: resolvedType }; + patched['@type-binding.annotation'] = m['@type-binding.alias']!; + delete patched['@type-binding.alias']; + resolved.push(patched); + continue; + } + } + if (m['@reference.arg-names'] !== undefined && m['@reference.parameter-types'] !== undefined) { + try { + const types: string[] = JSON.parse(m['@reference.parameter-types'].text); + const names: string[] = JSON.parse(m['@reference.arg-names'].text); + let patched = false; + for (let i = 0; i < types.length; i++) { + if (types[i] === '' && names[i] !== undefined && names[i] !== '') { + const rt = varTypes.get(names[i]!); + if (rt !== undefined) { + types[i] = rt; + patched = true; + } + } + } + if (patched) { + const patchedMatch: Record = { ...m }; + patchedMatch['@reference.parameter-types'] = { + ...m['@reference.parameter-types']!, + text: JSON.stringify(types), + }; + delete patchedMatch['@reference.arg-names']; + resolved.push(patchedMatch); + continue; + } + } catch { + // pass through + } + } + resolved.push(m); + } + return resolved; } type SyntaxNode = ReturnType['parse']>['rootNode']; diff --git a/gitnexus/src/core/ingestion/languages/java/interpret.ts b/gitnexus/src/core/ingestion/languages/java/interpret.ts index 9c207d451..87da580b6 100644 --- a/gitnexus/src/core/ingestion/languages/java/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/java/interpret.ts @@ -24,10 +24,11 @@ export function interpretJavaImport(captures: CaptureMatch): ParsedImport | null switch (kind) { case 'named': { // `import com.example.User;` + const simpleName = sourceCap.text.split('.').pop() ?? sourceCap.text; return { kind: 'named', - localName: nameCap?.text ?? sourceCap.text.split('.').pop() ?? sourceCap.text, - importedName: sourceCap.text, + localName: nameCap?.text ?? simpleName, + importedName: simpleName, targetRaw: sourceCap.text, }; } @@ -40,17 +41,14 @@ export function interpretJavaImport(captures: CaptureMatch): ParsedImport | null } case 'static': { // `import static com.example.Utils.format;` - // The source contains the full path including the member name - // (e.g. `com.example.Utils.format`). For file resolution we need - // the class path (`com.example.Utils`), so strip the final member - // segment. The local binding name is the member itself. const fullSource = sourceCap.text; const lastDot = fullSource.lastIndexOf('.'); + const memberName = lastDot >= 0 ? fullSource.slice(lastDot + 1) : fullSource; const classPath = lastDot >= 0 ? fullSource.slice(0, lastDot) : fullSource; return { kind: 'named', - localName: nameCap?.text ?? (lastDot >= 0 ? fullSource.slice(lastDot + 1) : fullSource), - importedName: fullSource, + localName: nameCap?.text ?? memberName, + importedName: memberName, targetRaw: classPath, }; } @@ -89,6 +87,9 @@ export function interpretJavaTypeBinding(captures: CaptureMatch): ParsedTypeBind let source: TypeRef['source'] = 'parameter-annotation'; if (captures['@type-binding.self'] !== undefined) source = 'self'; else if (captures['@type-binding.constructor'] !== undefined) source = 'constructor-inferred'; + else if (captures['@type-binding.pattern'] !== undefined) source = 'annotation'; + else if (captures['@type-binding.call-result'] !== undefined) source = 'annotation'; + else if (captures['@type-binding.alias'] !== undefined) source = 'annotation'; else if (captures['@type-binding.annotation'] !== undefined) source = 'annotation'; else if (captures['@type-binding.return'] !== undefined) source = 'return-annotation'; diff --git a/gitnexus/src/core/ingestion/languages/java/package-siblings.ts b/gitnexus/src/core/ingestion/languages/java/package-siblings.ts new file mode 100644 index 000000000..4ba5ac1e1 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/java/package-siblings.ts @@ -0,0 +1,156 @@ +/** + * Java package-scope implicit visibility. + * + * Classes in the same Java package see each other without explicit + * `import` statements. This hook groups files by `package` declaration, + * then injects cross-file class defs into each file's module-scope + * `bindingAugmentations` and mirrors type-bindings across same-package + * files — the Java equivalent of C#'s `populateNamespaceSiblings`. + */ + +import type { BindingRef, ParsedFile, ScopeId, TypeRef } from 'gitnexus-shared'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import { isClassLike } from '../../scope-resolution/scope/walkers.js'; +import { getJavaParser } from './query.js'; +import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; +import { logger } from '../../../logger.js'; + +function extractPackageName(content: string, cachedTree?: unknown): string { + const tree = + (cachedTree as ReturnType['parse']> | undefined) ?? + parseSourceSafe(getJavaParser(), content); + for (const child of tree.rootNode.namedChildren) { + if (child.type === 'package_declaration') { + const scoped = child.namedChildren.find( + (c) => c.type === 'scoped_identifier' || c.type === 'identifier', + ); + return scoped?.text ?? ''; + } + } + return ''; +} + +interface PackageBucket { + readonly parsed: ParsedFile[]; + readonly moduleScopes: { filePath: string; scope: ParsedFile['scopes'][number] }[]; +} + +export function populateJavaPackageSiblings( + parsedFiles: readonly ParsedFile[], + indexes: ScopeResolutionIndexes, + ctx: { + readonly fileContents: ReadonlyMap; + readonly treeCache?: { get(filePath: string): unknown }; + }, +): void { + const buckets = new Map(); + + for (const parsed of parsedFiles) { + const content = ctx.fileContents.get(parsed.filePath); + if (content === undefined) continue; + const pkg = extractPackageName(content, ctx.treeCache?.get(parsed.filePath)); + let bucket = buckets.get(pkg); + if (bucket === undefined) { + bucket = { parsed: [], moduleScopes: [] }; + buckets.set(pkg, bucket); + } + bucket.parsed.push(parsed); + const ms = parsed.scopes.find((s) => s.kind === 'Module'); + if (ms !== undefined) { + bucket.moduleScopes.push({ filePath: parsed.filePath, scope: ms }); + } + } + + const augmentations = indexes.bindingAugmentations as Map>; + + const MAX_PACKAGE_FILES = 500; + + for (const bucket of buckets.values()) { + if (bucket.moduleScopes.length < 2) continue; + if (bucket.moduleScopes.length > MAX_PACKAGE_FILES) { + logger.warn( + `[java-package-siblings] skipping package with ${bucket.moduleScopes.length} files (cap=${MAX_PACKAGE_FILES}); same-package implicit visibility disabled for this package`, + ); + continue; + } + + const classDefs: { def: BindingRef['def']; filePath: string }[] = []; + for (const parsed of bucket.parsed) { + const moduleScope = parsed.scopes.find((s) => s.kind === 'Module'); + const moduleScopeId = moduleScope?.id; + for (const scope of parsed.scopes) { + if (scope.kind !== 'Class') continue; + if (scope.parent !== moduleScopeId) continue; + for (const def of scope.ownedDefs) { + if (isClassLike(def.type)) { + classDefs.push({ def, filePath: parsed.filePath }); + break; + } + } + } + } + + for (const { filePath, scope } of bucket.moduleScopes) { + let scopeAug = augmentations.get(scope.id); + if (scopeAug === undefined) { + scopeAug = new Map(); + augmentations.set(scope.id, scopeAug); + } + + const candidates = classDefs.filter((d) => d.filePath !== filePath); + const proximityCache = new Map(); + for (const c of candidates) { + if (!proximityCache.has(c.filePath)) { + proximityCache.set(c.filePath, sharedSegmentCount(c.filePath, filePath)); + } + } + const sorted = candidates.sort( + (a, b) => (proximityCache.get(b.filePath) ?? 0) - (proximityCache.get(a.filePath) ?? 0), + ); + + const injectedIds = new Set(); + for (const { def } of sorted) { + if (injectedIds.has(def.nodeId)) continue; + const qn = def.qualifiedName; + if (qn === undefined) continue; + injectedIds.add(def.nodeId); + const simpleName = qn.includes('.') ? qn.slice(qn.lastIndexOf('.') + 1) : qn; + let list = scopeAug.get(simpleName); + if (list === undefined) { + list = []; + scopeAug.set(simpleName, list); + } + list.push({ def, origin: 'namespace' }); + } + + const tb = scope.typeBindings as Map; + for (const sibling of bucket.moduleScopes) { + if (sibling.filePath === filePath) continue; + for (const [name, ref] of sibling.scope.typeBindings) { + if (tb.has(name)) continue; + tb.set(name, ref); + } + } + + for (const sibParsed of bucket.parsed) { + if (sibParsed.filePath === filePath) continue; + for (const sibScope of sibParsed.scopes) { + if (sibScope.kind !== 'Class') continue; + for (const [name, ref] of sibScope.typeBindings) { + if (ref.source === 'self') continue; + if (tb.has(name)) continue; + tb.set(name, ref); + } + } + } + } + } +} + +function sharedSegmentCount(a: string, b: string): number { + const sa = a.replace(/\\/g, '/').split('/'); + const sb = b.replace(/\\/g, '/').split('/'); + let i = 0; + while (i < sa.length && i < sb.length && sa[i] === sb[i]) i++; + return i; +} diff --git a/gitnexus/src/core/ingestion/languages/java/query.ts b/gitnexus/src/core/ingestion/languages/java/query.ts index 3fabbb7bf..e1e581ad5 100644 --- a/gitnexus/src/core/ingestion/languages/java/query.ts +++ b/gitnexus/src/core/ingestion/languages/java/query.ts @@ -102,6 +102,47 @@ const JAVA_SCOPE_QUERY = ` declarator: (variable_declarator name: (identifier) @type-binding.name)) @type-binding.annotation +;; Type bindings — var u = svc.getUser(); (Java 10+ call-result inference) +(local_variable_declaration + type: (type_identifier) @_var_type + (#eq? @_var_type "var") + declarator: (variable_declarator + name: (identifier) @type-binding.name + value: (method_invocation + name: (identifier) @type-binding.type))) @type-binding.call-result + +;; Type bindings — var alias = u; (Java 10+ alias inference) +(local_variable_declaration + type: (type_identifier) @_var_type + (#eq? @_var_type "var") + declarator: (variable_declarator + name: (identifier) @type-binding.name + value: (identifier) @type-binding.type)) @type-binding.alias + +;; Type bindings — var addr = user.address; (Java 10+ field-access alias) +(local_variable_declaration + type: (type_identifier) @_var_type + (#eq? @_var_type "var") + declarator: (variable_declarator + name: (identifier) @type-binding.name + value: (field_access + field: (identifier) @type-binding.type))) @type-binding.alias + +;; Type bindings — enhanced-for with var: for (var user : users) +(enhanced_for_statement + (type_identifier) @_var_type + (#eq? @_var_type "var") + (identifier) @type-binding.name + (identifier) @type-binding.type) @type-binding.alias + +;; Enhanced-for with var + method iterable: for (var user : data.values()) +(enhanced_for_statement + (type_identifier) @_var_type + (#eq? @_var_type "var") + (identifier) @type-binding.name + (method_invocation + object: (identifier) @type-binding.type)) @type-binding.alias + ;; Type bindings — var u = new User(); (Java 10+ local variable type inference) ;; tree-sitter-java parses \`var\` as a \`type_identifier\` with text "var". ;; The type-binding.constructor anchor fires when the rhs is an @@ -143,6 +184,16 @@ const JAVA_SCOPE_QUERY = ` type: (generic_type) @type-binding.type name: (identifier) @type-binding.name) @type-binding.annotation +;; Type bindings — instanceof pattern (Java 16+): if (obj instanceof User user) +(instanceof_expression + (type_identifier) @type-binding.type + (identifier) @type-binding.name) @type-binding.pattern + +;; Type bindings — switch case pattern (Java 21+): case User user -> +(type_pattern + (type_identifier) @type-binding.type + (identifier) @type-binding.name) @type-binding.pattern + ;; References — all method calls: foo() and obj.method() ;; tree-sitter-java's query engine drops negation-based \`!object\` ;; patterns when a positive \`object:\` pattern exists for the same @@ -166,6 +217,30 @@ const JAVA_SCOPE_QUERY = ` (object_creation_expression type: (scoped_type_identifier) @reference.call.constructor.qualified) @reference.call.constructor +;; References — method references: User::getName, obj::method +(method_reference + (identifier) @reference.receiver + (identifier) @reference.name) @reference.call.member + +;; References — this::method and super::method +(method_reference + (this) @reference.receiver + (identifier) @reference.name) @reference.call.member + +(method_reference + (super) @reference.receiver + (identifier) @reference.name) @reference.call.member + +;; References — field_access::method: responseBuilder::buildResponse +(method_reference + (field_access) @reference.receiver + (identifier) @reference.name) @reference.call.member + +;; References — constructor references: User::new +(method_reference + (identifier) @reference.name + "new") @reference.call.constructor + ;; References — field/property writes: obj.name = "x" (assignment_expression left: (field_access diff --git a/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts index dac974cc7..a94c1c3b1 100644 --- a/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts @@ -4,53 +4,29 @@ * * ## Registry-primary parity status * - * Java is **not** in `MIGRATED_LANGUAGES` — the scope-resolution - * registry runs in shadow mode only. Parity in forced registry mode - * (`REGISTRY_PRIMARY_JAVA=1`) is 143/172 (83%). The 29 gaps fall into: + * Java is in `MIGRATED_LANGUAGES` — the scope-resolution registry is + * the primary call-resolution path. Parity: 178/178 (100%). * - * - switch pattern binding / sealed-class exhaustiveness - * - Map.values() / entrySet() iteration type propagation - * - assignment / method chain return-type propagation across files - * - virtual dispatch / interface default methods - * - * These are the same category of advanced-resolution gaps seen in prior - * migrations (Python, C#, Go). Parity is below the ≥99% flip threshold - * per RFC §6.4. - * - * **CI visibility:** Because Java is absent from `MIGRATED_LANGUAGES`, - * the parity CI workflow (`ci-scope-parity.yml`) does not run Java in - * either `REGISTRY_PRIMARY_JAVA=0` or `=1` mode. Regressions in forced - * mode are only visible via manual `REGISTRY_PRIMARY_JAVA=1 npx vitest - * run java.test.ts`. Before flipping Java to registry-primary, a - * non-required CI step should be added to run Java tests in forced mode - * and report parity as a dashboard input. - * - * **Parity baseline (29 failures):** The 29 gaps in forced registry mode - * are tracked in this PR (#1482) and this JSDoc. If the gap count - * changes (up or down), update this baseline accordingly. - * - * ### Known flip-blockers (must fix before adding to MIGRATED_LANGUAGES) - * - * - Varargs arity: fixed-prefix count is now preserved, but no - * integration fixture exercises the 0-arg rejection path yet. - * - Static import resolution: `import static X.Y.m` now correctly - * resolves to `X/Y.java` (the class), not `X/Y/m.java` (the member). - * Edge cases with nested classes may remain. - * - Generic superclass receiver binding: `BaseModel` now strips - * to `BaseModel` via JVM type-erasure fallback in `stripGeneric`. - * - Wildcard import (`import com.example.*`) file selection is - * nondeterministic when multiple classes share a package directory. - * May produce wrong-file edges in forced mode. - * - Qualified generic type parameters in field/parameter annotations - * (`com.example.BaseModel`) — rare in practice but may miss - * resolution when the full qualifier is present with generics. + * **CI visibility:** The parity CI workflow (`ci-scope-parity.yml`) + * runs Java tests in both `REGISTRY_PRIMARY_JAVA=0` and `=1` modes + * automatically. */ -import type { ParsedFile } from 'gitnexus-shared'; +import type { ParsedFile, TypeRef } from 'gitnexus-shared'; import { SupportedLanguages } from 'gitnexus-shared'; +import type { KnowledgeGraph } from '../../../graph/types.js'; import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js'; -import { populateClassOwnedMembers } 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 { + isClassLike, + lookupBindingsAt, + namesAtScope, + populateClassOwnedMembers, +} from '../../scope-resolution/scope/walkers.js'; import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import { followChainPostFinalize } from '../../scope-resolution/passes/imported-return-types.js'; import { javaProvider } from '../java.js'; import { javaArityCompatibility, @@ -58,6 +34,7 @@ import { resolveJavaImportTarget, type JavaResolveContext, } from './index.js'; +import { populateJavaPackageSiblings } from './package-siblings.js'; const javaScopeResolver: ScopeResolver = { language: SupportedLanguages.Java, @@ -76,22 +53,167 @@ const javaScopeResolver: ScopeResolver = { arityCompatibility: (callsite, def) => javaArityCompatibility(def, callsite), - buildMro: (graph, parsedFiles, nodeLookup) => - buildMro(graph, parsedFiles, nodeLookup, defaultLinearize), + buildMro: buildJavaMro, populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed), isSuperReceiver: (text) => text.trim() === 'super', - // Java is statically typed — field-fallback heuristic stays off fieldFallbackOnMethodLookup: false, propagatesReturnTypesAcrossImports: true, - - // Java doesn't collapse member calls - collapseMemberCallsByCallerTarget: false, - - // Hoist return-type bindings to Module scope for cross-file propagation + collapseMemberCallsByCallerTarget: true, hoistTypeBindingsToModule: true, + + populateNamespaceSiblings: populateJavaPackageSiblings, + populateRangeBindings: populateJavaCrossFileReturnTypes, }; export { javaScopeResolver }; + +function populateJavaCrossFileReturnTypes( + parsedFiles: readonly ParsedFile[], + indexes: ScopeResolutionIndexes, +): void { + const moduleScopeByFile = new Map(); + const classScopesByFile = new Map(); + for (const parsed of parsedFiles) { + const ms = parsed.scopes.find((s) => s.kind === 'Module'); + if (ms !== undefined) moduleScopeByFile.set(parsed.filePath, ms); + const cs = parsed.scopes.filter((s) => s.kind === 'Class'); + if (cs.length > 0) classScopesByFile.set(parsed.filePath, cs); + } + + for (const parsed of parsedFiles) { + const importerModule = moduleScopeByFile.get(parsed.filePath); + if (importerModule === undefined) continue; + + const ambiguousMirrors = new Set(); + for (const name of namesAtScope(importerModule.id, indexes)) { + const refs = lookupBindingsAt(importerModule.id, name, indexes); + for (const ref of refs) { + if (ref.origin !== 'import' && ref.origin !== 'reexport') continue; + if (!isClassLike(ref.def.type)) continue; + + const sourceModule = moduleScopeByFile.get(ref.def.filePath); + if (sourceModule === undefined) continue; + + const tb = importerModule.typeBindings as Map; + for (const [srcName, srcRef] of sourceModule.typeBindings) { + if (srcRef.source !== 'return-annotation') continue; + if (ambiguousMirrors.has(srcName)) continue; + const existing = tb.get(srcName); + if (existing !== undefined && existing.rawName !== srcRef.rawName) { + ambiguousMirrors.add(srcName); + tb.delete(srcName); + continue; + } + if (existing === undefined) tb.set(srcName, srcRef); + } + + for (const classScope of classScopesByFile.get(ref.def.filePath) ?? []) { + for (const [srcName, srcRef] of classScope.typeBindings) { + if (srcRef.source === 'self' || srcRef.source === 'parameter-annotation') continue; + if (ambiguousMirrors.has(srcName)) continue; + const existing = tb.get(srcName); + if (existing !== undefined && existing.rawName !== srcRef.rawName) { + ambiguousMirrors.add(srcName); + tb.delete(srcName); + continue; + } + if (existing === undefined) tb.set(srcName, srcRef); + } + } + } + } + + for (const [name, ref] of importerModule.typeBindings) { + const resolved = followChainPostFinalize(ref, importerModule.id, indexes); + if (resolved !== ref) { + (importerModule.typeBindings as Map).set(name, resolved); + } + } + } + + for (const parsed of parsedFiles) { + const moduleScopeId = moduleScopeByFile.get(parsed.filePath)?.id; + for (const scope of parsed.scopes) { + if (scope.id === moduleScopeId) continue; + for (const [name, ref] of scope.typeBindings) { + const resolved = followChainPostFinalize(ref, scope.id, indexes); + if (resolved !== ref) { + (scope.typeBindings as Map).set(name, resolved); + } + } + } + } +} + +function buildJavaMro( + graph: KnowledgeGraph, + parsedFiles: readonly ParsedFile[], + nodeLookup: GraphNodeLookup, +): Map { + const mro = buildMro(graph, parsedFiles, nodeLookup, defaultLinearize); + + 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); + } + } + + const directImpls = new Map(); + for (const rel of graph.iterRelationshipsByType('IMPLEMENTS')) { + const source = defIdByGraphId.get(rel.sourceId); + const target = defIdByGraphId.get(rel.targetId); + if (source === undefined || target === undefined) continue; + let list = directImpls.get(source); + if (list === undefined) { + list = []; + directImpls.set(source, list); + } + if (!list.includes(target)) list.push(target); + } + + for (const [classDefId, extendsMro] of mro) { + const ancestorChain = [classDefId, ...extendsMro]; + const seeds: string[] = []; + for (const ancestorId of ancestorChain) { + for (const ifaceId of directImpls.get(ancestorId) ?? []) { + seeds.push(ifaceId); + } + } + if (seeds.length === 0) continue; + const interfaces = closeInterfaces(seeds, directImpls); + mro.set(classDefId, [...extendsMro, ...interfaces.filter((i) => !extendsMro.includes(i))]); + } + + for (const [classDefId, ifaces] of directImpls) { + if (mro.has(classDefId)) continue; + mro.set(classDefId, closeInterfaces([...ifaces], directImpls)); + } + + return mro; +} + +function closeInterfaces( + seeds: readonly string[], + directImpls: ReadonlyMap, +): string[] { + const out: string[] = []; + const seen = new Set(); + const queue: string[] = [...seeds]; + let head = 0; + while (head < queue.length) { + const cur = queue[head++]!; + if (seen.has(cur)) continue; + seen.add(cur); + out.push(cur); + for (const next of directImpls.get(cur) ?? []) { + if (!seen.has(next)) queue.push(next); + } + } + return out; +} diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts index bf954b5cf..4d06b8d58 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts @@ -46,6 +46,7 @@ import { import { createResolutionContext } from '../model/resolution-context.js'; import { ASTCache, createASTCache } from '../ast-cache.js'; import { type PipelineProgress, getLanguageFromFilename } from 'gitnexus-shared'; +import { isRegistryPrimary } from '../registry-primary-flag.js'; import { readFileContents } from '../filesystem-walker.js'; import { isLanguageAvailable } from '../../tree-sitter/parser-loader.js'; import { createWorkerPool, WorkerPoolInitializationError } from '../workers/worker-pool.js'; @@ -606,11 +607,31 @@ export async function runChunkedParseAndResolve( if (chunkNeedsSynthesis[chunkIdx]) { anyChunkNeedsWildcardSynth = true; } - for (const item of chunkWorkerData.imports) deferredWorkerImports.push(item); - for (const item of chunkWorkerData.calls) deferredWorkerCalls.push(item); - for (const item of chunkWorkerData.heritage) deferredWorkerHeritage.push(item); - for (const item of chunkWorkerData.constructorBindings) - deferredConstructorBindings.push(item); + const skipFile = new Set(); + const checkFile = new Set(); + const shouldAccumulate = (filePath: string): boolean => { + if (checkFile.has(filePath)) return true; + if (skipFile.has(filePath)) return false; + const lang = getLanguageFromFilename(filePath); + if (lang !== null && isRegistryPrimary(lang)) { + skipFile.add(filePath); + return false; + } + checkFile.add(filePath); + return true; + }; + for (const item of chunkWorkerData.imports) { + if (shouldAccumulate(item.filePath)) deferredWorkerImports.push(item); + } + for (const item of chunkWorkerData.calls) { + if (shouldAccumulate(item.filePath)) deferredWorkerCalls.push(item); + } + for (const item of chunkWorkerData.heritage) { + if (shouldAccumulate(item.filePath)) deferredWorkerHeritage.push(item); + } + for (const item of chunkWorkerData.constructorBindings) { + if (shouldAccumulate(item.filePath)) deferredConstructorBindings.push(item); + } // Aggregate worker-produced ParsedFile artifacts so scope- // resolution can use them as a re-extraction cache (skips its // own tree-sitter re-parse on warm runs). @@ -618,7 +639,9 @@ export async function runChunkedParseAndResolve( for (const item of chunkWorkerData.parsedFiles) allParsedFiles.push(item); } if (chunkWorkerData.assignments?.length) { - for (const item of chunkWorkerData.assignments) deferredAssignments.push(item); + for (const item of chunkWorkerData.assignments) { + if (shouldAccumulate(item.filePath)) deferredAssignments.push(item); + } } if (chunkWorkerData.fileScopeBindings?.length) { diff --git a/gitnexus/src/core/ingestion/registry-primary-flag.ts b/gitnexus/src/core/ingestion/registry-primary-flag.ts index 9016a68b5..56e39962a 100644 --- a/gitnexus/src/core/ingestion/registry-primary-flag.ts +++ b/gitnexus/src/core/ingestion/registry-primary-flag.ts @@ -77,6 +77,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet = new Set { describe('processCalls — Phase P class lookup fallback', () => { let graph: ReturnType; let ctx: ResolutionContext; + let prevRegistryJava: string | undefined; beforeEach(() => { graph = createKnowledgeGraph(); ctx = createResolutionContext(); + prevRegistryJava = process.env['REGISTRY_PRIMARY_JAVA']; + process.env['REGISTRY_PRIMARY_JAVA'] = 'false'; + }); + + afterEach(() => { + if (prevRegistryJava === undefined) delete process.env['REGISTRY_PRIMARY_JAVA']; + else process.env['REGISTRY_PRIMARY_JAVA'] = prevRegistryJava; }); it('uses lookupClassByName to override interface receiver types for cross-file virtual dispatch', async () => { @@ -2147,10 +2155,13 @@ describe('processNextjsFetchRoutes', () => { describe('processCallsFromExtracted — interface dispatch', () => { let graph: ReturnType; let ctx: ResolutionContext; + let prevRegistryJava: string | undefined; beforeEach(() => { graph = createKnowledgeGraph(); ctx = createResolutionContext(); + prevRegistryJava = process.env['REGISTRY_PRIMARY_JAVA']; + process.env['REGISTRY_PRIMARY_JAVA'] = 'false'; const ifaceFile = 'contracts/Action.java'; const runnerFile = 'runner.java'; const implA = 'impl/A.java'; @@ -2195,6 +2206,11 @@ describe('processCallsFromExtracted — interface dispatch', () => { }); }); + afterEach(() => { + if (prevRegistryJava === undefined) delete process.env['REGISTRY_PRIMARY_JAVA']; + else process.env['REGISTRY_PRIMARY_JAVA'] = prevRegistryJava; + }); + it('adds CALLS to interface method plus lower-confidence edges to implementing methods', async () => { const heritage: ExtractedHeritage[] = [ { filePath: 'impl/A.java', className: 'A', parentName: 'Action', kind: 'implements' }, @@ -2241,21 +2257,26 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { let graph: ReturnType; let ctx: ResolutionContext; let prevRegistryPython: string | undefined; + let prevRegistryJava: string | undefined; beforeEach(() => { graph = createKnowledgeGraph(); ctx = createResolutionContext(); // These tests exercise the LEGACY call-resolution DAG directly - // using .py fixtures. Python defaults to registry-primary now - // (MIGRATED_LANGUAGES), which gates call-processor out for - // Python files. Force the flag off so the legacy DAG runs. + // using .py/.java fixtures. Python and Java default to registry- + // primary now (MIGRATED_LANGUAGES), which gates call-processor + // out for those files. Force the flags off so the legacy DAG runs. prevRegistryPython = process.env['REGISTRY_PRIMARY_PYTHON']; process.env['REGISTRY_PRIMARY_PYTHON'] = 'false'; + prevRegistryJava = process.env['REGISTRY_PRIMARY_JAVA']; + process.env['REGISTRY_PRIMARY_JAVA'] = 'false'; }); afterEach(() => { if (prevRegistryPython === undefined) delete process.env['REGISTRY_PRIMARY_PYTHON']; else process.env['REGISTRY_PRIMARY_PYTHON'] = prevRegistryPython; + if (prevRegistryJava === undefined) delete process.env['REGISTRY_PRIMARY_JAVA']; + else process.env['REGISTRY_PRIMARY_JAVA'] = prevRegistryJava; }); const setupChildParent = () => { diff --git a/gitnexus/test/unit/registry-primary-flag.test.ts b/gitnexus/test/unit/registry-primary-flag.test.ts index e800bd14a..856dcef43 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); - // Java is not in MIGRATED_LANGUAGES — default false stays + // Ruby is not in MIGRATED_LANGUAGES — default false stays // false regardless of Python's flag. - expect(isRegistryPrimary(SupportedLanguages.Java)).toBe(false); + expect(isRegistryPrimary(SupportedLanguages.Ruby)).toBe(false); }); it('respects a mid-process env-var mutation (no stale cache)', () => { - // Use Java — not in MIGRATED_LANGUAGES — so the unset default is + // Use Ruby — not in MIGRATED_LANGUAGES — so the unset default is // deterministically `false`, independent of which languages have // been flipped to registry-primary. - expect(isRegistryPrimary(SupportedLanguages.Java)).toBe(false); - process.env['REGISTRY_PRIMARY_JAVA'] = 'true'; - expect(isRegistryPrimary(SupportedLanguages.Java)).toBe(true); - delete process.env['REGISTRY_PRIMARY_JAVA']; - expect(isRegistryPrimary(SupportedLanguages.Java)).toBe(false); + 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); }); it('handles the CPlusPlus → REGISTRY_PRIMARY_CPP mapping correctly', () => { @@ -150,7 +150,7 @@ describe('primaryLanguages', () => { it('returns exactly the flipped languages (env opts in unmigrated, opts out migrated)', () => { // Migrated languages are default-on; each must be opted out here when - // testing explicit env overrides. Java (unmigrated) opts in. + // testing explicit env overrides. Ruby (unmigrated) opts in. // Opt out every member of MIGRATED_LANGUAGES dynamically so this test // does not have to be updated each time a new language ships its // Ring 3 migration (C++ and PHP joined the set in their respective @@ -158,15 +158,15 @@ describe('primaryLanguages', () => { for (const lang of MIGRATED_LANGUAGES) { process.env[envVarNameFor(lang)] = 'false'; } - process.env['REGISTRY_PRIMARY_JAVA'] = '1'; + process.env['REGISTRY_PRIMARY_RUBY'] = '1'; const enabled = primaryLanguages(); expect(enabled.has(SupportedLanguages.Python)).toBe(false); expect(enabled.has(SupportedLanguages.CSharp)).toBe(false); expect(enabled.has(SupportedLanguages.Go)).toBe(false); expect(enabled.has(SupportedLanguages.CPlusPlus)).toBe(false); expect(enabled.has(SupportedLanguages.PHP)).toBe(false); - expect(enabled.has(SupportedLanguages.Java)).toBe(true); - // Only Java is on: migrated defaults overridden off, Java explicitly on. + expect(enabled.has(SupportedLanguages.Ruby)).toBe(true); + // Only Ruby is on: migrated defaults overridden off, Ruby explicitly on. expect(enabled.size).toBe(1); });