Skip to content

merge-graphs: silently collapses nodes with the same stem from different source graphs (false cross-runtime edges in v0.9.10) #1729

Description

@fisapool

graphify merge-graphs collapses nodes with the same stem across different source graphs — produces false cross-runtime edges (v0.9.10)

Severity: High — produces confidently-wrong answers from graphify path queries. The user has no way to detect the silent data loss.

Affects: graphify merge-graphs on v0.9.10 (and likely earlier; related fixes in #1504, #1508, #1158 addressed adjacent cases but not this one).

Summary

When two separately-built graphs both contain a node with the same bare stem (e.g., a backend src/app.js and a frontend App.jsx that both produce a bare app node ID), graphify merge-graphs collapses them into a single node. The result is:

  • One node where there should be two distinct entities from different runtimes/repos
  • The node's label and source_file come from one graph (in my case, the second graph won the label), but the edges come from both graphs
  • graphify path then returns plausible-looking paths that cross runtime boundaries incorrectly
  • No warning is printed (despite fix(dedup): warn on cross-chunk node ID collision to surface silent data loss #1508 adding a cross-chunk collision warning, the merge-graphs path doesn't trigger it)

Related closed issues (not quite the same)

What's still broken: collisions between two separate graphs being combined via merge-graphs. The dedup warning from #1508 doesn't fire here, and the merge silently keeps one node's metadata while attaching edges from both.

Real-world trigger

I hit this building a cross-process knowledge graph for a project that has a backend src/app.js (Express) and a frontend App.jsx (React entry). The two files produce bare app node IDs in their respective builds:

src/ graph (AST only, 1894 nodes):
  id=app                            | label=app.js               | source=app.js
  id=app_routes                     | label=routes               | source=app.js
  ...

frontend/src/ graph (AST + LLM, 492 nodes):
  id=app                            | label=App.jsx              | source=App.jsx
  id=app_app                        | label=App()                | source=App.jsx
  ...

Reproduction (real-world evidence)

Run from /Users/arif/irmaya/:

graphify merge-graphs \
  src/graphify-out/graph.json \
  frontend/src/graphify-out/graph.json \
  --out graphify-out/merged-graph.json

Output: Merged 2 graphs -> 2384 nodes, 3968 edges (expected 2386 if no collision)

The merged graph contains a single src::app node with:

attribute value source
id src::app prefix from first input
label App.jsx from second input (frontend)
source_file App.jsx from second input (frontend)

But it has 82+ edges from BACKEND source files (app.js:L1…L43, etc.) and 11 edges from FRONTEND source files (App.jsx:L2…L7). It is a Frankenstein node.

The false-positive impact

graphify path "apiClient" "appointments.js" on the merged graph returns:

Shortest path (4 hops):
  apiClient <--contains-- api.js <--imports_from-- BotPanel.jsx
  <--imports_from-- Dashboard.jsx <--imports_from-- App.jsx
  --imports_from--> appointments.js

The user reads this as: "the frontend App.jsx imports from the backend routes/appointments.js" — but the front and back ends run in different runtimes (Vite/React vs Node/Express), so no such import can exist. The path LOOKS valid because the AST edge from src/app.js:13 (const appointmentsRoutes = require('./routes/appointments')) is attached to the frontend App.jsx node via the merge.

The real wiring is by string convention ('/api/appointments') and isn't represented in the graph at all.

Smaller reproduction (in a clean /tmp working dir)

mkdir -p repro/src/routes repro/frontend
cat > repro/src/app.js <<'EOF'
const routes = require('./routes/appointments');
const app = { use: () => {}, _routes: routes };
module.exports = app;
EOF
cat > repro/src/routes/appointments.js <<'EOF'
module.exports = { post: () => {} };
EOF
cat > repro/frontend/App.jsx <<'EOF'
import api from './api';
function App() { return null; }
export default App;
EOF
cat > repro/frontend/api.js <<'EOF'
const api = { list: () => fetch('/api/appointments') };
export default api;
EOF

cd repro
graphify src/         # 2 nodes, 1 edge
graphify frontend/    # 4 nodes, 4 edges
graphify merge-graphs src/graphify-out/graph.json frontend/graphify-out/graph.json --out merged.json
# → 6 nodes, 5 edges  (no collision in this minimal case; the bug needs scale)

In this minimal repro the merge correctly gives src::app (backend) and frontend::app (frontend) as separate nodes. The bug only triggers in the real Irmaya case — likely some interaction with the LLM-augmented graph or graph size. I haven't isolated the exact trigger condition beyond "≥ 2 graphs, ≥ ~500 nodes each, ≥ 1 LLM-derived node with a bare stem."

Even simpler: the prefix is wrong when run from a different cwd

Same two graphs, but merging from /tmp instead of from the project root:

cd /tmp
graphify merge-graphs \
  /Users/arif/irmaya/src/graphify-out/graph.json \
  /Users/arif/irmaya/frontend/src/graphify-out/graph.json \
  --out /tmp/merged.json
# → nodes have id=::app, id=::app_express, etc.  (empty namespace!)

The namespace prefix depends on the current working directory or the --out path, not on each input graph's source. Two merges of the same two files can produce different namespace prefixes.

Suggested fix

  1. Don't silently collapsemerge-graphs should either:
    • Refuse to merge graphs that would produce colliding IDs (error out, point to the conflict), or
    • Add a namespace prefix derived from each input's source directory (e.g. src/app.jssrc::app, frontend/src/App.jsxfrontend_src_App::app or similar), or
    • Auto-prefix with --as <tag> (the flag already exists per graphify help merge-graphs)
  2. Add a warning when collapsing — at minimum, the dedup-style warning from fix(dedup): warn on cross-chunk node ID collision to surface silent data loss #1508 should fire here too. The user should see WARNING: nodes 'app' from 'app.js' and 'app' from 'App.jsx' collapsed into one (different source files). Currently nothing.
  3. Use the --as <tag> flag consistently — the CLI already documents --as <tag> repo tag for --global (default: target directory name), but merge-graphs doesn't apply this to per-input namespacing.

Workaround (what I'm doing)

  • Don't use graphify path on the merged graph for cross-runtime (frontend ↔ backend) queries.
  • For cross-process wiring, use graphify only within a single runtime (src/graphify-out/graph.json for backend, frontend/src/graphify-out/graph.json for frontend), or fall back to grep for the string-based URL convention.
  • The graph.html viz is still useful for visual exploration since the source paths are visible.

Environment

  • graphifyy v0.9.10 (via uv tool install)
  • Python 3.12 (uv-managed)
  • macOS 25.5.0 (arm64)
  • Backend graph: pure AST extraction on src/
  • Frontend graph: graphify frontend/src/ --backend=openai --mode deep with local Ollama (qwen3-vl:30b) for the LLM semantic pass

Artifacts

The actual buggy graphs are at /Users/arif/irmaya/:

  • src/graphify-out/graph.json — backend (clean)
  • frontend/src/graphify-out/graph.json — frontend (clean)
  • graphify-out/graph.json — merged (bug visible: src::app with mixed runtime edges)

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