diff --git a/graphify/__main__.py b/graphify/__main__.py index 74ffb8b6..9e7b9c15 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -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) diff --git a/graphify/analyze.py b/graphify/analyze.py index 4f480c5b..de07cc13 100644 --- a/graphify/analyze.py +++ b/graphify/analyze.py @@ -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") diff --git a/graphify/cache.py b/graphify/cache.py index ba06ed2b..3de993cf 100644 --- a/graphify/cache.py +++ b/graphify/cache.py @@ -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() diff --git a/graphify/detect.py b/graphify/detect.py index 309f7f97..93e0adec 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -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: