Files
graphify/graphify/detect.py
T
Safi ce47198be1 feat: Claude Code skill, Obsidian vault, install, tests
skill.md with full pipeline steps, Obsidian as default output (canvas, tags,
dataview, graph colors), two-command install, 71 tests, .gitignore, deps
2026-04-04 18:53:43 +01:00

248 lines
8.6 KiB
Python

# file discovery, type classification, and corpus health checks
from __future__ import annotations
import json
import re
from enum import Enum
from pathlib import Path
class FileType(str, Enum):
CODE = "code"
DOCUMENT = "document"
PAPER = "paper"
IMAGE = "image"
_MANIFEST_PATH = ".graphify/manifest.json"
CODE_EXTENSIONS = {'.py', '.ts', '.js', '.go', '.rs', '.java', '.cpp', '.c', '.rb', '.swift', '.kt'}
DOC_EXTENSIONS = {'.md', '.txt', '.rst'}
PAPER_EXTENSIONS = {'.pdf'}
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}
CORPUS_WARN_THRESHOLD = 50_000 # words — below this, warn "you may not need a graph"
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
_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'(credential|secret|passwd|password|token|private_key)', 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),
]
# Signals that a .md/.txt file is actually a converted academic paper
_PAPER_SIGNALS = [
re.compile(r'\barxiv\b', re.IGNORECASE),
re.compile(r'\bdoi\s*:', re.IGNORECASE),
re.compile(r'\babstract\b', re.IGNORECASE),
re.compile(r'\bproceedings\b', re.IGNORECASE),
re.compile(r'\bjournal\b', re.IGNORECASE),
re.compile(r'\bpreprint\b', re.IGNORECASE),
re.compile(r'\\cite\{'), # LaTeX citation
re.compile(r'\[\d+\]'), # Numbered citation [1], [23] (inline)
re.compile(r'\[\n\d+\n\]'), # Numbered citation spread across lines (markdown conversion)
re.compile(r'eq\.\s*\d+|equation\s+\d+', re.IGNORECASE),
re.compile(r'\d{4}\.\d{4,5}'), # arXiv ID like 1706.03762
re.compile(r'\bwe propose\b', re.IGNORECASE), # common academic phrasing
re.compile(r'\bliterature\b', re.IGNORECASE), # "from the literature"
]
_PAPER_SIGNAL_THRESHOLD = 3 # need at least this many signals to call it a paper
def _is_sensitive(path: Path) -> bool:
"""Return True if this file likely contains secrets and should be skipped."""
name = path.name
full = str(path)
return any(p.search(name) or p.search(full) for p in _SENSITIVE_PATTERNS)
def _looks_like_paper(path: Path) -> bool:
"""Heuristic: does this text file read like an academic paper?"""
try:
# Only scan first 3000 chars for speed
text = path.read_text(errors="ignore")[:3000]
hits = sum(1 for pattern in _PAPER_SIGNALS if pattern.search(text))
return hits >= _PAPER_SIGNAL_THRESHOLD
except Exception:
return False
def classify_file(path: Path) -> FileType | None:
ext = path.suffix.lower()
if ext in CODE_EXTENSIONS:
return FileType.CODE
if ext in PAPER_EXTENSIONS:
return FileType.PAPER
if ext in IMAGE_EXTENSIONS:
return FileType.IMAGE
if ext in DOC_EXTENSIONS:
# Check if it's a converted paper
if _looks_like_paper(path):
return FileType.PAPER
return FileType.DOCUMENT
return None
def extract_pdf_text(path: Path) -> str:
"""Extract plain text from a PDF file using pypdf."""
try:
from pypdf import PdfReader
reader = PdfReader(str(path))
pages = []
for page in reader.pages:
text = page.extract_text()
if text:
pages.append(text)
return "\n".join(pages)
except Exception:
return ""
def count_words(path: Path) -> int:
try:
if path.suffix.lower() == ".pdf":
return len(extract_pdf_text(path).split())
return len(path.read_text(errors="ignore").split())
except Exception:
return 0
# Directory names to always skip — venvs, caches, build artifacts, deps
_SKIP_DIRS = {
"venv", ".venv", "env", ".env",
"node_modules", "__pycache__", ".git",
"dist", "build", "target", "out",
"site-packages", "lib64",
".pytest_cache", ".mypy_cache", ".ruff_cache",
".tox", ".eggs", "*.egg-info",
}
def _is_noise_dir(part: str) -> bool:
"""Return True if this directory name looks like a venv, cache, or dep dir."""
if part in _SKIP_DIRS:
return True
# Catch *_venv, *_repo/site-packages patterns
if part.endswith("_venv") or part.endswith("_env"):
return True
if part.endswith(".egg-info"):
return True
return False
def detect(root: Path) -> dict:
files: dict[FileType, list[str]] = {
FileType.CODE: [],
FileType.DOCUMENT: [],
FileType.PAPER: [],
FileType.IMAGE: [],
}
total_words = 0
skipped_sensitive: list[str] = []
for p in sorted(root.rglob("*")):
if not p.is_file():
continue
parts = p.relative_to(root).parts
# Skip hidden dirs and known noise dirs
if any(part.startswith(".") or _is_noise_dir(part) for part in parts):
continue
if _is_sensitive(p):
skipped_sensitive.append(str(p))
continue
ftype = classify_file(p)
if ftype:
files[ftype].append(str(p))
total_words += count_words(p)
total_files = sum(len(v) for v in files.values())
needs_graph = total_words >= CORPUS_WARN_THRESHOLD
# Determine warning — lower bound, upper bound, or sensitive files skipped
warning: str | None = None
if not needs_graph:
warning = (
f"Corpus is ~{total_words:,} words — fits in a single context window. "
f"You may not need a graph."
)
elif total_words >= CORPUS_UPPER_THRESHOLD or total_files >= FILE_COUNT_UPPER:
warning = (
f"Large corpus: {total_files} files · ~{total_words:,} words. "
f"Semantic extraction will be expensive (many Claude tokens). "
f"Consider running on a subfolder, or use --no-semantic to run AST-only."
)
return {
"files": {k.value: v for k, v in files.items()},
"total_files": total_files,
"total_words": total_words,
"needs_graph": needs_graph,
"warning": warning,
"skipped_sensitive": skipped_sensitive,
}
def load_manifest(manifest_path: str = _MANIFEST_PATH) -> dict[str, float]:
"""Load the file modification time manifest from a previous run."""
try:
return json.loads(Path(manifest_path).read_text())
except Exception:
return {}
def save_manifest(files: dict[str, list[str]], manifest_path: str = _MANIFEST_PATH) -> None:
"""Save current file mtimes so the next --update run can diff against them."""
manifest: dict[str, float] = {}
for file_list in files.values():
for f in file_list:
try:
manifest[f] = Path(f).stat().st_mtime
except Exception:
pass
Path(manifest_path).parent.mkdir(parents=True, exist_ok=True)
Path(manifest_path).write_text(json.dumps(manifest, indent=2))
def detect_incremental(root: Path, manifest_path: str = _MANIFEST_PATH) -> dict:
"""Like detect(), but returns only new or modified files since the last run.
Compares current file mtimes against the stored manifest.
Use for --update mode: re-extract only what changed, merge into existing graph.
"""
full = detect(root)
manifest = load_manifest(manifest_path)
if not manifest:
# No previous run — treat everything as new
full["incremental"] = True
full["new_files"] = full["files"]
full["unchanged_files"] = {k: [] for k in full["files"]}
full["new_total"] = full["total_files"]
return full
new_files: dict[str, list[str]] = {k: [] for k in full["files"]}
unchanged_files: dict[str, list[str]] = {k: [] for k in full["files"]}
for ftype, file_list in full["files"].items():
for f in file_list:
stored_mtime = manifest.get(f)
try:
current_mtime = Path(f).stat().st_mtime
except Exception:
current_mtime = 0
if stored_mtime is None or current_mtime > stored_mtime:
new_files[ftype].append(f)
else:
unchanged_files[ftype].append(f)
new_total = sum(len(v) for v in new_files.values())
full["incremental"] = True
full["new_files"] = new_files
full["unchanged_files"] = unchanged_files
full["new_total"] = new_total
return full