mirror of
https://github.com/safishamsi/graphify.git
synced 2026-09-04 20:55:50 +00:00
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:
co-authored by
Claude Sonnet 4.6
parent
2dc759a951
commit
e73b6060b1
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user