From 355c2b538baa1942a5cae32f62b44ac58f265dfd Mon Sep 17 00:00:00 2001 From: SrijanSriv Date: Mon, 5 Oct 2026 20:22:08 +0530 Subject: [PATCH] test(export): refuse a lost rationale id when the node total grows A larger total used to make to_json accept a write that dropped a saved rationale id. The new stub keeps the semantic count flat, so a count check would miss the same loss. --- tests/test_export.py | 53 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/test_export.py b/tests/test_export.py index ce405ec7b..d8c26d9cb 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -1045,6 +1045,59 @@ def test_to_json_refuses_shrink(tmp_path): assert to_json(_mkG(2), {}, str(p), force=True) is True # force overrides +def test_to_json_refuses_lost_semantic_id_when_total_grows(tmp_path, capsys): + """#2229: AST growth and a new stub must not replace a saved rationale id.""" + import networkx as nx + + saved_nodes = [ + {"id": "app_ts", "label": "app.ts", "source_file": "src/app.ts", "_origin": "ast", "source_location": "L1", "file_type": "code"}, + {"id": "app_ts_main", "label": "main", "source_file": "src/app.ts", "_origin": "ast", "source_location": "L2", "file_type": "code"}, + {"id": "rationale_kept", "label": "kept", "source_file": "src/app.ts", "_origin": "semantic", "source_location": None, "file_type": "rationale"}, + {"id": "rationale_lost", "label": "lost", "source_file": "src/app.ts", "_origin": "semantic", "source_location": None, "file_type": "rationale"}, + ] + new_nodes = [ + {"id": "app_ts", "label": "app.ts", "source_file": "src/app.ts", "_origin": "ast", "source_location": "L1", "file_type": "code"}, + {"id": "app_ts_main", "label": "main", "source_file": "src/app.ts", "_origin": "ast", "source_location": "L2", "file_type": "code"}, + {"id": "app_ts_hook", "label": "hook", "source_file": "src/app.ts", "_origin": "ast", "source_location": "L10", "file_type": "code"}, + {"id": "app_ts_route", "label": "route", "source_file": "src/app.ts", "_origin": "ast", "source_location": "L20", "file_type": "code"}, + {"id": "app_ts_util", "label": "util", "source_file": "src/app.ts", "_origin": "ast", "source_location": "L30", "file_type": "code"}, + {"id": "rationale_kept", "label": "kept", "source_file": "src/app.ts", "_origin": "semantic", "source_location": None, "file_type": "rationale"}, + {"id": "pathlib_Path", "label": "Path", "source_file": "", "source_location": None, "file_type": "concept"}, + ] + p = tmp_path / "graph.json" + p.write_text(json.dumps({"nodes": saved_nodes, "links": []}), encoding="utf-8") + G = nx.Graph() + for node in new_nodes: + G.add_node(node["id"], **{k: v for k, v in node.items() if k != "id"}) + assert G.number_of_nodes() > len(saved_nodes) + assert to_json(G, {}, str(p), force=False) is False + written = json.loads(p.read_text(encoding="utf-8")) + assert "rationale_lost" in {n["id"] for n in written["nodes"]} + assert "rationale_lost" in capsys.readouterr().err + + +def test_to_json_allows_ast_growth_that_keeps_semantic_ids(tmp_path): + """AST nodes may be added when every saved rationale id is still present.""" + import networkx as nx + + saved_nodes = [ + {"id": "app_ts", "label": "app.ts", "source_file": "src/app.ts", "_origin": "ast", "source_location": "L1", "file_type": "code"}, + {"id": "rationale_kept", "label": "kept", "source_file": "src/app.ts", "_origin": "semantic", "source_location": None, "file_type": "rationale"}, + {"id": "rationale_lost", "label": "lost", "source_file": "src/app.ts", "_origin": "semantic", "source_location": None, "file_type": "rationale"}, + ] + new_nodes = saved_nodes + [ + {"id": "app_ts_hook", "label": "hook", "source_file": "src/app.ts", "_origin": "ast", "source_location": "L10", "file_type": "code"}, + ] + p = tmp_path / "graph.json" + p.write_text(json.dumps({"nodes": saved_nodes, "links": []}), encoding="utf-8") + G = nx.Graph() + for node in new_nodes: + G.add_node(node["id"], **{k: v for k, v in node.items() if k != "id"}) + assert to_json(G, {}, str(p), force=False) is True + written = json.loads(p.read_text(encoding="utf-8")) + assert {"rationale_kept", "rationale_lost"} <= {n["id"] for n in written["nodes"]} + + def test_to_json_fails_safe_on_corrupt_existing(tmp_path): """A non-empty but unparseable existing graph.json (corrupt or mid-write) must NOT be silently overwritten — we can't verify the new graph isn't a