From 33f02c936bb1dcaeb99a2051a4372bdd3f68c54e Mon Sep 17 00:00:00 2001 From: prav Date: Sun, 30 Aug 2026 14:20:17 +0100 Subject: [PATCH] fix(json_config): stop emitting self-referential dep imports and array-as-extends edges --- graphify/extractors/json_config.py | 32 ++++++++++++++-- tests/test_extract.py | 59 ++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/graphify/extractors/json_config.py b/graphify/extractors/json_config.py index 6a9b641a9..e98666534 100644 --- a/graphify/extractors/json_config.py +++ b/graphify/extractors/json_config.py @@ -161,8 +161,14 @@ def extract_json(path: Path) -> dict: if val.type == "object": walk_object(val, key_nid, key, depth + 1, pair_count) - elif val.type == "array": - # For "extends" arrays (tsconfig, eslint): each string element. + elif val.type == "array" and key == "extends": + # Only a genuine `extends` array (tsconfig 5.0+, eslint) is + # inheritance. This branch used to fire for *every* array under + # a tracked key, so `compilerOptions.lib: ["dom","esnext"]` and + # `exclude: ["node_modules",".next"]` each emitted an `extends` + # edge — array membership relabelled as inheritance. The string + # branch below already gates on `key == "extends"`; this matches + # it. Non-`extends` arrays keep their `contains` edge only. # Prefix with "ref_" so external refs don't collide with real # code/file node IDs that share the same collapsed _make_id (J-4). for item in val.children: @@ -193,10 +199,28 @@ def extract_json(path: Path) -> dict: add_edge(parent_nid, ref_nid, "references", line) elif parent_key in _DEP_KEYS and val_text: - dep_nid = _make_id(key) + # Two defects fixed here, both from the #1764 shape: + # + # 1. The edge ran `key_nid -> dep_nid`, and both nodes carry + # the *same label* (the dependency name), so every + # dependency produced a visually self-referential + # `react --imports--> react` (22 of them on a mid-size + # Next.js repo). The self-loop guard in + # test_extract_json_import_and_extends_targets_are_real_nodes + # compares node ids, and these two ids differ, so the + # duplicate slipped past it. Sourcing the edge at the + # manifest yields `package.json --imports--> react`. + # + # 2. `_make_id(key)` minted a *bare* id, skipping the "ref" + # namespacing every other external reference in this file + # applies (J-4). A dependency named `utils`, `colors` or + # `types` could then be collapsed onto a same-named local + # module by build.py's alias index -- the #1638 failure + # mode reached through a different door. + dep_nid = _make_id("ref", key) if dep_nid: add_node(dep_nid, key, line, file_type="concept") - add_edge(key_nid, dep_nid, "imports", line, context="import") + add_edge(file_nid, dep_nid, "imports", line, context="import") # Entry: find root document → object doc = root diff --git a/tests/test_extract.py b/tests/test_extract.py index 09d9b5c93..14ff44092 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -3289,6 +3289,65 @@ def test_extract_json_extends_resolved(): assert extends_edges[0].get("context") == "import" +def test_extract_json_dependency_edges_are_not_label_self_loops(tmp_path): + """A dependency must not point at another node carrying its own label. + + test_extract_json_import_and_extends_targets_are_real_nodes already forbids + self-loops, but it compares node *ids*. The dependency branch minted a + second node for the same name under a different id, so every dependency + rendered as ``react --imports--> react`` -- a self-loop by label that an + id-based guard cannot see. + """ + package_json = tmp_path / "package.json" + package_json.write_text(json.dumps({ + "name": "demo", + "dependencies": {"react": "^19.0.0", "next": "^15.0.0"}, + "devDependencies": {"wrangler": "^4.71.0"}, + })) + + result = extract_json(package_json) + labels = {n["id"]: n["label"] for n in result["nodes"]} + offenders = [ + (labels.get(e["source"]), e["relation"], labels.get(e["target"])) + for e in result["edges"] + if e["relation"] == "imports" + and labels.get(e["source"]) == labels.get(e["target"]) + ] + assert offenders == [], f"label self-loop: {offenders}" + + # The dependency structure #1764 restored is still present. + imports = [e for e in result["edges"] if e["relation"] == "imports"] + assert {labels.get(e["target"]) for e in imports} == {"react", "next", "wrangler"} + + # External dependency ids stay "ref"-namespaced (J-4), so build.py's alias + # index cannot collapse a dep named `utils`/`colors` onto a local module. + assert all(e["target"].startswith("ref") for e in imports), [e["target"] for e in imports] + + +def test_extract_json_non_extends_arrays_are_not_inheritance(tmp_path): + """Only an ``extends`` array is inheritance. + + ``compilerOptions.lib`` and ``exclude`` are ordinary string lists; emitting + ``extends`` for them turns array membership into a supertype relation and + manufactures a hub out of a build-output glob. + """ + tsconfig = tmp_path / "tsconfig.json" + tsconfig.write_text(json.dumps({ + "extends": ["./base.json", "./strict.json"], + "compilerOptions": {"lib": ["dom", "esnext"]}, + "exclude": ["node_modules", ".next"], + })) + + result = extract_json(tsconfig) + labels = {n["id"]: n["label"] for n in result["nodes"]} + extends_targets = { + labels.get(e["target"]) for e in result["edges"] if e["relation"] == "extends" + } + assert extends_targets == {"./base.json", "./strict.json"} + for not_inheritance in ("dom", "esnext", ".next", "node_modules"): + assert not_inheritance not in extends_targets + + 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({