Repository navigation
AST extractor emits edges to node IDs it never creates, causing spurious GRAPH HEALTH WARNING on every JS/TS project #2265
Description
AST extractor emits edges to node IDs it never creates, causing a spurious GRAPH HEALTH WARNING on every JS/TS project
Version: graphifyy 0.9.26
Environment: Python 3.12.13, Linux x86_64 (glibc 2.31)
Summary
graphify.extract.extract() emits edges whose endpoints are never added to nodes. Step 4.5's diagnose_extraction then reports them as dangling_endpoint_edges and prints:
GRAPH HEALTH WARNING: 47 dangling-endpoint edges; ... - graph may be incomplete/corrupt.
On a real 69-file Next.js/TypeScript repo this fired with 47 dangling edges, all with _origin: "ast" and none from semantic extraction. Since the trigger is ordinary third-party imports, this warning fires on essentially every JS/TS project, which trains users to ignore a diagnostic that is supposed to signal corruption.
There are two distinct causes.
Cause 1 — ref_<module> targets for external imports are never materialised (45/47)
Every import of a third-party package produces an imports_from edge to a ref_* target with no matching node:
app_api_channels_route --imports_from--> ref_next_server (no such node)
Observed targets included ref_next_server (9×), ref_react (7×), ref_vitest (6×), ref_next_navigation, ref_mysql2, ref_swr, ref_leaflet, ref_mqtt, ref_auth0_nextjs_auth0_server, ref_influxdata_influxdb_client.
This is inconsistent within graphify itself: the JSON/tsconfig extractor does materialise ref_* nodes. In the same build, the only 10 surviving ref_* nodes (ref_dom, ref_esnext, ref_ts, ref_tsx, ref_node_modules, ref_next_env_d_ts, …) all came from tsconfig.json. So one producer creates these nodes and another emits edges to them without creating them.
Cause 2 — some edge producers build IDs from the absolute path and keep the extension (2/47)
{"source": "app_layout",
"target": "home_ati_workspace_pushmesh_app_globals_css",
"relation": "imports_from", "source_file": "app/layout.tsx",
"source_location": "L3", "_origin": "ast"}Two defects in one ID: the path was not relativised against cache_root, and the extension was retained (globals.css → ..._globals_css). Every other node in the build used repo-relative, extension-stripped IDs. The reproducer below hits the same malformed-ID builder from a different producer (indirect_call), so it is not specific to CSS imports.
Reproducer
mkdir -p /tmp/gr && cd /tmp/gr
cat > a.ts <<'EOF'
import { useState } from 'react';
export const A_MAX = Number(process.env.A_MAX || 10);
export function useA() { return useState(A_MAX); }
EOF
cat > b.ts <<'EOF'
import { useA } from './a';
export const B = String(useA);
EOF
python -c "
from pathlib import Path
from graphify.extract import extract
sp = Path('/tmp/gr')
r = extract([sp/'a.ts', sp/'b.ts'], cache_root=sp)
ids = {n['id'] for n in r['nodes']}
print('NODES:', sorted(ids))
for e in r['edges']:
bad = '' if e['source'] in ids and e['target'] in ids else ' <== DANGLING'
print(f\" {e['source']} --{e['relation']}--> {e['target']}{bad}\")
"Actual output:
NODES: ['a', 'a_a_max', 'a_usea', 'b']
a --imports_from--> ref_react <== DANGLING
a --contains--> a_a_max
a --contains--> a_usea
b --imports_from--> a
b --imports--> a_usea
tmp_gr_b_ts --indirect_call--> a_usea <== DANGLING
Note tmp_gr_b_ts on the last edge: absolute path, extension kept — while the same file's node is b.
Expected
Either behaviour would be fine as long as it is consistent:
- Materialise
ref_*nodes in the TS/JS import extractor, as the JSON extractor already does — external dependencies become first-class nodes; or - Drop the edge when the target is an unresolved external module, so no dangling edge is produced.
Separately, the malformed-ID builder should relativise against cache_root and strip the extension like every other producer.
Impact
GRAPH HEALTH WARNINGis a false positive on every JS/TS repo, so a genuine integrity problem is indistinguishable from routine noise.- Third-party dependency edges are silently lost at build time (595 of 644 edges survived the build in the real repo).
Activity
drmzperx commentedon Jul 28, 2026
Related: #2266 — exported
consts with scalar initializers produce no node, which is a third contributor to the dangling-edge count reported here (theb --imports--> a_a_prefixcase).udhawan97 commentedon Jul 29, 2026
Reproduced on 0.9.29, and I think I can narrow the trigger.
Second datapoint for the report: Svelte 5 + TypeScript pnpm monorepo, 282 files (189 code · 67 doc · 26 image),
.ts/.svelte/.mjs.The trigger is
export constThe node emitter creates nodes for functions, classes, and interfaces, but not for
export const— while the edge emitter still resolves imports of those bindings to{module}_{symbol}. Every such edge dangles.Checked against the built graph:
export function addSource()export class StorageConflictErrorexport interface WorkspaceGraphNodeexport const maxSourceBytes = 2 * 1024 * 1024export const appBasePath = …config/site.mjsis the cleanest case — sevenexport constdeclarations, and the extractor produces exactly one node for the whole file:…while every importer emits an edge to
config_site_appbasepath,config_site_projectbasepath, etc. Nothing to land on.Top offenders in my corpus, all
export const:A second, smaller variant: barrel re-exports
packages_core_src_index_workspacegraphnode(5 edges) dangles differently — the node does exist, aspackages_core_src_graph_workspace_graph_workspacegraphnodeat its declaring file. The importer goes through thepackages/core/src/index.tsbarrel, and the edge is minted against the barrel's namespace rather than the declaring module's. Possibly the same root as #2230's batch-scoped symbol resolution.Impact
Same as reported —
GRAPH HEALTH WARNINGon every build. Worth noting the warning isn't purely cosmetic here:maxSourceBytesandcompanionVersionare real cross-module couplings that silently don't exist in the graph, so a "what depends on this constant?" query comes back empty rather than wrong. Config modules that are nothing but exported constants (a common shape) contribute a module node and no edges at all.Happy to test a patch against this repo if useful.
ayushcodes10 commentedon Sep 17, 2026
Checked both causes independently against current `v8`.
Cause 2 (malformed absolute-path/extension-kept IDs) is already fixed. Re-ran this issue's exact reproducer:
```
NODES: ['a', 'a_a_max', 'a_usea', 'b', 'b_b']
a --imports_from--> ref_react <== DANGLING
a --contains--> a_a_max
a --contains--> a_usea
b --imports_from--> a
b --imports--> a_usea
b --contains--> b_b
b --indirect_call--> a_usea
```
The `indirect_call` edge now correctly sources from `b` (the real node id) rather than the old `tmp_gr_b_ts` absolute-path-with-extension form.
Cause 1 (`ref_*` external-import targets counted as dangling by `diagnose_extraction`) is still live, but it's the same underlying concern as #2191, which already has an open PR (#3590, "separate external references from dangling edges"). Linking here so it doesn't get duplicated — once #3590 lands, this issue should be fully resolved.