Skip to content

AST extractor emits edges to node IDs it never creates, causing spurious GRAPH HEALTH WARNING on every JS/TS project #2265

Open

Description

@drmzperx

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:

  1. Materialise ref_* nodes in the TS/JS import extractor, as the JSON extractor already does — external dependencies become first-class nodes; or
  2. 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 WARNING is 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

  1. drmzperx commented on Jul 28, 2026

    @drmzperx
    Author

    Related: #2266 — exported consts with scalar initializers produce no node, which is a third contributor to the dangling-edge count reported here (the b --imports--> a_a_prefix case).

  2. udhawan97 commented on Jul 29, 2026

    @udhawan97

    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.

    AST layer: 1451 nodes, 3227 edges, 254 dangling · 81 distinct missing endpoints
      external (ref_vitest, svelte, ref_zod, ref_node_fs, …) : 46 ids · 178 edges  ← correct to drop
      in-repo ids the extractor never creates                : 35 ids ·  76 edges  ← this issue
      absolute-path artifacts                                :  0             ← fixed since 0.9.25 (#2195/#2262)
    

    The trigger is export const

    The 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:

    declaration node created?
    export function addSource() yes
    export class StorageConflictError yes
    export interface WorkspaceGraphNode yes
    export const maxSourceBytes = 2 * 1024 * 1024 no
    export const appBasePath = … no

    config/site.mjs is the cleanest case — seven export const declarations, and the extractor produces exactly one node for the whole file:

    nodes from config/site.mjs: ['config_site']
    

    …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:

      8x  packages_core_src_sources_import_maxsourcebytes    export const maxSourceBytes  (import.ts:16)
      7x  config_site_appbasepath                            export const appBasePath     (site.mjs:3)
      4x  packages_companion_src_version_companionversion    export const companionVersion (version.ts:1)
      3x  config_site_projectbasepath                        export const projectBasePath (site.mjs:2)
    

    A second, smaller variant: barrel re-exports

    packages_core_src_index_workspacegraphnode (5 edges) dangles differently — the node does exist, as packages_core_src_graph_workspace_graph_workspacegraphnode at its declaring file. The importer goes through the packages/core/src/index.ts barrel, 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 WARNING on every build. Worth noting the warning isn't purely cosmetic here: maxSourceBytes and companionVersion are 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.

  3. ayushcodes10 commented on Sep 17, 2026

    @ayushcodes10
    Contributor

    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.

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