Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 38 additions & 5 deletions graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1647,12 +1647,25 @@ def dispatch_command(cmd: str) -> None:
no_cluster = False
args = sys.argv[2:]
watch_arg: str | None = None
for a in args:
external_extractions: list[Path] = []
i = 0
while i < len(args):
a = args[i]
if a == "--force":
force = True
i += 1
continue
if a == "--no-cluster":
no_cluster = True
i += 1
continue
if a == "--external-extraction" and i + 1 < len(args):
external_extractions.append(Path(args[i + 1]))
i += 2
continue
if a.startswith("--external-extraction="):
external_extractions.append(Path(a.split("=", 1)[1]))
i += 1
continue
if a.startswith("-"):
print(f"error: unknown update option: {a}", file=sys.stderr)
Expand All @@ -1661,6 +1674,7 @@ def dispatch_command(cmd: str) -> None:
print("error: update accepts at most one path argument", file=sys.stderr)
sys.exit(2)
watch_arg = a
i += 1

if watch_arg is not None:
watch_path = Path(watch_arg)
Expand All @@ -1680,7 +1694,13 @@ def dispatch_command(cmd: str) -> None:
# Interactive CLI: block on the per-repo lock rather than skip, so the
# user sees their explicit `graphify update` complete instead of
# exiting silently when a hook-driven rebuild happens to be running.
ok = _rebuild_code(watch_path, force=force, no_cluster=no_cluster, block_on_lock=True)
ok = _rebuild_code(
watch_path,
force=force,
no_cluster=no_cluster,
external_extractions=external_extractions,
block_on_lock=True,
)
if ok:
print("Code graph updated. For doc/paper/image changes run /graphify --update in your AI assistant.")
if not (
Expand Down Expand Up @@ -2325,6 +2345,7 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph":
print(
"Usage: graphify extract <path> [--backend gemini|kimi|claude|openai|deepseek|ollama] "
"[--model M] [--mode deep] [--out DIR] [--google-workspace] [--no-cluster] "
"[--external-extraction PATH ...] "
"[--no-gitignore] "
"[--max-workers N] [--token-budget N] [--max-concurrency N] "
"[--api-timeout S] [--postgres DSN] [--cargo] [--allow-partial] [--timing]",
Expand All @@ -2347,6 +2368,7 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph":
extract_mode: str | None = None
out_dir: Path | None = None
cli_postgres_dsn: str | None = None
external_extractions: list[Path] = []
cli_cargo: bool = False
cli_allow_partial: bool = False
no_cluster = False
Expand Down Expand Up @@ -2412,6 +2434,10 @@ def _parse_float(name: str, raw: str) -> float:
out_dir = Path(args[i + 1]); i += 2
elif a.startswith("--out="):
out_dir = Path(a.split("=", 1)[1]); i += 1
elif a == "--external-extraction" and i + 1 < len(args):
external_extractions.append(Path(args[i + 1])); i += 2
elif a.startswith("--external-extraction="):
external_extractions.append(Path(a.split("=", 1)[1])); i += 1
elif a == "--no-cluster":
no_cluster = True; i += 1
elif a == "--dedup-llm":
Expand Down Expand Up @@ -2499,6 +2525,12 @@ def _parse_float(name: str, raw: str) -> float:
out_root = (out_dir.resolve() if out_dir else target)
graphify_out = out_root / _GRAPHIFY_OUT
graphify_out.mkdir(parents=True, exist_ok=True)
from graphify.external import load_extractions as _load_external_extractions
external_result = _load_external_extractions(external_extractions, root=target)
external_source_files = {
str((target / source).resolve())
for source in external_result.get("source_files", [])
}
# Persist corpus-shaping options so later update/watch/hook rebuilds
# use the same file set as the initial extraction (#1886).
from graphify.watch import (
Expand Down Expand Up @@ -2584,6 +2616,7 @@ def _parse_float(name: str, raw: str) -> float:
# graph's own sources are reconciled against the current corpus.
_seen_files = {f for _fl in files_by_type.values() for f in _fl}
_seen_files.update(detection.get("unclassified", []))
_seen_files.update(external_source_files)
graph_stale_sources = _stale_graph_sources(
existing_graph_path, target, _seen_files
)
Expand Down Expand Up @@ -2996,9 +3029,9 @@ def _progress(idx: int, total: int, _result: dict) -> None:
# for symbols also referenced in docs). Hyperedges only come from the
# semantic side.
merged: dict = {
"nodes": list(ast_result.get("nodes", [])) + list(sem_result.get("nodes", [])) + list(pg_result.get("nodes", [])) + list(cargo_result.get("nodes", [])),
"edges": list(ast_result.get("edges", [])) + list(sem_result.get("edges", [])) + list(pg_result.get("edges", [])) + list(cargo_result.get("edges", [])),
"hyperedges": list(sem_result.get("hyperedges", [])),
"nodes": list(ast_result.get("nodes", [])) + list(sem_result.get("nodes", [])) + list(pg_result.get("nodes", [])) + list(cargo_result.get("nodes", [])) + list(external_result.get("nodes", [])),
"edges": list(ast_result.get("edges", [])) + list(sem_result.get("edges", [])) + list(pg_result.get("edges", [])) + list(cargo_result.get("edges", [])) + list(external_result.get("edges", [])),
"hyperedges": list(sem_result.get("hyperedges", [])) + list(external_result.get("hyperedges", [])),
"input_tokens": ast_result.get("input_tokens", 0) + sem_result.get("input_tokens", 0),
"output_tokens": ast_result.get("output_tokens", 0) + sem_result.get("output_tokens", 0),
}
Expand Down
2 changes: 1 addition & 1 deletion graphify/detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ class FileType(str, Enum):

_MANIFEST_PATH = str(out_path("manifest.json"))

CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.metal', '.rb', '.rake', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.svh', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.slnx', '.csproj', '.fsproj', '.vbproj', '.xaml', '.razor', '.cshtml', '.cls', '.trigger'}
CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.metal', '.rb', '.rake', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.svh', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.nix', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.slnx', '.csproj', '.fsproj', '.vbproj', '.xaml', '.razor', '.cshtml', '.cls', '.trigger'}
DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.skill', '.txt', '.rst', '.html', '.yaml', '.yml'}
PAPER_EXTENSIONS = {'.pdf'}
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}
Expand Down
73 changes: 73 additions & 0 deletions graphify/external.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""Generic external extraction ingress for Graphify integrations."""
from __future__ import annotations

import json
import os
from pathlib import Path

from graphify.validate import assert_valid

_MAX_EXTERNAL_BYTES = 32 * 1024 * 1024
_BUCKETS = ("nodes", "edges", "hyperedges")


def load_extractions(paths: list[Path], *, root: Path) -> dict:
"""Load and merge generic extraction envelopes from trusted integrations.

Each envelope contains ``nodes``, ``edges``, optional ``hyperedges``, and
``source_files``. Source paths are normalized to POSIX paths relative to
the scan root, matching Graphify's ordinary extraction output.
"""
merged = {"nodes": [], "edges": [], "hyperedges": [], "source_files": []}
seen_sources: set[str] = set()
root = root.resolve()
for raw_path in paths:
path = Path(raw_path)
if not path.is_file():
raise ValueError(f"external extraction does not exist: {path}")
if path.stat().st_size > _MAX_EXTERNAL_BYTES:
raise ValueError(f"external extraction exceeds {_MAX_EXTERNAL_BYTES} bytes: {path}")
payload = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError(f"external extraction must be an object: {path}")
if "hyperedges" not in payload:
payload["hyperedges"] = []
assert_valid(payload)

payload_sources = payload.get("source_files")
if payload_sources is None:
payload_sources = []
for bucket in _BUCKETS:
for item in payload.get(bucket, []):
source = item.get("source_file")
if source:
payload_sources.append(source)
if not isinstance(payload_sources, list) or not all(
isinstance(source, str) and source for source in payload_sources
):
raise ValueError(f"external extraction source_files must be a list of strings: {path}")

for bucket in _BUCKETS:
for item in payload.get(bucket, []):
source = item.get("source_file")
if source:
item["source_file"] = _relative_source(source, root)
item.setdefault("_origin", "external")
merged[bucket].append(item)
for source in payload_sources:
normalized = _relative_source(source, root)
if normalized not in seen_sources:
seen_sources.add(normalized)
merged["source_files"].append(normalized)

return merged


def _relative_source(source: str, root: Path) -> str:
path = Path(source)
if not path.is_absolute():
path = root / path
try:
return path.resolve().relative_to(root).as_posix()
except ValueError:
return os.path.relpath(path.resolve(), root).replace(os.sep, "/")
2 changes: 2 additions & 0 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
from graphify.extractors.go import extract_go # noqa: F401
from graphify.extractors.json_config import extract_json # noqa: F401
from graphify.extractors.markdown import extract_markdown # noqa: F401
from graphify.extractors.nix import extract_nix # noqa: F401
from graphify.extractors.pascal_forms import extract_delphi_form, extract_lazarus_form # noqa: F401
from graphify.extractors.powershell import extract_powershell, extract_powershell_manifest # noqa: F401
from graphify.extractors.razor import extract_razor # noqa: F401
Expand Down Expand Up @@ -3920,6 +3921,7 @@ def add_existing_edge(edge: dict) -> None:
".sh": extract_bash,
".bash": extract_bash,
".json": extract_json,
".nix": extract_nix,
".tf": extract_terraform,
".tfvars": extract_terraform,
".hcl": extract_terraform,
Expand Down
82 changes: 82 additions & 0 deletions graphify/extractors/nix.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""Dependency-free structural extraction for Nix expressions."""
from __future__ import annotations

import re
from pathlib import Path

from graphify.extractors.base import _file_stem, _make_id

_BINDING_RE = re.compile(r"(?m)^\s*([A-Za-z_][A-Za-z0-9_'-]*)\s*=\s*")
_FUNCTION_RE = re.compile(
r"(?m)^\s*([A-Za-z_][A-Za-z0-9_'-]*)\s*=\s*(?:\([^\n]*\)|[A-Za-z_][A-Za-z0-9_'-]*)\s*:\s*"
)
_IMPORT_RE = re.compile(r"(?<![A-Za-z0-9_.])((?:\.\.?/)[A-Za-z0-9_./'-]+\.nix)(?![A-Za-z0-9_])")
_INTERPOLATION_RE = re.compile(r"\$\{\s*([A-Za-z_][A-Za-z0-9_'-]*)")
_INHERIT_RE = re.compile(r"(?m)\binherit(?:From)?\s+([^;\n}]+)")


def extract_nix(path: Path) -> dict:
try:
source = path.read_text(encoding="utf-8", errors="replace")
except OSError as exc:
return {"nodes": [], "edges": [], "error": str(exc)}
source_file = str(path)
stem = _file_stem(path)
file_id = _make_id(stem)
nodes = [{"id": file_id, "label": path.name, "file_type": "code",
"source_file": source_file, "source_location": "L1"}]
edges: list[dict] = []
seen = {file_id}
bindings: dict[str, str] = {}

def line(match: re.Match) -> int:
return source.count("\n", 0, match.start()) + 1

def add_node(name: str, at: int, kind: str = "binding") -> str:
node_id = _make_id(stem, name)
if node_id not in seen:
seen.add(node_id)
nodes.append({"id": node_id, "label": name, "file_type": "code",
"source_file": source_file, "source_location": f"L{at}",
"kind": kind})
return node_id

def add_edge(source: str, target: str, relation: str, at: int, context: str | None = None) -> None:
if source == target:
return
edge = {"source": source, "target": target, "relation": relation,
"confidence": "EXTRACTED", "source_file": source_file,
"source_location": f"L{at}", "weight": 1.0}
if context:
edge["context"] = context
edges.append(edge)

for match in _BINDING_RE.finditer(source):
name = match.group(1)
if name in {"let", "in", "with", "inherit", "assert", "rec"}:
continue
kind = "function" if _FUNCTION_RE.match(source, match.start()) else "binding"
binding_id = add_node(name, line(match), kind)
bindings.setdefault(name, binding_id)
add_edge(file_id, binding_id, "contains", line(match))

for match in _IMPORT_RE.finditer(source):
target = match.group(1)
target_id = _make_id(_file_stem(path.parent / target))
if target_id not in seen:
seen.add(target_id)
nodes.append({"id": target_id, "label": Path(target).name, "file_type": "concept",
"source_file": source_file, "source_location": f"L{line(match)}",
"kind": "relative-import"})
add_edge(file_id, target_id, "imports", line(match), "literal-relative-path")

for match in _INTERPOLATION_RE.finditer(source):
target = bindings.get(match.group(1))
if target:
add_edge(file_id, target, "references", line(match), "interpolation")
for match in _INHERIT_RE.finditer(source):
for name in re.findall(r"[A-Za-z_][A-Za-z0-9_'-]*", match.group(1)):
target = bindings.get(name)
if target:
add_edge(file_id, target, "references", line(match), "inherit")
return {"nodes": nodes, "edges": edges}
Loading