Skip to content
Merged
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
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ graphify is a Claude Code skill backed by a Python library. The skill orchestrat
detect() → extract() → build() → cluster() → analyze helpers → report.generate() → export.to_*()
```

Each stage lives in its own module and they communicate through plain Python dicts and NetworkX graphs - no shared state, no side effects outside `graphify-out/`. Most stages are a single function; `analyze.py` and `export.py` are sets of sibling functions rather than one entry point.
Each stage lives in its own module; the public contract between them is plain Python dicts and NetworkX graphs, not shared in-process state. `extract()` does have process-level side effects of its own -- it raises the recursion limit, clears its own module-level caches on each call, and can emit warnings to stderr -- but nothing it does is visible to another stage except through the dict/graph it returns. Most stages are a single function; `analyze.py` and `export.py` are sets of sibling functions rather than one entry point.

## Module responsibilities

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -866,7 +866,7 @@ is added to CI later. The Bandit and pip-audit CI steps currently use
`continue-on-error`, so their findings are advisory rather than blocking.

> macOS note: the test suite includes both `sample.f90` and `sample.F90` fixtures. These collide on case-insensitive HFS+ / APFS file systems. Run on Linux or in a Docker container if you need to test both Fortran variants simultaneously.

>
> Windows note: the native Windows test suite exercises symbolic links, long
> paths, POSIX permissions, path separators, and UTF-8 filesystem behavior.
> Enable Windows Developer Mode to allow unprivileged symbolic-link creation, or
Expand Down
6 changes: 5 additions & 1 deletion graphify/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -918,11 +918,15 @@ def load_cached(path: Path, root: Path = Path("."), kind: str = "ast",
if entry.exists():
try:
result = json.loads(entry.read_text(encoding="utf-8"))
except json.JSONDecodeError:
except (json.JSONDecodeError, UnicodeDecodeError):
# Corrupt entry, not a miss: a truncated write or a bad producer
# (e.g. unescaped Windows backslashes in source_file) leaves JSON
# that fails to parse on every future run, so the file is silently
# re-extracted forever. Count it so the run can report it (#2405).
# UnicodeDecodeError included: read_text() can raise it before
# json.loads() ever runs, e.g. a truncated write that cuts off
# mid multi-byte UTF-8 character -- the same "corrupt, not a
# miss" case, just caught one call earlier (OSAC-4049 review).
_corrupt_cache_entries += 1
return None
except OSError:
Expand Down
21 changes: 21 additions & 0 deletions graphify/detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,27 @@ def classify_file(path: Path) -> FileType | None:
from graphify.manifest_ingest import is_package_manifest_path
if is_package_manifest_path(path):
return FileType.CODE
# GitHub Actions workflow YAML (.github/workflows/*.yml|.yaml) has real
# structure (jobs, needs, uses) an AST pass can extract deterministically
# -- same rationale as the manifest carve-out above, and same mechanism
# (route to CODE by path before the generic DOC_EXTENSIONS bucket claims
# the .yml/.yaml extension). Also requires a cheap content sniff
# (`looks_like_workflow_shape`, a bounded-prefix regex, no tree-sitter)
# -- path alone is not enough: a non-workflow file that merely sits at
# this path (a stray Docker Compose file, ...) would otherwise be routed
# to CODE, extracted as empty by extract_github_actions(), and never
# reach the semantic pass at all, permanently losing its content rather
# than just producing a warning (real bug caught in OSAC-4049's review;
# the original path-only design assumed the extractor's own empty-result
# fallback was equivalent to a DOCUMENT classification, but CODE files
# never reach the semantic pass regardless of what the extractor
# returns). Every OTHER .yaml/.yml (Helm values, k8s manifests, OpenAPI
# specs) deliberately keeps falling through to DOCUMENT below --
# reclassifying YAML generically would regress their existing, correct
# semantic-pass handling.
from graphify.extractors.github_actions import is_github_actions_workflow_path, looks_like_workflow_shape
if is_github_actions_workflow_path(path) and looks_like_workflow_shape(path):
return FileType.CODE
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# Compound extensions must be checked before simple suffix lookup
if path.name.lower().endswith(".blade.php"):
return FileType.CODE
Expand Down
4 changes: 3 additions & 1 deletion graphify/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,10 +180,12 @@ def _git_head(cwd: "str | Path | None" = None) -> str | None:
describes a different repo — provenance must come from the repo the graph
describes, so callers pass the graph's own location.
"""
import shutil
import subprocess as _sp
git = shutil.which("git") or "git"
try:
r = _sp.run(
["git", "rev-parse", "HEAD"], capture_output=True, text=True, timeout=3,
[git, "rev-parse", "HEAD"], capture_output=True, text=True, timeout=3,
cwd=str(cwd) if cwd is not None else None,
)
return r.stdout.strip() if r.returncode == 0 else None
Expand Down
28 changes: 28 additions & 0 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
from graphify.extractors.sql import extract_sql # noqa: F401
from graphify.extractors.terraform import extract_terraform # noqa: F401
from graphify.extractors.verilog import extract_verilog # noqa: F401
from graphify.extractors.github_actions import extract_github_actions # noqa: F401
from graphify.extractors.zig import extract_zig # noqa: F401
from graphify.security import sanitize_metadata
from graphify.paths import disambiguate_ambiguous_candidates
Expand Down Expand Up @@ -4846,6 +4847,8 @@ def add_existing_edge(edge: dict) -> None:
".sh": extract_bash,
".bash": extract_bash,
".json": extract_json,
".yaml": extract_github_actions,
".yml": extract_github_actions,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
".tf": extract_terraform,
".tfvars": extract_terraform,
".hcl": extract_terraform,
Expand Down Expand Up @@ -4873,6 +4876,8 @@ def add_existing_edge(edge: dict) -> None:
# extract() to tell the user which extra restores the language.
_EXTRA_FOR_EXTENSION = {
".sql": "sql",
".yaml": "yaml",
".yml": "yaml",
".tf": "terraform",
".tfvars": "terraform",
".hcl": "terraform",
Expand Down Expand Up @@ -4989,6 +4994,17 @@ def _is_cpp_header(path: Path) -> bool:

def _get_extractor(path: Path) -> Any | None:
"""Return the correct extractor function for a file, or None if unsupported."""
# A real GitHub Actions workflow takes priority over filename-only carve-
# outs below (package manifests, e.g. .github/workflows/apm.yml would
# otherwise match is_package_manifest_path first and lose its job/needs/
# uses extraction entirely -- checked here, ahead of everything else,
# since is_github_actions_workflow_path's own path check already scopes
# this to .github/workflows/ and can never misfire for a real manifest
# sitting where manifests actually live).
if path.suffix.lower() in (".yaml", ".yml"):
from graphify.extractors.github_actions import is_github_actions_workflow_path, looks_like_workflow_shape
if is_github_actions_workflow_path(path) and looks_like_workflow_shape(path):
return extract_github_actions
if path.name.lower().endswith(".blade.php"):
return extract_blade
# MCP config files (.mcp.json, claude_desktop_config.json, ...) are routed
Expand Down Expand Up @@ -5022,6 +5038,18 @@ def _get_extractor(path: Path) -> Any | None:
# mis-parsed. `.mm` is unambiguously Objective-C++ and stays on extract_objc.
if suffix == ".m" and not _is_objc_source(path):
return None
# Any other `.yaml`/`.yml` reaching this point already failed the
# workflow-shape check at the top of this function (real workflows
# return extract_github_actions there, before the manifest/MCP checks
# above get a chance to claim them by filename). Gating here too (not
# just leaving it to _DISPATCH) matters for callers that reach extract()
# directly: collect_files() collects every .yaml/.yml in a tree, not
# just workflow-shaped ones -- a stray docker-compose.yaml anywhere
# would otherwise dispatch to extract_github_actions, return empty, and
# get misreported as a failed/empty extraction rather than "no extractor
# for this file" (#OSAC-4049 review round 3).
if suffix in (".yaml", ".yml"):
return None
# Extensionless files: resolve by shebang, mirroring detect.classify_file.
# Without this, detect labels e.g. `#!/usr/bin/env bash` CLIs as code but
# extraction returns no extractor and the file silently contributes nothing.
Expand Down
Loading
Loading