From 450cebc268f7ec443b82b255fa34798285c06981 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:58:53 +0100 Subject: [PATCH] fix(java): JLS binary-name identities for local classes, enums, records & interfaces (#2562) (#2653) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- gitnexus/bench/scope-capture/baselines.json | 10 +- gitnexus/bench/scope-capture/measure.mjs | 19 +- .../ingestion/class-extractors/configs/jvm.ts | 12 +- .../core/ingestion/languages/java/captures.ts | 72 ++++- .../core/ingestion/languages/java/query.ts | 1 + .../src/core/ingestion/scope-extractor.ts | 1 + .../src/core/ingestion/type-extractors/jvm.ts | 8 +- .../src/core/ingestion/utils/ast-helpers.ts | 266 +++++++++++------- gitnexus/src/storage/parse-cache.ts | 3 + gitnexus/src/storage/repo-manager.ts | 8 +- .../java-local-class-naming/src/Compact.java | 12 + .../java-local-class-naming/src/Outer.java | 123 ++++++++ .../java-local-class-naming/src/Types.java | 24 ++ .../resolvers/java-javac-local-types.test.ts | 59 ++++ .../test/integration/resolvers/java.test.ts | 112 ++++++++ .../unit/call-summary-schema-version.test.ts | 10 +- .../java/java-captures.test.ts | 127 +++++++++ 17 files changed, 737 insertions(+), 130 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/java-local-class-naming/src/Compact.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-local-class-naming/src/Outer.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-local-class-naming/src/Types.java create mode 100644 gitnexus/test/integration/resolvers/java-javac-local-types.test.ts diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index f55fae7cd..be6cd6563 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -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", diff --git a/gitnexus/bench/scope-capture/measure.mjs b/gitnexus/bench/scope-capture/measure.mjs index 953a3d95f..56aa2592d 100644 --- a/gitnexus/bench/scope-capture/measure.mjs +++ b/gitnexus/bench/scope-capture/measure.mjs @@ -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 ---- diff --git a/gitnexus/src/core/ingestion/class-extractors/configs/jvm.ts b/gitnexus/src/core/ingestion/class-extractors/configs/jvm.ts index 8fa056a4b..cc6e41c0a 100644 --- a/gitnexus/src/core/ingestion/class-extractors/configs/jvm.ts +++ b/gitnexus/src/core/ingestion/class-extractors/configs/jvm.ts @@ -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 ); }, }; diff --git a/gitnexus/src/core/ingestion/languages/java/captures.ts b/gitnexus/src/core/ingestion/languages/java/captures.ts index c22108a54..dce0b1dca 100644 --- a/gitnexus/src/core/ingestion/languages/java/captures.ts +++ b/gitnexus/src/core/ingestion/languages/java/captures.ts @@ -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), diff --git a/gitnexus/src/core/ingestion/languages/java/query.ts b/gitnexus/src/core/ingestion/languages/java/query.ts index 31272c0b7..b51daa4e9 100644 --- a/gitnexus/src/core/ingestion/languages/java/query.ts +++ b/gitnexus/src/core/ingestion/languages/java/query.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index 9fe8ae8b9..2022ced64 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -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')) ?? '' diff --git a/gitnexus/src/core/ingestion/type-extractors/jvm.ts b/gitnexus/src/core/ingestion/type-extractors/jvm.ts index 28f1e5797..9788f3d8e 100644 --- a/gitnexus/src/core/ingestion/type-extractors/jvm.ts +++ b/gitnexus/src/core/ingestion/type-extractors/jvm.ts @@ -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 = 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; */ diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index edfe2dda8..508a0c466 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -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([ + ['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>(); +interface JavaTypeIdentityState { + readonly byStart: Map; + readonly ordinalByStart: Map; +} -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(); - 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(); + 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(); + const sequenceCounts = new Map(); + 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, }; } } diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 60828b709..9283060dd 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -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. diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index bef8a23ce..e875415eb 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -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; diff --git a/gitnexus/test/fixtures/lang-resolution/java-local-class-naming/src/Compact.java b/gitnexus/test/fixtures/lang-resolution/java-local-class-naming/src/Compact.java new file mode 100644 index 000000000..9a7702728 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-local-class-naming/src/Compact.java @@ -0,0 +1,12 @@ +record Compact(int value) { + Compact { + class Local { + void inner() {} + } + + new Local().inner(); + new Runnable() { + public void run() {} + }; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-local-class-naming/src/Outer.java b/gitnexus/test/fixtures/lang-resolution/java-local-class-naming/src/Outer.java new file mode 100644 index 000000000..4b152a7ed --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-local-class-naming/src/Outer.java @@ -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() {} + }; +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-local-class-naming/src/Types.java b/gitnexus/test/fixtures/lang-resolution/java-local-class-naming/src/Types.java new file mode 100644 index 000000000..0ff98c36e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-local-class-naming/src/Types.java @@ -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(); + } +} diff --git a/gitnexus/test/integration/resolvers/java-javac-local-types.test.ts b/gitnexus/test/integration/resolvers/java-javac-local-types.test.ts new file mode 100644 index 000000000..363fb49ce --- /dev/null +++ b/gitnexus/test/integration/resolvers/java-javac-local-types.test.ts @@ -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 }); + } + }); +}); diff --git a/gitnexus/test/integration/resolvers/java.test.ts b/gitnexus/test/integration/resolvers/java.test.ts index bfaa00196..2fc63a0ea 100644 --- a/gitnexus/test/integration/resolvers/java.test.ts +++ b/gitnexus/test/integration/resolvers/java.test.ts @@ -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 diff --git a/gitnexus/test/unit/call-summary-schema-version.test.ts b/gitnexus/test/unit/call-summary-schema-version.test.ts index 04a53f6e1..163cd5c75 100644 --- a/gitnexus/test/unit/call-summary-schema-version.test.ts +++ b/gitnexus/test/unit/call-summary-schema-version.test.ts @@ -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); }); }); diff --git a/gitnexus/test/unit/scope-resolution/java/java-captures.test.ts b/gitnexus/test/unit/scope-resolution/java/java-captures.test.ts index 36c88daca..4032b51c2 100644 --- a/gitnexus/test/unit/scope-resolution/java/java-captures.test.ts +++ b/gitnexus/test/unit/scope-resolution/java/java-captures.test.ts @@ -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); + }); +});