From 6eb7c014f4d0a28869231af21462bd2bde6f00df Mon Sep 17 00:00:00 2001 From: safishamsi Date: Wed, 1 Jul 2026 10:49:43 +0100 Subject: [PATCH] test(build): add regression tests for corrupt graph.json (#1537) #1537 shipped with a manual test checklist only. Add automated tests that corrupt a graph.json and assert the actionable RuntimeError at all three load paths (build_merge, affected.load_graph, diagnostics._read_json_file) plus a happy-path guard. Also record the six merged small fixes in the changelog. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 6 ++++ tests/test_corrupt_graph_json.py | 53 ++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 tests/test_corrupt_graph_json.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 74402f1..6deb034 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## Unreleased +- Fix: Ruby class inheritance now emits an `inherits` edge (#1535, thanks @Synvoya). `class Dog < Animal` produced `contains`/method/call edges but no `inherits` edge — the inheritance handler had branches for Java/Kotlin/C#/Scala/C++/PHP/Swift/Python but none for Ruby, so the `superclass` field was never read. Handles both bare (`< Animal`) and qualified (`< M::Base`) superclasses. +- Fix: Groovy `extends`/`implements` now emit `inherits`/`implements` edges (#1534, thanks @Synvoya). tree-sitter-groovy exposes inheritance through the same grammar shape as tree-sitter-java, but the handler was gated to Java only, so every Groovy inheritance relationship was dropped. +- Fix: corrupt `graph.json` now raises a clear, actionable error instead of a raw traceback (#1537 / #1536, thanks @guyoron1). The three graph-loading paths — `build_merge` (`--update`), `load_graph` (`graphify prs`), and diagnostics (`graphify diagnose`) — wrap `json.loads` and raise a `RuntimeError` with recovery guidance on a truncated/invalid file (incomplete write, power loss, manual edit). +- Fix: cross-chunk node-ID collisions now warn instead of silently dropping a node (#1508 / #1504, thanks @nuthalapativarun). When two nodes share an ID but come from different source files (two same-named files in different directories), dedup keeps the first and now prints a warning naming both files and how to avoid the loss (`graphify extract` per subfolder + `merge-graphs`). +- Fix: git hooks on Windows/MSYS default to sequential rebuilds (#1554, thanks @matiasduartee). Hook-triggered rebuilds now export `GRAPHIFY_MAX_WORKERS=1` on Windows/MSYS (explicit user value still wins), avoiding fragile inherited pipe handles; and the Windows-path hooks guard is a no-op on native Windows, where such paths are legitimate. +- Docs: correct the `deduplicate_by_label` docstring — it is dormant, not auto-called by `build()` (#1514, thanks @TPAteeq). The active dedup path is `deduplicate_entities`; the note that `deduplicate_by_label` runs automatically was never true, and it must not be enabled for code nodes (it merges by label with no file_type guard, conflating same-named symbols across files). - Feat: deterministic hub community labels, readable without an LLM (#1576, thanks @sheik-hiiobd). When no LLM backend is configured, community labels used to fall back to `Community 70`, making the report and its Suggested Questions unreadable. Each community is now named after its highest-degree member (the structural hub, ties broken by node id for run-to-run stability) — so a plain `graphify` run reads `auth` / `log_action` at zero token cost. A configured LLM naming pass still overrides these with richer names; `--no-label` still yields bare `Community N`. - Feat: extend `indirect_call` to `getattr(obj, "name")` reflective dispatch (#1575, #1566 slice 3, thanks @sheik-hiiobd). A callable looked up by a string literal — `fn = getattr(obj, "handler")` — now emits an `indirect_call` edge (context `getattr`, INFERRED) so `affected` reaches it. Only a plain string literal resolves; a variable, f-string, or concatenation is dynamic and emits nothing. Unlike the identifier paths, a getattr string names an attribute, not a binding, so it is never shadowed by a param/local — `def via(handler): getattr(x, "handler")` still resolves to the module `handler`. Function and module scope; cross-file handled by the shared resolver. Python only for now. - Fix: `graphify --update` no longer drops hyperedges from unchanged files (#1574, thanks @socar-tender). `build_merge` read only nodes and edges from the existing `graph.json`, never hyperedges — so every incremental update collapsed the graph's hyperedge set (the semantic domain-flow groupings) down to just the re-extracted files'. Existing hyperedges are now carried forward: re-extracted files' prior hyperedges are replaced by their new version (by `source_file`), deleted files' are pruned, and the rest are preserved with id-dedup — mirroring how `watch` already handled it. diff --git a/tests/test_corrupt_graph_json.py b/tests/test_corrupt_graph_json.py new file mode 100644 index 0000000..7f2258c --- /dev/null +++ b/tests/test_corrupt_graph_json.py @@ -0,0 +1,53 @@ +"""Corrupt graph.json produces an actionable error, not a raw traceback (#1536/#1537). + +Three load paths call json.loads on graph.json — build_merge (`--update`), +affected.load_graph (`graphify prs`), and diagnostics._read_json_file +(`graphify diagnose`). A truncated / invalid file (incomplete write, power loss, +manual edit) must raise a clear RuntimeError with recovery guidance at each. +""" +from __future__ import annotations + +import pytest + +from graphify.build import build_merge +from graphify.affected import load_graph +from graphify.diagnostics import _read_json_file + +_CORRUPT = '{"nodes": [{"id": "a", "labe' # truncated mid-object + + +def _corrupt(tmp_path): + p = tmp_path / "graph.json" + p.write_text(_CORRUPT, encoding="utf-8") + return p + + +def test_build_merge_corrupt_graph_raises_runtimeerror(tmp_path): + p = _corrupt(tmp_path) + with pytest.raises(RuntimeError, match=r"Cannot read .*incremental merge|rebuild"): + build_merge([], graph_path=p, dedup=False) + + +def test_affected_load_graph_corrupt_raises_runtimeerror(tmp_path): + p = _corrupt(tmp_path) + with pytest.raises(RuntimeError, match=r"Cannot read graph file|regenerate"): + load_graph(p) + + +def test_diagnostics_read_corrupt_raises_runtimeerror(tmp_path): + p = _corrupt(tmp_path) + with pytest.raises(RuntimeError, match=r"Cannot parse|corrupted"): + _read_json_file(p) + + +def test_valid_graph_still_loads(tmp_path): + """Happy path unchanged: a well-formed graph.json loads without raising.""" + p = tmp_path / "graph.json" + p.write_text( + '{"nodes": [{"id": "a", "label": "a", "file_type": "code"}], "edges": []}', + encoding="utf-8", + ) + # none of these should raise + load_graph(p) + _read_json_file(p) + build_merge([], graph_path=p, dedup=False)