feat: implement Java scope-based resolution (RFC #909 Ring 3)

Add scope-resolution pipeline for Java, following the C# pattern:

- query.ts: tree-sitter query for scopes, declarations, imports,
  type bindings, and references against tree-sitter-java grammar
- captures.ts: orchestrator synthesizing import decomposition,
  receiver bindings (this/super), arity metadata, and reference arity
- import-decomposer.ts: decompose import_declaration nodes into
  kind/source/name markers (named, wildcard, static, static-wildcard)
- interpret.ts: convert captures to ParsedImport/ParsedTypeBinding
- receiver-binding.ts: synthesize this/super type-bindings on instance
  methods with superclass support
- arity-metadata.ts: extract parameter count/types using javaMethodConfig
- arity.ts: Java arity compatibility check with varargs support
- merge-bindings.ts: Java shadowing precedence (local > import > wildcard)
- simple-hooks.ts: bindingScopeFor, importOwningScope, receiverBinding
- import-target.ts: package path to file path resolution
- scope-resolver.ts: ScopeResolver implementation registered in registry

Wire scope hooks into javaProvider (java.ts) and register
javaScopeResolver in SCOPE_RESOLVERS registry. Add createResolverParityIt
wrapper to java.test.ts for parity testing.

All 172 existing Java tests pass. Java is NOT added to
MIGRATED_LANGUAGES — the resolver sits idle until the migration flag
is flipped.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-05-10 16:13:16 +00:00 committed by GitHub
parent f1f1cea1b8
commit 99f5cf60e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1170 additions and 1 deletions

View file

@ -27,6 +27,17 @@ import { javaMethodConfig } from '../method-extractors/configs/jvm.js';
import { createVariableExtractor } from '../variable-extractors/generic.js';
import { javaVariableConfig } from '../variable-extractors/configs/jvm.js';
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
import {
emitJavaScopeCaptures,
interpretJavaImport,
interpretJavaTypeBinding,
javaBindingScopeFor,
javaImportOwningScope,
javaMergeBindings,
javaReceiverBinding,
javaArityCompatibility,
resolveJavaImportTarget,
} from './java/index.js';
export const javaProvider = defineLanguage({
id: SupportedLanguages.Java,
@ -65,4 +76,15 @@ export const javaProvider = defineLanguage({
variableExtractor: createVariableExtractor(javaVariableConfig),
classExtractor: createClassExtractor(javaClassConfig),
heritageExtractor: createHeritageExtractor(SupportedLanguages.Java),
// ── RFC #909 Ring 3: scope-based resolution hooks ──
emitScopeCaptures: emitJavaScopeCaptures,
interpretImport: interpretJavaImport,
interpretTypeBinding: interpretJavaTypeBinding,
bindingScopeFor: javaBindingScopeFor,
importOwningScope: javaImportOwningScope,
mergeBindings: (_scope, bindings) => javaMergeBindings(bindings),
receiverBinding: javaReceiverBinding,
arityCompatibility: javaArityCompatibility,
resolveImportTarget: resolveJavaImportTarget,
});

View file

@ -0,0 +1,43 @@
/**
* Extract Java arity metadata from a method-like tree-sitter node
* `method_declaration` or `constructor_declaration`.
*
* Reuses `javaMethodConfig.extractParameters` so scope-extracted defs
* carry the same arity semantics as the legacy parse-worker path:
* - varargs (`...`) collapses `parameterCount` to `undefined`
* - `parameterTypes` collects declared type names; a literal
* `'varargs'` marker is appended for variadic methods so
* `javaArityCompatibility` can detect them.
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import { javaMethodConfig } from '../../method-extractors/configs/jvm.js';
export interface JavaArityMetadata {
readonly parameterCount: number | undefined;
readonly requiredParameterCount: number | undefined;
readonly parameterTypes: readonly string[] | undefined;
}
export function computeJavaArityMetadata(fnNode: SyntaxNode): JavaArityMetadata {
const params = javaMethodConfig.extractParameters?.(fnNode) ?? [];
let hasVariadic = false;
const types: string[] = [];
for (const p of params) {
if (p.isVariadic) hasVariadic = true;
if (p.type !== null) types.push(p.type);
}
if (hasVariadic) types.push('varargs');
const total = params.length;
const parameterCount = hasVariadic ? undefined : total;
// Java has no optional parameters (no default values), so required = total
const requiredParameterCount = hasVariadic ? undefined : total;
return {
parameterCount,
requiredParameterCount,
parameterTypes: types.length > 0 ? types : undefined,
};
}

View file

@ -0,0 +1,31 @@
/**
* Java arity check, accommodating varargs (`...`).
*
* Verdicts:
* - `'compatible'` argCount matches parameterCount, OR varargs present.
* - `'incompatible'` argCount mismatches with no varargs.
* - `'unknown'` metadata absent / incomplete.
*/
import type { Callsite, SymbolDefinition } from 'gitnexus-shared';
export function javaArityCompatibility(
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';
const hasVarArgs =
def.parameterTypes !== undefined &&
def.parameterTypes.some((t) => t === 'varargs' || t.includes('...'));
if (min !== undefined && argCount < min) return 'incompatible';
if (max !== undefined && argCount > max && !hasVarArgs) return 'incompatible';
return 'compatible';
}

View file

@ -0,0 +1,30 @@
/**
* Dev-mode counters for the cross-phase scope-captures parse cache
* (Java mirror of `languages/csharp/cache-stats.ts`).
*
* Gated by `PROF_SCOPE_RESOLUTION=1`. Production builds fold every
* increment into dead code via the module-level `PROF` constant, so
* the hot path in `captures.ts` stays branch-free.
*/
const PROF = process.env.PROF_SCOPE_RESOLUTION === '1';
let CACHE_HITS = 0;
let CACHE_MISSES = 0;
export function recordCacheHit(): void {
if (PROF) CACHE_HITS++;
}
export function recordCacheMiss(): void {
if (PROF) CACHE_MISSES++;
}
export function getJavaCaptureCacheStats(): { hits: number; misses: number } {
return { hits: CACHE_HITS, misses: CACHE_MISSES };
}
export function resetJavaCaptureCacheStats(): void {
CACHE_HITS = 0;
CACHE_MISSES = 0;
}

View file

@ -0,0 +1,235 @@
/**
* `emitScopeCaptures` for Java.
*
* Drives the Java scope query against tree-sitter-java and groups raw
* matches into `CaptureMatch[]` for the central extractor. Layers:
*
* 1. **Decomposed import declarations** each `import_declaration`
* is re-emitted with `@import.kind/source/name` markers.
* 2. **Receiver binding synthesis** `this`/`super` type-bindings
* on instance methods.
* 3. **Arity metadata** on method/constructor declarations.
* 4. **Reference arity** on call sites.
*
* Pure given the input source text. No I/O, no globals consulted.
*/
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { findNodeAtRange, nodeToCapture, syntheticCapture } from '../../utils/ast-helpers.js';
import { splitImportDeclaration } from './import-decomposer.js';
import { computeJavaArityMetadata } from './arity-metadata.js';
import { synthesizeJavaReceiverBinding } from './receiver-binding.js';
import { getJavaParser, getJavaScopeQuery } from './query.js';
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
/** Declaration anchors that carry function-like arity metadata. */
const FUNCTION_DECL_TAGS = ['@declaration.method', '@declaration.constructor'] as const;
/** tree-sitter-java node types that the method extractor accepts. */
const FUNCTION_NODE_TYPES = ['method_declaration', 'constructor_declaration'] as const;
/** Suppress read.member emissions when the field_access is already
* covered by a method_invocation (object of a call) or an
* assignment_expression (write target). */
function shouldEmitReadMember(memberNode: SyntaxNode): boolean {
const parent = memberNode.parent;
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:
return true;
}
}
export function emitJavaScopeCaptures(
sourceText: string,
_filePath: string,
cachedTree?: unknown,
): readonly CaptureMatch[] {
let tree = cachedTree as ReturnType<ReturnType<typeof getJavaParser>['parse']> | undefined;
if (tree === undefined) {
tree = parseSourceSafe(getJavaParser(), sourceText, undefined, {
bufferSize: getTreeSitterBufferSize(sourceText),
});
recordCacheMiss();
} else {
recordCacheHit();
}
const rawMatches = getJavaScopeQuery().matches(tree.rootNode);
const out: CaptureMatch[] = [];
for (const m of rawMatches) {
const grouped: Record<string, Capture> = {};
for (const c of m.captures) {
const tag = '@' + c.name;
grouped[tag] = nodeToCapture(tag, c.node);
}
if (Object.keys(grouped).length === 0) continue;
// Decompose each `import_declaration`.
if (grouped['@import.statement'] !== undefined) {
const stmtCapture = grouped['@import.statement'];
const stmtNode = findNodeAtRange(tree.rootNode, stmtCapture.range, 'import_declaration');
if (stmtNode !== null) {
const decomposed = splitImportDeclaration(stmtNode);
if (decomposed !== null) {
out.push(decomposed);
continue;
}
}
out.push(grouped);
continue;
}
// Skip free-call matches that are actually member calls. The query
// matches ALL method_invocations as @reference.call.free (without
// negation) because tree-sitter-java's query engine drops !object
// patterns when a positive object: pattern exists for the same node
// type. Filter here: if the match has @reference.call.free but also
// has @reference.receiver, it's a member call — skip the free match
// (the separate @reference.call.member match covers it).
if (
grouped['@reference.call.free'] !== undefined &&
grouped['@reference.receiver'] !== undefined
) {
continue;
}
// Filter read.member when it's a child of method_invocation or assignment.
if (grouped['@reference.read.member'] !== undefined) {
const anchor = grouped['@reference.read.member'];
const memberNode = findNodeAtRange(tree.rootNode, anchor.range, 'field_access');
if (memberNode === null || !shouldEmitReadMember(memberNode)) {
continue;
}
}
// Synthesize `this` / `super` receiver type-bindings on every
// instance method-like.
if (grouped['@scope.function'] !== undefined) {
out.push(grouped);
const anchor = grouped['@scope.function']!;
const fnNode = findFunctionNode(tree.rootNode, anchor.range);
if (fnNode !== null) {
for (const synth of synthesizeJavaReceiverBinding(fnNode)) {
out.push(synth);
}
}
continue;
}
// Synthesize arity metadata on function-like declarations.
const declTag = FUNCTION_DECL_TAGS.find((t) => grouped[t] !== undefined);
if (declTag !== undefined) {
const anchor = grouped[declTag]!;
const fnNode = findFunctionNode(tree.rootNode, anchor.range);
if (fnNode !== null) {
const arity = computeJavaArityMetadata(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),
);
}
}
}
// Synthesize `@reference.arity` on every callsite.
const callTag = (
['@reference.call.free', '@reference.call.member', '@reference.call.constructor'] 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, 'method_invocation') ??
findNodeAtRange(tree.rootNode, anchor.range, 'object_creation_expression');
if (callNode !== null) {
const argList = callNode.childForFieldName('arguments');
const args =
argList === null
? []
: argList.namedChildren.filter((c) => c !== null && c.type !== 'comment');
grouped['@reference.arity'] = syntheticCapture(
'@reference.arity',
callNode,
String(args.length),
);
const argTypes = args.map((arg) => inferArgType(arg!));
grouped['@reference.parameter-types'] = syntheticCapture(
'@reference.parameter-types',
callNode,
JSON.stringify(argTypes),
);
}
}
out.push(grouped);
}
return out;
}
type SyntaxNode = ReturnType<ReturnType<typeof getJavaParser>['parse']>['rootNode'];
/** Infer a Java argument's static type from literal patterns. */
function inferArgType(argNode: SyntaxNode): string {
switch (argNode.type) {
case 'decimal_integer_literal':
case 'hex_integer_literal':
case 'octal_integer_literal':
case 'binary_integer_literal':
return 'int';
case 'decimal_floating_point_literal':
case 'hex_floating_point_literal':
return 'double';
case 'string_literal':
return 'String';
case 'character_literal':
return 'char';
case 'true':
case 'false':
return 'boolean';
case 'null_literal':
return 'null';
case 'object_creation_expression': {
const typeNode = argNode.childForFieldName('type');
return typeNode?.text ?? '';
}
default:
return '';
}
}
/** Find the first Java function-like node at the given range. */
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 as SyntaxNode;
}
return null;
}

View file

@ -0,0 +1,104 @@
/**
* Decompose a Java `import_declaration` into a `CaptureMatch` carrying
* the synthesized markers `@import.kind` / `@import.source` /
* `@import.name` that `interpretJavaImport` consumes.
*
* Unlike C#'s using-directive decomposer, Java has four import forms:
*
* import com.example.User; named
* import com.example.*; wildcard
* import static com.example.Utils.format; static
* import static com.example.Utils.*; static-wildcard
*
* Each produces exactly one import. The decomposer inspects the raw
* source text and tree-sitter children to determine the flavor.
*/
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
type ImportKind = 'named' | 'wildcard' | 'static' | 'static-wildcard';
interface ImportSpec {
readonly kind: ImportKind;
/** Full dotted path: `com.example.User`. */
readonly source: string;
/** Local binding name last path segment for named/static,
* `'*'` for wildcard/static-wildcard. */
readonly name: string;
/** Node to anchor the synthesized captures (range-wise). */
readonly atNode: SyntaxNode;
}
export function splitImportDeclaration(stmtNode: SyntaxNode): CaptureMatch | null {
if (stmtNode.type !== 'import_declaration') return null;
const spec = parseImportDeclaration(stmtNode);
if (spec === null) return null;
return buildImportMatch(stmtNode, spec);
}
function parseImportDeclaration(node: SyntaxNode): ImportSpec | null {
// Detect `static` by checking for an anonymous `static` token child.
let isStatic = false;
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child !== null && child.type === 'static') {
isStatic = true;
break;
}
}
// Detect wildcard by checking for `asterisk` named child.
let isWildcard = false;
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child !== null && child.type === 'asterisk') {
isWildcard = true;
break;
}
}
// Find the scoped_identifier (or identifier for single-segment imports).
let pathNode: SyntaxNode | null = null;
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child !== null && (child.type === 'scoped_identifier' || child.type === 'identifier')) {
pathNode = child;
break;
}
}
if (pathNode === null) return null;
const fullPath = pathNode.text;
if (fullPath === '') return null;
if (isStatic && isWildcard) {
// `import static com.example.Utils.*;`
return { kind: 'static-wildcard', source: fullPath, name: '*', atNode: node };
}
if (isStatic) {
// `import static com.example.Utils.format;`
const lastDot = fullPath.lastIndexOf('.');
const name = lastDot >= 0 ? fullPath.slice(lastDot + 1) : fullPath;
return { kind: 'static', source: fullPath, name, atNode: node };
}
if (isWildcard) {
// `import com.example.*;`
return { kind: 'wildcard', source: fullPath, name: '*', atNode: node };
}
// `import com.example.User;`
const lastDot = fullPath.lastIndexOf('.');
const name = lastDot >= 0 ? fullPath.slice(lastDot + 1) : fullPath;
return { kind: 'named', source: fullPath, name, atNode: node };
}
function buildImportMatch(stmtNode: SyntaxNode, spec: ImportSpec): CaptureMatch {
const m: Record<string, Capture> = {
'@import.statement': nodeToCapture('@import.statement', stmtNode),
'@import.kind': syntheticCapture('@import.kind', spec.atNode, spec.kind),
'@import.source': syntheticCapture('@import.source', spec.atNode, spec.source),
'@import.name': syntheticCapture('@import.name', spec.atNode, spec.name),
};
return m;
}

View file

@ -0,0 +1,108 @@
/**
* Adapter from `(ParsedImport, WorkspaceIndex)` concrete file path.
*
* Converts Java package paths (dots slashes) and tries:
* 1. Exact file match: `com/example/User.java`
* 2. Suffix match for nested layouts
* 3. Directory match (wildcard imports)
* 4. Progressive prefix stripping for non-standard layouts
*
* Returns `null` for unresolvable / JDK imports.
*/
import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
export interface JavaResolveContext {
readonly fromFile: string;
readonly allFilePaths: ReadonlySet<string>;
}
export function resolveJavaImportTarget(
parsedImport: ParsedImport,
workspaceIndex: WorkspaceIndex,
): string | null {
const ctx = workspaceIndex as JavaResolveContext | undefined;
if (
ctx === undefined ||
typeof (ctx as { fromFile?: unknown }).fromFile !== 'string' ||
!((ctx as { allFilePaths?: unknown }).allFilePaths instanceof Set)
) {
return null;
}
if (parsedImport.kind === 'dynamic-unresolved') return null;
if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null;
// Strip trailing `.*` for wildcard imports: `com.example.*` → `com.example`
let target = parsedImport.targetRaw;
if (target.endsWith('.*')) {
target = target.slice(0, -2);
}
// Package path: `com.example.User` → `com/example/User`
const pathLike = target.replace(/\./g, '/');
const suffix = `/${pathLike}`;
let exactFile: string | null = null;
let suffixFile: string | null = null;
let directoryChild: string | null = null;
const dirPrefix = `${pathLike}/`;
const suffixDirPrefix = `/${dirPrefix}`;
for (const raw of ctx.allFilePaths) {
const f = raw.replace(/\\/g, '/');
if (!f.endsWith('.java')) continue;
if (f === `${pathLike}.java`) {
exactFile = raw;
break;
}
if (suffixFile === null && f.endsWith(`${suffix}.java`)) {
suffixFile = raw;
}
if (directoryChild === null) {
const atRoot = f.startsWith(dirPrefix);
const atNested = f.includes(suffixDirPrefix);
if (atRoot || atNested) {
const idx = atRoot ? 0 : f.indexOf(suffixDirPrefix) + 1;
const after = f.slice(idx + dirPrefix.length);
if (after.length > 0 && !after.includes('/')) {
directoryChild = raw;
}
}
}
}
if (exactFile !== null) return exactFile;
if (suffixFile !== null) return suffixFile;
if (directoryChild !== null) return directoryChild;
// Progressive prefix stripping — handles `import com.example.User;`
// in a repo laid out `User.java` (no `com/example/` prefix).
const segments = pathLike.split('/').filter(Boolean);
for (let skip = 1; skip < segments.length; skip++) {
const tail = segments.slice(skip).join('/');
if (tail === '') continue;
const tailFile = `${tail}.java`;
const tailSuffix = `/${tailFile}`;
const tailDir = `${tail}/`;
const tailSuffixDir = `/${tailDir}`;
let tailDirectChild: string | null = null;
for (const raw of ctx.allFilePaths) {
const f = raw.replace(/\\/g, '/');
if (!f.endsWith('.java')) continue;
if (f === tailFile) return raw;
if (f.endsWith(tailSuffix)) return raw;
if (tailDirectChild === null) {
const atRoot = f.startsWith(tailDir);
const atNested = f.includes(tailSuffixDir);
if (atRoot || atNested) {
const idx = atRoot ? 0 : f.indexOf(tailSuffixDir) + 1;
const after = f.slice(idx + tailDir.length);
if (after.length > 0 && !after.includes('/')) tailDirectChild = raw;
}
}
}
if (tailDirectChild !== null) return tailDirectChild;
}
return null;
}

View file

@ -0,0 +1,34 @@
/**
* Java scope-resolution hooks (RFC #909 Ring 3).
*
* Public API barrel. Consumers should import from this file rather than
* the individual modules.
*
* Module layout:
*
* - `query.ts` tree-sitter query + lazy parser/query singletons
* - `captures.ts` `emitJavaScopeCaptures` orchestrator
* - `import-decomposer.ts` each `import` ParsedImport-shaped captures
* - `interpret.ts` capture-match `ParsedImport` / `ParsedTypeBinding`
* - `simple-hooks.ts` small hooks made explicit
* - `receiver-binding.ts` synthesize `this`/`super` type-bindings on
* instance-method entry
* - `merge-bindings.ts` Java import precedence
* - `arity.ts` Java arity compatibility (varargs)
* - `arity-metadata.ts` synthesize arity metadata from declarations
* - `import-target.ts` `(ParsedImport, WorkspaceIndex) → file path` adapter
* - `scope-resolver.ts` `ScopeResolver` registered in `SCOPE_RESOLVERS`
* - `cache-stats.ts` PROF_SCOPE_RESOLUTION cache hit/miss counters
*/
export { emitJavaScopeCaptures } from './captures.js';
export { getJavaCaptureCacheStats, resetJavaCaptureCacheStats } from './cache-stats.js';
export { interpretJavaImport, interpretJavaTypeBinding } from './interpret.js';
export { javaMergeBindings } from './merge-bindings.js';
export { javaArityCompatibility } from './arity.js';
export { resolveJavaImportTarget, type JavaResolveContext } from './import-target.js';
export {
javaBindingScopeFor,
javaImportOwningScope,
javaReceiverBinding,
} from './simple-hooks.js';

View file

@ -0,0 +1,102 @@
/**
* Capture-match semantic-shape interpreters for Java.
*
* - `interpretJavaImport` `ParsedImport`
* - `interpretJavaTypeBinding` `ParsedTypeBinding`
*
* Import matches arrive pre-decomposed by `emitJavaScopeCaptures`
* (one import per match, with synthesized `@import.kind/source/name`
* markers). Type-binding matches arrive from the raw query captures.
*/
import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared';
// ─── interpretImport ──────────────────────────────────────────────────────
export function interpretJavaImport(captures: CaptureMatch): ParsedImport | null {
const kindCap = captures['@import.kind'];
const sourceCap = captures['@import.source'];
const nameCap = captures['@import.name'];
const kind = kindCap?.text;
if (kind === undefined || sourceCap === undefined) return null;
switch (kind) {
case 'named': {
// `import com.example.User;`
return {
kind: 'named',
localName: nameCap?.text ?? sourceCap.text.split('.').pop() ?? sourceCap.text,
importedName: sourceCap.text,
targetRaw: sourceCap.text,
};
}
case 'wildcard': {
// `import com.example.*;`
return {
kind: 'wildcard',
targetRaw: sourceCap.text + '.*',
};
}
case 'static': {
// `import static com.example.Utils.format;`
return {
kind: 'named',
localName: nameCap?.text ?? sourceCap.text.split('.').pop() ?? sourceCap.text,
importedName: sourceCap.text,
targetRaw: sourceCap.text,
};
}
case 'static-wildcard': {
// `import static com.example.Utils.*;`
return {
kind: 'wildcard',
targetRaw: sourceCap.text + '.*',
};
}
default:
return null;
}
}
// ─── interpretTypeBinding ─────────────────────────────────────────────────
export function interpretJavaTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null {
const nameCap = captures['@type-binding.name'];
const typeCap = captures['@type-binding.type'];
if (nameCap === undefined || typeCap === undefined) return null;
const rawType = stripQualifier(stripGeneric(typeCap.text.trim()));
// Skip `var` — tree-sitter-java parses `var` as type_identifier with
// text "var". When used without a constructor initializer, there's no
// concrete type to bind.
if (rawType === 'var') return null;
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.annotation'] !== undefined) source = 'annotation';
else if (captures['@type-binding.return'] !== undefined) source = 'return-annotation';
return { boundName: nameCap.text, rawTypeName: rawType, source };
}
/**
* Unwrap a single-arg generic collection wrapper `List<User>`,
* `ArrayList<User>`, `Optional<User>` to its element type.
*/
function stripGeneric(text: string): string {
const single = text.match(
/^(?:[A-Za-z_][A-Za-z0-9_.]*\.)?(?:List|ArrayList|LinkedList|Set|HashSet|TreeSet|Collection|Iterable|Iterator|Optional|Stream|CompletableFuture|Future|Queue|Deque|ArrayDeque|Vector|Stack)<([^,<>]+)>$/,
);
if (single !== null) return single[1].trim();
return text;
}
/** `com.example.User` → `User`. */
function stripQualifier(text: string): string {
const lastDot = text.lastIndexOf('.');
if (lastDot === -1) return text;
return text.slice(lastDot + 1);
}

View file

@ -0,0 +1,44 @@
/**
* Java shadowing precedence for the `mergeBindings` hook.
*
* Tier ranking (lower wins):
* - 0: `local` class member, method, local variable, parameter
* - 1: `import` / `namespace` / `reexport` explicit imports
* - 2: `wildcard` wildcard imports (`import x.y.*`)
*
* Within a surviving tier: de-dup by DefId, last-write-wins.
*/
import type { BindingRef } from 'gitnexus-shared';
const TIER_LOCAL = 0;
const TIER_IMPORT = 1;
const TIER_WILDCARD = 2;
const TIER_UNKNOWN = 3;
function tierOf(b: BindingRef): number {
switch (b.origin) {
case 'local':
return TIER_LOCAL;
case 'reexport':
case 'import':
case 'namespace':
return TIER_IMPORT;
case 'wildcard':
return TIER_WILDCARD;
default:
return TIER_UNKNOWN;
}
}
export function javaMergeBindings(bindings: readonly BindingRef[]): readonly BindingRef[] {
if (bindings.length === 0) return bindings;
let bestTier = Number.POSITIVE_INFINITY;
for (const b of bindings) bestTier = Math.min(bestTier, tierOf(b));
const survivors = bindings.filter((b) => tierOf(b) === bestTier);
const seen = new Map<string, BindingRef>();
for (const b of survivors) seen.set(b.def.nodeId, b);
return [...seen.values()];
}

View file

@ -0,0 +1,197 @@
/**
* Tree-sitter query for Java scope captures (RFC §5.1).
*
* Captures the structural skeleton the generic scope-resolution
* pipeline consumes: scopes (module/class/function), declarations
* (class-likes, method-likes, fields, variables), imports (import
* declarations), type bindings (parameter annotations, variable
* annotations, constructor inference), and references (call sites,
* member writes/reads).
*
* Java specifics that shape this query:
*
* - Java uses `program` as the root node (not `compilation_unit`).
* - `import_declaration` nodes carry `scoped_identifier` children
* and optional `asterisk` for wildcard imports.
* - `static` imports are detected by an anonymous `static` token
* child within `import_declaration`.
* - `var` (Java 10+ local variable type inference) parses as a
* `type_identifier` with text `"var"`, not a dedicated node type.
* - Modifiers (`public`, `static`, etc.) are grouped under a
* `modifiers` named child with anonymous keyword tokens.
* - Superclass inheritance uses a `superclass:` field containing
* a `superclass` node wrapping a `type_identifier`.
*
* Exposes lazy `Parser` and `Query` singletons so callers don't pay
* tree-sitter init cost per file.
*/
import Parser from 'tree-sitter';
import Java from 'tree-sitter-java';
const JAVA_SCOPE_QUERY = `
;; Scopes
(program) @scope.module
(class_declaration) @scope.class
(interface_declaration) @scope.class
(enum_declaration) @scope.class
(record_declaration) @scope.class
(annotation_type_declaration) @scope.class
(method_declaration) @scope.function
(constructor_declaration) @scope.function
;; Declarations types
(class_declaration
name: (identifier) @declaration.name) @declaration.class
(interface_declaration
name: (identifier) @declaration.name) @declaration.interface
(enum_declaration
name: (identifier) @declaration.name) @declaration.enum
(record_declaration
name: (identifier) @declaration.name) @declaration.record
(annotation_type_declaration
name: (identifier) @declaration.name) @declaration.class
;; Declarations methods / constructors
(method_declaration
name: (identifier) @declaration.name) @declaration.method
(constructor_declaration
name: (identifier) @declaration.name) @declaration.constructor
;; Declarations fields
(field_declaration
declarator: (variable_declarator
name: (identifier) @declaration.name)) @declaration.variable
;; Declarations local variables
(local_variable_declaration
declarator: (variable_declarator
name: (identifier) @declaration.name)) @declaration.variable
;; Imports single anchor per import_declaration
(import_declaration) @import.statement
;; Type bindings parameter annotations: void f(User u)
(formal_parameter
type: (type_identifier) @type-binding.type
name: (identifier) @type-binding.name) @type-binding.parameter
(formal_parameter
type: (generic_type) @type-binding.type
name: (identifier) @type-binding.name) @type-binding.parameter
(formal_parameter
type: (scoped_type_identifier) @type-binding.type
name: (identifier) @type-binding.name) @type-binding.parameter
;; Type bindings local variable annotations: User u = new User();
(local_variable_declaration
type: (type_identifier) @type-binding.type
declarator: (variable_declarator
name: (identifier) @type-binding.name)) @type-binding.annotation
(local_variable_declaration
type: (generic_type) @type-binding.type
declarator: (variable_declarator
name: (identifier) @type-binding.name)) @type-binding.annotation
;; 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
;; object_creation_expression so interpretJavaTypeBinding can infer
;; the concrete type from the constructor call.
(local_variable_declaration
type: (type_identifier) @_var_type
declarator: (variable_declarator
name: (identifier) @type-binding.name
value: (object_creation_expression
type: (type_identifier) @type-binding.type))) @type-binding.constructor
;; Type bindings field declarations: private User user;
(field_declaration
type: (type_identifier) @type-binding.type
declarator: (variable_declarator
name: (identifier) @type-binding.name)) @type-binding.annotation
(field_declaration
type: (generic_type) @type-binding.type
declarator: (variable_declarator
name: (identifier) @type-binding.name)) @type-binding.annotation
;; Type bindings method return type: public User getUser() { }
(method_declaration
type: (type_identifier) @type-binding.type
name: (identifier) @type-binding.name) @type-binding.return
(method_declaration
type: (generic_type) @type-binding.type
name: (identifier) @type-binding.name) @type-binding.return
;; Type bindings enhanced for: for (User u : list)
(enhanced_for_statement
type: (type_identifier) @type-binding.type
name: (identifier) @type-binding.name) @type-binding.annotation
(enhanced_for_statement
type: (generic_type) @type-binding.type
name: (identifier) @type-binding.name) @type-binding.annotation
;; 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
;; node type, so we match all calls here and classify free vs
;; member in captures.ts based on the presence of @reference.receiver.
(method_invocation
object: (_) @reference.receiver
name: (identifier) @reference.name) @reference.call.member
(method_invocation
name: (identifier) @reference.name) @reference.call.free
;; References constructor calls: new User(...)
(object_creation_expression
type: (type_identifier) @reference.name) @reference.call.constructor
(object_creation_expression
type: (generic_type
(type_identifier) @reference.name)) @reference.call.constructor
(object_creation_expression
type: (scoped_type_identifier) @reference.call.constructor.qualified) @reference.call.constructor
;; References field/property writes: obj.name = "x"
(assignment_expression
left: (field_access
object: (_) @reference.receiver
field: (identifier) @reference.name)) @reference.write.member
;; References field/property reads: obj.name
(field_access
object: (_) @reference.receiver
field: (identifier) @reference.name) @reference.read.member
`;
let _parser: Parser | null = null;
let _query: Parser.Query | null = null;
export function getJavaParser(): Parser {
if (_parser === null) {
_parser = new Parser();
_parser.setLanguage(Java as Parameters<Parser['setLanguage']>[0]);
}
return _parser;
}
export function getJavaScopeQuery(): Parser.Query {
if (_query === null) {
_query = new Parser.Query(Java as Parameters<Parser['setLanguage']>[0], JAVA_SCOPE_QUERY);
}
return _query;
}

View file

@ -0,0 +1,103 @@
/**
* Synthesize `@type-binding.self` captures for Java instance methods
* one for `this` (always on non-static methods inside a type
* declaration) and optionally one for `super` (only on class methods
* when the enclosing class has a `superclass`).
*
* Mirrors `languages/csharp/receiver-binding.ts` in structure.
*/
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
const TYPE_DECL_NODE_TYPES = new Set([
'class_declaration',
'interface_declaration',
'enum_declaration',
'record_declaration',
]);
const FUNCTION_NODE_TYPES = new Set(['method_declaration', 'constructor_declaration']);
/** Walk up to the enclosing type declaration. */
function findEnclosingTypeDeclaration(node: SyntaxNode): SyntaxNode | null {
let cur: SyntaxNode | null = node.parent;
while (cur !== null) {
if (TYPE_DECL_NODE_TYPES.has(cur.type)) return cur;
cur = cur.parent;
}
return null;
}
function typeName(typeNode: SyntaxNode): string | null {
return typeNode.childForFieldName('name')?.text ?? null;
}
/** First superclass text. tree-sitter-java uses a `superclass` field
* containing a `superclass` node wrapping a `type_identifier`. */
function firstSuperclassText(typeNode: SyntaxNode): string | null {
const superclass = typeNode.childForFieldName('superclass');
if (superclass === null) return null;
// The superclass node wraps the type_identifier
for (let i = 0; i < superclass.namedChildCount; i++) {
const child = superclass.namedChild(i);
if (child !== null && (child.type === 'type_identifier' || child.type === 'generic_type')) {
return child.text;
}
}
return null;
}
/** Check if a method has the `static` modifier. In tree-sitter-java,
* modifiers are grouped under a `modifiers` named child with anonymous
* keyword tokens. */
function isStaticMethod(fnNode: SyntaxNode): boolean {
for (let i = 0; i < fnNode.namedChildCount; i++) {
const child = fnNode.namedChild(i);
if (child !== null && child.type === 'modifiers') {
for (let j = 0; j < child.childCount; j++) {
const mod = child.child(j);
if (mod !== null && mod.text.trim() === 'static') return true;
}
}
}
return false;
}
export function synthesizeJavaReceiverBinding(fnNode: SyntaxNode): CaptureMatch[] {
if (!FUNCTION_NODE_TYPES.has(fnNode.type)) return [];
if (isStaticMethod(fnNode)) return [];
const enclosingType = findEnclosingTypeDeclaration(fnNode);
if (enclosingType === null) return [];
const enclosingName = typeName(enclosingType);
if (enclosingName === null) return [];
// Anchor to the method body so the synthesized captures are inside
// the function scope.
const anchorNode = fnNode.childForFieldName('body');
if (anchorNode === null) return [];
const out: CaptureMatch[] = [];
out.push(buildReceiverMatch(anchorNode, 'this', enclosingName));
// `super` applies only to class/record methods with an explicit superclass.
if (enclosingType.type === 'class_declaration' || enclosingType.type === 'record_declaration') {
const superText = firstSuperclassText(enclosingType);
if (superText !== null) {
out.push(buildReceiverMatch(anchorNode, 'super', superText));
}
}
return out;
}
function buildReceiverMatch(anchorNode: SyntaxNode, name: string, typeText: string): CaptureMatch {
const m: Record<string, Capture> = {
'@type-binding.self': nodeToCapture('@type-binding.self', anchorNode),
'@type-binding.name': syntheticCapture('@type-binding.name', anchorNode, name),
'@type-binding.type': syntheticCapture('@type-binding.type', anchorNode, typeText),
};
return m;
}

View file

@ -0,0 +1,54 @@
/**
* Java `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by
* the generic `runScopeResolution` orchestrator (RFC #909 Ring 3).
*/
import type { ParsedFile } from 'gitnexus-shared';
import { SupportedLanguages } from 'gitnexus-shared';
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
import { javaProvider } from '../java.js';
import {
javaArityCompatibility,
javaMergeBindings,
resolveJavaImportTarget,
type JavaResolveContext,
} from './index.js';
const javaScopeResolver: ScopeResolver = {
language: SupportedLanguages.Java,
languageProvider: javaProvider,
importEdgeReason: 'java-scope: import',
resolveImportTarget: (targetRaw, fromFile, allFilePaths) => {
const ws: JavaResolveContext = { fromFile, allFilePaths };
return resolveJavaImportTarget(
{ kind: 'named', localName: '_', importedName: '_', targetRaw },
ws,
);
},
mergeBindings: (existing, incoming) => [...javaMergeBindings([...existing, ...incoming])],
arityCompatibility: (callsite, def) => javaArityCompatibility(def, callsite),
buildMro: (graph, parsedFiles, nodeLookup) =>
buildMro(graph, parsedFiles, nodeLookup, defaultLinearize),
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
hoistTypeBindingsToModule: true,
};
export { javaScopeResolver };

View file

@ -0,0 +1,57 @@
/**
* Small hooks for the Java provider. Each is a few lines; they make
* the provider's choice explicit rather than relying on defaults.
*/
import type {
CaptureMatch,
ParsedImport,
Scope,
ScopeId,
ScopeTree,
TypeRef,
} from 'gitnexus-shared';
// ─── bindingScopeFor ──────────────────────────────────────────────────────
/** Method return-type bindings hoist to Module scope so cross-file
* `propagateImportedReturnTypes` and chain-follow can find them. */
export function javaBindingScopeFor(
decl: CaptureMatch,
innermost: Scope,
tree: ScopeTree,
): ScopeId | null {
if (decl['@type-binding.return'] !== undefined) {
let cur: Scope | undefined = innermost;
while (cur !== undefined && cur.kind !== 'Module') {
const parentId: ScopeId | null = cur.parent ?? null;
if (parentId === null) break;
cur = tree.getScope(parentId);
}
if (cur !== undefined && cur.kind === 'Module') return cur.id;
}
return null;
}
// ─── importOwningScope ────────────────────────────────────────────────────
/** Java imports are always at file level. Defensively handle nested
* scopes by attaching to the innermost if it's Class or Function. */
export function javaImportOwningScope(
_imp: ParsedImport,
innermost: Scope,
_tree: ScopeTree,
): ScopeId | null {
if (innermost.kind === 'Class' || innermost.kind === 'Function') return innermost.id;
return null;
}
// ─── receiverBinding ──────────────────────────────────────────────────────
/** Look up `this` or `super` in the function scope's type bindings. */
export function javaReceiverBinding(functionScope: Scope): TypeRef | null {
if (functionScope.kind !== 'Function') return null;
return (
functionScope.typeBindings.get('this') ?? functionScope.typeBindings.get('super') ?? null
);
}

View file

@ -15,6 +15,7 @@ import { pythonScopeResolver } from '../../languages/python/scope-resolver.js';
import { csharpScopeResolver } from '../../languages/csharp/scope-resolver.js';
import { typescriptScopeResolver } from '../../languages/typescript/scope-resolver.js';
import { goScopeResolver } from '../../languages/go/scope-resolver.js';
import { javaScopeResolver } from '../../languages/java/scope-resolver.js';
/** Map of `SupportedLanguages` `ScopeResolver`. The phase iterates
* this map intersected with `MIGRATED_LANGUAGES` (the per-language
@ -28,4 +29,5 @@ export const SCOPE_RESOLVERS: ReadonlyMap<SupportedLanguages, ScopeResolver> = n
[SupportedLanguages.CSharp, csharpScopeResolver],
[SupportedLanguages.TypeScript, typescriptScopeResolver],
[SupportedLanguages.Go, goScopeResolver],
[SupportedLanguages.Java, javaScopeResolver],
]);

View file

@ -1,11 +1,12 @@
/**
* Java: class extends + implements multiple interfaces + ambiguous package disambiguation
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { describe, it as vitestIt, expect, beforeAll } from 'vitest';
import path from 'path';
import {
FIXTURES,
CROSS_FILE_FIXTURES,
createResolverParityIt,
getRelationships,
getNodesByLabel,
getNodesByLabelFull,
@ -14,6 +15,8 @@ import {
type PipelineResult,
} from './helpers.js';
const it = createResolverParityIt('java');
// ---------------------------------------------------------------------------
// Heritage: class extends + implements multiple interfaces
// ---------------------------------------------------------------------------