mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
fix(scope-resolution): stop platform builtins resolving to unrelated same-file symbols (#2549)
* fix(scope-resolution): stop platform builtins resolving to unrelated same-file symbols (#2545) An unqualified call to a platform/language builtin (e.g. TypeScript's global fetch()) could resolve to an unrelated same-file declaration sharing that name, most visibly a Cloudflare Worker's `export default { async fetch(req) {...} }` handler. Two contributing gaps, both fixed: - Object literals had no scope boundary in the TS/JS grammar queries, so a method's/property-arrow's name auto-hoisted past the literal into whatever lexically enclosed it (scope-extractor.ts's auto-hoist logic had nowhere to stop). Give object literals a Block scope, like 6 other languages already do for lexical blocks. - Independently, finalize's per-file bindings bucket (materializeBindings in gitnexus-shared) flattens every local declaration in a file onto its module scope for cross-file import resolution, regardless of true nesting -- so free-call-fallback's scope-chain walk could still hit the leaked binding at module scope. Guard free-call resolution: when a match for a known builtin name (LanguageProvider.isBuiltInName, already populated for TS/JS but never consulted by this pass) has no binding reachable via the true lexical scope chain, leave the call unresolved instead of emitting a false CALLS edge. Verified against the full TS/JS resolver suites plus every other language populating builtInNames (Python, Go, C/C++, C#, Dart, Kotlin, PHP, Ruby, Rust, Swift, Vue) -- 2333 tests, no regressions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(scope-resolution): extend the #2545 scope-leak fix to Kotlin and Java Anonymous object-expressions (Kotlin `object { ... }`) and anonymous class bodies (Java `new Runnable() { ... }`) have the same missing scope-boundary gap that caused #2545 in TypeScript/JavaScript: a method declared inside has no scope of its own to stop the auto-hoist at, so its name leaks past the container into the enclosing scope. - Kotlin: `(object_literal) @scope.class` (distinct from the already- scoped named `object_declaration`/`companion_object`). Kotlin already populates `builtInNames`, so free-call-fallback's isBuiltInName guard (added for #2545) fully closes the equivalent leak here too -- verified with a `println`-shadowing regression test. - Java: `(object_creation_expression (class_body) @scope.class)`, matching PHP's existing `anonymous_class` handling. Java has no `builtInNames` list, so the isBuiltInName guard doesn't engage -- the scope-tree fix is still correct and necessary (the anonymous class's own methods are now owned by the right scope), but an unqualified call to an unrelated same-file method sharing the anonymous class's method name can still resolve via finalize's per-file module-scope bucket (materializeBindings, shared/ language-agnostic, intentionally not touched by this PR). Documented in the test as a known residual gap, same as TS/JS/Kotlin's own non-builtin-name collisions. Audited every other language for the same shape (a value/container node with no @scope.* capture hosting a would-be-auto-hoisted named declaration): PHP and Vue already handle it correctly (PHP scopes anonymous_class; Vue's <script> delegates to the now-fixed TS/JS query). Ruby, Python, Dart, C#, Swift, Go, Rust, and C/C++ have no query pattern that treats a literal/container value position as a named declaration in the first place, so the bug shape can't occur there. Verified: full Kotlin + Java resolver suites, 468 tests, no regressions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(scope-resolution): dedicated Object scope kind for object literals (#2545, #2551) Review of the #2545 fix surfaced two defects, both fixed here: 1. The isBuiltInName guard suppressed genuine cross-file imports whose name matches a builtin (`import { fetch } from './fetch-polyfill'` silently stopped resolving -- verified regression vs. main). The leak the guard targets is inherently same-file (finalize's flat bucket is per-file), so the guard now also requires `fnDef.filePath === parsed.filePath`. New regression test covers the polyfill-import shape. 2. The sibling-property case of the reported bug was still broken and masked by a tautological assertion (`c.reason` -- a property that doesn't exist; the real path is `c.rel.reason` -- so the test passed regardless of behavior). In `export default { fetch() {...}, handler: () => fetch(...) }`, `handler`'s bare `fetch()` still resolved to its sibling. Reusing the `Block` scope kind was the root cause: correct for a real lexical block (a nested closure legitimately sees a sibling `let`/`const` from an enclosing `if`/`for`), wrong for object literals, whose members are reachable only via property access -- never as bare identifiers, not even by sibling property bodies. Fix: a dedicated `Object` ScopeKind (gitnexus-shared) -- a hoist boundary whose own bindings scope-chain walkers never consult while still traversing past it to the parent. TS/JS object literals now emit `@scope.object`; the four chain walkers in scope-resolution/scope/walkers.ts (walkScopeChain, findAllCallableBindingsInScope, findCallableBindingsAndAdlBlocker, findExportedDefByName) and free-call-fallback's hasGenuineLexicalBinding skip Object scopes' bindings. Kotlin's anonymous `object {}` keeps `@scope.class` -- unlike JS object literals it has real implicit-this sibling dispatch. Verified with the full resolver matrix run sequentially (TS 254, JS/ Kotlin/Java/Python/Go + TS variants 960, C/C++/C#/Dart/PHP/Ruby 1049, Rust/Swift/Vue/Cobol + route/flow/unit suites 828, scope-extractor/ scope-tree units 51). Worker-pool crashes under parallel suite load reproduced on unrelated files and pass in isolation (known flake, not caused by this change). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(autofix): apply prettier + eslint fixes via /autofix command * feat(java): model anonymous class bodies as first-class Class nodes (#2550, step 1) `new Runnable() { public void run() {} }` now emits a synthesized javac-style `Class` node (`Worker$1`, `$N` = source order within the top-level class) and owns its methods: the enclosing-owner walk attributes `run` to `Worker$1` (re-keyed `Method:...:Worker$1.run#0`, HAS_METHOD from the anonymous class) instead of the lexically enclosing named class. - `synthesizeJavaAnonymousClassName` (ast-helpers): single naming authority for every layer that keys the anonymous class; returns undefined for `object_creation_expression` without a `class_body` child, which also keeps it a no-op for C#'s same-named node type. - `findEnclosingClassInfo`: anonymous-body branch before the generic container walk. - JAVA_QUERIES: `(object_creation_expression (class_body)) @definition.class` (no @name); `getLabelFromCaptures` now lets a nameless `definition.class` through — the parse-worker's existing `!nameNode && !extractedClassSymbol` gate still drops any nameless class the extractor cannot name, so other languages are unaffected. - `javaClassConfig.extractName` synthesizes the name on the extractor path (worker node emission). - Node identities move on unchanged files: INCREMENTAL_SCHEMA_VERSION 7→8 and parse-cache SCHEMA_BUMP 17→18 (the v5 Route-identity precedent) force full re-analyze / cache invalidation. Verified: new #2550 identity tests + resolve-enclosing-owner and has-method suites (53 tests) green. Prep for step 2/3 (scope-side ownership + receiver typeBinding) and the free-call instance-ownership gate per docs/plans/2026-07-18-gitnexus-plan-java-instance-scoped-freecalls.md (plan file is local — docs/ is gitignored by repo policy). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(java): instance-scoped free-call resolution for anonymous-class methods (#2550, steps 2-4) Completes the #2550 instance model on top of the Worker$N identity commit: - Scope-side ownership (java/captures.ts): synthesize `@declaration.class` + `@declaration.name` (`Worker$N`) anchored on the anonymous `class_body` — same range as its `@scope.class`, so the def lands in that Class scope's ownedDefs, `populateClassOwnedMembers` stamps `ownerId` on the anonymous class's methods, and the name auto-hoists exactly like a named class declaration. - Receiver typeBinding (java/captures.ts + type-extractors/jvm.ts): `Runnable handler = new Runnable() { ... }` binds `handler` to the ANONYMOUS class (`Worker$1`), not the declared JDK interface — in both the scope-side TypeRef channel (receiver-bound Case 4) and the worker typeEnv. `handler.run()` now resolves through the receiver path (reason 'global', target `Worker$1.run#0`) instead of depending on the free-call finalize-bucket leak — which is why the prior gate attempt broke it (the #2550 landmine, now explained and structurally removed). - Instance-ownership gate (free-call-fallback.ts + contract + run.ts + java opt-in): with `ScopeResolver.freeCallsRequireInstanceOwnership`, a free call may resolve to a `Method` only when the caller's enclosing class chain (self + MRO via `scopes.methodDispatch.mroFor`) contains the method's owner. Same-file matches only — the `materializeBindings` leak is per-file; cross-file Method matches come through genuine import channels (suppressing them broke the arity-narrowing parity suite, verified). Suppressions recorded as `'free-call-instance-ownership'` outcomes. Java opts in; every other language is byte-identical (flag off). Result on the #2545 fixture: `process()`'s bare `run()` emits NO edge to the unrelated anonymous method (the #2550 bug, closed), while `handler.run()`, same-class implicit-this dispatch, and bare inherited calls (MRO arm) all keep resolving. Verified: full java.test.ts 223/223 twice sequentially (landmine gate); cross-language matrix (TS/JS/Kotlin/Python/Go/C/C++/C#/Dart/PHP/Ruby/ Rust/Swift/Vue/Cobol + callable-value-flow + java-class-impact + core units) — zero assertion failures; worker-crash flakes re-verified green in single-file isolation. Known deferral (documented): EXTENDS/IMPLEMENTS edges from the anonymous class to its constructed type are not yet emitted, so a same-file inherited-but-not-overridden member called ON the anonymous instance does not resolve through the anon MRO; tracked as the follow-up in #2550. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(autofix): apply prettier + eslint fixes via /autofix command * fix(java): anonymous-class inheritance, host coverage, and phantom-node guard (#2550 review) Self-review of the instance model (gitnexus-review with empirical lens probes) surfaced three defects, all fixed: 1. HIGH — the ownership gate suppressed TRUE bare calls to inherited methods inside an anonymous body extending a same-file class (`new Base() { void extra() { work(); } }` lost `extra -> work`): the anon class had no inheritance edge, so `mroFor(Worker$N)` was empty and the MRO arm could never pass. The synthesis now emits an `@reference.inherits` for the constructed type, anchored on the `class_body` so the reference's enclosing class resolves to the SYNTHESIZED def (anchoring on the type node would sit outside the anonymous scope and attribute the edge to the wrong class). Anon classes now get real EXTENDS/IMPLEMENTS edges and inherited bare calls pass the gate. 2. MEDIUM — hostless anonymous bodies materialized a phantom Class node named after the CONSTRUCTED type (`Class:...:Runnable`) via extract()'s extractTypeNameFromNode fallback. New `shouldSkipClassCapture` in javaClassConfig drops the capture when no name can be synthesized. 3. MEDIUM — enum/interface/record-hosted anonymous bodies silently fell back to the pre-#2550 model (mis-attribution + open leak). The topmost-host walk now accepts all four host type declarations (JAVA_ANON_HOST_TYPES), so `EnumHost$1` etc. are modeled; the phantom-node shape disappears for those hosts as a side effect. Also: per-parse-tree WeakMap memo for the `$N` numbering — the helper is called from four independent layers per anonymous body and each call re-scanned the host subtree (`descendantsOfType`), quadratic on anon-heavy files (old-style listener-per-widget Java); and the scope-capture bench fingerprints rebaselined for java/typescript/ javascript/kotlin (`measure.mjs --check` now passes all 14 languages — it failed for every scope query this PR touched; drift notes added per the file's convention). Verified: full java.test.ts 225/225; all 11 #2550 tests including the new anon-extends-base and enum-host scenarios; bench --check PASS. Known remaining (documented, unchanged-old behavior): enum CONSTANT bodies (`A { ... }`) stay unmodeled; nested-host naming is top-level- anchored (`EnumWrap$1`, not javac's `EnumWrap$Mode$1`). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(autofix): apply prettier + eslint fixes via /autofix command * test(storage): update the INCREMENTAL_SCHEMA_VERSION pin to v8 (#2550) The U-C5 reuse-gate test deliberately pins the exact schema version so a bump cannot land without consciously extending the gate expectations. Extend for v8 (Java anonymous-class node identities, #2550): a v7 stamp now fails the strict-equality reuse gate — a pre-v8 index would strand old `Worker.run`-keyed Method nodes alongside the re-keyed `Worker$N.run` ones on unchanged files — and v8 passes. Caught by CI (tests/ubuntu coverage shard 2/3 on PR #2549); the local matrix had not included this unit file. All 7 schema-referencing unit suites verified green (109 tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
parent
4d7a0a69ed
commit
1abcac9c16
32 changed files with 903 additions and 62 deletions
|
|
@ -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) ──────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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<T>), 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<T>()`); 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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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<T>`). 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':
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -455,6 +455,8 @@ function resolveKindForScopeMatch(
|
|||
return 'Block';
|
||||
case 'expression':
|
||||
return 'Expression';
|
||||
case 'object':
|
||||
return 'Object';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string>;
|
||||
|
|
@ -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<ScopeId>();
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
| {
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
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<string>();
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string> = 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);
|
||||
|
|
|
|||
|
|
@ -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<object, Map<number, string>>();
|
||||
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
20
gitnexus/test/fixtures/lang-resolution/java-anon-enum-host/src/EnumHost.java
vendored
Normal file
20
gitnexus/test/fixtures/lang-resolution/java-anon-enum-host/src/EnumHost.java
vendored
Normal file
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
16
gitnexus/test/fixtures/lang-resolution/java-anon-extends-base/src/App.java
vendored
Normal file
16
gitnexus/test/fixtures/lang-resolution/java-anon-extends-base/src/App.java
vendored
Normal file
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
19
gitnexus/test/fixtures/lang-resolution/java-anon-numbering/src/Multi.java
vendored
Normal file
19
gitnexus/test/fixtures/lang-resolution/java-anon-numbering/src/Multi.java
vendored
Normal file
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
14
gitnexus/test/fixtures/lang-resolution/java-anonymous-class-scope/src/Worker.java
vendored
Normal file
14
gitnexus/test/fixtures/lang-resolution/java-anonymous-class-scope/src/Worker.java
vendored
Normal file
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
9
gitnexus/test/fixtures/lang-resolution/java-builtin-name-legit-dispatch/src/RealTask.java
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/java-builtin-name-legit-dispatch/src/RealTask.java
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
public class RealTask implements Runnable {
|
||||
public void run() {
|
||||
System.out.println("real task");
|
||||
}
|
||||
|
||||
public void trigger() {
|
||||
run();
|
||||
}
|
||||
}
|
||||
11
gitnexus/test/fixtures/lang-resolution/java-inherited-bare-call/src/App.java
vendored
Normal file
11
gitnexus/test/fixtures/lang-resolution/java-inherited-bare-call/src/App.java
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
public class Base {
|
||||
public void log() {
|
||||
System.out.println("base log");
|
||||
}
|
||||
}
|
||||
|
||||
class Sub extends Base {
|
||||
public void go() {
|
||||
log();
|
||||
}
|
||||
}
|
||||
11
gitnexus/test/fixtures/lang-resolution/java-unrelated-method-collision/src/App.java
vendored
Normal file
11
gitnexus/test/fixtures/lang-resolution/java-unrelated-method-collision/src/App.java
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
public class A {
|
||||
public void helper() {
|
||||
System.out.println("A.helper");
|
||||
}
|
||||
}
|
||||
|
||||
class B {
|
||||
public void work() {
|
||||
helper();
|
||||
}
|
||||
}
|
||||
9
gitnexus/test/fixtures/lang-resolution/kotlin-object-literal-scope/src/Worker.kt
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/kotlin-object-literal-scope/src/Worker.kt
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
fun callExternal() {
|
||||
println("data")
|
||||
}
|
||||
|
||||
val handler = object {
|
||||
fun println(msg: String) {
|
||||
System.out.println("wrapped: $msg")
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<Response> {
|
||||
return fetch('https://example.com/api');
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(_request: Request): Promise<Response> {
|
||||
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<Response> {
|
||||
return globalThis.fetch(url);
|
||||
}
|
||||
`,
|
||||
'src/app.ts': `import { fetch } from './fetch-polyfill';
|
||||
|
||||
export async function loadData(): Promise<Response> {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue