fix(dart): core method names on untyped receivers are the core type's (#2207)

Dart joins Rust/Go/Kotlin/C# with a standard-method list (String, List,
Iterable, Map, Set: endsWith, contains, forEach, putIfAbsent, where,
sublist...): a guess onto a project method of such a name needs a receiver
named after its owner. And a Dart extension's owner, for that naming, is the
type it is `on` - getx's `ext.endsWith(".avi")` on a String shared the word
`Ext` with `extension RxStringExt on Rx<String>`, none with `Rx<String>`.

getx -36 (`RxStringExt::endsWith` x23, IOHttpResponse::join/toList),
riverpod -37 (`UnmodifiableMap::putIfAbsent/addAll` on plain maps), bloc -18
(`ignore.contains(...)` -> GithubCache, a Map's `forEach` -> _Emitter; bloc's
untyped `emit.forEach(...)` handler lambdas lose their lucky guess too).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-30 21:31:14 +00:00
committed by GitHub
parent fc73204f12
commit eb1452ba82
3 changed files with 93 additions and 2 deletions
+1
View File
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
- A Dart call like `ext.endsWith(".avi")` or `map.putIfAbsent(…)` on a value whose type isn't known now counts as Dart's own String, List or Map method. It no longer lands on a project method of the same name. A Dart extension is also matched by the type it extends, not by its own name, so getx's `ext.endsWith` on a String stopped reaching `extension RxStringExt on Rx<String>`.
- Kotlin calls inside a DSL lambda now reach the lambda's own receiver. That receiver comes from the function the lambda is passed to, even through a typealias like koin's `Definition<T> = Scope.(…) -> T`. It also wins over other types the project uses as lambda receivers elsewhere. On koin, over 2,000 `get()` calls in `single { … }` now reach `Scope.get` instead of `Koin.get`; on Exposed, `varchar(…)` inside a table object reaches `Table.varchar`; on kotlinx.coroutines, `launch { }` reaches `CoroutineScope.launch`.
- A Swift call on a property declared with a type, like Kingfisher's `var cache: ImageCache!` or `self.storage.write(…)`, now reaches that type's method or one it inherits. It no longer lands on a subclass override in a test, or on another type's method of the same name.
- Calls in your own JavaScript no longer link to a same-named function inside a vendored minified script, like a `*.min.js` or a bundle made of a few enormous lines. Those names are mangled. In healthchecks, every jQuery `$(…)` call had gone to a one-letter helper inside `bootstrap-native.min.js`.
+61
View File
@@ -0,0 +1,61 @@
/**
* A Dart core method name (`endsWith`, `contains`, `forEach`, …) on an
* untyped receiver is the core type's, unless the receiver is named after
* the owner — and a Dart extension's owner is the type it is `on`, not the
* extension's own name: getx's `ext.endsWith(".avi")` on a String went to
* `extension RxStringExt on Rx<String>` because `ext` matched `…Ext`.
*/
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
let root = '';
let cg: CodeGraph;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-dart-ext-'));
const files: Record<string, string> = {
'pubspec.yaml': 'name: app\n',
'lib/rx_string.dart': `class Rx<T> {
Rx(this.value);
T value;
}
extension RxStringExt on Rx<String> {
bool endsWith(String other) => value.endsWith(other);
}
extension RxnStringExt on Rx<String?> {
bool endsWith(String other) => false;
}
`,
'lib/get_utils.dart': `class GetUtils {
static bool isVideo(String filePath) {
var ext = filePath.toLowerCase();
return ext.endsWith(".avi");
}
}
`,
};
for (const [rel, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), content);
}
cg = await CodeGraph.init(root, { index: true });
});
afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});
describe('Dart core method names on untyped receivers', () => {
it('are not an extension’s method whose name the receiver happens to share', () => {
const ids = cg.getNodesInFile('lib/get_utils.dart').map((n) => n.id);
const targets = cg.getOutgoingEdgesFrom(ids).filter((e) => e.kind === 'calls').map((e) => cg.getNode(e.target)!.qualifiedName);
expect(targets).not.toContain('RxStringExt::endsWith');
expect(targets).not.toContain('RxnStringExt::endsWith');
});
});
+31 -2
View File
@@ -2065,10 +2065,39 @@ function stdMethodNames(language: string): ReadonlySet<string> | null {
case 'rust': return RUST_STD_METHODS;
case 'kotlin': return KOTLIN_STD_METHODS;
case 'csharp': return CSHARP_STD_METHODS;
case 'dart': return DART_STD_METHODS;
default: return null;
}
}
/** Methods of Dart's String, List, Iterable, Map and Set — names a project type rarely carries itself. */
const DART_STD_METHODS: ReadonlySet<string> = new Set([
'endsWith', 'startsWith', 'contains', 'split', 'substring', 'trim', 'trimLeft', 'trimRight', 'toLowerCase',
'toUpperCase', 'replaceAll', 'replaceFirst', 'replaceRange', 'indexOf', 'lastIndexOf', 'padLeft', 'padRight',
'codeUnitAt', 'allMatches', 'firstMatch', 'hasMatch', 'addAll', 'removeAt', 'removeWhere', 'removeLast',
'retainWhere', 'insertAll', 'where', 'whereType', 'forEach', 'toList', 'toSet', 'join', 'reduce', 'fold',
'any', 'every', 'firstWhere', 'lastWhere', 'singleWhere', 'containsKey', 'containsValue', 'putIfAbsent',
'sublist', 'take', 'takeWhile', 'skip', 'skipWhile', 'expand', 'cast', 'compareTo', 'elementAt', 'followedBy',
'asMap', 'getRange', 'setAll', 'fillRange', 'shuffle', 'sort', 'indexWhere', 'lastIndexWhere',
]);
/**
* Whether a receiver is named after the owner of `method` — for a Dart
* extension, after the type it is `on`: getx's `ext.endsWith(".avi")` on a
* String shares a word with `RxStringExt`, none with its `Rx<String>`.
*/
function receiverNamesOwner(receiver: string, method: Node, context: ResolutionContext): boolean {
if (method.language === 'dart') {
const cut = method.qualifiedName.lastIndexOf('::');
const owner = cut > 0 ? method.qualifiedName.slice(0, cut).split('::').pop()! : '';
const decl = owner ? context.getNodesByName(owner).find((n) => n.language === 'dart' && n.filePath === method.filePath) : undefined;
const line = decl ? (context.getFileLines?.(decl.filePath) ?? context.readFile(decl.filePath)?.split(/\r?\n/) ?? [])[decl.startLine - 1] ?? '' : '';
const on = /\bextension\s+\w*\s*(?:<[^>]*>)?\s*on\s+([\w<>, ?]+?)\s*\{/.exec(line)?.[1];
if (on) return sharesReceiverWord(receiver, { ...method, qualifiedName: `${on.replace(/[<>, ?]+/g, '')}::${method.name}` });
}
return sharesReceiverWord(receiver, method);
}
const CSHARP_ALIASES = new WeakMap<ResolutionContext, Map<string, Map<string, string>>>();
/**
@@ -6635,7 +6664,7 @@ export function matchMethodCall(
// untyped receiver (`sym.map(…)`, `w.Header().Get(…)`,
// `reader.Value.ToString()`) is the library type's.
!(stdMethodNames(ref.language)?.has(methodName!) &&
!/^(?:self|Self|this|base)$/.test(objectOrClass!) && !sharesReceiverWord(receiverLink(objectOrClass!), targetMethods[0]!)) &&
!/^(?:self|Self|this|base)$/.test(objectOrClass!) && !receiverNamesOwner(receiverLink(objectOrClass!), targetMethods[0]!, context)) &&
!(UNTYPED_RECEIVER_LANGUAGES.has(ref.language) && !/^(?:self|self\.class|this|super|weak_?self|strong_?self)$/i.test(objectOrClass!) &&
!sharesReceiverWord(objectOrClass!, targetMethods[0]!) &&
!(ref.language === 'objc' && objcReceiverReaches(objectOrClass!, targetMethods[0]!, context)) &&
@@ -6662,7 +6691,7 @@ export function matchMethodCall(
// first-indexed duplicate (#1079).
const std = stdMethodNames(ref.language)?.has(methodName!) && !/^(?:self|Self|this|base)$/.test(objectOrClass!);
for (const method of preferCallSiteFile(targetMethods, ref.filePath)) {
if (std && !sharesReceiverWord(receiverLink(objectOrClass!), method)) continue;
if (std && !receiverNamesOwner(receiverLink(objectOrClass!), method, context)) continue;
// The owner type's own name — not its namespace (`eShop.ClientApp…`
// shares `Client` with every `httpClient`) nor the method's.
const cut = method.qualifiedName.lastIndexOf('::');