From 7f336acfd94100e8bfb9eb65421e9d0597720293 Mon Sep 17 00:00:00 2001 From: Safi Date: Fri, 1 May 2026 18:45:12 +0100 Subject: [PATCH] fix .graphifyignore: correct gitignore semantics + hermetic non-VCS scan + skill auto-invoke Co-Authored-By: Claude Sonnet 4.6 --- graphify/detect.py | 108 +++++++++++++++++++++++++++++-------------- graphify/skill.md | 2 +- tests/test_detect.py | 23 ++++++++- 3 files changed, 96 insertions(+), 37 deletions(-) diff --git a/graphify/detect.py b/graphify/detect.py index 2fe8540..309f7f9 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -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"(? 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 diff --git a/graphify/skill.md b/graphify/skill.md index dde2fc3..06e3277 100644 --- a/graphify/skill.md +++ b/graphify/skill.md @@ -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 --- diff --git a/tests/test_detect.py b/tests/test_detect.py index ed43fea..6ded2fd 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -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)