mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
fix(java): JLS binary-name identities for local classes, enums, records & interfaces (#2562) (#2653)
* Initial plan * docs(plans): add Java local class naming plan * fix(java): model local class binary names * docs(java): clarify local class naming guards * fix(java): recognize local classes in compact constructors * chore: remove Java naming plan * fix(java): harden local type identities and scope * perf(java): linearize local type ordinal allocation * fix(java): harden ordinal benchmark follow-up * docs(java): clarify ordinal benchmark invariants * test(java): cover local type ownership paths --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
This commit is contained in:
parent
4af6fe8587
commit
450cebc268
17 changed files with 737 additions and 130 deletions
|
|
@ -91,7 +91,7 @@
|
|||
"_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0."
|
||||
},
|
||||
"java": {
|
||||
"fingerprint": "d04298a91beec76d0fa7099b3d71265723be60c1df688969aa954f135dd49686",
|
||||
"fingerprint": "6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata; same-name lexical regions use an O(ancestor-depth) ID-set lookup. Prior d5c59d7dc9e206637515d5aea1163f7c1cdd76410c38c5fe6143d13d19677d6a -> 004a3592998dca1193bd1429a8284513725de7764f2a3eceedaaa984cfd763b4; scaling 0.992 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Java method-reference/SAM callable flow facts with invocation-result suppression. Prior 062d754764aaa8a6772fb90875c710502a63e3e7a300e633942381ed914faada -> d5c59d7dc9e206637515d5aea1163f7c1cdd76410c38c5fe6143d13d19677d6a; scaling 1.074 < 1.5.",
|
||||
|
|
@ -101,7 +101,13 @@
|
|||
"_rebaselined_2550_instance_model": "PR #2549 (#2550): anonymous class bodies emit synthesized @declaration.class/@declaration.name (Worker$N), an @reference.inherits to the constructed type, and receiver @type-binding.* captures; six new java-* fixtures joined the corpus. Prior f3b4f4b6610e07c3ac90deb1c53d3572b6ad55a36e5d7134984876d30031ff67 -> d79c3b92acfc866094981499b977388ca14f90839bca0c040342ab1cec00aa90; scaling 1.058 < 1.5.",
|
||||
"_rebaselined_2555_enum_constant_bodies": "PR for #2555: enum constant bodies emit synthesized E$N classes + @reference.inherits to the host enum; anonymous naming follows JLS 13.1 immediately-enclosing-type chains INCLUDING anonymous enclosing types (NestHost$1$1, N$1$1); six new java-* fixtures joined the corpus. Prior d79c3b92acfc866094981499b977388ca14f90839bca0c040342ab1cec00aa90 -> 975b68aaac6d06094260fb0c67f9b1bc03692ba7220669d192aca9dccd5fc0ca; scaling 1.05 < 1.5.",
|
||||
"_rebaselined_2564_record_capture": "PR for #2564: JAVA_QUERIES gained a (record_declaration name: (identifier) @name) @definition.record capture, previously entirely missing (record_declaration had no structure-phase capture at all, unlike class/interface/enum) - a record's methods existed as ownerless Method nodes with no HAS_METHOD edge. Two new java-* fixtures (java-record-methods, java-new-expr-chain-call) joined the corpus. Prior 975b68aaac6d06094260fb0c67f9b1bc03692ba7220669d192aca9dccd5fc0ca -> 85fc7af9c3c1bceac76cb4f27214410b04967682a2eaa7e468e26efd1f4e2537; scaling 1.059 < 1.5.",
|
||||
"_rebaselined_2561_enum_constant_receiver": "PR for #2561: synthesizeJavaAnonymousClassDeclarations now emits a class-scope @type-binding.annotation/name/type per enum constant (constant simple name -> its E$N synthesized class when bodied, else the host enum) so E.CONST.method() resolves through the existing compound-receiver chain walk. Two drivers of the drift, both in the java-enum-constant-body fixture (this bench's corpus IS test/fixtures/lang-resolution): (1) one extra type-binding match per enum_constant from the capture change; (2) review follow-up added a body-less Plain.java enum + EnumConst.dispatchToConstant/dispatchInherited methods (bodied-override, inherited-via-MRO, and body-less dispatch call sites). The review's fail-safe hardening (bodied constant binds ONLY to E$N, never the host enum, when name synthesis fails on a malformed tree) is output-neutral on this well-formed corpus (verified: fingerprint identical with and without it). Prior 85fc7af9c3c1bceac76cb4f27214410b04967682a2eaa7e468e26efd1f4e2537 -> d04298a91beec76d0fa7099b3d71265723be60c1df688969aa954f135dd49686; scaling < 1.5."
|
||||
"_rebaselined_2561_enum_constant_receiver": "PR for #2561: synthesizeJavaAnonymousClassDeclarations now emits a class-scope @type-binding.annotation/name/type per enum constant (constant simple name -> its E$N synthesized class when bodied, else the host enum) so E.CONST.method() resolves through the existing compound-receiver chain walk. Two drivers of the drift, both in the java-enum-constant-body fixture (this bench's corpus IS test/fixtures/lang-resolution): (1) one extra type-binding match per enum_constant from the capture change; (2) review follow-up added a body-less Plain.java enum + EnumConst.dispatchToConstant/dispatchInherited methods (bodied-override, inherited-via-MRO, and body-less dispatch call sites). The review's fail-safe hardening (bodied constant binds ONLY to E$N, never the host enum, when name synthesis fails on a malformed tree) is output-neutral on this well-formed corpus (verified: fingerprint identical with and without it). Prior 85fc7af9c3c1bceac76cb4f27214410b04967682a2eaa7e468e26efd1f4e2537 -> d04298a91beec76d0fa7099b3d71265723be60c1df688969aa954f135dd49686; scaling < 1.5.",
|
||||
"_rebaselined_2562_local_classes": "#2562: Java block-local classes, enums, records, and interfaces use source-type-relative JLS 13.1 Host$NLocal identities with javac-compatible per-(host, simple-name) numbering; anonymous numbering remains separate. Lexical aliases begin at each declaration and end with its immediate block. Expanded java-local-class-naming fixtures cover declaration order, disjoint blocks, initializers, lambdas, local type kinds, and recursive local/member/anonymous host chains. Prior d04298a91beec76d0fa7099b3d71265723be60c1df688969aa954f135dd49686 -> 6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197; scaling 1.204 < 1.5."
|
||||
},
|
||||
"java-local-types": {
|
||||
"fingerprint": "a9ad88de21ca6747a923260dbdf677fb74a004abbf9d57781f745e3a9027530b",
|
||||
"scaling_budget": 1.5,
|
||||
"_added": "#2562 performance follow-up: co-scales same-host, same-name local classes and anonymous classes to gate JLS binary-name ordinal allocation. Precomputed per-sequence ordinals reduce the focused 100->800 workload from 176->6655ms to 141->752ms; normalized 250->800 scaling is 1.054."
|
||||
},
|
||||
"typescript": {
|
||||
"fingerprint": "3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4",
|
||||
|
|
|
|||
|
|
@ -264,6 +264,23 @@ const LANGS = [
|
|||
` public long getId() { return this.id; }\n` +
|
||||
` public void setName(String v) { this.name = v; }\n}\n\n`,
|
||||
},
|
||||
{
|
||||
name: 'java-local-types',
|
||||
emit: emitJavaScopeCaptures,
|
||||
fixturePrefix: 'java-local',
|
||||
exts: ['.java'],
|
||||
file: 'bench-local.java',
|
||||
header:
|
||||
'package generated;\n\nclass Base {}\n\ninterface Marker {}\n\nclass Bench {\n void run() {\n',
|
||||
// Co-scale both independent ordinal sequences under one host; construction
|
||||
// and dispatch keep lexical-alias captures hot. The old per-identity
|
||||
// host-candidate filter made this combined workload quadratic.
|
||||
unit: (n) =>
|
||||
` { class Local extends Base implements Marker { long value() { return ${n}L; } } ` +
|
||||
`new Local().value(); }\n` +
|
||||
` Marker marker${n} = new Marker() {};\n`,
|
||||
footer: ' }\n}\n',
|
||||
},
|
||||
{
|
||||
name: 'typescript',
|
||||
emit: emitTsScopeCaptures,
|
||||
|
|
@ -309,7 +326,7 @@ const LANGS = [
|
|||
function generate(lang, entityCount) {
|
||||
let src = lang.header;
|
||||
for (let i = 0; i < entityCount; i++) src += lang.unit(i);
|
||||
return src;
|
||||
return src + (lang.footer ?? '');
|
||||
}
|
||||
|
||||
// ---- timing ----
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { ClassExtractionConfig } from '../../class-types.js';
|
||||
import { synthesizeJavaAnonymousClassName } from '../../utils/ast-helpers.js';
|
||||
import { synthesizeJavaTypeIdentity } from '../../utils/ast-helpers.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Java
|
||||
|
|
@ -33,10 +33,10 @@ export const javaClassConfig: ClassExtractionConfig = {
|
|||
'record_declaration',
|
||||
],
|
||||
extractName(node) {
|
||||
if (node.type === 'object_creation_expression' || node.type === 'enum_constant') {
|
||||
return synthesizeJavaAnonymousClassName(node);
|
||||
}
|
||||
return undefined;
|
||||
return synthesizeJavaTypeIdentity(node)?.name;
|
||||
},
|
||||
extractType(node) {
|
||||
return synthesizeJavaTypeIdentity(node)?.label;
|
||||
},
|
||||
// An anonymous body whose name CANNOT be synthesized must not become a
|
||||
// Class node at all. Without this skip, `extract()`'s
|
||||
|
|
@ -50,7 +50,7 @@ export const javaClassConfig: ClassExtractionConfig = {
|
|||
definitionNode !== undefined &&
|
||||
(definitionNode.type === 'object_creation_expression' ||
|
||||
definitionNode.type === 'enum_constant') &&
|
||||
synthesizeJavaAnonymousClassName(definitionNode) === undefined
|
||||
synthesizeJavaTypeIdentity(definitionNode) === undefined
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -20,9 +20,10 @@ import {
|
|||
recordClassAnnotationCapture,
|
||||
} from '../../frameworks/spring/bean-candidates.js';
|
||||
import {
|
||||
javaLocalTypeDeclarationContainer,
|
||||
nodeIfType,
|
||||
nodeToCapture,
|
||||
synthesizeJavaAnonymousClassName,
|
||||
synthesizeJavaTypeIdentity,
|
||||
syntheticCapture,
|
||||
} from '../../utils/ast-helpers.js';
|
||||
import { splitImportDeclaration } from './import-decomposer.js';
|
||||
|
|
@ -46,7 +47,11 @@ import { captureJavaSpringDiClassFact, type JavaSpringDiClassFact } from './spri
|
|||
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;
|
||||
const FUNCTION_NODE_TYPES = [
|
||||
'method_declaration',
|
||||
'constructor_declaration',
|
||||
'compact_constructor_declaration',
|
||||
] as const;
|
||||
|
||||
const JAVA_CALLABLE_CAPTURE_OPTIONS = {
|
||||
functionNodeTypes: new Set([...FUNCTION_NODE_TYPES, 'lambda_expression']),
|
||||
|
|
@ -67,6 +72,26 @@ const JAVA_CALLABLE_CAPTURE_OPTIONS = {
|
|||
normalizeQualifiedName: (raw: string) => raw.replaceAll('::', '.'),
|
||||
} as const;
|
||||
|
||||
/** Visibility of a local type begins at its declaration and ends with its
|
||||
* immediately enclosing block (JLS 6.3). A Java-only synthetic Block scope
|
||||
* models that range without changing shared resolver selection semantics. */
|
||||
function javaLocalTypeVisibilityScope(node: SyntaxNode): CaptureMatch | undefined {
|
||||
const container = javaLocalTypeDeclarationContainer(node);
|
||||
if (container === null) return undefined;
|
||||
return {
|
||||
'@scope.block': {
|
||||
name: '@scope.block',
|
||||
range: {
|
||||
startLine: node.startPosition.row + 1,
|
||||
startCol: node.startPosition.column,
|
||||
endLine: container.endPosition.row + 1,
|
||||
endCol: container.endPosition.column,
|
||||
},
|
||||
text: node.text,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 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). */
|
||||
|
|
@ -136,6 +161,29 @@ export function emitJavaScopeCaptures(
|
|||
continue;
|
||||
}
|
||||
|
||||
const typeDeclaration = [
|
||||
nodeMap['@declaration.class'],
|
||||
nodeMap['@declaration.enum'],
|
||||
nodeMap['@declaration.record'],
|
||||
nodeMap['@declaration.interface'],
|
||||
].find((node): node is SyntaxNode => node !== undefined);
|
||||
const localTypeIdentity =
|
||||
typeDeclaration === undefined ? undefined : synthesizeJavaTypeIdentity(typeDeclaration);
|
||||
if (
|
||||
localTypeIdentity?.bindingName !== undefined &&
|
||||
grouped['@declaration.name'] !== undefined &&
|
||||
typeDeclaration !== undefined
|
||||
) {
|
||||
grouped['@declaration.binding-name'] = grouped['@declaration.name'];
|
||||
grouped['@declaration.name'] = syntheticCapture(
|
||||
'@declaration.name',
|
||||
typeDeclaration,
|
||||
localTypeIdentity.name,
|
||||
);
|
||||
const visibilityScope = javaLocalTypeVisibilityScope(typeDeclaration);
|
||||
if (visibilityScope !== undefined) out.push(visibilityScope);
|
||||
}
|
||||
|
||||
// Decompose each `import_declaration`. `@import.statement` is captured
|
||||
// directly on the `import_declaration` node.
|
||||
if (grouped['@import.statement'] !== undefined) {
|
||||
|
|
@ -312,8 +360,8 @@ export function emitJavaScopeCaptures(
|
|||
|
||||
/**
|
||||
* Synthesize `@declaration.class` matches for anonymous class bodies
|
||||
* (`new Runnable() { ... }`), named by the same javac-style authority
|
||||
* (`synthesizeJavaAnonymousClassName` → `Worker$N`) the structure phase
|
||||
* (`new Runnable() { ... }`), named by the same javac-compatible authority
|
||||
* (`synthesizeJavaTypeIdentity` → `Worker$N`) the structure phase
|
||||
* uses — the two layers agree by construction (#2550).
|
||||
*
|
||||
* The anchor is the `class_body` node: it shares its range with the
|
||||
|
|
@ -326,13 +374,13 @@ export function emitJavaScopeCaptures(
|
|||
function synthesizeJavaAnonymousClassDeclarations(rootNode: SyntaxNode): CaptureMatch[] {
|
||||
const out: CaptureMatch[] = [];
|
||||
for (const oce of rootNode.descendantsOfType('object_creation_expression')) {
|
||||
const name = synthesizeJavaAnonymousClassName(oce);
|
||||
if (name === undefined) continue;
|
||||
const identity = synthesizeJavaTypeIdentity(oce);
|
||||
if (identity === undefined) continue;
|
||||
const body = oce.namedChildren.find((c) => c.type === 'class_body');
|
||||
if (body === undefined) continue;
|
||||
out.push({
|
||||
'@declaration.class': nodeToCapture('@declaration.class', body),
|
||||
'@declaration.name': syntheticCapture('@declaration.name', body, name),
|
||||
'@declaration.name': syntheticCapture('@declaration.name', body, identity.name),
|
||||
});
|
||||
|
||||
// Inheritance: the anonymous class extends/implements its constructed
|
||||
|
|
@ -373,7 +421,7 @@ function synthesizeJavaAnonymousClassDeclarations(rootNode: SyntaxNode): Capture
|
|||
out.push({
|
||||
'@type-binding.annotation': nodeToCapture('@type-binding.annotation', declNode),
|
||||
'@type-binding.name': nodeToCapture('@type-binding.name', varName),
|
||||
'@type-binding.type': syntheticCapture('@type-binding.type', oce, name),
|
||||
'@type-binding.type': syntheticCapture('@type-binding.type', oce, identity.name),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -389,11 +437,11 @@ function synthesizeJavaAnonymousClassDeclarations(rootNode: SyntaxNode): Capture
|
|||
const hostEnum = javaEnclosingEnumNameOf(constant);
|
||||
const bodyNode = constant.childForFieldName?.('body');
|
||||
const isBodied = bodyNode !== null && bodyNode !== undefined && bodyNode.type === 'class_body';
|
||||
const bodiedName = synthesizeJavaAnonymousClassName(constant);
|
||||
if (bodiedName !== undefined && isBodied) {
|
||||
const bodiedIdentity = synthesizeJavaTypeIdentity(constant);
|
||||
if (bodiedIdentity !== undefined && isBodied) {
|
||||
out.push({
|
||||
'@declaration.class': nodeToCapture('@declaration.class', bodyNode),
|
||||
'@declaration.name': syntheticCapture('@declaration.name', bodyNode, bodiedName),
|
||||
'@declaration.name': syntheticCapture('@declaration.name', bodyNode, bodiedIdentity.name),
|
||||
});
|
||||
if (hostEnum !== undefined) {
|
||||
out.push({
|
||||
|
|
@ -421,7 +469,7 @@ function synthesizeJavaAnonymousClassDeclarations(rootNode: SyntaxNode): Capture
|
|||
// the `object_creation_expression` branch, which skips on synthesis
|
||||
// failure. `hostEnum` is used only for genuinely body-less constants.
|
||||
const constantNameNode = constant.childForFieldName?.('name');
|
||||
const constantType = isBodied ? bodiedName : hostEnum;
|
||||
const constantType = isBodied ? bodiedIdentity?.name : hostEnum;
|
||||
if (constantNameNode !== null && constantNameNode !== undefined && constantType !== undefined) {
|
||||
out.push({
|
||||
'@type-binding.annotation': nodeToCapture('@type-binding.annotation', constant),
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ const JAVA_SCOPE_QUERY = `
|
|||
|
||||
(method_declaration) @scope.function
|
||||
(constructor_declaration) @scope.function
|
||||
(compact_constructor_declaration) @scope.function
|
||||
|
||||
;; Declarations — types
|
||||
(class_declaration
|
||||
|
|
|
|||
|
|
@ -705,6 +705,7 @@ function parseJsonStringArrayCapture(
|
|||
|
||||
function deriveDeclarationName(match: CaptureMatch, def: SymbolDefinition): string | undefined {
|
||||
const nameCap =
|
||||
match['@declaration.binding-name'] ??
|
||||
match['@declaration.name'] ??
|
||||
match[
|
||||
Object.keys(match).find((k) => k.startsWith('@declaration.') && k.endsWith('.name')) ?? ''
|
||||
|
|
|
|||
|
|
@ -1,8 +1,4 @@
|
|||
import {
|
||||
findChild,
|
||||
synthesizeJavaAnonymousClassName,
|
||||
type SyntaxNode,
|
||||
} from '../utils/ast-helpers.js';
|
||||
import { findChild, synthesizeJavaTypeIdentity, type SyntaxNode } from '../utils/ast-helpers.js';
|
||||
import type {
|
||||
LanguageTypeConfig,
|
||||
ParameterExtractor,
|
||||
|
|
@ -40,7 +36,7 @@ const JAVA_DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
|
|||
const anonymousInitializerTypeName = (declarator: SyntaxNode): string | undefined => {
|
||||
const valueNode = declarator.childForFieldName('value');
|
||||
if (!valueNode || valueNode.type !== 'object_creation_expression') return undefined;
|
||||
return synthesizeJavaAnonymousClassName(valueNode);
|
||||
return synthesizeJavaTypeIdentity(valueNode)?.name;
|
||||
};
|
||||
|
||||
/** Java: Type x = ...; Type x; */
|
||||
|
|
|
|||
|
|
@ -405,31 +405,43 @@ export interface EnclosingClassInfo {
|
|||
const MAX_ENCLOSING_WALK_ITERATIONS = 4096;
|
||||
|
||||
/**
|
||||
* Synthesize a javac-style name for a Java anonymous class body:
|
||||
* `new Runnable() { ... }` inside top-level class `Worker` becomes
|
||||
* `Worker$1` (`$N` = 1-based source order of anonymous bodies within the
|
||||
* top-level class). Returns undefined when the node is not an
|
||||
* `object_creation_expression` carrying a `class_body` child — which also
|
||||
* keeps this a no-op for C#, whose `object_creation_expression` uses
|
||||
* `initializer_expression`, never `class_body` (#2550).
|
||||
*
|
||||
* The SAME name must be produced by every layer that keys the anonymous
|
||||
* class (structure-phase node id, enclosing-owner walk, scope-side
|
||||
* declaration synthesis, receiver typeBinding) — they agree by all calling
|
||||
* this one helper.
|
||||
* GitNexus's source-type-relative Java identity for local and anonymous
|
||||
* types. It follows javac's `$N` allocation but intentionally omits the
|
||||
* package prefix because graph ids already include the source file path.
|
||||
*/
|
||||
/** Type-declaration node types that can host (and name) a Java anonymous
|
||||
* class body. Naming follows JLS 13.1: the binary name is the
|
||||
* IMMEDIATELY enclosing type's binary name + `$N`, so the synthesized
|
||||
* name is the `$`-joined chain of enclosing host names
|
||||
* (`EnumWrap$Mode$1`), numbered per immediate host in source order. */
|
||||
const JAVA_ANON_HOST_TYPES = new Set([
|
||||
'class_declaration',
|
||||
'enum_declaration',
|
||||
'interface_declaration',
|
||||
'record_declaration',
|
||||
export interface JavaSynthesizedTypeIdentity {
|
||||
readonly name: string;
|
||||
readonly label: 'Class' | 'Enum' | 'Record' | 'Interface';
|
||||
readonly bindingName?: string;
|
||||
}
|
||||
|
||||
/** Named Java declarations that can host, or themselves be, local types. */
|
||||
const JAVA_NAMED_TYPE_NODE_LABELS = new Map<string, JavaSynthesizedTypeIdentity['label']>([
|
||||
['class_declaration', 'Class'],
|
||||
['enum_declaration', 'Enum'],
|
||||
['interface_declaration', 'Interface'],
|
||||
['record_declaration', 'Record'],
|
||||
]);
|
||||
|
||||
const JAVA_ANON_HOST_TYPES = new Set(JAVA_NAMED_TYPE_NODE_LABELS.keys());
|
||||
const JAVA_LOCAL_TYPE_CONTAINERS = new Set([
|
||||
'block',
|
||||
'constructor_body',
|
||||
'switch_block_statement_group',
|
||||
]);
|
||||
|
||||
/** A legal local type declaration is a class, enum, record, or interface
|
||||
* directly occupying a block-statement position. Annotation interfaces are
|
||||
* deliberately excluded: javac rejects local annotation declarations. */
|
||||
export const javaLocalTypeDeclarationContainer = (node: SyntaxNode): SyntaxNode | null => {
|
||||
if (!JAVA_NAMED_TYPE_NODE_LABELS.has(node.type)) return null;
|
||||
const parent = node.parent;
|
||||
return parent !== null && JAVA_LOCAL_TYPE_CONTAINERS.has(parent.type) ? parent : null;
|
||||
};
|
||||
|
||||
const isJavaLocalTypeNode = (node: SyntaxNode): boolean =>
|
||||
javaLocalTypeDeclarationContainer(node) !== null;
|
||||
|
||||
/** The two Java anonymous-class-body shapes (#2550/#2555): an
|
||||
* `object_creation_expression` with a `class_body` child
|
||||
* (`new Runnable() { ... }`), and an `enum_constant` with a `body:`
|
||||
|
|
@ -439,10 +451,7 @@ const isJavaAnonymousBodyNode = (node: SyntaxNode): boolean =>
|
|||
node.namedChildren?.some((c: SyntaxNode) => c.type === 'class_body') === true) ||
|
||||
(node.type === 'enum_constant' && node.childForFieldName?.('body')?.type === 'class_body');
|
||||
|
||||
/** Nearest ancestor of `node` that is an enclosing TYPE per JLS 13.1 —
|
||||
* a named host declaration OR another anonymous body (both shapes).
|
||||
* Anonymous ancestors chain through: an anon inside an anon is
|
||||
* `Host$1$1`, and an anon inside an enum constant body is `E$1$1`. */
|
||||
/** Nearest ancestor of `node` that is an enclosing type per JLS 13.1. */
|
||||
const nearestJavaEnclosingType = (node: SyntaxNode): SyntaxNode | null => {
|
||||
let cursor: SyntaxNode | null = node.parent;
|
||||
let iterations = 0;
|
||||
|
|
@ -454,83 +463,142 @@ const nearestJavaEnclosingType = (node: SyntaxNode): SyntaxNode | null => {
|
|||
return null;
|
||||
};
|
||||
|
||||
/** Per-parse-tree memo of anonymous-body numbering: tree → (startIndex →
|
||||
* synthesized name). Keyed by the tree OBJECT via WeakMap so entries die
|
||||
* with the parse; without it every call re-scans the host subtree
|
||||
* (`descendantsOfType`), and the helper is called from four independent
|
||||
* layers per anonymous body — quadratic on anon-heavy files (old-style
|
||||
* listener-per-widget Java). */
|
||||
const javaAnonNameMemo = new WeakMap<object, Map<number, string>>();
|
||||
interface JavaTypeIdentityState {
|
||||
readonly byStart: Map<number, JavaSynthesizedTypeIdentity>;
|
||||
readonly ordinalByStart: Map<number, number>;
|
||||
}
|
||||
|
||||
export const synthesizeJavaAnonymousClassName = (node: SyntaxNode): string | undefined => {
|
||||
if (!isJavaAnonymousBodyNode(node)) return undefined;
|
||||
/** Parse-tree-bounded memo. Sequence ordinals are built once per tree, avoiding
|
||||
* a host-candidate scan for every extraction/ownership consumer. */
|
||||
const javaTypeIdentityMemo = new WeakMap<object, JavaTypeIdentityState>();
|
||||
|
||||
const tree = (node as { tree?: object }).tree;
|
||||
if (tree !== undefined) {
|
||||
const cached = javaAnonNameMemo.get(tree)?.get(node.startIndex);
|
||||
if (cached !== undefined) return cached;
|
||||
const javaHostKey = (node: SyntaxNode): string => `${node.type}:${node.startIndex}`;
|
||||
|
||||
const javaIdentityCandidatesBelow = (root: SyntaxNode): SyntaxNode[] => {
|
||||
const seen = new Set<string>();
|
||||
const candidates: SyntaxNode[] = [];
|
||||
for (const type of [
|
||||
'object_creation_expression',
|
||||
'enum_constant',
|
||||
...JAVA_NAMED_TYPE_NODE_LABELS.keys(),
|
||||
]) {
|
||||
for (const candidate of root.descendantsOfType?.(type) ?? []) {
|
||||
if (!isJavaAnonymousBodyNode(candidate) && !isJavaLocalTypeNode(candidate)) continue;
|
||||
const key = javaHostKey(candidate);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
candidates.push(candidate);
|
||||
}
|
||||
}
|
||||
return candidates.sort((left, right) => left.startIndex - right.startIndex);
|
||||
};
|
||||
|
||||
// JLS 13.1: the binary name is the IMMEDIATELY ENCLOSING TYPE's binary
|
||||
// name + `$N`. The enclosing type may itself be anonymous — then its
|
||||
// own synthesized name is the prefix (recursion, memo-bounded):
|
||||
// `NestHost$1$1` for an anon inside an anon, `E$1$1` for an anon
|
||||
// inside an enum constant body. For a named enclosing type the prefix
|
||||
// is the `$`-joined chain of named hosts (`EnumWrap$Mode`).
|
||||
const buildJavaTypeIdentityState = (root: SyntaxNode): JavaTypeIdentityState => {
|
||||
const ordinalByStart = new Map<number, number>();
|
||||
const sequenceCounts = new Map<string, number>();
|
||||
for (const candidate of javaIdentityCandidatesBelow(root)) {
|
||||
const host = nearestJavaEnclosingType(candidate);
|
||||
if (host === null) continue;
|
||||
const isAnonymous = isJavaAnonymousBodyNode(candidate);
|
||||
const bindingName = isAnonymous ? '' : candidate.childForFieldName?.('name')?.text;
|
||||
// Anonymous types deliberately use the empty sequence key; malformed named
|
||||
// declarations must not enter that sequence.
|
||||
if (!isAnonymous && !bindingName) continue;
|
||||
const sequenceKey = `${javaHostKey(host)}:${bindingName}`;
|
||||
const ordinal = (sequenceCounts.get(sequenceKey) ?? 0) + 1;
|
||||
sequenceCounts.set(sequenceKey, ordinal);
|
||||
ordinalByStart.set(candidate.startIndex, ordinal);
|
||||
}
|
||||
return { byStart: new Map(), ordinalByStart };
|
||||
};
|
||||
|
||||
const javaTypeIdentityStateFor = (node: SyntaxNode): JavaTypeIdentityState => {
|
||||
const tree = (node as { tree?: { rootNode?: SyntaxNode } }).tree;
|
||||
if (tree === undefined) {
|
||||
const host = nearestJavaEnclosingType(node);
|
||||
return buildJavaTypeIdentityState(host ?? node);
|
||||
}
|
||||
let state = javaTypeIdentityMemo.get(tree);
|
||||
if (state === undefined) {
|
||||
state = buildJavaTypeIdentityState(tree.rootNode ?? node);
|
||||
javaTypeIdentityMemo.set(tree, state);
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
/** Source-type-relative binary name of a Java enclosing type, including
|
||||
* synthesized local/anonymous hosts and named member-type chains. */
|
||||
const javaBinaryNameOfType = (node: SyntaxNode): string | undefined => {
|
||||
if (isJavaAnonymousBodyNode(node) || isJavaLocalTypeNode(node)) {
|
||||
return synthesizeJavaTypeIdentity(node)?.name;
|
||||
}
|
||||
if (!JAVA_ANON_HOST_TYPES.has(node.type)) return undefined;
|
||||
const simpleName = node.childForFieldName?.('name')?.text;
|
||||
if (simpleName === undefined || simpleName.length === 0) return undefined;
|
||||
const enclosing = nearestJavaEnclosingType(node);
|
||||
if (enclosing === null) return simpleName;
|
||||
const enclosingName = javaBinaryNameOfType(enclosing);
|
||||
return enclosingName === undefined ? undefined : `${enclosingName}$${simpleName}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Authoritative Java local/anonymous type identity.
|
||||
*
|
||||
* JLS 13.1 defines the shape and immediate-host prefix. OpenJDK javac's
|
||||
* Check.localClassName allocates N independently for each
|
||||
* (enclosing binary name, local simple name) pair; anonymous types use the
|
||||
* empty simple name and therefore have their own sequence. Package names are
|
||||
* omitted from this project identity because graph ids already include the
|
||||
* file path.
|
||||
*/
|
||||
export const synthesizeJavaTypeIdentity = (
|
||||
node: SyntaxNode,
|
||||
): JavaSynthesizedTypeIdentity | undefined => {
|
||||
const localLabel = JAVA_NAMED_TYPE_NODE_LABELS.get(node.type);
|
||||
const isLocal = localLabel !== undefined && isJavaLocalTypeNode(node);
|
||||
const isAnonymous = isJavaAnonymousBodyNode(node);
|
||||
const enclosing = nearestJavaEnclosingType(node);
|
||||
const memberSimpleName =
|
||||
!isLocal && !isAnonymous && localLabel !== undefined
|
||||
? node.childForFieldName?.('name')?.text
|
||||
: undefined;
|
||||
const synthesizedHostIdentity =
|
||||
memberSimpleName !== undefined && enclosing !== null
|
||||
? synthesizeJavaTypeIdentity(enclosing)
|
||||
: undefined;
|
||||
if (!isLocal && !isAnonymous && synthesizedHostIdentity === undefined) return undefined;
|
||||
if (enclosing === null) return undefined;
|
||||
let prefix: string;
|
||||
if (isJavaAnonymousBodyNode(enclosing)) {
|
||||
const enclosingName = synthesizeJavaAnonymousClassName(enclosing);
|
||||
if (enclosingName === undefined) return undefined;
|
||||
prefix = enclosingName;
|
||||
} else {
|
||||
const hostNames: string[] = [];
|
||||
let cursor: SyntaxNode | null = enclosing;
|
||||
let iterations = 0;
|
||||
while (cursor) {
|
||||
if (++iterations > MAX_ENCLOSING_WALK_ITERATIONS) return undefined;
|
||||
if (JAVA_ANON_HOST_TYPES.has(cursor.type)) {
|
||||
const hostName = cursor.childForFieldName?.('name')?.text;
|
||||
if (hostName === undefined || hostName.length === 0) return undefined;
|
||||
hostNames.unshift(hostName);
|
||||
}
|
||||
cursor = cursor.parent;
|
||||
}
|
||||
prefix = hostNames.join('$');
|
||||
|
||||
const state = javaTypeIdentityStateFor(node);
|
||||
const cached = state.byStart.get(node.startIndex);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const prefix = javaBinaryNameOfType(enclosing);
|
||||
if (prefix === undefined) return undefined;
|
||||
|
||||
if (memberSimpleName !== undefined) {
|
||||
const identity: JavaSynthesizedTypeIdentity = {
|
||||
name: `${prefix}$${memberSimpleName}`,
|
||||
label: localLabel!,
|
||||
bindingName: memberSimpleName,
|
||||
};
|
||||
state.byStart.set(node.startIndex, identity);
|
||||
return identity;
|
||||
}
|
||||
|
||||
// All anonymous bodies (both shapes) whose immediately enclosing TYPE
|
||||
// is THIS one, in source order. `descendantsOfType` over the subtree
|
||||
// also finds bodies belonging to nested enclosing types — filter them
|
||||
// out by re-deriving each candidate's own enclosing type.
|
||||
const candidates = [
|
||||
...(enclosing.descendantsOfType?.('object_creation_expression') ?? []),
|
||||
...(enclosing.descendantsOfType?.('enum_constant') ?? []),
|
||||
]
|
||||
.filter(isJavaAnonymousBodyNode)
|
||||
.filter((c: SyntaxNode) => {
|
||||
const host = nearestJavaEnclosingType(c);
|
||||
return (
|
||||
host !== null && host.startIndex === enclosing.startIndex && host.type === enclosing.type
|
||||
);
|
||||
})
|
||||
.sort((a: SyntaxNode, b: SyntaxNode) => a.startIndex - b.startIndex);
|
||||
const bindingName = isLocal ? node.childForFieldName?.('name')?.text : undefined;
|
||||
if (isLocal && !bindingName) return undefined;
|
||||
|
||||
if (tree !== undefined) {
|
||||
let byStart = javaAnonNameMemo.get(tree);
|
||||
if (byStart === undefined) {
|
||||
byStart = new Map();
|
||||
javaAnonNameMemo.set(tree, byStart);
|
||||
}
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
byStart.set(candidates[i]!.startIndex, `${prefix}$${i + 1}`);
|
||||
}
|
||||
return byStart.get(node.startIndex);
|
||||
}
|
||||
const index = candidates.findIndex((c: SyntaxNode) => c.startIndex === node.startIndex);
|
||||
if (index === -1) return undefined;
|
||||
return `${prefix}$${index + 1}`;
|
||||
const ordinal = state.ordinalByStart.get(node.startIndex);
|
||||
if (ordinal === undefined) return undefined;
|
||||
|
||||
const identity: JavaSynthesizedTypeIdentity = {
|
||||
name: `${prefix}$${ordinal}${bindingName ?? ''}`,
|
||||
label: isAnonymous ? 'Class' : localLabel!,
|
||||
...(bindingName === undefined ? {} : { bindingName }),
|
||||
};
|
||||
state.byStart.set(node.startIndex, identity);
|
||||
return identity;
|
||||
};
|
||||
|
||||
export const findEnclosingClassInfo = (
|
||||
|
|
@ -605,12 +673,12 @@ export const findEnclosingClassInfo = (
|
|||
// enum constant, and every C# `object_creation_expression`), so the
|
||||
// walk continues unchanged for those — including on to
|
||||
// `enum_declaration`, which sits in CLASS_CONTAINER_TYPES below.
|
||||
if (current.type === 'object_creation_expression' || current.type === 'enum_constant') {
|
||||
const anonName = synthesizeJavaAnonymousClassName(current);
|
||||
if (anonName !== undefined) {
|
||||
if (isJavaAnonymousBodyNode(current) || JAVA_ANON_HOST_TYPES.has(current.type)) {
|
||||
const identity = synthesizeJavaTypeIdentity(current);
|
||||
if (identity !== undefined) {
|
||||
return {
|
||||
classId: generateId('Class', `${filePath}:${anonName}`),
|
||||
className: anonName,
|
||||
classId: generateId(identity.label, `${filePath}:${identity.name}`),
|
||||
className: identity.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,9 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
|
|||
// method injection sites plus bean-name and @Primary provider metadata.
|
||||
// v20: Java/Kotlin capture side-channels persist package and class-annotation
|
||||
// facts for shared Spring Bean resolution.
|
||||
// v21: Java local class/enum/record/interface captures use javac-compatible,
|
||||
// source-type-relative JLS 13.1 identities and declaration-to-block scopes
|
||||
// (#2562).
|
||||
// v19: Java enum constant bodies emit E$N Class nodes; anonymous naming uses
|
||||
// JLS 13.1 immediate-host chains (#2555).
|
||||
// v18: Worker$N anonymous bodies. v17: callable-value-flow operand identity.
|
||||
|
|
|
|||
|
|
@ -457,8 +457,14 @@ export interface RepoMeta {
|
|||
* spurious edges and these new resolved edges are cross-file, so a pre-v12
|
||||
* top-up would leave unchanged Rust files stale either way; force a full
|
||||
* re-analyze instead.
|
||||
* v13: Java local classes, enums, records, and interfaces use
|
||||
* source-type-relative JLS 13.1 identities (`Outer$1Local`). Number allocation
|
||||
* matches javac: one sequence per (enclosing type, local simple name), with a
|
||||
* separate sequence for anonymous types. Existing type/member ids, lexical
|
||||
* bindings, and ownership edges must not be mixed with newly named unchanged
|
||||
* Java files; force a full re-analyze.
|
||||
*/
|
||||
export const INCREMENTAL_SCHEMA_VERSION = 12;
|
||||
export const INCREMENTAL_SCHEMA_VERSION = 13;
|
||||
|
||||
export interface IndexedRepo {
|
||||
repoPath: string;
|
||||
|
|
|
|||
12
gitnexus/test/fixtures/lang-resolution/java-local-class-naming/src/Compact.java
vendored
Normal file
12
gitnexus/test/fixtures/lang-resolution/java-local-class-naming/src/Compact.java
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
record Compact(int value) {
|
||||
Compact {
|
||||
class Local {
|
||||
void inner() {}
|
||||
}
|
||||
|
||||
new Local().inner();
|
||||
new Runnable() {
|
||||
public void run() {}
|
||||
};
|
||||
}
|
||||
}
|
||||
123
gitnexus/test/fixtures/lang-resolution/java-local-class-naming/src/Outer.java
vendored
Normal file
123
gitnexus/test/fixtures/lang-resolution/java-local-class-naming/src/Outer.java
vendored
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
class Outer {
|
||||
class Cyclic {
|
||||
void member() {}
|
||||
}
|
||||
|
||||
class MemberHost {
|
||||
void make() {
|
||||
class Local {
|
||||
void ordinaryMemberHit() {}
|
||||
}
|
||||
|
||||
new Local().ordinaryMemberHit();
|
||||
}
|
||||
}
|
||||
|
||||
void first() {
|
||||
class Local {
|
||||
void inner() {
|
||||
new Runnable() {
|
||||
public void run() {}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class CtorHost {
|
||||
CtorHost() {
|
||||
class Local {
|
||||
void inner() {}
|
||||
}
|
||||
new Local().inner();
|
||||
}
|
||||
}
|
||||
|
||||
class NestedHost {
|
||||
class Member {
|
||||
void make() {
|
||||
class Local {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
new Local().inner();
|
||||
new Runnable() {
|
||||
public void run() {}
|
||||
};
|
||||
}
|
||||
|
||||
void second() {
|
||||
new Runnable() {
|
||||
public void run() {}
|
||||
};
|
||||
|
||||
class Local {
|
||||
void inner() {}
|
||||
}
|
||||
|
||||
new Local().inner();
|
||||
}
|
||||
|
||||
void declarationOrder() {
|
||||
new Cyclic().member();
|
||||
|
||||
class Cyclic {
|
||||
void local() {}
|
||||
}
|
||||
|
||||
new Cyclic().local();
|
||||
}
|
||||
|
||||
void blocks() {
|
||||
{
|
||||
class Local {
|
||||
void firstBlock() {}
|
||||
}
|
||||
|
||||
new Local().firstBlock();
|
||||
}
|
||||
|
||||
{
|
||||
class Local {
|
||||
void secondBlock() {}
|
||||
}
|
||||
|
||||
new Local().secondBlock();
|
||||
}
|
||||
}
|
||||
|
||||
static {
|
||||
class StaticLocal {
|
||||
void staticHit() {}
|
||||
}
|
||||
|
||||
new StaticLocal().staticHit();
|
||||
}
|
||||
|
||||
{
|
||||
class InstanceLocal {
|
||||
void instanceHit() {}
|
||||
}
|
||||
|
||||
new InstanceLocal().instanceHit();
|
||||
}
|
||||
|
||||
Runnable task = () -> {
|
||||
class LambdaLocal {
|
||||
void lambdaHit() {}
|
||||
}
|
||||
|
||||
new LambdaLocal().lambdaHit();
|
||||
};
|
||||
|
||||
Runnable anonymousTask = new Runnable() {
|
||||
{
|
||||
class Local {
|
||||
void anonymousHit() {}
|
||||
}
|
||||
|
||||
new Local().anonymousHit();
|
||||
}
|
||||
|
||||
public void run() {}
|
||||
};
|
||||
}
|
||||
24
gitnexus/test/fixtures/lang-resolution/java-local-class-naming/src/Types.java
vendored
Normal file
24
gitnexus/test/fixtures/lang-resolution/java-local-class-naming/src/Types.java
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
class Types {
|
||||
void types() {
|
||||
enum E {
|
||||
A;
|
||||
|
||||
void enumHit() {}
|
||||
}
|
||||
|
||||
record R(int x) {
|
||||
void recordHit() {}
|
||||
}
|
||||
|
||||
interface I {
|
||||
void run();
|
||||
}
|
||||
|
||||
E.A.enumHit();
|
||||
new R(1).recordHit();
|
||||
I implementation = new I() {
|
||||
public void run() {}
|
||||
};
|
||||
implementation.run();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { FIXTURES } from './helpers.js';
|
||||
|
||||
const javacAvailable = spawnSync('javac', ['-version'], { stdio: 'ignore' }).status === 0;
|
||||
|
||||
describe('Java local-type names emitted by javac', () => {
|
||||
it.runIf(javacAvailable)('matches the identities asserted by the resolver fixture', () => {
|
||||
const temp = mkdtempSync(path.join(tmpdir(), 'gitnexus-javac-local-types-'));
|
||||
const output = path.join(temp, 'classes');
|
||||
mkdirSync(output);
|
||||
|
||||
try {
|
||||
const sourceDir = path.join(FIXTURES, 'java-local-class-naming', 'src');
|
||||
const sources = readdirSync(sourceDir)
|
||||
.filter((name) => name.endsWith('.java'))
|
||||
.map((name) => path.join(sourceDir, name));
|
||||
execFileSync('javac', ['-d', output, ...sources]);
|
||||
|
||||
expect(readdirSync(output).sort()).toEqual([
|
||||
'Compact$1.class',
|
||||
'Compact$1Local.class',
|
||||
'Compact.class',
|
||||
'Outer$1.class',
|
||||
'Outer$1CtorHost$1Local.class',
|
||||
'Outer$1CtorHost.class',
|
||||
'Outer$1Cyclic.class',
|
||||
'Outer$1InstanceLocal.class',
|
||||
'Outer$1LambdaLocal.class',
|
||||
'Outer$1Local$1.class',
|
||||
'Outer$1Local.class',
|
||||
'Outer$1NestedHost$Member$1Local.class',
|
||||
'Outer$1NestedHost$Member.class',
|
||||
'Outer$1NestedHost.class',
|
||||
'Outer$1StaticLocal.class',
|
||||
'Outer$2.class',
|
||||
'Outer$2Local.class',
|
||||
'Outer$3$1Local.class',
|
||||
'Outer$3.class',
|
||||
'Outer$3Local.class',
|
||||
'Outer$4Local.class',
|
||||
'Outer$Cyclic.class',
|
||||
'Outer$MemberHost$1Local.class',
|
||||
'Outer$MemberHost.class',
|
||||
'Outer.class',
|
||||
'Types$1.class',
|
||||
'Types$1E.class',
|
||||
'Types$1I.class',
|
||||
'Types$1R.class',
|
||||
'Types.class',
|
||||
]);
|
||||
} finally {
|
||||
rmSync(temp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -2885,6 +2885,118 @@ describe('Java instance-ownership free-call gate (#2550)', () => {
|
|||
}, 60000);
|
||||
});
|
||||
|
||||
describe('Java local-type identity and lexical scope (#2562)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-local-class-naming'), () => {});
|
||||
}, 60000);
|
||||
|
||||
it('matches javac local-name and anonymous-name sequences per immediate host', () => {
|
||||
const classes = getNodesByLabel(result, 'Class');
|
||||
expect(classes).toContain('Outer$1Local');
|
||||
expect(classes).toContain('Outer$1CtorHost');
|
||||
expect(classes).toContain('Outer$1NestedHost');
|
||||
expect(classes).toContain('Outer$1');
|
||||
expect(classes).toContain('Outer$2');
|
||||
expect(classes).toContain('Outer$2Local');
|
||||
expect(classes).toContain('Outer$1Local$1');
|
||||
expect(classes).toContain('Outer$1CtorHost$1Local');
|
||||
expect(classes).toContain('Outer$1NestedHost$Member$1Local');
|
||||
expect(classes).toContain('Outer$MemberHost$1Local');
|
||||
expect(classes).toContain('Outer$1StaticLocal');
|
||||
expect(classes).toContain('Outer$1InstanceLocal');
|
||||
expect(classes).toContain('Outer$1LambdaLocal');
|
||||
expect(classes).toContain('Outer$3$1Local');
|
||||
expect(classes).toContain('Compact$1Local');
|
||||
expect(classes).toContain('Compact$1');
|
||||
expect(classes).not.toContain('Local');
|
||||
});
|
||||
|
||||
it('emits the correct graph label and owner for every local type kind', () => {
|
||||
expect(getNodesByLabel(result, 'Enum')).toContain('Types$1E');
|
||||
expect(getNodesByLabel(result, 'Record')).toContain('Types$1R');
|
||||
expect(getNodesByLabel(result, 'Interface')).toContain('Types$1I');
|
||||
expect(getNodesByLabel(result, 'Class')).not.toContain('Types$1E');
|
||||
expect(getNodesByLabel(result, 'Class')).not.toContain('Types$1R');
|
||||
expect(getNodesByLabel(result, 'Class')).not.toContain('Types$1I');
|
||||
|
||||
const hasMethod = getRelationships(result, 'HAS_METHOD');
|
||||
for (const [label, owner, method] of [
|
||||
['Class', 'Outer$1Local', 'inner'],
|
||||
['Class', 'Outer$2Local', 'inner'],
|
||||
['Class', 'Outer$1CtorHost$1Local', 'inner'],
|
||||
['Class', 'Outer$1NestedHost$Member', 'make'],
|
||||
['Enum', 'Types$1E', 'enumHit'],
|
||||
['Record', 'Types$1R', 'recordHit'],
|
||||
['Interface', 'Types$1I', 'run'],
|
||||
]) {
|
||||
expect(
|
||||
hasMethod.some(
|
||||
(edge) =>
|
||||
edge.rel.sourceId ===
|
||||
`${label}:src/${owner.startsWith('Types') ? 'Types' : 'Outer'}.java:${owner}` &&
|
||||
edge.rel.targetId ===
|
||||
`Method:src/${owner.startsWith('Types') ? 'Types' : 'Outer'}.java:${owner}.${method}#0`,
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps source-level construction dispatch bound to each local identity', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
expect(calls.find((c) => c.source === 'first' && c.target === 'inner')?.rel.targetId).toBe(
|
||||
'Method:src/Outer.java:Outer$1Local.inner#0',
|
||||
);
|
||||
expect(calls.find((c) => c.source === 'second' && c.target === 'inner')?.rel.targetId).toBe(
|
||||
'Method:src/Outer.java:Outer$2Local.inner#0',
|
||||
);
|
||||
expect(calls.find((c) => c.source === 'CtorHost' && c.target === 'inner')?.rel.targetId).toBe(
|
||||
'Method:src/Outer.java:Outer$1CtorHost$1Local.inner#0',
|
||||
);
|
||||
for (const targetId of [
|
||||
'Method:src/Outer.java:Outer$1StaticLocal.staticHit#0',
|
||||
'Method:src/Outer.java:Outer$1InstanceLocal.instanceHit#0',
|
||||
'Method:src/Outer.java:Outer$1LambdaLocal.lambdaHit#0',
|
||||
'Method:src/Outer.java:Outer$3$1Local.anonymousHit#0',
|
||||
'Method:src/Outer.java:Outer$MemberHost$1Local.ordinaryMemberHit#0',
|
||||
'Method:src/Compact.java:Compact$1Local.inner#0',
|
||||
'Method:src/Types.java:Types$1E.enumHit#0',
|
||||
'Method:src/Types.java:Types$1R.recordHit#0',
|
||||
'Method:src/Types.java:Types$1.run#0',
|
||||
]) {
|
||||
expect(
|
||||
calls.some((call) => call.rel.targetId === targetId),
|
||||
targetId,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('respects declaration-order visibility against a same-named member type', () => {
|
||||
const calls = getRelationships(result, 'CALLS').filter(
|
||||
(call) => call.source === 'declarationOrder',
|
||||
);
|
||||
|
||||
expect(calls.find((call) => call.target === 'member')?.rel.targetId).toBe(
|
||||
'Method:src/Outer.java:Cyclic.member#0',
|
||||
);
|
||||
expect(calls.find((call) => call.target === 'local')?.rel.targetId).toBe(
|
||||
'Method:src/Outer.java:Outer$1Cyclic.local#0',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps same-named local types isolated to their disjoint blocks', () => {
|
||||
const calls = getRelationships(result, 'CALLS').filter((call) => call.source === 'blocks');
|
||||
|
||||
expect(calls.find((call) => call.target === 'firstBlock')?.rel.targetId).toBe(
|
||||
'Method:src/Outer.java:Outer$3Local.firstBlock#0',
|
||||
);
|
||||
expect(calls.find((call) => call.target === 'secondBlock')?.rel.targetId).toBe(
|
||||
'Method:src/Outer.java:Outer$4Local.secondBlock#0',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #2550 review hardening: (a) an anonymous class inherits from its
|
||||
// constructed type, so bare calls to inherited methods INSIDE the
|
||||
|
|
|
|||
|
|
@ -73,8 +73,8 @@ describe('CALL_SUMMARY relation-type exclusion (U-C1)', () => {
|
|||
});
|
||||
|
||||
describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => {
|
||||
it('INCREMENTAL_SCHEMA_VERSION is bumped to 12 (Rust range-binding ambiguity latch + import-disambiguated resolution, #2514)', () => {
|
||||
expect(INCREMENTAL_SCHEMA_VERSION).toBe(12);
|
||||
it('INCREMENTAL_SCHEMA_VERSION is 13 (Java local-type identity migration, #2562)', () => {
|
||||
expect(INCREMENTAL_SCHEMA_VERSION).toBe(13);
|
||||
});
|
||||
|
||||
it('a pre-current stamp fails the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate → forces full re-analyze', () => {
|
||||
|
|
@ -121,7 +121,11 @@ describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => {
|
|||
// import-disambiguated resolution adds new ones on unchanged Rust files,
|
||||
// neither of which reach an incremental write set → must NOT reuse.
|
||||
expect(passesReuseGate(11)).toBe(false);
|
||||
// A pre-v13 (v12) index predates javac-compatible Java local-type
|
||||
// identities and lexical visibility scopes (#2562), so unchanged
|
||||
// simple-name-keyed type/member ids must not survive.
|
||||
expect(passesReuseGate(12)).toBe(false);
|
||||
// A current-version stamp passes the gate (incremental top-up eligible).
|
||||
expect(passesReuseGate(12)).toBe(true);
|
||||
expect(passesReuseGate(13)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -146,3 +146,130 @@ class C {
|
|||
expect(invokeFactsFor(src)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitJavaScopeCaptures — local-type identities (#2562)', () => {
|
||||
it('uses the source-type-relative identity for the definition and the simple lexical binding', () => {
|
||||
const matches = emitJavaScopeCaptures(
|
||||
'class Outer { void m() { class Local {} new Local(); } }',
|
||||
'Outer.java',
|
||||
);
|
||||
const local = matches.find((m) => m['@declaration.name']?.text === 'Outer$1Local');
|
||||
|
||||
expect(local?.['@declaration.binding-name']?.text).toBe('Local');
|
||||
});
|
||||
|
||||
it('leaves non-local class declarations unchanged', () => {
|
||||
const matches = emitJavaScopeCaptures('class Outer { class Member {} }', 'Outer.java');
|
||||
const member = matches.find((m) => m['@declaration.name']?.text === 'Member');
|
||||
|
||||
expect(member?.['@declaration.binding-name']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('recognizes a local class inside a record compact constructor', () => {
|
||||
const matches = emitJavaScopeCaptures(
|
||||
'record R(int x) { R { class Local {} new Runnable() {}; } }',
|
||||
'R.java',
|
||||
);
|
||||
const names = matches.flatMap((m) => m['@declaration.name']?.text ?? []);
|
||||
|
||||
expect(names).toContain('R$1Local');
|
||||
expect(names).toContain('R$1');
|
||||
});
|
||||
|
||||
it('uses javac-compatible independent sequences for anonymous and named local types', () => {
|
||||
const matches = emitJavaScopeCaptures(
|
||||
`class Outer {
|
||||
void first() {
|
||||
new Runnable() {};
|
||||
class Local {}
|
||||
class Other {}
|
||||
new Runnable() {};
|
||||
}
|
||||
void second() { class Local {} }
|
||||
}`,
|
||||
'Outer.java',
|
||||
);
|
||||
const names = matches.flatMap((m) => m['@declaration.name']?.text ?? []);
|
||||
|
||||
expect(names).toEqual(
|
||||
expect.arrayContaining([
|
||||
'Outer$1',
|
||||
'Outer$2',
|
||||
'Outer$1Local',
|
||||
'Outer$2Local',
|
||||
'Outer$1Other',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('synthesizes every legal local type kind with its lexical binding name', () => {
|
||||
const matches = emitJavaScopeCaptures(
|
||||
`class Outer {
|
||||
void types() {
|
||||
class C {}
|
||||
enum E { A }
|
||||
record R(int x) {}
|
||||
interface I { void run(); }
|
||||
}
|
||||
}`,
|
||||
'Outer.java',
|
||||
);
|
||||
|
||||
for (const [tag, identityName, bindingName] of [
|
||||
['@declaration.class', 'Outer$1C', 'C'],
|
||||
['@declaration.enum', 'Outer$1E', 'E'],
|
||||
['@declaration.record', 'Outer$1R', 'R'],
|
||||
['@declaration.interface', 'Outer$1I', 'I'],
|
||||
] as const) {
|
||||
const declaration = matches.find(
|
||||
(match) => match[tag] !== undefined && match['@declaration.name']?.text === identityName,
|
||||
);
|
||||
expect(declaration?.['@declaration.binding-name']?.text).toBe(bindingName);
|
||||
}
|
||||
});
|
||||
|
||||
it('detects local types from block position in initializers, lambdas, and anonymous bodies', () => {
|
||||
const matches = emitJavaScopeCaptures(
|
||||
`class Outer {
|
||||
static { class StaticLocal {} }
|
||||
{ record InstanceLocal(int x) {} }
|
||||
Runnable task = () -> { interface LambdaLocal {} };
|
||||
Runnable anon = new Runnable() {
|
||||
{ enum AnonymousLocal { A } }
|
||||
public void run() {}
|
||||
};
|
||||
}`,
|
||||
'Outer.java',
|
||||
);
|
||||
const names = matches.flatMap((match) => match['@declaration.name']?.text ?? []);
|
||||
|
||||
expect(names).toEqual(
|
||||
expect.arrayContaining([
|
||||
'Outer$1StaticLocal',
|
||||
'Outer$1InstanceLocal',
|
||||
'Outer$1LambdaLocal',
|
||||
'Outer$1$1AnonymousLocal',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('emits declaration-to-block visibility scopes for local types', () => {
|
||||
const matches = emitJavaScopeCaptures(
|
||||
`class Outer {
|
||||
void blocks() {
|
||||
new Local();
|
||||
class Local {}
|
||||
new Local();
|
||||
}
|
||||
}`,
|
||||
'Outer.java',
|
||||
);
|
||||
const local = matches.find((match) => match['@declaration.name']?.text === 'Outer$1Local');
|
||||
const visibility = matches.find(
|
||||
(match) =>
|
||||
match['@scope.block']?.range.startLine === local?.['@declaration.class']?.range.startLine,
|
||||
);
|
||||
|
||||
expect(visibility?.['@scope.block']?.range.endLine).toBe(6);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue