diff --git a/CHANGELOG.md b/CHANGELOG.md index 87660e61..c505d023 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.13 (unreleased) +- Fix: the query log is now opt-in (off by default) (#1797, thanks @adam-pond-agent). `querylog` wrote every `query`/`path`/`explain` question and corpus path (and full responses if `GRAPHIFY_QUERY_LOG_RESPONSES`) to a default-on, unbounded, fail-silent plaintext file at `~/.cache/graphify-queries.log` — outside any repo's .gitignore/retention, and undocumented, which contradicts graphify's on-device / no-telemetry posture. Logging is now OFF unless you opt in with `GRAPHIFY_QUERY_LOG_ENABLE=1` (default path) or `GRAPHIFY_QUERY_LOG=`; `GRAPHIFY_QUERY_LOG_DISABLE=1` still forces it off. All the query-log env vars are now documented in the README. + - Fix: a markdown file that went through semantic extraction is no longer duplicated into two disconnected nodes on later `graphify update` (#1799, thanks @jerp86). The semantic pass mints `_doc` while the markdown quick-scan mints the bare ``, so the file's edges split across two twins (a docs->code path query would dead-end on the bare half; centrality and communities split too). `build_from_json` now merges the bare quick-scan node into the semantic `_doc` node when both share the same `source_file` and are `file_type: document`, consolidating their edges/hyperedges onto one node. Gated so an unrelated code symbol `foo` and `foo_doc` never merge. - Fix: incremental `graphify update` no longer silently evicts nodes for a file that left the scan corpus but still exists on disk (#1795, thanks @CJNA). `_reconcile_existing_graph` read "source absent from the collected corpus" as "deleted", but that's also what an ignore-rule/filter change looks like (e.g. an upgrade that starts honoring `.gitignore`) — in one 27k-node graph the first rebuild after such an upgrade mass-evicted 655 nodes whose files were present the whole time. Eviction now fails closed: a corpus-absent source is only evicted when `Path(identity).exists()` is False (true deletion), otherwise its nodes/edges/hyperedges are preserved and a loud line reports how many were kept and why. True deletions and renames evict as before; a full `extract --force` still purges deliberate exclusions. diff --git a/README.md b/README.md index ae91181c..0a883e75 100644 --- a/README.md +++ b/README.md @@ -513,9 +513,10 @@ These are only needed for **headless / CI extraction** (`graphify extract`). Whe | `GRAPHIFY_GOOGLE_WORKSPACE` | Auto-enable Google Workspace export | optional — set to `1` | | `GRAPHIFY_TRIAGE_BACKEND` | Backend for `graphify prs --triage` | optional — auto-detected from available keys | | `GRAPHIFY_TRIAGE_MODEL` | Model override for triage | optional — e.g. `claude-opus-4-7` | -| `GRAPHIFY_QUERY_LOG` | Override query log path (default: `~/.cache/graphify-queries.log`) | optional — set to empty or `/dev/null` to silence | -| `GRAPHIFY_QUERY_LOG_DISABLE` | Set to `1` to disable query logging entirely | optional | -| `GRAPHIFY_QUERY_LOG_RESPONSES` | Set to `1` to also log full subgraph responses (off by default) | optional | +| `GRAPHIFY_QUERY_LOG_ENABLE` | Set to `1` to turn on the local query log at `~/.cache/graphify-queries.log` (records each query/path/explain question + corpus path). Off by default — nothing is written unless you opt in (#1797) | optional | +| `GRAPHIFY_QUERY_LOG` | Enable the query log and write it to this path instead of the default | optional — off unless this or `_ENABLE` is set | +| `GRAPHIFY_QUERY_LOG_DISABLE` | Set to `1` to force the query log off (wins over the enable vars) | optional | +| `GRAPHIFY_QUERY_LOG_RESPONSES` | When the log is enabled, also record full subgraph responses (off by default) | optional | | `GRAPHIFY_MAX_GRAPH_BYTES` | Override the 512 MiB graph.json size cap — e.g. `700MB`, `2GB`, or plain bytes | optional — useful for very large corpora | | `GRAPHIFY_LLM_TEMPERATURE` | Override LLM temperature for semantic extraction — e.g. `0.7`, or `none` to omit | optional — auto-omitted for o1/o3/o4/gpt-5 reasoning models | diff --git a/graphify/querylog.py b/graphify/querylog.py index 1bee5b24..b89f419d 100644 --- a/graphify/querylog.py +++ b/graphify/querylog.py @@ -13,12 +13,22 @@ _NODES_RE = re.compile(r"(\d+)\s+nodes?\s+found") def _log_path() -> Path | None: + # Opt-in only (#1797). The log records every query/path/explain question and + # corpus path (and full responses if GRAPHIFY_QUERY_LOG_RESPONSES) in a + # plaintext file under ~/.cache — outside any repo's .gitignore/retention. A + # default-on record of proprietary queries contradicts graphify's on-device, + # no-telemetry posture, so it is OFF unless explicitly enabled: + # GRAPHIFY_QUERY_LOG= log to that path, or + # GRAPHIFY_QUERY_LOG_ENABLE=1 log to ~/.cache/graphify-queries.log. + # GRAPHIFY_QUERY_LOG_DISABLE=1 still forces it off (back-compat, wins). if os.environ.get("GRAPHIFY_QUERY_LOG_DISABLE", "").lower() in ("1", "true", "yes"): return None override = os.environ.get("GRAPHIFY_QUERY_LOG", "").strip() if override: return Path(override).expanduser() - return Path.home() / ".cache" / "graphify-queries.log" + if os.environ.get("GRAPHIFY_QUERY_LOG_ENABLE", "").lower() in ("1", "true", "yes"): + return Path.home() / ".cache" / "graphify-queries.log" + return None def _log_responses() -> bool: diff --git a/tests/test_querylog.py b/tests/test_querylog.py index 2ebe851e..b843550c 100644 --- a/tests/test_querylog.py +++ b/tests/test_querylog.py @@ -176,3 +176,48 @@ def test_kind_mcp_query(tmp_path, monkeypatch): rec = json.loads(log_file.read_text()) assert rec["kind"] == "mcp_query" + + +# --------------------------------------------------------------------------- +# #1797 — query log is opt-in (default OFF) +# --------------------------------------------------------------------------- + +def _clear_log_env(monkeypatch): + for k in ("GRAPHIFY_QUERY_LOG", "GRAPHIFY_QUERY_LOG_ENABLE", "GRAPHIFY_QUERY_LOG_DISABLE"): + monkeypatch.delenv(k, raising=False) + + +def test_query_log_off_by_default(monkeypatch): + from graphify.querylog import _log_path + _clear_log_env(monkeypatch) + assert _log_path() is None + + +def test_query_log_enabled_by_explicit_flag(monkeypatch): + from graphify.querylog import _log_path + _clear_log_env(monkeypatch) + monkeypatch.setenv("GRAPHIFY_QUERY_LOG_ENABLE", "1") + assert str(_log_path()).endswith("graphify-queries.log") + + +def test_query_log_enabled_by_explicit_path(monkeypatch, tmp_path): + from graphify.querylog import _log_path + _clear_log_env(monkeypatch) + monkeypatch.setenv("GRAPHIFY_QUERY_LOG", str(tmp_path / "q.log")) + assert _log_path() == tmp_path / "q.log" + + +def test_query_log_disable_wins(monkeypatch): + from graphify.querylog import _log_path + _clear_log_env(monkeypatch) + monkeypatch.setenv("GRAPHIFY_QUERY_LOG_ENABLE", "1") + monkeypatch.setenv("GRAPHIFY_QUERY_LOG_DISABLE", "1") + assert _log_path() is None + + +def test_log_query_writes_nothing_by_default(monkeypatch, tmp_path): + """End-to-end: with no opt-in, log_query must not create the default log.""" + _clear_log_env(monkeypatch) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + log_query(kind="query", question="secret internal ticket TICKET-123", corpus=".", result="1 node found") + assert not (tmp_path / ".cache" / "graphify-queries.log").exists()