From d68bf2819c2735c403b06d0cec0bea5e8a1f9e23 Mon Sep 17 00:00:00 2001 From: oleksii-tumanov Date: Fri, 10 Jul 2026 10:53:58 +0100 Subject: [PATCH] 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) --- CHANGELOG.md | 2 ++ graphify/extractors/json_config.py | 7 +++-- tests/test_extract.py | 50 ++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 043594d5..a397bb1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/graphify/extractors/json_config.py b/graphify/extractors/json_config.py index 025ca20a..90dab918 100644 --- a/graphify/extractors/json_config.py +++ b/graphify/extractors/json_config.py @@ -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 diff --git a/tests/test_extract.py b/tests/test_extract.py index 545a4f5d..6247ec59 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -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