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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ Full release notes with details on each version: [GitHub Releases](https://githu

## Unreleased

- Feat: Ruby `include`/`extend`/`prepend <Module>` now emits a `mixes_in` edge to the module (#1668, thanks @krishnateja7). Concerns/mixins are the composition mechanism in Rails, but they produced no edges, so the blast radius of editing a shared concern was invisible to `affected`. A constant-argument mixin inside a class or module body now resolves to the module node (reusing the #1634 candidate logic and the #1640 module nodes, under the single-owner guard) and emits `Class --mixes_in--> Module`, which `affected` already traverses. `extend self` and non-constant arguments are skipped; an ambiguous or undefined module produces no edge.
- Feat: `affected <Class>` now reaches callers that bind to the class's method nodes (#1669, thanks @krishnateja7). Since #1634 binds `Service.call` precisely to the `def self.call` method node, a class-level `affected` query missed those callers because `method`/`contains` are (correctly) not general-traversal relations. The reverse walk now seeds from the root's own member nodes (one `method`/`contains` hop outward) so method-bound callers are reachable from the class, with no change to the general traversal (no forward noise) and the member nodes themselves are not reported as hits.
- Fix: capitalized/mixed-case file extensions are no longer silently skipped (#1671, thanks @raman118). `collect_files` and `_get_extractor` matched suffixes case-sensitively, so `App.PY`, `script.JS`, `Lib.Ts`, etc. fell through and were never extracted. Suffix matching now falls back to the lowercased form for both file discovery and extractor dispatch (including `.blade.php`); an unsupported extension like `.xyz` is still skipped.
- Fix: the virtual PostgreSQL `source_file` URI no longer gets backslash-mangled on Windows (#1672, thanks @raman118). `introspect_postgres` built the synthetic `postgresql://host/db` path with `Path`, which rewrites `/` to `\` on Windows; it now uses `PurePosixPath` so the URI stays forward-slashed on every platform.
- Fix: a deferred `import(...)` no longer manufactures a phantom file cycle (#1241, thanks @Synvoya). Dynamic imports are real dependencies but not static ones, so two files that reference each other via one static import plus one dynamic import were reported as a circular dependency. The dynamic-import edge stays in the graph (marked `deferred`) but is excluded from `find_import_cycles`.
- Fix: an extractable source file that produces zero nodes is no longer cached, and is surfaced with a warning (#1666, thanks @krishnateja7). Every supported file yields at least a file node, so a zero-node result is anomalous (a transient batch/parallel hiccup). Caching it made the empty byte-stable across runs and silently blinded `affected`/`explain` to and through the file. The cache write is now skipped for a zero-node result so a rerun self-heals, and `extract` warns when an accepted source file lands in the graph with no nodes. This addresses the persistence and the silent blindness; if the underlying zero-node extraction still reproduces on a specific corpus, the warning now makes it visible to report.
- Fix: the Windows skill variant now declares `name: graphify` instead of `name: graphify-windows` (#1635, thanks @ray8875). `graphify install --platform windows` writes the variant to `~/.claude/skills/graphify/SKILL.md`, but Claude Code requires the skill folder name to equal the frontmatter `name`, so the `-windows` suffix broke discovery/validation. The variant suffix is a packaging detail, not part of the skill's identity.
- Fix: the OpenCode plugin joins its reminder to the user's command with `;` instead of `&&` (#1646, thanks @gonaik). Windows PowerShell 5.1 rejects `&&` as a statement separator (`not a valid statement separator`), so the first bash command of every OpenCode session on Windows failed. `;` works in PowerShell 5.1, Bash, and POSIX shells. (Both the OpenCode and Kilo plugin templates are fixed.)
- Fix: the `GRAPH_REPORT.md` "Import Cycles" section is now emitted only when the graph contains code (#1657, thanks @Ns2384-star). On a documents-only corpus there are no imports, so the section was pure noise ("None detected") on every run; it is now conditioned on code nodes or import edges being present. (The same report also confirms the mojibake and stdout-encoding items in that issue are already addressed on the current branch: manifest.json and `GRAPH_REPORT.md` are written UTF-8, and the CLI reconfigures stdout/stderr to UTF-8 with `errors="replace"`.)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<p align="center">
<a href="https://graphifylabs.ai"><img src="https://raw.githubusercontent.com/safishamsi/graphify/v4/docs/logo-text.svg" width="260" height="64" alt="Graphify"/></a>
<a href="https://graphifylabs.ai"><img src="https://raw.githubusercontent.com/Graphify-Labs/graphify/v8/docs/logo.png" width="300" height="140" alt="Graphify"/></a>
</p>

<p align="center">
Expand Down
Binary file added docs/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
21 changes: 21 additions & 0 deletions graphify/affected.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,27 @@ def affected_nodes(
queue: deque[tuple[str, int]] = deque([(seed, 0)])
hits: list[AffectedHit] = []

# #1669: seed the reverse walk with the root's own member nodes (one outward
# `method`/`contains` hop). A caller can bind to a class's method node rather
# than the class node itself (e.g. `Service.call` resolves to the `def
# self.call` node, #1634), so those callers are unreachable from the class
# otherwise. The member nodes are seeds only (not reported as hits), and
# `method`/`contains` stay out of the general relation-filtered walk, so this
# adds no forward noise anywhere else.
if hasattr(graph, "out_edges"):
member_edges = graph.out_edges(seed, data=True)
else:
member_edges = (
(s, t, d) for s, t, d in graph.edges(data=True) if s == seed
)
for _s, member, data in member_edges:
if str(data.get("relation", "")) not in ("method", "contains"):
continue
member = str(member)
if member not in seen:
seen.add(member)
queue.append((member, 0))

while queue:
current, current_depth = queue.popleft()
if current_depth >= depth:
Expand Down
5 changes: 5 additions & 0 deletions graphify/analyze.py
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,11 @@ def _endpoint_source_file(node_id: str) -> str:
if rel not in ("imports_from", "re_exports"):
continue

# Deferred `import(...)` edges are real dependencies but do not form a
# hard file-level cycle, so they are excluded from cycle detection (#1241).
if data.get("deferred"):
continue

src_file_attr = data.get("source_file", "")
if not isinstance(src_file_attr, str) or not src_file_attr:
continue
Expand Down
95 changes: 88 additions & 7 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -1932,8 +1932,13 @@ def _dynamic_import_js(node, source: bytes, caller_nid: str, str_path: str, edge
edges.append({
"source": caller_nid,
"target": tgt_nid,
# A deferred `import(...)` is a real dependency, so keep it as an
# `imports_from` edge (visible in the graph) but mark it `deferred`
# so find_import_cycles does not treat it as a static import and
# report a phantom file cycle (#1241).
"relation": "imports_from",
"context": "import",
"deferred": True,
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
Expand Down Expand Up @@ -3445,6 +3450,10 @@ def _extract_generic(
# `let vm = VM()`) live outside function bodies, so the call-walk never
# reaches them. Collect (owner_nid, call_node) here and walk them too.
initializer_nodes: list[tuple[str, object]] = []
# Ruby include/extend/prepend mixins collected during the node walk (#1668),
# merged into raw_calls after the call-walk populates it (raw_calls does not
# exist yet while walk() runs). Resolved cross-file by the Ruby resolver.
_ruby_mixin_calls: list[dict] = []
# #1356: per-file map of local name -> declared type (properties + params),
# threaded out as `swift_type_table` so member calls (`vm.update()`) can be
# resolved to the receiver's real definition in _resolve_swift_member_calls.
Expand Down Expand Up @@ -3814,6 +3823,36 @@ def _php_emit_base(base_name: str, rel: str, at_line: int) -> None:
seen_ids.add(base_nid)
add_edge(class_nid, base_nid, "inherits", line)

# `include`/`extend`/`prepend <Const>` in the class/module body ->
# a `mixes_in` edge to the module (#1668). The module usually lives
# in another file, so defer resolution to the cross-file Ruby
# resolver (reusing the #1634 candidate logic and the #1640 module
# nodes as targets). Only bare/namespaced constant arguments count;
# `extend self`, `include some_var`, etc. are skipped.
_rb_body = _find_body(node, config)
if _rb_body is not None:
for _stmt in _rb_body.children:
if _stmt.type != "call" or _stmt.child_by_field_name("receiver") is not None:
continue
_m = _stmt.child_by_field_name("method")
if _m is None or _read_text(_m, source) not in ("include", "extend", "prepend"):
continue
_args = _stmt.child_by_field_name("arguments")
if _args is None:
continue
for _arg in _args.children:
if _arg.type not in ("constant", "scope_resolution"):
continue
_mod = _ruby_const_last_name(_arg, source)
if _mod:
_ruby_mixin_calls.append({
"caller_nid": class_nid,
"callee": _mod,
"is_mixin": True,
"source_file": str_path,
"source_location": f"L{_stmt.start_point[0] + 1}",
})

# C#-specific: inheritance / interface implementation via base_list
if config.ts_module == "tree_sitter_c_sharp":
csharp_type_params = _csharp_type_parameters_in_scope(node, source)
Expand Down Expand Up @@ -5571,6 +5610,10 @@ def _scan_js_module_dispatch(n) -> None:
if src in valid_ids and (tgt in valid_ids or edge["relation"] in ("imports", "imports_from", "re_exports")):
clean_edges.append(edge)

# Ruby mixins were collected during the node walk (before raw_calls existed);
# fold them in so the cross-file resolver sees them (#1668).
if _ruby_mixin_calls:
raw_calls.extend(_ruby_mixin_calls)
result = {"nodes": nodes, "edges": clean_edges, "raw_calls": raw_calls}
if callable_def_nids:
# Mark function / method / class defs with a `_callable` attribute so the
Expand Down Expand Up @@ -15839,7 +15882,7 @@ 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."""
if path.name.endswith(".blade.php"):
if path.name.lower().endswith(".blade.php"):
return extract_blade
# MCP config files (.mcp.json, claude_desktop_config.json, ...) are routed
# by filename before generic .json dispatch so they get MCP-aware nodes
Expand All @@ -15855,14 +15898,17 @@ def _get_extractor(path: Path) -> Any | None:
# (the suffix map sends `.h` to extract_c, which can't read @interface etc.).
# ObjC sniffing has priority over the C++ sniff: an Objective-C++ header can
# contain both `@interface` and inline C++ (`::`), and it must parse as ObjC.
if path.suffix == ".h":
suffix = path.suffix
if suffix not in _DISPATCH and suffix.lower() in _DISPATCH:
suffix = suffix.lower()
if suffix == ".h":
if _is_objc_header(path):
return extract_objc
# A C++ class header routed to extract_c loses the class entirely (the C
# grammar has no class_specifier). Reroute to extract_cpp (#1547).
if _is_cpp_header(path):
return extract_cpp
return _DISPATCH.get(path.suffix)
return _DISPATCH.get(suffix)


def _safe_extract_with_xaml_root(extractor, path: Path, root: Path) -> dict:
Expand Down Expand Up @@ -15904,7 +15950,12 @@ def _extract_single_file(args: tuple) -> tuple[int, dict]:
return idx, {"nodes": [], "edges": []}

result = _safe_extract_with_xaml_root(extractor, path, cache_root)
if not bypass_cache and "error" not in result:
# Never cache a zero-node result for an extractable file. Every supported
# source produces at least a file node, so an empty node list is anomalous
# (e.g. a transient batch/parallel hiccup). Caching it makes the empty
# byte-stable across runs and silently blinds affected/explain to and
# through the file (#1666); skipping the write lets a rerun self-heal.
if not bypass_cache and "error" not in result and result.get("nodes"):
save_cached(path, result, cache_root)
return idx, result

Expand Down Expand Up @@ -16028,7 +16079,8 @@ def _extract_sequential(
continue
bypass_cache = path.suffix in _JS_CACHE_BYPASS_SUFFIXES
result = _safe_extract_with_xaml_root(extractor, path, effective_root)
if not bypass_cache and "error" not in result:
# See _extract_single_file: don't cache an anomalous zero-node result (#1666).
if not bypass_cache and "error" not in result and result.get("nodes"):
save_cached(path, result, effective_root)
per_file[idx] = result
if total_files >= _PROGRESS_INTERVAL:
Expand Down Expand Up @@ -16124,6 +16176,27 @@ def extract(
if per_file[i] is None:
per_file[i] = {"nodes": [], "edges": []}

# #1666: surface any source file an extractor accepted but that produced zero
# nodes (not even a file node). Such a file is silently absent from the graph,
# so affected/explain are blind to and through it with no other signal.
_empty_sources: list[str] = []
for i, _p in enumerate(paths):
_res = per_file[i] or {}
if _res.get("nodes") or _res.get("error"):
continue
if _get_extractor(_p) is not None:
_empty_sources.append(str(_p))
if _empty_sources:
_shown = ", ".join(Path(x).name for x in _empty_sources[:5])
_more = f" (+{len(_empty_sources) - 5} more)" if len(_empty_sources) > 5 else ""
print(
f" warning: {len(_empty_sources)} source file(s) produced zero nodes and "
f"are absent from the graph: {_shown}{_more}. A re-run will retry them "
f"(empties are no longer cached); if it persists, please report the "
f"file(s) (#1666).",
file=sys.stderr, flush=True,
)

all_nodes: list[dict] = []
all_edges: list[dict] = []
all_raw_calls: list[dict] = []
Expand Down Expand Up @@ -16403,6 +16476,12 @@ def extract(
# and collides with any top-level function named "log" in the corpus.
if rc.get("is_member_call"):
continue
# Skip Ruby include/extend/prepend mixin markers: they carry a module
# name as `callee` but are not calls — the Ruby resolver turns them into
# `mixes_in` edges. Letting the shared pass emit a `calls` edge here would
# both mislabel the relation and block the mixes_in emit as a dup (#1668).
if rc.get("is_mixin"):
continue
# Exact-case match first (case is semantic). Fold only when the CALLING
# file's language is case-insensitive, and only against the folded index of
# case-insensitive-language definitions — so a Python `Path()` call can never
Expand Down Expand Up @@ -16614,7 +16693,8 @@ def _ignored(p: Path) -> bool:
]
for fname in filenames:
p = dp / fname
if p.suffix in _EXTENSIONS and not _ignored(p) and _resolves_under_root(p, containment_root):
suffix = p.suffix
if (suffix in _EXTENSIONS or suffix.lower() in _EXTENSIONS) and not _ignored(p) and _resolves_under_root(p, containment_root):
results.append(p)
return sorted(results)
# Walk with symlink following + cycle detection
Expand All @@ -16634,7 +16714,8 @@ def _ignored(p: Path) -> bool:
]
for fname in filenames:
p = dp / fname
if p.suffix in _EXTENSIONS and not _ignored(p) and _resolves_under_root(p, containment_root):
suffix = p.suffix
if (suffix in _EXTENSIONS or suffix.lower() in _EXTENSIONS) and not _ignored(p) and _resolves_under_root(p, containment_root):
results.append(p)
return sorted(results)

Expand Down
4 changes: 2 additions & 2 deletions graphify/pg_introspect.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from __future__ import annotations
from pathlib import Path
from pathlib import Path, PurePosixPath
from graphify.extract import extract_sql


Expand Down Expand Up @@ -135,7 +135,7 @@ def introspect_postgres(dsn: str | None = None) -> dict:
info = psycopg.conninfo.conninfo_to_dict(dsn or "")
host = info.get("host", "localhost")
dbname = info.get("dbname", "db")
virtual_path = Path(f"postgresql://{host}/{dbname}")
virtual_path = PurePosixPath(f"postgresql://{host}/{dbname}")

# Pass virtual path and in-memory DDL content to extract_sql
result = extract_sql(virtual_path, content=ddl_string)
Expand Down
22 changes: 19 additions & 3 deletions graphify/ruby_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@ def _unique_class(name: str) -> str | None:
nids = class_def_nids.get(_key(name), [])
return nids[0] if len(nids) == 1 else None

def _emit(caller: str, target: str, rc: dict[str, Any]) -> None:
def _emit(caller: str, target: str, rc: dict[str, Any],
relation: str = "calls", context: str = "call") -> None:
if not caller or not target or caller == target:
return
if (caller, target) in existing_pairs:
Expand All @@ -103,15 +104,30 @@ def _emit(caller: str, target: str, rc: dict[str, Any]) -> None:
all_edges.append({
"source": caller,
"target": target,
"relation": "calls",
"context": "call",
"relation": relation,
"context": context,
"confidence": "EXTRACTED",
"confidence_score": 1.0,
"source_file": rc.get("source_file", ""),
"source_location": rc.get("source_location"),
"weight": 1.0,
})

# `include`/`extend`/`prepend <Const>` mixins (#1668): resolve the module by
# its constant name to the single owning module/class node and emit a
# `mixes_in` edge, under the same single-definition god-node guard. An
# ambiguous or unresolved constant produces no edge.
for rc in _ruby_raw_calls(per_file):
if not rc.get("is_mixin"):
continue
caller = str(rc.get("caller_nid", ""))
module_name = rc.get("callee")
if not caller or not module_name:
continue
target = _unique_class(str(module_name))
if target is not None:
_emit(caller, target, rc, relation="mixes_in", context="mixin")

for rc in _ruby_raw_calls(per_file):
if not rc.get("is_member_call"):
continue
Expand Down
Loading
Loading