From 1f435069ddbcbefd5fc29cab887cb2118549599f Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Thu, 17 Sep 2026 15:24:45 +0530 Subject: [PATCH 1/3] Emit a bare file node for skipped data JSON extract_json already built the file node before checking whether the document was a recognized config or manifest, but the skip branches threw that node away and returned an empty node list, so a data shaped JSON file such as an eval fixture or a parity corpus never appeared anywhere in the graph at all, not even as a leaf. Both skip paths now return the already built nodes list, which at that point holds exactly the one bare file node and nothing else: no per key traversal, no children, no edges. The file becomes discoverable through query, explain, and affected without reintroducing the orphan key node explosion the original skip was added to prevent. Co-Authored-By: Claude Sonnet 5 --- graphify/extractors/json_config.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/graphify/extractors/json_config.py b/graphify/extractors/json_config.py index e98666534f..84d6d282e4 100644 --- a/graphify/extractors/json_config.py +++ b/graphify/extractors/json_config.py @@ -229,12 +229,17 @@ def walk_object(obj_node, parent_nid: str, parent_key: str | None, if doc.type == "object": # Only AST-extract recognized config/manifest JSON. Data JSON (fixtures, # datasets, GeoJSON, API dumps) is skipped so it doesn't explode into - # orphan key-nodes (#1224); it's left to the LLM semantic pass. + # orphan key-nodes (#1224); it's left to the LLM semantic pass. `nodes` + # already holds the bare file node added above — returning it (rather + # than an empty list) keeps the file discoverable via query/explain/ + # affected without reintroducing the key-node explosion #1224 fixed + # (#2108): no children, no edges, just the one file node. if not _is_config_json(path, doc, source): - return {"nodes": [], "edges": [], "skipped": "data json (not a config/manifest)"} + return {"nodes": nodes, "edges": [], "skipped": "data json (not a config/manifest)"} walk_object(doc, file_nid, None, 0, [0]) else: # Top-level array or scalar => data JSON, never a config/manifest. - return {"nodes": [], "edges": [], "skipped": "data json (non-object root)"} + # Same bare-file-node rationale as above (#2108). + return {"nodes": nodes, "edges": [], "skipped": "data json (non-object root)"} return {"nodes": nodes, "edges": edges} From 05c600a83d56b2ebb3c25201fe23439ce1da20aa Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Thu, 17 Sep 2026 15:24:49 +0530 Subject: [PATCH 2/3] Add regression tests for the skipped data JSON file node Updates the two existing tests that pinned an empty node list for skipped data JSON to assert the new bare file node instead, and adds two more: the node looks like a normal file node (same id scheme, file_type code, no error key), and a small corpus of data files still gets exactly one node per file, never per key nodes. Co-Authored-By: Claude Sonnet 5 --- tests/test_extract.py | 44 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/tests/test_extract.py b/tests/test_extract.py index d7a263b8e2..7c1c37c297 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -3705,7 +3705,12 @@ def test_extract_json_no_self_loops(): # --------------------------------------------------------------------------- def test_extract_json_data_file_skipped(tmp_path): - """A data-shaped .json (eval fixture / dataset) must NOT emit per-key nodes.""" + """A data-shaped .json (eval fixture / dataset) must NOT emit per-key nodes. + + It still emits exactly one bare file node (#2108) — no children, no + edges — so the file stays discoverable via query/explain/affected + instead of being entirely absent from the graph. + """ data = tmp_path / "cases.json" data.write_text(json.dumps({ "generation": {"target": "gpt-4", "cases_file": "c.json", "num_cases": 12}, @@ -3713,20 +3718,51 @@ def test_extract_json_data_file_skipped(tmp_path): "suite": [{"name": "x"}, {"name": "y"}], })) result = extract_json(data) - assert result["nodes"] == [] + assert len(result["nodes"]) == 1, "must emit exactly the bare file node, no per-key nodes" + assert result["nodes"][0]["label"] == "cases.json" + assert result["nodes"][0]["source_file"] == str(data) assert result["edges"] == [] assert "skipped" in result def test_extract_json_top_level_array_skipped(tmp_path): - """A JSON file whose root is an array is data, never a config/manifest.""" + """A JSON file whose root is an array is data, never a config/manifest. + + Still emits exactly one bare file node (#2108), same as the object-root + data case. + """ data = tmp_path / "records.json" data.write_text(json.dumps([{"id": 1}, {"id": 2}])) result = extract_json(data) - assert result["nodes"] == [] + assert len(result["nodes"]) == 1, "must emit exactly the bare file node, no per-key nodes" + assert result["nodes"][0]["label"] == "records.json" assert result["edges"] == [] +def test_extract_json_data_file_node_is_a_real_file_node(tmp_path): + """#2108: the bare file node for a skipped data .json must look like every + other file node graphify emits — same id scheme, file_type "code" (matching + .json's CODE_EXTENSIONS classification in detect()), and no error key, so it + behaves like a normal corpus member rather than a special case.""" + data = tmp_path / "stateful_corpus.json" + data.write_text(json.dumps({"cases": [{"input": 1, "expected": 2}]})) + result = extract_json(data) + assert result["nodes"][0]["id"] == _make_id(str(data)) + assert result["nodes"][0]["file_type"] == "code" + assert "error" not in result + + +def test_extract_json_many_data_files_still_one_node_each(tmp_path): + """A corpus of several data .json files must stay #1224-safe: one bare file + node per file, never per-key nodes, regardless of how many files.""" + for i in range(5): + f = tmp_path / f"fixture_{i}.json" + f.write_text(json.dumps({"a": i, "b": {"c": i, "d": [1, 2, 3]}})) + result = extract_json(f) + assert len(result["nodes"]) == 1 + assert result["edges"] == [] + + def test_extract_json_config_by_filename_still_extracted(tmp_path): """tsconfig.json must still be AST-extracted even without telltale keys.""" cfg = tmp_path / "tsconfig.json" From 5bc0939527f6d06d7364f743d50fc528a1d7d8b9 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Thu, 17 Sep 2026 15:25:03 +0530 Subject: [PATCH 3/3] Add changelog entry for issue 2108 Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a0daf515f..99741bc83b 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.63 (2026-09-16) +- Fix: a data-shaped `.json` file (an eval fixture, a parity corpus, an i18n catalogue) that fails the config/manifest check is no longer entirely absent from the graph. `extract_json` already built the file node before deciding whether to AST-walk the document; the skip path discarded it and returned an empty node list, so the file never appeared anywhere — not reachable from `query`, `explain`, or `affected`. It now returns exactly that one bare file node (no per-key nodes, no edges), keeping the file discoverable without reintroducing the orphan key-node explosion #1224 fixed (#2108, thanks @dmitryvostryakov). + - Feature: Elixir `alias`/`import`/`require`/`use` targets now resolve onto the module's `defmodule` node across files, so the internal module dependency graph is no longer dropped as dangling. Only top-level modules are indexed (a nested `defmodule`, labeled with its bare inner name, cannot capture an unrelated `use ` from another file), and a same-file reference is left unresolved so it cannot clobber the structural `contains` edge (#3603, thanks @ayushcodes10). - Feature: a Rust `self.method()` call now resolves to a method defined on the same type in another file (the common split-`impl`-block layout), pooling methods across every `impl` of one type and refusing to link when two unrelated types share a bare name (#3602, thanks @ayushcodes10). - Feature: a Ruby member call `obj.foo` on a known-type receiver now resolves to a method `foo` inherited from a superclass, including across files, using the same conservative promotion as the implicit-self resolver — a single owning class, matching method kind, and one unambiguous ancestry chain, or it stays dangling (#3585, thanks @oleksii-tumanov).