fix .graphifyignore: correct gitignore semantics + hermetic non-VCS scan + skill auto-invoke

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Safi
2026-05-01 18:45:12 +01:00
co-authored by Claude Sonnet 4.6
parent 8a6306f769
commit 7f336acfd9
3 changed files with 96 additions and 37 deletions
+74 -34
View File
@@ -347,38 +347,70 @@ def _is_noise_dir(part: str) -> bool:
return False
def _load_graphifyignore(root: Path) -> list[tuple[Path, str]]:
"""Read .graphifyignore from root **and ancestor directories**.
_VCS_MARKERS = (".git", ".hg", ".svn", "_darcs", ".fossil")
Returns a list of (anchor_dir, pattern) pairs. Each pattern is matched
against paths relative to both the scan root and the anchor_dir where
the .graphifyignore file was found — so patterns written relative to a
parent directory still work when graphify is run on a subfolder.
Walks upward from *root* stopping at the nearest VCS root (.git, .hg, etc.)
— never crosses a VCS boundary into a different repository. If no VCS root
is found, walks up to the home directory as a safety limit.
Lines starting with # are comments; blank lines ignored.
def _parse_gitignore_line(raw: str) -> str:
"""Parse one raw line from a .graphifyignore file per gitignore spec.
- Strip newline chars
- Remove trailing spaces unless escaped with backslash
- Strip leading whitespace
- Return empty string for blank lines and comments
"""
_VCS_MARKERS = (".git", ".hg", ".svn", "_darcs", ".fossil")
home = Path.home()
line = raw.rstrip("\n\r")
# Remove unescaped trailing spaces (per gitignore spec)
line = re.sub(r"(?<!\\) +$", "", line)
line = line.lstrip()
if not line or line.startswith("#"):
return ""
return line
patterns: list[tuple[Path, str]] = []
current = root.resolve()
def _find_vcs_root(start: Path) -> Path | None:
"""Walk upward from start; return the first directory containing a VCS marker."""
current = start.resolve()
home = Path.home()
while True:
ignore_file = current / ".graphifyignore"
if ignore_file.exists():
for line in ignore_file.read_text(encoding="utf-8", errors="ignore").splitlines():
line = line.strip()
if line and not line.startswith("#"):
patterns.append((current, line))
# Stop once we've processed a VCS root — never walk above it
if any((current / marker).exists() for marker in _VCS_MARKERS):
break
if any((current / m).exists() for m in _VCS_MARKERS):
return current
parent = current.parent
if parent == current or current == home:
break
return None
current = parent
def _load_graphifyignore(root: Path) -> list[tuple[Path, str]]:
"""Read .graphifyignore files and return (anchor_dir, pattern) pairs.
Patterns are returned outer-first so that inner (closer) rules are
appended last and win via last-match-wins semantics — matching gitignore
behavior exactly.
Walk ceiling: the nearest VCS root if inside a repo, otherwise the scan
root itself (hermetic — no leakage across unrelated sibling projects).
"""
root = root.resolve()
ceiling = _find_vcs_root(root) or root
# Collect ancestor dirs from ceiling down to root (outer → inner)
dirs: list[Path] = []
current = root
while True:
dirs.append(current)
if current == ceiling:
break
current = current.parent
dirs.reverse() # ceiling first, scan root last
patterns: list[tuple[Path, str]] = []
for d in dirs:
ignore_file = d / ".graphifyignore"
if ignore_file.exists():
for raw in ignore_file.read_text(encoding="utf-8", errors="ignore").splitlines():
line = _parse_gitignore_line(raw)
if line:
patterns.append((d, line))
return patterns
@@ -401,25 +433,33 @@ def _is_ignored(path: Path, root: Path, patterns: list[tuple[Path, str]]) -> boo
return False
for anchor, pattern in patterns:
anchored = pattern.startswith("/")
p = pattern.strip("/")
if not p:
continue
# Try path relative to the scan root
try:
rel = str(path.relative_to(root)).replace(os.sep, "/")
if _matches(rel, p):
return True
except ValueError:
pass
# Also try relative to the anchor dir (the .graphifyignore's location),
# so patterns written at a parent level still fire when running on a subfolder
if anchor != root:
if anchored:
# Anchored patterns are relative to the .graphifyignore's own dir only
try:
rel_anchor = str(path.relative_to(anchor)).replace(os.sep, "/")
if _matches(rel_anchor, p):
return True
except ValueError:
pass
else:
# Non-anchored: try relative to scan root first, then anchor
try:
rel = str(path.relative_to(root)).replace(os.sep, "/")
if _matches(rel, p):
return True
except ValueError:
pass
if anchor != root:
try:
rel_anchor = str(path.relative_to(anchor)).replace(os.sep, "/")
if _matches(rel_anchor, p):
return True
except ValueError:
pass
return False
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: graphify
description: "any input (code, docs, papers, images) - knowledge graph - clustered communities - HTML + JSON + audit report"
description: "Use when the user asks any question about a codebase, documents, papers, images, or any content in a project - especially if graphify-out/ exists, treat it as a /graphify query. Also use to build a knowledge graph from any folder of files."
trigger: /graphify
---
+21 -2
View File
@@ -138,8 +138,27 @@ 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."""
def test_graphifyignore_hermetic_without_vcs(tmp_path):
"""Without a VCS root, parent .graphifyignore does NOT apply (hermetic)."""
(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)
# parent .graphifyignore must NOT leak into a non-VCS scan
assert any("vendor" in f for f in code_files)
assert result["graphifyignore_patterns"] == 0
def test_graphifyignore_discovered_from_parent_in_vcs(tmp_path):
"""Inside a VCS repo, parent .graphifyignore applies to subdirectory scans."""
(tmp_path / ".git").mkdir()
(tmp_path / ".graphifyignore").write_text("vendor/\n")
sub = tmp_path / "packages" / "mylib"
sub.mkdir(parents=True)