fix(dart): type locals from prefixed-import factory calls

`import 'mod.dart' as rp; final x = rp.make(); x.m()` gave `x` no type:
local call inference accepted only a bare callee (`Callee()`), and the
prefix-stripping type normalizer would have reduced `rp.make` to `make`.

Bind a local initialized by exactly `prefix.member(...)` (direct or
awaited) to the dotted callee and keep its prefix, so the shared chain
follow types it from the namespace function's return annotation. Only
call-derived bindings keep the prefix; annotated `prefix.Type` spellings
are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-10-20 19:21:23 +00:00 • committed by GitHub
parent cddbe135c0
commit a65bd7bae3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 105 additions and 4 deletions

View file

@ -619,6 +619,32 @@ function findDirectCallValue(initVarDef: SyntaxNode): SyntaxNode | null {
return null;
}
/**
* `prefix.member(…)` as the whole initializer, written directly or awaited: the
* dotted callee. The shared chain follow types a namespace function from its
* return annotation, and a member call through the receiver's class (#3498).
* Longer chains (`rp.make().other`) are left alone.
*/
function qualifiedCallValue(initVarDef: SyntaxNode): string | null {
let head = initVarDef.childForFieldName('value');
if (head?.type === 'unary_expression') head = head.namedChild(0);
if (head?.type === 'await_expression') head = head.namedChild(0);
if (head?.type !== 'identifier') return null;
const member = head.nextNamedSibling;
const call = member?.nextNamedSibling;
const inner = member?.type === 'selector' ? member.namedChild(0) : null;
const name = inner?.type === 'unconditional_assignable_selector' ? selectorName(inner) : null;
if (
name === null ||
call?.type !== 'selector' ||
call.namedChild(0)?.type !== 'argument_part' ||
call.nextNamedSibling !== null
) {
return null;
}
return `${head.text}.${name.text}`;
}
/**
* Callee identifier of a class field initialized by a direct constructor call —
* `var b = Outer();` / `final b = Outer();` — or `null` for anything else.
@ -644,13 +670,13 @@ function dartFieldConstructorCallee(nameNode: SyntaxNode): SyntaxNode | null {
function emitVarTypeBinding(initVarDef: SyntaxNode, out: CaptureMatch[]): void {
const nameNode = initVarDef.childForFieldName('name');
if (nameNode === null) return;
const calleeId = findDirectCallValue(initVarDef);
if (calleeId === null) return;
const callee = findDirectCallValue(initVarDef)?.text ?? qualifiedCallValue(initVarDef);
if (callee === null) return;
out.push({
'@type-binding.constructor': nodeToCapture('@type-binding.constructor', initVarDef),
'@type-binding.name': syntheticCapture('@type-binding.name', initVarDef, nameNode.text),
'@type-binding.type': syntheticCapture('@type-binding.type', initVarDef, calleeId.text),
'@type-binding.type': syntheticCapture('@type-binding.type', initVarDef, callee),
});
}

View file

@ -90,7 +90,12 @@ export function interpretDartTypeBinding(captures: CaptureMatch): ParsedTypeBind
const typeCap = captures['@type-binding.type'];
if (nameCap === undefined || typeCap === undefined) return null;
const rawType = normalizeDartType(typeCap.text);
// A call-derived `prefix.member` names a namespace function or a member
// call, not a library-prefixed type, so it keeps its prefix (#3498).
const rawType =
captures['@type-binding.constructor'] !== undefined && typeCap.text.includes('.')
? typeCap.text
: normalizeDartType(typeCap.text);
if (
rawType === '' ||
rawType === 'void' ||

View file

@ -0,0 +1,18 @@
import 'mod.dart' as rp;
int finalCall() {
final x = rp.make();
return x.m();
}
int varCall() {
var x = rp.make();
return x.m();
}
int inlineCall() => rp.make().m();
int untypedCall() {
final x = rp.untyped();
return x.m();
}

View file

@ -0,0 +1,11 @@
class C {
int m() => 1;
}
class Other {
int m() => 2;
}
C make() => C();
untyped() => C();

View file

@ -0,0 +1 @@
name: prefixed_factory_return

View file

@ -13,6 +13,7 @@ import {
getRelationships,
getNodesByLabel,
getNodesByLabelFull,
getResolutionOutcomes,
edgeSet,
runPipelineFromRepo,
type PipelineResult,
@ -149,6 +150,45 @@ describe.skipIf(!dartAvailable)('Dart field-type resolution', () => {
});
});
// ── Prefixed-import factory results (#3498) ─────────────────────────────
describe.skipIf(!dartAvailable)('Dart prefixed-import factory result types (#3498)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'dart-prefixed-factory-return'),
() => {},
);
}, 60000);
const methodCalls = (source: string) =>
getRelationships(result, 'CALLS')
.filter((edge) => edge.source === source && edge.target === 'm')
.map((edge) => edge.rel.targetId);
it.each(['finalCall', 'varCall', 'inlineCall'])('resolves %s to C.m', (source) => {
expect(methodCalls(source)).toEqual(['Method:lib/mod.dart:C.m#0']);
});
it('does not guess a receiver type for an untyped factory result', () => {
expect(methodCalls('untypedCall')).toEqual([]);
});
it('records the untyped factory result as a dropped site', () => {
expect(
getResolutionOutcomes(result)
.filter(
(outcome) =>
outcome.kind === 'suppressed' &&
outcome.reason === 'receiver-unresolved' &&
outcome.filePath === 'lib/callers.dart',
)
.map((outcome) => `${outcome.range.startLine}:${outcome.name}`),
).toEqual(['17:m']);
});
});
// ── Phase 9: Call-result binding ────────────────────────────────────────
describe.skipIf(!dartAvailable)('Dart call-result binding', () => {