fix(core): DartExtractor — call graph coverage for codex P2 findings

Two gaps in the call-graph walker, both flagged by codex on #435:

1. `const Foo(...)` / `new Foo(...)` constructor calls were silently
   dropped. The grammar emits these as `const_object_expression` /
   `new_expression` containing `arguments` directly — they bypass the
   `selector > argument_part` shape the walker relied on. Added a
   dedicated branch that records the inner `type_identifier` as the
   callee. Critical for Flutter widget trees where
   `runApp(const MyApp())` would otherwise lose the MyApp construction
   edge.

2. When a getter / setter / constructor / factory_constructor has a
   body, its `method_signature` wraps `getter_signature` /
   `setter_signature` / `constructor_signature` /
   `factory_constructor_signature` instead of `function_signature`. The
   walker only looked for `function_signature`, so `pendingName`
   stayed null and the sibling `function_body` was walked with an
   empty stack — calls inside ctor/factory/getter/setter bodies were
   silently dropped even though those members were already extracted
   as functions. Now dispatch across all five signature variants,
   using `constructorName` for the (factory) constructor pair to
   match what `collectClassBody` pushes.

Tests: 41 → 47 dart cases (+6); full core 733 → 739; no regressions.
This commit is contained in:
thejesh
2026-06-15 02:50:06 -07:00
Unverified
parent f2d6b997c3
commit a555f4dd2a
2 changed files with 149 additions and 3 deletions
@@ -526,6 +526,109 @@ int caller() {
tree.delete();
parser.delete();
});
it("records a `const Foo()` constructor as a call edge (Flutter widget shape)", () => {
const { tree, parser, root } = parse(`void main() {
runApp(const MyApp());
}
`);
const entries = extractor.extractCallGraph(root);
const callees = entries.map((e) => e.callee);
// Both the enclosing `runApp` call and the inner `MyApp` construction
// must surface — the latter is the dependency edge that motivates
// Flutter call-graph support.
expect(callees).toContain("runApp");
expect(callees).toContain("MyApp");
const myAppCall = entries.find((e) => e.callee === "MyApp");
expect(myAppCall!.caller).toBe("main");
tree.delete();
parser.delete();
});
it("records a `new Foo()` constructor as a call edge", () => {
const { tree, parser, root } = parse(`void main() {
var x = new Counter(1);
}
`);
const entries = extractor.extractCallGraph(root);
const counterCall = entries.find((e) => e.callee === "Counter");
expect(counterCall).toBeDefined();
expect(counterCall!.caller).toBe("main");
tree.delete();
parser.delete();
});
it("attributes calls inside a getter body to the getter", () => {
const { tree, parser, root } = parse(`class C {
int _v = 0;
int get value {
return helper();
}
}
`);
const entries = extractor.extractCallGraph(root);
const helperCall = entries.find((e) => e.callee === "helper");
expect(helperCall).toBeDefined();
expect(helperCall!.caller).toBe("value");
tree.delete();
parser.delete();
});
it("attributes calls inside a setter body to the setter", () => {
const { tree, parser, root } = parse(`class C {
int _v = 0;
set value(int x) {
_v = clamp(x);
}
}
`);
const entries = extractor.extractCallGraph(root);
const clampCall = entries.find((e) => e.callee === "clamp");
expect(clampCall).toBeDefined();
expect(clampCall!.caller).toBe("value");
tree.delete();
parser.delete();
});
it("attributes calls inside a constructor body to the constructor", () => {
const { tree, parser, root } = parse(`class Foo {
int x;
Foo(this.x) {
validate(x);
}
}
`);
const entries = extractor.extractCallGraph(root);
const validateCall = entries.find((e) => e.callee === "validate");
expect(validateCall).toBeDefined();
expect(validateCall!.caller).toBe("Foo");
tree.delete();
parser.delete();
});
it("attributes calls inside a factory constructor body to `Class.named`", () => {
const { tree, parser, root } = parse(`class Foo {
int x;
Foo(this.x);
factory Foo.fromString(String s) {
return Foo(int.parse(s));
}
}
`);
const entries = extractor.extractCallGraph(root);
// Either the bare `Foo(...)` call inside the factory or the chained
// `int.parse(...)` must be attributed to the factory's qualified name.
const fromFactory = entries.filter((e) => e.caller === "Foo.fromString");
expect(fromFactory.length).toBeGreaterThan(0);
tree.delete();
parser.delete();
});
});
describe("extractStructure - visibility", () => {
@@ -587,6 +587,29 @@ export class DartExtractor implements LanguageExtractor {
});
}
}
// Constructor-call shapes that bypass the `selector > argument_part`
// pattern:
// const Foo(...) → `const_object_expression { const_builtin, type_identifier, arguments }`
// new Foo(...) → `new_expression { (unnamed `new`), type_identifier, arguments }`
// Both are extremely common in Flutter widget trees; without this branch
// the construction edge would be silently dropped. The callee is the
// `type_identifier` child.
if (
(node.type === "const_object_expression" ||
node.type === "new_expression") &&
functionStack.length > 0
) {
const typeNode = findChild(node, "type_identifier");
if (typeNode) {
entries.push({
caller: functionStack[functionStack.length - 1],
callee: typeNode.text,
lineNumber: node.startPosition.row + 1,
});
}
}
walkSiblings(node);
};
@@ -606,9 +629,29 @@ export class DartExtractor implements LanguageExtractor {
// Recurse into signature (no calls expected, but stay complete).
walkSiblings(child);
} else if (child.type === "method_signature") {
// method_signature wraps function_signature; sibling function_body follows.
const inner = findChild(child, "function_signature");
if (inner) pendingName = extractFunctionName(inner);
// method_signature wraps one of:
// function_signature → normal method
// getter_signature → getter (with body)
// setter_signature → setter (with body)
// constructor_signature → constructor (with body)
// factory_constructor_signature → factory (with body)
// All five carry the name as their first `identifier` child (factory
// ctors carry two — class + named — handled by `constructorName`).
// Without this dispatch, ctor/factory/getter/setter bodies were
// walked with an empty functionStack and their internal calls were
// dropped from the graph.
const fn =
findChild(child, "function_signature") ??
findChild(child, "getter_signature") ??
findChild(child, "setter_signature");
if (fn) {
pendingName = extractFunctionName(fn);
} else {
const ctor =
findChild(child, "constructor_signature") ??
findChild(child, "factory_constructor_signature");
if (ctor) pendingName = constructorName(ctor);
}
walkSiblings(child);
} else if (child.type === "function_body") {
// Consume pendingName: push for the duration of this body.