mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-13 02:47:00 +00:00
f065933391
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
834 lines
30 KiB
Python
834 lines
30 KiB
Python
# file discovery, type classification, and corpus health checks
|
|
from __future__ import annotations
|
|
import fnmatch
|
|
import json
|
|
import os
|
|
import re
|
|
from enum import Enum
|
|
from pathlib import Path
|
|
|
|
|
|
class FileType(str, Enum):
|
|
CODE = "code"
|
|
DOCUMENT = "document"
|
|
PAPER = "paper"
|
|
IMAGE = "image"
|
|
VIDEO = "video"
|
|
|
|
|
|
_MANIFEST_PATH = "graphify-out/manifest.json"
|
|
|
|
CODE_EXTENSIONS = {'.py', '.ts', '.js', '.jsx', '.tsx', '.mjs', '.ejs', '.go', '.rs', '.java', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.toc', '.zig', '.ps1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.dart', '.v', '.sv', '.sql', '.r'}
|
|
DOC_EXTENSIONS = {'.md', '.mdx', '.txt', '.rst', '.html', '.yaml', '.yml'}
|
|
PAPER_EXTENSIONS = {'.pdf'}
|
|
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}
|
|
OFFICE_EXTENSIONS = {'.docx', '.xlsx'}
|
|
VIDEO_EXTENSIONS = {'.mp4', '.mov', '.webm', '.mkv', '.avi', '.m4v', '.mp3', '.wav', '.m4a', '.ogg'}
|
|
|
|
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
|
|
return any(p.search(name) 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(encoding="utf-8", errors="ignore")[:3000]
|
|
hits = sum(1 for pattern in _PAPER_SIGNALS if pattern.search(text))
|
|
return hits >= _PAPER_SIGNAL_THRESHOLD
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
_ASSET_DIR_MARKERS = {".imageset", ".xcassets", ".appiconset", ".colorset", ".launchimage"}
|
|
|
|
|
|
_SHEBANG_CODE_INTERPRETERS = {
|
|
"python", "python3", "python2",
|
|
"ruby", "perl", "node", "nodejs",
|
|
"bash", "sh", "dash", "zsh", "fish", "ksh", "tcsh",
|
|
"lua", "php", "julia", "Rscript",
|
|
}
|
|
|
|
|
|
def _shebang_file_type(path: Path) -> FileType | None:
|
|
"""Peek at the first line of an extensionless file for a shebang."""
|
|
try:
|
|
with path.open("rb") as f:
|
|
first = f.read(128)
|
|
if not first.startswith(b"#!"):
|
|
return None
|
|
line = first.split(b"\n")[0].decode(errors="replace")
|
|
parts = line[2:].strip().split()
|
|
if not parts:
|
|
return None
|
|
interp = parts[0].split("/")[-1] # /usr/bin/env → env
|
|
if interp == "env" and len(parts) > 1:
|
|
interp = parts[1].split("/")[-1]
|
|
if interp in _SHEBANG_CODE_INTERPRETERS:
|
|
return FileType.CODE
|
|
except OSError:
|
|
pass
|
|
return None
|
|
|
|
|
|
def classify_file(path: Path) -> FileType | None:
|
|
# Compound extensions must be checked before simple suffix lookup
|
|
if path.name.lower().endswith(".blade.php"):
|
|
return FileType.CODE
|
|
ext = path.suffix.lower()
|
|
if not ext:
|
|
return _shebang_file_type(path)
|
|
if ext in CODE_EXTENSIONS:
|
|
return FileType.CODE
|
|
if ext in PAPER_EXTENSIONS:
|
|
# PDFs inside Xcode asset catalogs are vector icons, not papers
|
|
if any(part.endswith(tuple(_ASSET_DIR_MARKERS)) for part in path.parts):
|
|
return None
|
|
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
|
|
if ext in OFFICE_EXTENSIONS:
|
|
return FileType.DOCUMENT
|
|
if ext in VIDEO_EXTENSIONS:
|
|
return FileType.VIDEO
|
|
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 docx_to_markdown(path: Path) -> str:
|
|
"""Convert a .docx file to markdown text using python-docx."""
|
|
try:
|
|
from docx import Document
|
|
from docx.oxml.ns import qn
|
|
doc = Document(str(path))
|
|
lines = []
|
|
for para in doc.paragraphs:
|
|
style = para.style.name if para.style else ""
|
|
text = para.text.strip()
|
|
if not text:
|
|
lines.append("")
|
|
continue
|
|
if style.startswith("Heading 1"):
|
|
lines.append(f"# {text}")
|
|
elif style.startswith("Heading 2"):
|
|
lines.append(f"## {text}")
|
|
elif style.startswith("Heading 3"):
|
|
lines.append(f"### {text}")
|
|
elif style.startswith("List"):
|
|
lines.append(f"- {text}")
|
|
else:
|
|
lines.append(text)
|
|
# Tables
|
|
for table in doc.tables:
|
|
rows = [[cell.text.strip() for cell in row.cells] for row in table.rows]
|
|
if not rows:
|
|
continue
|
|
header = "| " + " | ".join(rows[0]) + " |"
|
|
sep = "| " + " | ".join("---" for _ in rows[0]) + " |"
|
|
lines.extend([header, sep])
|
|
for row in rows[1:]:
|
|
lines.append("| " + " | ".join(row) + " |")
|
|
return "\n".join(lines)
|
|
except ImportError:
|
|
return ""
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
def xlsx_to_markdown(path: Path) -> str:
|
|
"""Convert an .xlsx file to markdown text using openpyxl."""
|
|
try:
|
|
import openpyxl
|
|
wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True)
|
|
sections = []
|
|
for sheet_name in wb.sheetnames:
|
|
ws = wb[sheet_name]
|
|
rows = []
|
|
for row in ws.iter_rows(values_only=True):
|
|
if all(cell is None for cell in row):
|
|
continue
|
|
rows.append([str(cell) if cell is not None else "" for cell in row])
|
|
if not rows:
|
|
continue
|
|
sections.append(f"## Sheet: {sheet_name}")
|
|
if len(rows) >= 1:
|
|
header = "| " + " | ".join(rows[0]) + " |"
|
|
sep = "| " + " | ".join("---" for _ in rows[0]) + " |"
|
|
sections.extend([header, sep])
|
|
for row in rows[1:]:
|
|
sections.append("| " + " | ".join(row) + " |")
|
|
wb.close()
|
|
return "\n".join(sections)
|
|
except ImportError:
|
|
return ""
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
def xlsx_extract_structure(path: Path) -> dict:
|
|
"""Extract structural nodes (sheets, named tables, column headers) from an .xlsx file.
|
|
|
|
Returns a nodes/edges dict compatible with the graphify extract pipeline.
|
|
Used in addition to xlsx_to_markdown so Claude sees both structure and content.
|
|
"""
|
|
def _nid(*parts: str) -> str:
|
|
return re.sub(r"[^a-z0-9_]", "_", "_".join(p.lower() for p in parts).strip("_"))
|
|
|
|
try:
|
|
import openpyxl
|
|
except ImportError:
|
|
return {"nodes": [], "edges": []}
|
|
|
|
try:
|
|
wb = openpyxl.load_workbook(str(path), read_only=False, data_only=True)
|
|
except Exception:
|
|
return {"nodes": [], "edges": []}
|
|
|
|
stem = _re.sub(r"[^a-z0-9]", "_", path.stem.lower())
|
|
str_path = str(path)
|
|
file_nid = _nid(str_path)
|
|
nodes: list[dict] = [{"id": file_nid, "label": path.name, "file_type": "document",
|
|
"source_file": str_path, "source_location": None}]
|
|
edges: list[dict] = []
|
|
seen: set[str] = {file_nid}
|
|
|
|
def _add(nid: str, label: str) -> None:
|
|
if nid not in seen:
|
|
seen.add(nid)
|
|
nodes.append({"id": nid, "label": label, "file_type": "document",
|
|
"source_file": str_path, "source_location": None})
|
|
|
|
def _edge(src: str, tgt: str, relation: str) -> None:
|
|
edges.append({"source": src, "target": tgt, "relation": relation,
|
|
"confidence": "EXTRACTED", "source_file": str_path,
|
|
"source_location": None, "weight": 1.0})
|
|
|
|
for sheet_name in wb.sheetnames:
|
|
ws = wb[sheet_name]
|
|
sheet_nid = _nid(stem, sheet_name)
|
|
_add(sheet_nid, f"{sheet_name} (sheet)")
|
|
_edge(file_nid, sheet_nid, "contains")
|
|
|
|
# Named Excel Tables (ListObjects)
|
|
if hasattr(ws, "tables"):
|
|
for tbl in ws.tables.values():
|
|
tbl_nid = _nid(stem, sheet_name, tbl.name)
|
|
_add(tbl_nid, tbl.name)
|
|
_edge(sheet_nid, tbl_nid, "contains")
|
|
# Column headers from table header row
|
|
ref = tbl.ref # e.g. "A1:D10"
|
|
if ref:
|
|
try:
|
|
from openpyxl.utils import range_boundaries
|
|
min_col, min_row, max_col, _ = range_boundaries(ref)
|
|
header_row = list(ws.iter_rows(min_row=min_row, max_row=min_row,
|
|
min_col=min_col, max_col=max_col,
|
|
values_only=True))
|
|
if header_row:
|
|
for col_name in header_row[0]:
|
|
if col_name:
|
|
col_nid = _nid(stem, tbl.name, str(col_name))
|
|
_add(col_nid, str(col_name))
|
|
_edge(tbl_nid, col_nid, "contains")
|
|
except Exception:
|
|
pass
|
|
else:
|
|
# Fallback: first non-empty row as column headers
|
|
for row in ws.iter_rows(max_row=1, values_only=True):
|
|
for cell in row:
|
|
if cell:
|
|
col_nid = _nid(stem, sheet_name, str(cell))
|
|
_add(col_nid, str(cell))
|
|
_edge(sheet_nid, col_nid, "contains")
|
|
break
|
|
|
|
try:
|
|
wb.close()
|
|
except Exception:
|
|
pass
|
|
|
|
return {"nodes": nodes, "edges": edges}
|
|
|
|
|
|
def convert_office_file(path: Path, out_dir: Path) -> Path | None:
|
|
"""Convert a .docx or .xlsx to a markdown sidecar in out_dir.
|
|
|
|
Returns the path of the converted .md file, or None if conversion failed
|
|
or the required library is not installed.
|
|
"""
|
|
ext = path.suffix.lower()
|
|
if ext == ".docx":
|
|
text = docx_to_markdown(path)
|
|
elif ext == ".xlsx":
|
|
text = xlsx_to_markdown(path)
|
|
else:
|
|
return None
|
|
|
|
if not text.strip():
|
|
return None
|
|
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
# Use a stable name derived from the original path to avoid collisions
|
|
import hashlib
|
|
name_hash = hashlib.sha256(str(path.resolve()).encode()).hexdigest()[:8]
|
|
out_path = out_dir / f"{path.stem}_{name_hash}.md"
|
|
out_path.write_text(
|
|
f"<!-- converted from {path.name} -->\n\n{text}",
|
|
encoding="utf-8",
|
|
)
|
|
return out_path
|
|
|
|
|
|
def count_words(path: Path) -> int:
|
|
try:
|
|
ext = path.suffix.lower()
|
|
if ext == ".pdf":
|
|
return len(extract_pdf_text(path).split())
|
|
if ext == ".docx":
|
|
return len(docx_to_markdown(path).split())
|
|
if ext == ".xlsx":
|
|
return len(xlsx_to_markdown(path).split())
|
|
return len(path.read_text(encoding="utf-8", 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",
|
|
"graphify-out", # never treat own output as source input (#524)
|
|
}
|
|
|
|
# Large generated files that are never useful to extract
|
|
_SKIP_FILES = {
|
|
"package-lock.json", "yarn.lock", "pnpm-lock.yaml",
|
|
"Cargo.lock", "poetry.lock", "Gemfile.lock",
|
|
"composer.lock", "go.sum", "go.work.sum",
|
|
}
|
|
|
|
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
|
|
|
|
|
|
_VCS_MARKERS = (".git", ".hg", ".svn", "_darcs", ".fossil")
|
|
|
|
|
|
def _parse_gitignore_line(raw: str) -> str:
|
|
"""Parse one raw line from a .graphifyignore file per gitignore spec.
|
|
|
|
- Strip newline chars
|
|
- Strip inline comments (whitespace + # suffix), but only when # is
|
|
preceded by whitespace — so path#with#hash.py is preserved
|
|
- Unescape \\# to literal #
|
|
- Remove trailing spaces unless escaped with backslash
|
|
- Strip leading whitespace
|
|
- Return empty string for blank lines and full-line comments
|
|
"""
|
|
line = raw.rstrip("\n\r")
|
|
line = line.lstrip()
|
|
if not line or line.startswith("#"):
|
|
return ""
|
|
# Strip inline comments: require whitespace before # (gitignore extension)
|
|
line = re.sub(r"\s+#+[^\\].*$", "", line)
|
|
# Unescape \# → literal #
|
|
line = line.replace("\\#", "#")
|
|
# Remove unescaped trailing spaces (per gitignore spec)
|
|
line = re.sub(r"(?<!\\) +$", "", line)
|
|
return line
|
|
|
|
|
|
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:
|
|
if any((current / m).exists() for m in _VCS_MARKERS):
|
|
return current
|
|
parent = current.parent
|
|
if parent == current or current == home:
|
|
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
|
|
|
|
|
|
def _is_ignored(path: Path, root: Path, patterns: list[tuple[Path, str]]) -> bool:
|
|
"""Return True if the path should be ignored per .graphifyignore patterns.
|
|
|
|
Uses gitignore last-match-wins semantics: all patterns are evaluated in
|
|
order; the final matching pattern determines the result. Negation patterns
|
|
(starting with !) un-ignore a previously ignored path.
|
|
"""
|
|
if not patterns:
|
|
return False
|
|
|
|
def _matches(rel: str, p: str) -> bool:
|
|
parts = rel.split("/")
|
|
if fnmatch.fnmatch(rel, p):
|
|
return True
|
|
if fnmatch.fnmatch(path.name, p):
|
|
return True
|
|
for i, part in enumerate(parts):
|
|
if fnmatch.fnmatch(part, p):
|
|
return True
|
|
if fnmatch.fnmatch("/".join(parts[:i + 1]), p):
|
|
return True
|
|
return False
|
|
|
|
result = False
|
|
for anchor, pattern in patterns:
|
|
negated = pattern.startswith("!")
|
|
raw = pattern[1:] if negated else pattern
|
|
anchored = raw.startswith("/")
|
|
p = raw.strip("/")
|
|
if not p:
|
|
continue
|
|
|
|
matched = False
|
|
if anchored:
|
|
try:
|
|
rel_anchor = str(path.relative_to(anchor)).replace(os.sep, "/")
|
|
matched = _matches(rel_anchor, p)
|
|
except ValueError:
|
|
pass
|
|
else:
|
|
try:
|
|
rel = str(path.relative_to(root)).replace(os.sep, "/")
|
|
matched = _matches(rel, p)
|
|
except ValueError:
|
|
pass
|
|
if not matched and anchor != root:
|
|
try:
|
|
rel_anchor = str(path.relative_to(anchor)).replace(os.sep, "/")
|
|
matched = _matches(rel_anchor, p)
|
|
except ValueError:
|
|
pass
|
|
|
|
if matched:
|
|
result = not negated # last match wins; ! flips to un-ignore
|
|
return result
|
|
|
|
|
|
def _load_graphifyinclude(root: Path) -> list[tuple[Path, str]]:
|
|
"""Read .graphifyinclude allowlist patterns from root and ancestors.
|
|
|
|
Include patterns opt matching hidden files/dirs into traversal. Sensitive
|
|
files and hard-skipped noise directories are still excluded later.
|
|
Uses the same VCS-root ceiling logic as _load_graphifyignore.
|
|
"""
|
|
root = root.resolve()
|
|
ceiling = _find_vcs_root(root) or root
|
|
|
|
dirs: list[Path] = []
|
|
current = root
|
|
while True:
|
|
dirs.append(current)
|
|
if current == ceiling:
|
|
break
|
|
current = current.parent
|
|
dirs.reverse()
|
|
|
|
patterns: list[tuple[Path, str]] = []
|
|
for d in dirs:
|
|
include_file = d / ".graphifyinclude"
|
|
if include_file.exists():
|
|
for raw in include_file.read_text(encoding="utf-8", errors="ignore").splitlines():
|
|
line = _parse_gitignore_line(raw)
|
|
if line:
|
|
patterns.append((d, line))
|
|
return patterns
|
|
|
|
|
|
def _is_included(path: Path, root: Path, patterns: list[tuple[Path, str]]) -> bool:
|
|
"""Return True if path matches any .graphifyinclude allowlist pattern."""
|
|
if not patterns:
|
|
return False
|
|
|
|
def _matches(rel: str, p: str) -> bool:
|
|
parts = rel.split("/")
|
|
if fnmatch.fnmatch(rel, p):
|
|
return True
|
|
if fnmatch.fnmatch(path.name, p):
|
|
return True
|
|
for i, part in enumerate(parts):
|
|
if fnmatch.fnmatch(part, p):
|
|
return True
|
|
if fnmatch.fnmatch("/".join(parts[:i + 1]), p):
|
|
return True
|
|
return False
|
|
|
|
for anchor, pattern in patterns:
|
|
anchored = pattern.startswith("/")
|
|
p = pattern.strip("/")
|
|
if not p:
|
|
continue
|
|
if anchored:
|
|
try:
|
|
rel_anchor = str(path.relative_to(anchor)).replace(os.sep, "/")
|
|
if _matches(rel_anchor, p):
|
|
return True
|
|
except ValueError:
|
|
pass
|
|
else:
|
|
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
|
|
|
|
|
|
def _could_contain_included_path(path: Path, root: Path, patterns: list[tuple[Path, str]]) -> bool:
|
|
"""Return True if a directory may contain files matched by .graphifyinclude."""
|
|
if not patterns:
|
|
return False
|
|
|
|
rels: list[str] = []
|
|
try:
|
|
rels.append(str(path.relative_to(root)).replace(os.sep, "/"))
|
|
except ValueError:
|
|
pass
|
|
for anchor, _ in patterns:
|
|
if anchor != root:
|
|
try:
|
|
rels.append(str(path.relative_to(anchor)).replace(os.sep, "/"))
|
|
except ValueError:
|
|
pass
|
|
|
|
for rel in rels:
|
|
rel = rel.strip("/")
|
|
if not rel:
|
|
return True
|
|
for _, pattern in patterns:
|
|
p = pattern.strip("/")
|
|
if not p:
|
|
continue
|
|
if p == rel or p.startswith(rel + "/"):
|
|
return True
|
|
if fnmatch.fnmatch(rel, p):
|
|
return True
|
|
return False
|
|
|
|
|
|
def detect(root: Path, *, follow_symlinks: bool = False) -> dict:
|
|
root = root.resolve()
|
|
files: dict[FileType, list[str]] = {
|
|
FileType.CODE: [],
|
|
FileType.DOCUMENT: [],
|
|
FileType.PAPER: [],
|
|
FileType.IMAGE: [],
|
|
FileType.VIDEO: [],
|
|
}
|
|
total_words = 0
|
|
|
|
skipped_sensitive: list[str] = []
|
|
ignore_patterns = _load_graphifyignore(root)
|
|
include_patterns = _load_graphifyinclude(root)
|
|
|
|
# Always include graphify-out/memory/ - query results filed back into the graph
|
|
memory_dir = root / "graphify-out" / "memory"
|
|
scan_paths = [root]
|
|
if memory_dir.exists():
|
|
scan_paths.append(memory_dir)
|
|
|
|
seen: set[Path] = set()
|
|
all_files: list[Path] = []
|
|
|
|
for scan_root in scan_paths:
|
|
in_memory_tree = memory_dir.exists() and str(scan_root).startswith(str(memory_dir))
|
|
for dirpath, dirnames, filenames in os.walk(scan_root, followlinks=follow_symlinks):
|
|
dp = Path(dirpath)
|
|
if follow_symlinks and os.path.islink(dirpath):
|
|
real = os.path.realpath(dirpath)
|
|
parent_real = os.path.realpath(os.path.dirname(dirpath))
|
|
if parent_real == real or parent_real.startswith(real + os.sep):
|
|
dirnames.clear()
|
|
continue
|
|
if not in_memory_tree:
|
|
# Prune noise dirs in-place so os.walk never descends into them.
|
|
# Hidden dirs are allowed through if they could contain an
|
|
# explicitly included path (.graphifyinclude allowlist).
|
|
# When negation patterns (!) exist, skip directory-level ignore
|
|
# pruning so negated files inside can still be reached.
|
|
has_negation = any(p.startswith("!") for _, p in ignore_patterns)
|
|
dirnames[:] = [
|
|
d for d in dirnames
|
|
if (not d.startswith(".") or _could_contain_included_path(dp / d, root, include_patterns))
|
|
and not _is_noise_dir(d)
|
|
and (has_negation or not _is_ignored(dp / d, root, ignore_patterns))
|
|
]
|
|
for fname in filenames:
|
|
if fname in _SKIP_FILES:
|
|
continue
|
|
p = dp / fname
|
|
if p not in seen:
|
|
seen.add(p)
|
|
all_files.append(p)
|
|
|
|
converted_dir = root / "graphify-out" / "converted"
|
|
|
|
for p in all_files:
|
|
# For memory dir files, skip hidden/noise filtering
|
|
in_memory = memory_dir.exists() and str(p).startswith(str(memory_dir))
|
|
if not in_memory:
|
|
# Hidden files are already excluded via dir pruning above,
|
|
# but catch hidden files at the root level. A .graphifyinclude
|
|
# entry can opt a specific hidden file back in.
|
|
if p.name.startswith(".") and not _is_included(p, root, include_patterns):
|
|
continue
|
|
# Skip files inside our own converted/ dir (avoid re-processing sidecars)
|
|
if str(p).startswith(str(converted_dir)):
|
|
continue
|
|
if _is_ignored(p, root, ignore_patterns):
|
|
continue
|
|
if _is_sensitive(p):
|
|
skipped_sensitive.append(str(p))
|
|
continue
|
|
ftype = classify_file(p)
|
|
if ftype:
|
|
# Office files: convert to markdown sidecar so subagents can read them
|
|
if p.suffix.lower() in OFFICE_EXTENSIONS:
|
|
md_path = convert_office_file(p, converted_dir)
|
|
if md_path:
|
|
files[ftype].append(str(md_path))
|
|
total_words += count_words(md_path)
|
|
else:
|
|
# Conversion failed (library not installed) - skip with note
|
|
skipped_sensitive.append(str(p) + " [office conversion failed - pip install graphifyy[office]]")
|
|
continue
|
|
files[ftype].append(str(p))
|
|
if ftype != FileType.VIDEO:
|
|
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,
|
|
"graphifyignore_patterns": len(ignore_patterns),
|
|
}
|
|
|
|
|
|
def _md5_file(path: Path) -> str:
|
|
"""MD5 of file contents streamed in 64KB chunks — for change detection only."""
|
|
import hashlib as _hl
|
|
h = _hl.md5(usedforsecurity=False)
|
|
try:
|
|
with path.open("rb") as f:
|
|
for chunk in iter(lambda: f.read(65536), b""):
|
|
h.update(chunk)
|
|
except OSError:
|
|
return ""
|
|
return h.hexdigest()
|
|
|
|
|
|
def load_manifest(manifest_path: str = _MANIFEST_PATH) -> dict:
|
|
"""Load the manifest from a previous run. Returns {} on any error."""
|
|
try:
|
|
return json.loads(Path(manifest_path).read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def save_manifest(files: dict[str, list[str]], manifest_path: str = _MANIFEST_PATH) -> None:
|
|
"""Save current file mtimes + content hashes for change detection on --update."""
|
|
manifest: dict[str, dict] = {}
|
|
for file_list in files.values():
|
|
for f in file_list:
|
|
try:
|
|
p = Path(f)
|
|
manifest[f] = {"mtime": p.stat().st_mtime, "hash": _md5_file(p)}
|
|
except OSError:
|
|
pass # file deleted between detect() and manifest write - skip it
|
|
Path(manifest_path).parent.mkdir(parents=True, exist_ok=True)
|
|
Path(manifest_path).write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
|
|
|
|
|
def detect_incremental(root: Path, manifest_path: str = _MANIFEST_PATH) -> dict:
|
|
"""Like detect(), but returns only new or modified files since the last run.
|
|
|
|
Fast path: mtime unchanged → unchanged (free, no hash).
|
|
Slow path: mtime bumped → compare MD5. Same hash = sync tool touched mtime,
|
|
treat as unchanged. Different hash = actually changed, re-extract.
|
|
|
|
Backwards compatible with legacy manifests storing plain float mtime values.
|
|
"""
|
|
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 = manifest.get(f)
|
|
try:
|
|
current_mtime = Path(f).stat().st_mtime
|
|
except Exception:
|
|
current_mtime = 0
|
|
|
|
# Legacy manifest: plain float value
|
|
if isinstance(stored, (int, float)):
|
|
changed = stored is None or current_mtime > stored
|
|
elif isinstance(stored, dict):
|
|
stored_mtime = stored.get("mtime")
|
|
if stored_mtime is None or current_mtime != stored_mtime:
|
|
# mtime bumped — verify with content hash before re-extracting
|
|
changed = _md5_file(Path(f)) != stored.get("hash", "")
|
|
else:
|
|
changed = False
|
|
else:
|
|
changed = True # unknown format, re-extract to be safe
|
|
|
|
if changed:
|
|
new_files[ftype].append(f)
|
|
else:
|
|
unchanged_files[ftype].append(f)
|
|
|
|
# Files in manifest that no longer exist - their cached nodes are now ghost nodes
|
|
current_files = {f for flist in full["files"].values() for f in flist}
|
|
deleted_files = [f for f in manifest if f not in current_files]
|
|
|
|
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
|
|
full["deleted_files"] = deleted_files
|
|
return full
|