From 47e65658c724f53fcdaecd9927dcb3a1260d18c9 Mon Sep 17 00:00:00 2001 From: Safi Date: Mon, 18 May 2026 15:43:45 +0100 Subject: [PATCH] =?UTF-8?q?bump=20version=20to=200.8.12=20=E2=80=94=20secu?= =?UTF-8?q?rity=20and=20wiki=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 7 ++++ graphify/detect.py | 24 ++++++++++- graphify/extract.py | 47 ++++++++++++++++++++- graphify/watch.py | 25 +++++++++++- graphify/wiki.py | 20 +++++---- pyproject.toml | 2 +- tests/fixtures/sample.cpp | 13 ++++++ tests/test_detect.py | 56 ++++++++++++++++++++++++- tests/test_languages.py | 24 +++++++++++ tests/test_watch.py | 86 +++++++++++++++++++++++++++++++++++++++ tests/test_wiki.py | 28 +++++++++++++ 11 files changed, 318 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dfaab5a..63212315 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/graphify/detect.py b/graphify/detect.py index 8ca1c337..69b4ee3b 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -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'(? 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) diff --git a/graphify/extract.py b/graphify/extract.py index b086feec..e747df04 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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" + # 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: diff --git a/graphify/watch.py b/graphify/watch.py index 7d2e2c07..2447e244 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -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): diff --git a/graphify/wiki.py b/graphify/wiki.py index 8d8baa3e..53ed6250 100644 --- a/graphify/wiki.py +++ b/graphify/wiki.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index a9544901..0fb4ef21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } diff --git a/tests/fixtures/sample.cpp b/tests/fixtures/sample.cpp index 88f4013b..7cf274a0 100644 --- a/tests/fixtures/sample.cpp +++ b/tests/fixtures/sample.cpp @@ -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"); diff --git a/tests/test_detect.py b/tests/test_detect.py index a7067d70..869c6a0d 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -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")) diff --git a/tests/test_languages.py b/tests/test_languages.py index 1d2ccdbc..3497f3f6 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -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(): diff --git a/tests/test_watch.py b/tests/test_watch.py index e3d3f665..f5b332f6 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -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" diff --git a/tests/test_wiki.py b/tests/test_wiki.py index 48335958..2eb5bc8c 100644 --- a/tests/test_wiki.py +++ b/tests/test_wiki.py @@ -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