fix #590 #628 #629 #630 #619 #617: directed graph, negation patterns, Windows paths, cross-lang noise, shebang, R

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Safi
2026-05-01 21:56:17 +01:00
co-authored by Claude Sonnet 4.6
parent 2dc759a951
commit e73b6060b1
4 changed files with 101 additions and 16 deletions
+2 -1
View File
@@ -1391,7 +1391,8 @@ def main() -> None:
from graphify.export import to_json, to_html
print("Loading existing graph...")
_raw = json.loads(graph_json.read_text(encoding="utf-8"))
G = build_from_json(_raw)
_directed = bool(_raw.get("directed", False))
G = build_from_json(_raw, directed=_directed)
print(f"Graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges")
print("Re-clustering...")
communities = cluster(G)
+33
View File
@@ -1,7 +1,34 @@
"""Graph analysis: god nodes (most connected), surprising connections (cross-community), suggested questions."""
from __future__ import annotations
from pathlib import Path
import networkx as nx
# Language families — extensions sharing a runtime can legitimately call each other
_LANG_FAMILY: dict[str, str] = {
**{e: "python" for e in (".py", ".pyw")},
**{e: "js" for e in (".js", ".jsx", ".mjs", ".ejs", ".ts", ".tsx", ".vue", ".svelte")},
**{e: "go" for e in (".go",)},
**{e: "rust" for e in (".rs",)},
**{e: "jvm" for e in (".java", ".kt", ".kts", ".scala")},
**{e: "c" for e in (".c", ".h", ".cpp", ".cc", ".cxx", ".hpp")},
**{e: "ruby" for e in (".rb",)},
**{e: "swift" for e in (".swift",)},
**{e: "dotnet" for e in (".cs",)},
**{e: "php" for e in (".php",)},
**{e: "r" for e in (".r",)},
}
def _cross_language(src_a: str, src_b: str) -> bool:
"""Return True if two source files belong to different language families."""
ext_a = Path(src_a).suffix.lower()
ext_b = Path(src_b).suffix.lower()
fam_a = _LANG_FAMILY.get(ext_a)
fam_b = _LANG_FAMILY.get(ext_b)
if fam_a is None or fam_b is None:
return False
return fam_a != fam_b
def _node_community_map(communities: dict[int, list[str]]) -> dict[str, int]:
"""Invert communities dict: node_id -> community_id."""
@@ -143,7 +170,13 @@ def _surprise_score(
# 1. Confidence weight - uncertain connections are more noteworthy
conf = data.get("confidence", "EXTRACTED")
relation = data.get("relation", "")
conf_bonus = {"AMBIGUOUS": 3, "INFERRED": 2, "EXTRACTED": 1}.get(conf, 1)
# Cross-language INFERRED calls are likely resolver pollution, not real surprises
if conf == "INFERRED" and relation == "calls" and _cross_language(u_source, v_source):
conf_bonus = 0 # downgrade: don't promote likely false positives
score += conf_bonus
if conf in ("AMBIGUOUS", "INFERRED"):
reasons.append(f"{conf.lower()} connection - not explicitly stated in source")
+13 -1
View File
@@ -17,6 +17,17 @@ def _body_content(content: bytes) -> bytes:
return content
def _normalize_path(path: Path) -> Path:
"""Normalize path for consistent cache keys across Windows path spellings."""
import sys
if sys.platform != "win32":
return path
s = str(path)
if s.startswith("\\\\?\\"):
s = s[4:] # strip extended-length prefix \\?\
return Path(os.path.normcase(s))
def file_hash(path: Path, root: Path = Path(".")) -> str:
"""SHA256 of file contents + path relative to root.
@@ -27,7 +38,8 @@ def file_hash(path: Path, root: Path = Path(".")) -> str:
For Markdown files (.md), only the body below the YAML frontmatter is hashed,
so metadata-only changes (e.g. reviewed, status, tags) do not invalidate the cache.
"""
p = Path(path)
p = _normalize_path(Path(path))
root = _normalize_path(Path(root))
if not p.is_file():
raise IsADirectoryError(f"file_hash requires a file, got: {p}")
raw = p.read_bytes()
+53 -14
View File
@@ -18,7 +18,7 @@ class FileType(str, Enum):
_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'}
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'}
@@ -78,11 +78,42 @@ def _looks_like_paper(path: Path) -> bool:
_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:
@@ -415,7 +446,12 @@ def _load_graphifyignore(root: Path) -> list[tuple[Path, str]]:
def _is_ignored(path: Path, root: Path, patterns: list[tuple[Path, str]]) -> bool:
"""Return True if path matches any .graphifyignore pattern."""
"""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
@@ -432,35 +468,38 @@ def _is_ignored(path: Path, root: Path, patterns: list[tuple[Path, str]]) -> boo
return True
return False
result = False
for anchor, pattern in patterns:
anchored = pattern.startswith("/")
p = pattern.strip("/")
negated = pattern.startswith("!")
raw = pattern[1:] if negated else pattern
anchored = raw.startswith("/")
p = raw.strip("/")
if not p:
continue
matched = False
if anchored:
# Anchored patterns are relative to the .graphifyignore's own dir only
try:
rel_anchor = str(path.relative_to(anchor)).replace(os.sep, "/")
if _matches(rel_anchor, p):
return True
matched = _matches(rel_anchor, p)
except ValueError:
pass
else:
# Non-anchored: try relative to scan root first, then anchor
try:
rel = str(path.relative_to(root)).replace(os.sep, "/")
if _matches(rel, p):
return True
matched = _matches(rel, p)
except ValueError:
pass
if anchor != root:
if not matched and anchor != root:
try:
rel_anchor = str(path.relative_to(anchor)).replace(os.sep, "/")
if _matches(rel_anchor, p):
return True
matched = _matches(rel_anchor, p)
except ValueError:
pass
return False
if matched:
result = not negated # last match wins; ! flips to un-ignore
return result
def detect(root: Path, *, follow_symlinks: bool = False) -> dict: