mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-24 00:51:53 +00:00
Merge branch 'main' into cpp-scope-resolution-parity
This commit is contained in:
commit
8e90f8141b
23 changed files with 1349 additions and 2 deletions
2
.github/workflows/claude.yml
vendored
2
.github/workflows/claude.yml
vendored
|
|
@ -158,6 +158,8 @@ jobs:
|
|||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
allowed_non_write_users: '*'
|
||||
show_full_output: true
|
||||
# Review posts use Bash (`gh`, etc.); default mode asks for approval — impossible in CI.
|
||||
claude_args: '--dangerously-skip-permissions'
|
||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||
plugins: 'code-review@claude-code-plugins'
|
||||
prompt: '/code-review:code-review https://github.com/${{ github.repository }}/pull/${{ steps.pr.outputs.number }} --comment'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
49
gitnexus/src/core/ingestion/languages/java/arity-metadata.ts
Normal file
49
gitnexus/src/core/ingestion/languages/java/arity-metadata.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* 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;
|
||||
// For varargs methods, `parameterCount` (max) is unknown — any number of
|
||||
// trailing arguments is valid. But the fixed-prefix parameters (everything
|
||||
// before the variadic `...` param) are still required, so we preserve that
|
||||
// count in `requiredParameterCount` so `javaArityCompatibility` can reject
|
||||
// calls that undersupply the fixed prefix (e.g. `f(int x, String... args)`
|
||||
// called with 0 args).
|
||||
const fixedCount = params.filter((p) => !p.isVariadic).length;
|
||||
const parameterCount = hasVariadic ? undefined : total;
|
||||
const requiredParameterCount = hasVariadic ? fixedCount : total;
|
||||
|
||||
return {
|
||||
parameterCount,
|
||||
requiredParameterCount,
|
||||
parameterTypes: types.length > 0 ? types : undefined,
|
||||
};
|
||||
}
|
||||
31
gitnexus/src/core/ingestion/languages/java/arity.ts
Normal file
31
gitnexus/src/core/ingestion/languages/java/arity.ts
Normal 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';
|
||||
}
|
||||
30
gitnexus/src/core/ingestion/languages/java/cache-stats.ts
Normal file
30
gitnexus/src/core/ingestion/languages/java/cache-stats.ts
Normal 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;
|
||||
}
|
||||
235
gitnexus/src/core/ingestion/languages/java/captures.ts
Normal file
235
gitnexus/src/core/ingestion/languages/java/captures.ts
Normal 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;
|
||||
}
|
||||
104
gitnexus/src/core/ingestion/languages/java/import-decomposer.ts
Normal file
104
gitnexus/src/core/ingestion/languages/java/import-decomposer.ts
Normal 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;
|
||||
}
|
||||
108
gitnexus/src/core/ingestion/languages/java/import-target.ts
Normal file
108
gitnexus/src/core/ingestion/languages/java/import-target.ts
Normal 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;
|
||||
}
|
||||
30
gitnexus/src/core/ingestion/languages/java/index.ts
Normal file
30
gitnexus/src/core/ingestion/languages/java/index.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* 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';
|
||||
141
gitnexus/src/core/ingestion/languages/java/interpret.ts
Normal file
141
gitnexus/src/core/ingestion/languages/java/interpret.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
/**
|
||||
* 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;`
|
||||
// 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 classPath = lastDot >= 0 ? fullSource.slice(0, lastDot) : fullSource;
|
||||
return {
|
||||
kind: 'named',
|
||||
localName: nameCap?.text ?? (lastDot >= 0 ? fullSource.slice(lastDot + 1) : fullSource),
|
||||
importedName: fullSource,
|
||||
targetRaw: classPath,
|
||||
};
|
||||
}
|
||||
case 'static-wildcard': {
|
||||
// `import static com.example.Utils.*;`
|
||||
// The source is the class path (e.g. `com.example.Utils`).
|
||||
// Resolution should target the class file, not a wildcard directory
|
||||
// scan — `Utils.java` is the file that contains the static members.
|
||||
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;
|
||||
|
||||
// Strip qualifier first so that `com.example.BaseModel<T>` becomes
|
||||
// `BaseModel<T>` before stripGeneric — the JVM-erasure fallback pattern
|
||||
// requires an unqualified identifier at the start of the string.
|
||||
const rawType = stripGeneric(stripQualifier(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 generic type parameters from Java types.
|
||||
*
|
||||
* Three tiers, checked in order:
|
||||
* 1. Known single-arg collection wrappers → extract the element type
|
||||
* (`List<User>` → `User`, `Optional<User>` → `User`).
|
||||
* 2. Known two-arg map/container types → extract the value type
|
||||
* (`Map<String, User>` → `User`).
|
||||
* 3. **Fallback (JVM type erasure):** any other generic type →
|
||||
* strip the generic parameters and keep the raw class name
|
||||
* (`BaseModel<T>` → `BaseModel`, `CustomList<Foo>` → `CustomList`).
|
||||
* This ensures receiver bindings (`this`/`super`) on classes with
|
||||
* generic superclasses resolve to the correct class file.
|
||||
*/
|
||||
function stripGeneric(text: string): string {
|
||||
// Single-type-argument containers — extract the element type.
|
||||
const single = text.match(
|
||||
/^(?:[A-Za-z_][A-Za-z0-9_.]*\.)?(?:List|ArrayList|LinkedList|Set|HashSet|TreeSet|SortedSet|LinkedHashSet|Collection|Iterable|Iterator|Optional|Stream|CompletableFuture|Future|Queue|Deque|ArrayDeque|PriorityQueue|Vector|Stack|Supplier|Consumer|Predicate|Function)<([^,<>]+)>$/,
|
||||
);
|
||||
if (single !== null) return single[1].trim();
|
||||
|
||||
// Two-type-argument map/container types — extract the value type (second arg).
|
||||
const twoArg = text.match(
|
||||
/^(?:[A-Za-z_][A-Za-z0-9_.]*\.)?(?:Map|HashMap|TreeMap|LinkedHashMap|ConcurrentHashMap|ConcurrentMap|SortedMap|NavigableMap|Hashtable|EnumMap|WeakHashMap|IdentityHashMap|BiFunction|BiConsumer|BiPredicate|Pair|Entry)<[^,<>]+,\s*([^,<>]+)>$/,
|
||||
);
|
||||
if (twoArg !== null) return twoArg[1].trim();
|
||||
|
||||
// Fallback: strip generic parameters from any unrecognized generic type.
|
||||
// `BaseModel<T>` → `BaseModel`, `Builder<Self>` → `Builder`.
|
||||
// This mirrors JVM type erasure — the raw class name is the resolvable symbol.
|
||||
// The pattern matches up to the first `<` to handle nested generics safely
|
||||
// (e.g. `BaseModel<List<String>>` → `BaseModel`).
|
||||
const fallback = text.match(/^([A-Za-z_$][A-Za-z0-9_$]*)<.+>$/s);
|
||||
if (fallback !== null) return fallback[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);
|
||||
}
|
||||
44
gitnexus/src/core/ingestion/languages/java/merge-bindings.ts
Normal file
44
gitnexus/src/core/ingestion/languages/java/merge-bindings.ts
Normal 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()];
|
||||
}
|
||||
197
gitnexus/src/core/ingestion/languages/java/query.ts
Normal file
197
gitnexus/src/core/ingestion/languages/java/query.ts
Normal 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;
|
||||
}
|
||||
103
gitnexus/src/core/ingestion/languages/java/receiver-binding.ts
Normal file
103
gitnexus/src/core/ingestion/languages/java/receiver-binding.ts
Normal 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;
|
||||
}
|
||||
97
gitnexus/src/core/ingestion/languages/java/scope-resolver.ts
Normal file
97
gitnexus/src/core/ingestion/languages/java/scope-resolver.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* Java `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by
|
||||
* the generic `runScopeResolution` orchestrator (RFC #909 Ring 3).
|
||||
*
|
||||
* ## 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:
|
||||
*
|
||||
* - 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<T>` 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<T>`) — rare in practice but may miss
|
||||
* resolution when the full qualifier is present with generics.
|
||||
*/
|
||||
|
||||
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 };
|
||||
54
gitnexus/src/core/ingestion/languages/java/simple-hooks.ts
Normal file
54
gitnexus/src/core/ingestion/languages/java/simple-hooks.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* 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 compilation-unit (Module) level (JLS §7.5).
|
||||
* Return `null` unconditionally so the default Module scope is used. */
|
||||
export function javaImportOwningScope(
|
||||
_imp: ParsedImport,
|
||||
_innermost: Scope,
|
||||
_tree: ScopeTree,
|
||||
): ScopeId | null {
|
||||
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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
import { cScopeResolver } from '../../languages/c/scope-resolver.js';
|
||||
import { cppScopeResolver } from '../../languages/cpp/scope-resolver.js';
|
||||
|
||||
|
|
@ -30,6 +31,7 @@ export const SCOPE_RESOLVERS: ReadonlyMap<SupportedLanguages, ScopeResolver> = n
|
|||
[SupportedLanguages.CSharp, csharpScopeResolver],
|
||||
[SupportedLanguages.TypeScript, typescriptScopeResolver],
|
||||
[SupportedLanguages.Go, goScopeResolver],
|
||||
[SupportedLanguages.Java, javaScopeResolver],
|
||||
[SupportedLanguages.C, cScopeResolver],
|
||||
[SupportedLanguages.CPlusPlus, cppScopeResolver],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,25 @@
|
|||
package com.example.app;
|
||||
|
||||
import com.example.util.Logger;
|
||||
import com.example.util.Formatter;
|
||||
|
||||
public class Main {
|
||||
public void run() {
|
||||
Logger logger = new Logger();
|
||||
logger.record("hello", "world", "test");
|
||||
|
||||
Formatter fmt = new Formatter();
|
||||
// 2-arg call: satisfies fixed prefix (level) + 1 vararg
|
||||
fmt.format(1, "hello");
|
||||
// 3-arg call: satisfies fixed prefix (level) + 2 varargs
|
||||
fmt.format(2, "hello", "world");
|
||||
}
|
||||
|
||||
public void badCall() {
|
||||
Formatter fmt = new Formatter();
|
||||
// 0-arg call: does NOT satisfy the required fixed prefix (int level)
|
||||
// This should be rejected by arity — no CALLS edge to format
|
||||
fmt.format();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
package com.example.util;
|
||||
|
||||
public class Formatter {
|
||||
/** Varargs with a required fixed prefix — 0-arg calls should be rejected. */
|
||||
public void format(int level, String... args) {
|
||||
for (String a : args) System.out.println(level + ": " + a);
|
||||
}
|
||||
}
|
||||
10
gitnexus/test/fixtures/lang-resolution/java-wildcard-import/com/example/app/Main.java
vendored
Normal file
10
gitnexus/test/fixtures/lang-resolution/java-wildcard-import/com/example/app/Main.java
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
package com.example.app;
|
||||
|
||||
import com.example.models.*;
|
||||
|
||||
public class Main {
|
||||
public void run() {
|
||||
User user = new User();
|
||||
user.save();
|
||||
}
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/java-wildcard-import/com/example/models/Order.java
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/java-wildcard-import/com/example/models/Order.java
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package com.example.models;
|
||||
|
||||
public class Order {
|
||||
public void submit() {
|
||||
System.out.println("submitting order");
|
||||
}
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/java-wildcard-import/com/example/models/User.java
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/java-wildcard-import/com/example/models/User.java
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package com.example.models;
|
||||
|
||||
public class User {
|
||||
public void save() {
|
||||
System.out.println("saving user");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
/**
|
||||
* Java: class extends + implements multiple interfaces + ambiguous package disambiguation
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { describe, 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -438,6 +441,54 @@ describe('Java variadic call resolution', () => {
|
|||
}
|
||||
expect(allDangling).toEqual([]);
|
||||
});
|
||||
|
||||
it('resolves 2-arg call to fixed-prefix varargs method format(int, String...) in Formatter.java', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const fmtCall = calls.find((c) => c.target === 'format' && c.source === 'run');
|
||||
expect(fmtCall).toBeDefined();
|
||||
expect(fmtCall!.targetFilePath).toBe('com/example/util/Formatter.java');
|
||||
});
|
||||
|
||||
it('0-arg call to format(int, String...) still resolves in legacy mode (arity rejection is registry-only)', () => {
|
||||
// In REGISTRY_PRIMARY_JAVA=1 mode, `requiredParameterCount = 1` causes
|
||||
// `javaArityCompatibility` to return 'incompatible' for 0-arg calls,
|
||||
// preventing the CALLS edge. In default (legacy) mode, arity is not
|
||||
// enforced so the edge is created. This test documents the legacy
|
||||
// behavior; the negative assertion is a flip-blocker for registry-primary.
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const zeroArgFmtCall = calls.find((c) => c.target === 'format' && c.source === 'badCall');
|
||||
expect(zeroArgFmtCall).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wildcard import: `import com.example.models.*` resolves to a package file
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Java wildcard import resolution', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-wildcard-import'), () => {});
|
||||
}, 60000);
|
||||
|
||||
it('parses wildcard import without errors and creates graph nodes', () => {
|
||||
// The wildcard import (`import com.example.models.*`) exercises the
|
||||
// directoryChild branch in resolveJavaImportTarget. Even if no IMPORTS
|
||||
// edge is created (nondeterministic file selection — documented flip
|
||||
// blocker), the graph must contain valid nodes for all classes.
|
||||
const classes = getNodesByLabel(result, 'Class');
|
||||
expect(classes).toContain('Main');
|
||||
expect(classes).toContain('User');
|
||||
expect(classes).toContain('Order');
|
||||
});
|
||||
|
||||
it('resolves user.save() call via wildcard-imported User', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const saveCall = calls.find((c) => c.target === 'save' && c.source === 'run');
|
||||
expect(saveCall).toBeDefined();
|
||||
expect(saveCall!.targetFilePath).toBe('com/example/models/User.java');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -354,7 +354,7 @@ describe('worker pool integration', () => {
|
|||
|
||||
try {
|
||||
await expect(pool.dispatch<any, any>([{ path: 'crash.ts', content: '' }])).rejects.toThrow(
|
||||
/simulated startup crash|exited with code/,
|
||||
/simulated startup crash|exited with code|idle timeout/,
|
||||
);
|
||||
const warnRecords = cap.records().filter((r) => Number(r.level) >= 40 /* warn or above */);
|
||||
expect(warnRecords.length).toBeGreaterThan(0);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue