mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-28 09:17:02 +00:00
release 0.3.25: Aider + Copilot CLI, directed graphs, frontmatter cache, graphifyignore parent discovery, MCP fixes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
cf39034744
commit
1cbcee539f
@@ -2,6 +2,18 @@
|
||||
|
||||
Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases)
|
||||
|
||||
## 0.3.25 (2026-04-09)
|
||||
|
||||
- Fix: `graphify install --platform gemini` now routes to `gemini_install()` instead of erroring — `gemini` was missing from `_PLATFORM_CONFIG` (#171)
|
||||
- Fix: `graphify install --platform cursor` now routes to `_cursor_install()` the same way (#171)
|
||||
- Fix: `serve.py` `validate_graph_path` now passes `base=Path(graph_path).resolve().parent` so MCP server works when graph is outside cwd (#170)
|
||||
- Fix: MCP `call_tool()` handler now wraps dispatch in try/except — exceptions in tool handlers return graceful error strings instead of crashing the stdio loop (#163)
|
||||
- Fix: `_load_graphifyignore` now walks parent directories up to the `.git` boundary, matching `.gitignore` discovery behavior — subdirectory scans now inherit root ignore patterns (#168)
|
||||
- Add: Aider platform support — `graphify install --platform aider` copies skill to `~/.aider/graphify/SKILL.md`; `graphify aider install/uninstall` writes AGENTS.md rules (#74)
|
||||
- Add: GitHub Copilot CLI platform support — `graphify install --platform copilot` copies skill to `~/.copilot/skills/graphify/SKILL.md`; `graphify copilot install/uninstall` for skill management (#134)
|
||||
- Add: `--directed` flag — `build_from_json()` and `build()` now accept `directed=True` to produce a `DiGraph` preserving edge direction (source→target); `cluster()` converts to undirected internally for Leiden; `graph_diff` edge key handles directed graphs correctly (#125)
|
||||
- Add: Frontmatter-aware cache for Markdown files — `.md` files hash only the body below YAML frontmatter, so metadata-only changes (reviewed, status, tags) no longer invalidate the cache (#131)
|
||||
|
||||
## 0.3.24 (2026-04-09)
|
||||
|
||||
- Fix: `graphify codex install` (and opencode) no longer exits early when `AGENTS.md` already has the graphify section — partial installs with a missing `.codex/hooks.json` can now recover on re-run (#153)
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
[](https://pypi.org/project/graphifyy/)
|
||||
[](https://github.com/sponsors/safishamsi)
|
||||
|
||||
**An AI coding assistant skill.** Type `/graphify` in Claude Code, Codex, OpenCode, Cursor, Gemini CLI, OpenClaw, Factory Droid, or Trae - it reads your files, builds a knowledge graph, and gives you back structure you didn't know was there. Understand a codebase faster. Find the "why" behind architectural decisions.
|
||||
**An AI coding assistant skill.** Type `/graphify` in Claude Code, Codex, OpenCode, Cursor, Gemini CLI, GitHub Copilot CLI, Aider, OpenClaw, Factory Droid, or Trae - it reads your files, builds a knowledge graph, and gives you back structure you didn't know was there. Understand a codebase faster. Find the "why" behind architectural decisions.
|
||||
|
||||
Fully multimodal. Drop in code, PDFs, markdown, screenshots, diagrams, whiteboard photos, even images in other languages - graphify uses Claude vision to extract concepts and relationships from all of it and connects them into one graph. 20 languages supported via tree-sitter AST (Python, JS, TS, Go, Rust, Java, C, C++, Ruby, C#, Kotlin, Scala, PHP, Swift, Lua, Zig, PowerShell, Elixir, Objective-C, Julia).
|
||||
|
||||
@@ -47,7 +47,7 @@ Every relationship is tagged `EXTRACTED` (found directly in source), `INFERRED`
|
||||
|
||||
## Install
|
||||
|
||||
**Requires:** Python 3.10+ and one of: [Claude Code](https://claude.ai/code), [Codex](https://openai.com/codex), [OpenCode](https://opencode.ai), [Cursor](https://cursor.com), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [OpenClaw](https://openclaw.ai), [Factory Droid](https://factory.ai), or [Trae](https://trae.ai)
|
||||
**Requires:** Python 3.10+ and one of: [Claude Code](https://claude.ai/code), [Codex](https://openai.com/codex), [OpenCode](https://opencode.ai), [Cursor](https://cursor.com), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/copilot-cli), [Aider](https://aider.chat), [OpenClaw](https://openclaw.ai), [Factory Droid](https://factory.ai), or [Trae](https://trae.ai)
|
||||
|
||||
```bash
|
||||
pip install graphifyy && graphify install
|
||||
@@ -63,6 +63,8 @@ pip install graphifyy && graphify install
|
||||
| Claude Code (Windows) | `graphify install` (auto-detected) or `graphify install --platform windows` |
|
||||
| Codex | `graphify install --platform codex` |
|
||||
| OpenCode | `graphify install --platform opencode` |
|
||||
| GitHub Copilot CLI | `graphify install --platform copilot` |
|
||||
| Aider | `graphify install --platform aider` |
|
||||
| OpenClaw | `graphify install --platform claw` |
|
||||
| Factory Droid | `graphify install --platform droid` |
|
||||
| Trae | `graphify install --platform trae` |
|
||||
@@ -70,7 +72,7 @@ pip install graphifyy && graphify install
|
||||
| Gemini CLI | `graphify install --platform gemini` |
|
||||
| Cursor | `graphify cursor install` |
|
||||
|
||||
Codex users also need `multi_agent = true` under `[features]` in `~/.codex/config.toml` for parallel extraction. Factory Droid uses the `Task` tool for parallel subagent dispatch. OpenClaw uses sequential extraction (parallel agent support is still early on that platform). Trae uses the Agent tool for parallel subagent dispatch and does **not** support PreToolUse hooks — AGENTS.md is the always-on mechanism.
|
||||
Codex users also need `multi_agent = true` under `[features]` in `~/.codex/config.toml` for parallel extraction. Factory Droid uses the `Task` tool for parallel subagent dispatch. OpenClaw and Aider use sequential extraction (parallel agent support is still early on those platforms). Trae uses the Agent tool for parallel subagent dispatch and does **not** support PreToolUse hooks — AGENTS.md is the always-on mechanism.
|
||||
|
||||
Then open your AI coding assistant and type:
|
||||
|
||||
@@ -89,6 +91,8 @@ After building a graph, run this once in your project:
|
||||
| Claude Code | `graphify claude install` |
|
||||
| Codex | `graphify codex install` |
|
||||
| OpenCode | `graphify opencode install` |
|
||||
| GitHub Copilot CLI | `graphify copilot install` |
|
||||
| Aider | `graphify aider install` |
|
||||
| OpenClaw | `graphify claw install` |
|
||||
| Factory Droid | `graphify droid install` |
|
||||
| Trae | `graphify trae install` |
|
||||
@@ -179,6 +183,7 @@ When the user types `/graphify`, invoke the Skill tool with `skill: "graphify"`
|
||||
/graphify ./raw # run on a specific folder
|
||||
/graphify ./raw --mode deep # more aggressive INFERRED edge extraction
|
||||
/graphify ./raw --update # re-extract only changed files, merge into existing graph
|
||||
/graphify ./raw --directed # build directed graph (preserves edge direction: source→target)
|
||||
/graphify ./raw --cluster-only # rerun clustering on existing graph, no re-extraction
|
||||
/graphify ./raw --no-viz # skip HTML, just produce report + JSON
|
||||
/graphify ./raw --obsidian # also generate Obsidian vault (opt-in)
|
||||
|
||||
+45
-3
@@ -62,6 +62,16 @@ _PLATFORM_CONFIG: dict[str, dict] = {
|
||||
"skill_dst": Path(".config") / "opencode" / "skills" / "graphify" / "SKILL.md",
|
||||
"claude_md": False,
|
||||
},
|
||||
"aider": {
|
||||
"skill_file": "skill-aider.md",
|
||||
"skill_dst": Path(".aider") / "graphify" / "SKILL.md",
|
||||
"claude_md": False,
|
||||
},
|
||||
"copilot": {
|
||||
"skill_file": "skill-copilot.md",
|
||||
"skill_dst": Path(".copilot") / "skills" / "graphify" / "SKILL.md",
|
||||
"claude_md": False,
|
||||
},
|
||||
"claw": {
|
||||
"skill_file": "skill-claw.md",
|
||||
"skill_dst": Path(".claw") / "skills" / "graphify" / "SKILL.md",
|
||||
@@ -91,9 +101,15 @@ _PLATFORM_CONFIG: dict[str, dict] = {
|
||||
|
||||
|
||||
def install(platform: str = "claude") -> None:
|
||||
if platform == "gemini":
|
||||
gemini_install()
|
||||
return
|
||||
if platform == "cursor":
|
||||
_cursor_install()
|
||||
return
|
||||
if platform not in _PLATFORM_CONFIG:
|
||||
print(
|
||||
f"error: unknown platform '{platform}'. Choose from: {', '.join(_PLATFORM_CONFIG)}",
|
||||
f"error: unknown platform '{platform}'. Choose from: {', '.join(_PLATFORM_CONFIG)}, gemini, cursor",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
@@ -608,7 +624,7 @@ def main() -> None:
|
||||
print("Usage: graphify <command>")
|
||||
print()
|
||||
print("Commands:")
|
||||
print(" install [--platform P] copy skill to platform config dir (claude|windows|codex|opencode|claw|droid|trae|trae-cn)")
|
||||
print(" install [--platform P] copy skill to platform config dir (claude|windows|codex|opencode|aider|claw|droid|trae|trae-cn|gemini|cursor)")
|
||||
print(" query \"<question>\" BFS traversal of graph.json for a question")
|
||||
print(" --dfs use depth-first instead of breadth-first")
|
||||
print(" --budget N cap output at N tokens (default 2000)")
|
||||
@@ -633,6 +649,10 @@ def main() -> None:
|
||||
print(" codex uninstall remove graphify section from AGENTS.md")
|
||||
print(" opencode install write graphify section to AGENTS.md + tool.execute.before plugin (OpenCode)")
|
||||
print(" opencode uninstall remove graphify section from AGENTS.md + plugin")
|
||||
print(" aider install write graphify section to AGENTS.md (Aider)")
|
||||
print(" aider uninstall remove graphify section from AGENTS.md")
|
||||
print(" copilot install copy graphify skill to ~/.copilot/skills (GitHub Copilot CLI)")
|
||||
print(" copilot uninstall remove graphify skill from ~/.copilot/skills")
|
||||
print(" claw install write graphify section to AGENTS.md (OpenClaw)")
|
||||
print(" claw uninstall remove graphify section from AGENTS.md")
|
||||
print(" droid install write graphify section to AGENTS.md (Factory Droid)")
|
||||
@@ -688,7 +708,29 @@ def main() -> None:
|
||||
else:
|
||||
print("Usage: graphify cursor [install|uninstall]", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif cmd in ("codex", "opencode", "claw", "droid", "trae", "trae-cn"):
|
||||
elif cmd == "copilot":
|
||||
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
|
||||
if subcmd == "install":
|
||||
install(platform="copilot")
|
||||
elif subcmd == "uninstall":
|
||||
skill_dst = Path.home() / _PLATFORM_CONFIG["copilot"]["skill_dst"]
|
||||
removed = []
|
||||
if skill_dst.exists():
|
||||
skill_dst.unlink()
|
||||
removed.append(f"skill removed: {skill_dst}")
|
||||
version_file = skill_dst.parent / ".graphify_version"
|
||||
if version_file.exists():
|
||||
version_file.unlink()
|
||||
for d in (skill_dst.parent, skill_dst.parent.parent, skill_dst.parent.parent.parent):
|
||||
try:
|
||||
d.rmdir()
|
||||
except OSError:
|
||||
break
|
||||
print("; ".join(removed) if removed else "nothing to remove")
|
||||
else:
|
||||
print("Usage: graphify copilot [install|uninstall]", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif cmd in ("aider", "codex", "opencode", "claw", "droid", "trae", "trae-cn"):
|
||||
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
|
||||
if subcmd == "install":
|
||||
_agents_install(Path("."), cmd)
|
||||
|
||||
@@ -469,6 +469,8 @@ def graph_diff(G_old: nx.Graph, G_new: nx.Graph) -> dict:
|
||||
]
|
||||
|
||||
def edge_key(G: nx.Graph, u: str, v: str, data: dict) -> tuple:
|
||||
if G.is_directed():
|
||||
return (u, v, data.get("relation", ""))
|
||||
return (min(u, v), max(u, v), data.get("relation", ""))
|
||||
|
||||
old_edge_keys = {
|
||||
|
||||
+12
-4
@@ -26,13 +26,18 @@ import networkx as nx
|
||||
from .validate import validate_extraction
|
||||
|
||||
|
||||
def build_from_json(extraction: dict) -> nx.Graph:
|
||||
def build_from_json(extraction: dict, *, directed: bool = False) -> nx.Graph:
|
||||
"""Build a NetworkX graph from an extraction dict.
|
||||
|
||||
directed=True produces a DiGraph that preserves edge direction (source→target).
|
||||
directed=False (default) produces an undirected Graph for backward compatibility.
|
||||
"""
|
||||
errors = validate_extraction(extraction)
|
||||
# Dangling edges (stdlib/external imports) are expected - only warn about real schema errors.
|
||||
real_errors = [e for e in errors if "does not match any node id" not in e]
|
||||
if real_errors:
|
||||
print(f"[graphify] Extraction warning ({len(real_errors)} issues): {real_errors[0]}", file=sys.stderr)
|
||||
G = nx.Graph()
|
||||
G: nx.Graph = nx.DiGraph() if directed else nx.Graph()
|
||||
for node in extraction.get("nodes", []):
|
||||
G.add_node(node["id"], **{k: v for k, v in node.items() if k != "id"})
|
||||
node_set = set(G.nodes())
|
||||
@@ -52,9 +57,12 @@ def build_from_json(extraction: dict) -> nx.Graph:
|
||||
return G
|
||||
|
||||
|
||||
def build(extractions: list[dict]) -> nx.Graph:
|
||||
def build(extractions: list[dict], *, directed: bool = False) -> nx.Graph:
|
||||
"""Merge multiple extraction results into one graph.
|
||||
|
||||
directed=True produces a DiGraph that preserves edge direction (source→target).
|
||||
directed=False (default) produces an undirected Graph for backward compatibility.
|
||||
|
||||
Extractions are merged in order. For nodes with the same ID, the last
|
||||
extraction's attributes win (NetworkX add_node overwrites). Pass AST
|
||||
results before semantic results so semantic labels take precedence, or
|
||||
@@ -67,4 +75,4 @@ def build(extractions: list[dict]) -> nx.Graph:
|
||||
combined["hyperedges"].extend(ext.get("hyperedges", []))
|
||||
combined["input_tokens"] += ext.get("input_tokens", 0)
|
||||
combined["output_tokens"] += ext.get("output_tokens", 0)
|
||||
return build_from_json(combined)
|
||||
return build_from_json(combined, directed=directed)
|
||||
|
||||
+18
-2
@@ -7,11 +7,27 @@ import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _body_content(content: bytes) -> bytes:
|
||||
"""Strip YAML frontmatter from Markdown content, returning only the body."""
|
||||
text = content.decode(errors="replace")
|
||||
if text.startswith("---"):
|
||||
end = text.find("\n---", 3)
|
||||
if end != -1:
|
||||
return text[end + 4:].encode()
|
||||
return content
|
||||
|
||||
|
||||
def file_hash(path: Path) -> str:
|
||||
"""SHA256 of file contents + resolved path. Prevents cache collisions on identical content."""
|
||||
"""SHA256 of file contents + resolved path. Prevents cache collisions on identical content.
|
||||
|
||||
For Markdown files (.md), only the body below the YAML frontmatter is hashed,
|
||||
so metadata-only changes (e.g. reviewed, status, tags) do not invalidate the cache.
|
||||
"""
|
||||
p = Path(path)
|
||||
raw = p.read_bytes()
|
||||
content = _body_content(raw) if p.suffix.lower() == ".md" else raw
|
||||
h = hashlib.sha256()
|
||||
h.update(p.read_bytes())
|
||||
h.update(content)
|
||||
h.update(b"\x00")
|
||||
h.update(str(p.resolve()).encode())
|
||||
return h.hexdigest()
|
||||
|
||||
@@ -62,9 +62,14 @@ def cluster(G: nx.Graph) -> dict[int, list[str]]:
|
||||
Community IDs are stable across runs: 0 = largest community after splitting.
|
||||
Oversized communities (> 25% of graph nodes, min 10) are split by running
|
||||
a second Leiden pass on the subgraph.
|
||||
|
||||
Accepts directed or undirected graphs. DiGraphs are converted to undirected
|
||||
internally since Louvain/Leiden require undirected input.
|
||||
"""
|
||||
if G.number_of_nodes() == 0:
|
||||
return {}
|
||||
if G.is_directed():
|
||||
G = G.to_undirected()
|
||||
if G.number_of_edges() == 0:
|
||||
return {i: [n] for i, n in enumerate(sorted(G.nodes))}
|
||||
|
||||
|
||||
+22
-9
@@ -250,21 +250,34 @@ def _is_noise_dir(part: str) -> bool:
|
||||
|
||||
|
||||
def _load_graphifyignore(root: Path) -> list[str]:
|
||||
"""Read .graphifyignore from root and return a list of patterns.
|
||||
"""Read .graphifyignore from root **and ancestor directories**, returning patterns.
|
||||
|
||||
Walks upward from *root* towards the filesystem root, collecting patterns
|
||||
from every ``.graphifyignore`` encountered (like ``.gitignore`` discovery).
|
||||
The search stops at the filesystem root or at a ``.git`` directory boundary
|
||||
so it doesn't leak outside the repository.
|
||||
|
||||
Lines starting with # are comments. Blank lines are ignored.
|
||||
Patterns follow gitignore semantics: glob matched against the path
|
||||
relative to root. A leading slash anchors to root. A trailing slash
|
||||
matches directories only (we match both dir and file for simplicity).
|
||||
"""
|
||||
ignore_file = root / ".graphifyignore"
|
||||
if not ignore_file.exists():
|
||||
return []
|
||||
patterns = []
|
||||
for line in ignore_file.read_text(errors="ignore").splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#"):
|
||||
patterns.append(line)
|
||||
patterns: list[str] = []
|
||||
current = root.resolve()
|
||||
while True:
|
||||
ignore_file = current / ".graphifyignore"
|
||||
if ignore_file.exists():
|
||||
for line in ignore_file.read_text(errors="ignore").splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#"):
|
||||
patterns.append(line)
|
||||
# Stop climbing once we've processed the git repo root
|
||||
if (current / ".git").exists():
|
||||
break
|
||||
parent = current.parent
|
||||
if parent == current:
|
||||
break # filesystem root
|
||||
current = parent
|
||||
return patterns
|
||||
|
||||
|
||||
|
||||
+5
-2
@@ -10,7 +10,7 @@ from graphify.security import validate_graph_path, sanitize_label
|
||||
|
||||
def _load_graph(graph_path: str) -> nx.Graph:
|
||||
try:
|
||||
safe = validate_graph_path(graph_path)
|
||||
safe = validate_graph_path(graph_path, base=Path(graph_path).resolve().parent)
|
||||
data = json.loads(safe.read_text())
|
||||
try:
|
||||
return json_graph.node_link_graph(data, edges="links")
|
||||
@@ -309,7 +309,10 @@ def serve(graph_path: str = "graphify-out/graph.json") -> None:
|
||||
handler = _handlers.get(name)
|
||||
if not handler:
|
||||
return [types.TextContent(type="text", text=f"Unknown tool: {name}")]
|
||||
return [types.TextContent(type="text", text=handler(arguments))]
|
||||
try:
|
||||
return [types.TextContent(type="text", text=handler(arguments))]
|
||||
except Exception as exc:
|
||||
return [types.TextContent(type="text", text=f"Error executing {name}: {exc}")]
|
||||
|
||||
import asyncio
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@ Turn any folder of files into a navigable knowledge graph with community detecti
|
||||
/graphify <path> # full pipeline on specific path
|
||||
/graphify <path> --mode deep # thorough extraction, richer INFERRED edges
|
||||
/graphify <path> --update # incremental - re-extract only new/changed files
|
||||
/graphify <path> --directed # build directed graph (preserves edge direction: source→target)
|
||||
/graphify <path> --cluster-only # rerun clustering on existing graph
|
||||
/graphify <path> --no-viz # skip visualization, just report + JSON
|
||||
/graphify <path> --html # (HTML is generated by default - this flag is a no-op)
|
||||
@@ -340,6 +341,8 @@ print(f'Merged: {total} nodes, {edges} edges ({len(ast[\"nodes\"])} AST + {len(s
|
||||
|
||||
### Step 4 - Build graph, cluster, analyze, generate outputs
|
||||
|
||||
**Before starting:** note whether `--directed` was given. If so, pass `directed=True` to `build_from_json()` in the code block below. This builds a `DiGraph` that preserves edge direction (source→target) instead of the default undirected `Graph`.
|
||||
|
||||
```bash
|
||||
mkdir -p graphify-out
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "graphifyy"
|
||||
version = "0.3.24"
|
||||
version = "0.3.25"
|
||||
description = "AI coding assistant skill (Claude Code, Codex, OpenCode, Cursor, OpenClaw, Factory Droid, Trae) - turn any folder of code, docs, papers, or images into a queryable knowledge graph"
|
||||
readme = "README.md"
|
||||
license = { file = "LICENSE" }
|
||||
@@ -57,4 +57,4 @@ where = ["."]
|
||||
include = ["graphify*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
graphify = ["skill.md", "skill-codex.md", "skill-opencode.md", "skill-claw.md", "skill-windows.md", "skill-droid.md", "skill-trae.md"]
|
||||
graphify = ["skill.md", "skill-codex.md", "skill-opencode.md", "skill-aider.md", "skill-copilot.md", "skill-claw.md", "skill-windows.md", "skill-droid.md", "skill-trae.md"]
|
||||
|
||||
+53
-1
@@ -1,7 +1,7 @@
|
||||
"""Tests for graphify/cache.py."""
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from graphify.cache import file_hash, cache_dir, load_cached, save_cached, cached_files, clear_cache
|
||||
from graphify.cache import file_hash, cache_dir, load_cached, save_cached, cached_files, clear_cache, _body_content
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -72,3 +72,55 @@ def test_clear_cache(tmp_file, cache_root):
|
||||
assert len(list((cache_root / "graphify-out" / "cache").glob("*.json"))) > 0
|
||||
clear_cache(cache_root)
|
||||
assert len(list((cache_root / "graphify-out" / "cache").glob("*.json"))) == 0
|
||||
|
||||
|
||||
def test_md_frontmatter_only_change_same_hash(tmp_path):
|
||||
"""Changing only frontmatter fields in a .md file does not change the hash."""
|
||||
f = tmp_path / "doc.md"
|
||||
f.write_text("---\nreviewed: 2026-01-01\n---\n\n# Title\n\nBody text.")
|
||||
h1 = file_hash(f)
|
||||
f.write_text("---\nreviewed: 2026-04-09\n---\n\n# Title\n\nBody text.")
|
||||
h2 = file_hash(f)
|
||||
assert h1 == h2
|
||||
|
||||
|
||||
def test_md_body_change_different_hash(tmp_path):
|
||||
"""Changing the body of a .md file produces a different hash."""
|
||||
f = tmp_path / "doc.md"
|
||||
f.write_text("---\nreviewed: 2026-01-01\n---\n\n# Title\n\nOriginal body.")
|
||||
h1 = file_hash(f)
|
||||
f.write_text("---\nreviewed: 2026-01-01\n---\n\n# Title\n\nChanged body.")
|
||||
h2 = file_hash(f)
|
||||
assert h1 != h2
|
||||
|
||||
|
||||
def test_md_no_frontmatter_hashed_normally(tmp_path):
|
||||
"""A .md file with no frontmatter is hashed by its full content."""
|
||||
f = tmp_path / "doc.md"
|
||||
f.write_text("# Just a heading\n\nNo frontmatter here.")
|
||||
h1 = file_hash(f)
|
||||
f.write_text("# Just a heading\n\nDifferent content.")
|
||||
h2 = file_hash(f)
|
||||
assert h1 != h2
|
||||
|
||||
|
||||
def test_non_md_file_hashed_fully(tmp_path):
|
||||
"""Non-.md files are still hashed by their full content."""
|
||||
f = tmp_path / "script.py"
|
||||
f.write_text("# comment\nx = 1")
|
||||
h1 = file_hash(f)
|
||||
f.write_text("# changed comment\nx = 1")
|
||||
h2 = file_hash(f)
|
||||
assert h1 != h2
|
||||
|
||||
|
||||
def test_body_content_strips_frontmatter():
|
||||
"""_body_content correctly strips YAML frontmatter."""
|
||||
content = b"---\ntitle: Test\n---\n\nActual body."
|
||||
assert _body_content(content) == b"\n\nActual body."
|
||||
|
||||
|
||||
def test_body_content_no_frontmatter():
|
||||
"""_body_content returns content unchanged when no frontmatter present."""
|
||||
content = b"No frontmatter here."
|
||||
assert _body_content(content) == content
|
||||
|
||||
@@ -138,6 +138,59 @@ def test_detect_follows_symlinked_file(tmp_path):
|
||||
assert any("link.py" in f for f in code)
|
||||
|
||||
|
||||
def test_graphifyignore_discovered_from_parent(tmp_path):
|
||||
"""A .graphifyignore in a parent directory applies to subdirectory scans."""
|
||||
(tmp_path / ".graphifyignore").write_text("vendor/\n")
|
||||
sub = tmp_path / "packages" / "mylib"
|
||||
sub.mkdir(parents=True)
|
||||
(sub / "main.py").write_text("x = 1")
|
||||
vendor = sub / "vendor"
|
||||
vendor.mkdir()
|
||||
(vendor / "dep.py").write_text("y = 2")
|
||||
|
||||
result = detect(sub)
|
||||
code_files = result["files"]["code"]
|
||||
assert any("main.py" in f for f in code_files)
|
||||
assert not any("vendor" in f for f in code_files)
|
||||
assert result["graphifyignore_patterns"] >= 1
|
||||
|
||||
|
||||
def test_graphifyignore_stops_at_git_boundary(tmp_path):
|
||||
"""Upward search stops at the git repo root (.git directory)."""
|
||||
(tmp_path / ".graphifyignore").write_text("main.py\n")
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
(repo / ".git").mkdir()
|
||||
sub = repo / "sub"
|
||||
sub.mkdir()
|
||||
(sub / "main.py").write_text("x = 1")
|
||||
|
||||
result = detect(sub)
|
||||
code_files = result["files"]["code"]
|
||||
assert any("main.py" in f for f in code_files)
|
||||
assert result["graphifyignore_patterns"] == 0
|
||||
|
||||
|
||||
def test_graphifyignore_at_git_root_is_included(tmp_path):
|
||||
"""A .graphifyignore at the git repo root is included when scanning a subdir."""
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
(repo / ".git").mkdir()
|
||||
(repo / ".graphifyignore").write_text("vendor/\n")
|
||||
sub = repo / "packages" / "mylib"
|
||||
sub.mkdir(parents=True)
|
||||
(sub / "main.py").write_text("x = 1")
|
||||
vendor = sub / "vendor"
|
||||
vendor.mkdir()
|
||||
(vendor / "dep.py").write_text("y = 2")
|
||||
|
||||
result = detect(sub)
|
||||
code_files = result["files"]["code"]
|
||||
assert any("main.py" in f for f in code_files)
|
||||
assert not any("vendor" in f for f in code_files)
|
||||
assert result["graphifyignore_patterns"] == 1
|
||||
|
||||
|
||||
def test_detect_handles_circular_symlinks(tmp_path):
|
||||
sub = tmp_path / "a"
|
||||
sub.mkdir()
|
||||
|
||||
Reference in New Issue
Block a user