Skip to content

Phantom self-cycles: same-basename cross-extension re-exports (X.ts → ./X.mjs) collapse to one node #1814

Description

@Greg-Moskalenko

Version observed: graphify (PyPI graphifyy) 0.9.12

Summary

When a source file re-exports from a same-basename sibling with a different extension — e.g. foo.ts doing export { … } from "./foo.mjs" — graphify records a false self-loop (foo.ts → foo.ts) and reports it as a 1-file import cycle. The extension is stripped during edge resolution, so the re-export target ./foo.mjs is matched back to the foo.ts node instead of a distinct foo.mjs node.

Repro

mkdir demo && cd demo
# foo.mjs — hand-written plain-ESM runtime
printf 'export const N = 1;\n' > foo.mjs
# foo.ts — typed wrapper that re-exports the runtime
printf 'export { N } from "./foo.mjs";\n' > foo.ts
graphify update .

Then inspect:

graphify explain "foo.ts" --graph graphify-out/graph.json

Expected

Two nodes (foo.ts, foo.mjs) with one directed edge foo.ts —re_exports→ foo.mjs. No cycle.

Actual

One node whose id is the extension-stripped basename, with a self-edge:

--> foo.ts [re_exports]
<-- foo.ts [re_exports]

and foo.ts is listed under Import Cycles in GRAPH_REPORT.md. In the graph JSON the edge is relation: "re_exports" with source == target and a node id like …foo_ts_…foo (the .ts node id concatenated with the extension-stripped target).

Root cause (best guess)

Node identity for an import/re-export target is keyed by path with the extension stripped, so foo.ts and foo.mjs resolve to the same node id. The manifest does index .mjs/.d.mts as separate files — it's specifically the edge/import resolver's basename matching that collapses them.

Why the obvious workaround doesn't fit

This pattern is a legitimate, common one: a hand-written .mjs plain-ESM runtime (so it's importable by Node/CI without a TS build step) plus a thin typed .ts wrapper (export … from "./X.mjs") and a hand-written .d.mts. So:

  • Excluding dist/ / build/ does nothing — the files aren't build output.
  • Excluding **/*.d.mts doesn't remove the loop (the loop is .ts → .mjs).
  • Excluding **/*.mjs would delete real runtime source.

Suggested fix

Key nodes by full path including extension so foo.ts and foo.mjs are distinct nodes (then foo.ts → foo.mjs is a correct 2-node edge and the phantom cycle disappears). Equivalently: when a re-export specifier carries an explicit extension that resolves to a different physical file, don't strip it back to the same-basename sibling.

Impact

Over-reports import cycles for any codebase using the ESM-runtime + typed-wrapper convention (7 phantom cycles surfaced in a single app), which can mislead a cycle-cleanup pass into "fixing" non-problems.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions