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.
Version observed: graphify (PyPI
graphifyy) 0.9.12Summary
When a source file re-exports from a same-basename sibling with a different extension — e.g.
foo.tsdoingexport { … } 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.mjsis matched back to thefoo.tsnode instead of a distinctfoo.mjsnode.Repro
Then inspect:
Expected
Two nodes (
foo.ts,foo.mjs) with one directed edgefoo.ts —re_exports→ foo.mjs. No cycle.Actual
One node whose id is the extension-stripped basename, with a self-edge:
and
foo.tsis listed under Import Cycles inGRAPH_REPORT.md. In the graph JSON the edge isrelation: "re_exports"withsource == targetand a node id like…foo_ts_…foo(the.tsnode 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.tsandfoo.mjsresolve to the same node id. The manifest does index.mjs/.d.mtsas 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
.mjsplain-ESM runtime (so it's importable by Node/CI without a TS build step) plus a thin typed.tswrapper (export … from "./X.mjs") and a hand-written.d.mts. So:dist//build/does nothing — the files aren't build output.**/*.d.mtsdoesn't remove the loop (the loop is.ts → .mjs).**/*.mjswould delete real runtime source.Suggested fix
Key nodes by full path including extension so
foo.tsandfoo.mjsare distinct nodes (thenfoo.ts → foo.mjsis 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.