diff --git a/gitnexus-shared/src/scope-resolution/types.ts b/gitnexus-shared/src/scope-resolution/types.ts index 91bc1f22c..0956c3db4 100644 --- a/gitnexus-shared/src/scope-resolution/types.ts +++ b/gitnexus-shared/src/scope-resolution/types.ts @@ -33,14 +33,27 @@ export type ScopeId = string; /** Stable symbol-definition identifier (graph nodeId). */ export type DefId = string; -/** Kinds of lexical scope a `Scope` node can represent. */ +/** + * Kinds of lexical scope a `Scope` node can represent. + * + * `Object` is a hoist boundary ONLY: an object/record literal body + * (TS/JS `{...}`, Kotlin anonymous `object {...}`). Members are + * reachable via property access, never as bare identifiers, so + * scope-chain walkers (`scope/walkers.ts`) must skip an `Object` + * scope's own bindings while still traversing past it to the parent + * (#2545/#2551) -- unlike `Block`, where a nested closure legitimately + * DOES see a sibling `let`/`const` from an enclosing `if`/`for`/`while`, + * a nested closure inside an object literal must NOT see a sibling + * property's name as a free identifier. + */ export type ScopeKind = | 'Module' // file root | 'Namespace' // C++ namespace, C# namespace, Kotlin package-object, Rust mod | 'Class' // class/struct/trait/interface body | 'Function' // function/method/closure/lambda body | 'Block' // { ... }, if-body, for-body, with-body, match arms - | 'Expression'; // comprehensions, for-init, pattern bindings, lambda param lists + | 'Expression' // comprehensions, for-init, pattern bindings, lambda param lists + | 'Object'; // object/record literal body -- see doc comment above // ─── Range + Capture (parser-agnostic) ────────────────────────────────────── diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 15e8b2f88..d040361de 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -87,42 +87,46 @@ "_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": "f3b4f4b6610e07c3ac90deb1c53d3572b6ad55a36e5d7134984876d30031ff67", + "fingerprint": "d79c3b92acfc866094981499b977388ca14f90839bca0c040342ab1cec00aa90", "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.", "_rebaselined": "#2357 (supersedes #2353): + java-cast-receiver, java-this-field-chain, java-this-dispatch fixtures (cast-wrapped receivers, this.field chains incl. initializer contexts, bare-this dispatch pinning). Drift is purely fixture-additive: with the three new dirs parked, the fingerprint reproduces the prior baseline byte-identically \u2014 no emit/capture change. #1956 synth-widening: + java-iface-extends fixture; synthesizeJavaInheritanceReferences now ALSO walks interface_declaration extends_interfaces (interface IA extends IB, IC), matching the #1940 legacy leg. (Earlier U2+review: java-qualified-base fixture covers 2- AND 3-segment qualified bases guarding the legacy end-anchor; synth tail-resolves scoped bases.) Linear (~1.03). (Earliest: java added to bench, exposed+fixed the O(n^2) findNodeAtRange root-walk; 3.09 -> ~0.99.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.", "_note": "#1928 / #2045: F35 adds qualified + qualified-generic constructor query captures (`new pkg.Foo()`, `new a.b.Foo()`, `new pkg.Box()`); F38 synthesizes `@reference.call.constructor` on `super(...)`/`this(...)` explicit_constructor_invocation nodes; F41 generic-aware stripQualifier in interpret (type-binding normalization). + java-qualified-constructor and java-explicit-constructor fixtures. Pure capture-additive + fixture-corpus drift; scaling stays linear (~1.06).", - "_rebaselined_2522_review_fixes": "PR #2522 review fixes: get/test dropped from callableProtocolMethods. Prior 004a3592998dca1193bd1429a8284513725de7764f2a3eceedaaa984cfd763b4 -> f3b4f4b6610e07c3ac90deb1c53d3572b6ad55a36e5d7134984876d30031ff67; scaling ratio re-verified within budget." + "_rebaselined_2522_review_fixes": "PR #2522 review fixes: get/test dropped from callableProtocolMethods. Prior 004a3592998dca1193bd1429a8284513725de7764f2a3eceedaaa984cfd763b4 -> f3b4f4b6610e07c3ac90deb1c53d3572b6ad55a36e5d7134984876d30031ff67; scaling ratio re-verified within budget.", + "_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." }, "typescript": { - "fingerprint": "e05446620c5b80b7aae291cfdf32f693580fada2ae687124769b04a0c03bfe63", + "fingerprint": "3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4", "scaling_budget": 1.5, "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 27f937bfb47d4bded316ea3c785ff659c8cd88a5761d928f113477a08c802c78 -> e05446620c5b80b7aae291cfdf32f693580fada2ae687124769b04a0c03bfe63; scaling 0.983 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: lexical callable bindings, direct-callee argument metadata, and invocation-result suppression. Prior db5933cc6760234ed7d495123410feba6de243646d583f20d43032b9459f81fd -> 27f937bfb47d4bded316ea3c785ff659c8cd88a5761d928f113477a08c802c78; scaling 0.975 < 1.5.", "_rebaselined_callable_flow": "Callable assignment/copy/formal/argument/invoke facts (also consumed by Vue script blocks). Prior 25de86fd3377132c4e35d3d98f4f94a58e0cfeb7c22948a8ea3be4e793be74fd -> db5933cc6760234ed7d495123410feba6de243646d583f20d43032b9459f81fd; measured scaling ratio 0.951 < 1.5.", "_rebaselined": "#1962: F44 (class scope@), F85 (enum member declarations), F87 (optional_parameter type annotations) add new captures \u2014 fingerprint drift expected.", "_note": "#1968: F44, F85, F87 \u2014 fingerprint drift expected.", - "_rebaselined_2522": "#2522 intentional @reference.value-ref/property-key capture additions. GitHub Actions run 29553361660 job 87800394279: prior 3f44a4a6892698df2d145c8ff2812c3b318807648983c88aca28fbd694f172f9 -> 25de86fd3377132c4e35d3d98f4f94a58e0cfeb7c22948a8ea3be4e793be74fd; scaling ratio 0.987 < 1.5." + "_rebaselined_2522": "#2522 intentional @reference.value-ref/property-key capture additions. GitHub Actions run 29553361660 job 87800394279: prior 3f44a4a6892698df2d145c8ff2812c3b318807648983c88aca28fbd694f172f9 -> 25de86fd3377132c4e35d3d98f4f94a58e0cfeb7c22948a8ea3be4e793be74fd; scaling ratio 0.987 < 1.5.", + "_rebaselined_2550_instance_model": "PR #2549 (#2545/#2551): object literals emit @scope.object (was unscoped, then @scope.block during development). Prior e05446620c5b80b7aae291cfdf32f693580fada2ae687124769b04a0c03bfe63 -> 3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4; scaling 0.981 < 1.5." }, "javascript": { - "fingerprint": "479927409bbdd9852a36172c8260aa56df260e99129a7a9c20a0d1903dd5538b", + "fingerprint": "f1ccf42a36895c8e34dcb724286f247d469835f2dcbb23ad3347190adc7fde1c", "scaling_budget": 1.5, "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior b59fe8135b6a31a12bc3f872b224054b16592588153ae3661d03958d787c76f3 -> 479927409bbdd9852a36172c8260aa56df260e99129a7a9c20a0d1903dd5538b; scaling 1.050 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: lexical callable bindings, direct-callee argument metadata, and invocation-result suppression. Prior 917a9cd975ba035bdad71fdb70cd72eeddec58c25797e5a1addfa6172808a55c -> b59fe8135b6a31a12bc3f872b224054b16592588153ae3661d03958d787c76f3; scaling 1.093 < 1.5.", "_rebaselined_callable_flow": "Callable assignment/copy/formal/argument/invoke facts (also consumed by Vue script blocks). Prior 5567dd47e7ba29821a518c4a9852adc3b774e25ef3e7a6e2b3ecb7b59ddab73c -> 917a9cd975ba035bdad71fdb70cd72eeddec58c25797e5a1addfa6172808a55c; measured scaling ratio 1.126 < 1.5.", "_added": "#1951: bench coverage added (was ungated); scale source heritage-bearing (extends Base); js/kotlin O(n^2) findNodeAtRange-per-match fixed to threaded captured node, now linear.", "_rebaselined": "#1956 synth-widening: + javascript-qualified-base fixture; synthesizeJsInheritanceReferences now handles a member_expression base (class S extends ns.Base -> Base), matching the #1940 legacy leg + the TS terminalTsTypeNameNode property_identifier case, at parity. Linear (~1.05). | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.", - "_rebaselined_2522": "#2522 intentional @reference.value-ref/property-key capture additions. GitHub Actions run 29553361660 job 87800394279: prior d72f03c6c502235d2d4b74d66baa5c7d361f040d7a1b72e84acad61210d05ae8 -> 5567dd47e7ba29821a518c4a9852adc3b774e25ef3e7a6e2b3ecb7b59ddab73c; scaling ratio 1.031 < 1.5." + "_rebaselined_2522": "#2522 intentional @reference.value-ref/property-key capture additions. GitHub Actions run 29553361660 job 87800394279: prior d72f03c6c502235d2d4b74d66baa5c7d361f040d7a1b72e84acad61210d05ae8 -> 5567dd47e7ba29821a518c4a9852adc3b774e25ef3e7a6e2b3ecb7b59ddab73c; scaling ratio 1.031 < 1.5.", + "_rebaselined_2550_instance_model": "PR #2549 (#2545/#2551): object literals emit @scope.object. Prior 479927409bbdd9852a36172c8260aa56df260e99129a7a9c20a0d1903dd5538b -> f1ccf42a36895c8e34dcb724286f247d469835f2dcbb23ad3347190adc7fde1c; scaling 1.096 < 1.5." }, "kotlin": { - "fingerprint": "4b31f46cfb004ba769a96feeb06ae4ef109c77410f54e7aaab4a688df599b112", + "fingerprint": "a6fce0dff00e88d41d85023eaf3f35016b5217c7e5225f24a598e4c70bb63091", "scaling_budget": 1.5, "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior bddba25d5a88152bbbee8d70e82c944b5302accb4b625df782adb1d4f7a7ac12 -> e856951c2a779163d555dadc8e1bf59304a86caed78ac1f450d9caa2b50f63d1; scaling 1.090 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Kotlin callable-reference flow facts with invocation-result suppression. Prior 4900431791f2b9280009deb2b82659c26ead8aa6fb8731190a7c505dec5a9041 -> bddba25d5a88152bbbee8d70e82c944b5302accb4b625df782adb1d4f7a7ac12; scaling 0.880 < 1.5.", "_added": "#1951: bench coverage added (was ungated); scale source heritage-bearing (: Base()); js/kotlin O(n^2) findNodeAtRange-per-match fixed to threaded captured node, now linear.", "_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.", "_rebaselined_2271": "PR #2271: re-vendored tree-sitter-kotlin 0.3.8 -> unreleased fwcd main c8ac3d26 for `fun interface` support + new kotlin-fun-interface fixture in the corpus. Drift is both corpus-additive (the fixture) and grammar-driven (the new grammar parses `fun interface` as a class_declaration, not an ERROR node). Baselined to the NEW grammar's fingerprint, so this --check passes only once the regenerated prebuilds land \u2014 until then CI loads the committed 0.3.8 binary and the bench is red, same as the kotlin fun-interface integration tests. scaling ~0.83 (linear).", - "_rebaselined_2522_review_fixes": "PR #2522 review fixes: fieldless assignment nodes decomposed positionally. Prior e856951c2a779163d555dadc8e1bf59304a86caed78ac1f450d9caa2b50f63d1 -> 4b31f46cfb004ba769a96feeb06ae4ef109c77410f54e7aaab4a688df599b112; scaling ratio re-verified within budget." + "_rebaselined_2522_review_fixes": "PR #2522 review fixes: fieldless assignment nodes decomposed positionally. Prior e856951c2a779163d555dadc8e1bf59304a86caed78ac1f450d9caa2b50f63d1 -> 4b31f46cfb004ba769a96feeb06ae4ef109c77410f54e7aaab4a688df599b112; scaling ratio re-verified within budget.", + "_rebaselined_2550_instance_model": "PR #2549 (#2545): anonymous object expressions (object_literal) emit @scope.class, and the kotlin-object-literal-scope fixture joined the corpus. Prior 4b31f46cfb004ba769a96feeb06ae4ef109c77410f54e7aaab4a688df599b112 -> a6fce0dff00e88d41d85023eaf3f35016b5217c7e5225f24a598e4c70bb63091; scaling 0.951 < 1.5." } } diff --git a/gitnexus/src/core/ingestion/class-extractors/configs/jvm.ts b/gitnexus/src/core/ingestion/class-extractors/configs/jvm.ts index fbd22f545..8a807dbfa 100644 --- a/gitnexus/src/core/ingestion/class-extractors/configs/jvm.ts +++ b/gitnexus/src/core/ingestion/class-extractors/configs/jvm.ts @@ -2,6 +2,7 @@ import { SupportedLanguages } from 'gitnexus-shared'; import type { ClassExtractionConfig } from '../../class-types.js'; +import { synthesizeJavaAnonymousClassName } from '../../utils/ast-helpers.js'; // --------------------------------------------------------------------------- // Java @@ -14,6 +15,12 @@ export const javaClassConfig: ClassExtractionConfig = { 'interface_declaration', 'enum_declaration', 'record_declaration', + // Anonymous class bodies (`new Runnable() { ... }`) — the matching + // JAVA_QUERIES pattern only captures `object_creation_expression` + // WITH a `class_body`, and `extractName` below returns undefined for + // any other shape, so plain `new Foo()` constructor calls never + // produce a Class node (#2550). + 'object_creation_expression', ], fileScopeNodeTypes: ['package_declaration'], ancestorScopeNodeTypes: [ @@ -22,6 +29,25 @@ export const javaClassConfig: ClassExtractionConfig = { 'enum_declaration', 'record_declaration', ], + extractName(node) { + if (node.type === 'object_creation_expression') { + return synthesizeJavaAnonymousClassName(node); + } + return undefined; + }, + // An anonymous body whose name CANNOT be synthesized (no supported host + // type declaration) must not become a Class node at all. Without this + // skip, `extract()`'s `extractTypeNameFromNode` fallback names the node + // after the CONSTRUCTED type — emitting a phantom `Class:...:Runnable` + // for `new Runnable() { ... }` (empirically caught in review). + shouldSkipClassCapture({ definitionNode }) { + return ( + definitionNode !== null && + definitionNode !== undefined && + definitionNode.type === 'object_creation_expression' && + synthesizeJavaAnonymousClassName(definitionNode) === undefined + ); + }, }; // --------------------------------------------------------------------------- diff --git a/gitnexus/src/core/ingestion/languages/java/captures.ts b/gitnexus/src/core/ingestion/languages/java/captures.ts index 30b6f90cc..70be64217 100644 --- a/gitnexus/src/core/ingestion/languages/java/captures.ts +++ b/gitnexus/src/core/ingestion/languages/java/captures.ts @@ -15,7 +15,12 @@ */ import type { Capture, CaptureMatch } from 'gitnexus-shared'; -import { nodeIfType, nodeToCapture, syntheticCapture } from '../../utils/ast-helpers.js'; +import { + nodeIfType, + nodeToCapture, + synthesizeJavaAnonymousClassName, + syntheticCapture, +} from '../../utils/ast-helpers.js'; import { splitImportDeclaration } from './import-decomposer.js'; import { computeJavaArityMetadata } from './arity-metadata.js'; import { synthesizeJavaReceiverBinding } from './receiver-binding.js'; @@ -240,10 +245,82 @@ export function emitJavaScopeCaptures( ...resolveVarTypeBindings(out), ...synthesizeJavaInheritanceReferences(tree.rootNode), ...synthesizeJavaExplicitConstructorReferences(tree.rootNode), + ...synthesizeJavaAnonymousClassDeclarations(tree.rootNode), ...synthesizeCallableFlowCaptures(tree.rootNode, JAVA_CALLABLE_CAPTURE_OPTIONS), ]; } +/** + * Synthesize `@declaration.class` matches for anonymous class bodies + * (`new Runnable() { ... }`), named by the same javac-style authority + * (`synthesizeJavaAnonymousClassName` → `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 + * `(object_creation_expression (class_body) @scope.class)` scope rule in + * query.ts, so the def is owned by that Class scope's `ownedDefs` + * (making `populateClassOwnedMembers` stamp `ownerId` on the anonymous + * class's methods) and the name auto-hoists to the enclosing scope — + * exactly the binding shape a named class declaration produces. + */ +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 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), + }); + + // Inheritance: the anonymous class extends/implements its constructed + // type. Anchor the `@reference.inherits` on the `class_body` — its + // range equals the anonymous Class scope, so the reference site's + // enclosing class resolves to the SYNTHESIZED `Worker$N` def (anchoring + // on the constructed-type node would sit OUTSIDE the anonymous scope + // and mis-attribute the edge to the lexically enclosing class). The + // synthetic `@reference.name` carries the base's simple name; a JDK + // type with no repo def simply resolves to nothing (no edge). Without + // this edge `mroFor(Worker$N)` is empty and the #2550 instance- + // ownership gate suppressed TRUE bare calls to inherited methods + // inside the anonymous body (empirically caught in review). + const constructedType = oce.childForFieldName?.('type'); + const baseSimpleName = + constructedType !== null && constructedType !== undefined + ? javaBaseSimpleNameOf(constructedType) + : undefined; + if (baseSimpleName !== undefined) { + out.push({ + '@reference.inherits': nodeToCapture('@reference.inherits', body), + '@reference.name': syntheticCapture('@reference.name', body, baseSimpleName), + }); + } + + // Receiver typeBinding: `Runnable handler = new Runnable() { ... }` + // binds `handler` to the ANONYMOUS class (`Worker$1`), not the declared + // interface — the instance is what `handler.run()` dispatches into, and + // the declared type is frequently a JDK interface with no repo def. + // Appended after the raw matches, so it overwrites the declared-type + // binding the `@type-binding.annotation` query rule produced for the + // same variable (pass-4 applies bindings in match order; last wins). + const declarator = oce.parent; + if (declarator !== null && declarator.type === 'variable_declarator') { + const varName = declarator.childForFieldName?.('name'); + const declNode = declarator.parent ?? declarator; + if (varName !== null && varName !== undefined) { + 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), + }); + } + } + } + return out; +} + /** * Synthesize `@reference.call.constructor` captures for explicit constructor * invocations — `super(...)` and `this(...)` (F38 #1928). tree-sitter-java @@ -429,6 +506,14 @@ function emitJavaInheritanceBase(out: CaptureMatch[], base: SyntaxNode | null): } /** Resolve a Java base-type node to its bare simple-name identifier node. */ +/** Simple name of a constructed/base type node, reusing the same node + * shapes `javaBaseLookupNameNode` handles (`Runnable`, `a.b.Base`, + * `Box`). Returns undefined when the node is none of those. */ +function javaBaseSimpleNameOf(typeNode: SyntaxNode): string | undefined { + const nameNode = javaBaseLookupNameNode(typeNode); + return nameNode === null ? undefined : nameNode.text; +} + function javaBaseLookupNameNode(node: SyntaxNode): SyntaxNode | null { switch (node.type) { case 'type_identifier': diff --git a/gitnexus/src/core/ingestion/languages/java/query.ts b/gitnexus/src/core/ingestion/languages/java/query.ts index 4821e9703..2975590c6 100644 --- a/gitnexus/src/core/ingestion/languages/java/query.ts +++ b/gitnexus/src/core/ingestion/languages/java/query.ts @@ -39,6 +39,14 @@ const JAVA_SCOPE_QUERY = ` (record_declaration) @scope.class (annotation_type_declaration) @scope.class +;; Anonymous class body: \`new Runnable() { public void run() {} }\`. +;; Without its own scope, a method's auto-hoist (scope-extractor.ts) has +;; nowhere to stop and leaks the name past the anonymous class into the +;; enclosing scope -- the same failure mode fixed for TS/JS object +;; literals (#2545). +(object_creation_expression + (class_body) @scope.class) + (method_declaration) @scope.function (constructor_declaration) @scope.function diff --git a/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts index 9eae59998..ee6b8e0a8 100644 --- a/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts @@ -58,6 +58,12 @@ const javaScopeResolver: ScopeResolver = { collapseMemberCallsByCallerTarget: true, hoistTypeBindingsToModule: true, stripReceiverCastExpressions: true, + // #2550: every Java method belongs to a class instance — a free call may + // resolve to a Method only when the caller's enclosing class chain + // (self + MRO) contains the method's owner. Closes the finalize-bucket + // leak (unqualified `run()` matching an unrelated same-file anonymous + // class's method). C# is the intended next adopter. + freeCallsRequireInstanceOwnership: true, populateNamespaceSiblings: populateJavaPackageSiblings, populateRangeBindings: populateJavaCrossFileReturnTypes, diff --git a/gitnexus/src/core/ingestion/languages/javascript/query.ts b/gitnexus/src/core/ingestion/languages/javascript/query.ts index 61f691ee4..8bb4143b8 100644 --- a/gitnexus/src/core/ingestion/languages/javascript/query.ts +++ b/gitnexus/src/core/ingestion/languages/javascript/query.ts @@ -73,6 +73,13 @@ const JAVASCRIPT_SCOPE_QUERY = ` (arrow_function) @scope.function (method_definition) @scope.function +;; Object literals get their own scope boundary -- see the matching +;; comment in typescript/query.ts (#2545/#2551). Prevents a +;; method_definition/property-arrow's auto-hoist from leaking its name +;; past the literal into the enclosing scope, and (unlike Block) keeps +;; sibling properties from seeing each other as bare identifiers. +(object) @scope.object + ;; Declarations — classes (class_declaration name: (identifier) @declaration.name) @declaration.class diff --git a/gitnexus/src/core/ingestion/languages/kotlin/query.ts b/gitnexus/src/core/ingestion/languages/kotlin/query.ts index 7015e6e28..64f3bba50 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/query.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/query.ts @@ -16,6 +16,12 @@ const KOTLIN_SCOPE_QUERY = ` (class_declaration) @scope.class (object_declaration) @scope.class (companion_object) @scope.class +;; Anonymous object expression: \`val h = object { fun fetch() {} }\` +;; (distinct from the named \`object_declaration\`/\`companion_object\` above). +;; Without its own scope, a method's auto-hoist (scope-extractor.ts) has +;; nowhere to stop and leaks the name past the literal into the enclosing +;; scope -- the same failure mode fixed for TS/JS object literals (#2545). +(object_literal) @scope.class (function_declaration) @scope.function ;; Secondary-constructor body scope (issue #1919 review CF1). A diff --git a/gitnexus/src/core/ingestion/languages/typescript/query.ts b/gitnexus/src/core/ingestion/languages/typescript/query.ts index d048a188b..bd0b98955 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/query.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/query.ts @@ -103,6 +103,23 @@ const TYPESCRIPT_SCOPE_QUERY = ` (arrow_function) @scope.function (function_expression) @scope.function +;; Object literals (the { ... } value expression, NOT object_type or +;; object_pattern) get their own scope boundary. Without it, a +;; method_definition/property-arrow's auto-hoist (scope-extractor.ts) +;; has nowhere to stop and leaks the name past the literal into whatever +;; lexically encloses it -- e.g. 'export default { async fetch(req) {} }' +;; would bind fetch at Module scope, letting an unrelated same-file +;; fetch(...) call (the platform global) incorrectly resolve to it +;; (#2545). Object (not Block or Class): object-literal members are +;; reachable only via property access, never as bare identifiers -- not +;; even by a SIBLING property's function body, unlike a real Block +;; (if/for/while, where a nested closure legitimately sees a sibling +;; let/const) or a Class (implicit-this sibling dispatch). Scope-chain +;; walkers (scope-resolution/scope/walkers.ts) skip an Object scope's +;; own bindings entirely while still treating it as a hoist boundary +;; (#2551). +(object) @scope.object + ;; Type aliases that contain an object_type are structurally class-like — ;; they define a shape with named members. Emit @scope.class so the ;; field-extractor's type-alias-with-object-type handling (in diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index 87439cab3..08d2a689d 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -455,6 +455,8 @@ function resolveKindForScopeMatch( return 'Block'; case 'expression': return 'Expression'; + case 'object': + return 'Object'; default: return null; } diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index fd12a222c..63f26b027 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -717,6 +717,24 @@ export interface ScopeResolver { */ readonly allowGlobalFreeCallFallback?: boolean; + /** + * In this language every `Method` belongs to a class instance, so a + * FREE (receiver-less) call may resolve to a `Method` only when the + * caller's enclosing class chain — the class itself plus its MRO — + * contains the method's owner (#2550). Suppresses the finalize-bucket + * leak where an unqualified call matched any same-file method by bare + * name (`materializeBindings` flattens every declaration onto module + * scope). Java opts in; C# is the intended next adopter. + * + * NOT implemented via `LanguageProvider.builtInNames`: that mechanism + * has unrelated consumers (`parse-worker`'s call-site extraction gate + * suppresses member calls too; `type-env`'s return-type lookup) which + * assume a flagged name is never a real repository declaration — + * false for common method names like `run`/`get`/`compare` (verified + * regression). + */ + readonly freeCallsRequireInstanceOwnership?: boolean; + /** * When true, a constructor-form call `Type(...)` links to the Class def * itself rather than its explicit Constructor def. Default diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts index ccb36230e..2f0262410 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts @@ -40,6 +40,7 @@ import { findAllCallableBindingsInScope, findCallableBindingInScope, findCallableBindingsAndAdlBlocker, + findEnclosingClassDef, resolveInheritanceBaseInScope, } from '../scope/walkers.js'; import { @@ -88,6 +89,15 @@ export function emitFreeCallFallback( * fail at the call site. Three-valued; `'unknown'` keeps the * candidate (monotonicity). */ readonly constraintCompatibility?: ScopeResolver['constraintCompatibility']; + /** Platform/language built-in names (e.g. `fetch`, `setTimeout`) that + * are never real repository declarations. Gates the finalize-bucket + * guard below (#2545) -- see `hasGenuineLexicalBinding`. */ + readonly isBuiltInName?: (name: string) => boolean; + /** Instance-ownership gate (#2550): a free call may resolve to a + * `Method` only when the caller's enclosing class chain (self + MRO) + * contains the method's owner. See + * `ScopeResolver.freeCallsRequireInstanceOwnership`. */ + readonly freeCallsRequireInstanceOwnership?: boolean; readonly recordResolutionOutcome?: ResolutionOutcomeRecorder; /** Call sites owned by a later precise pass (for example callable-value-flow). */ readonly skipSites?: ReadonlySet; @@ -193,6 +203,77 @@ export function emitFreeCallFallback( // available AND the binding scope contains multiple overloads, // refine with narrowOverloadCandidates (#1578). fnDef = findCallableBindingInScope(site.inScope, site.name, scopes); + if ( + fnDef !== undefined && + options.isBuiltInName?.(site.name) === true && + fnDef.filePath === parsed.filePath && + !hasGenuineLexicalBinding(site.inScope, site.name, scopes) + ) { + // A platform/language built-in (e.g. `fetch`, `setTimeout`) + // with no binding reachable via the TRUE lexical scope chain + // (Scope.bindings only) -- the match came solely from + // finalize's per-file "local + imports + wildcards" bucket, + // which flattens every declaration in the file onto the + // module scope regardless of true nesting depth + // (gitnexus-shared's `materializeBindings`). That flattening + // is correct for its purpose (cross-file import targets) but + // over-matches same-file built-in-shadowing declarations that + // were never really module-scope-visible -- e.g. a Cloudflare + // Worker's `export default { fetch(req) {} }` handler (#2545). + // Leave the call unresolved rather than emit a false edge. + // + // `fnDef.filePath === parsed.filePath` is the load-bearing + // guard against a real regression: `materializeBindings`'s + // flat bucket is per-file, so the leak this guard targets is + // ALWAYS same-file. A cross-file match at this point can only + // come from a genuine import/namespace/workspace-FQN channel + // (the separate, gated `pickUniqueGlobalCallable` global + // fallback runs later and isn't what populated `fnDef` here) + // -- e.g. `import { fetch } from './fetch-polyfill'` must + // keep resolving. Without this check, that import silently + // stopped resolving (verified via a scratch probe fixture). + fnDef = undefined; + } + // Instance-ownership gate (#2550). Placement matters: after the + // scope-chain lookup, BEFORE overload narrowing -- a suppressed + // candidate must not participate in overload selection. The + // legitimate same-class bare call already resolved earlier via + // `pickImplicitThisOverload`; an inherited bare call passes the + // MRO arm here; what remains is the finalize-bucket leak (an + // unrelated same-file method matched by bare name). + // + // Same-file only (mirrors the #2545 guard's load-bearing + // condition): the `materializeBindings` bucket is per-file, so + // the leak is ALWAYS same-file. A cross-file Method match here + // came through a genuine import channel -- e.g. the arity- + // narrowing parity fixtures resolve a bare `writeAudit(u)` to + // an imported class's method, which must keep working + // (suppressing it broke `java.test.ts`'s arity-filtering suite, + // verified empirically). + if ( + fnDef !== undefined && + options.freeCallsRequireInstanceOwnership === true && + fnDef.type === 'Method' && + fnDef.ownerId !== undefined && + fnDef.filePath === parsed.filePath + ) { + const enclosing = findEnclosingClassDef(site.inScope, scopes); + const ownerReachable = + enclosing !== undefined && + (enclosing.nodeId === fnDef.ownerId || + scopes.methodDispatch.mroFor(enclosing.nodeId).includes(fnDef.ownerId)); + if (!ownerReachable) { + recordSuppressedOutcome(options.recordResolutionOutcome, { + phase: 'free-call-fallback', + filePath: parsed.filePath, + name: site.name, + range: site.atRange, + reason: 'free-call-instance-ownership', + candidates: [fnDef], + }); + fnDef = undefined; + } + } if (fnDef !== undefined && options.conversionRankFn !== undefined) { const allCallables = findAllCallableBindingsInScope(site.inScope, site.name, scopes); if (allCallables.length > 1) { @@ -878,3 +959,33 @@ export function pickImplicitThisOverload( if (candidates.length !== 1) return undefined; return candidates[0]; } + +/** + * True when `name` is bound somewhere along the TRUE lexical scope + * chain from `startScope` -- i.e. via `Scope.bindings` (the raw, + * nesting-aware per-scope map built during extraction), NOT via + * finalize's `indexes.bindings` module-scope bucket (which flattens + * every declaration in the file onto the module scope regardless of + * true nesting -- see `hasGenuineLexicalBinding`'s caller for why that + * distinction matters, #2545). + */ +function hasGenuineLexicalBinding( + startScope: ScopeId, + name: string, + scopes: ScopeResolutionIndexes, +): boolean { + let currentId: ScopeId | null = startScope; + const visited = new Set(); + while (currentId !== null) { + if (visited.has(currentId)) return false; + visited.add(currentId); + const scope = scopes.scopeTree.getScope(currentId); + if (scope === undefined) return false; + // `Object` scopes (object/record literal bodies) are a hoist + // boundary only -- never a genuine lexical binding, not even to + // their own nested children (#2551, mirrors scope/walkers.ts). + if (scope.kind !== 'Object' && scope.bindings.get(name) !== undefined) return true; + currentId = scope.parent; + } + return false; +} diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 542c377a6..e47498ebb 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -835,6 +835,8 @@ export function runScopeResolution( allowGlobalFallback: provider.allowGlobalFreeCallFallback === true, constructorCallTargetsClass: provider.constructorCallTargetsClass === true, isFileLocalDef: provider.isFileLocalDef, + isBuiltInName: provider.languageProvider.isBuiltInName, + freeCallsRequireInstanceOwnership: provider.freeCallsRequireInstanceOwnership === true, isCallableVisibleFromCaller: provider.isCallableVisibleFromCaller, resolveAdlCandidates: provider.resolveAdlCandidates, conversionRankFn: provider.conversionRankFn, diff --git a/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts b/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts index ad6a19370..da5371918 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts @@ -7,7 +7,8 @@ export type ResolutionSuppressionReason = | 'member-lookup-ambiguous' | 'selected-callable-deleted' | 'overload-ambiguous' - | 'overload-ambiguous-normalization'; + | 'overload-ambiguous-normalization' + | 'free-call-instance-ownership'; export type ResolutionOutcome = | { diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index 264b09765..16d9d46b3 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -625,20 +625,26 @@ function walkScopeChain( const scope = scopes.scopeTree.getScope(currentId); if (scope === undefined) return undefined; - // Local first: a `const x` in this scope shadows any imported `x`. - const localBindings = scope.bindings.get(name); - if (localBindings !== undefined) { - for (const b of localBindings) { + // `Object` scopes (object/record literal bodies) are a hoist + // boundary only -- their members are reachable via property access, + // never bare identifiers, so they contribute nothing to lookup + // (#2545/#2551). Still traverse past to the parent. + if (scope.kind !== 'Object') { + // Local first: a `const x` in this scope shadows any imported `x`. + const localBindings = scope.bindings.get(name); + if (localBindings !== undefined) { + for (const b of localBindings) { + if (predicate(b.def)) return b.def; + } + } + + // Then imported/augmented bindings — only consulted when no local match. + const importedBindings = lookupBindingsAt(currentId, name, scopes); + for (const b of importedBindings) { if (predicate(b.def)) return b.def; } } - // Then imported/augmented bindings — only consulted when no local match. - const importedBindings = lookupBindingsAt(currentId, name, scopes); - for (const b of importedBindings) { - if (predicate(b.def)) return b.def; - } - currentId = scope.parent; } return undefined; @@ -683,28 +689,32 @@ export function findAllCallableBindingsInScope( const scope = scopes.scopeTree.getScope(currentId); if (scope === undefined) return []; - const out: SymbolDefinition[] = []; - const seen = new Set(); - const pushCallable = (def: SymbolDefinition): void => { - if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') return; - if (seen.has(def.nodeId)) return; - seen.add(def.nodeId); - out.push(def); - }; + // `Object` scopes are a hoist boundary only -- see walkScopeChain's + // comment (#2545/#2551). Skip lookup here, still traverse to parent. + if (scope.kind !== 'Object') { + const out: SymbolDefinition[] = []; + const seen = new Set(); + const pushCallable = (def: SymbolDefinition): void => { + if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') return; + if (seen.has(def.nodeId)) return; + seen.add(def.nodeId); + out.push(def); + }; - const localBindings = scope.bindings.get(callableName); - if (localBindings !== undefined) { - for (const b of localBindings) { + const localBindings = scope.bindings.get(callableName); + if (localBindings !== undefined) { + for (const b of localBindings) { + pushCallable(b.def); + } + } + + const importedBindings = lookupBindingsAt(currentId, callableName, scopes); + for (const b of importedBindings) { pushCallable(b.def); } - } - const importedBindings = lookupBindingsAt(currentId, callableName, scopes); - for (const b of importedBindings) { - pushCallable(b.def); + if (out.length > 0) return out; } - - if (out.length > 0) return out; currentId = scope.parent; } return []; @@ -762,18 +772,24 @@ export function findCallableBindingsAndAdlBlocker( } }; - const localBindings = scope.bindings.get(name); - if (localBindings !== undefined) { - for (const b of localBindings) { + // `Object` scopes are a hoist boundary only (#2545/#2551) -- never + // reached by C++'s ADL path in practice (no language reusing this + // function emits `@scope.object`), guarded for consistency with the + // other scope-chain walkers in this file. + if (scope.kind !== 'Object') { + const localBindings = scope.bindings.get(name); + if (localBindings !== undefined) { + for (const b of localBindings) { + process(b.def); + } + } + + const importedBindings = lookupBindingsAt(currentId, name, scopes); + for (const b of importedBindings) { process(b.def); } } - const importedBindings = lookupBindingsAt(currentId, name, scopes); - for (const b of importedBindings) { - process(b.def); - } - if (anyBinding) { // ISO C++: a block-scope function declaration (Function or Block scope) // that is NOT a using-declaration blocks ADL. If we found callables at @@ -996,16 +1012,19 @@ export function findExportedDefByName( visited.add(currentId); const scope = scopes.scopeTree.getScope(currentId); if (scope === undefined) break; - const local = scope.bindings.get(name); - if (local !== undefined) { - for (const b of local) { + // `Object` scopes are a hoist boundary only (#2545/#2551). + if (scope.kind !== 'Object') { + const local = scope.bindings.get(name); + if (local !== undefined) { + for (const b of local) { + if (b.def.type === 'Function' || b.def.type === 'Method') return b.def; + } + } + const finalized = lookupBindingsAt(currentId, name, scopes); + for (const b of finalized) { if (b.def.type === 'Function' || b.def.type === 'Method') return b.def; } } - const finalized = lookupBindingsAt(currentId, name, scopes); - for (const b of finalized) { - if (b.def.type === 'Function' || b.def.type === 'Method') return b.def; - } currentId = scope.parent; } // Workspace-wide fallback: the first locally-declared callable binding diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index e412cab67..a06565005 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -749,6 +749,10 @@ export const JAVA_QUERIES = ` (enum_declaration name: (identifier) @name) @definition.enum (annotation_type_declaration name: (identifier) @name) @definition.annotation +; Anonymous class bodies: new Runnable() { ... } — no @name capture; the +; class extractor synthesizes the javac-style Worker$N name (#2550) +(object_creation_expression (class_body)) @definition.class + ; Methods & Constructors (method_declaration name: (identifier) @name) @definition.method (constructor_declaration name: (identifier) @name) @definition.constructor diff --git a/gitnexus/src/core/ingestion/type-extractors/jvm.ts b/gitnexus/src/core/ingestion/type-extractors/jvm.ts index c8538914b..28f1e5797 100644 --- a/gitnexus/src/core/ingestion/type-extractors/jvm.ts +++ b/gitnexus/src/core/ingestion/type-extractors/jvm.ts @@ -1,4 +1,8 @@ -import { findChild, type SyntaxNode } from '../utils/ast-helpers.js'; +import { + findChild, + synthesizeJavaAnonymousClassName, + type SyntaxNode, +} from '../utils/ast-helpers.js'; import type { LanguageTypeConfig, ParameterExtractor, @@ -29,6 +33,16 @@ const JAVA_DECLARATION_NODE_TYPES: ReadonlySet = new Set([ 'field_declaration', ]); +/** `Runnable handler = new Runnable() { ... }` — the variable's effective + * type is the ANONYMOUS class (`Worker$1`), not the declared interface; + * that is the instance `handler.run()` dispatches into (#2550). Returns + * undefined for declarators without an anonymous-body initializer. */ +const anonymousInitializerTypeName = (declarator: SyntaxNode): string | undefined => { + const valueNode = declarator.childForFieldName('value'); + if (!valueNode || valueNode.type !== 'object_creation_expression') return undefined; + return synthesizeJavaAnonymousClassName(valueNode); +}; + /** Java: Type x = ...; Type x; */ const extractJavaDeclaration: TypeBindingExtractor = ( node: SyntaxNode, @@ -46,7 +60,7 @@ const extractJavaDeclaration: TypeBindingExtractor = ( const nameNode = child.childForFieldName('name'); if (nameNode) { const varName = extractVarName(nameNode); - if (varName) env.set(varName, typeName); + if (varName) env.set(varName, anonymousInitializerTypeName(child) ?? typeName); } } }; @@ -67,6 +81,11 @@ const extractJavaInitializer: InitializerExtractor = ( const varName = extractVarName(nameNode); if (!varName || env.has(varName)) continue; if (valueNode.type !== 'object_creation_expression') continue; + const anonName = anonymousInitializerTypeName(child); + if (anonName) { + env.set(varName, anonName); + continue; + } const ctorType = valueNode.childForFieldName('type'); if (!ctorType) continue; const typeName = extractSimpleTypeName(ctorType); diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index 77260ffd6..4be34749a 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -317,7 +317,16 @@ export function getLabelFromCaptures( const hasDefaultExportHocNameSeed = captureMap['definition.function'] !== undefined && (captureMap['hoc'] !== undefined || captureMap['callee'] !== undefined); - if (!captureMap['name'] && !captureMap['definition.constructor'] && !hasDefaultExportHocNameSeed) + // Nameless `definition.class` passes through: a class extractor may + // synthesize the name (Java anonymous class bodies → `Worker$N`, #2550). + // Downstream stays safe — parse-worker skips any nameless definition the + // extractor could not name (its `!nameNode && !extractedClassSymbol` gate). + if ( + !captureMap['name'] && + !captureMap['definition.constructor'] && + !captureMap['definition.class'] && + !hasDefaultExportHocNameSeed + ) return null; if (captureMap['definition.function']) { @@ -395,6 +404,82 @@ export interface EnclosingClassInfo { * pathological hooks from creating an infinite loop. */ 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. + */ +/** Type-declaration node types that can host (and name) a Java anonymous + * class body — javac numbers `$N` per top-level type of any of these + * kinds. Enum constant bodies (`A { ... }`) are a different node shape + * and remain unmodeled. */ +const JAVA_ANON_HOST_TYPES = new Set([ + 'class_declaration', + 'enum_declaration', + 'interface_declaration', + 'record_declaration', +]); + +/** 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>(); + +export const synthesizeJavaAnonymousClassName = (node: SyntaxNode): string | undefined => { + if (node.type !== 'object_creation_expression') return undefined; + const hasClassBody = node.namedChildren?.some((c: SyntaxNode) => c.type === 'class_body'); + if (hasClassBody !== true) return undefined; + + const tree = (node as { tree?: object }).tree; + if (tree !== undefined) { + const cached = javaAnonNameMemo.get(tree)?.get(node.startIndex); + if (cached !== undefined) return cached; + } + + // Topmost enclosing host type declaration — javac numbers per top-level type. + let topHost: SyntaxNode | null = null; + let cursor: SyntaxNode | null = node.parent; + let iterations = 0; + while (cursor) { + if (++iterations > MAX_ENCLOSING_WALK_ITERATIONS) return undefined; + if (JAVA_ANON_HOST_TYPES.has(cursor.type)) topHost = cursor; + cursor = cursor.parent; + } + if (topHost === null) return undefined; + const topName = topHost.childForFieldName?.('name')?.text; + if (topName === undefined || topName.length === 0) return undefined; + + const anonBodies = (topHost.descendantsOfType?.('object_creation_expression') ?? []).filter( + (c: SyntaxNode) => c.namedChildren?.some((n: SyntaxNode) => n.type === 'class_body'), + ); + if (tree !== undefined) { + let byStart = javaAnonNameMemo.get(tree); + if (byStart === undefined) { + byStart = new Map(); + javaAnonNameMemo.set(tree, byStart); + } + for (let i = 0; i < anonBodies.length; i++) { + byStart.set(anonBodies[i]!.startIndex, `${topName}$${i + 1}`); + } + return byStart.get(node.startIndex); + } + const index = anonBodies.findIndex((c: SyntaxNode) => c.startIndex === node.startIndex); + if (index === -1) return undefined; + return `${topName}$${index + 1}`; +}; + export const findEnclosingClassInfo = ( node: SyntaxNode, filePath: string, @@ -459,6 +544,21 @@ export const findEnclosingClassInfo = ( } } } + // Java: an anonymous class body (`new Runnable() { ... }`) owns its + // members — attribute to the synthesized `Worker$N` class, not the + // lexically enclosing named class (#2550). `synthesizeJavaAnonymousClassName` + // returns undefined for `object_creation_expression` without a + // `class_body` (plain `new Foo()`, and every C# shape), so the walk + // continues unchanged for those. + if (current.type === 'object_creation_expression') { + const anonName = synthesizeJavaAnonymousClassName(current); + if (anonName !== undefined) { + return { + classId: generateId('Class', `${filePath}:${anonName}`), + className: anonName, + }; + } + } if (CLASS_CONTAINER_TYPES.has(current.type)) { // Delegate language-specific container remapping to the provider hook. if (resolveEnclosingOwner) { diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index e1eed3484..bc00400b4 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -55,7 +55,7 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j // the main thread (the #1983 OOM). Because the two stores share this version, // any future change to the `ParsedFile` serialization shape MUST bump // SCHEMA_BUMP so both invalidate in lockstep. -const SCHEMA_BUMP = 17; // Callable-value-flow operands now retain expression kind/qualified identity and formal signature metadata. (16 = direct callee identity; 15 = always-on callableFlowSites; 14 = #2437 value refs.) +const SCHEMA_BUMP = 18; // Java anonymous class bodies emit synthesized Worker$N Class nodes and re-keyed methods (#2550). (17 = callable-value-flow operand identity; 16 = direct callee identity; 15 = always-on callableFlowSites.) const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index f9c12f49b..931c66231 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -360,8 +360,14 @@ export interface RepoMeta { * only covers changed files (`computeEffectiveWriteSet`), so a top-up against a * pre-v7 index would silently omit the new edges for every unchanged file pair; * force a full re-analyze instead (same contract as v2–v6). + * v8: Java anonymous class bodies became first-class Class nodes (#2550): + * `new Runnable() { run(){} }` now emits `Class:...:Worker$1` and its methods + * re-keyed from `Worker.run` to `Worker$1.run`. Node identities move on + * unchanged files — a top-up against a pre-v8 index would strand the old + * `Worker.run`-keyed Method nodes alongside the new ones (the v5 Route + * precedent); force a full re-analyze instead. */ -export const INCREMENTAL_SCHEMA_VERSION = 7; +export const INCREMENTAL_SCHEMA_VERSION = 8; export interface IndexedRepo { repoPath: string; diff --git a/gitnexus/test/fixtures/lang-resolution/java-anon-enum-host/src/EnumHost.java b/gitnexus/test/fixtures/lang-resolution/java-anon-enum-host/src/EnumHost.java new file mode 100644 index 000000000..a7739448e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-anon-enum-host/src/EnumHost.java @@ -0,0 +1,20 @@ +public enum EnumHost { + A; + + public void run() { + System.out.println("enum run"); + } + + public void install() { + Runnable r = new Runnable() { + public void run() { + System.out.println("anon run"); + } + }; + r.run(); + } + + public void caller() { + run(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-anon-extends-base/src/App.java b/gitnexus/test/fixtures/lang-resolution/java-anon-extends-base/src/App.java new file mode 100644 index 000000000..46d1531fe --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-anon-extends-base/src/App.java @@ -0,0 +1,16 @@ +public class Base { + public void work() { + System.out.println("base work"); + } +} + +class AnonExtHost { + public void make() { + Base b = new Base() { + public void extra() { + work(); + } + }; + b.extra(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-anon-numbering/src/Multi.java b/gitnexus/test/fixtures/lang-resolution/java-anon-numbering/src/Multi.java new file mode 100644 index 000000000..c93cb1d72 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-anon-numbering/src/Multi.java @@ -0,0 +1,19 @@ +public class Multi { + public void first() { + Runnable a = new Runnable() { + public void run() { + System.out.println("first"); + } + }; + a.run(); + } + + public void second() { + Runnable b = new Runnable() { + public void run() { + System.out.println("second"); + } + }; + b.run(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-anonymous-class-scope/src/Worker.java b/gitnexus/test/fixtures/lang-resolution/java-anonymous-class-scope/src/Worker.java new file mode 100644 index 000000000..47d984b64 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-anonymous-class-scope/src/Worker.java @@ -0,0 +1,14 @@ +public class Worker { + public void process() { + run(); + } + + public void makeHandler() { + Runnable handler = new Runnable() { + public void run() { + System.out.println("handling"); + } + }; + handler.run(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-builtin-name-legit-dispatch/src/RealTask.java b/gitnexus/test/fixtures/lang-resolution/java-builtin-name-legit-dispatch/src/RealTask.java new file mode 100644 index 000000000..52efd26b2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-builtin-name-legit-dispatch/src/RealTask.java @@ -0,0 +1,9 @@ +public class RealTask implements Runnable { + public void run() { + System.out.println("real task"); + } + + public void trigger() { + run(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-inherited-bare-call/src/App.java b/gitnexus/test/fixtures/lang-resolution/java-inherited-bare-call/src/App.java new file mode 100644 index 000000000..427bd83cb --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-inherited-bare-call/src/App.java @@ -0,0 +1,11 @@ +public class Base { + public void log() { + System.out.println("base log"); + } +} + +class Sub extends Base { + public void go() { + log(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-unrelated-method-collision/src/App.java b/gitnexus/test/fixtures/lang-resolution/java-unrelated-method-collision/src/App.java new file mode 100644 index 000000000..66c12e507 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-unrelated-method-collision/src/App.java @@ -0,0 +1,11 @@ +public class A { + public void helper() { + System.out.println("A.helper"); + } +} + +class B { + public void work() { + helper(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-object-literal-scope/src/Worker.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-object-literal-scope/src/Worker.kt new file mode 100644 index 000000000..bcad4b3d7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-object-literal-scope/src/Worker.kt @@ -0,0 +1,9 @@ +fun callExternal() { + println("data") +} + +val handler = object { + fun println(msg: String) { + System.out.println("wrapped: $msg") + } +} diff --git a/gitnexus/test/integration/resolvers/java.test.ts b/gitnexus/test/integration/resolvers/java.test.ts index ce6176c47..07062fc59 100644 --- a/gitnexus/test/integration/resolvers/java.test.ts +++ b/gitnexus/test/integration/resolvers/java.test.ts @@ -2725,3 +2725,134 @@ describe('Java bare-this dispatch (Case 4 pinning)', () => { } }); }); + +// --------------------------------------------------------------------------- +// Issues #2545/#2550: an anonymous class body (`new Runnable() { public +// void run() {} }`) is a first-class instance. It gets a synthesized +// javac-style Class node (`Worker$1`), owns its methods (re-keyed +// `Worker$1.run`), and the enclosing-owner walk attributes to it instead +// of the lexically enclosing named class. `(object_creation_expression +// (class_body) @scope.class)` (from #2545) provides the scope boundary; +// the #2550 instance model provides identity + ownership. +// --------------------------------------------------------------------------- + +describe('Java anonymous-class instance identity (#2550)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'java-anonymous-class-scope'), () => {}); + }, 60000); + + it('emits a Class node Worker$1 for the anonymous Runnable body', () => { + expect(getNodesByLabel(result, 'Class')).toContain('Worker$1'); + }); + + it('re-keys the anonymous run method to Worker$1.run and owns it via HAS_METHOD', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const owned = hasMethod.find( + (e) => + e.rel.sourceId === 'Class:src/Worker.java:Worker$1' && + e.rel.targetId === 'Method:src/Worker.java:Worker$1.run#0', + ); + expect(owned).toBeDefined(); + }); + + it('still extracts the anonymous Runnable method as a Method', () => { + expect(getNodesByLabel(result, 'Method')).toContain('run'); + }); + + it('resolves handler.run() to Worker$1.run via the receiver path, not the free-call leak', () => { + const calls = getRelationships(result, 'CALLS'); + const explicit = calls.find((c) => c.source === 'makeHandler' && c.target === 'run'); + expect(explicit).toBeDefined(); + expect(explicit!.rel.targetId).toBe('Method:src/Worker.java:Worker$1.run#0'); + expect(explicit!.rel.reason).not.toBe('local-call'); + }); + + it("does not resolve process()'s bare run() to the anonymous class's method (any reason)", () => { + const calls = getRelationships(result, 'CALLS'); + const leaked = calls.find((c) => c.source === 'process' && c.target === 'run'); + expect(leaked).toBeUndefined(); + }); +}); + +describe('Java instance-ownership free-call gate (#2550)', () => { + it("does not resolve a bare call to an unrelated same-file class's method", async () => { + const result = await runPipelineFromRepo( + path.join(FIXTURES, 'java-unrelated-method-collision'), + () => {}, + ); + const calls = getRelationships(result, 'CALLS'); + const leaked = calls.find((c) => c.source === 'work' && c.target === 'helper'); + expect(leaked).toBeUndefined(); + }, 60000); + + it("still resolves a class's own bare call to its own method (implicit this)", async () => { + const result = await runPipelineFromRepo( + path.join(FIXTURES, 'java-builtin-name-legit-dispatch'), + () => {}, + ); + const calls = getRelationships(result, 'CALLS'); + const legitimate = calls.find((c) => c.source === 'trigger' && c.target === 'run'); + expect(legitimate).toBeDefined(); + expect(legitimate!.targetFilePath).toBe('src/RealTask.java'); + }, 60000); + + it('still resolves a bare inherited call through the MRO arm', async () => { + const result = await runPipelineFromRepo( + path.join(FIXTURES, 'java-inherited-bare-call'), + () => {}, + ); + const calls = getRelationships(result, 'CALLS'); + const inherited = calls.find((c) => c.source === 'go' && c.target === 'log'); + expect(inherited).toBeDefined(); + }, 60000); + + it('numbers multiple anonymous bodies in source order (Multi$1, Multi$2)', async () => { + const result = await runPipelineFromRepo(path.join(FIXTURES, 'java-anon-numbering'), () => {}); + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('Multi$1'); + expect(classes).toContain('Multi$2'); + }, 60000); +}); + +// --------------------------------------------------------------------------- +// #2550 review hardening: (a) an anonymous class inherits from its +// constructed type, so bare calls to inherited methods INSIDE the +// anonymous body pass the ownership gate's MRO arm (review caught the +// gate suppressing that true edge — the anon had no EXTENDS edge and an +// empty MRO); (b) enum/interface/record hosts synthesize names too, and +// a hostless anonymous body must NOT materialize a phantom Class node +// named after the constructed type. +// --------------------------------------------------------------------------- + +describe('Java anonymous-class inheritance and host coverage (#2550 review)', () => { + it('anon extending a same-file class keeps bare inherited calls and gains an EXTENDS edge', async () => { + const result = await runPipelineFromRepo( + path.join(FIXTURES, 'java-anon-extends-base'), + () => {}, + ); + const extends_ = getRelationships(result, 'EXTENDS'); + const anonExtends = extends_.find( + (e) => e.rel.sourceId === 'Class:src/App.java:AnonExtHost$1' && e.target === 'Base', + ); + expect(anonExtends).toBeDefined(); + + const calls = getRelationships(result, 'CALLS'); + const inherited = calls.find((c) => c.source === 'extra' && c.target === 'work'); + expect(inherited).toBeDefined(); + expect(inherited!.rel.targetId).toBe('Method:src/App.java:Base.work#0'); + }, 60000); + + it('enum-hosted anonymous body is modeled (EnumHost$1) with no phantom constructed-type Class', async () => { + const result = await runPipelineFromRepo(path.join(FIXTURES, 'java-anon-enum-host'), () => {}); + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('EnumHost$1'); + expect(classes).not.toContain('Runnable'); + + const calls = getRelationships(result, 'CALLS'); + const own = calls.find((c) => c.source === 'caller' && c.target === 'run'); + expect(own).toBeDefined(); + expect(own!.rel.targetId).toBe('Method:src/EnumHost.java:EnumHost.run#0'); + }, 60000); +}); diff --git a/gitnexus/test/integration/resolvers/kotlin.test.ts b/gitnexus/test/integration/resolvers/kotlin.test.ts index 4f5152f84..942bc374d 100644 --- a/gitnexus/test/integration/resolvers/kotlin.test.ts +++ b/gitnexus/test/integration/resolvers/kotlin.test.ts @@ -2930,3 +2930,31 @@ describe('Kotlin functional (fun) interfaces', () => { expect(edgeSet(implements_)).toContain('Button → Plain'); }); }); + +// --------------------------------------------------------------------------- +// Issue #2545: an anonymous `object { ... }` expression has no scope +// boundary of its own, so a method's name auto-hoists past it into +// whatever lexically encloses it -- letting an unrelated same-file call +// to a builtin like `println` incorrectly resolve to it. +// --------------------------------------------------------------------------- + +describe('Kotlin anonymous object-expression method scoping (#2545)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'kotlin-object-literal-scope'), + () => {}, + ); + }, 60000); + + it('does not resolve the builtin println() call to the anonymous object-expression method', () => { + const calls = getRelationships(result, 'CALLS'); + const printlnCall = calls.find((c) => c.source === 'callExternal' && c.target === 'println'); + expect(printlnCall).toBeUndefined(); + }); + + it('still extracts the anonymous object-expression method as a Method', () => { + expect(getNodesByLabel(result, 'Method')).toContain('println'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts index b667c2f31..c4d29e625 100644 --- a/gitnexus/test/integration/resolvers/typescript.test.ts +++ b/gitnexus/test/integration/resolvers/typescript.test.ts @@ -613,6 +613,121 @@ describe('TypeScript local definition shadows import', () => { }); }); +// --------------------------------------------------------------------------- +// Issue #2545 (+ #2551): an object literal has no scope boundary of its +// own, so a method's name auto-hoists past the literal into whatever +// lexically encloses it (e.g. Module scope for a top-level +// `export default { ... }`). A Cloudflare Worker's `fetch` handler +// shape is the reported case: an unrelated same-file call to the +// platform-global `fetch()` was matching that leaked binding instead +// of staying unresolved. +// +// #2551 caught a second manifestation of the same underlying bug during +// review: a SIBLING property within the same object literal (`handler` +// below) calling another sibling's name (`fetch`) as a bare identifier +// also incorrectly resolved to it. The first fix reused the `Block` +// scope kind, correct for a real lexical block (`if`/`for`/`while`, +// where a nested closure legitimately sees block-scoped bindings) but +// wrong for object literals, which have no such semantic -- sibling +// properties are never visible to each other as bare identifiers, only +// via property access. Fixed with a dedicated `Object` scope kind +// (`gitnexus-shared`'s `ScopeKind`): a hoist boundary only, whose own +// bindings scope-chain walkers (`scope-resolution/scope/walkers.ts`) +// never consult, while still traversing past it to the parent. +// --------------------------------------------------------------------------- + +describe('TypeScript object-literal method scoping (#2545)', () => { + let repoDir: string; + let result: PipelineResult; + + beforeAll(async () => { + repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-ts-object-literal-scope-')); + writeFixtureRepo(repoDir, { + 'src/worker.ts': `export async function callExternal(): Promise { + return fetch('https://example.com/api'); +} + +export default { + async fetch(_request: Request): Promise { + return new Response('ok'); + }, + handler: () => { + return fetch('https://example.com/other'); + }, +}; +`, + }); + result = await runPipelineFromRepo(repoDir, () => {}, {}); + }, 60000); + + afterAll(() => { + if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true }); + }); + + it('does not resolve the global fetch() call to the object-literal fetch method', () => { + const calls = getRelationships(result, 'CALLS'); + const fetchCall = calls.find((c) => c.source === 'callExternal' && c.target === 'fetch'); + expect(fetchCall).toBeUndefined(); + }); + + it("does not resolve a sibling arrow-property's fetch() call to its own sibling either (#2551)", () => { + const calls = getRelationships(result, 'CALLS'); + const fetchFromHandler = calls.find( + (c) => c.source === 'handler' && c.target === 'fetch' && c.rel.reason === 'local-call', + ); + expect(fetchFromHandler).toBeUndefined(); + }); + + it('still extracts the Worker fetch handler as a Method', () => { + expect(getNodesByLabel(result, 'Method')).toContain('fetch'); + }); +}); + +// --------------------------------------------------------------------------- +// Issue #2545 fix regression: the isBuiltInName guard must not suppress a +// genuine cross-file import whose name happens to match a builtin +// (e.g. a `fetch` polyfill). Caught during review: the guard originally +// suppressed ANY same-name match with no local scope-chain binding, +// including real imports -- `hasGenuineLexicalBinding` only walks +// `Scope.bindings` (local declarations), never the imports channel. +// Fixed by scoping the guard to same-file matches only (the leak it +// targets is inherently same-file -- finalize's flat bucket is per-file). +// --------------------------------------------------------------------------- + +describe('TypeScript builtin-name import still resolves (#2545 regression)', () => { + let repoDir: string; + let result: PipelineResult; + + beforeAll(async () => { + repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-ts-builtin-import-')); + writeFixtureRepo(repoDir, { + 'src/fetch-polyfill.ts': `export function fetch(url: string): Promise { + return globalThis.fetch(url); +} +`, + 'src/app.ts': `import { fetch } from './fetch-polyfill'; + +export async function loadData(): Promise { + return fetch('https://example.com/data'); +} +`, + }); + result = await runPipelineFromRepo(repoDir, () => {}, {}); + }, 60000); + + afterAll(() => { + if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true }); + }); + + it('resolves loadData() to the imported fetch polyfill, not left unresolved', () => { + const calls = getRelationships(result, 'CALLS'); + const fetchCall = calls.find((c) => c.source === 'loadData' && c.target === 'fetch'); + expect(fetchCall).toBeDefined(); + expect(fetchCall!.targetFilePath).toBe('src/fetch-polyfill.ts'); + expect(fetchCall!.rel.reason).toBe('import-resolved'); + }); +}); + // --------------------------------------------------------------------------- // Variadic resolution: rest params don't get filtered by arity // --------------------------------------------------------------------------- diff --git a/gitnexus/test/unit/call-summary-schema-version.test.ts b/gitnexus/test/unit/call-summary-schema-version.test.ts index 5d6c8674e..d4cbe298a 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 7 (callable-value-flow edges re-index window)', () => { - expect(INCREMENTAL_SCHEMA_VERSION).toBe(7); + it('INCREMENTAL_SCHEMA_VERSION is bumped to 8 (Java anonymous-class node-identity re-index window)', () => { + expect(INCREMENTAL_SCHEMA_VERSION).toBe(8); }); it('a pre-current stamp fails the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate → forces full re-analyze', () => { @@ -99,7 +99,11 @@ describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => { // — new edges between unchanged files would never enter the incremental // write set → must NOT reuse. expect(passesReuseGate(6)).toBe(false); + // A pre-v8 (v7) index predates the Java anonymous-class instance model + // (#2550) — `Worker.run`-keyed Method nodes would be stranded alongside + // the re-keyed `Worker$N.run` ones on unchanged files → must NOT reuse. + expect(passesReuseGate(7)).toBe(false); // A current-version stamp passes the gate (incremental top-up eligible). - expect(passesReuseGate(7)).toBe(true); + expect(passesReuseGate(8)).toBe(true); }); });