diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cfafde..ac90d67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.47 (unreleased) +- Fix: `graphify update` / `save_manifest` no longer rewrites `manifest.json` timestamps on a no-op run, so `graphify-out/` stops showing as dirty (and stops producing a trailing graph commit) when nothing changed; a genuine change still updates and persists (#2838, thanks @hopstreax). - Fix: a C# 12 primary constructor's parameters are now walked, so `class Svc(IRepo repo)` emits the `references` edge to `IRepo` and calls through `repo` resolve — previously the class silently dropped its dependency; built-in and type-parameter types are not fabricated (#2829, thanks @brobl2008). - Fix: AST-derived INFERRED edges now carry a rubric `confidence_score` keyed to the relation (`uses` 0.95, `indirect_call`/unresolved cross-file `calls` 0.85) instead of landing at the rubric-forbidden 0.5 or a flat 0.8; the INFERRED default moves 0.5→0.55 so every score-less INFERRED edge is on the discrete rubric set (#2813, thanks @abhay-codes07). - Fix: a legacy `graph.json` that stored a numeric edge `confidence` (from a pre-enum version) no longer warns once per edge on every incremental reload; the numeric value is normalized to the `INFERRED` tag with the original float preserved in `confidence_score` (thanks @Trantor-develops). diff --git a/tests/test_detect.py b/tests/test_detect.py index 72235a3..b8833a6 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -3051,6 +3051,44 @@ def test_save_manifest_noop_skips_disk_write(tmp_path): assert mtime_1 == mtime_2 +def test_save_manifest_ast_kind_noop_then_change(tmp_path): + """#2838's literal path: `graphify update` calls save_manifest with kind='ast'. + A no-op re-run must leave the manifest byte-identical; a real edit must update it.""" + import json + a = tmp_path / "a.py" + a.write_text("x = 1\n", encoding="utf-8") + manifest_path = Path(tmp_path / "graphify-out" / "manifest.json") + + save_manifest({"code": [str(a)]}, str(manifest_path), root=tmp_path, kind="ast") + bytes_1 = manifest_path.read_bytes() + seen_1 = json.loads(bytes_1)["a.py"]["seen"] + + save_manifest({"code": [str(a)]}, str(manifest_path), root=tmp_path, kind="ast") # no-op + assert manifest_path.read_bytes() == bytes_1, "ast-kind no-op re-run churned the manifest" + + import time as _t; _t.sleep(0.01) + a.write_text("x = 2\n", encoding="utf-8") + save_manifest({"code": [str(a)]}, str(manifest_path), root=tmp_path, kind="ast") + raw2 = json.loads(manifest_path.read_text(encoding="utf-8")) + assert raw2["a.py"]["ast_hash"] != json.loads(bytes_1)["a.py"]["ast_hash"] + assert raw2["a.py"]["seen"] >= seen_1 + + +def test_save_manifest_corrupt_existing_manifest_still_writes(tmp_path): + """The byte-equality skip must never turn an unparseable on-disk manifest into + a silent no-write — a real update has to persist over corruption.""" + import json + a = tmp_path / "a.py" + a.write_text("x = 1\n", encoding="utf-8") + manifest_path = Path(tmp_path / "graphify-out" / "manifest.json") + manifest_path.parent.mkdir(parents=True, exist_ok=True) + manifest_path.write_text("{ this is not valid json", encoding="utf-8") + + save_manifest({"code": [str(a)]}, str(manifest_path), root=tmp_path) + raw = json.loads(manifest_path.read_text(encoding="utf-8")) # must parse now + assert "a.py" in raw and isinstance(raw["a.py"]["seen"], (int, float)) + + # ── #2106: sensitive-filter over-match (prose/source rescued, real secrets kept) ── @pytest.mark.parametrize("path", [