fix(json_config): stop emitting self-referential dep imports and array-as-extends edges

This commit is contained in:
prav committed 2026-09-04 00:59:48 +01:00
1 parent 33362d9692
commit 33f02c936b
2 files changed
+87 -4

No files matched your search

+28 -4
View File
@@ -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
+59
View File
@@ -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({