fix(extract): create json_config reference target nodes (#1764)

extract_json emitted `imports` edges for package.json dependencies and
`extends`/`$ref` edges for tsconfig.json to target ids (`_make_id("ref", ...)`
/ `_make_id(key)`) that it never created as nodes. build_from_json drops edges
to unknown node ids silently (that case is filtered out of real_errors), so
dependency and extends structure vanished from the graph on two of the most
common files in any JS/TS repo, surfaced only by diagnose_extraction after the
fact.

The extractor now adds the referenced target as a `concept` node (external ref,
not a corpus file) before emitting each edge, so the edges survive build.
Regression test asserts no dangling endpoints, the concept nodes exist, and the
import/extends edges land on real targets with no self-loops.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
oleksii-tumanov
2026-07-10 10:53:58 +01:00
committed by safishamsi
co-authored by Claude Opus 4.8
parent 51b15a9ff1
commit d68bf2819c
3 changed files with 57 additions and 2 deletions
+2
View File
@@ -4,6 +4,8 @@ Full release notes with details on each version: [GitHub Releases](https://githu
## 0.9.12 (unreleased)
- Fix: `json_config` no longer emits `imports`/`extends` edges to node IDs it never creates (#1764, thanks @oleksii-tumanov). `package.json` dependencies and `tsconfig.json` `extends`/`$ref` targets produced edges whose endpoint node was absent, so `build_from_json` silently dropped them (the "no matching node id" case is filtered out of real errors) — losing dependency/extends structure on two of the most common files in any JS/TS repo. The extractor now creates the referenced target as a `concept` node before adding the edge.
- Fix: `graphify update` no longer deletes semantic hyperedges on every run (#1755, thanks @oleksii-tumanov). The AST-only rebuild treated every rebuilt corpus file as grounds to evict hyperedges anchored to it, but the AST pass never re-emits hyperedges, so doc-sourced hyperedges (exactly what semantic extraction produces) were permanently lost on the first `update` after a full build — even a no-op run. Hyperedge eviction is now scoped to genuinely deleted (or symlink-outside) sources, mirroring node/edge handling; replacement-by-id and dangling-member cleanup are unchanged.
- Fix: Java member calls resolve against the receiver's declared type instead of a bare method-name match (#1696/#1697, thanks @oleksii-tumanov). `gw.charge()` where `gw: PaymentGateway` now binds to `PaymentGateway.charge`, not a same-named `AuditLog.charge` in another file. Explicit-type receivers and `this` are exact; current-class fields, method parameters, and explicitly-typed locals resolve via a method-scoped type table; a missing, ambiguous, inherited, or chained receiver is skipped rather than guessed (same god-node guard as the C#/Swift/Ruby resolvers). Fully-qualified and nested-type receivers are deferred (they need package/nesting-aware type identity).
+5 -2
View File
@@ -90,10 +90,10 @@ def extract_json(path: Path) -> dict:
"optionalDependencies", "bundleDependencies", "bundledDependencies",
})
def add_node(nid: str, label: str, line: int) -> None:
def add_node(nid: str, label: str, line: int, file_type: str = "code") -> None:
if nid and nid not in seen_ids:
seen_ids.add(nid)
nodes.append({"id": nid, "label": label, "file_type": "code",
nodes.append({"id": nid, "label": label, "file_type": file_type,
"source_file": str_path, "source_location": f"L{line}"})
def add_edge(src: str, tgt: str, relation: str, line: int,
@@ -165,6 +165,7 @@ def extract_json(path: Path) -> dict:
if ref:
ref_nid = _make_id("ref", ref)
if ref_nid:
add_node(ref_nid, ref, line, file_type="concept")
add_edge(key_nid, ref_nid, "extends", line, context="import")
elif val.type == "string":
@@ -175,6 +176,7 @@ def extract_json(path: Path) -> dict:
# Namespace external refs to avoid ID collision with file nodes (J-4)
ref_nid = _make_id("ref", val_text)
if ref_nid:
add_node(ref_nid, val_text, line, file_type="concept")
add_edge(file_nid, ref_nid, "extends", line, context="import")
elif key == "$ref" and val_text:
@@ -186,6 +188,7 @@ def extract_json(path: Path) -> dict:
elif parent_key in _DEP_KEYS and val_text:
dep_nid = _make_id(key)
if dep_nid:
add_node(dep_nid, key, line, file_type="concept")
add_edge(key_nid, dep_nid, "imports", line, context="import")
# Entry: find root document → object
+50
View File
@@ -6,6 +6,7 @@ from pathlib import Path
import pytest
from graphify.build import build_from_json
from graphify.extract import extract_python, extract, collect_files, _make_id, extract_bash, extract_json, _DISPATCH
FIXTURES = Path(__file__).parent / "fixtures"
@@ -1468,6 +1469,55 @@ def test_extract_json_extends_resolved():
assert extends_edges[0].get("context") == "import"
def test_extract_json_import_and_extends_targets_are_real_nodes(tmp_path):
package_json = tmp_path / "package.json"
package_json.write_text(json.dumps({
"name": "demo",
"dependencies": {"left-pad": "^1.3.0"},
"devDependencies": {"bats": "^1.11.0"},
}))
tsconfig = tmp_path / "tsconfig.json"
tsconfig.write_text(json.dumps({
"extends": "./tsconfig.base.json",
"compilerOptions": {"strict": True},
}))
results = [extract_json(package_json), extract_json(tsconfig)]
combined = {
"nodes": [node for result in results for node in result["nodes"]],
"edges": [edge for result in results for edge in result["edges"]],
}
node_ids = {node["id"] for node in combined["nodes"]}
dangling = [
edge for edge in combined["edges"]
if edge["source"] not in node_ids or edge["target"] not in node_ids
]
assert dangling == []
assert {"left-pad", "bats", "./tsconfig.base.json"} <= {
node["label"] for node in combined["nodes"] if node["file_type"] == "concept"
}
extracted = extract([package_json, tsconfig], cache_root=tmp_path, parallel=False)
graph = build_from_json(extracted, directed=True)
import_targets = {
graph.nodes[data["_tgt"]]["label"]
for _, _, data in graph.edges(data=True)
if data.get("relation") == "imports"
}
extends_targets = {
graph.nodes[data["_tgt"]]["label"]
for _, _, data in graph.edges(data=True)
if data.get("relation") == "extends"
}
self_loops = [
data for _, _, data in graph.edges(data=True)
if data.get("relation") in {"imports", "extends"} and data["_src"] == data["_tgt"]
]
assert self_loops == []
assert {"left-pad", "bats"} <= import_targets
assert extends_targets == {"./tsconfig.base.json"}
def test_extract_json_large_file_skipped(tmp_path):
big = tmp_path / "big.json"
# Write a JSON file just over 1 MiB