diff --git a/graphify/extractors/json_config.py b/graphify/extractors/json_config.py index 025ca20a8..90dab918e 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 walk_object(obj_node, parent_nid: str, parent_key: str | None, 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 walk_object(obj_node, parent_nid: str, parent_key: str | None, # 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 walk_object(obj_node, parent_nid: str, parent_key: str | None, 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 545a4f5d0..6247ec59c 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -6,6 +6,7 @@ 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