Skip to content

Scan root silently changes the graph: src-layout project loses most 'imports' edges, same node count, no warning #2072

Closed

Description

@Dao-LQ-AI

The same files produce a different graph depending on which directory you point graphify at. Node count is identical, so nothing looks wrong, and no warning is printed.

For a src-layout project, scanning the repository root instead of the package root silently drops most imports edges. In my real case that was 219 edges out of 2,236, about 10%.

Minimal reproduction

Four files in a package mypkg, using absolute imports (from mypkg.core import Engine), which is what a src-layout project looks like on disk.

mre/
  direct/            <- scan THIS: package root
    mypkg/
      __init__.py    from mypkg.core import Engine
      core.py        class Engine
      app.py         from mypkg.core import Engine; import mypkg.helpers as helpers
      helpers.py     from mypkg.core import Engine
  nested/
    src/             <- identical bytes, one level deeper
      mypkg/ ...

nested/src/ is a byte-identical copy of direct/, verified with diff -rq.

Run the same command in each directory, graphify 0.9.22, no API key set:

graphify . --code-only --no-label --no-viz

Result:

direct   ->  8 nodes, 14 edges, 3 communities
nested   ->  8 nodes, 10 edges, 4 communities

Edge relations:

relation direct nested delta
imports 4 1 -3
re_exports 1 0 -1
calls 3 2 -1
imports_from 2 3 +1
contains 3 3 0
method 1 1 0

Same signature at real-project scale: 34 files, 927 nodes in both cases, 2,236 edges vs 2,016, with the gap being 197 imports, 17 imports_from and 5 re_exports. Every other relation matched exactly, and calls differed by one.

Why this is easy to hit and hard to notice

  1. Pointing at a repository root is the natural first thing to do, and it's what the README's graphify . implies.
  2. The node count is unchanged, so the graph looks complete.
  3. No warning is emitted. I grepped the degraded run's output for warn, unresolved, could not and skip and got zero lines.
  4. The ranking changes. In my real project, god-nodes on the correct graph reports A (40), B (39), C (38). On the degraded graph it reports C (37), B (33), A (31). The top three are the same three nodes in a different order, so a reader draws a different conclusion about which component is most central, from a graph that gives no sign of being incomplete.

Point 4 is the reason I'm filing this rather than just changing my command. A silently incomplete graph that still answers questions is worse than one that fails.

Suggestions

Any one of these would have saved me the investigation:

  • When a module-level import target can't be resolved to a node in the corpus, count them and print a summary line, e.g. 197 import edge(s) unresolved — is the scan root the package root?
  • Detect a single src/ (or lib/) directory at the scan root containing the package and either descend or warn.
  • Document in the README that the scan root should be the directory imports resolve against, not necessarily the repository root.

Happy to test a patch against the real 34-file project if useful.

Environment: graphifyy 0.9.22, Python 3.14, macOS.

Activity

  1. Kyo14363 commented on Jul 21, 2026

    @Kyo14363

    Independent corroboration on a different OS and a different layout, plus two findings that extend this: the exact import form that gets dropped, and a downstream consequence on calls edges that makes affected return silent false negatives.

    Environment: graphifyy 0.9.22, Python 3.13.7, Windows 11. Layout is a monorepo (projects/<ticket>/<pkg>/…), not src-layout — so this isn't limited to a src/ directory.

    The discriminator: module-object import vs symbol import

    Your table shows imports -3 and imports_from +1, which I think is this mechanism. It can be isolated to two adjacent lines in a single file.

    pkg/capture.py:

    from pkg import gate              # module-object import
    from pkg.fetch import Fetcher     # symbol import
    
    
    def run():
        rows = Fetcher().get()
        return gate.validate(rows)    # call via module attribute

    with pkg/gate.py defining validate() and pkg/fetch.py defining Fetcher.

    Same four files, extracted twice, differing only in depth below the scan root (graphify extract . --code-only):

    edge flat (scan root = package parent) nested (projects/moneydj-capture/pkg/, scan root = repo top)
    capture --imports_from--> fetch present present
    run --calls--> Fetcher present present
    capture --imports_from--> gate present dropped
    run --calls--> gate.validate present dropped

    The symbol import and its call survive the nesting; the module-object import and its call do not. Node count is 10 in both runs, as you found.

    Downstream: the calls edge goes too, and affected then lies

    This is the part I'd flag as worse than the ranking distortion. Losing capture --imports--> gate also costs run --calls--> gate.validate, because the call is written gate.validate(...) and gate no longer resolves. The validate node survives, so nothing looks missing — it just has no inbound edge other than contains from its own file.

    Consequence on a real repo of mine (95 files, 749 nodes, 1287 edges, scanned from repo root):

    $ graphify affected "projects_moneydj_capture_etf_capture_gate_validate" --graph graph.json --depth 2
    Affected nodes for validate()
    Depth: 2
    No affected nodes found.
    

    Ground truth is two call sites — etf_capture/capture.py:81 and a verification script that imports the module and calls m_gate.validate(...). Asking at file level (…_etf_capture_gate) also returns No affected nodes found, despite from etf_capture import gate on line 12.

    142 of the 749 nodes (19%) ended up with zero inbound edges in that run.

    affected is the reverse-dependency/impact-analysis entry point, so a false negative reads as "nothing depends on this, safe to change." Unlike the god-nodes reordering, there's no plausible-but-wrong ranking to be suspicious of — the answer is empty and definitive.

    diagnose multigraph doesn't catch it

    Worth noting for the "no warning is emitted" point in your issue — the built-in diagnostic reports the degraded graph as fully clean:

    missing_endpoint_edges: 0
    dangling_endpoint_edges: 0
    same_endpoint_group_count: 0
    

    It checks for edge collapse, not edge absence, so running it after a degraded extraction actively increases confidence in the wrong direction.

    On your suggested fixes

    The unresolved-import counter would have caught this for me too. One addition: it would be worth counting unresolved call targets separately, since in this failure mode each dropped module import silently takes an unknown number of calls edges with it, and that count is what determines whether affected / path can be trusted.

    Happy to run a patch against either the 4-file MRE or the 95-file repo.

  2. safishamsi commented on Jul 21, 2026

    @safishamsi
    Member

    Fixed in v0.9.23: Python absolute imports now resolve against nested package roots (detected via the __init__.py chain) and import edges are repointed to the real file nodes, so a src-layout project yields the same imports/imports_from edges whether scanned from the repo root or from src/. https://github.com/Graphify-Labs/graphify/releases/tag/v0.9.23

  3. added a commit that references this issue on Jul 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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