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
+1 -1
View File
@@ -21,4 +21,4 @@
* turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty
* in the product is load-bearing").
*/
export const EXTRACTION_VERSION = 13;
export const EXTRACTION_VERSION = 14;
+164 -1
View File
@@ -2,10 +2,128 @@ import type { Node as SyntaxNode } from 'web-tree-sitter';
import { getNodeText } from '../tree-sitter-helpers';
import type { LanguageExtractor } from '../tree-sitter-types';
/**
* The `function_signature` carrying a method's return type — unwrapped from a
* `method_signature` wrapper (Dart nests the signature one level for methods).
*/
function dartInnerSignature(node: SyntaxNode): SyntaxNode {
if (node.type === 'method_signature') {
const inner = node.namedChildren.find((c: SyntaxNode) =>
c.type === 'function_signature' || c.type === 'getter_signature' || c.type === 'setter_signature'
);
if (inner) return inner;
}
return node;
}
/**
* The factory/named-constructor signature inside a node, if any. A constructor
* parses as `method_signature > {factory_,}constructor_signature` (e.g.
* `factory Foo.create()` or `Foo._()`), whose children are the class identifier
* and — for a named ctor — the constructor-name identifier.
*/
function dartConstructorSignature(node: SyntaxNode): SyntaxNode | undefined {
if (node.type === 'factory_constructor_signature' || node.type === 'constructor_signature') {
return node;
}
if (node.type === 'method_signature') {
return node.namedChildren.find((c: SyntaxNode) =>
c.type === 'factory_constructor_signature' || c.type === 'constructor_signature'
);
}
return undefined;
}
/** The name of the class/mixin/extension/enum lexically enclosing `node`. */
function dartEnclosingTypeName(node: SyntaxNode): string | undefined {
let p = node.parent;
while (p) {
if (
p.type === 'class_definition' || p.type === 'mixin_declaration' ||
p.type === 'extension_declaration' || p.type === 'enum_declaration'
) {
return p.childForFieldName('name')?.text;
}
p = p.parent;
}
return undefined;
}
/**
* Validated constructor info for `node`, or undefined if it isn't genuinely a
* constructor. A constructor signature is structurally `<Class>` or
* `<Class>.<name>`, but tree-sitter-dart MISPARSES `@override (T) m()` — the
* annotation swallows the record return type `(T)`, leaving `m()` looking like a
* single-identifier constructor_signature. We disambiguate by the class name:
* a real ctor's class identifier matches the enclosing type; a misparsed method
* (`reduce` inside class `Action`) doesn't, and is treated as the method it is.
*/
function dartCtorInfo(node: SyntaxNode): { className: string; ctorName: string } | undefined {
const ctor = dartConstructorSignature(node);
if (!ctor) return undefined;
const ids = ctor.namedChildren.filter((c: SyntaxNode) => c.type === 'identifier');
const className = dartEnclosingTypeName(node);
if (!className || !ids[0]) return undefined;
if (ids[0].text !== className) return undefined; // misparsed method, not a ctor
// `<Class>.<name>` is a named ctor; bare `<Class>` is the unnamed ctor.
return { className, ctorName: ids[1]?.text ?? className };
}
/**
* Capture a Dart method/function's declared return type as a bare type name, for
* the chained static-factory / fluent call mechanism (#750). `Bar makeBar()`
* yields `Bar`; a generic `List<Foo>` yields its container `List` (the method is
* on the container, not the element); a prefixed `prefix.Bar` yields `Bar`. A
* factory / named constructor returns its enclosing class implicitly, so its
* "return type" is the class.
*/
function extractDartReturnType(node: SyntaxNode, source: string): string | undefined {
const ctor = dartCtorInfo(node);
if (ctor) return ctor.className;
const sig = dartInnerSignature(node);
// The return type precedes the method name; it's the first type_identifier
// (generic args sit in a sibling `type_arguments`, so this is the container).
const retType = sig.namedChildren.find((c: SyntaxNode) => c.type === 'type_identifier');
if (!retType) return undefined;
const text = getNodeText(retType, source).replace(/<[^>]*>/g, '').trim();
const last = text.split('.').pop(); // prefixed `p.Bar` → `Bar`
if (!last || !/^[A-Za-z_]\w*$/.test(last)) return undefined;
return last;
}
/**
* The callee name of the Dart call whose `argument_part` selector is `argPart`
* — mirrors the main extractBareCall accessor logic so a chained receiver
* (`Foo.create()` in `Foo.create().bar()`) can be reconstructed. Returns
* `Foo.create`, a bare `create`, or `Foo` (constructor) — or undefined.
*/
function dartCalleeOfArgPart(argPart: SyntaxNode): string | undefined {
const prev = argPart.previousNamedSibling;
if (!prev) return undefined;
if (prev.type === 'identifier') return prev.text; // bare `Foo()` / `create()`
if (prev.type === 'selector') {
const accessor = prev.namedChildren.find((c: SyntaxNode) =>
c.type === 'unconditional_assignable_selector' || c.type === 'conditional_assignable_selector'
);
const methodId = accessor?.namedChildren.find((c: SyntaxNode) => c.type === 'identifier');
if (methodId) {
const accessorPrev = prev.previousNamedSibling;
if (accessorPrev?.type === 'identifier') return accessorPrev.text + '.' + methodId.text;
return methodId.text;
}
}
return undefined;
}
export const dartExtractor: LanguageExtractor = {
functionTypes: ['function_signature'],
classTypes: ['class_definition'],
methodTypes: ['method_signature'],
// `method_signature` covers regular methods AND factory constructors (which
// parse as method_signature > factory_constructor_signature). A plain named
// constructor `Foo._()` parses as a bare `constructor_signature`, so include
// it too — resolveName names it by the ctor name and getReturnType gives it
// the class as its return type, so `Foo._().bar()` chains resolve (#750).
methodTypes: ['method_signature', 'constructor_signature'],
interfaceTypes: [],
structTypes: [],
enumTypes: ['enum_declaration'],
@@ -33,6 +151,19 @@ export const dartExtractor: LanguageExtractor = {
bodyField: 'body', // class_definition uses 'body' field
paramsField: 'formal_parameter_list',
returnField: 'type',
getReturnType: extractDartReturnType,
isMisparsedFunction: (_name, node) => {
// Skip the UNNAMED constructor `Foo()` (its ctor name equals the class). It's
// ordinary construction — an `instantiates` edge to the class `Foo` — so
// extracting it as a `Foo::Foo` method node would hijack instantiation
// resolution (a `Foo(...)` call would resolve to the ctor method, not the
// class). NAMED ctors `Foo.create()` / `Foo._()` ARE kept so their chains
// resolve (#750). dartCtorInfo validates against the class name, so a method
// tree-sitter misparsed as a ctor (`@override (T) m()`) is NOT skipped here.
// (isMisparsedFunction skips node creation but still visits the body.)
const ctor = dartCtorInfo(node);
return ctor != null && ctor.ctorName === ctor.className;
},
getSignature: (node, source) => {
// For function_signature: extract params + return type
// For method_signature: delegate to inner function_signature
@@ -88,6 +219,23 @@ export const dartExtractor: LanguageExtractor = {
}
return false;
},
resolveName: (node) => {
// Name a factory / named constructor by its constructor name — the 2nd
// identifier (`create` in `factory Foo.create()`, `_` in `Foo._()`) — not
// the class, so a call `Foo.create()` resolves to `Foo::create` (#750). The
// default Dart naming returns the FIRST identifier (the class), which
// collides every named ctor onto `Foo::Foo` and leaves `Foo.create()`
// unresolvable. An unnamed ctor `Foo()` has a single identifier — fall
// through (undefined) to the default class name. Letting the core's
// extractMethod own the factory (rather than a custom visitNode) keeps the
// body attribution intact: calls inside `factory Foo.create() { … }` are
// attributed to `Foo::create`, and getReturnType gives it return type Foo.
const ctor = dartCtorInfo(node);
// A named ctor `Foo.create` → `create`; the unnamed ctor `Foo()` → undefined
// (default naming gives the class name `Foo`, which is correct).
if (ctor && ctor.ctorName !== ctor.className) return ctor.ctorName;
return undefined;
},
extractImport: (node, source) => {
const importText = source.substring(node.startIndex, node.endIndex).trim();
let moduleName = '';
@@ -160,6 +308,21 @@ export const dartExtractor: LanguageExtractor = {
if (accessorPrev?.type === 'identifier') {
return accessorPrev.text + '.' + methodId.text;
}
// Chained static-factory / fluent call: the receiver is itself a call
// (`Foo.create().bar()`), so accessorPrev is that call's argument_part
// selector. Encode `<innerCallee>().<method>` so resolution can infer
// bar's class from what `Foo.create` RETURNS (#645/#608 mechanism) —
// but only when the chain starts with a capitalized type (a companion
// factory / static method / constructor); an instance chain
// (`obj.foo().bar()`) keeps the bare name (its receiver's type can't
// be recovered here).
if (accessorPrev?.type === 'selector' &&
accessorPrev.namedChildren.some((c: SyntaxNode) => c.type === 'argument_part')) {
const innerCallee = dartCalleeOfArgPart(accessorPrev);
if (innerCallee && /^[A-Z]/.test(innerCallee)) {
return `${innerCallee}().${methodId.text}`;
}
}
return methodId.text;
}
}