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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
43 changes: 35 additions & 8 deletions graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
37 changes: 37 additions & 0 deletions tests/test_affected_cli.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import json
import os

import networkx as nx
from networkx.readwrite import json_graph
Expand Down Expand Up @@ -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
33 changes: 33 additions & 0 deletions tests/test_god_nodes_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from __future__ import annotations

import json
import os

import networkx as nx
import pytest
Expand Down Expand Up @@ -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
Loading