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). 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} 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"