fix(resolution): a bare-recorded call on another expression's value is not the caller (#2223)

Generalizes the collapsed-chain self veto beyond TS/JS this./window. roots: a
call the extractor recorded by its bare name but the source writes as a member
call on anything other than this/self/super is never the calling method; and a
value's initializer never calls the value itself.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-01 00:27:15 +00:00
committed by GitHub
parent d6f68648cb
commit ca1eba98bd
3 changed files with 70 additions and 26 deletions
+1
View File
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
- A method that calls a same-named method on another expression's result is no longer linked to itself in any language. Examples are Scala's `requestToArmeria(request).execute()` inside `execute()`, Rust's `self.0.into_route(state)` inside `into_route()`, and Kotlin's `this@Buffer.write(…)` from an inner object. A value whose initializer chain calls a same-named method, like sttp's `val response = basicRequest.get(…).response(…)`, no longer links to itself either.
- In Spring projects, a class name now resolves to the one in the reference's own file or package before any guess by folder convention. A class nested inside another file's class is never reached by its bare name. Before, every MyBatis `Example` class's `new Criteria()` in mall linked to the first `Example`'s nested `Criteria`, and halo's Lombok `@Builder` linked to a nested `Builder` class elsewhere.
- A method that hands its call on to another object is no longer linked to itself. In TypeScript and JavaScript, a call through `this.<field>` or `window.<x>` counts as recursion only when the field is declared as the method's own class. A guess from a receiver's name alone never lands on the calling method. Before, BookStack's `toggle()` doing `this.container.classList.toggle('open')`, `listen()` doing `window.$events.listen(…)`, and `FileStorage::delete` doing `$storage->delete($path)` each pointed at themselves.
- C# names now follow the language's scopes. A block `namespace X { … }` qualifies only the types inside it, so a type declared after the block, or in a file's second namespace, is no longer filed under the first. A namespace written inside another is `Outer.Inner`. A `using` links to the project's namespace instead of another file's `using` of the same name. A `global using` applies only within its own project. A nested type is reachable by bare name only from inside its owner or a type deriving from it, including through another partial part. Before, serilog's `Guard` was filed under `JetBrains.Annotations`, its tests' `Some.InformationEvent()` went to the performance tests' `Some`, and AutoMapper's same-file `new Source()` could reach another test class's nested `Source`.
+37 -6
View File
@@ -1,11 +1,13 @@
/**
* A method that hands its call on to another object — a wrapper — is not
* calling itself. TS/JS calls through `this.<field>…` or `window.<x>…` reach
* the resolver by their bare name, so BookStack's `toggle()` doing
* `this.container.classList.toggle('open')` and `listen()` doing
* `window.$events.listen(…)` bound to themselves; a guess from a receiver's
* name alone (`FileStorage::delete` doing `$storage->delete($path)`) did too.
* A recursion through a field the class declares as its own type stays.
* calling itself. Calls through `this.<field>…` / `window.<x>…` (TS/JS) or
* through an expression's value (Scala `requestToArmeria(request).execute()`,
* Rust `self.0.into_route(state)`) reach the resolver by their bare name, so
* BookStack's `toggle()` doing `this.container.classList.toggle('open')`
* bound to itself; a guess from a receiver's name alone (`FileStorage::delete`
* doing `$storage->delete($path)`) did too, and a value's initializer chain
* (`val response = basicRequest.get(…).response(…)`) to the value. A recursion
* through a field the class declares as its own type stays.
*/
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import * as fs from 'fs';
@@ -64,6 +66,30 @@ class ImageStorage
{
}
}
`,
'core/src/main/scala/sttp/ArmeriaBackend.scala': `package sttp
class ArmeriaBackend {
def requestToArmeria(request: String): Client = new Client
def execute(request: String): Unit = {
val armeriaRes = requestToArmeria(request).execute()
}
}
class CurlTest {
val response = basicRequest
.get("http://example.com")
.response(asString)
}
`,
'src/routing/route.rs': `pub struct BoxedIntoRoute(Box<dyn ErasedIntoRoute>);
impl BoxedIntoRoute {
pub fn into_route(self, state: u32) -> u32 {
self.0.into_route(state)
}
}
`,
};
for (const [rel, content] of Object.entries(files)) {
@@ -93,6 +119,11 @@ describe('a call a method hands on', () => {
expect(selfCallLines('resources/js/tree.ts')).toEqual([5]);
});
it('through another expression’s value is not the method itself', () => {
expect(selfCallLines('core/src/main/scala/sttp/ArmeriaBackend.scala')).toEqual([]);
expect(selfCallLines('src/routing/route.rs')).toEqual([]);
});
it('through a receiver named like the caller’s class is not the method itself', () => {
expect(selfCallLines('app/Uploads/FileStorage.php')).toEqual([]);
});
+32 -20
View File
@@ -1403,31 +1403,35 @@ export const CASE_INSENSITIVE_LANGUAGES = new Set<string>(['php', 'pascal', 'cfm
* used to pick over the module-scope function the call actually means.
*/
/**
* The links between `this` / `super` / `window` and the method of a TS/JS
* call the extractor recorded by its bare name — `this.container.classList
* .toggle()` reaches the resolver as `toggle`, with links `container`,
* `classList`. Null for a call not written that way, `this.toggle()` included.
* The receiver a call the extractor recorded by its bare name is written on,
* read from the source: TS/JS keeps `this.container.classList.toggle()` and
* `window.$events.listen()` bare, Scala `requestToArmeria(request).execute()`
* and `_.get.whenRequestMatchesPartial(…)`. `'self'` for `this.m()` /
* `self.m()` / `super.m()` / `super().m()`; null for a call written bare (or
* not found). `links` are the member names between `this` and the method.
*/
function collapsedJsChain(ref: UnresolvedRef, context: ResolutionContext): { root: string; links: string[] } | null {
if (!JS_FAMILY.has(ref.language) || ref.referenceKind !== 'calls' || !/^[A-Za-z_$][\w$]*$/.test(ref.referenceName)) return null;
function bareCallReceiver(ref: UnresolvedRef, context: ResolutionContext): { receiver: string; links: string[] } | null {
if (ref.referenceKind !== 'calls' || !/^[A-Za-z_$][\w$]*$/.test(ref.referenceName)) return null;
const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/);
if (!lines) return null;
const text = lines.slice(ref.line - 1, ref.line + 3).join('\n').slice(ref.column);
const text = lines.slice(ref.line - 1, ref.line + 7).join('\n').slice(Math.max(0, ref.column));
const name = ref.referenceName.replace(/\$/g, '\\$');
const m = new RegExp(`^(this|super|window)((?:\\s*\\??\\.\\s*#?[\\w$]+(?:\\([^()]*\\))?)+?)\\s*\\??\\.\\s*${name}\\s*(?:<[^<>()]*>)?\\s*\\(`).exec(text);
if (!m) return null;
return { root: m[1]!, links: m[2]!.split('.').map((l) => l.replace(/[\s?]/g, '')).filter((l) => l !== '') };
const at = new RegExp(`(?<![\\w$])${name}\\s*(?:<[^<>()]*>|\\[(?:[^\\[\\]]|\\[[^\\[\\]]*\\])*\\])?\\s*[({]`).exec(text);
if (!at) return null;
const before = text.slice(0, at.index).replace(/\s+$/, '');
if (!/\??\.$/.test(before)) return null;
const head = before.replace(/\??\.$/, '').replace(/\s+$/, '');
if (/(?:^|[^\w$.])(?:this|self|super|Self)$/.test(head) || /(?:^|[^\w$.])super\s*\([^()]*\)$/.test(head)) return { receiver: 'self', links: [] };
const chain = /(?:^|[^\w$.#])((?:this|super)(?:\s*\??\.\s*#?[\w$]+)+)$/.exec(head);
const links = chain ? chain[1]!.split('.').slice(1).map((l) => l.replace(/[\s?]/g, '')) : [];
return { receiver: head.slice(-40), links };
}
/**
* Whether a collapsed `this.<field>.m()` inside `m` can be `m` itself: only
* when the class declares the field as its own type — a tree node's
* `this.left.insert(v)` — never through a field of another type
* (`this.editor.input.focus()`, `this.container.classList.toggle()`).
*/
/** Whether a call recorded by its bare name is written on something other than the caller's own object. */
function isCollapsedNonRecursion(ref: UnresolvedRef, context: ResolutionContext): boolean {
const chain = collapsedJsChain(ref, context);
return chain !== null && !isCollapsedSelfRecursion(chain, ref, context);
const written = bareCallReceiver(ref, context);
if (!written || written.receiver === 'self') return false;
return !(JS_FAMILY.has(ref.language) && isCollapsedSelfRecursion({ root: 'this', links: written.links }, ref, context));
}
function isCollapsedSelfRecursion(chain: { root: string; links: string[] }, ref: UnresolvedRef, context: ResolutionContext): boolean {
@@ -8379,12 +8383,20 @@ export function matchReference(
): ResolvedRef | null {
const result = gateLanguageMatch(matchReferenceInner(ref, context), ref, context);
// `this.container.classList.toggle()` inside `toggle()`, `window.$events
// .listen()` inside `listen()`: a member of what the chain reaches, which is
// the calling method only through a field of the caller's own type.
// .listen()` inside `listen()`, Scala's `requestToArmeria(request).execute()`
// inside `execute()`: a member of what the receiver is, which is the calling
// method only through a TS/JS field of the caller's own type.
if (result && result.targetNodeId === ref.fromNodeId && isCollapsedNonRecursion(ref, context)) return null;
// Nor does a value's initializer call the value: sttp's `val response =
// basicRequest.get(…).response(asStringAlways)` is a request's `response`.
if (result && result.targetNodeId === ref.fromNodeId && ref.referenceKind === 'calls' &&
VALUE_KINDS.has(context.getNodeById?.(ref.fromNodeId)?.kind ?? '')) return null;
return result ? retargetSelfOverload(result, ref, context) : result;
}
/** Node kinds that hold a value rather than run code. */
const VALUE_KINDS: ReadonlySet<string> = new Set(['variable', 'constant', 'field', 'property']);
/** Languages whose methods overload by arity. */
const OVERLOADING_LANGUAGES: ReadonlySet<string> = new Set(['csharp', 'java', 'kotlin', 'swift', 'cpp', 'scala', 'dart', 'vbnet']);