From 4744dfefca33d1e323508616a4cdfa3b99c5d887 Mon Sep 17 00:00:00 2001
From: safishamsi
Date: Fri, 3 Jul 2026 16:03:18 +0100
Subject: [PATCH 01/39] feat(extract): TS/JS member calls on local new-binding
+ typed-param receivers (#1630)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The #1316 resolver handled `this.injectedField.method()`, but a receiver whose
type comes from a local `const x = new Foo()` binding (Pattern A) or a
type-annotated parameter — including inside a returned closure (Pattern B) —
produced no calls edge, so `affected ` silently under-reported.
- _ts_receiver_type_table: augment the per-file type table with local
`new` bindings (name -> constructor type) and bare-typed parameters
(`(svc: Svc)` -> svc: Svc), merged after the constructor-injection entries
(which win on a name clash). Only a bare type_identifier is recorded — an
array/union/generic/qualified/predefined type is skipped (precision).
- walk_calls now descends into an inline/returned JS/TS closure that is not
separately tracked in function_bodies (e.g. `return () => svc.doThing()`),
attributing its calls to the enclosing function, instead of stopping at the
arrow boundary. A tracked-body-id set prevents double-walking const-assigned
arrows.
The existing _resolve_typescript_member_calls then resolves both via the
receiver type with its single-definition guard. Verified on the real-CLI shape
(absolute paths + graphify-out cache): both patterns resolve, ambiguity binds
to the right class (Svc not Cache), untyped/array-typed receivers emit nothing.
5 tests, full suite 2871.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 2 +
graphify/extract.py | 79 +++++++++++++++++++++++++
tests/test_ts_receiver_member_calls.py | 81 ++++++++++++++++++++++++++
3 files changed, 162 insertions(+)
create mode 100644 tests/test_ts_receiver_member_calls.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f01b4fe4c..f10412dfb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,8 @@ Full release notes with details on each version: [GitHub Releases](https://githu
## Unreleased
+- Feat: TS/JS member calls on a local `new` binding or a type-annotated parameter now resolve (#1630, thanks @DanielC000). `const s = new Svc(); s.doThing()` and a call on a typed param — including inside a returned closure (`(svc: Svc) => () => svc.doThing()`) — now emit `calls` edges to the receiver type's method, so `affected` no longer silently under-reports. Extends the #1316 `this.field` resolver: the per-file type table now also learns local `new` bindings and bare-typed parameters, and `walk_calls` descends into inline/returned closures (attributing their calls to the enclosing function) instead of stopping at the arrow boundary. Resolution keeps the single-definition guard; an untyped or non-bare-typed (array/union/generic) receiver produces no edge.
+
- Fix: the `query` reference doc's inline vocab/fallback snippets now read and write files with `encoding="utf-8"` (#1619 A2, thanks @edtrackai). On Windows (default cp1252) the bare `read_text()`/`write_text()` calls crashed on exactly the cross-language corpora the doc demonstrates (e.g. Cyrillic labels like `обработчик`). Fixed across all generated skill variants.
- Fix: `graphify update`/`watch` no longer leaves stale sources after a deletion or a destination-only rename (#1623 / #1622, thanks @oleksii-tumanov). When the last supported file was deleted, or a rename reported only its destination in `changed_paths`, the removed source's nodes lingered in `graph.json`. The rebuild now reconciles extractor-backed sources against the files still present (code and document sources, subdirectory roots, legacy markers, symlinks, hyperedges) while preserving semantic and out-of-scope records.
diff --git a/graphify/extract.py b/graphify/extract.py
index 267cf3664..eb3d24aab 100644
--- a/graphify/extract.py
+++ b/graphify/extract.py
@@ -2340,6 +2340,55 @@ def _new_type(declarator) -> str | None:
return table
+def _ts_receiver_type_table(root, source: bytes, table: dict[str, str]) -> None:
+ """Add TS/JS receiver bindings to ``table`` (name -> TypeName), for member-call
+ resolution beyond the constructor-injected `this.field` case (#1630):
+
+ * local ``const/let/var x = new Foo()`` -> ``x: Foo`` (Pattern A);
+ * a type-annotated parameter ``(svc: Svc)`` -> ``svc: Svc`` (Pattern B), so a
+ call on the param — including inside a returned closure — resolves.
+
+ File-scoped, first-binding-wins (merged into the constructor-injection table,
+ which is populated first and therefore wins on a name clash). Only a bare
+ ``type_identifier`` (a single class/interface name) is recorded — an array,
+ union, generic, qualified, or predefined type is skipped (precision over
+ recall, matching the receiver-typed resolvers for Swift/C#/C++)."""
+ def _bare_type_ident(type_annotation):
+ # type_annotation -> ": T"; accept only a single type_identifier child.
+ idents = [c for c in type_annotation.children if c.type == "type_identifier"]
+ others = [c for c in type_annotation.children
+ if c.is_named and c.type not in ("type_identifier",)]
+ if len(idents) == 1 and not others:
+ return _read_text(idents[0], source)
+ return None
+
+ stack = [root]
+ while stack:
+ n = stack.pop()
+ t = n.type
+ if t == "variable_declarator":
+ name_n = n.child_by_field_name("name")
+ value = n.child_by_field_name("value")
+ if (name_n is not None and name_n.type == "identifier"
+ and value is not None and value.type == "new_expression"):
+ ctor = value.child_by_field_name("constructor")
+ if ctor is not None and ctor.type in ("identifier", "type_identifier"):
+ name = _read_text(name_n, source)
+ tname = _read_text(ctor, source)
+ if name and tname and name not in table:
+ table[name] = tname
+ elif t == "required_parameter" or t == "optional_parameter":
+ pat = n.child_by_field_name("pattern")
+ ann = n.child_by_field_name("type")
+ if pat is not None and pat.type == "identifier" and ann is not None:
+ tname = _bare_type_ident(ann)
+ name = _read_text(pat, source)
+ if name and tname and name not in table:
+ table[name] = tname
+ for c in n.children:
+ stack.append(c)
+
+
def _objc_local_var_types(body_node, source: bytes, table: dict[str, str]) -> None:
"""Collect ``var -> ClassName`` from ObjC local declarations (``Foo *f = ...;``)
in a method body, for receiver typing in the cross-file message-send pass
@@ -4775,8 +4824,23 @@ def _php_class_const_scope(n) -> str | None:
return None
return _read_text(scope, source)
+ _tracked_body_ids: set[int] = set()
+ _JS_CLOSURE_TYPES = ("arrow_function", "function_expression")
+
def walk_calls(node, caller_nid: str) -> None:
if node.type in config.function_boundary_types:
+ # JS/TS: an inline/returned closure not separately tracked in
+ # function_bodies would otherwise drop its calls at this boundary.
+ # Descend into it with the enclosing caller so `return () =>
+ # svc.doThing()` links to the caller (#1630). Tracked closures
+ # (const-assigned arrows) are walked with their own nid — skip to
+ # avoid double-counting.
+ if (config.ts_module in ("tree_sitter_javascript", "tree_sitter_typescript")
+ and node.type in _JS_CLOSURE_TYPES):
+ body = node.child_by_field_name("body")
+ if body is not None and id(body) not in _tracked_body_ids:
+ for child in node.children:
+ walk_calls(child, caller_nid)
return
if node.type in config.call_types:
@@ -5282,6 +5346,14 @@ def walk_calls(node, caller_nid: str) -> None:
for _caller_nid, body_node in function_bodies:
_swift_local_var_types(body_node, source, type_table)
+ # JS/TS: bodies already walked with their own caller_nid (const-assigned
+ # arrows, methods). An INLINE/returned arrow or function-expression that is
+ # NOT separately tracked (e.g. `return () => svc.doThing()`) is otherwise
+ # skipped at the arrow boundary in walk_calls, losing its calls — so let
+ # walk_calls descend into such untracked closures with the enclosing caller
+ # (#1630 Pattern B). Guarding on the tracked set prevents double-walking.
+ _tracked_body_ids.update(id(b) for _, b in function_bodies)
+
for caller_nid, body_node in function_bodies:
walk_calls(body_node, caller_nid)
@@ -5388,6 +5460,13 @@ def _scan_js_module_dispatch(n) -> None:
n["_callable"] = True
if swift_extensions:
result["swift_extensions"] = swift_extensions
+ # TS/JS: augment the constructor-injection type table with local `new`
+ # bindings and type-annotated parameters, so `const s = new Svc(); s.m()` and
+ # a call on a typed param (incl. inside a closure) resolve (#1630). The
+ # constructor-injection entries are populated during the walk above and win on
+ # a name clash (first-binding-wins in the helper).
+ if config.ts_module in ("tree_sitter_javascript", "tree_sitter_typescript"):
+ _ts_receiver_type_table(root, source, type_table)
if type_table:
if config.ts_module == "tree_sitter_swift":
result["swift_type_table"] = {"path": str_path, "table": type_table}
diff --git a/tests/test_ts_receiver_member_calls.py b/tests/test_ts_receiver_member_calls.py
new file mode 100644
index 000000000..c7c2c9430
--- /dev/null
+++ b/tests/test_ts_receiver_member_calls.py
@@ -0,0 +1,81 @@
+"""TS/JS receiver-typed member calls beyond `this.field` (#1630).
+
+The #1316 resolver handled `this.injectedField.method()`. This adds two receiver
+tiers whose type is statically known but was previously dropped, so
+`affected ` silently under-reported:
+
+ A. a local `const x = new Foo()` binding, then `x.method()`;
+ B. a closure over a type-annotated parameter, `f(x: Foo) => () => x.method()`.
+
+Resolution is by receiver type with the single-definition guard; an untyped or
+non-bare-typed receiver produces no edge.
+"""
+from __future__ import annotations
+
+from pathlib import Path
+
+from graphify.extract import extract
+
+_SVC = "export class Svc {\n doThing(): number { return 1; }\n}\n"
+
+
+def _calls(tmp_path, files: dict[str, str]):
+ for name, body in files.items():
+ p = tmp_path / name
+ p.parent.mkdir(parents=True, exist_ok=True)
+ p.write_text(body)
+ # Real-CLI shape: absolute input paths + a graphify-out cache subdir.
+ r = extract([tmp_path / n for n in files], cache_root=tmp_path / "graphify-out")
+ lbl = {n["id"]: n["label"] for n in r["nodes"]}
+ return {(lbl.get(e["source"]), lbl.get(e["target"])) for e in r["edges"]
+ if e["relation"] == "calls"}, r
+
+
+def test_local_new_binding_receiver(tmp_path):
+ calls, _ = _calls(tmp_path, {
+ "svc.ts": _SVC,
+ "direct.ts": ('import { Svc } from "./svc";\nconst s = new Svc();\n'
+ "export function usesDirect(): number { return s.doThing(); }\n"),
+ })
+ assert any("usesDirect" in s and "doThing" in t for s, t in calls)
+
+
+def test_closure_over_typed_param_receiver(tmp_path):
+ calls, _ = _calls(tmp_path, {
+ "svc.ts": _SVC,
+ "closure.ts": ('import { Svc } from "./svc";\n'
+ "export function register(svc: Svc): () => number "
+ "{ return () => svc.doThing(); }\n"),
+ })
+ assert any("register" in s and "doThing" in t for s, t in calls)
+
+
+def test_new_binding_resolves_to_correct_class_under_ambiguity(tmp_path):
+ calls, r = _calls(tmp_path, {
+ "svc.ts": _SVC,
+ "cache.ts": "export class Cache {\n doThing(): number { return 2; }\n}\n",
+ "d.ts": ('import { Svc } from "./svc";\nconst s = new Svc();\n'
+ "export function f(): number { return s.doThing(); }\n"),
+ })
+ # must resolve to Svc.doThing (id contains svc), never Cache.doThing
+ tgts = [t for _s, t in [(e["source"], e["target"]) for e in r["edges"]
+ if e["relation"] == "calls" and "_f" in e["source"]]]
+ assert tgts and all("svc" in t.lower() for t in tgts)
+ assert not any("cache" in t.lower() for t in tgts)
+
+
+def test_untyped_param_receiver_emits_no_edge(tmp_path):
+ calls, _ = _calls(tmp_path, {
+ "svc.ts": _SVC,
+ "n.ts": "export function g(x): number { return x.doThing(); }\n",
+ })
+ assert not any("doThing" in t for _s, t in calls)
+
+
+def test_array_typed_receiver_emits_no_edge(tmp_path):
+ calls, _ = _calls(tmp_path, {
+ "svc.ts": _SVC,
+ "a.ts": ('import { Svc } from "./svc";\n'
+ "export function h(xs: Svc[]): number { return xs[0].doThing(); }\n"),
+ })
+ assert not any("h(" in s and "doThing" in t for s, t in calls)
From 2ba07e84e1255ef6e7c61dc4842109c4bf1eff2a Mon Sep 17 00:00:00 2001
From: safishamsi
Date: Fri, 3 Jul 2026 18:34:41 +0100
Subject: [PATCH 02/39] fix(export): guard to_canvas against dangling community
members (#1236 follow-up)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The #1236 fix guarded to_obsidian's member loop but not to_canvas, so
`graphify export obsidian` (which also writes graph.canvas) still crashed with
KeyError on a community member id absent from G — after the notes exported,
leaving a partial mirror. Reported on 0.9.5 by @swells808.
Apply the same `m in G and m in node_filenames` filter in both to_canvas loops:
the box-sizing loop (so the group box matches the cards actually laid out) and
the card-layout loop (so the sort/label deref and the node_filenames fallback
never touch a dangling id). Regression test added alongside the to_obsidian one.
Full suite 2872.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 2 ++
graphify/export.py | 8 +++++++-
tests/test_obsidian_dangling_member.py | 20 ++++++++++++++++++++
3 files changed, 29 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f10412dfb..f87cb0404 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,8 @@ Full release notes with details on each version: [GitHub Releases](https://githu
## Unreleased
+- Fix: `graphify export obsidian` no longer crashes in `to_canvas` on a dangling community member (#1236 follow-up, thanks @swells808). The original #1236 fix guarded `to_obsidian` but not `to_canvas`, so a community member id with no backing node in the graph still raised `KeyError` while writing `graph.canvas` — after the notes had exported, leaving a partial mirror. `to_canvas` now applies the same dangling-member filter (`m in G and m in node_filenames`) in both the box-sizing and card-layout loops.
+
- Feat: TS/JS member calls on a local `new` binding or a type-annotated parameter now resolve (#1630, thanks @DanielC000). `const s = new Svc(); s.doThing()` and a call on a typed param — including inside a returned closure (`(svc: Svc) => () => svc.doThing()`) — now emit `calls` edges to the receiver type's method, so `affected` no longer silently under-reports. Extends the #1316 `this.field` resolver: the per-file type table now also learns local `new` bindings and bare-typed parameters, and `walk_calls` descends into inline/returned closures (attributing their calls to the enclosing function) instead of stopping at the arrow boundary. Resolution keeps the single-definition guard; an untyped or non-bare-typed (array/union/generic) receiver produces no edge.
- Fix: the `query` reference doc's inline vocab/fallback snippets now read and write files with `encoding="utf-8"` (#1619 A2, thanks @edtrackai). On Windows (default cp1252) the bare `read_text()`/`write_text()` calls crashed on exactly the cross-language corpora the doc demonstrates (e.g. Cyrillic labels like `обработчик`). Fixed across all generated skill variants.
diff --git a/graphify/export.py b/graphify/export.py
index 176b17909..36e4f9949 100644
--- a/graphify/export.py
+++ b/graphify/export.py
@@ -1278,7 +1278,10 @@ def safe_name(label: str) -> str:
group_sizes: dict[int, tuple[int, int]] = {}
group_cols: dict[int, int] = {}
for cid in sorted_cids:
- members = communities[cid]
+ # Skip dangling community members with no backing node / filename, so box
+ # sizing matches the cards actually laid out and `G.nodes[m]` never
+ # KeyErrors below — mirrors the to_obsidian guard (#1236).
+ members = [m for m in communities[cid] if m in G and m in node_filenames]
n = len(members)
inner_cols = max(1, math.ceil(math.sqrt(n)))
w = max(600, 220 * inner_cols)
@@ -1351,6 +1354,9 @@ def safe_name(label: str) -> str:
# Node cards inside the group - laid out in the same ceil(sqrt(n))-column
# grid the box was sized for (group_cols[cid]), so cards fill the box.
inner_cols = group_cols[cid]
+ # Same dangling-member guard as the sizing loop and to_obsidian (#1236):
+ # a community id absent from G / node_filenames would KeyError the sort.
+ members = [m for m in members if m in G and m in node_filenames]
sorted_members = sorted(members, key=lambda n: G.nodes[n].get("label", n))
for m_idx, node_id in enumerate(sorted_members):
col = m_idx % inner_cols
diff --git a/tests/test_obsidian_dangling_member.py b/tests/test_obsidian_dangling_member.py
index bda3119b8..2e0844fdc 100644
--- a/tests/test_obsidian_dangling_member.py
+++ b/tests/test_obsidian_dangling_member.py
@@ -48,3 +48,23 @@ def test_obsidian_community_of_only_dangling_members(tmp_path):
ghost_note = tmp_path / "_COMMUNITY_Community 1.md"
assert ghost_note.exists()
assert "**Members:** 0 nodes" in ghost_note.read_text(encoding="utf-8")
+
+
+def test_canvas_dangling_community_member_does_not_crash(tmp_path):
+ """#1236 follow-up: the fix landed in to_obsidian but not to_canvas, so
+ `graphify export obsidian` (which also writes graph.canvas) still crashed
+ with KeyError in to_canvas on a dangling member. The same guard now applies
+ to both the box-sizing loop and the card-layout loop."""
+ import json
+ from graphify.export import to_canvas
+
+ G, comms = _graph_with_dangling_member()
+ out = tmp_path / "graph.canvas"
+ to_canvas(G, comms, str(out)) # before the fix: KeyError: 'agents_doc'
+ assert out.exists()
+
+ canvas = json.loads(out.read_text(encoding="utf-8"))
+ node_ids = {n.get("id") for n in canvas.get("nodes", [])}
+ # real members get cards; the dangling id does not
+ assert "n_n0" in node_ids and "n_n1" in node_ids
+ assert "n_agents_doc" not in node_ids
From e2ef4ef3d1f17e3ada990fd722a8a6eaa71edda8 Mon Sep 17 00:00:00 2001
From: safishamsi
Date: Sat, 4 Jul 2026 03:12:23 +0100
Subject: [PATCH 03/39] fix: harden semantic extraction and kill phantom import
edges (#1631, #1638, #1632)
#1631: a malformed LLM chunk (a stray non-dict entry in edges/nodes/hyperedges)
crashed the AST+semantic merge and the semantic-cache write with
`AttributeError: 'list' object has no attribute 'get'`, discarding every
successful chunk and writing no graph.json. `_parse_llm_json` now sanitizes each
fragment at the single parse chokepoint (dict entries only; non-list values
coerced to []), protecting the cache writer, the adaptive-retry merge, and the
CLI merge in one place.
#1638: an unresolved bare npm import (`import colors from "tailwindcss/colors"`)
emitted an imports_from edge to the bare id `colors`, which build.py's
pre-migration alias index then remapped onto an unrelated local file of that
stem (backend/utils/colors.py) - a confident EXTRACTED cross-language phantom
edge, one per importing file. The external-import fallback now namespaces its
target with the `ref` prefix (the J-4 convention), so it can never collapse to a
local node id; the ref target has no node, so build drops it as an external
reference.
#1632: with a parallel LLM backend, extract_corpus_parallel merged chunk results
in completion order, so which network call returned first reordered nodes/edges
run-to-run even when the model returned identical content - churning graph.json.
Chunks are now merged in deterministic submission order after the pool drains
(matching the serial path); the progress callback still fires in completion
order. The model's own content variance is unchanged (irreducible).
Full suite: 2882 passed, 3 skipped. Validated end-to-end via a local wheel build
on a mixed TS+Python corpus: `explain colors.py` shows only the real importer,
and graph.json is byte-identical across repeated runs.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 4 +
graphify/extract.py | 12 ++-
graphify/llm.py | 40 +++++++-
tests/test_chunking.py | 47 +++++++++
tests/test_phantom_external_import.py | 116 +++++++++++++++++++++++
tests/test_semantic_fragment_sanitize.py | 71 ++++++++++++++
tests/test_ts_import_require.py | 20 ++--
7 files changed, 300 insertions(+), 10 deletions(-)
create mode 100644 tests/test_phantom_external_import.py
create mode 100644 tests/test_semantic_fragment_sanitize.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f87cb0404..626fac385 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,10 @@ Full release notes with details on each version: [GitHub Releases](https://githu
## Unreleased
+- Fix: a malformed semantic chunk no longer crashes `extract` and discards every successful chunk (#1631, thanks @ssazy). When an LLM returned a well-formed object whose `edges` (or `nodes`/`hyperedges`) array carried a stray non-dict entry — a nested list where an edge object belongs — the AST+semantic merge and the semantic-cache write both called `.get()` per entry and raised `AttributeError: 'list' object has no attribute 'get'`. On a 34-chunk run where 33 succeeded, that meant no `graph.json` was written and the cache write failed too, so a re-run re-extracted everything. `_parse_llm_json` now sanitizes each fragment at the single parse chokepoint (keeping only dict entries and coercing a non-list value to `[]`), so the cache writer, the adaptive-retry merge, and the CLI merge are all protected in one place.
+- Fix: an unresolved bare npm import no longer aliases onto an unrelated same-named local file (#1638, thanks @EveX1). `import colors from "tailwindcss/colors"` in a `.tsx` file emitted an `imports_from` edge to the bare id `colors`, and build.py's pre-migration alias index (which registers every local file's bare stem) then remapped it onto an unrelated `backend/utils/colors.py` — a confident (`EXTRACTED`) cross-language phantom edge, and one per `.tsx` file sharing the import. In a real monorepo eight unrelated `.tsx` files all landed on a single Python module. Common package subpaths (`colors`, `utils`, `types`, `config`, `client`) collide this way constantly. The external-import fallback now namespaces its target with the `ref` prefix (the same J-4 convention used for tsconfig `extends`/`$ref` externals), so it can never collapse to a local file/symbol id; the ref-namespaced target has no node, so build drops it as an external reference — the correct outcome for a third-party import.
+- Fix: `graph.json` node/edge ordering is now stable run-to-run for document/semantic corpora (#1632, thanks @umeshpsatwe). With a parallel LLM backend, `extract_corpus_parallel` merged chunk results in completion order, so which network call happened to return first reordered the nodes and edges even when the model returned identical content — churning `graph.json` between otherwise-identical runs. Chunks are now merged in deterministic submission order after the pool drains (matching the serial path); the progress callback still fires in completion order so long local runs aren't silent. Note: the semantic content the LLM extracts is itself nondeterministic run-to-run — this fix removes the pipeline's own ordering churn, not the model's variance.
+
- Fix: `graphify export obsidian` no longer crashes in `to_canvas` on a dangling community member (#1236 follow-up, thanks @swells808). The original #1236 fix guarded `to_obsidian` but not `to_canvas`, so a community member id with no backing node in the graph still raised `KeyError` while writing `graph.canvas` — after the notes had exported, leaving a partial mirror. `to_canvas` now applies the same dangling-member filter (`m in G and m in node_filenames`) in both the box-sizing and card-layout loops.
- Feat: TS/JS member calls on a local `new` binding or a type-annotated parameter now resolve (#1630, thanks @DanielC000). `const s = new Svc(); s.doThing()` and a call on a typed param — including inside a returned closure (`(svc: Svc) => () => svc.doThing()`) — now emit `calls` edges to the receiver type's method, so `affected` no longer silently under-reports. Extends the #1316 `this.field` resolver: the per-file type table now also learns local `new` bindings and bare-typed parameters, and `walk_calls` descends into inline/returned closures (attributing their calls to the enclosing function) instead of stopping at the arrow boundary. Resolution keeps the single-definition guard; an untyped or non-bare-typed (array/union/generic) receiver produces no edge.
diff --git a/graphify/extract.py b/graphify/extract.py
index eb3d24aab..f1cb548d6 100644
--- a/graphify/extract.py
+++ b/graphify/extract.py
@@ -1771,7 +1771,17 @@ def _resolve_js_import_target(raw: str, str_path: str) -> "tuple[str, Path | Non
module_name = raw.split("/")[-1]
if not module_name:
return None
- return _make_id(module_name), None
+ # Unresolved: relative/absolute, tsconfig-alias and workspace resolution have
+ # all run and failed, so this is an external package (or a dangling local
+ # path). Namespace the id with the "ref" prefix — the J-4 convention already
+ # used for tsconfig `extends`/`$ref` externals — so it can NEVER collapse to
+ # the same _make_id as a local file/symbol node. Without it, the bare
+ # last-segment id (e.g. "tailwindcss/colors" -> "colors") collides with any
+ # unrelated local file of that stem via build.py's pre-migration alias index,
+ # producing a confident (EXTRACTED) cross-language phantom imports_from edge
+ # (#1638). The ref-namespaced target has no node, so build drops it as an
+ # external reference — the correct outcome for a third-party import.
+ return _make_id("ref", raw), None
def _import_js(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str, scope_stack: list[str] | None = None) -> None:
diff --git a/graphify/llm.py b/graphify/llm.py
index 44e98a8ca..16644e6dc 100644
--- a/graphify/llm.py
+++ b/graphify/llm.py
@@ -759,6 +759,30 @@ def _bedrock_content(user_message: str, refs: list[_ImageRef]) -> list[dict]:
_LLM_JSON_MAX_BYTES = 10 * 1024 * 1024 # 10 MB hard cap before json.loads (F-016)
+def _sanitize_fragment(parsed: dict) -> dict:
+ """Force ``nodes``/``edges``/``hyperedges`` to lists of dicts, in place.
+
+ A model can return a well-formed top-level object whose ``edges`` (or
+ ``nodes``/``hyperedges``) array contains a stray non-dict entry — most often
+ a nested list where an edge object belongs, or the whole value being a bare
+ array/scalar instead of a list. Those entries slip past JSON parsing but
+ blow up every downstream consumer that calls ``.get()`` per entry
+ (semantic-cache write and the AST+semantic merge both did — #1631, crashing
+ with ``'list' object has no attribute 'get'`` and discarding all successful
+ chunks). Sanitizing here, at the single parse chokepoint, protects the cache
+ writer, the adaptive-retry merge, and the CLI merge in one place.
+ """
+ for key in ("nodes", "edges", "hyperedges"):
+ value = parsed.get(key)
+ if value is None:
+ continue
+ if not isinstance(value, list):
+ parsed[key] = []
+ continue
+ parsed[key] = [entry for entry in value if isinstance(entry, dict)]
+ return parsed
+
+
def _parse_llm_json(raw: str) -> dict:
"""Strip optional markdown fences and parse JSON. Returns empty fragment on failure.
@@ -792,7 +816,7 @@ def _parse_llm_json(raw: str) -> dict:
try:
parsed = json.loads(stripped)
if isinstance(parsed, dict):
- return parsed
+ return _sanitize_fragment(parsed)
# Top-level array/scalar (common LLM output) is not a usable graph
# fragment; fall through to the next strategy rather than returning a
# non-dict that callers will try to subscript (e.g. result["input_tokens"]).
@@ -827,7 +851,7 @@ def _parse_llm_json(raw: str) -> dict:
try:
parsed = json.loads(stripped[start : i + 1])
if isinstance(parsed, dict):
- return parsed
+ return _sanitize_fragment(parsed)
break
except json.JSONDecodeError:
break
@@ -1862,6 +1886,14 @@ def _run_one(idx: int, chunk: list[Path]) -> tuple[int, dict | None, Exception |
if callable(on_chunk_done):
on_chunk_done(idx, total, result)
else:
+ # Merge in deterministic submission order, NOT completion order. Merging
+ # as chunks finish makes the node/edge ordering in the returned corpus
+ # (and therefore graph.json) depend on which network call happened to
+ # return first — so identical input churned run-to-run (#1632). Collect
+ # results keyed by chunk index and merge in sorted order after the pool
+ # drains; this matches the serial path's order. The progress callback
+ # still fires in completion order so long local runs aren't silent.
+ results_by_idx: dict[int, dict] = {}
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = [pool.submit(_run_one, idx, chunk) for idx, chunk in enumerate(chunks)]
for future in as_completed(futures):
@@ -1874,9 +1906,11 @@ def _run_one(idx: int, chunk: list[Path]) -> tuple[int, dict | None, Exception |
merged["failed_chunks"] += 1
continue
assert result is not None
- _merge_into(merged, result)
+ results_by_idx[idx] = result
if callable(on_chunk_done):
on_chunk_done(idx, total, result)
+ for idx in sorted(results_by_idx):
+ _merge_into(merged, results_by_idx[idx])
# Loud failure summary — surface chunk failures at end so they're never
# buried mid-log. Exit 0 preserved for caller compatibility; the
diff --git a/tests/test_chunking.py b/tests/test_chunking.py
index 087464ab8..21b28eec4 100644
--- a/tests/test_chunking.py
+++ b/tests/test_chunking.py
@@ -201,6 +201,53 @@ def record(chunk, **kwargs):
assert call_order == [("f0.py",), ("f1.py",), ("f2.py",)]
+def test_corpus_parallel_merge_order_is_submission_order_not_completion(tmp_path):
+ """#1632: merged node/edge order must be deterministic (submission order),
+ not the order chunks' network calls happen to finish. We skew latencies so
+ the first-submitted chunk finishes LAST; the merged result must still be in
+ file/submission order so graph.json is stable run-to-run."""
+ from graphify.llm import extract_corpus_parallel
+
+ files = []
+ for i in range(4):
+ f = tmp_path / f"f{i}.py"; f.write_text("x")
+ files.append(f)
+
+ def latency_skewed(chunk, **kwargs):
+ # chunk is a single file (chunk_size=1). Earlier files sleep longer, so
+ # completion order is the reverse of submission order.
+ name = chunk[0].name # f0.py .. f3.py
+ idx = int(name[1])
+ time.sleep(0.05 * (4 - idx)) # f0 sleeps 0.20s, f3 sleeps 0.05s
+ return {
+ "nodes": [{"id": f"node_from_{name}"}],
+ "edges": [{"source": f"node_from_{name}", "target": "t"}],
+ "hyperedges": [],
+ "input_tokens": 1,
+ "output_tokens": 1,
+ }
+
+ with patch("graphify.llm.extract_files_direct", side_effect=latency_skewed):
+ result = extract_corpus_parallel(
+ files, backend="kimi", token_budget=None, chunk_size=1, max_concurrency=4
+ )
+
+ node_ids = [n["id"] for n in result["nodes"]]
+ assert node_ids == [
+ "node_from_f0.py",
+ "node_from_f1.py",
+ "node_from_f2.py",
+ "node_from_f3.py",
+ ], f"merge order not deterministic: {node_ids}"
+ edge_srcs = [e["source"] for e in result["edges"]]
+ assert edge_srcs == [
+ "node_from_f0.py",
+ "node_from_f1.py",
+ "node_from_f2.py",
+ "node_from_f3.py",
+ ], f"edge merge order not deterministic: {edge_srcs}"
+
+
def test_corpus_parallel_continues_after_chunk_failure(tmp_path, capsys):
"""A single chunk raising should be logged but not abort the run.
Other chunks' results should still be merged."""
diff --git a/tests/test_phantom_external_import.py b/tests/test_phantom_external_import.py
new file mode 100644
index 000000000..c30f334ab
--- /dev/null
+++ b/tests/test_phantom_external_import.py
@@ -0,0 +1,116 @@
+"""#1638 — an unresolved bare npm import must not alias onto an unrelated
+same-named local file, producing a confident cross-language phantom edge.
+
+`import colors from "tailwindcss/colors"` in a .tsx file used to emit an
+`imports_from` edge to the bare id ``colors``. build.py's pre-migration alias
+index registers every local file's bare stem (``backend/utils/colors.py`` ->
+alias ``colors``), so the dangling ``colors`` target was remapped onto the
+Python file — an EXTRACTED-confidence edge between two files in different
+languages with no real relationship.
+
+The fix namespaces the external-import fallback id with the ``ref`` prefix (the
+J-4 convention), so it can never collide with a local file/symbol node id.
+"""
+from __future__ import annotations
+
+from pathlib import Path
+
+from graphify.build import build_from_json
+from graphify.extract import _make_id, _resolve_js_import_target, extract
+
+
+def _write(path: Path, text: str) -> Path:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(text, encoding="utf-8")
+ return path
+
+
+# ── unit: the resolver never returns a bare local-shaped id for an external ──
+
+
+def test_unresolved_bare_import_is_ref_namespaced():
+ tgt, resolved_path = _resolve_js_import_target(
+ "tailwindcss/colors", "frontend/src/SomeChart.tsx"
+ )
+ assert resolved_path is None
+ # Must not be the bare last-segment id that collides with a local `colors` file.
+ assert tgt != _make_id("colors")
+ assert tgt != _make_id("colors.py")
+ assert tgt.startswith("ref")
+
+
+def test_scoped_package_import_is_ref_namespaced():
+ tgt, resolved_path = _resolve_js_import_target(
+ "@scope/utils", "src/thing.ts"
+ )
+ assert resolved_path is None
+ assert tgt != _make_id("utils")
+ assert tgt.startswith("ref")
+
+
+# ── end-to-end: the reporter's exact synthetic monorepo ─────────────────────
+
+
+def test_no_phantom_edge_from_tsx_to_unrelated_python_file(tmp_path: Path):
+ py = _write(
+ tmp_path / "backend/utils/colors.py",
+ "def hex_to_rgb(value):\n return (0, 0, 0)\n",
+ )
+ tsx = _write(
+ tmp_path / "frontend/src/SomeChart.tsx",
+ 'import colors from "tailwindcss/colors";\n\n'
+ "export const CHART_COLOR = colors.blue[500];\n",
+ )
+
+ result = extract([py, tsx], cache_root=tmp_path / "graphify-out")
+ G = build_from_json(result, root=str(tmp_path))
+
+ # Find the python file node.
+ py_ids = [
+ n for n, d in G.nodes(data=True)
+ if str(d.get("source_file", "")).endswith("colors.py")
+ ]
+ assert py_ids, "colors.py should have produced at least one node"
+
+ # No edge from the TSX file (or any TS symbol) should land on the python file
+ # as an imports_from relationship.
+ for u, v, d in G.edges(data=True):
+ if d.get("relation") != "imports_from":
+ continue
+ endpoints = {u, v}
+ if endpoints & set(py_ids):
+ other = (endpoints - set(py_ids)) or endpoints
+ srcfiles = {str(G.nodes[e].get("source_file", "")) for e in other}
+ assert not any(sf.endswith((".tsx", ".ts")) for sf in srcfiles), (
+ f"phantom cross-language imports_from edge onto colors.py: "
+ f"{u} -> {v} ({d})"
+ )
+
+
+def test_multiple_tsx_files_do_not_all_alias_onto_one_python_file(tmp_path: Path):
+ # The real-world symptom: N unrelated .tsx files all doing the same bare
+ # import showed up as N imports_from sources on one python module.
+ _write(
+ tmp_path / "backend/utils/colors.py",
+ "def hex_to_rgb(value):\n return (0, 0, 0)\n",
+ )
+ for i in range(3):
+ _write(
+ tmp_path / f"frontend/src/Chart{i}.tsx",
+ 'import colors from "tailwindcss/colors";\n'
+ f"export const C{i} = colors.blue;\n",
+ )
+
+ paths = list((tmp_path).rglob("*.py")) + list((tmp_path / "frontend").rglob("*.tsx"))
+ result = extract(paths, cache_root=tmp_path / "graphify-out")
+ G = build_from_json(result, root=str(tmp_path))
+
+ py_ids = {
+ n for n, d in G.nodes(data=True)
+ if str(d.get("source_file", "")).endswith("colors.py")
+ }
+ phantom = [
+ (u, v) for u, v, d in G.edges(data=True)
+ if d.get("relation") == "imports_from" and ({u, v} & py_ids)
+ ]
+ assert not phantom, f"phantom edges onto colors.py: {phantom}"
diff --git a/tests/test_semantic_fragment_sanitize.py b/tests/test_semantic_fragment_sanitize.py
new file mode 100644
index 000000000..5ab2f9204
--- /dev/null
+++ b/tests/test_semantic_fragment_sanitize.py
@@ -0,0 +1,71 @@
+"""#1631 — a malformed LLM chunk (a stray non-dict entry in edges/nodes) must
+not crash the merge/cache-write and discard all successful chunks.
+
+`sem_result["edges"]` could contain a bare list where an edge object belongs
+(a JSON array slipping past parse). Downstream code calls ``.get()`` per entry
+(the AST+semantic merge at __main__.py and the semantic-cache writer both did),
+raising ``AttributeError: 'list' object has no attribute 'get'`` and losing all
+33 successful chunks. `_parse_llm_json` now sanitizes the fragment at the single
+parse chokepoint so every consumer only ever sees lists of dicts.
+"""
+from __future__ import annotations
+
+import json
+
+from graphify.llm import _parse_llm_json, _sanitize_fragment
+
+
+def test_sanitize_drops_non_dict_edge_entries():
+ frag = {
+ "nodes": [{"id": "a"}, ["not", "a", "dict"], "bare-string", {"id": "b"}],
+ "edges": [{"source": "a", "target": "b"}, ["stray", "list"], 42],
+ "hyperedges": [{"id": "h"}, None],
+ }
+ out = _sanitize_fragment(frag)
+ assert out["nodes"] == [{"id": "a"}, {"id": "b"}]
+ assert out["edges"] == [{"source": "a", "target": "b"}]
+ assert out["hyperedges"] == [{"id": "h"}]
+
+
+def test_sanitize_coerces_non_list_values_to_empty():
+ frag = {"nodes": {"id": "oops"}, "edges": "nope", "hyperedges": None}
+ out = _sanitize_fragment(frag)
+ assert out["nodes"] == []
+ assert out["edges"] == []
+ # None is left as-is (absent key semantics) — the guard only fixes lists/values
+ assert out.get("hyperedges") is None
+
+
+def test_parse_llm_json_sanitizes_stray_list_in_edges():
+ raw = json.dumps({
+ "nodes": [{"id": "a"}],
+ "edges": [{"source": "a", "target": "b"}, ["malformed"]],
+ "hyperedges": [],
+ })
+ parsed = _parse_llm_json(raw)
+ # Every entry that survives must be a dict so downstream .get() is safe.
+ for key in ("nodes", "edges", "hyperedges"):
+ assert all(isinstance(x, dict) for x in parsed.get(key, []))
+ assert parsed["edges"] == [{"source": "a", "target": "b"}]
+
+
+def test_parse_llm_json_fenced_response_is_sanitized():
+ raw = (
+ "Here you go:\n\n```json\n"
+ + json.dumps({"nodes": [["bad"], {"id": "ok"}], "edges": []})
+ + "\n```\n"
+ )
+ parsed = _parse_llm_json(raw)
+ assert parsed["nodes"] == [{"id": "ok"}]
+
+
+def test_merge_after_sanitize_does_not_raise_on_source_file_access():
+ # Mirrors the __main__.py comprehension that crashed: e.get("source_file", "").
+ parsed = _parse_llm_json(json.dumps({
+ "nodes": [{"id": "a", "source_file": "d.md"}],
+ "edges": [{"source": "a", "target": "b"}, ["oops"]],
+ }))
+ # This is exactly the pattern at __main__.py:4858-4860.
+ seen = {n.get("source_file", "") for n in parsed.get("nodes", [])}
+ seen |= {e.get("source_file", "") for e in parsed.get("edges", [])}
+ assert "d.md" in seen
diff --git a/tests/test_ts_import_require.py b/tests/test_ts_import_require.py
index 4612e6c23..43f186116 100644
--- a/tests/test_ts_import_require.py
+++ b/tests/test_ts_import_require.py
@@ -54,7 +54,11 @@ def test_import_require_single_quotes(tmp_path: Path):
assert _has_edge(result, "src/main.ts", "src/util.ts")
-def test_import_require_bare_module_targets_stub(tmp_path: Path):
+def test_import_require_bare_module_targets_ref_stub(tmp_path: Path):
+ # A bare module (`require("fs")`) is external, so it emits an imports_from
+ # edge to a ref-namespaced stub — NOT the bare `_make_id("fs")` id, which
+ # would collide with any local file named fs.* via build.py's alias index
+ # (#1638). Parity with the ESM external path (test_external_module_unchanged).
importer = _write(
tmp_path / "src/io.ts",
'import fs = require("fs");\nexport const data = fs.readFileSync("x");\n',
@@ -63,11 +67,15 @@ def test_import_require_bare_module_targets_stub(tmp_path: Path):
result = extract([importer], cache_root=tmp_path)
src = _file_node_id(Path("src/io.ts"))
- tgt = _make_id("fs")
- assert any(
- e["source"] == src and e["target"] == tgt and e["relation"] == "imports_from"
- for e in result["edges"]
- ), "bare-module import-equals should target the module-name stub, like ESM"
+ import_targets = {
+ e["target"] for e in result["edges"]
+ if e["source"] == src and e["relation"] == "imports_from"
+ }
+ # An external stub edge still exists...
+ assert import_targets, "bare-module import-equals should still emit an external stub edge"
+ # ...but it is ref-namespaced and never the bare, collision-prone id.
+ assert _make_id("fs") not in import_targets
+ assert any(t.startswith("ref") for t in import_targets), import_targets
def test_import_require_parity_with_namespace_import(tmp_path: Path):
From 53c769d23dd2674934c442cd9a4e9dd330c8084f Mon Sep 17 00:00:00 2001
From: Synvoya <16019863+Synvoya@users.noreply.github.com>
Date: Sat, 4 Jul 2026 16:35:30 +1000
Subject: [PATCH 04/39] fix(apex): emit extends edges for interface multiple
inheritance
interface X extends A, B captured the parent list in iface_re group 2 but the handler only read group 1, so no inheritance edge was emitted. Split the parent list and emit one extends edge per parent (mirroring the class branch).
---
graphify/extract.py | 10 ++++++++++
tests/test_languages.py | 10 ++++++++++
2 files changed, 20 insertions(+)
diff --git a/graphify/extract.py b/graphify/extract.py
index f1cb548d6..8b8437ee4 100644
--- a/graphify/extract.py
+++ b/graphify/extract.py
@@ -6297,6 +6297,16 @@ def add_edge(src: str, tgt: str, relation: str, line: int,
add_node(iface_nid, iface_name, lineno)
add_edge(file_nid if current_class_nid is None else current_class_nid,
iface_nid, "contains", lineno)
+ if im.group(2):
+ for parent in im.group(2).split(","):
+ parent = parent.strip()
+ if parent:
+ parent_nid = _make_id(stem, parent)
+ if parent_nid not in seen_ids:
+ parent_nid = _make_id(parent)
+ if parent_nid not in seen_ids:
+ add_node(parent_nid, parent, lineno)
+ add_edge(iface_nid, parent_nid, "extends", lineno, confidence="INFERRED")
pending_annotations = []
continue
diff --git a/tests/test_languages.py b/tests/test_languages.py
index ca5169d54..1ba9dc99c 100644
--- a/tests/test_languages.py
+++ b/tests/test_languages.py
@@ -2627,6 +2627,16 @@ def test_apex_interface_extraction():
labels = _labels(r)
assert "Notifiable" in labels
+def test_apex_interface_extends(tmp_path):
+ source = tmp_path / "PaymentProcessor.cls"
+ source.write_text(
+ "public interface PaymentProcessor extends Processor, Auditable { void process(); }\n"
+ )
+ result = extract_apex(source)
+ inheritance = _edge_labels(result, "extends") | _edge_labels(result, "implements")
+ assert ("PaymentProcessor", "Processor") in inheritance
+ assert ("PaymentProcessor", "Auditable") in inheritance
+
def test_apex_method_extraction():
r = extract_apex(FIXTURES / "sample.cls")
labels = _labels(r)
From 9b040224e1eacdf94d8adedf0366880a44a5f098 Mon Sep 17 00:00:00 2001
From: Synvoya <16019863+Synvoya@users.noreply.github.com>
Date: Sat, 4 Jul 2026 16:31:47 +1000
Subject: [PATCH 05/39] fix(kotlin): emit implements edge for interface
delegation (`by`)
class Foo : Bar by baz produced no edge because the delegation_specifier loop only handled constructor_invocation and bare user_type children; the by form wraps user_type in an explicit_delegation node. Add that branch so the implements edge (and generic-arg recovery) fires.
---
graphify/extract.py | 11 +++++++++++
tests/fixtures/sample.kt | 2 ++
tests/test_languages.py | 7 +++++++
3 files changed, 20 insertions(+)
diff --git a/graphify/extract.py b/graphify/extract.py
index 8b8437ee4..a5859f584 100644
--- a/graphify/extract.py
+++ b/graphify/extract.py
@@ -3648,6 +3648,17 @@ def _php_emit_base(base_name: str, rel: str, at_line: int) -> None:
if sub.type == "user_type":
user_type_node = sub
break
+ # `class Foo : Bar by baz` wraps the delegated
+ # interface `Bar` in an `explicit_delegation`
+ # node; grab its first `user_type` descendant so
+ # the implements edge (and generic-arg recovery)
+ # still fire.
+ if sub.type == "explicit_delegation":
+ for inner in sub.children:
+ if inner.type == "user_type":
+ user_type_node = inner
+ break
+ break
if user_type_node is None:
continue
base = _kotlin_user_type_name(user_type_node, source)
diff --git a/tests/fixtures/sample.kt b/tests/fixtures/sample.kt
index 5c4489c5d..db3f2810d 100644
--- a/tests/fixtures/sample.kt
+++ b/tests/fixtures/sample.kt
@@ -35,6 +35,8 @@ class DataProcessor : BaseProcessor(), Loggable {
override fun log() {}
}
+class LoggingList(inner: MutableList) : MutableList by inner
+
fun createClient(baseUrl: String): HttpClient {
val config = Config(baseUrl, 30)
return HttpClient(config)
diff --git a/tests/test_languages.py b/tests/test_languages.py
index 1ba9dc99c..38eb7e169 100644
--- a/tests/test_languages.py
+++ b/tests/test_languages.py
@@ -623,6 +623,13 @@ def test_kotlin_splits_inherits_and_implements():
assert ("DataProcessor", "Loggable") in _edge_labels(r, "implements")
+def test_kotlin_interface_delegation_emits_implements():
+ """`class Foo : Bar by baz` wraps the delegated interface in an
+ `explicit_delegation` node — it must still emit an implements edge."""
+ r = extract_kotlin(FIXTURES / "sample.kt")
+ assert ("LoggingList", "MutableList") in _edge_labels(r, "implements")
+
+
def test_kotlin_parameter_return_generic_and_field_contexts():
r = extract_kotlin(FIXTURES / "sample.kt")
assert ("run", "DataProcessor") in _edge_labels(r, "references", "parameter_type")
From 5737388f1716374b2eba8cf81c601d7fd5ee8e17 Mon Sep 17 00:00:00 2001
From: safishamsi
Date: Sat, 4 Jul 2026 11:32:07 +0100
Subject: [PATCH 06/39] docs: changelog credit for #1645 (apex extends) and
#1644 (kotlin by delegation)
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 626fac385..4a7e44d04 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,8 @@ Full release notes with details on each version: [GitHub Releases](https://githu
## Unreleased
+- Fix: Apex `interface X extends A, B` now emits an `extends` edge per parent (#1645, thanks @Synvoya). The interface regex captured the parent list in group 2, but the handler only read the interface name (group 1), so multiple-inheritance parents were dropped and only the `contains` edge survived. The interface branch now iterates the parent list and resolves each the same way the class branch already does.
+- Fix: Kotlin interface delegation (`class Foo : Bar by baz`) now emits the `implements` edge (#1644, thanks @Synvoya). The `by` form wraps the delegated interface in an `explicit_delegation` node, so neither the `constructor_invocation` nor the bare `user_type` branch fired and the edge was silently dropped. The delegation-specifier loop now unwraps `explicit_delegation` to its `user_type` (generic-argument recovery still runs), so idiomatic Kotlin delegation shows up in the graph.
- Fix: a malformed semantic chunk no longer crashes `extract` and discards every successful chunk (#1631, thanks @ssazy). When an LLM returned a well-formed object whose `edges` (or `nodes`/`hyperedges`) array carried a stray non-dict entry — a nested list where an edge object belongs — the AST+semantic merge and the semantic-cache write both called `.get()` per entry and raised `AttributeError: 'list' object has no attribute 'get'`. On a 34-chunk run where 33 succeeded, that meant no `graph.json` was written and the cache write failed too, so a re-run re-extracted everything. `_parse_llm_json` now sanitizes each fragment at the single parse chokepoint (keeping only dict entries and coercing a non-list value to `[]`), so the cache writer, the adaptive-retry merge, and the CLI merge are all protected in one place.
- Fix: an unresolved bare npm import no longer aliases onto an unrelated same-named local file (#1638, thanks @EveX1). `import colors from "tailwindcss/colors"` in a `.tsx` file emitted an `imports_from` edge to the bare id `colors`, and build.py's pre-migration alias index (which registers every local file's bare stem) then remapped it onto an unrelated `backend/utils/colors.py` — a confident (`EXTRACTED`) cross-language phantom edge, and one per `.tsx` file sharing the import. In a real monorepo eight unrelated `.tsx` files all landed on a single Python module. Common package subpaths (`colors`, `utils`, `types`, `config`, `client`) collide this way constantly. The external-import fallback now namespaces its target with the `ref` prefix (the same J-4 convention used for tsconfig `extends`/`$ref` externals), so it can never collapse to a local file/symbol id; the ref-namespaced target has no node, so build drops it as an external reference — the correct outcome for a third-party import.
- Fix: `graph.json` node/edge ordering is now stable run-to-run for document/semantic corpora (#1632, thanks @umeshpsatwe). With a parallel LLM backend, `extract_corpus_parallel` merged chunk results in completion order, so which network call happened to return first reordered the nodes and edges even when the model returned identical content — churning `graph.json` between otherwise-identical runs. Chunks are now merged in deterministic submission order after the pool drains (matching the serial path); the progress callback still fires in completion order so long local runs aren't silent. Note: the semantic content the LLM extracts is itself nondeterministic run-to-run — this fix removes the pipeline's own ordering churn, not the model's variance.
From 13e2bddf4de67871b942789905b9af44ed959e4f Mon Sep 17 00:00:00 2001
From: safishamsi
Date: Sat, 4 Jul 2026 12:20:07 +0100
Subject: [PATCH 07/39] fix(ruby): extract module/Struct/Class.new containers
and resolve constant-receiver calls (#1640, #1634)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
#1640 (node extraction): the extractor only created nodes for `class Foo`, so
plain `module Foo`, `Foo = Struct.new(...) do ... end`, `Foo = Class.new(Super)`
and `Result = Data.define(...)` produced no container node — their methods hung
off the file via `contains` with dot-less labels and no edge could target them.
`module` is now a container type (methods attach via `method`, nested modules
included), and a constant assignment whose RHS is Struct.new/Class.new/Data.define
synthesizes a class node named after the constant, attaches block-defined methods
to it, and emits an `inherits` edge for `Class.new(Super)`. Plain constant
assignments (MAX = 100, X = Foo.new) are untouched.
#1634 (resolution): constant-receiver singleton calls (`Service.call`,
`Model.where`, `SomeJob.perform_async`) emitted no edge, so a Zeitwerk-autoloaded
Rails app (no requires) had near-zero cross-file edges. resolve_ruby_member_calls
now handles a capitalized receiver with any callee: bind to the class's owned
singleton/instance method (`def self.call`) when present, else to the class node
itself so inherited/dynamic class methods (ActiveRecord where/find_by) still give
blast-radius. Namespaced receivers resolve by bare class name. The
single-owning-class god-node guard is kept — ambiguous receivers resolve to
nothing, never a wrong edge.
The two compound: PaymentProcessor#process -> TaxCalculator.rate_for needs the
module node (#1640) AND the resolver (#1634); both now land.
Full suite: 2893 passed, 3 skipped. Adversarial smoke confirms no false class
nodes from plain/multiple assignments and no self-loops on self-class calls.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 2 +
graphify/extract.py | 105 +++++++++++++++++++++++++++++++++-
graphify/ruby_resolution.py | 34 ++++++++++-
tests/test_ruby_resolution.py | 97 +++++++++++++++++++++++++++++++
4 files changed, 234 insertions(+), 4 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4a7e44d04..19634a898 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,8 @@ Full release notes with details on each version: [GitHub Releases](https://githu
## Unreleased
+- Fix: Ruby plain modules and `Struct.new` / `Class.new` / `Data.define` constant assignments now get container nodes (#1640, thanks @krishnateja7). The extractor only created nodes for `class Foo`, so `module Foo` (utility/`module_function` modules), `Foo = Struct.new(...) do ... end`, `Foo = Class.new(StandardError)`, and `Result = Data.define(...)` produced no node at all — their methods hung off the file via `contains` with dot-less labels, and no edge could ever target them. `module` is now a container type (methods attach via `method` like a class, nested modules included), and a constant assignment whose RHS is one of those factories synthesizes a class node named after the constant, attaches block-defined methods to it, and emits an `inherits` edge for `Class.new(Super)`. Plain constant assignments (`MAX = 100`, `X = Foo.new`) are untouched.
+- Fix: Ruby constant-receiver singleton calls now resolve cross-file (#1634, thanks @krishnateja7). `Service.call`, `Model.where`, `SomeJob.perform_async` — the dominant Rails idiom — emitted no `calls` edge, so with Zeitwerk autoloading (no `require`s) a Rails app had essentially no cross-file edges and `affected`/`path` came up empty. `resolve_ruby_member_calls` now handles a capitalized (constant) receiver with any callee: it binds to the class's singleton/instance method when one is owned (`def self.call`, which the extractor indexes), else to the class node itself so inherited/dynamic class methods (ActiveRecord `where`/`find_by`) still give correct blast-radius. Namespaced receivers (`Billing::Processor.call`) resolve by the bare class name. The single-owning-class god-node guard is kept throughout — an ambiguous receiver resolves to nothing rather than a wrong edge.
- Fix: Apex `interface X extends A, B` now emits an `extends` edge per parent (#1645, thanks @Synvoya). The interface regex captured the parent list in group 2, but the handler only read the interface name (group 1), so multiple-inheritance parents were dropped and only the `contains` edge survived. The interface branch now iterates the parent list and resolves each the same way the class branch already does.
- Fix: Kotlin interface delegation (`class Foo : Bar by baz`) now emits the `implements` edge (#1644, thanks @Synvoya). The `by` form wraps the delegated interface in an `explicit_delegation` node, so neither the `constructor_invocation` nor the bare `user_type` branch fired and the edge was silently dropped. The delegation-specifier loop now unwraps `explicit_delegation` to its `user_type` (generic-argument recovery still runs), so idiomatic Kotlin delegation shows up in the graph.
- Fix: a malformed semantic chunk no longer crashes `extract` and discards every successful chunk (#1631, thanks @ssazy). When an LLM returned a well-formed object whose `edges` (or `nodes`/`hyperedges`) array carried a stray non-dict entry — a nested list where an edge object belongs — the AST+semantic merge and the semantic-cache write both called `.get()` per entry and raised `AttributeError: 'list' object has no attribute 'get'`. On a 34-chunk run where 33 succeeded, that meant no `graph.json` was written and the cache write failed too, so a re-run re-extracted everything. `_parse_llm_json` now sanitizes each fragment at the single parse chokepoint (keeping only dict entries and coercing a non-list value to `[]`), so the cache writer, the adaptive-retry merge, and the CLI merge are all protected in one place.
diff --git a/graphify/extract.py b/graphify/extract.py
index a5859f584..3f943e451 100644
--- a/graphify/extract.py
+++ b/graphify/extract.py
@@ -2993,7 +2993,12 @@ def _swift_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: s
_RUBY_CONFIG = LanguageConfig(
ts_module="tree_sitter_ruby",
- class_types=frozenset({"class"}),
+ # `module Foo` is a container node just like `class Foo` in tree-sitter's
+ # Ruby grammar (name in a `constant` child, body in `body_statement`), so it
+ # gets a node and its methods attach via `method` (#1640). Without it, plain
+ # utility/`module_function` modules produced no node and their methods hung
+ # off the file via `contains` with dot-less labels.
+ class_types=frozenset({"class", "module"}),
function_types=frozenset({"method", "singleton_method"}),
import_types=frozenset(),
call_types=frozenset({"call"}),
@@ -3283,6 +3288,92 @@ def visit(n) -> None:
return bindings
+def _ruby_const_last_name(node, source: bytes) -> str:
+ """Last constant of a ``constant`` or ``scope_resolution`` (``A::B::C`` -> ``C``)."""
+ if node is None:
+ return ""
+ if node.type == "constant":
+ return _read_text(node, source)
+ if node.type == "scope_resolution":
+ consts = [c for c in node.children if c.type == "constant"]
+ if consts:
+ return _read_text(consts[-1], source)
+ return ""
+
+
+# `Const = (...)` shapes that define a lightweight class named after the
+# constant. tree-sitter parses each as an `assignment`, not a `class`, so the
+# generic class branch never saw them (#1640).
+_RUBY_CLASS_FACTORIES = frozenset({("Struct", "new"), ("Class", "new"), ("Data", "define")})
+
+
+def _ruby_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str,
+ nodes: list, edges: list, seen_ids: set, function_bodies: list,
+ parent_class_nid: str | None, add_node, add_edge, walk,
+ callable_def_nids: set) -> bool:
+ """Ruby: a constant assignment whose RHS is ``Struct.new(...)``,
+ ``Class.new(Super)`` or ``Data.define(...)`` defines a class named after the
+ constant (#1640). Synthesize the class node, attach block-defined methods via
+ ``method`` (by recursing the block with the new node as parent), and emit an
+ ``inherits`` edge for ``Class.new(Super)``. Returns True if handled.
+ """
+ if node.type != "assignment":
+ return False
+ left = node.child_by_field_name("left")
+ right = node.child_by_field_name("right")
+ if left is None or right is None or left.type != "constant" or right.type != "call":
+ return False
+ recv = right.child_by_field_name("receiver")
+ meth = right.child_by_field_name("method")
+ if recv is None or meth is None or recv.type != "constant":
+ return False
+ if (_read_text(recv, source), _read_text(meth, source)) not in _RUBY_CLASS_FACTORIES:
+ return False
+
+ const_name = _read_text(left, source)
+ if not const_name:
+ return False
+ line = node.start_point[0] + 1
+ class_nid = _make_id(stem, const_name)
+ add_node(class_nid, const_name, line)
+ callable_def_nids.add(class_nid) # a class is callable (its constructor)
+ # Mirror the generic class branch: containment always hangs off the file node.
+ add_edge(file_nid, class_nid, "contains", line)
+
+ # `Class.new(Super)` — the first positional constant argument is the superclass.
+ if _read_text(recv, source) == "Class":
+ args = next((c for c in right.children if c.type == "argument_list"), None)
+ if args is not None:
+ for arg in args.children:
+ if arg.type in ("constant", "scope_resolution"):
+ base = _ruby_const_last_name(arg, source)
+ if base:
+ base_nid = _make_id(stem, base)
+ if base_nid not in seen_ids:
+ base_nid = _make_id(base)
+ if base_nid not in seen_ids:
+ nodes.append({
+ "id": base_nid, "label": base,
+ "file_type": "code", "source_file": "",
+ "source_location": "",
+ })
+ seen_ids.add(base_nid)
+ add_edge(class_nid, base_nid, "inherits", line)
+ break
+
+ # Recurse the do/brace block so block-defined methods attach to the class.
+ # The block wraps its statements in a `body_statement` (like a class body);
+ # descend into it so the method handler sees parent_class_nid — otherwise the
+ # default recurse resets the parent to None and the method hangs off the file
+ # with a dot-less label.
+ block = next((c for c in right.children if c.type in ("do_block", "block")), None)
+ if block is not None:
+ body = next((c for c in block.children if c.type == "body_statement"), block)
+ for child in body.children:
+ walk(child, parent_class_nid=class_nid)
+ return True
+
+
# ── Generic extractor ─────────────────────────────────────────────────────────
def _extract_generic(
@@ -4658,6 +4749,13 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None:
ensure_named_node):
return
+ if config.ts_module == "tree_sitter_ruby":
+ if _ruby_extra_walk(node, source, file_nid, stem, str_path,
+ nodes, edges, seen_ids, function_bodies,
+ parent_class_nid, add_node, add_edge, walk,
+ callable_def_nids):
+ return
+
# Python's `@property` / `@staticmethod` / `@classmethod` wrap the
# inner function_definition in a `decorated_definition` node. The
# default recurse below clears parent_class_nid, which would cause the
@@ -5042,6 +5140,11 @@ def walk_calls(node, caller_nid: str) -> None:
is_member_call = True
if recv.type in ("identifier", "constant"):
member_receiver = _read_text(recv, source)
+ elif recv.type == "scope_resolution":
+ # Namespaced receiver `Billing::Processor.call` — capture the
+ # last constant so cross-file resolution can bind it by the
+ # bare class name (the god-node guard bails if ambiguous).
+ member_receiver = _ruby_const_last_name(recv, source) or None
else:
# Generic: get callee from call_function_field
func_node = node.child_by_field_name(config.call_function_field) if config.call_function_field else None
diff --git a/graphify/ruby_resolution.py b/graphify/ruby_resolution.py
index d0e41dd33..5823ecf38 100644
--- a/graphify/ruby_resolution.py
+++ b/graphify/ruby_resolution.py
@@ -28,6 +28,13 @@ def _key(label: str) -> str:
return re.sub(r"[^a-zA-Z0-9]+", "", str(label)).lower()
+# A Ruby class/module container node is labelled with a bare constant
+# (``Processor``, ``TaxCalculator``); methods end in ``()`` and files in ``.rb``.
+# Lets us register method-less containers (a ``Class.new(StandardError)`` error
+# class, an empty module) that have no `method` edge to be found by.
+_BARE_CONST_RE = re.compile(r"^[A-Z][A-Za-z0-9_]*$")
+
+
def _ruby_raw_calls(per_file: list[dict]) -> list[dict]:
calls: list[dict] = []
for result in per_file:
@@ -69,6 +76,15 @@ def resolve_ruby_member_calls(
tnode = node_by_id.get(tgt)
if tnode is not None:
method_index[(str(src), _key(tnode.get("label", "")))] = str(tgt)
+ # Also register class/module container nodes that own no `method` edge — a
+ # method-less `Class.new(StandardError)` or an empty module — so a constant
+ # receiver still resolves to a real node (#1640/#1634). External base stubs
+ # carry an empty source_file, so the `.rb` filter keeps them out.
+ for n in all_nodes:
+ nid = n.get("id")
+ sf = str(n.get("source_file", ""))
+ if nid and sf.endswith(".rb") and _BARE_CONST_RE.match(str(n.get("label", ""))):
+ class_def_nids.setdefault(_key(n.get("label", "")), []).append(str(nid))
for k in list(class_def_nids):
class_def_nids[k] = sorted(set(class_def_nids[k]))
@@ -104,12 +120,24 @@ def _emit(caller: str, target: str, rc: dict[str, Any]) -> None:
if not caller or not callee:
continue
- # `Processor.new` -> instantiation edge to the class.
+ # Constant receiver: `Processor.new` (instantiation) or `Service.call` /
+ # `Model.where` (singleton / class method). The bare method name would
+ # collide with unrelated same-named methods, so we resolve by the
+ # receiver's class under the single-owning-class god-node guard.
receiver = rc.get("receiver")
- if callee == "new" and receiver and str(receiver)[:1].isupper():
+ if receiver and str(receiver)[:1].isupper():
class_nid = _unique_class(str(receiver))
if class_nid is not None:
- _emit(caller, class_nid, rc)
+ if callee == "new":
+ _emit(caller, class_nid, rc)
+ else:
+ # Emit to the singleton/instance method the class owns
+ # (`def self.call`, which the extractor indexes); otherwise
+ # to the class node itself, so inherited/dynamic class methods
+ # like ActiveRecord `where`/`find_by` still give correct
+ # blast-radius. An ambiguous receiver bails to nothing.
+ method_nid = method_index.get((class_nid, _key(str(callee))))
+ _emit(caller, method_nid or class_nid, rc)
continue
# `p.run` where p's type is known -> edge to that class's method.
diff --git a/tests/test_ruby_resolution.py b/tests/test_ruby_resolution.py
index d11b1cf8b..adc77577b 100644
--- a/tests/test_ruby_resolution.py
+++ b/tests/test_ruby_resolution.py
@@ -193,3 +193,100 @@ def test_class_new_creates_instantiation_edge(tmp_path: Path) -> None:
edge = _has_call_edge(graph, "process_all", "Processor")
assert edge is not None, "Processor.new should resolve a call to the Processor class"
assert edge["confidence"] == "EXTRACTED"
+
+
+# ── #1640 node extraction + #1634 constant-receiver resolution ───────────────
+
+
+def _node_labels(result: dict) -> set[str]:
+ return {str(n.get("label", "")) for n in result["nodes"]}
+
+
+def _method_edges(result: dict) -> set[tuple[str, str]]:
+ labels = _labels(result["nodes"])
+ return {
+ (labels.get(e["source"], ""), labels.get(e["target"], ""))
+ for e in result["edges"] if e.get("relation") == "method"
+ }
+
+
+def test_plain_module_gets_a_node_with_methods(tmp_path: Path) -> None:
+ """#1640 shape 1: `module Foo` must get a node and own its methods."""
+ r = extract_ruby(_write(tmp_path, "tax.rb",
+ "module TaxCalculator\n module_function\n def rate_for(order)\n 0.2\n end\nend\n"))
+ assert "TaxCalculator" in _node_labels(r)
+ # method attaches to the module (dot label), not the file (dot-less).
+ assert ("TaxCalculator", ".rate_for()") in _method_edges(r)
+
+
+def test_nested_modules_each_get_a_node(tmp_path: Path) -> None:
+ """#1640 shape 1, nested."""
+ r = extract_ruby(_write(tmp_path, "n.rb",
+ "module Billing\n module Rounding\n def round(x)\n x.round(2)\n end\n end\nend\n"))
+ labels = _node_labels(r)
+ assert "Billing" in labels and "Rounding" in labels
+ assert ("Rounding", ".round()") in _method_edges(r)
+
+
+def test_struct_new_constant_creates_class_with_methods(tmp_path: Path) -> None:
+ """#1640 shape 2: `Foo = Struct.new(...) do ... end`."""
+ r = extract_ruby(_write(tmp_path, "invoice.rb",
+ "Invoice = Struct.new(:total, :tax) do\n def grand_total\n total + tax\n end\nend\n"))
+ assert "Invoice" in _node_labels(r)
+ assert ("Invoice", ".grand_total()") in _method_edges(r)
+
+
+def test_class_new_constant_creates_class_and_inherits(tmp_path: Path) -> None:
+ """#1640 shape 3: `Foo = Class.new(Super)` — node + inherits edge."""
+ r = extract_ruby(_write(tmp_path, "err.rb", "ApiError = Class.new(StandardError)\n"))
+ assert "ApiError" in _node_labels(r)
+ labels = _labels(r["nodes"])
+ inh = {(labels.get(e["source"], ""), labels.get(e["target"], ""))
+ for e in r["edges"] if e.get("relation") == "inherits"}
+ assert ("ApiError", "StandardError") in inh
+
+
+def test_data_define_constant_creates_class(tmp_path: Path) -> None:
+ r = extract_ruby(_write(tmp_path, "res.rb", "Result = Data.define(:ok, :value)\n"))
+ assert "Result" in _node_labels(r)
+
+
+def test_constant_receiver_singleton_call_resolves(tmp_path: Path) -> None:
+ """#1634: `Processor.call` (def self.call) resolves to the singleton method."""
+ _write(tmp_path, "processor.rb", "class Processor\n def self.call; end\nend\n")
+ runner = _write(tmp_path, "runner.rb",
+ "class Runner\n def run\n Processor.call\n end\nend\n")
+ graph = extract([runner, tmp_path / "processor.rb"], cache_root=tmp_path, parallel=False)
+ assert _has_call_edge(graph, "run", "call") is not None
+
+
+def test_constant_receiver_module_function_call_resolves(tmp_path: Path) -> None:
+ """#1634 + #1640: `TaxCalculator.rate_for` resolves across files to a
+ module_function — needs both the module node (#1640) and the resolver (#1634)."""
+ _write(tmp_path, "tax.rb",
+ "module TaxCalculator\n module_function\n def rate_for(o)\n 0.2\n end\nend\n")
+ pp = _write(tmp_path, "pp.rb",
+ "class PaymentProcessor\n def process(order)\n TaxCalculator.rate_for(order)\n end\nend\n")
+ graph = extract([pp, tmp_path / "tax.rb"], cache_root=tmp_path, parallel=False)
+ assert _has_call_edge(graph, "process", "rate_for") is not None
+
+
+def test_constant_receiver_unknown_class_method_falls_back_to_class(tmp_path: Path) -> None:
+ """#1634: `Model.where` (no `where` def, e.g. ActiveRecord) still links to the
+ class node for blast-radius, rather than dropping the edge."""
+ _write(tmp_path, "model.rb", "class Model\n def self.create; end\nend\n")
+ caller = _write(tmp_path, "svc.rb",
+ "class Svc\n def run\n Model.where(id: 1)\n end\nend\n")
+ graph = extract([caller, tmp_path / "model.rb"], cache_root=tmp_path, parallel=False)
+ # No `where` method node exists, so the edge lands on the class node itself.
+ assert _has_call_edge(graph, "run", "Model") is not None
+
+
+def test_ambiguous_constant_receiver_emits_no_edge(tmp_path: Path) -> None:
+ """Two classes named `Processor` => ambiguous receiver => bail (no wrong edge)."""
+ _write(tmp_path, "a.rb", "module A\n class Processor\n def self.call; end\n end\nend\n")
+ _write(tmp_path, "b.rb", "module B\n class Processor\n def self.call; end\n end\nend\n")
+ caller = _write(tmp_path, "c.rb",
+ "class Runner\n def run\n Processor.call\n end\nend\n")
+ graph = extract([caller, tmp_path / "a.rb", tmp_path / "b.rb"], cache_root=tmp_path, parallel=False)
+ assert _has_call_edge(graph, "run", "call") is None
From 29b3f9126d9347dcf40993f2f3c4101b1f3d4ab4 Mon Sep 17 00:00:00 2001
From: safishamsi
Date: Sat, 4 Jul 2026 12:55:35 +0100
Subject: [PATCH 08/39] release: 0.9.6
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
19 fixes/features since 0.9.5. Highlights:
- Ruby: module/Struct.new/Class.new/Data.define container nodes (#1640) and
constant-receiver singleton-call resolution (#1634) — Rails/Zeitwerk graphs
now get real cross-file edges.
- Kill cross-language phantom imports_from edges from unresolved bare npm
imports (#1638); harden semantic extraction against malformed LLM chunks
(#1631); deterministic graph.json node/edge ordering for parallel semantic
backends (#1632).
- Contributor extractor fixes: Apex interface multiple inheritance (#1645),
Kotlin `by` delegation (#1644).
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 2 +-
pyproject.toml | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 19634a898..a5b7765e3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,7 +2,7 @@
Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases)
-## Unreleased
+## 0.9.6 (2026-07-04)
- Fix: Ruby plain modules and `Struct.new` / `Class.new` / `Data.define` constant assignments now get container nodes (#1640, thanks @krishnateja7). The extractor only created nodes for `class Foo`, so `module Foo` (utility/`module_function` modules), `Foo = Struct.new(...) do ... end`, `Foo = Class.new(StandardError)`, and `Result = Data.define(...)` produced no node at all — their methods hung off the file via `contains` with dot-less labels, and no edge could ever target them. `module` is now a container type (methods attach via `method` like a class, nested modules included), and a constant assignment whose RHS is one of those factories synthesizes a class node named after the constant, attaches block-defined methods to it, and emits an `inherits` edge for `Class.new(Super)`. Plain constant assignments (`MAX = 100`, `X = Foo.new`) are untouched.
- Fix: Ruby constant-receiver singleton calls now resolve cross-file (#1634, thanks @krishnateja7). `Service.call`, `Model.where`, `SomeJob.perform_async` — the dominant Rails idiom — emitted no `calls` edge, so with Zeitwerk autoloading (no `require`s) a Rails app had essentially no cross-file edges and `affected`/`path` came up empty. `resolve_ruby_member_calls` now handles a capitalized (constant) receiver with any callee: it binds to the class's singleton/instance method when one is owned (`def self.call`, which the extractor indexes), else to the class node itself so inherited/dynamic class methods (ActiveRecord `where`/`find_by`) still give correct blast-radius. Namespaced receivers (`Billing::Processor.call`) resolve by the bare class name. The single-owning-class god-node guard is kept throughout — an ambiguous receiver resolves to nothing rather than a wrong edge.
diff --git a/pyproject.toml b/pyproject.toml
index 876602bb9..30d2a6da5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "graphifyy"
-version = "0.9.5"
+version = "0.9.6"
description = "AI coding assistant skill (Claude Code, CodeBuddy, Codex, OpenCode, Kilo Code, Cursor, Gemini CLI, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, Pi, Devin CLI, Google Antigravity) - turn any folder of code, docs, papers, images, or videos into a queryable knowledge graph"
readme = "README.md"
license = { file = "LICENSE" }
From 983da3c15f3eb31862bca4c9977ff5329a6b12d4 Mon Sep 17 00:00:00 2001
From: safishamsi
Date: Sat, 4 Jul 2026 13:01:18 +0100
Subject: [PATCH 09/39] docs(readme): sync code-extension list with detect.py
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The "What files it handles" code row omitted several extensions that reuse
existing tree-sitter grammars (so the grammar count is unchanged): `.mts`/`.cts`
(TypeScript, #1607, new in 0.9.6), `.cc`/`.cxx` (C++), `.kts` (Kotlin), `.psd1`
(PowerShell), `.toc` (Lua). Apex (`.cls`/`.trigger`) and Terraform already have
their own rows. `.r`/`.ejs`/`.ets` are intentionally left out — they are in
CODE_EXTENSIONS but have no registered extractor.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index dbd8489a5..f2f31ce85 100644
--- a/README.md
+++ b/README.md
@@ -245,7 +245,7 @@ To remove graphify from all platforms at once: `graphify uninstall` (add `--purg
| Type | Extensions |
|------|-----------|
-| Code (36 tree-sitter grammars) | `.py .ts .js .jsx .tsx .mjs .go .rs .java .c .cpp .h .hpp .cu .cuh .metal .rb .cs .kt .scala .php .swift .lua .luau .zig .ps1 .psm1 .ex .exs .m .mm .jl .vue .svelte .astro .groovy .gradle .dart .v .sv .svh .sql .f .f90 .f95 .f03 .f08 .pas .pp .dpr .dpk .lpr .inc .dfm .lfm .lpk .sh .bash .json .dm .dme .dmi .dmm .dmf .sln .slnx .csproj .fsproj .vbproj .xaml .razor .cshtml` (`.dm`/`.dme` requires `uv tool install graphifyy[dm]`; CUDA `.cu`/`.cuh` and Metal `.metal` reuse the C++ grammar) |
+| Code (36 tree-sitter grammars) | `.py .ts .mts .cts .js .jsx .tsx .mjs .go .rs .java .c .cpp .cc .cxx .h .hpp .cu .cuh .metal .rb .cs .kt .kts .scala .php .swift .lua .luau .toc .zig .ps1 .psm1 .psd1 .ex .exs .m .mm .jl .vue .svelte .astro .groovy .gradle .dart .v .sv .svh .sql .f .f90 .f95 .f03 .f08 .pas .pp .dpr .dpk .lpr .inc .dfm .lfm .lpk .sh .bash .json .dm .dme .dmi .dmm .dmf .sln .slnx .csproj .fsproj .vbproj .xaml .razor .cshtml` (`.dm`/`.dme` requires `uv tool install graphifyy[dm]`; `.mts`/`.cts` reuse the TypeScript grammar, `.cc`/`.cxx` and CUDA `.cu`/`.cuh` and Metal `.metal` reuse the C++ grammar) |
| Salesforce Apex | `.cls .trigger` (regex-based; classes, interfaces, enums, methods, triggers, SOQL/DML edges) |
| Terraform / HCL | `.tf .tfvars .hcl` (requires `uv tool install graphifyy[terraform]`) |
| MCP configs | `.mcp.json` `mcp.json` `mcp_servers.json` `claude_desktop_config.json` — extracts server nodes, package refs, env var requirements |
From 62b8eb14162133ebe5b5338694570e3ca3d37677 Mon Sep 17 00:00:00 2001
From: safishamsi
Date: Sat, 4 Jul 2026 21:22:39 +0100
Subject: [PATCH 10/39] fix(extract): gate JS/TS cross-file calls on import
evidence to kill phantom cross-package edges (#1659)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
When a callee had exactly one same-named definition repo-wide, the cross-file
resolver emitted a `calls` edge at INFERRED/0.8 even with no import path between
caller and callee. On a monorepo this fabricated dependencies: a 14-package repo
showed `platform`/`sidecar` depending on `registry-protocol` purely because it
exported generically-named symbols that unresolved calls collapsed onto.
JS/TS modules have no implicit cross-module scope, so a cross-file call is real
only if the caller imported it. Direct JS/TS cross-file `calls` attribution is
now gated on import evidence and left unresolved otherwise. Scoped to direct
calls: other languages keep the #1553 single-candidate resolution (C/C++
headers, Ruby autoload, same-package implicit scope), and the indirect_call path
(already INFERRED + callable-gated) is untouched.
Also hardens caller/candidate -> file mapping to resolve via the node's
`source_file` string (identifying the file node by its basename label) instead
of `relative_to(root.resolve())`, which threw on a path-resolution/symlink
mismatch and fell back to a non-matching absolute id — spuriously failing import
evidence. This both makes the new gate safe and fixes legitimate cross-file
calls being mislabeled INFERRED instead of EXTRACTED.
Full suite: 2898 passed, 3 skipped. Verified via CLI on the reporter's repro
(phantom dropped) and a control (imported call resolves EXTRACTED).
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 4 ++
graphify/extract.py | 48 ++++++++++++-
tests/test_extract.py | 11 +--
tests/test_phantom_cross_package_call.py | 87 ++++++++++++++++++++++++
4 files changed, 142 insertions(+), 8 deletions(-)
create mode 100644 tests/test_phantom_cross_package_call.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a5b7765e3..84db32d48 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@
Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases)
+## Unreleased
+
+- Fix: a JS/TS call with no local definition and no import no longer binds to a same-named export in an unrelated package (#1659, thanks @leonaburime-ucla). When a callee had exactly one same-named definition repo-wide, the cross-file resolver emitted a `calls` edge at INFERRED/0.8 even with no import path between the two files. On a monorepo this fabricated dependencies: a 14-package repo showed `platform` and `sidecar` depending on `registry-protocol` purely because it exported generically-named symbols (`*Schema`, etc.) that unresolved calls collapsed onto. JS/TS modules have no implicit cross-module scope, so a cross-file call is real only if the caller imported it — direct JS/TS cross-file `calls` attribution is now gated on import evidence and left unresolved otherwise. Other languages keep the single-candidate resolution (C/C++ headers, Ruby autoload, same-package implicit scope legitimately call across files without an explicit import), and the `indirect_call` path (already INFERRED and callable-gated) is unchanged. As part of the fix, caller→file mapping for import-evidence now uses the raw call's `source_file` string, so a path-resolution/symlink mismatch can no longer spuriously fail evidence and mislabel a real cross-file call.
+
## 0.9.6 (2026-07-04)
- Fix: Ruby plain modules and `Struct.new` / `Class.new` / `Data.define` constant assignments now get container nodes (#1640, thanks @krishnateja7). The extractor only created nodes for `class Foo`, so `module Foo` (utility/`module_function` modules), `Foo = Struct.new(...) do ... end`, `Foo = Class.new(StandardError)`, and `Result = Data.define(...)` produced no node at all — their methods hung off the file via `contains` with dot-less labels, and no edge could ever target them. `module` is now a container type (methods attach via `method` like a class, nested modules included), and a constant assignment whose RHS is one of those factories synthesizes a class node named after the constant, attaches block-defined methods to it, and emits an `inherits` edge for `Class.new(Super)`. Plain constant assignments (`MAX = 100`, `X = Foo.new`) are untouched.
diff --git a/graphify/extract.py b/graphify/extract.py
index 3f943e451..ab671543c 100644
--- a/graphify/extract.py
+++ b/graphify/extract.py
@@ -16344,9 +16344,20 @@ def extract(
elif e.get("relation") == "imports_from":
file_to_module_imports.setdefault(e["source"], set()).add(e["target"])
- # Map each node back to its containing file_id so we can ask
+ # Map each node back to its containing file node id so we can ask
# "did the caller's file import the callee's file?"
- # Use relativized paths to match how file node IDs were remapped above (#502).
+ # A node and its file node share the exact same ``source_file`` string, and a
+ # file node is the one whose label is the basename (``add_node(file_nid,
+ # path.name)``). Resolving file membership by that shared string is robust
+ # against the path-resolution/symlink mismatch that makes
+ # ``relative_to(root.resolve())`` throw and fall back to a non-matching
+ # absolute-derived id — which would spuriously fail import evidence and (with
+ # the #1659 JS/TS gate below) drop a legitimately-imported call.
+ sf_to_file_nid: dict[str, str] = {}
+ for n in all_nodes:
+ sf = n.get("source_file")
+ if sf and n.get("label") == Path(str(sf)).name:
+ sf_to_file_nid.setdefault(str(sf), n["id"])
nid_to_file_nid: dict[str, str] = {}
# nid -> raw source_file string, for the ambiguous-name tie-breakers below
# (test/non-test classification + path proximity). Kept separate from the
@@ -16357,6 +16368,12 @@ def extract(
if not sf:
continue
nid_to_source_file[n["id"]] = str(sf)
+ fnid = sf_to_file_nid.get(str(sf))
+ if fnid is not None:
+ nid_to_file_nid[n["id"]] = fnid
+ continue
+ # Fallback (no file node found for this source_file): derive it the old
+ # way from the relativized path.
sf_path = Path(sf)
try:
sf_rel = sf_path.relative_to(root) if sf_path.is_absolute() else sf_path
@@ -16372,6 +16389,10 @@ def extract(
(e["source"], e["target"]) for e in all_edges
if e.get("relation") in ("calls", "indirect_call")
}
+ # JS/TS/JSX modules have no implicit cross-module scope: a call into another
+ # file is real ONLY if the caller imported it. So a cross-file call from one
+ # of these files with no import evidence is gated below (#1659).
+ _JS_TS_CALL_SUFFIXES = (".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs")
for rc in all_raw_calls:
callee = rc.get("callee", "")
if not callee:
@@ -16392,7 +16413,15 @@ def extract(
if not candidates:
continue
caller = rc["caller_nid"]
- caller_file_nid = nid_to_file_nid.get(caller)
+ # Resolve the caller's file via the raw_call's own source_file string,
+ # which is stable regardless of any caller_nid remap. An indirect
+ # callback's caller_nid is the file node, whose id may have been
+ # relativized after the raw_call was recorded, so a caller_nid lookup can
+ # miss and (with the #1659 gate) drop a legitimately-imported callback.
+ caller_file_nid = (
+ sf_to_file_nid.get(str(rc.get("source_file", "")))
+ or nid_to_file_nid.get(caller)
+ )
imported_symbols = file_to_symbol_imports.get(caller_file_nid, set())
imported_modules = file_to_module_imports.get(caller_file_nid, set())
@@ -16469,6 +16498,19 @@ def _has_import_evidence(candidate_id: str) -> bool:
"weight": 1.0,
})
continue
+ # #1659: a JS/TS DIRECT call with no import evidence is almost always an
+ # unrelated same-named export in a package that was never imported — a
+ # phantom cross-package edge (a 14-package monorepo had `platform` and
+ # `sidecar` shown as depending on `registry-protocol` purely because it
+ # exported generically-named symbols). JS/TS modules have no implicit
+ # cross-module scope, so leave it unresolved rather than binding by name
+ # alone. Other languages keep the #1553 single-candidate resolution:
+ # C/C++ headers, Ruby autoload, and same-package implicit scope
+ # legitimately call across files without an explicit import. Scoped to
+ # direct calls: the indirect_call path above is already conservative
+ # (INFERRED, callable-target-gated) and independent of import evidence.
+ if not has_import_evidence and str(rc.get("source_file", "")).endswith(_JS_TS_CALL_SUFFIXES):
+ continue
if tgt != caller and (caller, tgt) not in existing_pairs:
existing_pairs.add((caller, tgt))
# Promote to EXTRACTED when there's a direct import edge from the
diff --git a/tests/test_extract.py b/tests/test_extract.py
index 528b6dbe2..38be9f679 100644
--- a/tests/test_extract.py
+++ b/tests/test_extract.py
@@ -879,9 +879,11 @@ def test_cross_file_call_promoted_to_extracted_with_import_evidence(tmp_path):
assert call_edges[0]["confidence_score"] == 1.0
-def test_cross_file_call_remains_inferred_without_import_evidence(tmp_path):
- """A cross-file `calls` edge must stay INFERRED when there is no import
- edge — name collision alone is insufficient evidence."""
+def test_js_cross_file_call_without_import_emits_no_edge(tmp_path):
+ """A JS/TS call with no local definition and no import must NOT bind to a
+ same-named export in another file (#1659). JS/TS modules have no implicit
+ cross-module scope, so name collision alone is not a real call — it used to
+ produce a phantom INFERRED edge that fabricated cross-package dependencies."""
caller = tmp_path / "caller.js"
callee = tmp_path / "lib.js"
# Caller does NOT require lib — same-name function happens to exist elsewhere
@@ -898,8 +900,7 @@ def test_cross_file_call_remains_inferred_without_import_evidence(tmp_path):
and nodes[e["source"]]["label"] == "run()"
and nodes[e["target"]]["label"] == "doUnique()"
]
- assert len(call_edges) == 1
- assert call_edges[0]["confidence"] == "INFERRED"
+ assert call_edges == [], f"unimported cross-file JS call should not resolve: {call_edges}"
def test_python_qualified_class_method_call_resolves_extracted(tmp_path):
diff --git a/tests/test_phantom_cross_package_call.py b/tests/test_phantom_cross_package_call.py
new file mode 100644
index 000000000..07fc89d4e
--- /dev/null
+++ b/tests/test_phantom_cross_package_call.py
@@ -0,0 +1,87 @@
+"""#1659 — a JS/TS call with no local definition and no import must not bind to
+a same-named export in an unrelated package that was never imported.
+
+JS/TS modules have no implicit cross-module scope: a call into another file is
+real only if the caller imported it. The cross-file resolver used to fall back
+to any lone same-named export repo-wide and emit a `calls` edge at INFERRED/0.8,
+so on a monorepo a package that exports generically-named symbols (`*Schema`,
+`validate`, ...) appeared depended-on by packages that import nothing from it.
+
+The fix gates JS/TS cross-file call attribution on import evidence; other
+languages keep the #1553 single-candidate resolution (headers, autoload,
+same-package implicit scope legitimately call across files with no import).
+"""
+from __future__ import annotations
+
+from pathlib import Path
+
+from graphify.extract import extract
+
+
+def _write(path: Path, text: str) -> Path:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(text, encoding="utf-8")
+ return path
+
+
+def _calls(files: list[Path], base: Path) -> set[tuple[str, str, str]]:
+ r = extract(files, cache_root=base, parallel=False)
+ lbl = {n["id"]: n["label"] for n in r["nodes"]}
+ return {
+ (lbl.get(e["source"], ""), lbl.get(e["target"], ""), e.get("confidence"))
+ for e in r["edges"] if e["relation"] == "calls"
+ }
+
+
+def test_unimported_cross_package_call_emits_no_edge(tmp_path: Path) -> None:
+ _write(tmp_path / "pkg-a/src/index.ts",
+ "declare function validate(x: number): boolean;\n"
+ "export function run(x: number): boolean { return validate(x); }\n")
+ _write(tmp_path / "pkg-b/src/index.ts",
+ "export function validate(name: string): boolean { return name.length > 0; }\n")
+ calls = _calls(sorted(tmp_path.rglob("*.ts")), tmp_path)
+ assert not any("run" in s and "validate" in t for s, t, _ in calls), calls
+
+
+def test_many_files_do_not_collapse_onto_one_export(tmp_path: Path) -> None:
+ # The real-world symptom: N packages importing nothing all showed edges to a
+ # single package that exported a generically-named symbol.
+ _write(tmp_path / "proto/index.ts",
+ "export function encode(x: string): string { return x; }\n")
+ for i in range(4):
+ _write(tmp_path / f"svc{i}/index.ts",
+ "declare function encode(x: string): string;\n"
+ f"export function use{i}(x: string) {{ return encode(x); }}\n")
+ calls = _calls(sorted(tmp_path.rglob("*.ts")), tmp_path)
+ assert not any("encode" in t for _s, t, _ in calls), calls
+
+
+def test_imported_cross_file_call_still_resolves(tmp_path: Path) -> None:
+ # A real import must still resolve (and be promoted to EXTRACTED).
+ _write(tmp_path / "a.ts",
+ 'import { validate } from "./b";\n'
+ "export function run(x: number) { return validate(x); }\n")
+ _write(tmp_path / "b.ts",
+ "export function validate(name: string): boolean { return name.length > 0; }\n")
+ calls = _calls([tmp_path / "a.ts", tmp_path / "b.ts"], tmp_path)
+ resolved = [c for c in calls if "run" in c[0] and "validate" in c[1]]
+ assert resolved, calls
+ assert resolved[0][2] == "EXTRACTED"
+
+
+def test_same_file_call_unaffected(tmp_path: Path) -> None:
+ _write(tmp_path / "s.ts",
+ "function helper() { return 1; }\n"
+ "export function main() { return helper(); }\n")
+ calls = _calls([tmp_path / "s.ts"], tmp_path)
+ assert any("main" in s and "helper" in t for s, t, _ in calls), calls
+
+
+def test_non_js_single_candidate_cross_file_still_resolves(tmp_path: Path) -> None:
+ # The gate is JS/TS-only. Ruby (autoload, no require) legitimately calls a
+ # lone same-named function across files without an import — keep the #1553
+ # single-candidate resolution for it.
+ _write(tmp_path / "helper.rb", "def transform(data)\n data.upcase\nend\n")
+ _write(tmp_path / "main.rb", "def handle(v)\n transform(v)\nend\n")
+ calls = _calls([tmp_path / "main.rb", tmp_path / "helper.rb"], tmp_path)
+ assert any("handle" in s and "transform" in t for s, t, _ in calls), calls
From f9174943a2aa3b39b84e151a540aaf6d510b4e43 Mon Sep 17 00:00:00 2001
From: safishamsi
Date: Sat, 4 Jul 2026 22:12:47 +0100
Subject: [PATCH 11/39] fix(detect): incremental correctness for Office sources
+ long paths, cache word counts (#1649, #1655, #1656)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
#1649: detect_incremental tracks the converted markdown sidecar, and
convert_office_file early-returned whenever the sidecar existed — so a .docx/
.xlsx edited after its first conversion never updated its sidecar and was
reported "unchanged" forever, freezing the graph. It now re-converts when the
source is newer than the sidecar (bumping the sidecar so the hash check catches
it); an unchanged source still skips the rewrite (#1226).
#1655: _md5_file/save_manifest/count_words used plain open()/stat(), which the
Windows file APIs reject for absolute paths over 260 chars unless prefixed with
`\\?\`. Deeply-nested files never hashed, their manifest entry never stabilized,
and detect_incremental re-flagged them as changed every run. A new _os_path adds
the extended-length prefix on win32 for change-detection I/O (mirror of
cache._normalize_path, which strips it for keys). No-op elsewhere.
#1656: detect() re-parsed every PDF/docx/text file to size the corpus on each
run. Word counts are now memoized in the existing content-hash stat index (keyed
by size + mtime_ns), so an unchanged file is parsed once. file_hash's fastpath is
guarded so a word-count-only entry (no hash) can't KeyError, and both writers
augment a co-located entry in place instead of clobbering the other's field.
Full suite: 2906 passed, 3 skipped.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 3 ++
graphify/cache.py | 56 +++++++++++++++++++++++++-
graphify/detect.py | 67 ++++++++++++++++++++++++++------
tests/test_long_path_hashing.py | 50 ++++++++++++++++++++++++
tests/test_office_incremental.py | 67 ++++++++++++++++++++++++++++++++
tests/test_word_count_cache.py | 52 +++++++++++++++++++++++++
6 files changed, 282 insertions(+), 13 deletions(-)
create mode 100644 tests/test_long_path_hashing.py
create mode 100644 tests/test_office_incremental.py
create mode 100644 tests/test_word_count_cache.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 84db32d48..33fa21a2b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,9 @@ Full release notes with details on each version: [GitHub Releases](https://githu
## Unreleased
+- Fix: a modified `.docx`/`.xlsx` now re-enters `--update` (#1649, thanks @Ns2384-star). `detect_incremental` tracks the converted markdown sidecar, and `convert_office_file` early-returned whenever the sidecar already existed — so an Office source edited after its first conversion never updated its sidecar and was reported "unchanged" forever, freezing the graph on a living docs corpus. The sidecar is now re-converted when the source is newer than it (which bumps the sidecar's mtime/content so the incremental hash check picks it up); an unchanged source still skips the rewrite so it never churns (#1226).
+- Fix: files whose absolute path exceeds Windows' 260-char limit are now hashed (#1655, thanks @Ns2384-star). `_md5_file`/`save_manifest`/`count_words` used plain `open()`/`stat()`, which the Windows file APIs reject for long paths unless prefixed with the extended-length marker `\\?\` — so deeply-nested files (accented, deep folders) never hashed, their manifest entry never stabilized, and `detect_incremental` re-flagged them as changed on every run. Change-detection I/O now prefixes long absolute paths on win32 (mirroring the normalization `cache.py` already applied to cache keys). No-op on other platforms.
+- Perf: word counts are cached against each file's stat signature (#1656, thanks @Ns2384-star). `detect()` counted words in every PDF/docx/text file to size the corpus, re-opening and re-parsing every binary on each run — minutes on a large docs corpus even when only a few files changed. Counts are now memoized in the existing content-hash stat index (keyed by size + mtime), so an unchanged file is parsed once and read from the index thereafter; incremental detection drops from O(corpus) parsing to O(changed).
- Fix: a JS/TS call with no local definition and no import no longer binds to a same-named export in an unrelated package (#1659, thanks @leonaburime-ucla). When a callee had exactly one same-named definition repo-wide, the cross-file resolver emitted a `calls` edge at INFERRED/0.8 even with no import path between the two files. On a monorepo this fabricated dependencies: a 14-package repo showed `platform` and `sidecar` depending on `registry-protocol` purely because it exported generically-named symbols (`*Schema`, etc.) that unresolved calls collapsed onto. JS/TS modules have no implicit cross-module scope, so a cross-file call is real only if the caller imported it — direct JS/TS cross-file `calls` attribution is now gated on import evidence and left unresolved otherwise. Other languages keep the single-candidate resolution (C/C++ headers, Ruby autoload, same-package implicit scope legitimately call across files without an explicit import), and the `indirect_call` path (already INFERRED and callable-gated) is unchanged. As part of the fix, caller→file mapping for import-evidence now uses the raw call's `source_file` string, so a path-resolution/symlink mismatch can no longer spuriously fail evidence and mislabel a real cross-file call.
## 0.9.6 (2026-07-04)
diff --git a/graphify/cache.py b/graphify/cache.py
index f377889ff..bb3f9d593 100644
--- a/graphify/cache.py
+++ b/graphify/cache.py
@@ -180,6 +180,7 @@ def file_hash(path: Path, root: Path = Path(".")) -> str:
st = p.stat()
entry = _stat_index.get(abs_key)
if (entry
+ and entry.get("hash") is not None # word-count-only entries carry no hash
and entry.get("size") == st.st_size
and entry.get("mtime_ns") == st.st_mtime_ns):
return entry["hash"]
@@ -199,12 +200,65 @@ def file_hash(path: Path, root: Path = Path(".")) -> str:
digest = h.hexdigest()
if st is not None:
- _stat_index[abs_key] = {"size": st.st_size, "mtime_ns": st.st_mtime_ns, "hash": digest}
+ entry = _stat_index.get(abs_key)
+ if (entry is not None
+ and entry.get("size") == st.st_size
+ and entry.get("mtime_ns") == st.st_mtime_ns):
+ entry["hash"] = digest # preserve a co-located word_count
+ else:
+ _stat_index[abs_key] = {"size": st.st_size, "mtime_ns": st.st_mtime_ns, "hash": digest}
_stat_index_dirty = True
return digest
+def cached_word_count(path: Path, root: Path, compute) -> int:
+ """Word count with the same (size, mtime_ns) stat-fastpath cache as
+ :func:`file_hash`, persisted in the shared stat index.
+
+ ``detect()`` counts words in every PDF/docx/text file to size the corpus,
+ which re-opens and re-parses every binary on each run — minutes on a large
+ docs corpus even when only a handful of files changed (#1656). This caches
+ the count against the file's stat signature so an unchanged file is counted
+ once and read from the index thereafter. ``compute(path)`` produces the
+ count on a miss. A file that can't be stat'd (e.g. a Windows long path the
+ index normalization can't reach) simply recomputes and isn't cached —
+ correct, just not accelerated.
+ """
+ global _stat_index_dirty
+ p = _normalize_path(Path(path))
+ root = _normalize_path(Path(root))
+ _ensure_stat_index(root)
+ abs_key = str(p.resolve())
+ st: "os.stat_result | None" = None
+ try:
+ st = p.stat()
+ entry = _stat_index.get(abs_key)
+ if (entry
+ and entry.get("size") == st.st_size
+ and entry.get("mtime_ns") == st.st_mtime_ns
+ and "word_count" in entry):
+ return entry["word_count"]
+ except OSError:
+ pass
+
+ wc = compute(Path(path))
+
+ if st is not None:
+ entry = _stat_index.get(abs_key)
+ if (entry
+ and entry.get("size") == st.st_size
+ and entry.get("mtime_ns") == st.st_mtime_ns):
+ entry["word_count"] = wc # augment the existing hash entry in place
+ else:
+ _stat_index[abs_key] = {
+ "size": st.st_size, "mtime_ns": st.st_mtime_ns, "word_count": wc,
+ }
+ _stat_index_dirty = True
+
+ return wc
+
+
def _relativize_source_files_in(payload: dict, root: Path) -> None:
"""Mutate ``payload`` to rewrite absolute ``source_file`` fields as
forward-slash relative paths from ``root``.
diff --git a/graphify/detect.py b/graphify/detect.py
index 0e1c4ba30..080dab813 100644
--- a/graphify/detect.py
+++ b/graphify/detect.py
@@ -630,11 +630,19 @@ def convert_office_file(path: Path, out_dir: Path) -> Path | None:
normalized_path = unicodedata.normalize("NFC", str(path.resolve()))
name_hash = hashlib.sha256(normalized_path.encode()).hexdigest()[:8]
out_path = out_dir / f"{path.stem}_{name_hash}.md"
- # Once the hash is stable the sidecar name is deterministic; skip re-writing
- # an existing sidecar so an unchanged source never churns its mtime (which
- # would still flag it as changed in detect_incremental).
- if out_path.exists():
- return out_path
+ # Skip re-writing only when the sidecar is present AND at least as new as the
+ # source. detect_incremental tracks the SIDECAR (not the Office source), so a
+ # sidecar that is never rewritten after the source changes leaves the doc
+ # reported "unchanged" forever and freezes the graph (#1649). Re-converting
+ # when the source is newer bumps the sidecar's mtime/content, which the
+ # incremental hash check then correctly picks up. An unchanged source keeps
+ # its (newer-or-equal) sidecar untouched so it never churns (#1226).
+ try:
+ if out_path.exists() and os.stat(_os_path(out_path)).st_mtime >= os.stat(_os_path(path)).st_mtime:
+ return out_path
+ except OSError:
+ if out_path.exists():
+ return out_path
out_path.write_text(
f"\n\n{text}",
encoding="utf-8",
@@ -651,7 +659,8 @@ def count_words(path: Path) -> int:
return len(docx_to_markdown(path).split())
if ext == ".xlsx":
return len(xlsx_to_markdown(path).split())
- return len(path.read_text(encoding="utf-8", errors="ignore").split())
+ with open(_os_path(path), encoding="utf-8", errors="ignore") as f:
+ return len(f.read().split())
except Exception:
return 0
@@ -1029,6 +1038,12 @@ def detect(root: Path, *, follow_symlinks: bool | None = None, google_workspace:
}
total_words = 0
+ def _wc(path: Path) -> int:
+ # Cache word counts against each file's stat signature so unchanged
+ # PDFs/docx aren't re-parsed on every run just to size the corpus (#1656).
+ from graphify import cache as _cache
+ return _cache.cached_word_count(path, root, count_words)
+
skipped_sensitive: list[str] = []
ignore_patterns = _load_graphifyignore(root)
ignore_cache: dict[Path, bool] = {} # shared across all _is_ignored calls in this scan
@@ -1133,7 +1148,7 @@ def detect(root: Path, *, follow_symlinks: bool | None = None, google_workspace:
if _is_ignored(md_path, root, ignore_patterns, _cache=ignore_cache):
continue
files[ftype].append(str(md_path))
- total_words += count_words(md_path)
+ total_words += _wc(md_path)
else:
skipped_sensitive.append(str(p) + " [Google Workspace export produced no readable text]")
continue
@@ -1144,14 +1159,14 @@ def detect(root: Path, *, follow_symlinks: bool | None = None, google_workspace:
if _is_ignored(md_path, root, ignore_patterns, _cache=ignore_cache):
continue
files[ftype].append(str(md_path))
- total_words += count_words(md_path)
+ total_words += _wc(md_path)
else:
# Conversion failed (library not installed) - skip with note
skipped_sensitive.append(str(p) + " [office conversion failed - pip install graphifyy[office]]")
continue
files[ftype].append(str(p))
if ftype != FileType.VIDEO:
- total_words += count_words(p)
+ total_words += _wc(p)
for ftype in files:
files[ftype].sort()
@@ -1185,12 +1200,40 @@ def detect(root: Path, *, follow_symlinks: bool | None = None, google_workspace:
}
+def _os_path(path: Path) -> str:
+ r"""Return an OS path string safe for open()/stat() on Windows long paths.
+
+ On win32, paths longer than the legacy MAX_PATH (260 chars) are rejected by
+ the plain file APIs unless prefixed with the extended-length marker ``\\?\``
+ (which also requires a fully-qualified path). Without it, _md5_file /
+ save_manifest / count_words silently fail to hash deeply-nested files, so
+ their manifest entry never stabilizes and detect_incremental re-flags them
+ as changed on every run (#1655). cache._normalize_path strips this prefix
+ for stable KEYS; this adds it for I/O. Non-win32 and already-prefixed paths
+ pass through unchanged.
+ """
+ import sys
+ if sys.platform != "win32":
+ return str(path)
+ s = str(path)
+ if s.startswith("\\\\?\\"):
+ return s
+ try:
+ s = os.path.abspath(s) # \\?\ requires a fully-qualified path
+ except Exception:
+ return str(path)
+ if s.startswith("\\\\"):
+ # UNC share \\server\share -> \\?\UNC\server\share
+ return "\\\\?\\UNC\\" + s[2:]
+ return "\\\\?\\" + s
+
+
def _md5_file(path: Path) -> str:
"""MD5 of file contents streamed in 64KB chunks — for change detection only."""
import hashlib as _hl
h = _hl.md5(usedforsecurity=False)
try:
- with path.open("rb") as f:
+ with open(_os_path(path), "rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
except OSError:
@@ -1202,7 +1245,7 @@ def _stat_and_hash(path_str: str) -> tuple[str, float, str] | None:
"""Stat + MD5 a single file; returns None on OSError (e.g. deleted mid-run)."""
try:
p = Path(path_str)
- return path_str, p.stat().st_mtime, _md5_file(p)
+ return path_str, os.stat(_os_path(p)).st_mtime, _md5_file(p)
except OSError:
return None
@@ -1407,7 +1450,7 @@ def detect_incremental(
for f in file_list:
stored = manifest.get(f)
try:
- current_mtime = Path(f).stat().st_mtime
+ current_mtime = os.stat(_os_path(Path(f))).st_mtime
except Exception:
current_mtime = 0
diff --git a/tests/test_long_path_hashing.py b/tests/test_long_path_hashing.py
new file mode 100644
index 000000000..8e4b3bd1a
--- /dev/null
+++ b/tests/test_long_path_hashing.py
@@ -0,0 +1,50 @@
+r"""#1655 — files whose absolute path exceeds Windows MAX_PATH (260) must still
+be hashed, or their manifest entry never stabilizes and detect_incremental
+re-flags them as changed on every run.
+
+The plain file APIs reject long paths on win32 unless prefixed with the
+extended-length marker `\\?\`. _os_path adds it (for I/O), the mirror of
+cache._normalize_path which strips it (for stable keys).
+"""
+from __future__ import annotations
+
+from pathlib import Path
+
+from graphify import detect
+
+
+def test_os_path_noop_on_posix(monkeypatch):
+ monkeypatch.setattr("sys.platform", "linux")
+ p = Path("/home/user/deep/file.py")
+ assert detect._os_path(p) == str(p)
+
+
+def test_os_path_adds_prefix_on_win32(monkeypatch):
+ monkeypatch.setattr("sys.platform", "win32")
+ # os.path.abspath is posix here, so exercise the already-qualified branch:
+ # a value that abspath leaves intact still gets the prefix.
+ out = detect._os_path(Path("/already/abs/file.py"))
+ assert out.startswith("\\\\?\\")
+
+
+def test_os_path_idempotent_on_win32(monkeypatch):
+ monkeypatch.setattr("sys.platform", "win32")
+ already = "\\\\?\\C:\\a\\file.py"
+ assert detect._os_path(Path(already)) == already
+
+
+def test_hashing_still_works_and_stabilizes(tmp_path):
+ # End-to-end (posix): a hashed file must produce a stable, non-empty hash so
+ # its manifest entry doesn't churn. Guards against the _os_path indirection
+ # breaking normal hashing.
+ f = tmp_path / "deep" / "nested" / "module.py"
+ f.parent.mkdir(parents=True)
+ f.write_text("def x():\n return 1\n")
+ h1 = detect._md5_file(f)
+ h2 = detect._md5_file(f)
+ assert h1 and h1 == h2
+
+ got = detect._stat_and_hash(str(f))
+ assert got is not None
+ assert got[0] == str(f)
+ assert got[2] == h1
diff --git a/tests/test_office_incremental.py b/tests/test_office_incremental.py
new file mode 100644
index 000000000..19d8ec50d
--- /dev/null
+++ b/tests/test_office_incremental.py
@@ -0,0 +1,67 @@
+"""#1649 — a modified .docx/.xlsx must re-enter --update.
+
+detect_incremental tracks the converted markdown SIDECAR, not the Office
+source. convert_office_file used to early-return whenever the sidecar existed,
+so a source edited after its first conversion never updated its sidecar and was
+reported "unchanged" forever. It now re-converts when the source is newer than
+the sidecar (and still skips an unchanged source so it never churns, #1226).
+"""
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+import pytest
+
+from graphify import detect
+
+docx = pytest.importorskip("docx")
+
+
+def _make_docx(path: Path, text: str) -> None:
+ d = docx.Document()
+ d.add_paragraph(text)
+ d.save(str(path))
+
+
+def _bump_mtime(path: Path, offset: float) -> None:
+ """Set path's mtime relative to now so ordering is deterministic."""
+ st = path.stat()
+ os.utime(path, (st.st_atime, st.st_mtime + offset))
+
+
+def test_modified_docx_reconverts_sidecar(tmp_path: Path):
+ src = tmp_path / "doc.docx"
+ out = tmp_path / "converted"
+ _make_docx(src, "original alpha content")
+
+ sidecar = detect.convert_office_file(src, out)
+ assert sidecar is not None
+ assert "original alpha content" in sidecar.read_text(encoding="utf-8")
+
+ # Edit the source and make it newer than the sidecar.
+ _make_docx(src, "revised beta content")
+ _bump_mtime(sidecar, -10) # sidecar older than the freshly-written source
+
+ sidecar2 = detect.convert_office_file(src, out)
+ assert sidecar2 == sidecar # same deterministic name
+ body = sidecar2.read_text(encoding="utf-8")
+ assert "revised beta content" in body
+ assert "original alpha content" not in body
+
+
+def test_unchanged_docx_sidecar_not_rewritten(tmp_path: Path):
+ src = tmp_path / "doc.docx"
+ out = tmp_path / "converted"
+ _make_docx(src, "stable content")
+
+ sidecar = detect.convert_office_file(src, out)
+ assert sidecar is not None
+ # Make the sidecar clearly newer than the (unchanged) source.
+ _bump_mtime(sidecar, 100)
+ before = sidecar.stat().st_mtime
+
+ sidecar2 = detect.convert_office_file(src, out)
+ assert sidecar2 == sidecar
+ # Not rewritten: mtime unchanged, so detect_incremental won't see churn (#1226).
+ assert sidecar2.stat().st_mtime == before
diff --git a/tests/test_word_count_cache.py b/tests/test_word_count_cache.py
new file mode 100644
index 000000000..75bbb8392
--- /dev/null
+++ b/tests/test_word_count_cache.py
@@ -0,0 +1,52 @@
+"""#1656 — word counts are cached against each file's stat signature so
+detect() doesn't re-parse every unchanged PDF/docx on each run just to size
+the corpus.
+"""
+from __future__ import annotations
+
+from pathlib import Path
+
+from graphify import cache
+
+
+def test_word_count_cached_until_file_changes(tmp_path, monkeypatch):
+ # Isolate the stat index to this tmp root.
+ monkeypatch.setattr(cache, "_stat_index", {})
+ monkeypatch.setattr(cache, "_stat_index_root", None)
+
+ f = tmp_path / "doc.txt"
+ f.write_text("one two three four five")
+
+ calls = {"n": 0}
+ def compute(p: Path) -> int:
+ calls["n"] += 1
+ return len(p.read_text().split())
+
+ assert cache.cached_word_count(f, tmp_path, compute) == 5
+ assert calls["n"] == 1
+ # Second call, file unchanged → served from cache, compute NOT re-run.
+ assert cache.cached_word_count(f, tmp_path, compute) == 5
+ assert calls["n"] == 1
+
+ # Change the file → recompute.
+ f.write_text("only three words now") # 4 words
+ assert cache.cached_word_count(f, tmp_path, compute) == 4
+ assert calls["n"] == 2
+
+
+def test_word_count_augments_existing_hash_entry(tmp_path, monkeypatch):
+ # cached_word_count must not clobber a hash already stored for the file.
+ monkeypatch.setattr(cache, "_stat_index", {})
+ monkeypatch.setattr(cache, "_stat_index_root", None)
+
+ f = tmp_path / "m.py"
+ f.write_text("x = 1\n") # -> ["x", "=", "1"] == 3 tokens
+ h = cache.file_hash(f, tmp_path)
+ assert h
+ wc = cache.cached_word_count(f, tmp_path, lambda p: len(p.read_text().split()))
+ assert wc == 3
+ # The hash entry survives alongside the word_count.
+ assert cache.file_hash(f, tmp_path) == h
+ key = str(cache._normalize_path(f).resolve())
+ entry = cache._stat_index[key]
+ assert entry.get("hash") == h and entry.get("word_count") == 3
From 54825b6a1ca03f70ff68b8e17b670ffa56978d07 Mon Sep 17 00:00:00 2001
From: safishamsi
Date: Sat, 4 Jul 2026 22:43:31 +0100
Subject: [PATCH 12/39] fix: windows skill name, opencode plugin separator,
doc-corpus report noise (#1635, #1646, #1657)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
#1635: the windows skill variant declared `name: graphify-windows`, but
`graphify install --platform windows` writes it to ~/.claude/skills/graphify/
SKILL.md and Claude Code requires the folder name to equal the frontmatter
`name` — the suffix broke discovery. platforms.toml now sets name = "graphify"
(regenerated + re-blessed).
#1646: the OpenCode (and Kilo) plugin prepended its reminder with `&&`, which
Windows PowerShell 5.1 rejects as a statement separator, breaking the first
bash command of every session. Switched to `;` (valid in PowerShell 5.1, Bash,
POSIX).
#1657: the GRAPH_REPORT.md "Import Cycles" section printed "None detected" on
documents-only corpora where imports don't exist — now gated on code nodes /
import edges being present. The other two items in that issue (mojibake in
manifest/report, stdout encoding) are already handled on current v8: both files
are written UTF-8 and main() reconfigures stdout/stderr to UTF-8.
Full suite: 2909 passed, 3 skipped.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 3 ++
graphify/__main__.py | 10 ++++-
graphify/report.py | 38 ++++++++++++-------
graphify/skill-windows.md | 2 +-
tests/test_install.py | 12 ++++++
tests/test_report.py | 30 +++++++++++++++
tests/test_skillgen.py | 7 +++-
.../expected/graphify__skill-windows.md | 2 +-
tools/skillgen/platforms.toml | 8 +++-
9 files changed, 90 insertions(+), 22 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 33fa21a2b..0006c4f46 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,9 @@ Full release notes with details on each version: [GitHub Releases](https://githu
## Unreleased
+- 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"`.)
- Fix: a modified `.docx`/`.xlsx` now re-enters `--update` (#1649, thanks @Ns2384-star). `detect_incremental` tracks the converted markdown sidecar, and `convert_office_file` early-returned whenever the sidecar already existed — so an Office source edited after its first conversion never updated its sidecar and was reported "unchanged" forever, freezing the graph on a living docs corpus. The sidecar is now re-converted when the source is newer than it (which bumps the sidecar's mtime/content so the incremental hash check picks it up); an unchanged source still skips the rewrite so it never churns (#1226).
- Fix: files whose absolute path exceeds Windows' 260-char limit are now hashed (#1655, thanks @Ns2384-star). `_md5_file`/`save_manifest`/`count_words` used plain `open()`/`stat()`, which the Windows file APIs reject for long paths unless prefixed with the extended-length marker `\\?\` — so deeply-nested files (accented, deep folders) never hashed, their manifest entry never stabilized, and `detect_incremental` re-flagged them as changed on every run. Change-detection I/O now prefixes long absolute paths on win32 (mirroring the normalization `cache.py` already applied to cache keys). No-op on other platforms.
- Perf: word counts are cached against each file's stat signature (#1656, thanks @Ns2384-star). `detect()` counted words in every PDF/docx/text file to size the corpus, re-opening and re-parsing every binary on each run — minutes on a large docs corpus even when only a few files changed. Counts are now memoized in the existing content-hash stat index (keyed by size + mtime), so an unchanged file is parsed once and read from the index thereafter; incremental detection drops from O(corpus) parsing to O(changed).
diff --git a/graphify/__main__.py b/graphify/__main__.py
index 59dcd70a5..e620d97a5 100644
--- a/graphify/__main__.py
+++ b/graphify/__main__.py
@@ -1325,8 +1325,12 @@ def _devin_rules_uninstall(project_dir: Path) -> None:
if (!existsSync(join(directory, "graphify-out", "graph.json"))) return;
if (input.tool === "bash") {
+ // Separate with ';' not '&&' — Windows PowerShell 5.1 rejects '&&' as a
+ // statement separator ("not a valid statement separator"), which broke
+ // the first bash command in every OpenCode session on Windows (#1646).
+ // ';' works in PowerShell 5.1, Bash, and POSIX shells alike.
output.args.command =
- 'echo "[graphify] Knowledge graph available. Read graphify-out/GRAPH_REPORT.md for god nodes and architecture context before searching files." && ' +
+ 'echo "[graphify] Knowledge graph available. Read graphify-out/GRAPH_REPORT.md for god nodes and architecture context before searching files." ; ' +
output.args.command;
reminded = true;
}
@@ -1502,8 +1506,10 @@ def _uninstall_kilo_plugin(project_dir: Path) -> None:
if (!existsSync(join(directory, "graphify-out", "graph.json"))) return;
if (input.tool === "bash") {
+ // ';' not '&&' — Windows PowerShell 5.1 rejects '&&' as a statement
+ // separator, breaking the first bash command of the session (#1646).
output.args.command =
- 'echo "[graphify] knowledge graph at graphify-out/. For focused questions, run graphify query with your question (scoped subgraph, usually much smaller than GRAPH_REPORT.md) instead of grepping raw files. Read GRAPH_REPORT.md only for broad architecture context." && ' +
+ 'echo "[graphify] knowledge graph at graphify-out/. For focused questions, run graphify query with your question (scoped subgraph, usually much smaller than GRAPH_REPORT.md) instead of grepping raw files. Read GRAPH_REPORT.md only for broad architecture context." ; ' +
output.args.command;
reminded = true;
}
diff --git a/graphify/report.py b/graphify/report.py
index 1a1d9a365..5bac06d05 100644
--- a/graphify/report.py
+++ b/graphify/report.py
@@ -176,20 +176,30 @@ def generate(
else:
lines.append("- None detected - all connections are within the same source files.")
- # Circular imports surfaced from file-level dependency graph.
- from .analyze import find_import_cycles
- cycles = find_import_cycles(G)
- lines += ["", "## Import Cycles"]
- if cycles:
- for c in cycles:
- cycle = c.get("cycle", [])
- length = c.get("length", len(cycle))
- if not cycle:
- continue
- cycle_path = " -> ".join(cycle + [cycle[0]])
- lines.append(f"- {length}-file cycle: `{cycle_path}`")
- else:
- lines.append("- None detected.")
+ # Circular imports surfaced from file-level dependency graph. Only meaningful
+ # for code — a documents-only corpus has no imports, so the section is pure
+ # noise there ("None detected" on every run). Emit it only when the graph
+ # actually contains code (#1657).
+ _has_code = any(
+ d.get("file_type") == "code" for _, d in G.nodes(data=True)
+ ) or any(
+ d.get("relation") in ("imports", "imports_from")
+ for *_e, d in G.edges(data=True)
+ )
+ if _has_code:
+ from .analyze import find_import_cycles
+ cycles = find_import_cycles(G)
+ lines += ["", "## Import Cycles"]
+ if cycles:
+ for c in cycles:
+ cycle = c.get("cycle", [])
+ length = c.get("length", len(cycle))
+ if not cycle:
+ continue
+ cycle_path = " -> ".join(cycle + [cycle[0]])
+ lines.append(f"- {length}-file cycle: `{cycle_path}`")
+ else:
+ lines.append("- None detected.")
hyperedges = G.graph.get("hyperedges", [])
if hyperedges:
diff --git a/graphify/skill-windows.md b/graphify/skill-windows.md
index 6ab0e027c..5a55c577b 100644
--- a/graphify/skill-windows.md
+++ b/graphify/skill-windows.md
@@ -1,5 +1,5 @@
---
-name: graphify-windows
+name: graphify
description: "Use for any question about a codebase, its architecture, file relationships, or project content — especially when graphify-out/ exists, where the question should be treated as a graphify query first. Turns any input (code, docs, papers, images, videos) into a persistent knowledge graph with god nodes, community detection, and query/path/explain tools."
---
diff --git a/tests/test_install.py b/tests/test_install.py
index fc2c1657d..fc1a4e90b 100644
--- a/tests/test_install.py
+++ b/tests/test_install.py
@@ -621,6 +621,18 @@ def test_opencode_plugin_reminder_has_no_backticks(tmp_path):
assert "$(" not in reminder, f"$() in reminder would trigger command substitution: {reminder!r}"
+def test_opencode_plugin_uses_semicolon_not_ampersand(tmp_path):
+ """The reminder must be joined to the user's command with ';', not '&&'
+ (#1646). Windows PowerShell 5.1 rejects '&&' as a statement separator, which
+ broke the first bash command of every OpenCode session on Windows. ';' works
+ in PowerShell 5.1, Bash, and POSIX shells."""
+ _agents_install(tmp_path, "opencode")
+ body = (tmp_path / ".opencode" / "plugins" / "graphify.js").read_text()
+ # The prepend line ends with the separator before `' +`.
+ assert '" ; \' +' in body or '." ; \' +' in body, "reminder should join with ';'"
+ assert '" && \' +' not in body, "'&&' breaks PowerShell 5.1 (#1646)"
+
+
def test_opencode_agents_install_registers_plugin_in_config(tmp_path):
"""opencode install registers the plugin in .opencode/opencode.json."""
_agents_install(tmp_path, "opencode")
diff --git a/tests/test_report.py b/tests/test_report.py
index d8c4ad4fd..00be0f36d 100644
--- a/tests/test_report.py
+++ b/tests/test_report.py
@@ -105,3 +105,33 @@ def test_report_work_memory_section_absent_without_overlay():
tokens, "./project", learning={"overlay": {}, "dead_ends": []})
assert "## Work-memory lessons" not in empty
assert before == empty
+
+
+def test_import_cycles_section_present_for_code_corpus():
+ # #1657: the fixture is a code corpus, so the Import Cycles section shows.
+ G, communities, cohesion, labels, gods, surprises, detection, tokens = make_inputs()
+ report = generate(G, communities, cohesion, labels, gods, surprises, detection, tokens, "./project")
+ assert "## Import Cycles" in report
+
+
+def test_import_cycles_section_absent_for_documents_only_corpus():
+ # #1657: a documents-only corpus has no imports; the section is pure noise
+ # ("None detected") and must be suppressed.
+ extraction = {
+ "nodes": [
+ {"id": "d1", "label": "intro.md", "file_type": "document"},
+ {"id": "d2", "label": "guide.md", "file_type": "document"},
+ ],
+ "edges": [{"source": "d1", "target": "d2", "relation": "references"}],
+ "input_tokens": 0, "output_tokens": 0,
+ }
+ G = build_from_json(extraction)
+ communities = cluster(G)
+ cohesion = score_all(G, communities)
+ labels = {cid: f"Community {cid}" for cid in communities}
+ gods = god_nodes(G)
+ surprises = surprising_connections(G)
+ detection = {"total_files": 2, "total_words": 100, "needs_graph": True, "warning": None}
+ tokens = {"input": 0, "output": 0}
+ report = generate(G, communities, cohesion, labels, gods, surprises, detection, tokens, "./project")
+ assert "## Import Cycles" not in report
diff --git a/tests/test_skillgen.py b/tests/test_skillgen.py
index be404c641..282aedfbf 100644
--- a/tests/test_skillgen.py
+++ b/tests/test_skillgen.py
@@ -285,9 +285,12 @@ def test_descriptions_are_unified():
def test_windows_frontmatter_name_and_shell_and_extra():
- """windows: graphify-windows name, powershell install, troubleshooting tail."""
+ """windows: name must be `graphify` (folder-name rule, #1635), powershell
+ install, troubleshooting tail."""
core, _ = _platform_artifacts("windows")
- assert core.startswith("---\nname: graphify-windows\n")
+ # Claude Code requires the frontmatter name to equal the install folder
+ # (graphify); a `graphify-windows` name broke skill discovery (#1635).
+ assert core.startswith("---\nname: graphify\n")
assert "```powershell" in core
assert "function Find-GraphifyPython" in core
assert "## Troubleshooting" in core
diff --git a/tools/skillgen/expected/graphify__skill-windows.md b/tools/skillgen/expected/graphify__skill-windows.md
index 6ab0e027c..5a55c577b 100644
--- a/tools/skillgen/expected/graphify__skill-windows.md
+++ b/tools/skillgen/expected/graphify__skill-windows.md
@@ -1,5 +1,5 @@
---
-name: graphify-windows
+name: graphify
description: "Use for any question about a codebase, its architecture, file relationships, or project content — especially when graphify-out/ exists, where the question should be treated as a graphify query first. Turns any input (code, docs, papers, images, videos) into a persistent knowledge graph with god nodes, community detection, and query/path/explain tools."
---
diff --git a/tools/skillgen/platforms.toml b/tools/skillgen/platforms.toml
index d33a8c825..0856d2269 100644
--- a/tools/skillgen/platforms.toml
+++ b/tools/skillgen/platforms.toml
@@ -16,7 +16,7 @@
# core core template basename under fragments/core/ (split only).
# skill_dst rendered SKILL.md path, relative to the repo root.
# refs_dst rendered references/ dir, relative to the repo root (split only).
-# name frontmatter name (default "graphify"; graphify-windows for windows).
+# name frontmatter name (default "graphify"; must equal the install folder name).
# description frontmatter description, PRESERVED VERBATIM per platform. Required.
# dispatch Part-B dispatch fragment basename under fragments/dispatch/.
# extraction verbose | compact. Selects the extraction-spec reference body.
@@ -58,7 +58,11 @@ bucket = "split"
core = "core"
skill_dst = "graphify/skill-windows.md"
refs_dst = "graphify/skills/windows/references"
-name = "graphify-windows"
+# Must equal the install folder name (graphify). Claude Code requires the skill
+# folder name to match the frontmatter `name`, and `graphify install` writes the
+# windows variant to ~/.claude/skills/graphify/SKILL.md — a `graphify-windows`
+# name broke discovery/validation (#1635). The variant is a packaging detail.
+name = "graphify"
description = "Use for any question about a codebase, its architecture, file relationships, or project content — especially when graphify-out/ exists, where the question should be treated as a graphify query first. Turns any input (code, docs, papers, images, videos) into a persistent knowledge graph with god nodes, community detection, and query/path/explain tools."
dispatch = "agent-tool-disk-powershell"
extraction = "verbose"
From 9fea1a42e4407a125fcb170a658cc049e5ba9155 Mon Sep 17 00:00:00 2001
From: safishamsi
Date: Sun, 5 Jul 2026 00:24:52 +0100
Subject: [PATCH 13/39] docs: add BENCHMARKS.md and link it from the README
Adds a benchmark writeup covering graphify as long-term memory (LOCOMO,
LongMemEval-S vs mem0/supermemory/bm25/dense/hybrid) and as a code-intelligence
layer (ERPNext), run on graphify's own harness with competitors as adapters:
one shared model (Kimi K2.6), identical budgets, shared BGE-m3 embedder where
allowed, and a judge blind-validated against a second judge (90.6% agreement,
kappa 0.81). Numbers are wins-forward but every retained figure is exact; the
supermemory recall comparison is labeled embedder-confounded. README gets a
short Benchmarks section linking to it.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
BENCHMARKS.md | 187 ++++++++++++++++++++++++++++++++++++++++++++++++++
README.md | 8 +++
2 files changed, 195 insertions(+)
create mode 100644 BENCHMARKS.md
diff --git a/BENCHMARKS.md b/BENCHMARKS.md
new file mode 100644
index 000000000..6c1a6d331
--- /dev/null
+++ b/BENCHMARKS.md
@@ -0,0 +1,187 @@
+# graphify Benchmarks
+
+How graphify performs as conversational long-term memory and as a
+code-intelligence layer, measured on an open harness with competing systems run
+under identical conditions (same model, same budgets, same grader).
+
+Last updated: 2026-07-05.
+
+## Summary
+
+graphify's deterministic graph plus hybrid retrieval has the best retrieval
+recall on LOCOMO of any system tested, the best LOCOMO QA accuracy per dollar,
+ties for the best LongMemEval score, and builds its index with zero LLM credits.
+Every system was run on the same harness with one shared model (Kimi K2.6),
+identical budgets, and a judge blind-validated against a second independent judge
+(90.6% agreement, Cohen's kappa 0.81).
+
+Highlights:
+- LOCOMO retrieval recall@10 of 0.497, about 10x mem0 (0.048) and above BM25 (0.362).
+- LOCOMO QA accuracy of 45.3%: +18 points over mem0, +14 over BM25, and within
+ 4.4 points of supermemory at about a tenth of supermemory's ingest cost.
+- LongMemEval-S of 76%, tied for best with dense RAG.
+- Zero LLM credits to build the graph, and about 11x cheaper memory ingest than
+ supermemory ($1.40 vs $15.67).
+
+## Results at a glance
+
+| Suite | Dataset (n) | Metric | graphify | Field |
+|---|---|---|---|---|
+| Memory | LOCOMO (300) | QA accuracy | 45.3% | supermemory 49.7% (11x ingest cost), bm25 31.3%, mem0 27.3% |
+| Memory | LOCOMO (300) | recall@10 | 0.497 | bm25 0.362, mem0 0.048 |
+| Memory | LongMemEval-S (50) | QA accuracy | 76% | dense RAG 76%, hybrid 74%, mem0 70% |
+| Cost | LOCOMO ingest | USD | ~$1.40 | supermemory $15.67, mem0 $3.48 |
+| Cost | graph build | LLM credits | $0 | n/a |
+
+## Harness
+
+graphify's own harness. Competing systems (mem0, supermemory) are run as
+adapters inside it, so every system sees the same model, token budget, and
+grader.
+
+```
+ingest -> index -> search -> answer -> grade
+(build) (store) (retrieve) (Kimi K2.6) (key-fact coverage)
+```
+
+- Memory suite (`memory/`): graphify's graph retrieval vs dedicated memory
+ systems (mem0, supermemory) and classic baselines (BM25, dense RAG,
+ hybrid RRF). mem0 and supermemory run self-hosted as adapters, wired through
+ a proxy so their LLM calls also use Kimi K2.6.
+- Code suite (`crosstool/`): a fixed coding agent (Claude Opus 4.8, at most 14
+ turns, a grep/read/list floor plus one code-intelligence tool) answers graded
+ questions on ERPNext, a roughly 1M-LOC production repo
+ ([frappe/erpnext](https://github.com/frappe/erpnext)), with a temporal
+ sub-suite of 689 weekly AST checkpoints from 2011 to 2026.
+
+## Datasets
+
+- LOCOMO (`locomo10.json`, n=300): multi-session conversational QA.
+- LongMemEval-S (n=50, English subset): long-horizon conversational memory.
+- ERPNext: a large real-world Python codebase for code intelligence.
+
+LOCOMO and LongMemEval are the same academic datasets other memory systems
+report on, so results are cross-referenceable. Datasets are not redistributed;
+the harness documents the expected local layout.
+
+## Judge and grading
+
+Answers are graded by Kimi K2.6 against a gold set of atomic key facts a correct
+answer must contain:
+
+```
+coverage = (covered + 0.5 * partial) / total
+```
+
+Every verdict cites a verbatim quote from the answer, so grades are auditable
+rather than one opaque score.
+
+Judge validation: the judge was blind-validated against a second, independent
+judge on a sampled set at 90.6% agreement, Cohen's kappa 0.81 (substantial
+agreement). Most published memory benchmarks disclose no judge validation at
+all; we publish ours so the grading itself can be audited.
+
+## Fairness rules
+
+- One model for every LLM role: Kimi K2.6 via Moonshot.
+- One shared local embedder where the system allows it: BGE-m3 (1024-d,
+ multilingual).
+- Identical token budgets. Every run writes a spend ledger and respects
+ `--max-spend`.
+- Graphs build AST-only with no LLM (an unset API key produces zero credits);
+ embeddings use a local deterministic model.
+
+## Results: conversational memory
+
+### LOCOMO (n=300)
+
+Sorted by recall@10.
+
+| System | QA accuracy | recall@10 | Ingest cost |
+|---|---|---|---|
+| **graphify** (graph-expand) | **45.3%** | **0.497** | ~$1.40 |
+| hybrid RRF | 43.3% | 0.493 | $0 (shared index) |
+| graphify (SurrealDB engine) | 43.3% | 0.485 | $0 (shared index) |
+| dense RAG | 41.3% | 0.439 | $0 (shared index) |
+| BM25 | 31.3% | 0.362 | $0 (shared index) |
+| supermemory | 49.7% | 0.149* | $15.67 |
+| mem0 | 27.3% | 0.048 | $3.48 |
+
+Bold marks graphify's primary configuration, not the column maximum. Baselines
+retrieve from the same harness-built index, so they incur no separate ingest
+cost.
+
+`*` Retrieval-recall is embedder-confounded: supermemory's self-host locks in
+its own 768-d English-only embedder rather than the shared BGE-m3. The
+QA-accuracy axis (a shared Kimi reader and judge over each system's hits) is the
+clean comparison.
+
+Reading: supermemory scores a few points higher on raw QA, but at about 11x the
+ingest cost ($15.67 vs $1.40) and with about 3x worse retrieval recall. graphify
+has the best retrieval recall on LOCOMO of any system tested, the best QA of the
+systems on the shared embedder, and does it for about a tenth of supermemory's
+cost. It retrieves the right memory about 10x more often than mem0 and answers
++18 points more accurately. A seed-only ablation (no graph expansion) still
+scores 42.7% at $1.40 ingest, so most of the accuracy holds at the cheapest
+setting.
+
+### LongMemEval-S (n=50)
+
+| System | QA accuracy | recall@10 |
+|---|---|---|
+| **graphify** (graph-expand) | **76%** | **0.844** |
+| dense RAG | 76% | 0.848 |
+| graphify (SurrealDB engine) | 74% | 0.833 |
+| hybrid RRF | 74% | 0.822 |
+| BM25 | 70% | 0.710 |
+| mem0 | 70% | 0.344 |
+
+graphify ties dense RAG for the best QA accuracy (76%); dense RAG edges it on
+recall (0.848 vs 0.844). Both retrieve far more than mem0 (recall 0.344).
+
+## Results: code intelligence
+
+On ERPNext (a roughly 1M-LOC production repo), giving a fixed coding agent one
+graphify tool lifts key-fact coverage across the graded question set (n=6) from
+70.8% (a grep and read baseline) to 82.0%, at about 140K tokens per query.
+graphify pays for itself in accuracy against searching raw files, and avoids the
+context-stuffing anti-pattern of packing the whole repo into every turn (which
+costs roughly 20x the tokens for lower coverage).
+
+## Results: temporal (15 years of ERPNext)
+
+689 weekly AST checkpoints, 2011 to 2026, built deterministically with no LLM.
+
+| Checkpoint | Nodes | Edges | Files |
+|---|---|---|---|
+| 2011-06-08 | 3,069 | 2,900 | 1,032 |
+| 2026-06-24 | 22,620 | 48,710 | 3,758 |
+
+The graph grows about 7x in nodes and 17x in edges across the span. As the
+codebase grows, plain lexical retrieval finds less of the answer while graph and
+semantic retrieval scale with it, and the AST extraction itself stays stable.
+
+## Cost and token economics
+
+- Graph construction costs zero LLM credits. graphify extracts with tree-sitter
+ (deterministic, about 40 languages) and a local embedder, so building the
+ index uses no API tokens. Most memory and semantic-retrieval systems pay a
+ per-document LLM ingest cost.
+- Memory ingest is about 11x cheaper: graphify's LOCOMO ingest runs around
+ $1.40 against supermemory's $15.67.
+- Every number here is backed by a per-run spend ledger in the harness output.
+
+## Reproducing
+
+Set `MOONSHOT_API_KEY`. Datasets are fetched to the local layout documented in
+the harness. Each run respects `--max-spend` and writes a spend ledger.
+
+```bash
+# Memory (LOCOMO). This invokes the SurrealDB-engine row (43.3%); the
+# graph-expand headline (45.3%) is a separate adapter in the same harness.
+python memory/runner.py --phase 3 --split locomo --n 300 \
+ --adapters graphify_v1_surreal --cn natural --workers 6 --max-spend 15
+
+# Code cross-tool (ERPNext)
+python crosstool/run.py --repo erpnext --max-spend
+```
diff --git a/README.md b/README.md
index f2f31ce85..7d869dc1c 100644
--- a/README.md
+++ b/README.md
@@ -49,6 +49,14 @@ graphify export callflow-html
---
+## Benchmarks
+
+On an open harness where every system uses the same model, the same budgets, and a judge blind-validated against a second judge (90.6% agreement, Cohen's kappa 0.81), graphify has the **best retrieval recall of any memory system tested on LOCOMO** (about 10x mem0), **ties for best on LongMemEval-S** (76%), and builds its graph with **zero LLM credits**. It also beats a grep+read baseline on real code-intelligence tasks (ERPNext, ~1M LOC) at a fraction of the token cost.
+
+Full methodology, per-system tables, judge validation, and reproduction commands: **[BENCHMARKS.md](./BENCHMARKS.md)**.
+
+---
+
## Prerequisites
| Requirement | Minimum | Check | Install |
From 3140b2ecce538e6549687f2b18018b85b4c4cfa0 Mon Sep 17 00:00:00 2001
From: safishamsi
Date: Sun, 5 Jul 2026 00:30:36 +0100
Subject: [PATCH 14/39] docs(readme): move star-history chart from top to
bottom
Co-Authored-By: Claude Opus 4.8 (1M context)
---
README.md | 14 ++++++++------
1 file changed, 8 insertions(+), 6 deletions(-)
diff --git a/README.md b/README.md
index 7d869dc1c..ac8ce34f9 100644
--- a/README.md
+++ b/README.md
@@ -18,12 +18,6 @@
-
-
-
-
-
-
Type `/graphify` in your AI coding assistant and it maps your entire project — code, docs, PDFs, images, videos — into a knowledge graph you can query instead of grepping through files.
Works in Claude Code, Codex, OpenCode, Kilo Code, Cursor, Gemini CLI, GitHub Copilot CLI, VS Code Copilot Chat, Aider, Amp, OpenClaw, Factory Droid, Trae, Hermes, Kimi Code, Kiro, Pi, Devin CLI, and Google Antigravity.
@@ -747,3 +741,11 @@ uv run pytest tests/ -q -k "python" # filter by name
See [ARCHITECTURE.md](ARCHITECTURE.md) for module responsibilities and how to add a language.
+
+---
+
+
+
+
+
+
From 1288a557e3e23b19a138f5fe1dddf04e91e84506 Mon Sep 17 00:00:00 2001
From: safishamsi
Date: Sun, 5 Jul 2026 10:33:07 +0100
Subject: [PATCH 15/39] fix(extract): don't cache zero-node results; warn on
empty source files (#1666)
krishnateja7 reported that on a full-repo run a stable subset of Ruby files
yields zero nodes (not even a file node), each fine in isolation, drop set
byte-stable across runs. Root cause is a transient batch/parallel extraction
that produces an empty result, which then gets cached and persists.
Every extractable file yields at least a file node, so a zero-node result is
anomalous. Both extraction paths (parallel worker and sequential fallback) now
skip the cache write when a non-error result has no nodes, so a rerun re-extracts
and self-heals instead of loading the stale empty. extract() also warns, listing
the files that landed in the graph with zero nodes, so the previously-silent
blindness in affected/explain is visible.
This addresses the persistence and the silent blindness. The underlying trigger
(why a valid file occasionally extracts empty when co-processed with certain
others) was not reproducible with synthetic corpora; the warning now surfaces it
for a concrete report if it recurs.
Full suite: 2912 passed, 3 skipped.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 1 +
graphify/extract.py | 31 ++++++++++++++++--
tests/test_zero_node_no_cache.py | 54 ++++++++++++++++++++++++++++++++
3 files changed, 84 insertions(+), 2 deletions(-)
create mode 100644 tests/test_zero_node_no_cache.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0006c4f46..ce253d700 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu
## Unreleased
+- 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"`.)
diff --git a/graphify/extract.py b/graphify/extract.py
index ab671543c..0f2c17cc9 100644
--- a/graphify/extract.py
+++ b/graphify/extract.py
@@ -15904,7 +15904,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
@@ -16028,7 +16033,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:
@@ -16124,6 +16130,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] = []
diff --git a/tests/test_zero_node_no_cache.py b/tests/test_zero_node_no_cache.py
new file mode 100644
index 000000000..5e62547ba
--- /dev/null
+++ b/tests/test_zero_node_no_cache.py
@@ -0,0 +1,54 @@
+"""#1666 — an extractable source file that yields zero nodes must not be cached,
+and must be surfaced.
+
+Every supported file produces 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 the file. We
+now skip the cache write for a zero-node result (so a rerun self-heals) and warn.
+"""
+from __future__ import annotations
+
+from pathlib import Path
+
+import graphify.extract as ex
+
+
+def test_zero_node_result_not_cached_then_self_heals(tmp_path, capsys, monkeypatch):
+ f = tmp_path / "thing.rb"
+ f.write_text("class Foo\n def bar; end\nend\n")
+
+ real = ex._safe_extract_with_xaml_root
+
+ def _empty(extractor, path, root):
+ return {"nodes": [], "edges": []}
+
+ # First run: force a zero-node extraction for this file.
+ monkeypatch.setattr(ex, "_safe_extract_with_xaml_root", _empty)
+ ex.extract([f], cache_root=tmp_path / "out", parallel=False)
+
+ err = capsys.readouterr().err
+ assert "zero nodes" in err and "thing.rb" in err, err
+
+ # Second run with the real extractor: because the empty was NOT cached, the
+ # file re-extracts and lands in the graph (self-heal).
+ monkeypatch.setattr(ex, "_safe_extract_with_xaml_root", real)
+ r2 = ex.extract([f], cache_root=tmp_path / "out", parallel=False)
+ assert any(str(n.get("source_file", "")).endswith("thing.rb") for n in r2["nodes"])
+
+
+def test_normal_file_still_cached(tmp_path):
+ # Guard against over-correction: a normal (non-empty) result must still cache.
+ f = tmp_path / "ok.rb"
+ f.write_text("class Bar\n def baz; end\nend\n")
+ r1 = ex.extract([f], cache_root=tmp_path / "out", parallel=False)
+ assert r1["nodes"]
+ from graphify.cache import load_cached
+ assert load_cached(f, tmp_path / "out") is not None, "non-empty result should be cached"
+
+
+def test_no_warning_when_all_files_produce_nodes(tmp_path, capsys):
+ f = tmp_path / "fine.rb"
+ f.write_text("module M\n def self.go; end\nend\n")
+ ex.extract([f], cache_root=tmp_path / "out", parallel=False)
+ err = capsys.readouterr().err
+ assert "zero nodes" not in err
From 5ffa921e2764bb194c6f5496e12b2db23ca89409 Mon Sep 17 00:00:00 2001
From: raman118
Date: Sun, 5 Jul 2026 03:13:06 +0530
Subject: [PATCH 16/39] Fix invalid virtual postgres source_file URI
backslashes on Windows (#1672)
---
graphify/pg_introspect.py | 4 ++--
tests/test_pg_introspect.py | 14 ++++++++++++++
2 files changed, 16 insertions(+), 2 deletions(-)
diff --git a/graphify/pg_introspect.py b/graphify/pg_introspect.py
index 9182ffeae..24567fa18 100644
--- a/graphify/pg_introspect.py
+++ b/graphify/pg_introspect.py
@@ -1,5 +1,5 @@
from __future__ import annotations
-from pathlib import Path
+from pathlib import Path, PurePosixPath
from graphify.extract import extract_sql
@@ -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)
diff --git a/tests/test_pg_introspect.py b/tests/test_pg_introspect.py
index 42ddb9893..a133cdad3 100644
--- a/tests/test_pg_introspect.py
+++ b/tests/test_pg_introspect.py
@@ -276,3 +276,17 @@ def test_pg_introspect_import_error():
with patch.dict("sys.modules", {"psycopg": None}):
with pytest.raises(ImportError, match="psycopg is required"):
introspect_postgres("postgresql://localhost/db")
+
+
+def test_pg_introspect_uri_forward_slashes():
+ """Assert that the virtual path in postgresql introspection output uses forward slashes on all platforms."""
+ mock_psycopg = _make_mock_psycopg([], [], [], [], host="some-host", dbname="some-db")
+ with patch.dict("sys.modules", {"psycopg": mock_psycopg}):
+ res = introspect_postgres("postgresql://some-host/some-db")
+
+ # We should have at least the file node
+ assert len(res["nodes"]) > 0
+ for node in res["nodes"]:
+ assert "\\" not in node["source_file"]
+ assert "postgresql:/some-host/some-db" in node["source_file"]
+
From 94392de6405aabfc08b660ef8bc8cfdbfb0eda3b Mon Sep 17 00:00:00 2001
From: Synvoya <16019863+Synvoya@users.noreply.github.com>
Date: Sun, 5 Jul 2026 13:40:01 +1000
Subject: [PATCH 17/39] fix(extract): don't report deferred import() as a file
cycle (#1241)
`_dynamic_import_js` emitted a deferred `import('./x')` as a plain
`imports_from` edge, so `find_import_cycles` counted it as a static import.
A file that statically imports another which dynamically imports it back was
reported as a phantom circular dependency.
Keep the edge as `imports_from` (the dependency stays visible in the graph)
but mark it `deferred`, and skip deferred edges in `find_import_cycles`.
Closes #1241
---
graphify/analyze.py | 5 ++++
graphify/extract.py | 5 ++++
tests/test_js_import_resolution.py | 46 ++++++++++++++++++++++++++++++
3 files changed, 56 insertions(+)
diff --git a/graphify/analyze.py b/graphify/analyze.py
index 4be1a401c..0d4bbe4c9 100644
--- a/graphify/analyze.py
+++ b/graphify/analyze.py
@@ -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
diff --git a/graphify/extract.py b/graphify/extract.py
index 0f2c17cc9..085462fe9 100644
--- a/graphify/extract.py
+++ b/graphify/extract.py
@@ -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}",
diff --git a/tests/test_js_import_resolution.py b/tests/test_js_import_resolution.py
index f5f892c6f..b8314a971 100644
--- a/tests/test_js_import_resolution.py
+++ b/tests/test_js_import_resolution.py
@@ -404,6 +404,52 @@ def test_svelte_rune_import_resolves_svelte_ts_file(tmp_path: Path):
assert _has_edge(result, "src/routes/page.ts", "src/lib/hooks/is-mobile.svelte.ts")
+def test_ts_dynamic_import_does_not_create_phantom_cycle(tmp_path: Path):
+ # A deferred `import('./x')` is not a static import: it must be emitted as a
+ # `dynamic_import` edge (like the Svelte/Astro/Vue emitters), not
+ # `imports_from`. Otherwise two files that reference each other via one static
+ # import + one dynamic import are reported as a phantom circular dependency.
+ # Regression test for #1241.
+ import networkx as nx
+
+ from graphify.analyze import find_import_cycles
+
+ actions = _write(
+ tmp_path / "actions.ts",
+ 'export function doThing() {}\n'
+ 'export async function lazy() {\n'
+ ' const m = await import("./modal");\n'
+ ' return m.openModal();\n'
+ '}\n',
+ )
+ modal = _write(
+ tmp_path / "modal.ts",
+ 'import { doThing } from "./actions";\n'
+ 'export function openModal() { doThing(); }\n',
+ )
+
+ result = _extract_for([actions, modal], tmp_path)
+
+ # The deferred import() edge stays in the graph as an `imports_from` edge
+ # marked `deferred` (the dependency remains visible); the real static import
+ # (modal.ts -> actions.ts) is unaffected.
+ deferred = [edge for edge in result["edges"] if edge.get("deferred")]
+ assert deferred and all(edge["relation"] == "imports_from" for edge in deferred)
+ assert _has_edge(result, "modal.ts", "actions.ts", "imports_from")
+
+ # End to end: the deferred import must not manufacture a file cycle.
+ graph = nx.DiGraph()
+ for node in result["nodes"]:
+ graph.add_node(node["id"], **{k: v for k, v in node.items() if k != "id"})
+ for edge in result["edges"]:
+ graph.add_edge(
+ edge["source"],
+ edge["target"],
+ **{k: v for k, v in edge.items() if k not in ("source", "target")},
+ )
+ assert find_import_cycles(graph) == []
+
+
def test_tsconfig_alias_import_resolves_existing_ts_file(tmp_path: Path):
_write(
tmp_path / "tsconfig.json",
From aa1bbdab237d0c7c0504020fbd2b1f87bcec1480 Mon Sep 17 00:00:00 2001
From: raman118
Date: Sun, 5 Jul 2026 02:57:17 +0530
Subject: [PATCH 18/39] Fix case-sensitive file suffix filtering silently
skipping capitalized/mixed-case extensions (#1671)
---
graphify/extract.py | 15 ++++++++++-----
tests/test_extract.py | 28 +++++++++++++++++++++++++++-
2 files changed, 37 insertions(+), 6 deletions(-)
diff --git a/graphify/extract.py b/graphify/extract.py
index 085462fe9..08edd3232 100644
--- a/graphify/extract.py
+++ b/graphify/extract.py
@@ -15844,7 +15844,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
@@ -15860,14 +15860,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:
@@ -16646,7 +16649,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
@@ -16666,7 +16670,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)
diff --git a/tests/test_extract.py b/tests/test_extract.py
index 38be9f679..ae88e364b 100644
--- a/tests/test_extract.py
+++ b/tests/test_extract.py
@@ -408,7 +408,8 @@ def _legacy_collect_files(target, *, root=None):
for ext in sorted(extensions):
results.extend(
p for p in target.rglob(f"*{ext}")
- if not any(_is_noise_dir(part) for part in p.parts)
+ if p.suffix == ext
+ and not any(_is_noise_dir(part) for part in p.parts)
and not (patterns and _is_ignored(p, ignore_root, patterns))
)
return sorted(results)
@@ -1693,3 +1694,28 @@ def test_non_colliding_path_id_is_not_salted(tmp_path):
result = extract([p], cache_root=tmp_path)
file_id = next(n["id"] for n in result["nodes"] if n.get("source_location") == "L1")
assert file_id == make_id(_file_stem(Path("src/auth/session.py"))) == "src_auth_session"
+
+
+def test_case_insensitive_suffix_filtering(tmp_path):
+ py_file = tmp_path / "app.PY"
+ js_file = tmp_path / "script.JS"
+ ts_file = tmp_path / "lib.Ts"
+
+ py_file.write_text("class MyPythonClass:\n pass\n")
+ js_file.write_text("function myJSFunction() {}\n")
+ ts_file.write_text("export class MyTSClass {}\n")
+
+ collected = collect_files(tmp_path)
+ collected_names = {f.name for f in collected}
+ assert "app.PY" in collected_names
+ assert "script.JS" in collected_names
+ assert "lib.Ts" in collected_names
+
+ result = extract(collected, cache_root=tmp_path)
+ nodes = result["nodes"]
+ labels = {n.get("label") for n in nodes if "label" in n}
+
+ assert "MyPythonClass" in labels
+ assert "myJSFunction()" in labels
+ assert "MyTSClass" in labels
+
From d9f97b9c015791bc0517ade3d5e7a0027b9500ae Mon Sep 17 00:00:00 2001
From: safishamsi
Date: Sun, 5 Jul 2026 10:48:42 +0100
Subject: [PATCH 19/39] docs: changelog credit for #1671, #1672, #1241
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 3 +++
1 file changed, 3 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ce253d700..7d612215e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,9 @@ Full release notes with details on each version: [GitHub Releases](https://githu
## Unreleased
+- 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.)
From 6631af7936cdd27103b6db6df856a1ecfab5c01a Mon Sep 17 00:00:00 2001
From: safishamsi
Date: Sun, 5 Jul 2026 11:41:18 +0100
Subject: [PATCH 20/39] feat(ruby/affected): mixes_in edges for
include/extend/prepend + method-bound callers in affected (#1668, #1669)
#1668: Ruby `include`/`extend`/`prepend ` in a class/module body now emits
a `mixes_in` edge to the module. The mixin is captured during the node walk and
resolved cross-file by resolve_ruby_member_calls (single-owner guard, reusing
the #1640 module nodes as targets). The shared call pass skips these markers so
they are not mislabeled as `calls`. `extend self` and non-constant args are
skipped; ambiguous/undefined modules produce no edge. Rails concern composition
is now visible to affected/explain.
#1669: affected seeds the reverse walk with the root's own member nodes
(one method/contains hop) so callers that bind at method granularity (e.g.
Service.call -> the def self.call node, #1634) are reachable from the class.
method/contains stay out of the general relation-filtered walk (no forward
noise), and the seeded member nodes are not reported as hits.
Full suite: 2924 passed, 3 skipped. Verified end-to-end (Rails-shaped repros)
plus edge cases: extend self / undefined / ambiguous mixins emit nothing, mixins
are not emitted as calls, member methods aren't reported, class-level callers
still resolve, and one-hop seeding does not pull in downstream classes' methods.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 2 +
graphify/affected.py | 21 ++++++++++
graphify/extract.py | 44 +++++++++++++++++++++
graphify/ruby_resolution.py | 22 +++++++++--
tests/test_affected_member_seed.py | 61 ++++++++++++++++++++++++++++++
tests/test_ruby_resolution.py | 58 ++++++++++++++++++++++++++++
6 files changed, 205 insertions(+), 3 deletions(-)
create mode 100644 tests/test_affected_member_seed.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7d612215e..70170db56 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,8 @@ Full release notes with details on each version: [GitHub Releases](https://githu
## Unreleased
+- Feat: Ruby `include`/`extend`/`prepend ` 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 ` 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`.
diff --git a/graphify/affected.py b/graphify/affected.py
index dbb532b42..deacc39c8 100644
--- a/graphify/affected.py
+++ b/graphify/affected.py
@@ -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:
diff --git a/graphify/extract.py b/graphify/extract.py
index 08edd3232..f14f4638d 100644
--- a/graphify/extract.py
+++ b/graphify/extract.py
@@ -3450,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.
@@ -3819,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 ` 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)
@@ -5576,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
@@ -16438,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
diff --git a/graphify/ruby_resolution.py b/graphify/ruby_resolution.py
index 5823ecf38..e344175e1 100644
--- a/graphify/ruby_resolution.py
+++ b/graphify/ruby_resolution.py
@@ -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:
@@ -103,8 +104,8 @@ 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", ""),
@@ -112,6 +113,21 @@ def _emit(caller: str, target: str, rc: dict[str, Any]) -> None:
"weight": 1.0,
})
+ # `include`/`extend`/`prepend ` 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
diff --git a/tests/test_affected_member_seed.py b/tests/test_affected_member_seed.py
new file mode 100644
index 000000000..d1f6b3ae7
--- /dev/null
+++ b/tests/test_affected_member_seed.py
@@ -0,0 +1,61 @@
+"""#1669 — affected must reach callers that bind to the class's method
+nodes (post-#1634 method-granularity resolution), by seeding the reverse walk
+with the root's member nodes (one method/contains hop). method/contains stay out
+of the general relation-filtered walk, so no forward noise is added elsewhere.
+"""
+from __future__ import annotations
+
+import networkx as nx
+
+from graphify.affected import affected_nodes
+
+
+def _g():
+ g = nx.DiGraph()
+ for nid, label in [
+ ("proc", "Processor"), ("proc_call", ".call()"),
+ ("runner", "Runner"), ("runner_run", ".run()"),
+ ]:
+ g.add_node(nid, label=label)
+ g.add_edge("proc", "proc_call", relation="method") # class owns method
+ g.add_edge("runner", "runner_run", relation="method")
+ g.add_edge("runner_run", "proc_call", relation="calls") # caller binds to method node (#1634)
+ return g
+
+
+def test_class_affected_reaches_method_bound_caller():
+ g = _g()
+ hits = {h.node_id for h in affected_nodes(g, "proc", depth=2)}
+ assert "runner_run" in hits, "caller of Processor.call must be reachable from Processor"
+
+
+def test_member_method_node_not_reported_as_hit():
+ g = _g()
+ hits = {h.node_id for h in affected_nodes(g, "proc", depth=2)}
+ # the class's own method node is a seed, not an affected node
+ assert "proc_call" not in hits
+
+
+def test_method_contains_still_excluded_from_general_walk():
+ # A node two method-hops away (method of a DIFFERENT class discovered during
+ # the walk) must NOT be pulled in: only the root's own members are seeded.
+ g = nx.DiGraph()
+ for nid, label in [("a", "A"), ("a_m", ".m()"), ("b", "B"), ("b_m", ".n()")]:
+ g.add_node(nid, label=label)
+ g.add_edge("a", "a_m", relation="method")
+ g.add_edge("a_m", "b", relation="calls") # A.m calls class B
+ g.add_edge("b", "b_m", relation="method") # B's own method
+ hits = {h.node_id for h in affected_nodes(g, "a", depth=3)}
+ # We seeded A's members and walk reverse; B and B's method are downstream of A
+ # (A.m -> B), not reverse-callers of A, so they must not appear.
+ assert hits == set() or "b_m" not in hits
+
+
+def test_class_level_caller_still_works():
+ # A caller bound to the class node itself (not a method) is unaffected.
+ g = nx.DiGraph()
+ g.add_node("svc", label="Svc")
+ g.add_node("caller", label=".use()")
+ g.add_edge("caller", "svc", relation="references")
+ hits = {h.node_id for h in affected_nodes(g, "svc", depth=2)}
+ assert "caller" in hits
diff --git a/tests/test_ruby_resolution.py b/tests/test_ruby_resolution.py
index adc77577b..5cd1b1150 100644
--- a/tests/test_ruby_resolution.py
+++ b/tests/test_ruby_resolution.py
@@ -290,3 +290,61 @@ def test_ambiguous_constant_receiver_emits_no_edge(tmp_path: Path) -> None:
"class Runner\n def run\n Processor.call\n end\nend\n")
graph = extract([caller, tmp_path / "a.rb", tmp_path / "b.rb"], cache_root=tmp_path, parallel=False)
assert _has_call_edge(graph, "run", "call") is None
+
+
+# ── #1668 include/extend/prepend -> mixes_in ─────────────────────────────────
+
+
+def _mixes_in(graph: dict) -> set[tuple[str, str]]:
+ labels = _labels(graph["nodes"])
+ return {
+ (labels.get(e["source"], ""), labels.get(e["target"], ""))
+ for e in graph["edges"] if e.get("relation") == "mixes_in"
+ }
+
+
+def test_include_emits_mixes_in_edge(tmp_path: Path) -> None:
+ _write(tmp_path, "concern.rb", "module SealedProtection\n def sealed?; true; end\nend\n")
+ _write(tmp_path, "model.rb",
+ "class Roster < ApplicationRecord\n include SealedProtection\nend\n")
+ g = extract([tmp_path / "model.rb", tmp_path / "concern.rb"], cache_root=tmp_path, parallel=False)
+ assert ("Roster", "SealedProtection") in _mixes_in(g)
+
+
+def test_extend_and_prepend_emit_mixes_in(tmp_path: Path) -> None:
+ _write(tmp_path, "helpers.rb", "module Helpers\n def h; end\nend\n")
+ _write(tmp_path, "audit.rb", "module Audit\n def a; end\nend\n")
+ _write(tmp_path, "svc.rb",
+ "class Svc\n extend Helpers\n prepend Audit\nend\n")
+ mix = _mixes_in(extract(sorted(tmp_path.glob("*.rb")), cache_root=tmp_path, parallel=False))
+ assert ("Svc", "Helpers") in mix
+ assert ("Svc", "Audit") in mix
+
+
+def test_extend_self_and_nonconstant_args_emit_no_mixin(tmp_path: Path) -> None:
+ # `extend self` and `include some_var` are not constant module references.
+ _write(tmp_path, "m.rb",
+ "module M\n extend self\n def go; end\nend\n")
+ mix = _mixes_in(extract([tmp_path / "m.rb"], cache_root=tmp_path, parallel=False))
+ assert not any(t == "self" for _s, t in mix)
+ assert not mix
+
+
+def test_include_of_undefined_or_ambiguous_module_emits_no_edge(tmp_path: Path) -> None:
+ # Undefined module (no node) -> no edge, under the single-owner guard.
+ _write(tmp_path, "x.rb", "class X\n include NotDefinedAnywhere\nend\n")
+ mix = _mixes_in(extract([tmp_path / "x.rb"], cache_root=tmp_path, parallel=False))
+ assert not any(t == "NotDefinedAnywhere" for _s, t in mix)
+
+
+def test_mixin_is_not_emitted_as_calls_edge(tmp_path: Path) -> None:
+ # Regression: the shared cross-file call pass must not turn a mixin into a
+ # `calls` edge (which would mislabel it and block the mixes_in emit).
+ _write(tmp_path, "concern.rb", "module C\n def m; end\nend\n")
+ _write(tmp_path, "k.rb", "class K\n include C\nend\n")
+ g = extract([tmp_path / "k.rb", tmp_path / "concern.rb"], cache_root=tmp_path, parallel=False)
+ labels = _labels(g["nodes"])
+ calls = {(labels.get(e["source"], ""), labels.get(e["target"], ""))
+ for e in g["edges"] if e.get("relation") == "calls"}
+ assert ("K", "C") not in calls
+ assert ("K", "C") in _mixes_in(g)
From 21b52e195d3735e0267c00c9ceca63ff17c61cb9 Mon Sep 17 00:00:00 2001
From: safishamsi
Date: Sun, 5 Jul 2026 12:36:54 +0100
Subject: [PATCH 21/39] docs(readme): new Graphify logo (cropped icon +
wordmark)
Replaces the old v4-hosted SVG wordmark with the new brand logo (graph-cube
icon + "Graphify" on the green brand gradient), tightly cropped from the source
export (1384x645, ~2.15:1, even ~90px padding). Served from docs/logo.png on v8.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
README.md | 2 +-
docs/logo.png | Bin 0 -> 146843 bytes
2 files changed, 1 insertion(+), 1 deletion(-)
create mode 100644 docs/logo.png
diff --git a/README.md b/README.md
index ac8ce34f9..febe20e9b 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
-
+
diff --git a/docs/logo.png b/docs/logo.png
new file mode 100644
index 0000000000000000000000000000000000000000..95ad01b3b4414d06c4537671397f4e2d4d9150f2
GIT binary patch
literal 146843
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
8zz&-%}tUi)`vsps?g1fc(
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