From a65bd7bae39f921d78359acfca4e154a0edc63ab Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Tue, 20 Oct 2026 19:21:23 +0000 Subject: [PATCH] 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 --- .../core/ingestion/languages/dart/captures.ts | 32 +++++++++++++-- .../ingestion/languages/dart/interpret.ts | 7 +++- .../lib/callers.dart | 18 +++++++++ .../dart-prefixed-factory-return/lib/mod.dart | 11 +++++ .../dart-prefixed-factory-return/pubspec.yaml | 1 + .../test/integration/resolvers/dart.test.ts | 40 +++++++++++++++++++ 6 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/dart-prefixed-factory-return/lib/callers.dart create mode 100644 gitnexus/test/fixtures/lang-resolution/dart-prefixed-factory-return/lib/mod.dart create mode 100644 gitnexus/test/fixtures/lang-resolution/dart-prefixed-factory-return/pubspec.yaml diff --git a/gitnexus/src/core/ingestion/languages/dart/captures.ts b/gitnexus/src/core/ingestion/languages/dart/captures.ts index a671d1e227..c045c3040a 100644 --- a/gitnexus/src/core/ingestion/languages/dart/captures.ts +++ b/gitnexus/src/core/ingestion/languages/dart/captures.ts @@ -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), }); } diff --git a/gitnexus/src/core/ingestion/languages/dart/interpret.ts b/gitnexus/src/core/ingestion/languages/dart/interpret.ts index 6872bfc014..94ffe5ac76 100644 --- a/gitnexus/src/core/ingestion/languages/dart/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/dart/interpret.ts @@ -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' || diff --git a/gitnexus/test/fixtures/lang-resolution/dart-prefixed-factory-return/lib/callers.dart b/gitnexus/test/fixtures/lang-resolution/dart-prefixed-factory-return/lib/callers.dart new file mode 100644 index 0000000000..648ceb37d4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/dart-prefixed-factory-return/lib/callers.dart @@ -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(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/dart-prefixed-factory-return/lib/mod.dart b/gitnexus/test/fixtures/lang-resolution/dart-prefixed-factory-return/lib/mod.dart new file mode 100644 index 0000000000..1ef4c6fc21 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/dart-prefixed-factory-return/lib/mod.dart @@ -0,0 +1,11 @@ +class C { + int m() => 1; +} + +class Other { + int m() => 2; +} + +C make() => C(); + +untyped() => C(); diff --git a/gitnexus/test/fixtures/lang-resolution/dart-prefixed-factory-return/pubspec.yaml b/gitnexus/test/fixtures/lang-resolution/dart-prefixed-factory-return/pubspec.yaml new file mode 100644 index 0000000000..f75c9b3399 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/dart-prefixed-factory-return/pubspec.yaml @@ -0,0 +1 @@ +name: prefixed_factory_return diff --git a/gitnexus/test/integration/resolvers/dart.test.ts b/gitnexus/test/integration/resolvers/dart.test.ts index 9f36a33d08..cc419231f4 100644 --- a/gitnexus/test/integration/resolvers/dart.test.ts +++ b/gitnexus/test/integration/resolvers/dart.test.ts @@ -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', () => {