Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Name>` 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).
Expand Down
11 changes: 8 additions & 3 deletions graphify/extractors/json_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
44 changes: 40 additions & 4 deletions tests/test_extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -3705,28 +3705,64 @@ 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},
"prompt_inputs_spec": {"a": 1, "b": 2},
"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"
Expand Down
Loading