diff --git a/CHANGELOG.md b/CHANGELOG.md index c82baccf9..ee51fd3a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) +## Unreleased + +- Fix: the CLI `graphify affected` and `graphify god-nodes` verbs now count as orientation, exactly like `query` / `path` / `explain`: a successful run refreshes the strict read hook's "recently oriented" stamp (`graphify-out/cache/last_query_stamp`) and writes a query-ledger line (kind `affected` with the impacted-node count, kind `god_nodes` with the returned-hub count; both fail-silent, and the ledger stays opt-in via the usual `GRAPHIFY_QUERY_LOG*` gates). Before this, an agent that oriented through either verb was still treated as blind by the strict guard — its next raw read denied after it had already consulted the graph — and left no trace in the query audit trail. Orientation is orientation regardless of which door it comes through; the MCP tools' missing stamp is the same defect class, reported separately in #3039 / fixed by #3042. + ## 0.9.50 (2026-08-25) - Fix: Ruby methods whose names end in `!`, `?`, or `=` now keep distinct node ids, so `save` and `save!` (or `foo` and `foo=`) no longer collide into one node; the label keeps the raw spelling and member-call resolution still matches (#3077, thanks @hopstreax). diff --git a/graphify/cli.py b/graphify/cli.py index bd2956815..0dd1f0a78 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -1245,15 +1245,31 @@ def dispatch_command(cmd: str) -> None: # --graph falls back to its own directory. from graphify.paths import GRAPHIFY_OUT_NAME graph_root = gp.parent.parent if gp.parent.name == GRAPHIFY_OUT_NAME else gp.parent - print( - format_affected( - graph, - query, - relations=relations or DEFAULT_AFFECTED_RELATIONS, - depth=depth, - root=graph_root, - ) + import time as _time + from graphify import querylog + _t0 = _time.perf_counter() + _out = format_affected( + graph, + query, + relations=relations or DEFAULT_AFFECTED_RELATIONS, + depth=depth, + root=graph_root, ) + # Orientation is orientation regardless of door (#3042): `affected` + # answers a graph question exactly like `query`/`path`/`explain`, so a + # successful run gets the same querylog line and strict-guard stamp. + # Each impacted node renders as exactly one "- " line (no header line + # starts with "- "), so the count is exact; both calls are fail-silent. + querylog.log_query( + kind="affected", + question=query, + corpus=str(gp), + nodes_returned=sum(1 for _l in _out.splitlines() if _l.startswith("- ")), + depth=depth, + duration_ms=(_time.perf_counter() - _t0) * 1000, + ) + _touch_query_stamp(gp) + print(_out) elif cmd in ("god-nodes", "god_nodes"): # god_nodes has long been an analyzer (analyze.py), an MCP tool, and a # README-advertised capability, but never a CLI subcommand — `graphify @@ -1303,6 +1319,17 @@ def dispatch_command(cmd: str) -> None: print(f"error: could not load graph: {exc}", file=sys.stderr) sys.exit(1) gods = _god_nodes(G, top_n=top_n) + # Same orientation contract as `affected` (#3042: orientation is + # orientation regardless of door): consulting the graph's hubs IS + # consulting the graph, so stamp + log the successful run. Fail-silent. + from graphify import querylog + querylog.log_query( + kind="god_nodes", + question=f"top {top_n}", + corpus=str(gp), + nodes_returned=len(gods), + ) + _touch_query_stamp(gp) if as_json: print(json.dumps(gods, indent=2)) else: diff --git a/tests/test_affected_cli.py b/tests/test_affected_cli.py index 7c452907d..86ccc0c6e 100644 --- a/tests/test_affected_cli.py +++ b/tests/test_affected_cli.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import os import networkx as nx from networkx.readwrite import json_graph @@ -423,3 +424,39 @@ def test_affected_absolute_seed_with_graph_not_under_out_dir(tmp_path, monkeypat assert "Affected nodes for Foo" in out assert "X()" in out + + +def test_affected_cli_stamps_orientation_and_logs(monkeypatch, tmp_path, capsys): + """`affected` is orientation, same as query/path/explain (#3042's + "regardless of door" reasoning applied to the CLI): a successful run must + refresh the strict guard's freshness stamp AND write a query-ledger line, + so the orienting agent is neither blocked nor invisible to the audit trail. + """ + graph_path = _write_graph(tmp_path) + stamp = tmp_path / "cache" / "last_query_stamp" + stamp.parent.mkdir(parents=True) + stamp.write_text("0") + os.utime(stamp, (0, 0)) # aged: the mtime must ADVANCE, not merely exist + log_file = tmp_path / "queries.jsonl" + monkeypatch.setenv("GRAPHIFY_QUERY_LOG", str(log_file)) + monkeypatch.delenv("GRAPHIFY_QUERY_LOG_DISABLE", raising=False) + monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None) + monkeypatch.setattr( + mainmod.sys, + "argv", + ["graphify", "affected", "Foo", "--graph", str(graph_path)], + ) + + mainmod.main() + + assert "Affected nodes for Foo" in capsys.readouterr().out + assert stamp.stat().st_mtime > 0 # refreshed past the aged epoch mtime + lines = log_file.read_text(encoding="utf-8").splitlines() + assert len(lines) == 1 + rec = json.loads(lines[0]) + assert rec["kind"] == "affected" + assert rec["question"] == "Foo" + assert rec["corpus"] == str(graph_path.resolve()) + assert rec["nodes_returned"] == 3 # caller + barrel + consumer + assert rec["depth"] == 2 + assert rec["duration_ms"] >= 0 diff --git a/tests/test_god_nodes_cli.py b/tests/test_god_nodes_cli.py index 28bacc9b1..b4884aeb0 100644 --- a/tests/test_god_nodes_cli.py +++ b/tests/test_god_nodes_cli.py @@ -8,6 +8,7 @@ from __future__ import annotations import json +import os import networkx as nx import pytest @@ -74,3 +75,35 @@ def test_god_nodes_cli_missing_graph_errors(monkeypatch, tmp_path, capsys): _run(monkeypatch, ["graphify", "god-nodes", "--graph", str(tmp_path / "nope.json")]) assert exc.value.code == 1 assert "graph file not found" in capsys.readouterr().err + + +def test_god_nodes_cli_stamps_orientation_and_logs(monkeypatch, tmp_path, capsys): + """`god-nodes` is orientation, same as query/path/explain (#3042's + "regardless of door" reasoning applied to the CLI): a successful run must + refresh the strict guard's freshness stamp AND write a query-ledger line. + """ + gp = _write_graph(tmp_path) + stamp = tmp_path / "cache" / "last_query_stamp" + stamp.parent.mkdir(parents=True) + stamp.write_text("0") + os.utime(stamp, (0, 0)) # aged: the mtime must ADVANCE, not merely exist + log_file = tmp_path / "queries.jsonl" + monkeypatch.setenv("GRAPHIFY_QUERY_LOG", str(log_file)) + monkeypatch.delenv("GRAPHIFY_QUERY_LOG_DISABLE", raising=False) + + _run(monkeypatch, ["graphify", "god-nodes", "--graph", str(gp), "--top", "3"]) + + out = capsys.readouterr().out + assert "God nodes (most connected):" in out + assert stamp.stat().st_mtime > 0 # refreshed past the aged epoch mtime + lines = log_file.read_text(encoding="utf-8").splitlines() + assert len(lines) == 1 + rec = json.loads(lines[0]) + assert rec["kind"] == "god_nodes" + assert rec["question"] == "top 3" + assert rec["corpus"] == str(gp.resolve()) + # The ledger count is exactly the number of hubs the run printed (one + # ranked line per hub, each ending "N edges") — not the --top ceiling. + printed = out.count(" edges") + assert printed >= 1 + assert rec["nodes_returned"] == printed