mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-29 01:36:33 +00:00
fix #623 #621 #605 #638 #589 #586 #593: kimi thinking, manifest, inline comments, query boost, cache race, markdownify, content hash
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
e73b6060b1
commit
3fdae8f334
@@ -949,11 +949,15 @@ def _clone_repo(url: str, branch: str | None = None, out_dir: Path | None = None
|
||||
else:
|
||||
dest = Path.home() / ".graphify" / "repos" / owner / repo
|
||||
|
||||
if branch and branch.startswith("-"):
|
||||
print(f"error: invalid branch name: {branch!r}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if dest.exists():
|
||||
print(f"Repo already cloned at {dest} — pulling latest...", flush=True)
|
||||
cmd = ["git", "-C", str(dest), "pull"]
|
||||
if branch:
|
||||
cmd += ["origin", branch]
|
||||
cmd += ["origin", "--", branch]
|
||||
result = _sp.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
print(f"warning: git pull failed:\n{result.stderr}", file=sys.stderr)
|
||||
@@ -963,7 +967,7 @@ def _clone_repo(url: str, branch: str | None = None, out_dir: Path | None = None
|
||||
cmd = ["git", "clone", "--depth", "1"]
|
||||
if branch:
|
||||
cmd += ["--branch", branch]
|
||||
cmd += [git_url, str(dest)]
|
||||
cmd += ["--", git_url, str(dest)]
|
||||
result = _sp.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
print(f"error: git clone failed:\n{result.stderr}", file=sys.stderr)
|
||||
|
||||
+17
-7
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -111,20 +112,29 @@ def save_cached(path: Path, result: dict, root: Path = Path("."), kind: str = "a
|
||||
if not p.is_file():
|
||||
return
|
||||
h = file_hash(p, root)
|
||||
entry = cache_dir(root, kind) / f"{h}.json"
|
||||
tmp = entry.with_suffix(".tmp")
|
||||
target_dir = cache_dir(root, kind)
|
||||
entry = target_dir / f"{h}.json"
|
||||
fd, tmp_path = tempfile.mkstemp(dir=target_dir, prefix=f"{h}.", suffix=".tmp")
|
||||
try:
|
||||
tmp.write_text(json.dumps(result), encoding="utf-8")
|
||||
os.write(fd, json.dumps(result).encode())
|
||||
os.close(fd)
|
||||
try:
|
||||
os.replace(tmp, entry)
|
||||
os.replace(tmp_path, entry)
|
||||
except PermissionError:
|
||||
# Windows: os.replace can fail with WinError 5 if the target is
|
||||
# briefly locked. Fall back to copy-then-delete.
|
||||
import shutil
|
||||
shutil.copy2(tmp, entry)
|
||||
tmp.unlink(missing_ok=True)
|
||||
shutil.copy2(tmp_path, entry)
|
||||
os.unlink(tmp_path)
|
||||
except Exception:
|
||||
tmp.unlink(missing_ok=True)
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
|
||||
+50
-12
@@ -385,16 +385,23 @@ 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 comments
|
||||
- Return empty string for blank lines and full-line comments
|
||||
"""
|
||||
line = raw.rstrip("\n\r")
|
||||
# Remove unescaped trailing spaces (per gitignore spec)
|
||||
line = re.sub(r"(?<!\\) +$", "", line)
|
||||
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
|
||||
|
||||
|
||||
@@ -613,8 +620,21 @@ def detect(root: Path, *, follow_symlinks: bool = False) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def load_manifest(manifest_path: str = _MANIFEST_PATH) -> dict[str, float]:
|
||||
"""Load the file modification time manifest from a previous run."""
|
||||
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()
|
||||
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:
|
||||
@@ -622,12 +642,13 @@ def load_manifest(manifest_path: str = _MANIFEST_PATH) -> dict[str, float]:
|
||||
|
||||
|
||||
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] = {}
|
||||
"""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:
|
||||
manifest[f] = Path(f).stat().st_mtime
|
||||
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)
|
||||
@@ -637,8 +658,11 @@ def save_manifest(files: dict[str, list[str]], manifest_path: str = _MANIFEST_PA
|
||||
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.
|
||||
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)
|
||||
@@ -656,12 +680,26 @@ def detect_incremental(root: Path, manifest_path: str = _MANIFEST_PATH) -> dict:
|
||||
|
||||
for ftype, file_list in full["files"].items():
|
||||
for f in file_list:
|
||||
stored_mtime = manifest.get(f)
|
||||
stored = 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:
|
||||
|
||||
# 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)
|
||||
|
||||
+8
-11
@@ -49,19 +49,16 @@ def _fetch_html(url: str) -> str:
|
||||
|
||||
|
||||
def _html_to_markdown(html: str, url: str) -> str:
|
||||
"""Convert HTML to clean markdown. Uses html2text if available, else basic strip."""
|
||||
"""Convert HTML to clean markdown. Uses markdownify if available, else basic strip."""
|
||||
# Always pre-strip script/style so their text content never leaks into output
|
||||
html = re.sub(r"<script[^>]*>.*?</script>", "", html, flags=re.DOTALL | re.IGNORECASE)
|
||||
html = re.sub(r"<style[^>]*>.*?</style>", "", html, flags=re.DOTALL | re.IGNORECASE)
|
||||
try:
|
||||
import html2text
|
||||
h = html2text.HTML2Text()
|
||||
h.ignore_links = False
|
||||
h.ignore_images = True
|
||||
h.body_width = 0
|
||||
return h.handle(html)
|
||||
from markdownify import markdownify
|
||||
return markdownify(html, heading_style="ATX", bullets="-", strip=["img"])
|
||||
except ImportError:
|
||||
# Fallback: strip tags
|
||||
text = re.sub(r"<script[^>]*>.*?</script>", "", html, flags=re.DOTALL | re.IGNORECASE)
|
||||
text = re.sub(r"<style[^>]*>.*?</style>", "", text, flags=re.DOTALL | re.IGNORECASE)
|
||||
text = re.sub(r"<[^>]+>", " ", text)
|
||||
# Fallback: basic tag strip
|
||||
text = re.sub(r"<[^>]+>", " ", html)
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
return text[:8000]
|
||||
|
||||
|
||||
@@ -102,6 +102,9 @@ def _call_openai_compat(
|
||||
}
|
||||
if temperature is not None:
|
||||
kwargs["temperature"] = temperature
|
||||
# Kimi-k2.6 is a reasoning model — disable thinking so content isn't empty
|
||||
if "moonshot" in base_url:
|
||||
kwargs["extra_body"] = {"thinking": {"type": "disabled"}}
|
||||
resp = client.chat.completions.create(**kwargs)
|
||||
result = _parse_llm_json(resp.choices[0].message.content or "{}")
|
||||
result["input_tokens"] = resp.usage.prompt_tokens if resp.usage else 0
|
||||
|
||||
+17
-4
@@ -45,6 +45,9 @@ def _strip_diacritics(text: str) -> str:
|
||||
return "".join(c for c in nfkd if not unicodedata.combining(c))
|
||||
|
||||
|
||||
_EXACT_MATCH_BONUS = 100.0
|
||||
|
||||
|
||||
def _score_nodes(G: nx.Graph, terms: list[str]) -> list[tuple[float, str]]:
|
||||
scored = []
|
||||
norm_terms = [_strip_diacritics(t).lower() for t in terms]
|
||||
@@ -52,6 +55,9 @@ def _score_nodes(G: nx.Graph, terms: list[str]) -> list[tuple[float, str]]:
|
||||
norm_label = data.get("norm_label") or _strip_diacritics(data.get("label") or "").lower()
|
||||
source = (data.get("source_file") or "").lower()
|
||||
score = sum(1 for t in norm_terms if t in norm_label) + sum(0.5 for t in norm_terms if t in source)
|
||||
# Exact match: single term equals the full label (strip trailing () for functions)
|
||||
if any(t == norm_label or t == norm_label.rstrip("()") for t in norm_terms):
|
||||
score += _EXACT_MATCH_BONUS
|
||||
if score > 0:
|
||||
scored.append((score, nid))
|
||||
return sorted(scored, reverse=True)
|
||||
@@ -89,11 +95,18 @@ def _dfs(G: nx.Graph, start_nodes: list[str], depth: int) -> tuple[set[str], lis
|
||||
return visited, edges_seen
|
||||
|
||||
|
||||
def _subgraph_to_text(G: nx.Graph, nodes: set[str], edges: list[tuple], token_budget: int = 2000) -> str:
|
||||
"""Render subgraph as text, cutting at token_budget (approx 3 chars/token)."""
|
||||
def _subgraph_to_text(G: nx.Graph, nodes: set[str], edges: list[tuple], token_budget: int = 2000, *, seeds: list[str] | None = None) -> str:
|
||||
"""Render subgraph as text, cutting at token_budget (approx 3 chars/token).
|
||||
|
||||
seeds: exact-match nodes rendered first before the degree-sorted expansion,
|
||||
so the queried symbol always appears at the top of the output.
|
||||
"""
|
||||
char_budget = token_budget * 3
|
||||
lines = []
|
||||
for nid in sorted(nodes, key=lambda n: G.degree(n), reverse=True):
|
||||
seed_set = set(seeds or [])
|
||||
ordered = [n for n in (seeds or []) if n in nodes] + \
|
||||
sorted(nodes - seed_set, key=lambda n: G.degree(n), reverse=True)
|
||||
for nid in ordered:
|
||||
d = G.nodes[nid]
|
||||
line = f"NODE {sanitize_label(d.get('label', nid))} [src={d.get('source_file', '')} loc={d.get('source_location', '')} community={d.get('community', '')}]"
|
||||
lines.append(line)
|
||||
@@ -246,7 +259,7 @@ def serve(graph_path: str = "graphify-out/graph.json") -> None:
|
||||
return "No matching nodes found."
|
||||
nodes, edges = _dfs(G, start_nodes, depth) if mode == "dfs" else _bfs(G, start_nodes, depth)
|
||||
header = f"Traversal: {mode.upper()} depth={depth} | Start: {[G.nodes[n].get('label', n) for n in start_nodes]} | {len(nodes)} nodes found\n\n"
|
||||
return header + _subgraph_to_text(G, nodes, edges, budget)
|
||||
return header + _subgraph_to_text(G, nodes, edges, budget, seeds=start_nodes)
|
||||
|
||||
def _tool_get_node(arguments: dict) -> str:
|
||||
label = arguments["label"].lower()
|
||||
|
||||
@@ -104,6 +104,12 @@ def _rebuild_code(watch_path: Path, *, follow_symlinks: bool = False) -> bool:
|
||||
if not json_written:
|
||||
return False
|
||||
|
||||
try:
|
||||
from graphify.detect import save_manifest
|
||||
save_manifest(detected["files"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
report = generate(G, communities, cohesion, labels, gods, surprises, detection,
|
||||
{"input": 0, "output": 0}, report_root, suggested_questions=questions)
|
||||
(out / "GRAPH_REPORT.md").write_text(report, encoding="utf-8")
|
||||
|
||||
+2
-2
@@ -44,7 +44,7 @@ Issues = "https://github.com/safishamsi/graphify/issues"
|
||||
[project.optional-dependencies]
|
||||
mcp = ["mcp"]
|
||||
neo4j = ["neo4j"]
|
||||
pdf = ["pypdf", "html2text"]
|
||||
pdf = ["pypdf", "markdownify"]
|
||||
watch = ["watchdog"]
|
||||
svg = ["matplotlib"]
|
||||
leiden = ["graspologic; python_version < '3.13'"]
|
||||
@@ -52,7 +52,7 @@ office = ["python-docx", "openpyxl"]
|
||||
video = ["faster-whisper", "yt-dlp"]
|
||||
kimi = ["openai"]
|
||||
sql = ["tree-sitter-sql"]
|
||||
all = ["mcp", "neo4j", "pypdf", "html2text", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper", "yt-dlp", "matplotlib", "openai", "tree-sitter-sql"]
|
||||
all = ["mcp", "neo4j", "pypdf", "markdownify", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper", "yt-dlp", "matplotlib", "openai", "tree-sitter-sql"]
|
||||
|
||||
[project.scripts]
|
||||
graphify = "graphify.__main__:main"
|
||||
|
||||
Reference in New Issue
Block a user