fix(dart): resolve chained static-factory / constructor calls Foo.create().bar() (#750) (#762)

Ports the #645/#608 chained-receiver mechanism to Dart, plus makes Dart factory
and named constructors first-class so their chains can resolve at all. A call
whose receiver is itself a call — `Foo.create().bar()` (static factory or
factory/named constructor) — used to drop the receiver to a bare `bar`, which
name-matched a same-named method on an unrelated type (commonly a stdlib
`Option`/`Iterator` `.map`/`.where` mis-tied to the project's own class).

- dart.ts: extractBareCall now re-encodes `Foo.create().bar` when the chain
  starts with a capitalized type; getReturnType captures the return type (generic
  `List<Foo>` → `List`); factory (`factory Foo.create()`) and named (`Foo._()`)
  constructors are indexed as `Foo::create` / `Foo::_` with return type = the
  class (via resolveName + getReturnType + constructor_signature in methodTypes).
- The UNNAMED ctor `Foo()` is deliberately NOT extracted (isMisparsedFunction),
  so plain construction stays an `instantiates` edge to the class rather than a
  call to a phantom `Foo::Foo` method.
- dartCtorInfo validates a "constructor" against the enclosing class name, so a
  method tree-sitter MISPARSES as a constructor — `@override (A, B) m()`, where
  the annotation swallows the record return type and `m()` looks like a one-id
  constructor_signature — is still extracted as the method it is (regression
  found on localsend; covered by a new test).
- name-matcher.ts / index.ts: `dart` joins the dotted-chain gate,
  CONSTRUCTS_VIA_BARE_CALL (case construction), and CHAIN_LANGUAGES (conformance
  for superclass/mixin methods). resolveMethodOnType validates, so a wrong
  inference yields no edge.

Validation: 7 synthetic tests (static factory, factory/named ctor, construction,
conformance, absent-method safety, the misparse regression, instantiation-not-
hijacked). Real-repo A/B on localsend (368 Dart files): hand-written +17/-10 — all
corrections (the -10 = 7 wrong stdlib/extension misattributions removed + 3 ctor
source-renames), plus additive factory/named-ctor call resolution. Instantiation
preserved; no node explosion. EXTRACTION_VERSION 13->14. Full suite green.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-06-09 12:53:04 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 2f96f58cbb
commit 16c73e2b0e
6 changed files with 343 additions and 9 deletions
+168
View File
@@ -2826,4 +2826,172 @@ object Main {
expect(callerNamesOf('Other::onlyOther')).toEqual([]);
});
});
describe('Dart chained static-factory / factory-constructor call resolution (#645/#608 mechanism)', () => {
function callerNamesOf(qualifiedName: string): string[] {
const target = cg.getNodesByKind('method').find((n) => n.qualifiedName === qualifiedName);
if (!target) return [];
const names = cg
.getIncomingEdges(target.id)
.filter((e) => e.kind === 'calls')
.map((e) => cg.getNode(e.source)?.name)
.filter((n): n is string => !!n);
return [...new Set(names)].sort();
}
it('resolves a static-factory chain Foo.makeBar().doIt() to the return type, never a same-named decoy', async () => {
fs.writeFileSync(
path.join(tempDir, 'main.dart'),
`class Foo {
static Bar makeBar() => Bar();
}
class Bar {
void doIt() {}
}
class Decoy {
void doIt() {}
}
void run() {
Foo.makeBar().doIt();
}
`
);
cg = await CodeGraph.init(tempDir, { index: true });
expect(callerNamesOf('Bar::doIt')).toEqual(['run']);
expect(callerNamesOf('Decoy::doIt')).toEqual([]);
});
it('resolves a named factory-constructor chain Foo.create().ship() on the constructed class', async () => {
fs.writeFileSync(
path.join(tempDir, 'main.dart'),
`class Foo {
Foo._();
factory Foo.create() => Foo._();
void ship() {}
}
class Decoy {
void ship() {}
}
void run() {
Foo.create().ship();
}
`
);
cg = await CodeGraph.init(tempDir, { index: true });
// The factory constructor `Foo.create` is now a node whose return type is Foo,
// so `ship` resolves on Foo, not the same-named Decoy.
expect(callerNamesOf('Foo::ship')).toEqual(['run']);
expect(callerNamesOf('Decoy::ship')).toEqual([]);
});
it('resolves a constructor-receiver chain Bar().doIt() on the constructed class', async () => {
fs.writeFileSync(
path.join(tempDir, 'main.dart'),
`class Bar {
void doIt() {}
}
class Decoy {
void doIt() {}
}
void run() {
Bar().doIt();
}
`
);
cg = await CodeGraph.init(tempDir, { index: true });
expect(callerNamesOf('Bar::doIt')).toEqual(['run']);
expect(callerNamesOf('Decoy::doIt')).toEqual([]);
});
it('resolves a chained method inherited from a superclass the return type extends (via conformance)', async () => {
fs.writeFileSync(
path.join(tempDir, 'main.dart'),
`class Base {
void render() {}
}
class Widget extends Base {
static Widget make() => Widget();
}
class Decoy {
void render() {}
}
void run() {
Widget.make().render();
}
`
);
cg = await CodeGraph.init(tempDir, { index: true });
expect(callerNamesOf('Base::render')).toEqual(['run']);
expect(callerNamesOf('Decoy::render')).toEqual([]);
});
it('creates NO edge when neither the factory return type nor a supertype has the method (silent miss)', async () => {
fs.writeFileSync(
path.join(tempDir, 'main.dart'),
`class Foo {
static Bar makeBar() => Bar();
}
class Bar {
}
class Other {
void onlyOther() {}
}
void run() {
Foo.makeBar().onlyOther();
}
`
);
cg = await CodeGraph.init(tempDir, { index: true });
// Bar has no onlyOther() — must not mis-attach to the same-named Other::onlyOther.
expect(callerNamesOf('Other::onlyOther')).toEqual([]);
});
it('still extracts a method tree-sitter misparses as a constructor (@override + record return)', async () => {
// tree-sitter-dart misparses `@override (A, B) reduce()` — the annotation
// swallows the record return type, so `reduce()` looks like a single-
// identifier constructor_signature. It must NOT be skipped as an unnamed
// ctor (its name doesn't match the class); its body call must attribute to
// `reduce`, not the class.
fs.writeFileSync(
path.join(tempDir, 'main.dart'),
`class Base {}
class Action extends Base {
Action({required int x});
@override
(int, String) reduce() {
return (compute(), "y");
}
int compute() => 1;
}
`
);
cg = await CodeGraph.init(tempDir, { index: true });
// reduce must be a node and its body call must resolve to Action::compute.
expect(callerNamesOf('Action::compute')).toEqual(['reduce']);
});
it('keeps plain construction Foo() as instantiation, not a Foo::Foo method call', async () => {
// The unnamed constructor is intentionally NOT extracted as a `Foo::Foo`
// method, so `Foo(...)` resolves to the class (an `instantiates` edge),
// never hijacked into a call to a phantom constructor method.
fs.writeFileSync(
path.join(tempDir, 'main.dart'),
`class Widget {
final int x;
Widget(this.x);
}
void run() {
Widget(3);
}
`
);
cg = await CodeGraph.init(tempDir, { index: true });
// No Foo::Foo phantom method node.
expect(cg.getNodesByKind('method').some((n) => n.qualifiedName === 'Widget::Widget')).toBe(false);
// The construction resolves to the class as an `instantiates` edge.
const widget = cg.getNodesByKind('class').find((n) => n.name === 'Widget')!;
const incoming = cg.getIncomingEdges(widget.id);
expect(incoming.some((e) => e.kind === 'instantiates')).toBe(true);
});
});
});