bump version to 0.8.12 — security and wiki fixes

Security: _is_sensitive now flags underscore-prefixed names (api_token.txt, oauth_token.json) by replacing \b with lookarounds; adds _SENSITIVE_DIRS check on parent path components (parts[:-1]) so .ssh/, secrets/, .aws/ directories are always skipped; aligns both patterns to (?![a-zA-Z]) for consistent underscore-after-keyword behavior (#920)

Fix: --wiki Relationships section always empty because _cross_community_links read community from node attrs (always None) instead of the communities dict; _god_node_article had the same bug and never linked to the owning community; fixed by building a node->community map in to_wiki() and threading it through (#925)

Fix: --watch now respects .graphifyignore; patterns loaded once at startup, handler checks _is_ignored before extension filter so node_modules/, .venv/, build/ churn no longer triggers rebuilds (#928)

Fix: C++ struct inheritance edges via base_class_clause; initialize base="" per iteration to prevent stale carryover (#915)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Safi
2026-05-18 15:43:45 +01:00
co-authored by Claude Sonnet 4.6
parent 2209a9c1e8
commit 47e65658c7
11 changed files with 318 additions and 14 deletions
+7
View File
@@ -2,6 +2,13 @@
Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases)
## 0.8.12 (2026-05-18)
- Security: `_is_sensitive` now correctly flags underscore-prefixed secret filenames (`api_token.txt`, `oauth_token.json`) — `\b` word boundary was treating `_` as a word char, so names like `api_token` never matched (#920)
- Security: `_is_sensitive` now checks parent directories against a `_SENSITIVE_DIRS` blocklist (`.ssh`, `.aws`, `.gcloud`, `secrets`, etc.) and exempts code-extension files from name-pattern checks so `tokenizer.py` is never skipped (#920)
- Fix: `--wiki` Relationships section was always empty — `_cross_community_links` read `community` from node attributes (always None) instead of the `communities` dict; `_god_node_article` had the same bug and never linked to the owning community (#925)
- Fix: `--watch` now respects `.graphifyignore` — the event handler was checking extensions before the ignore filter, so paths inside `node_modules/`, `.venv/`, etc. triggered rebuilds (#928)
## 0.8.11 (2026-05-18)
- Fix: LLM empty choices / None message guard — Gemini and other providers return `choices=[]` on content-filtered HTTP 200 responses; now raises a clean error instead of crashing with IndexError (#924)
+22 -2
View File
@@ -35,11 +35,25 @@ CORPUS_WARN_THRESHOLD = 50_000 # words - below this, warn "you may not need a
CORPUS_UPPER_THRESHOLD = 500_000 # words - above this, warn about token cost
FILE_COUNT_UPPER = 200 # files - above this, warn about token cost
# Files that may contain secrets - skip silently
# Parent directories whose contents are always sensitive.
# Checked against path.parts[:-1] (parents only) so a root-level file named
# "credentials" or "secrets" is not falsely flagged by this stage.
_SENSITIVE_DIRS = frozenset({
".ssh", ".gnupg", ".aws", ".gcloud", "secrets", ".secrets", "credentials",
})
# Files that may contain secrets - skip silently.
# Uses lookarounds instead of \b so underscore-prefixed names like api_token.txt
# match. Both patterns use (?![a-zA-Z]) so that the trailing-underscore behavior
# is consistent: "secret_store.txt" IS flagged, "tokenizer.py" is NOT (because
# "i" after "token" is alpha and blocks the match).
# `token` is kept separate because its longer suffix "izer"/"ize" is the only
# common false-positive; other keywords have no such well-known derivatives.
_SENSITIVE_PATTERNS = [
re.compile(r'(^|[\\/])\.(env|envrc)(\.|$)', re.IGNORECASE),
re.compile(r'\.(pem|key|p12|pfx|cert|crt|der|p8)$', re.IGNORECASE),
re.compile(r'\b(credential|secret|passwd|password|token|private_key)s?\b', re.IGNORECASE),
re.compile(r'(?<![a-zA-Z0-9])(credential|secret|passwd|password|private_key)s?(?![a-zA-Z])', re.IGNORECASE),
re.compile(r'(?<![a-zA-Z0-9])tokens?(?![a-zA-Z])', re.IGNORECASE),
re.compile(r'(id_rsa|id_dsa|id_ecdsa|id_ed25519)(\.pub)?$'),
re.compile(r'(\.netrc|\.pgpass|\.htpasswd)$', re.IGNORECASE),
re.compile(r'(aws_credentials|gcloud_credentials|service.account)', re.IGNORECASE),
@@ -66,6 +80,12 @@ _PAPER_SIGNAL_THRESHOLD = 3 # need at least this many signals to call it a pape
def _is_sensitive(path: Path) -> bool:
"""Return True if this file likely contains secrets and should be skipped."""
# Stage 1: any PARENT directory is a known secrets dir (parts[:-1] excludes
# the filename itself so a root-level file named "credentials" is not falsely
# skipped — the name patterns in Stage 2 handle the filename).
if any(part in _SENSITIVE_DIRS for part in path.parts[:-1]):
return True
# Stage 2: filename pattern match
name = path.name
return any(p.search(name) for p in _SENSITIVE_PATTERNS)
+46 -1
View File
@@ -1012,7 +1012,7 @@ _C_CONFIG = LanguageConfig(
_CPP_CONFIG = LanguageConfig(
ts_module="tree_sitter_cpp",
class_types=frozenset({"class_specifier"}),
class_types=frozenset({"class_specifier", "struct_specifier"}),
function_types=frozenset({"function_definition"}),
import_types=frozenset({"preproc_include"}),
call_types=frozenset({"call_expression"}),
@@ -1417,6 +1417,51 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
if tid.type == "type_identifier":
_emit_java_parent(_read_text(tid, source), "extends", line)
# C++-specific: inheritance via base_class_clause (class and struct).
# tree-sitter-cpp shape:
# class_specifier / struct_specifier
# base_class_clause
# access_specifier? ("public"/"protected"/"private") -- skip
# "virtual"? -- skip
# type_identifier -- "Base"
# qualified_identifier -- "ns::Base"
# template_type -- "Vec<int>"
# Multiple bases are siblings separated by ',' tokens.
if config.ts_module == "tree_sitter_cpp":
for child in node.children:
if child.type != "base_class_clause":
continue
for sub in child.children:
base = ""
if sub.type == "type_identifier":
base = _read_text(sub, source)
elif sub.type == "qualified_identifier":
# Use the unqualified tail so "std::vector" matches
# a "vector" node id if one exists in the graph;
# fall back to the full qualified text otherwise.
tail = sub.child_by_field_name("name")
base = _read_text(tail, source) if tail else _read_text(sub, source)
elif sub.type == "template_type":
tname = sub.child_by_field_name("name")
base = _read_text(tname, source) if tname else _read_text(sub, source)
else:
continue
if not base:
continue
base_nid = _make_id(stem, base)
if base_nid not in seen_ids:
base_nid = _make_id(base)
if base_nid not in seen_ids:
nodes.append({
"id": base_nid,
"label": base,
"file_type": "code",
"source_file": "",
"source_location": "",
})
seen_ids.add(base_nid)
add_edge(class_nid, base_nid, "inherits", line)
# Find body and recurse
body = _find_body(node, config)
if body:
+24 -1
View File
@@ -109,7 +109,14 @@ def _git_head() -> str | None:
return None
from graphify.detect import CODE_EXTENSIONS, DOC_EXTENSIONS, PAPER_EXTENSIONS, IMAGE_EXTENSIONS
from graphify.detect import (
CODE_EXTENSIONS,
DOC_EXTENSIONS,
PAPER_EXTENSIONS,
IMAGE_EXTENSIONS,
_load_graphifyignore,
_is_ignored,
)
_WATCHED_EXTENSIONS = CODE_EXTENSIONS | DOC_EXTENSIONS | PAPER_EXTENSIONS | IMAGE_EXTENSIONS
_CODE_EXTENSIONS = CODE_EXTENSIONS
@@ -647,12 +654,28 @@ def watch(watch_path: Path, debounce: float = 3.0) -> None:
pending: bool = False
changed: set[Path] = set()
# Load .graphifyignore patterns ONCE at startup so the handler does not
# re-parse the file on every filesystem event. Watchdog's handler runs on
# the observer thread and is invoked for every event the OS delivers
# (Time Machine writes, Docker/Colima VM I/O, Spotlight indexing, …) —
# without this short-circuit a busy volume can saturate a CPU core
# discarding events one extension at a time. (gh-928)
watch_root_for_ignore = watch_path.resolve()
ignore_patterns = _load_graphifyignore(watch_root_for_ignore)
class Handler(FileSystemEventHandler):
def on_any_event(self, event):
nonlocal last_trigger, pending
if event.is_directory:
return
path = Path(event.src_path)
# Check .graphifyignore BEFORE the extension/dotfile/out filters so
# the cheapest short-circuit for users with broad ignore patterns
# (node_modules/, .venv/, build/, …) fires first. _is_ignored
# tolerates absolute paths outside watch_root via its internal
# relative_to guard, so a stray symlinked event won't raise.
if ignore_patterns and _is_ignored(path, watch_root_for_ignore, ignore_patterns):
return
if path.suffix.lower() not in _WATCHED_EXTENSIONS:
return
if any(part.startswith(".") for part in path.parts):
+12 -8
View File
@@ -23,13 +23,12 @@ def _safe_filename(name: str) -> str:
return s[:200] if s else 'unnamed'
def _cross_community_links(G: nx.Graph, nodes: list[str], own_cid: int, labels: dict[int, str]) -> list[tuple[str, int]]:
def _cross_community_links(G: nx.Graph, nodes: list[str], own_cid: int, labels: dict[int, str], node_community: dict[str, int]) -> list[tuple[str, int]]:
"""Return (community_label, edge_count) pairs for cross-community connections, sorted descending."""
counts: dict[str, int] = Counter()
for nid in nodes:
for neighbor in G.neighbors(nid):
nd = G.nodes[neighbor]
ncid = nd.get("community")
ncid = node_community.get(neighbor)
if ncid is not None and ncid != own_cid:
counts[labels.get(ncid, f"Community {ncid}")] += 1
return sorted(counts.items(), key=lambda x: -x[1])
@@ -42,9 +41,10 @@ def _community_article(
label: str,
labels: dict[int, str],
cohesion: float | None,
node_community: dict[str, int] | None = None,
) -> str:
top_nodes = sorted(nodes, key=lambda n: G.degree(n), reverse=True)[:25]
cross = _cross_community_links(G, nodes, cid, labels)
cross = _cross_community_links(G, nodes, cid, labels, node_community or {})
# Edge confidence breakdown
conf_counts: Counter = Counter()
@@ -102,11 +102,11 @@ def _community_article(
return "\n".join(lines)
def _god_node_article(G: nx.Graph, nid: str, labels: dict[int, str]) -> str:
def _god_node_article(G: nx.Graph, nid: str, labels: dict[int, str], node_community: dict[str, int] | None = None) -> str:
d = G.nodes[nid]
node_label = d.get("label", nid)
src = d.get("source_file", "")
cid = d.get("community")
cid = (node_community or {}).get(nid)
community_name = labels.get(cid, f"Community {cid}") if cid is not None else None
lines: list[str] = []
@@ -217,6 +217,10 @@ def to_wiki(
cohesion = cohesion or {}
god_nodes_data = god_nodes_data or []
# Build node->community lookup once; node attrs never carry community (it lives in
# the communities dict), so _cross_community_links and _god_node_article need this.
node_community: dict[str, int] = {n: cid for cid, nodes in communities.items() for n in nodes}
count = 0
used_slugs: set[str] = set()
@@ -232,7 +236,7 @@ def to_wiki(
# Community articles
for cid, nodes in communities.items():
label = labels.get(cid, f"Community {cid}")
article = _community_article(G, cid, nodes, label, labels, cohesion.get(cid))
article = _community_article(G, cid, nodes, label, labels, cohesion.get(cid), node_community)
slug = _unique_slug(_safe_filename(label))
(out / f"{slug}.md").write_text(article, encoding="utf-8")
count += 1
@@ -241,7 +245,7 @@ def to_wiki(
for node_data in god_nodes_data:
nid = node_data.get("id")
if nid and nid in G:
article = _god_node_article(G, nid, labels)
article = _god_node_article(G, nid, labels, node_community)
slug = _unique_slug(_safe_filename(node_data['label']))
(out / f"{slug}.md").write_text(article, encoding="utf-8")
count += 1
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "graphifyy"
version = "0.8.11"
version = "0.8.12"
description = "AI coding assistant skill (Claude Code, Codex, OpenCode, Cursor, Gemini CLI, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, Pi, Google Antigravity) - turn any folder of code, docs, papers, images, or videos into a queryable knowledge graph"
readme = "README.md"
license = { file = "LICENSE" }
+13
View File
@@ -22,6 +22,19 @@ private:
}
};
class AuthedHttpClient : public HttpClient {
public:
AuthedHttpClient(const std::string& baseUrl, const std::string& token)
: HttpClient(baseUrl), token_(token) {}
private:
std::string token_;
};
struct RetryingHttpClient : HttpClient {
int maxRetries;
};
int main() {
HttpClient client("https://api.example.com");
std::string response = client.get("/users");
+55 -1
View File
@@ -1,5 +1,5 @@
from pathlib import Path
from graphify.detect import classify_file, count_words, detect, detect_incremental, save_manifest, FileType, _looks_like_paper, _is_ignored, _load_graphifyignore
from graphify.detect import classify_file, count_words, detect, detect_incremental, save_manifest, FileType, _looks_like_paper, _is_ignored, _load_graphifyignore, _is_sensitive
FIXTURES = Path(__file__).parent / "fixtures"
@@ -501,3 +501,57 @@ def test_negation_ancestor_itself_reincluded(tmp_path):
patterns = _load_graphifyignore(tmp_path)
# vendor/ is excluded then re-included; ancestor eval returns False so file is evaluated on its own
assert not _is_ignored(f, tmp_path, patterns)
# Regression tests for #920 - sensitive pattern misses underscore-prefixed names
def test_sensitive_flags_api_token_txt():
assert _is_sensitive(Path("api_token.txt"))
def test_sensitive_flags_oauth_token_json():
assert _is_sensitive(Path("oauth_token.json"))
def test_sensitive_flags_underscore_secret():
assert _is_sensitive(Path("app_secret.yaml"))
def test_sensitive_does_not_flag_tokenizer_py():
assert not _is_sensitive(Path("tokenizer.py"))
def test_sensitive_does_not_flag_tokenize_py():
assert not _is_sensitive(Path("tokenize.py"))
def test_sensitive_flags_passwords_py():
# passwords.py is just as likely a secret store as passwords.txt — code ext is no excuse
assert _is_sensitive(Path("passwords.py"))
def test_sensitive_flags_ssh_dir():
assert _is_sensitive(Path("/home/user/.ssh/id_rsa"))
def test_sensitive_flags_secrets_dir():
assert _is_sensitive(Path("config/secrets/db.json"))
def test_sensitive_flags_token_txt():
assert _is_sensitive(Path("token.txt"))
def test_sensitive_flags_credentials_json():
assert _is_sensitive(Path("credentials.json"))
def test_sensitive_does_not_flag_root_file_named_credentials():
# A root-level file called "credentials" (no parent dir named credentials)
# must NOT be flagged by Stage 1; Stage 2 name-pattern check catches it instead.
# Specifically: Path("credentials").parts == ('credentials',) which is parts[:-1] == ()
# so the dir check passes. The name pattern for "credential" then picks it up.
# What we are asserting here is that the Stage 1 check uses parts[:-1], not parts.
p = Path("credentials")
# The name pattern WILL match "credentials" (it's a sensitive name), but the
# false-flag we fixed was Stage 1 matching on the filename itself as a "dir".
# Verify the whole function still returns True (via name pattern, not dir check).
assert _is_sensitive(p)
def test_sensitive_secret_handler_txt():
# Both patterns now use (?![a-zA-Z]) so underscore after keyword is allowed.
# "secret_handler.txt": "secret" followed by "_" (not alpha) → flagged.
assert _is_sensitive(Path("secret_handler.txt"))
def test_sensitive_token_config_yaml():
# "token_config.yaml": "token" followed by "_" (not alpha) → flagged.
assert _is_sensitive(Path("token_config.yaml"))
+24
View File
@@ -149,6 +149,30 @@ def test_cpp_import_edges_have_import_context():
assert all(e.get("context") == "import" for e in import_edges)
def test_cpp_class_inherits_edge():
"""Regression for #915: `class Derived : public Base {}` should emit an inherits edge."""
r = extract_cpp(FIXTURES / "sample.cpp")
node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
found = any(
"AuthedHttpClient" in node_by_id.get(e["source"], "")
and "HttpClient" in node_by_id.get(e["target"], "")
for e in r["edges"] if e["relation"] == "inherits"
)
assert found, "AuthedHttpClient should have inherits edge to HttpClient"
def test_cpp_struct_inherits_edge():
"""Structs use the same `: Base` syntax as classes and must also emit inherits."""
r = extract_cpp(FIXTURES / "sample.cpp")
node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
found = any(
"RetryingHttpClient" in node_by_id.get(e["source"], "")
and "HttpClient" in node_by_id.get(e["target"], "")
for e in r["edges"] if e["relation"] == "inherits"
)
assert found, "RetryingHttpClient (struct) should have inherits edge to HttpClient"
# ── Ruby ─────────────────────────────────────────────────────────────────────
def test_ruby_no_error():
+86
View File
@@ -208,3 +208,89 @@ def test_rebuild_code_skips_cluster_when_topology_unchanged(tmp_path, monkeypatc
assert _rebuild_code(tmp_path)
assert _rebuild_code(tmp_path)
assert calls["n"] == 1
# --- .graphifyignore honored in watch handler (gh-928) ---
def _watchdog_available() -> bool:
try:
import watchdog # noqa: F401
return True
except ImportError:
return False
@pytest.mark.skipif(not _watchdog_available(), reason="watchdog not installed")
def test_watch_handler_honors_graphifyignore(tmp_path, monkeypatch):
"""gh-928: the watch Handler must short-circuit paths matching
.graphifyignore so busy volumes (node_modules churn, build artefacts,
Time Machine writes, ) don't wake the rebuild pipeline.
"""
import threading
from graphify import watch as watch_mod
(tmp_path / ".graphifyignore").write_text("node_modules/\nbuild/\n", encoding="utf-8")
(tmp_path / "node_modules").mkdir()
(tmp_path / "build").mkdir()
rebuild_calls: list[Path] = []
notify_calls: list[Path] = []
monkeypatch.setattr(watch_mod, "_rebuild_code", lambda p, **kw: rebuild_calls.append(p) or True)
monkeypatch.setattr(watch_mod, "_notify_only", lambda p: notify_calls.append(p))
# Run watch() in a thread with a short debounce so we can verify the
# post-debounce dispatch path actually runs on real events.
t = threading.Thread(target=watch_mod.watch, args=(tmp_path,), kwargs={"debounce": 0.2}, daemon=True)
t.start()
time.sleep(0.5) # let observer.start() settle
# Ignored writes — handler must drop these.
(tmp_path / "node_modules" / "junk.js").write_text("// noise\n", encoding="utf-8")
(tmp_path / "build" / "out.py").write_text("x = 1\n", encoding="utf-8")
time.sleep(1.0)
assert rebuild_calls == [], "ignored writes triggered a rebuild"
assert notify_calls == [], "ignored writes triggered a notify"
# Non-ignored write — handler must accept and (after debounce) dispatch.
(tmp_path / "app.py").write_text("def f():\n return 1\n", encoding="utf-8")
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline and not rebuild_calls:
time.sleep(0.1)
assert rebuild_calls, "non-ignored .py write should have triggered _rebuild_code"
@pytest.mark.skipif(not _watchdog_available(), reason="watchdog not installed")
def test_watch_loads_graphifyignore_once(tmp_path, monkeypatch):
"""gh-928: .graphifyignore must be parsed exactly once at watch() startup,
not per filesystem event. Otherwise busy volumes re-read the file
thousands of times per second.
"""
import threading
from graphify import watch as watch_mod
from graphify import detect as detect_mod
(tmp_path / ".graphifyignore").write_text("ignored/\n", encoding="utf-8")
(tmp_path / "ignored").mkdir()
calls = {"n": 0}
real_loader = detect_mod._load_graphifyignore
def counting_loader(root):
calls["n"] += 1
return real_loader(root)
# Patch the symbol the watch module imported at module-load time.
monkeypatch.setattr(watch_mod, "_load_graphifyignore", counting_loader)
monkeypatch.setattr(watch_mod, "_rebuild_code", lambda p, **kw: True)
monkeypatch.setattr(watch_mod, "_notify_only", lambda p: None)
t = threading.Thread(target=watch_mod.watch, args=(tmp_path,), kwargs={"debounce": 0.2}, daemon=True)
t.start()
time.sleep(0.5)
# Generate many events; loader must not be called again.
for i in range(50):
(tmp_path / "ignored" / f"f{i}.py").write_text("x\n", encoding="utf-8")
time.sleep(0.7)
assert calls["n"] == 1, f"_load_graphifyignore called {calls['n']} times; expected 1"
+28
View File
@@ -137,3 +137,31 @@ def test_community_article_truncation_notice(tmp_path):
to_wiki(G, communities, tmp_path, community_labels={0: "Big Community"})
article = (tmp_path / "Big_Community.md").read_text()
assert "and 5 more nodes" in article
# Regression tests for #925 - cross-community links always empty when node attrs lack community
def test_cross_community_links_without_node_community_attrs(tmp_path):
"""Cross-community links must work even when nodes have no 'community' attribute (#925)."""
G = nx.Graph()
G.add_node("n1", label="parse", file_type="code", source_file="parser.py")
G.add_node("n2", label="render", file_type="code", source_file="renderer.py")
G.add_edge("n1", "n2", relation="references", confidence="INFERRED", weight=1.0)
communities = {0: ["n1"], 1: ["n2"]}
labels = {0: "Parsing", 1: "Rendering"}
to_wiki(G, communities, tmp_path, community_labels=labels)
article = (tmp_path / "Parsing.md").read_text()
assert "[[Rendering]]" in article
def test_god_node_article_community_without_node_attr(tmp_path):
"""God node article must show community name even when node has no 'community' attr (#925)."""
G = nx.Graph()
G.add_node("n1", label="parse", file_type="code", source_file="parser.py")
G.add_node("n2", label="validate", file_type="code", source_file="parser.py")
G.add_edge("n1", "n2", relation="calls", confidence="EXTRACTED", weight=1.0)
communities = {0: ["n1", "n2"]}
labels = {0: "Core Logic"}
god_nodes = [{"id": "n1", "label": "parse", "degree": 1}]
to_wiki(G, communities, tmp_path, community_labels=labels, god_nodes_data=god_nodes)
article = (tmp_path / "parse.md").read_text()
assert "[[Core Logic]]" in article