mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-29 01:36:33 +00:00
skill.md with full pipeline steps, Obsidian as default output (canvas, tags, dataview, graph colors), two-command install, 71 tests, .gitignore, deps
248 lines
8.6 KiB
Python
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
|