mirror of
https://github.com/safishamsi/graphify.git
synced 2026-09-22 21:45:58 +00:00
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:
committed by
safishamsi
co-authored by
Claude Opus 4.8
parent
51b15a9ff1
commit
d68bf2819c
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user