mirror of
https://github.com/Graphify-Labs/graphify.git
synced 2026-09-14 19:34:09 +08:00
32bf8b4a37
* feat(extract): add Pascal/Delphi language support Adds full AST extraction for Pascal and Delphi source files using tree-sitter-pascal (https://github.com/Isopod/tree-sitter-pascal). Supported file extensions: .pas, .pp, .dpr, .dpk, .inc Extracted nodes: - File node (the .pas file itself) - unit / program / library declarations - class, interface, and helper type declarations - procedure and function implementations Extracted edges: - file --contains--> module - module --imports--> dependency (via uses clause, resolved to path-based IDs) - class --inherits--> base class / interface - class/module --contains/method--> procedure or function - procedure --calls--> procedure (in-file call resolution) Key design: uses clause targets are resolved to path-based node IDs by scanning all Pascal files under the project root (_pascal_project_root + _pascal_resolve_unit helpers). This avoids dangling import edges that result from resolving bare unit names like "SysUtils" to IDs that never match any file node. Bare procedure calls (e.g. `Reset;` without parentheses) are detected by inspecting statement nodes whose sole named child is an identifier, in addition to the standard exprCall nodes used for calls with arguments. Requires: pip install tree-sitter-pascal (https://github.com/Isopod/tree-sitter-pascal) If not installed, extract_pascal returns {"nodes":[], "edges":[], "error": ...} so the rest of the pipeline is unaffected. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(extract): add Lazarus .lfm and .lpk file support Adds two new extractors for Lazarus IDE-specific file formats: extract_lazarus_form() — .lfm (Lazarus Form files) .lfm files are text-based UI component trees. The extractor parses `object Name: TClassName ... end` blocks to build a containment graph of form components, and captures `OnXxx = HandlerName` event bindings as `references` edges (context: "event") linking each component to its handler procedure. extract_lazarus_package() — .lpk (Lazarus Package files) .lpk files are XML package definitions. The extractor reads the package name, required package dependencies (→ imports edges), and listed unit files (→ contains edges). Unit names are resolved to path-based node IDs via _pascal_resolve_unit so they connect to the same nodes produced by extract_pascal on .pas files. Both extensions added to CODE_EXTENSIONS in detect.py and to _DISPATCH. 13 new tests in test_pascal.py cover both extractors. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(pascal): fix dangling inherits-edge targets and capture all base classes The declType/typeref handler built inherits edge targets with _make_id(_read(child)) — just the bare class name. But class nodes use _make_id(stem, type_name), so targets never matched, making the entire class hierarchy invisible in the graph. Add _pascal_class_stem_cache and _pascal_resolve_class(): strips the conventional T/I prefix, locates the defining file by stem lookup (same cache mechanism as _pascal_resolve_unit), and returns the correct _make_id(file_stem, class_name) ID. RTL/unresolvable bases (e.g. TObject) fall back to _make_id(bare_name) with an explicit stub node, following the same pattern as the Python extractor. Also remove the `break` that stopped after the first typeref, so all parents are captured (e.g. class(TBase, IInterface)). Extend test_pascal_no_dangling_edges to also assert that within-file edge targets (contains, method, inherits, calls) resolve to real nodes. * feat(extract): add Delphi .dfm form file support Adds extract_delphi_form() for Delphi Form files (.dfm), which use the same `object Name: TClassName ... end` text syntax as Lazarus .lfm files. Binary .dfm files (FF 0A magic header) are skipped gracefully with an informative error message so the pipeline is unaffected. Text .dfm files are parsed identically to .lfm: component containment (`contains` edges) and event handler references (`references`, context "event"). Adds .dfm to _DISPATCH and CODE_EXTENSIONS. 10 new tests in test_pascal.py, including a regression test for the binary-format detection. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add .lpr extension and fix removesuffix in Pascal extractor Address review feedback from @safishamsi: - Add .lpr (Lazarus program file, identical syntax to .dpr) to _DISPATCH in extract.py and CODE_EXTENSIONS in detect.py so Lazarus project entry points are indexed. Completes the promised Lazarus IDE support. - Replace rstrip("()") with removesuffix("()") in the call-resolution dict comprehension for precise suffix removal (rstrip strips individual characters, not the literal string "()"). - Add .lpr assertions to test_pascal_dispatch_registered and test_pascal_detect_extensions_registered. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Simeon Bodurov <simeon.bodurov@speedy.bg> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
878 lines
33 KiB
Python
878 lines
33 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
|
|
|
|
from graphify.google_workspace import (
|
|
GOOGLE_WORKSPACE_EXTENSIONS,
|
|
convert_google_workspace_file,
|
|
google_workspace_enabled,
|
|
)
|
|
|
|
|
|
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', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.dart', '.v', '.sv', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk'}
|
|
DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.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'\b(credential|secret|passwd|password|token|private_key)s?\b', 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 GOOGLE_WORKSPACE_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": []}
|
|
|
|
# F-035: typo fix — was `_re.sub` (NameError, but unreachable because the
|
|
# whole xlsx codepath is currently behind a feature flag / not yet wired
|
|
# into the dispatcher). Before re-enabling this path, re-audit it for
|
|
# zip/XML bombs (openpyxl is built on top of zipfile and lxml-style XML
|
|
# parsing — a malicious .xlsx can blow up memory at load_workbook time).
|
|
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, google_workspace: bool | None = None) -> dict:
|
|
root = root.resolve()
|
|
google_workspace = google_workspace_enabled() if google_workspace is None else google_workspace
|
|
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:
|
|
if p.suffix.lower() in GOOGLE_WORKSPACE_EXTENSIONS:
|
|
if not google_workspace:
|
|
skipped_sensitive.append(
|
|
str(p)
|
|
+ " [Google Workspace shortcut skipped - pass --google-workspace "
|
|
"or set GRAPHIFY_GOOGLE_WORKSPACE=1]"
|
|
)
|
|
continue
|
|
try:
|
|
md_path = convert_google_workspace_file(p, converted_dir, xlsx_to_markdown=xlsx_to_markdown)
|
|
except Exception as exc:
|
|
skipped_sensitive.append(str(p) + f" [Google Workspace export failed: {exc}]")
|
|
continue
|
|
if md_path:
|
|
files[ftype].append(str(md_path))
|
|
total_words += count_words(md_path)
|
|
else:
|
|
skipped_sensitive.append(str(p) + " [Google Workspace export produced no readable text]")
|
|
continue
|
|
# 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,
|
|
*,
|
|
follow_symlinks: bool = False,
|
|
google_workspace: bool | None = None,
|
|
) -> 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.
|
|
|
|
The ``follow_symlinks`` flag is forwarded to :func:`detect` so corpora that
|
|
rely on symlinked sub-trees (e.g. a ``state_of_truth/`` symlink pointing to a
|
|
directory outside the scan root) are scanned consistently between full and
|
|
incremental runs.
|
|
"""
|
|
full = detect(root, follow_symlinks=follow_symlinks, google_workspace=google_workspace)
|
|
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
|