Skip to content

fix(dart): emit intra-file calls so .dart files are not stars - #2771

Open
Saifitdin wants to merge 1 commit into
Graphify-Labs:v8from
Saifitdin:dart-intra-file-calls
Open

fix(dart): emit intra-file calls so .dart files are not stars#2771
Saifitdin wants to merge 1 commit into
Graphify-Labs:v8from
Saifitdin:dart-intra-file-calls

Conversation

@Saifitdin

Copy link
Copy Markdown

The problem

extract_dart links every member back to the file node and never to another member, so a .dart file comes out as a star: N members, N−1 defines edges, nothing in between. Section 7 does find invocations, but attributes them to the file node as references, so no call structure survives.

On a Flutter + TypeScript app in one repo:

edges
.dart defines 567, imports 171, references 125, inherits 56, calls 0
.ts contains 285, imports 203, calls 167

The consequence is not just missing edges. A star's cohesion is 2(n−1)/n(n−1) — the mathematical floor — so GRAPH_REPORT.md flags every Flutter community as weakly interconnected and suggests splitting it. On the app above, the 74-node community around a product screen scored 0.027 and was reported as the loosest thing in the project; it was simply the biggest file. Clustering also had no signal left except inheritance from framework base classes, so Louvain grouped "all StatefulWidgets in the project" and "all StatelessWidgets in the project" rather than anything about the product.

The change

A self-contained section 8 in graphify/extractors/dart.py that resolves call sites inside function bodies against declarations in the same file.

  • Calls are attributed to the enclosing class, not the method. Member ids are (stem, name), so all 17 build methods in one file collapse onto a single node — a method-level caller would carry no information. Top-level functions still resolve to themselves.
  • Constructor invocation of a locally declared class becomes calls with context="widget_composition". In Flutter this is the only edge that assembles sibling widgets into a tree, and it is what turns a flat file into a readable structure.
  • Only unqualified calls plus this./widget. resolve. _controller.dispose() is not this widget's dispose, super.initState() is a framework upcall, and ..cancel() is a cascade onto some other object. Without type inference, matching those against local declarations invents edges. An earlier iteration did exactly that and produced _LoadingViewState → dispose from _controller.dispose().
  • Unresolved names are dropped, not guessed — imported symbols and calls into other libraries add nothing, so the pass introduces no cross-file speculation.
  • Repeated call sites raise weight instead of duplicating the edge; self-loops are skipped.

calls is already in SEMANTIC_RELATIONS, so no vocabulary change is needed.

Result

Same app, after the change:

before after
calls from .dart 0 112
graph edges 2321 2458
Flutter communities in the top 12 with internal structure (not a star) 0 of 5 3 of 6

The 1691-line product screen now splits by feature instead of by Flutter class kind:

  • alternatives subtree — _AlternativesSection(+State), _AlternativeTile, _AlternativesEmpty, _AlternativesSkeleton
  • score breakdown and additives — _BreakdownCard, _AdditivesCardState, _row, _factRow, _riskExplanation
  • actions and error handling — _snack, _addPhoto, _shareSheet, _favoriteButtons, _describeError

What stays a star is the residue of plain fields and locals, which genuinely have no edges other than defines.

Tests

Two cases added to tests/test_dart.py:

  • test_intra_file_calls_and_widget_composition — composition edges, plain helper calls, enclosing-class attribution, weight counting, no self-loops
  • test_intra_file_calls_ignore_foreign_receiversthis./widget. resolve; super., receiver-qualified calls and cascades do not

tests/test_dart.py passes 7/7. Full suite: 90 failures before and after (pre-existing, in test_skillgen / test_terraform / test_tree_html, unrelated to this change), passing count 4118 → 4120 — exactly the two new tests. TypeScript extraction is byte-identical.

🤖 Generated with Claude Code

The Dart extractor only ever linked members back to the file node, so every
.dart file came out as a star: N members, N-1 `defines` edges, nothing between
them. On a 32-file Flutter app that meant 0 `calls` edges from .dart against 167
from .ts in the same repo, and Louvain had no signal to cluster on beyond
inheritance from framework base classes.

The knock-on effect is that cohesion scores for Flutter communities sit at the
mathematical floor for a tree (2(n-1)/n(n-1)), so GRAPH_REPORT.md reports every
Flutter community as weakly interconnected and suggests splitting it - advice
driven by the extractor, not by the code.

Section 8 resolves call sites inside function bodies against declarations in the
same file:

- calls are attributed to the enclosing class, not the method. Member ids are
  (stem, name), so all 17 `build` methods in one file collapse onto one node and
  a method-level caller carries no information.
- a constructor invocation of a class declared in the same file becomes `calls`
  with context="widget_composition". In Flutter this is the only edge that links
  sibling widgets into a tree.
- only unqualified calls plus `this.`/`widget.` resolve. Receiver-qualified calls
  (`_controller.dispose()`), `super.` upcalls and cascades (`..cancel()`) are
  skipped - without type inference they invent edges that do not exist.
- repeated call sites raise the edge weight instead of duplicating the edge.

Names that resolve to nothing (imported symbols, calls into other libraries) are
dropped rather than guessed, so the pass adds no cross-file speculation.

Measured on a Flutter + TypeScript app (32 .dart files, 1691-line product screen):
calls from .dart 0 -> 112, graph edges 2321 -> 2458, and the largest screen file
now clusters by feature (alternatives subtree, score breakdown, actions/errors)
instead of by StatefulWidget-vs-StatelessWidget. TypeScript extraction is
untouched.

Tests: two new cases in tests/test_dart.py covering composition, weights,
enclosing-class attribution, no self-loops, and receiver rejection. Full suite is
unchanged at 90 pre-existing failures, 4118 -> 4120 passed.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 2 advisory finding(s) below merit a look before merge.

Formal verification. No changes could be formally verified in this run.


Graphify review — findings

This PR adds a new step (section 8) to the Dart extractor that builds an intra-file call graph. It scans function/method bodies for call sites and creates calls edges to other declarations in the same file, distinguishing constructor invocations (tagged widget_composition) from plain function calls (tagged call), attributing calls to the enclosing class where applicable, and dropping calls with foreign receivers or unresolved names. It also aggregates repeated calls into an edge weight and avoids self-loops. The accompanying test file additions cover widget composition edges, helper call attribution, weight counting, and the exclusion of receiver-qualified calls like super., cascades, and other-object calls.

Worth a look

  • Cascade detection only checks single preceding '.', missing '..' cascade with whitespacegraphify/extractors/dart.py · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review Execution auto-disposal is off for this run; enable it (with sandbox isolation) to have Graphify try to confirm or refute this automatically.
  • widget. receiver resolves against declared_funcs, inventing edges to other objects' membersgraphify/extractors/dart.py · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review Execution auto-disposal is off for this run; enable it (with sandbox isolation) to have Graphify try to confirm or refute this automatically.
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 30 functions depend on the 29 functions this change touches.

Health — this change adds coupling hotspots:

  • new: extract_dart() — 10 callers, 7 callees

Verification — 30 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 30 function(s) in the blast radius were not formally verified this run

Formal verification

Could not verify: Could not verify extract\_dart.

The verifier did not have enough to check extract\_dart, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set

· 1 more finding(s) on lines outside this diff (see the check run).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant