Summary
On 0.9.25, node ids for the targets of relative imports that cross directories are still built from the absolute resolved path, while the same node's source_file is correctly relativized. This is the same class as #1789 / #1899 (Variant B: relativized source_file but an id derived from the absolute path) and #1961, all closed, but with a trigger those did not cover: in-root relative import targets in an Astro plus TypeScript project (../../lib/content, ../../config, ../styles/global.css).
Because the importing file also produces a correctly named node for the same target, the result is a ghost duplicate: src_lib_content and users_<name>_..._<repo>_src_lib_content both exist in the graph.
Environment
- graphify 0.9.25 (installed via
uv tool, Python 3.14, macOS)
- AST-only extraction (no LLM), undirected build
- Project: Astro site. Pages under
src/pages/** and src/layouts/Base.astro import ../../lib/content, ../../config, and ../styles/global.css.
Observed
Three import-target nodes carry an id derived from the absolute path, with a correct repo-relative source_file:
id: users_<name>_startups_<...>_<repo>_src_lib_content
source_file: src/lib/content
label: ../../lib/content
id: users_<name>_..._<repo>_src_config
source_file: src/config
id: users_<name>_..._<repo>_src_styles_global_css
source_file: src/styles/global.css
For src_lib_content and src_config, a correctly named node with the same repo-relative id already exists in the graph (the file's own node), so the absolute-id node is a pure duplicate. The absolute-id nodes are also the endpoints of imports_from edges, so on the post-build graph they show up as dangling endpoints (as in #1961).
Reproduction
Minimal shape: a file that imports another in-root file by a relative path that ascends at least one directory.
src/lib/content.ts
src/pages/work/index.astro -> import { ... } from '../../lib/content'
Then:
from pathlib import Path
from graphify.extract import extract
res = extract([Path('src/pages/work/index.astro'), Path('src/lib/content.ts')], cache_root=Path('.'))
print([n['id'] for n in res['nodes'] if 'lib_content' in n['id']])
# -> ['src_lib_content', 'users_<name>_..._src_lib_content'] # duplicate, second is absolute
Notes:
- Deterministic. It reproduces whether the paths passed to
extract are absolute or relative to the scan root.
- It recurs through the post-commit hook.
graphify.watch._rebuild_code(root, changed_paths=[that page]) regenerates the absolute-id node on every incremental rebuild of a file that has such an import, so a graph that was sanitized and committed gets re-poisoned the next time one of those code files changes.
Expected
The import-target node id should be relativized against the scan root before slugifying, the same way source_file already is, and should reuse the existing repo-relative id for the target file rather than minting a second node. When relative_to(root) cannot apply, fall back to the module specifier (the import string) rather than the absolute filesystem path.
Consequences
Workaround in use
A pre-commit hook strips any node id that embeds an absolute path back to its repo-relative form and merges the duplicate into its canonical twin before commit, so the tracked graph stays clean. Happy to share the script if useful.
Summary
On 0.9.25, node ids for the targets of relative imports that cross directories are still built from the absolute resolved path, while the same node's
source_fileis correctly relativized. This is the same class as #1789 / #1899 (Variant B: relativizedsource_filebut an id derived from the absolute path) and #1961, all closed, but with a trigger those did not cover: in-root relative import targets in an Astro plus TypeScript project (../../lib/content,../../config,../styles/global.css).Because the importing file also produces a correctly named node for the same target, the result is a ghost duplicate:
src_lib_contentandusers_<name>_..._<repo>_src_lib_contentboth exist in the graph.Environment
uv tool, Python 3.14, macOS)src/pages/**andsrc/layouts/Base.astroimport../../lib/content,../../config, and../styles/global.css.Observed
Three import-target nodes carry an id derived from the absolute path, with a correct repo-relative
source_file:For
src_lib_contentandsrc_config, a correctly named node with the same repo-relative id already exists in the graph (the file's own node), so the absolute-id node is a pure duplicate. The absolute-id nodes are also the endpoints ofimports_fromedges, so on the post-build graph they show up as dangling endpoints (as in #1961).Reproduction
Minimal shape: a file that imports another in-root file by a relative path that ascends at least one directory.
Then:
Notes:
extractare absolute or relative to the scan root.graphify.watch._rebuild_code(root, changed_paths=[that page])regenerates the absolute-id node on every incremental rebuild of a file that has such an import, so a graph that was sanitized and committed gets re-poisoned the next time one of those code files changes.Expected
The import-target node id should be relativized against the scan root before slugifying, the same way
source_filealready is, and should reuse the existing repo-relative id for the target file rather than minting a second node. Whenrelative_to(root)cannot apply, fall back to the module specifier (the import string) rather than the absolute filesystem path.Consequences
graph.jsonleaks the local username and home directory layout (same as Node IDs for file / directory / project nodes embed the absolute scan path (leaks local username, makes committed graph.json non-portable / non-idempotent) #1789).graphify updateor the post-commit hook rebuild from another clone re-creates these nodes (diff churn, non-idempotent versioned graphs).Workaround in use
A
pre-commithook strips any node id that embeds an absolute path back to its repo-relative form and merges the duplicate into its canonical twin before commit, so the tracked graph stays clean. Happy to share the script if useful.