Skip to content

Dart private named constructor (Foo._()) collapses to an empty entity in make_id, colliding with the file node and disabling _is_file_node #2738

Description

@sonal-sithara

Summary

make_id(stem, entity) strips non-alphanumeric characters from entity. A Dart private named constructor (class Foo { const Foo._(); }) has the entity name _, which normalizes to the empty string — so the symbol's ID collapses to the bare file stem, which is the ID that file's own node already owns.

On build the two nodes merge and the symbol's _ label overwrites the filename label.

That second-order effect is the damaging one. analyze._is_file_node() identifies file-level hubs by comparing the label against the source filename:

source_file = attrs.get("source_file", "")
if source_file:
    from graphify.build import _is_file_node_label
    if _is_file_node_label(label, source_file):
        return True

Once the label is _ instead of foo.dart, the node is no longer recognised as a file node — so it leaks straight into god_nodes(), surprising_connections() and knowledge-gap reporting, which are exactly the consumers the file-node filter exists to protect. god_nodes()' own docstring says file hubs "accumulate import/contains edges mechanically and don't represent meaningful architectural abstractions", and that intent is silently defeated.

Reproduction

graphify 0.9.42, Python 3.13, macOS.

lib/widget.dart — 5 lines:

class AvatarCard {
  const AvatarCard._({required this.size});

  factory AvatarCard.small() => const AvatarCard._(size: 1.0);

  final double size;
}
from pathlib import Path
from graphify.extract import extract

r = extract([Path('lib/widget.dart')], cache_root=Path('.'))
for n in r['nodes']:
    print(f"label={n['label']!r:16s} id={n['id']}")
label='widget.dart'    id=lib_widget
label='AvatarCard'     id=lib_widget_avatarcard
label='size'           id=lib_widget_size
label='_'              id=lib_widget      <-- collides with the file node
label='small'          id=lib_widget_small

The collapse is directly visible in make_id:

>>> from graphify.extractors.base import _make_id
>>> _make_id("lib/widget", "_")
'lib_widget'
>>> _make_id("lib/widget")          # the file node
'lib_widget'

__ collapses identically. And after build_from_json:

>>> G.nodes['lib_widget']['label']
'_'
>>> from graphify.analyze import _is_file_node
>>> _is_file_node(G, 'lib_widget')
False        # expected True

Impact observed on a real corpus

A ~1,050-file Dart/Flutter codebase, lib/ only, 10,152 nodes / 17,941 edges:

  • 246 nodes ended up labelled _.
  • The v3: semantic query with embeddings #1 god node was a generated DI config file at 409 edges, labelled _. The Wroked out examples missing graph.html #3 was another file node at 43 edges. Both are precisely what _is_file_node is meant to exclude; with correct labels neither appears in the ranking at all.
  • The two top entries of the report's Suggested Questions section were also built on these nodes ("Why does _ connect ... to <80 communities>"), so the highest-signal part of the generated report was pointed at an artifact.
  • 236 of the 246 had no sibling node sharing the ID, so the collapsed symbol is the sole occupant of the file-stem ID — for those files the filename label is lost outright rather than merely contested.

Since /graphify's report presents the God Nodes list to the user as "your core abstractions", this reads as a confident, wrong answer about the codebase's architecture rather than as a visible glitch.

Suggested fix

Primary — keep the symbol's ID distinct. In make_id, when a non-empty entity was supplied but normalizes to empty, fall back to a stable placeholder rather than silently dropping the segment, so the symbol never lands on the file-stem ID:

# entity was given but is all-punctuation (Dart `Foo._()`, a bare `_`);
# without this the ID collapses onto the file node and overwrites its label.
if raw_entity and not normalized_entity:
    normalized_entity = "underscore"   # or a short stable hash of raw_entity

Secondary — make the merge non-destructive. When build merges two nodes sharing an ID, prefer the label that matches the source filename over one that does not. That keeps a single bad label from disabling _is_file_node even if some other construct collapses in future.

Not Dart-specific

A bare _ is idiomatic in Rust, Go and Python too, and any all-punctuation identifier hits the same empty-entity path. I only verified the Dart case, but the collapse is in the shared make_id, so it is worth checking whether the other extractors emit nodes for such identifiers.

Workaround

For anyone hitting this before a fix lands — relabel rather than filter, since the edges are real. After merging the extraction and before build_from_json:

for n in nodes:
    if str(n.get('label', '')).strip() == '_' and n.get('source_file'):
        n['label'] = Path(n['source_file']).name

Then dedupe by ID. On the corpus above this restored all 246 labels and cost 0 edges; god nodes became the real abstractions, and the file node kept its 409 edges while correctly dropping out of the abstraction ranking. Filtering the _ nodes out instead is worse: it discards 862 real edges and fragmented that graph from 395 to 641 communities.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions