Script 'mail_helper' called by obssrc
Hello community,
here is the log from the commit of package python-graphifyy for
openSUSE:Factory checked in at 2026-08-29 17:43:24
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-graphifyy (Old)
and /work/SRC/openSUSE:Factory/.python-graphifyy.new.1265 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-graphifyy"
Sat Aug 29 17:43:24 2026 rev:4 rq:1374585 version:0.9.51
Changes:
--------
--- /work/SRC/openSUSE:Factory/python-graphifyy/python-graphifyy.changes
2026-08-26 19:55:46.059073482 +0200
+++
/work/SRC/openSUSE:Factory/.python-graphifyy.new.1265/python-graphifyy.changes
2026-08-29 17:44:28.859542294 +0200
@@ -1,0 +2,25 @@
+Sat Aug 29 07:36:12 UTC 2026 - Martin Pluskal <[email protected]>
+
+- Update to 0.9.51:
+ * The incomplete-build shrink guard stays armed when a chunk comes
+ back hollow, unparseable or missing files, so a lossy run can no
+ longer overwrite the graph with a smaller one (--allow-partial
+ still overrides)
+ * graphify extract --force --code-only fully rescans code instead
+ of keeping stale import/alias resolution, while carrying the
+ existing document/semantic tier forward
+ * Hyperedges carried over from a prior graph.json are routed
+ through the dedup survivor remap, so they no longer dangle
+ * Leiden clustering canonicalizes undirected edge endpoints, so
+ community assignments no longer drift between builds and machines
+ * TypeScript/JavaScript new Foo() emits a calls edge to the
+ constructed class; built-in globals are not fabricated
+ * Elixir single-clause functions with a when guard are extracted
+ instead of dropped
+ * Common Lisp node ids use the full path stem, so same-basename
+ files in different directories no longer collide on merge
+ * The atexit stat-index flush no longer recreates a graphify-out/
+ tree deleted during the run
+ * ... see upstream's release notes for the full list
+
+-------------------------------------------------------------------
Old:
----
graphify-0.9.50.tar.gz
New:
----
graphify-0.9.51.tar.gz
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Other differences:
------------------
++++++ python-graphifyy.spec ++++++
--- /var/tmp/diff_new_pack.1t7y4r/_old 2026-08-29 17:44:29.459563518 +0200
+++ /var/tmp/diff_new_pack.1t7y4r/_new 2026-08-29 17:44:29.460563554 +0200
@@ -19,7 +19,7 @@
%bcond_without libalternatives
%{?sle15_python_module_pythons}
Name: python-graphifyy
-Version: 0.9.50
+Version: 0.9.51
Release: 0
Summary: Code knowledge graph builder and query CLI for AI assistants
License: Apache-2.0 AND MIT
++++++ graphify-0.9.50.tar.gz -> graphify-0.9.51.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/graphify-0.9.50/CHANGELOG.md
new/graphify-0.9.51/CHANGELOG.md
--- old/graphify-0.9.50/CHANGELOG.md 2026-08-25 19:24:12.000000000 +0200
+++ new/graphify-0.9.51/CHANGELOG.md 2026-08-28 02:25:56.000000000 +0200
@@ -2,6 +2,19 @@
Full release notes with details on each version: [GitHub
Releases](https://github.com/safishamsi/graphify/releases)
+## 0.9.51 (2026-08-28)
+
+- Fix: the incomplete-build shrink guard now stays armed when a chunk came
back hollow, unparseable, or omitting files, so a run that silently lost
content can no longer overwrite the existing graph with a smaller one; a
complete run and a retry-recovered chunk are unaffected, and `--allow-partial`
still overrides (#3105, thanks @abhay-codes07).
+- Fix: `graphify extract --force --code-only` now fully rescans code (instead
of skipping unchanged files and keeping stale import/alias resolution) while
still carrying the existing document/semantic tier forward (#3125, thanks
@hopstreax).
+- Fix: a hyperedge carried from a prior `graph.json` now has its members
routed through the dedup survivor remap, so it no longer dangles when one of
its members is merged away; an unresolvable member is dropped gracefully
(#3102, thanks @abhay-codes07).
+- Fix: the cache's atexit stat-index flush no longer recreates a
`graphify-out/` tree that was deleted during the run, so a removed corpus stays
removed (#2974, thanks @abhay-codes07).
+- Fix: Leiden clustering canonicalizes undirected edge endpoints before
sorting, so community assignments no longer drift across builds or machines
from networkx yielding an edge's endpoints in a different order (thanks
@ErichKinuya).
+- Fix: a TypeScript/JavaScript `new Foo()` now emits a `calls` edge to the
constructed class (member, chained, and generic forms), so constructor usage is
visible; built-in globals like `new Map()` / `new Promise()` are not fabricated
(#3116, thanks @hopstreax).
+- Fix: an Elixir function whose only clause carries a `when` guard (`def
foo(x) when is_integer(x), do: ...`) is now extracted, not dropped;
multi-clause, multi-condition guards, and `defp` are handled (#3111, thanks
@santhiprakash).
+- Fix: Common Lisp node ids are now derived from the full path stem like every
other extractor, so two same-basename `.lisp` files in different directories no
longer collide on merge (thanks @guitelesc).
+- Perf: Leiden clustering now calls the `graspologic_native` binding directly
instead of importing the full `graspologic` package, avoiding its heavy import
chain (umap / pynndescent / numba JIT); clustering output is unchanged, and it
falls back to the `graspologic` wrapper and then NetworkX Louvain when the
native binding is absent (#3104, thanks @Mohammad-Palla).
+- Docs: the README now documents the git workflow for keeping the graph in
sync — commits and branch switches rebuild automatically via the installed
hooks, while `git pull` / `git merge` need a manual `graphify update .` (thanks
@Mohammad-Palla).
+
## 0.9.50 (2026-08-25)
- Fix: Ruby methods whose names end in `!`, `?`, or `=` now keep distinct node
ids, so `save` and `save!` (or `foo` and `foo=`) no longer collide into one
node; the label keeps the raw spelling and member-call resolution still matches
(#3077, thanks @hopstreax).
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/graphify-0.9.50/README.md
new/graphify-0.9.51/README.md
--- old/graphify-0.9.50/README.md 2026-08-25 19:24:12.000000000 +0200
+++ new/graphify-0.9.51/README.md 2026-08-28 02:25:56.000000000 +0200
@@ -390,7 +390,7 @@
/graphify add https://arxiv.org/abs/1706.03762 # fetch a paper and add it
/graphify add <youtube-url> # transcribe and add a video
-graphify hook install # auto-rebuild on git commit
+graphify hook install # auto-rebuild on commit + branch checkout
(run `graphify update .` after `git pull` — see "Recommended workflow" below)
graphify merge-graphs a.json b.json # combine two graphs
graphify prs # PR dashboard: CI state, review status,
worktree mapping
@@ -437,11 +437,29 @@
> `manifest.json` is now portable — keys are stored as relative paths and
> re-anchored on load, so committing it is safe and avoids a full rebuild on
> first checkout.
-**Workflow:**
-1. One person runs `/graphify .` and commits `graphify-out/`.
-2. Everyone pulls — their assistant reads the graph immediately.
-3. Run `graphify hook install` to auto-rebuild after each commit (AST only, no
API cost). This also sets up a git merge driver so `graph.json` is never left
with conflict markers — two devs committing in parallel get their graphs
union-merged automatically.
-4. When docs or papers change, run `/graphify --update` to refresh those nodes.
+### Recommended workflow
+
+Set this up once per clone. From then on, three of your normal git commands
keep the graph current by themselves, and one keeps it in sync with your team:
+
+| you do | graphify does |
+|---|---|
+| `graphify hook install` (once, right after cloning) | installs the hooks
below, plus a merge driver so `graph.json` never shows conflict markers |
+| `git commit` | rebuilds automatically — AST only, no API cost |
+| `git checkout` / `git switch` (branches) | rebuilds automatically (a
file-only `git checkout -- <path>` does not) |
+| `git pull` / `git merge` | run `graphify update .` right after |
+| `git push` | nothing to do |
+
+The commit and branch-switch rebuilds run in the background and return
immediately, so on a large repo the graph can lag the commit by a few seconds —
step 5 covers the rare case where you query before it catches up.
+
+**Step by step:**
+1. Clone the repo and run `graphify hook install` once.
+2. Commit and switch branches as normal — the graph stays current on its own.
+3. After every `git pull` (or merge), run `graphify update .` to bring the
graph in sync with what you just pulled. On a large or active repo, put it on
autopilot with a pull alias:
+ ```bash
+ git config --global alias.gpull '!git pull && graphify update .'
+ ```
+4. When docs or papers change, run `/graphify --update` to refresh those nodes
too (code and docs update independently).
+5. If a query ever seems to be missing something you just added, run `graphify
update .` first, then ask again.
---
@@ -614,6 +632,13 @@
**`graph.json` has conflict markers after two devs commit at once**
Run `graphify hook install` — it sets up a git merge driver that union-merges
`graph.json` automatically so conflicts never happen.
+**Graph doesn't reflect a teammate's recent changes**
+Run `graphify update .` right after `git pull` or any merge — see [Recommended
workflow](#recommended-workflow). Commits and branch switches update the graph
automatically via the installed hooks; syncing with a pull is the one step you
run yourself. Fold it into a pull alias so it's one command either way:
+```bash
+git config --global alias.gpull '!git pull && graphify update .'
+```
+Confirm the hooks are active with `graphify hook status`; re-run `graphify
hook install` after an interpreter upgrade/reinstall to refresh them.
+
**Extraction returns empty nodes/edges for docs or PDFs**
Docs, PDFs, and images require an LLM call — code-only corpora need no key.
Check that your API key is set and the backend is correct:
```bash
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/graphify-0.9.50/graphify/build.py
new/graphify-0.9.51/graphify/build.py
--- old/graphify-0.9.50/graphify/build.py 2026-08-25 19:24:12.000000000
+0200
+++ new/graphify-0.9.51/graphify/build.py 2026-08-28 02:25:56.000000000
+0200
@@ -1649,6 +1649,9 @@
graph to inherit from. An explicit True/False always overrides the on-disk
flag.
"""
+ # Iterated more than once below (source sets, the hyperedge carry, the
+ # build itself), so a one-shot iterator must be materialised first.
+ new_chunks = list(new_chunks)
graph_path = Path(graph_path if graph_path is not None else
_default_graph_json())
_loaded = _load_existing_graph(graph_path)
if _loaded is not None:
@@ -1726,11 +1729,6 @@
file=sys.stderr,
)
- base = [{"nodes": existing_nodes, "edges": existing_edges}] if had_graph
else []
-
- all_chunks = base + list(new_chunks)
- G = build(all_chunks, directed=directed, dedup=dedup,
dedup_llm_backend=dedup_llm_backend, root=root)
-
# Prune set for deleted source files — both the raw form (matches nodes
that
# kept absolute source_file) and the normalised relative form (matches
nodes
# relativised by _norm_source_file at build time). .resolve() (via
_eff_root)
@@ -1797,12 +1795,25 @@
# deleted (#1574). build() only sees the new chunks' hyperedges, so without
# this every --update collapses the graph's hyperedge set down to just the
# changed files'. Re-extracted files' prior hyperedges are dropped (their
new
- # version is already in G — replace-per-source, like nodes/edges); deleted
- # files' are dropped via prune_set. id-dedup (attach_hyperedges) so a
carried
- # hyperedge never duplicates one the new chunks re-emitted. Mirrors
watch.py,
- # which already preserves existing hyperedges across a rebuild.
+ # version is already in the new chunks — replace-per-source, like
+ # nodes/edges); deleted files' are dropped via prune_set; id-dedup so a
+ # carried hyperedge never duplicates one the new chunks re-emitted. Mirrors
+ # watch.py, which already preserves existing hyperedges across a rebuild.
+ #
+ # The carried set rides INTO build() on the base chunk rather than being
+ # attached to G afterwards (#3102): entity dedup rewires every edge
endpoint
+ # and every hyperedge member it sees onto the survivor (#2805), but a
+ # hyperedge attached after the fact kept naming the merged-away node — a
+ # dangling member with no backing node in the written graph.
+ carried_hyperedges: list[dict] = []
if existing_hyperedges:
- carried = []
+ carried = carried_hyperedges
+ _new_hyperedge_ids = {
+ he.get("id")
+ for chunk in new_chunks
+ for he in (chunk.get("hyperedges") or [])
+ if isinstance(he, dict) and he.get("id")
+ }
for he in existing_hyperedges:
if not isinstance(he, dict):
continue
@@ -1815,10 +1826,17 @@
continue # semantically re-extracted — replaced by the new
chunk's version
if _prune_match(sf):
continue # deleted — pruned
+ if he.get("id") and he.get("id") in _new_hyperedge_ids:
+ continue # the new chunks re-emitted it — theirs wins
carried.append(he)
- if carried:
- from graphify.export import attach_hyperedges
- attach_hyperedges(G, carried)
+
+ base = (
+ [{"nodes": existing_nodes, "edges": existing_edges, "hyperedges":
carried_hyperedges}]
+ if had_graph else []
+ )
+
+ all_chunks = base + list(new_chunks)
+ G = build(all_chunks, directed=directed, dedup=dedup,
dedup_llm_backend=dedup_llm_backend, root=root)
# Prune nodes and edges from deleted source files
if prune_sources:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/graphify-0.9.50/graphify/cache.py
new/graphify-0.9.51/graphify/cache.py
--- old/graphify-0.9.50/graphify/cache.py 2026-08-25 19:24:12.000000000
+0200
+++ new/graphify-0.9.51/graphify/cache.py 2026-08-28 02:25:56.000000000
+0200
@@ -379,6 +379,20 @@
continue
dk = _stat_key_to_relative(k, _stat_index_anchor) if
_stat_index_anchor is not None else k
on_disk[dk] = v
+ # Never resurrect a corpus that was deleted while graphify was running
+ # (#2974): a hook-launched `graphify update . &` in a short-lived worktree
+ # outlives `git worktree remove`, and an unconditional `mkdir -p` here
+ # rebuilt the dead path as a husk holding nothing but this index. The
+ # index is a pure optimisation, so when its root is gone it is simply not
+ # written. Creating graphify-out/cache/ under a root that still exists is
+ # unchanged (a first run writes the index before anything else does).
+ try:
+ if not _stat_index_root.is_dir():
+ _stat_index_dirty = False
+ return
+ except OSError:
+ _stat_index_dirty = False
+ return
try:
p.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=p.parent, prefix="stat-index.",
suffix=".tmp")
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/graphify-0.9.50/graphify/cli.py
new/graphify-0.9.51/graphify/cli.py
--- old/graphify-0.9.50/graphify/cli.py 2026-08-25 19:24:12.000000000 +0200
+++ new/graphify-0.9.51/graphify/cli.py 2026-08-28 02:25:56.000000000 +0200
@@ -3252,16 +3252,17 @@
# --force: full scan, not the manifest-gated incremental diff — a warm
# unchanged tree would otherwise dispatch zero files (#1894).
incremental_mode = incremental_mode and not force
- # #2923: --force --code-only must NOT drop the existing semantic layer.
- # The AST pass is fully replaced (full re-scan, semantic cache reads
- # skipped), but the semantic pass is itself skipped entirely, so
- # doc/paper/image nodes from the existing graph carry forward via the
- # incremental merge (build_merge / merge_raw_extraction keep them
- # because no new semantic-tier sources are dispatched). Without this,
- # a single --code-only --force silently erases every doc/paper/image
- # node plus its connected hyperedges.
+ # #2923/#3125: --force --code-only must NOT drop the existing semantic
layer.
+ # The AST pass is fully replaced (full code re-scan, AST extraction on
all
+ # code files), but the semantic pass is skipped, so doc/paper/image
nodes
+ # from the existing graph carry forward via the merge (build_merge /
+ # merge_raw_extraction keep them because no new semantic-tier sources
+ # are dispatched). We do NOT set incremental_mode = True here because
that
+ # would run _detect_incremental and drop unchanged code files from the
AST
+ # pass; instead we keep incremental_mode = False so all code files are
+ # scanned, while merge_existing_graph below ensures build_merge still
runs.
+ merge_existing_graph = incremental_mode or (code_only and
existing_graph_path.exists())
if force and code_only and existing_graph_path.exists():
- incremental_mode = True
print(
"[graphify extract] --force --code-only: full AST re-scan, "
"existing semantic layer preserved (no semantic pass this run)"
@@ -3378,6 +3379,12 @@
excluded_files = []
graph_stale_sources = []
unchanged_total = 0
+ if existing_graph_path.exists():
+ _seen_files = {f for _fl in files_by_type.values() for f in
_fl}
+ _seen_files.update(detection.get("unclassified", []))
+ graph_stale_sources = _stale_graph_sources(
+ existing_graph_path, target, _seen_files,
detection=detection
+ )
semantic_files = doc_files + paper_files + image_files
# --code-only: index code (pure local AST, no key) and skip the
semantic
@@ -3813,6 +3820,26 @@
_strip_partial_markers as _strip_partial,
)
_partial_semantic_files = set(_partial_sf(fresh))
+ # A chunk that came back hollow after every retry, or as
+ # unparseable JSON, or that simply omitted some of its files,
+ # does not raise - it returns fewer nodes - so it counted as a
+ # SUCCEEDED chunk above and the run read as complete,
force=True
+ # bypassed the shrink guard, and a 570-node graph was
overwritten
+ # with 111 nodes without a word (#3105). With an LLM backend
+ # that is the normal way an extraction silently produces a
+ # fraction of the graph, so it must arm the guard exactly like
a
+ # crashed chunk does. --allow-partial still overrides.
+ _omitted_files = list(fresh.get("uncovered_files") or [])
+ if _omitted_files or _partial_semantic_files:
+ _extraction_incomplete = True
+ print(
+ f"[graphify extract] semantic extraction is
incomplete: "
+ f"{len(_omitted_files)} dispatched file(s) produced no
nodes and "
+ f"{len(_partial_semantic_files)} came back truncated
or hollow. "
+ f"The shrink guard stays armed for this write; pass "
+ f"--allow-partial to overwrite a larger existing graph
anyway.",
+ file=sys.stderr,
+ )
try:
_save_semantic_cache(
fresh.get("nodes", []),
@@ -4012,7 +4039,7 @@
stages.total()
sys.exit(0)
- if incremental_mode:
+ if merge_existing_graph:
# #2169: this raw path used to write ONLY this run's extraction
# over graph.json — on an incremental run that is just the
# changed files, silently dropping every node/edge owned by an
@@ -4140,7 +4167,7 @@
from graphify.export import to_json as _to_json
from graphify.analyze import god_nodes as _god_nodes,
surprising_connections as _surprising
dedup_backend = backend if dedup_llm else None
- if incremental_mode:
+ if merge_existing_graph:
# Prune everything the current scan no longer covers: genuinely
# deleted manifest rows, excluded-but-alive manifest rows (#1908),
# and the graph's own stale sources — which catches files that
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/graphify-0.9.50/graphify/cluster.py
new/graphify-0.9.51/graphify/cluster.py
--- old/graphify-0.9.50/graphify/cluster.py 2026-08-25 19:24:12.000000000
+0200
+++ new/graphify-0.9.51/graphify/cluster.py 2026-08-28 02:25:56.000000000
+0200
@@ -19,11 +19,86 @@
return contextlib.redirect_stdout(io.StringIO())
+def _native_leiden(stable: nx.Graph, resolution: float) -> dict[str, int] |
None:
+ """Call graspologic_native.leiden() directly, bypassing graspologic's own
+ package import.
+
+ graspologic.partition.leiden() is a thin wrapper around exactly this
+ native (Rust) call. Importing the *package* — as opposed to the native
+ extension module it depends on — pulls in graspologic.layouts, which
+ imports umap, which imports pynndescent, which numba-JIT-compiles at
+ import time for a layout algorithm this function never calls: measured
+ at 7-19s of one-time import cost against a ~1s native call and a ~1.4s
+ full round trip (conversion + call + map-back) — see the "third update"
+ in GRAPHIFY_BUILD_PERF.md for the measurements this is based on.
+
+ Returns None (the caller falls through to the graspologic.partition.leiden
+ path, then to the networkx Louvain fallback) if graspologic_native isn't
+ installed, or if `stable` isn't the plain undirected, non-multigraph
+ input leiden actually supports — the same shape check
+ graspologic.partition.leiden itself makes before calling the same native
+ function.
+ """
+ try:
+ import graspologic_native as gn
+ except ImportError:
+ return None
+
+ if stable.is_directed() or stable.is_multigraph():
+ return None
+
+ # graspologic_native identifies nodes by their string form; two DISTINCT
+ # node objects that happen to stringify the same way would silently merge
+ # under it (this is exactly what graspologic.partition.leiden's own
+ # _IdentityMapper guards against). Graphify's own node IDs are already
+ # unique strings by construction — extractors/resolution.py's
+ # _disambiguate_colliding_node_ids salts any two distinct nodes that would
+ # otherwise share a string id before the graph is ever built — so this is
+ # a defensive check on an assumption that should never actually trip, not
+ # an expected path. One pass over the nodes, cheaper than an
+ # _IdentityMapper-style dict-store-per-edge-endpoint.
+ id_to_node: dict[str, object] = {}
+ for node in stable.nodes():
+ key = str(node)
+ existing = id_to_node.get(key)
+ if existing is not None and existing != node:
+ return None # let graspologic.partition.leiden's own check
handle/raise on this
+ id_to_node[key] = node
+
+ edges = [
+ (str(u), str(v), float(attrs.get("weight", 1.0)))
+ for u, v, attrs in stable.edges(data=True)
+ ]
+
+ try:
+ old_stderr = sys.stderr
+ try:
+ sys.stderr = io.StringIO()
+ with _suppress_output():
+ _quality, native_partitions = gn.leiden(
+ edges=edges,
+ starting_communities=None,
+ resolution=resolution,
+ randomness=0.001,
+ iterations=1,
+ use_modularity=True,
+ seed=42,
+ trials=1,
+ )
+ finally:
+ sys.stderr = old_stderr
+ except Exception:
+ return None
+
+ return {id_to_node[node_id]: community for node_id, community in
native_partitions.items()}
+
+
def _partition(G: nx.Graph, resolution: float = 1.0) -> dict[str, int]:
"""Run community detection. Returns {node_id: community_id}.
- Tries Leiden (graspologic) first — best quality.
- Falls back to Louvain (built into networkx) if graspologic is not
installed.
+ Tries Leiden (graspologic_native directly, then graspologic) first — best
+ quality. Falls back to Louvain (built into networkx) if neither is
+ installed.
resolution > 1.0 → more, smaller communities.
resolution < 1.0 → fewer, larger communities.
@@ -33,17 +108,31 @@
"""
stable = nx.Graph()
stable.add_nodes_from(sorted(G.nodes(), key=str))
+ # Canonicalise the endpoint pair before sorting. On an undirected graph the
+ # (u, v) orientation each edge is yielded with comes from adjacency
+ # iteration, which follows CPython's per-process string-hash order - so the
+ # SAME edge appears as (A, B) in one run and (B, A) in the next. Sorting on
+ # the raw pair therefore does not canonicalise anything: the edge lands in
a
+ # different position, `stable` is built in a different insertion order, and
+ # Louvain - order-sensitive even with a fixed seed - can return a different
+ # grouping. Measured on a 914-node graph: identical input, identical
+ # first-pass partition, but the cohesion-split pass produced 70 communities
+ # under PYTHONHASHSEED=1 and 69 under =2. Sorting the pair itself removes
+ # the dependency; for nx.Graph the orientation carries no meaning anyway.
edge_rows = sorted(
G.edges(data=True),
key=lambda row: (
- str(row[0]),
- str(row[1]),
+ *sorted((str(row[0]), str(row[1]))),
json.dumps(row[2], sort_keys=True, ensure_ascii=False,
default=str),
),
)
for src, tgt, attrs in edge_rows:
stable.add_edge(src, tgt, **attrs)
+ native_result = _native_leiden(stable, resolution)
+ if native_result is not None:
+ return native_result
+
try:
from graspologic.partition import leiden
lsig = inspect.signature(leiden).parameters
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/graphify-0.9.50/graphify/extractors/commonlisp.py
new/graphify-0.9.51/graphify/extractors/commonlisp.py
--- old/graphify-0.9.50/graphify/extractors/commonlisp.py 2026-08-25
19:24:12.000000000 +0200
+++ new/graphify-0.9.51/graphify/extractors/commonlisp.py 2026-08-28
02:25:56.000000000 +0200
@@ -4,7 +4,7 @@
import warnings
from pathlib import Path
-from graphify.extractors.base import _make_id
+from graphify.extractors.base import _file_stem, _make_id
# Standard CL definer forms that introduce data/type/variable bindings
@@ -84,7 +84,11 @@
except Exception as e:
return {"nodes": [], "edges": [], "error": str(e)}
- stem = path.stem
+ # Path-qualified, not the bare `path.stem`: same-named .lisp files in
+ # different directories must not collide (#1504). Pre-collapsed through
+ # `_make_id` because `_cl_id` would otherwise map the `/` separators to
+ # `_slash` via _CL_CHAR_MAP.
+ stem = _make_id(_file_stem(path))
str_path = str(path)
nodes: list[dict] = []
edges: list[dict] = []
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/graphify-0.9.50/graphify/extractors/elixir.py
new/graphify-0.9.51/graphify/extractors/elixir.py
--- old/graphify-0.9.50/graphify/extractors/elixir.py 2026-08-25
19:24:12.000000000 +0200
+++ new/graphify-0.9.51/graphify/extractors/elixir.py 2026-08-28
02:25:56.000000000 +0200
@@ -128,6 +128,19 @@
func_name = None
if arguments_node:
for child in arguments_node.children:
+ # tree-sitter-elixir wraps a guarded head
+ # (`def f(x) when guard`) in `binary_operator`;
+ # without unwrapping, a function whose only clause
+ # carries `when` is dropped (#3111).
+ while child.type == "binary_operator":
+ head = None
+ for sub in child.children:
+ if sub.type in ("call", "identifier",
"binary_operator"):
+ head = sub
+ break
+ if head is None:
+ break
+ child = head
if child.type == "call":
for sub in child.children:
if sub.type == "identifier":
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/graphify-0.9.50/graphify/extractors/engine.py
new/graphify-0.9.51/graphify/extractors/engine.py
--- old/graphify-0.9.50/graphify/extractors/engine.py 2026-08-25
19:24:12.000000000 +0200
+++ new/graphify-0.9.51/graphify/extractors/engine.py 2026-08-28
02:25:56.000000000 +0200
@@ -5388,8 +5388,10 @@
# unique-but-wrong one (#3078).
member_receiver = _ruby_const_full_name(recv, source)
or None
else:
- # Generic: get callee from call_function_field
+ # Generic: get callee from call_function_field (or constructor
on new_expression)
func_node =
node.child_by_field_name(config.call_function_field) if
config.call_function_field else None
+ if func_node is None and node.type == "new_expression":
+ func_node = node.child_by_field_name("constructor")
if func_node:
if func_node.type == "identifier":
callee_name = _read_text(func_node, source)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/graphify-0.9.50/pyproject.toml
new/graphify-0.9.51/pyproject.toml
--- old/graphify-0.9.50/pyproject.toml 2026-08-25 19:24:12.000000000 +0200
+++ new/graphify-0.9.51/pyproject.toml 2026-08-28 02:25:56.000000000 +0200
@@ -4,7 +4,7 @@
[project]
name = "graphifyy"
-version = "0.9.50"
+version = "0.9.51"
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 = "Apache-2.0"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/graphify-0.9.50/tests/test_carried_hyperedge_remap.py
new/graphify-0.9.51/tests/test_carried_hyperedge_remap.py
--- old/graphify-0.9.50/tests/test_carried_hyperedge_remap.py 1970-01-01
01:00:00.000000000 +0100
+++ new/graphify-0.9.51/tests/test_carried_hyperedge_remap.py 2026-08-28
02:25:56.000000000 +0200
@@ -0,0 +1,103 @@
+"""Carried-forward hyperedges must follow the dedup survivor remap (#3102).
+
+build_merge() carries hyperedges from unchanged files across an incremental
+rebuild (#1574). They used to be attached to G AFTER build() and entity
+dedup had finished, so while every edge endpoint was rewired onto the dedup
+survivor (#2805), a carried hyperedge kept naming the merged-away node — a
+dangling member in graph.json with no backing node.
+"""
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from graphify.build import build_from_json, build_merge
+from graphify.export import to_json
+
+# `alpha_a` and `alpha_concept_long_variant_id` label-dedup into one node.
+NODES = [
+ {"id": "alpha_a", "label": "Alpha Concept", "file_type": "concept",
"source_file": "notes/a.md"},
+ {"id": "alpha_concept_long_variant_id", "label": "alpha_concept",
"file_type": "concept",
+ "source_file": "notes/b.md"},
+ {"id": "beta_node", "label": "Beta", "file_type": "concept",
"source_file": "notes/group.md"},
+ {"id": "gamma_node", "label": "Gamma", "file_type": "concept",
"source_file": "notes/group.md"},
+]
+EDGES = [{"source": "alpha_concept_long_variant_id", "target": "beta_node",
"relation": "references",
+ "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file":
"notes/b.md"}]
+HYPEREDGE = {"id": "the_group", "label": "The Group",
+ "nodes": ["alpha_concept_long_variant_id", "beta_node",
"gamma_node"],
+ "relation": "participate_in", "confidence": "EXTRACTED",
"confidence_score": 1.0,
+ "source_file": "notes/group.md"}
+UNRELATED_CHUNK = {
+ "nodes": [{"id": "delta_node", "label": "Delta", "file_type": "concept",
"source_file": "notes/d.md"}],
+ "edges": [], "hyperedges": [],
+}
+
+
+def _baseline(tmp_path: Path) -> Path:
+ """A graph written WITHOUT dedup, so the pair is still two nodes on disk
+ and the hyperedge names the variant — the shape an older build leaves."""
+ G = build_from_json({"nodes": NODES, "edges": EDGES, "hyperedges":
[HYPEREDGE]})
+ p = tmp_path / "graph.json"
+ to_json(G, {0: list(G.nodes)}, str(p))
+ return p
+
+
+def _hyperedges(G):
+ return {he["id"]: he for he in G.graph.get("hyperedges", [])}
+
+
+def test_a_carried_hyperedge_is_remapped_onto_the_dedup_survivor(tmp_path):
+ G = build_merge([UNRELATED_CHUNK], _baseline(tmp_path))
+ survivors = set(G.nodes)
+ assert "alpha_concept_long_variant_id" not in survivors # merged away
+ he = _hyperedges(G)["the_group"]
+ assert set(he["nodes"]) <= survivors, f"dangling members:
{set(he['nodes']) - survivors}"
+ assert "alpha_a" in he["nodes"] # onto the survivor, not just dropped
+ assert {"beta_node", "gamma_node"} <= set(he["nodes"])
+
+
+def test_the_written_graph_has_no_dangling_hyperedge_member(tmp_path):
+ G = build_merge([UNRELATED_CHUNK], _baseline(tmp_path))
+ out = tmp_path / "merged.json"
+ to_json(G, {0: list(G.nodes)}, str(out), force=True)
+ data = json.loads(out.read_text(encoding="utf-8"))
+ ids = {n["id"] for n in data["nodes"]}
+ for he in data.get("hyperedges", []):
+ assert set(he["nodes"]) <= ids, f"{he['id']} names a node that is not
in the graph"
+
+
+def test_edges_and_hyperedges_agree_on_the_survivor(tmp_path):
+ """The edge endpoint and the hyperedge member came from the same
+ merged-away node; both must now name the same survivor."""
+ G = build_merge([UNRELATED_CHUNK], _baseline(tmp_path))
+ edge_ends = {u for u, v in G.edges} | {v for u, v in G.edges}
+ assert "alpha_a" in edge_ends
+ assert "alpha_a" in _hyperedges(G)["the_group"]["nodes"]
+
+
+def test_a_hyperedge_re_emitted_by_the_new_chunk_is_not_duplicated(tmp_path):
+ fresh = {"nodes": [{"id": "beta_node", "label": "Beta", "file_type":
"concept",
+ "source_file": "notes/group.md"},
+ {"id": "gamma_node", "label": "Gamma", "file_type":
"concept",
+ "source_file": "notes/group.md"}],
+ "edges": [],
+ "hyperedges": [{**HYPEREDGE, "nodes": ["beta_node",
"gamma_node"], "label": "The Group v2"}]}
+ G = build_merge([fresh], _baseline(tmp_path))
+ hes = [he for he in G.graph.get("hyperedges", []) if he["id"] ==
"the_group"]
+ assert len(hes) == 1
+ assert hes[0]["label"] == "The Group v2" # the re-extracted version wins
+
+
+def test_a_pruned_sources_hyperedge_is_still_dropped(tmp_path):
+ G = build_merge([UNRELATED_CHUNK], _baseline(tmp_path),
prune_sources=["notes/group.md"])
+ assert "the_group" not in _hyperedges(G)
+
+
+def
test_an_unchanged_hyperedge_with_no_dedup_involved_is_carried_verbatim(tmp_path):
+ nodes = [n for n in NODES if n["id"] != "alpha_a"] # nothing to dedup now
+ G0 = build_from_json({"nodes": nodes, "edges": EDGES, "hyperedges":
[HYPEREDGE]})
+ p = tmp_path / "g.json"
+ to_json(G0, {0: list(G0.nodes)}, str(p))
+ G = build_merge([UNRELATED_CHUNK], p)
+ assert set(_hyperedges(G)["the_group"]["nodes"]) == set(HYPEREDGE["nodes"])
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/graphify-0.9.50/tests/test_cluster.py
new/graphify-0.9.51/tests/test_cluster.py
--- old/graphify-0.9.50/tests/test_cluster.py 2026-08-25 19:24:12.000000000
+0200
+++ new/graphify-0.9.51/tests/test_cluster.py 2026-08-28 02:25:56.000000000
+0200
@@ -98,3 +98,82 @@
assert list(remapped.keys()) == [0, 1]
assert remapped[0] == ["x", "y", "z"]
assert remapped[1] == ["m"]
+
+
+def _grouping(partition):
+ """Canonicalize {node: community_id} into a set of frozenset node-groups,
+ so two partitions compare equal regardless of the community-id labels."""
+ from collections import defaultdict
+ groups = defaultdict(set)
+ for node, cid in partition.items():
+ groups[cid].add(node)
+ return {frozenset(s) for s in groups.values()}
+
+
+def test_native_leiden_matches_graspologic_wrapper(monkeypatch):
+ """#3104: the direct graspologic_native path must produce the SAME
partition
+ as the graspologic wrapper it replaces. Run _partition with the native path
+ active, then with _native_leiden forced to fall through to the wrapper, and
+ assert identical node groupings. Skips unless both are installed."""
+ import importlib.util
+ import pytest
+ if not (importlib.util.find_spec("graspologic_native")
+ and importlib.util.find_spec("graspologic")):
+ pytest.skip("graspologic / graspologic_native not installed")
+ import graphify.cluster as cl
+
+ # Two triangles joined by a single edge: an unambiguous 2-community split.
+ G = nx.Graph()
+ for a, b in [("a1", "a2"), ("a1", "a3"), ("a2", "a3"),
+ ("b1", "b2"), ("b1", "b3"), ("b2", "b3"), ("a1", "b1")]:
+ G.add_edge(a, b)
+
+ native = cl._partition(G, 1.0)
+ monkeypatch.setattr(cl, "_native_leiden", lambda *a, **k: None)
+ wrapper = cl._partition(G, 1.0)
+
+ assert _grouping(native) == _grouping(wrapper), (
+ f"native path diverged from the wrapper: {native} vs {wrapper}"
+ )
+
+
+def test_native_leiden_returns_none_when_binding_absent(monkeypatch):
+ """When graspologic_native cannot be imported, _native_leiden must return
+ None so _partition falls through to the wrapper / Louvain, not crash."""
+ import graphify.cluster as cl
+ monkeypatch.setitem(sys.modules, "graspologic_native", None) # import →
ImportError
+ stable = nx.Graph()
+ stable.add_edge("x", "y")
+ assert cl._native_leiden(stable, 1.0) is None
+
+
+def test_partition_is_invariant_to_edge_endpoint_orientation():
+ """#3146: for an undirected graph, (a,b) and (b,a) are the same edge, but
the
+ orientation networkx yields can vary across builds/machines. _partition
must
+ canonicalise endpoints so the ordering fed to the clusterer — and thus the
+ resulting communities — is identical regardless of how edges were
inserted."""
+ import random
+ edges = [
+ ("a1", "a2"), ("a1", "a3"), ("a2", "a3"), ("a3", "a4"),
+ ("b1", "b2"), ("b1", "b3"), ("b2", "b3"), ("b3", "b4"),
+ ("a1", "b1"),
+ ]
+
+ def build(order, flip):
+ G = nx.Graph()
+ for n in order:
+ G.add_node(n)
+ for (u, v) in edges:
+ G.add_edge(v, u) if flip else G.add_edge(u, v)
+ return G
+
+ nodes = sorted({n for e in edges for n in e})
+ forward = build(nodes, flip=False)
+ shuffled = list(nodes)
+ random.Random(0).shuffle(shuffled)
+ flipped = build(shuffled, flip=True)
+
+ from graphify.cluster import _partition
+ assert _grouping(_partition(forward, 1.0)) ==
_grouping(_partition(flipped, 1.0)), (
+ "partition drifted with edge-endpoint orientation / insertion order"
+ )
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/graphify-0.9.50/tests/test_extract_code_only_cli.py
new/graphify-0.9.51/tests/test_extract_code_only_cli.py
--- old/graphify-0.9.50/tests/test_extract_code_only_cli.py 2026-08-25
19:24:12.000000000 +0200
+++ new/graphify-0.9.51/tests/test_extract_code_only_cli.py 2026-08-28
02:25:56.000000000 +0200
@@ -373,3 +373,61 @@
"NOTES.txt was deleted from disk; its semantic nodes must be pruned "
"(#2923 follow-up)"
)
+
+
+def
test_code_only_force_rescan_re_resolves_tsconfig_paths_and_preserves_semantics(tmp_path:
Path):
+ """#3125 regression: `extract --code-only --force` over an existing graph
+ must perform a full code/AST re-scan so updated tsconfig paths take effect
on
+ unchanged .ts files, while preserving the existing semantic layer (#2923).
+ """
+ repo = tmp_path / "repo"
+ repo.mkdir()
+ src = repo / "src"
+ src.mkdir()
+ (src / "utils.ts").write_text("export const helper = 42;\n")
+ (src / "index.ts").write_text("import { helper } from
'@/utils';\nconsole.log(helper);\n")
+ (repo / "tsconfig.json").write_text(json.dumps({"compilerOptions":
{"target": "es2020"}}))
+
+ # 1. Initial extract --code-only
+ r1 = _run(repo, "--code-only", "--no-cluster")
+ assert r1.returncode == 0, r1.stderr
+ graph_path = repo / "graphify-out" / "graph.json"
+ g1 = json.loads(graph_path.read_text(encoding="utf-8"))
+ edges1 = [(e["source"], e["target"]) for e in g1.get("edges",
g1.get("links", []))]
+ assert not any(src == "src_index" and ("src_utils" in tgt or "helper" in
tgt) for src, tgt in edges1), (
+ "alias import @/utils must not resolve without tsconfig paths mapping"
+ )
+
+ # 2. Seed a semantic file and node into graph.json to verify semantic
preservation (#2923)
+ (repo / "ARCH.md").write_text("# Architecture\nDesign notes.\n")
+ g1["nodes"].append({
+ "id": "doc_arch", "label": "Architecture", "type": "concept",
+ "source_file": "ARCH.md", "origin": "SEMANTIC", "_origin": "semantic"
+ })
+ graph_path.write_text(json.dumps(g1), encoding="utf-8")
+
+ # 3. Modify only tsconfig.json to add baseUrl and paths alias
+ (repo / "tsconfig.json").write_text(json.dumps({
+ "compilerOptions": {
+ "target": "es2020",
+ "baseUrl": ".",
+ "paths": {"@/*": ["src/*"]}
+ }
+ }))
+
+ # 4. Run extract --code-only --force
+ r2 = _run(repo, "--code-only", "--force", "--no-cluster")
+ assert r2.returncode == 0, r2.stderr
+
+ g2 = json.loads(graph_path.read_text(encoding="utf-8"))
+ edges2 = [(e["source"], e["target"]) for e in g2.get("edges",
g2.get("links", []))]
+ # Verify the import edge now exists
+ assert any(src == "src_index" and ("src_utils" in tgt or "helper" in tgt)
for src, tgt in edges2), (
+ "extract --code-only --force must re-resolve alias imports after
tsconfig paths change (#3125)"
+ )
+
+ # Verify the semantic entity was preserved
+ nodes2 = {n.get("id") for n in g2.get("nodes", [])}
+ assert "doc_arch" in nodes2, (
+ "existing semantic nodes must survive --code-only --force
(#2923/#3125)"
+ )
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/graphify-0.9.50/tests/test_falkordb_integration.py
new/graphify-0.9.51/tests/test_falkordb_integration.py
--- old/graphify-0.9.50/tests/test_falkordb_integration.py 2026-08-25
19:24:12.000000000 +0200
+++ new/graphify-0.9.51/tests/test_falkordb_integration.py 2026-08-28
02:25:56.000000000 +0200
@@ -5,8 +5,10 @@
docker run -d -p 6379:6379 falkordb/falkordb:latest
uv run pytest tests/test_falkordb_integration.py -q
-The test auto-skips when the `falkordb` SDK is not installed or no FalkorDB is
-reachable, so it is a no-op in the default CI (which runs no external
services).
+The test auto-skips when the `falkordb` SDK is not installed, no server is
+reachable, or the server that answers is not FalkorDB, so it is a no-op in the
+default CI (which runs no external services) and on a dev box that already has
+a plain Redis on 6379.
Host/port are overridable via FALKORDB_HOST / FALKORDB_PORT.
"""
from __future__ import annotations
@@ -26,13 +28,25 @@
def _connect():
- """Return a connected FalkorDB client, or skip if none is reachable."""
+ """Return a connected FalkorDB client, or skip if none is reachable.
+
+ ``ping()`` only proves *something* answers on the port. A plain Redis -- or
+ an SSH tunnel forwarding one -- replies to PING but has no graph module, so
+ the guard passed and the tests FAILED on `unknown command 'GRAPH.QUERY'`
+ instead of skipping. ``GRAPH.LIST`` identifies the service rather than mere
+ liveness: it is read-only, creates no keys, and anything that is not
+ FalkorDB/RedisGraph rejects it as an unknown command.
+ """
try:
db = falkordb.FalkorDB(host=HOST, port=PORT)
db.connection.ping()
- return db
except Exception as e: # pragma: no cover - depends on local environment
- pytest.skip(f"no FalkorDB reachable at {HOST}:{PORT} ({e})")
+ pytest.skip(f"no server reachable at {HOST}:{PORT} ({e})")
+ try:
+ db.connection.execute_command("GRAPH.LIST")
+ except Exception as e: # pragma: no cover - depends on local environment
+ pytest.skip(f"server at {HOST}:{PORT} is not FalkorDB, no graph module
({e})")
+ return db
@pytest.fixture()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/graphify-0.9.50/tests/test_hollow_chunks_arm_shrink_guard.py
new/graphify-0.9.51/tests/test_hollow_chunks_arm_shrink_guard.py
--- old/graphify-0.9.50/tests/test_hollow_chunks_arm_shrink_guard.py
1970-01-01 01:00:00.000000000 +0100
+++ new/graphify-0.9.51/tests/test_hollow_chunks_arm_shrink_guard.py
2026-08-28 02:25:56.000000000 +0200
@@ -0,0 +1,118 @@
+"""Hollow, unparseable and omitting chunks must count as incomplete (#3105).
+
+The #479 shrink guard is bypassed (force=True) on a run that is classified
+as complete. `_extraction_incomplete` tracked hard failures only — a crashed
+pass, a chunk that raised. A chunk that came back hollow after every retry,
+or as invalid JSON, or that simply omitted some of its files does not raise:
+it returns fewer nodes and counts as a SUCCEEDED chunk. Two consecutive
+`--update` runs on an unchanged repo: the first had 3 raised chunks and the
+guard refused; the second had 0 raised, 6 hollow, and wrote 111 nodes over a
+570-node graph without a word.
+"""
+from __future__ import annotations
+
+import pytest
+
+import graphify.__main__ as mainmod
+
+
+def _corpus(tmp_path):
+ (tmp_path / "README.md").write_text("# Notes\nThe entry point
overview.\n", encoding="utf-8")
+ (tmp_path / "GUIDE.md").write_text("# Guide\nHow to use the thing.\n",
encoding="utf-8")
+ return tmp_path
+
+
+def _record_force(monkeypatch):
+ rec = {"called": False, "force": None}
+
+ def _stub(G, communities, output_path, *, force=False, **kwargs):
+ rec["called"] = True
+ rec["force"] = force
+ return True
+
+ monkeypatch.setattr("graphify.export.to_json", _stub)
+ return rec
+
+
+def _arm(monkeypatch, tmp_path, *, uncovered=(), partial=(), extra_argv=()):
+ corpus = _corpus(tmp_path)
+ out_dir = tmp_path / "out"
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test-fake-key")
+
+ def _stub_corpus(paths, **kwargs):
+ # Every chunk "succeeds": the callback fires for each, nothing raises.
+ on_chunk = kwargs.get("on_chunk_done")
+ if on_chunk:
+ on_chunk(0, 1, {"nodes": [], "edges": [], "hyperedges": []})
+ nodes = [{"id": "s1", "source_file": str(corpus / "README.md"),
+ "file_type": "document", "label": "Notes"}]
+ for sf in partial:
+ nodes.append({"id": f"p_{sf}", "source_file": str(corpus / sf),
+ "file_type": "document", "label": sf, "_partial":
True})
+ return {"nodes": nodes, "edges": [], "hyperedges": [],
+ "input_tokens": 10, "output_tokens": 5,
+ "uncovered_files": [str(corpus / sf) for sf in uncovered]}
+
+ monkeypatch.setattr("graphify.llm.extract_corpus_parallel", _stub_corpus)
+ monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None)
+ monkeypatch.setattr(
+ mainmod.sys, "argv",
+ ["graphify", "extract", str(corpus), "--backend", "claude",
+ "--out", str(out_dir), *extra_argv],
+ )
+ return out_dir
+
+
+def _run():
+ try:
+ mainmod.main()
+ except SystemExit as exc:
+ return exc.code
+ return 0
+
+
+def test_a_chunk_that_omitted_files_arms_the_shrink_guard(monkeypatch,
tmp_path, capsys):
+ rec = _record_force(monkeypatch)
+ _arm(monkeypatch, tmp_path, uncovered=("GUIDE.md",))
+ _run()
+ assert rec["called"] and rec["force"] is False, "an omitting chunk must
not bypass the guard"
+ assert "semantic extraction is incomplete" in capsys.readouterr().err
+
+
+def test_a_hollow_chunk_arms_the_shrink_guard(monkeypatch, tmp_path, capsys):
+ """After every retry a hollow chunk is returned (not raised) with its
+ files marked partial; that is the reporter's run 2."""
+ rec = _record_force(monkeypatch)
+ _arm(monkeypatch, tmp_path, partial=("GUIDE.md",))
+ _run()
+ assert rec["called"] and rec["force"] is False
+ assert "1 came back truncated or hollow" in capsys.readouterr().err
+
+
+def test_allow_partial_still_overrides(monkeypatch, tmp_path):
+ rec = _record_force(monkeypatch)
+ _arm(monkeypatch, tmp_path, uncovered=("GUIDE.md",),
extra_argv=["--allow-partial"])
+ _run()
+ assert rec["called"] and rec["force"] is True
+
+
+def test_a_run_with_every_file_covered_keeps_force_write(monkeypatch,
tmp_path, capsys):
+ """The ordinary complete run is unchanged: a full build legitimately
+ shrinks (dedup, deleted code) and keeps bypassing the guard."""
+ rec = _record_force(monkeypatch)
+ _arm(monkeypatch, tmp_path)
+ _run()
+ assert rec["called"] and rec["force"] is True
+ assert "semantic extraction is incomplete" not in capsys.readouterr().err
+
+
+def test_the_manifest_is_not_stamped_when_the_guard_refuses(monkeypatch,
tmp_path):
+ """Refusal must leave the omitted files un-stamped so the next run retries
+ them — the same contract a crashed chunk already has."""
+ def _refuse(G, communities, output_path, *, force=False, **kwargs):
+ return False
+ monkeypatch.setattr("graphify.export.to_json", _refuse)
+ out_dir = _arm(monkeypatch, tmp_path, uncovered=("GUIDE.md",))
+ code = _run()
+ assert code not in (None, 0)
+ assert not (out_dir / "graphify-out" / "manifest.json").exists()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/graphify-0.9.50/tests/test_languages.py
new/graphify-0.9.51/tests/test_languages.py
--- old/graphify-0.9.50/tests/test_languages.py 2026-08-25 19:24:12.000000000
+0200
+++ new/graphify-0.9.51/tests/test_languages.py 2026-08-28 02:25:56.000000000
+0200
@@ -1440,6 +1440,47 @@
assert len(methods) >= 3
+def test_elixir_guarded_single_clause_is_extracted(tmp_path):
+ """A function whose only clause has a `when` guard must still get a node.
+
+ tree-sitter-elixir wraps `def f(x) when guard` in a `binary_operator`,
+ so the head is not a direct `call` child of `arguments`. Multi-clause
+ functions survive via an unguarded clause; a single guarded clause
+ was dropped entirely (#3111).
+ """
+ src = tmp_path / "demo.ex"
+ src.write_text(
+ "defmodule Demo do\n"
+ " def plain(x) do\n"
+ " x + 1\n"
+ " end\n"
+ "\n"
+ " def guarded(x) when is_integer(x) do\n"
+ " x + 1\n"
+ " end\n"
+ "\n"
+ " def mixed(x) when is_integer(x) do\n"
+ " x + 1\n"
+ " end\n"
+ "\n"
+ " def mixed(_), do: :error\n"
+ "\n"
+ " defp guarded_private(x) when is_binary(x) do\n"
+ " String.upcase(x)\n"
+ " end\n"
+ "end\n"
+ )
+ r = extract_elixir(src)
+ assert "error" not in r
+ labels = {(n.get("label") or "").rstrip("()") for n in r["nodes"]}
+ assert "plain" in labels
+ assert "mixed" in labels
+ assert "guarded" in labels, f"single-clause guarded def dropped:
{sorted(labels)}"
+ assert "guarded_private" in labels, (
+ f"single-clause guarded defp dropped: {sorted(labels)}"
+ )
+
+
# ── Objective-C ──────────────────────────────────────────────────────────────
from graphify.extract import extract_objc
@@ -3974,3 +4015,25 @@
for e in r["edges"] if e["relation"] == "calls"
}
assert (".area()", "helper()") in calls, "call from union method body
dropped"
+
+
+@_needs_commonlisp
+def test_cl_ids_are_path_qualified_across_directories(tmp_path):
+ """Two same-named .lisp files in DIFFERENT directories must mint distinct
+ ids (#1504). The prefix was derived from the bare `path.stem`, so both
+ `a/sample.lisp` and `b/sample.lisp` minted `sample` / `sample_init`; when
+ they land in separate extract batches (what `graphify update` does) build()
+ merges them and one file's nodes are dropped."""
+ a = tmp_path / "a" / "sample.lisp"
+ b = tmp_path / "b" / "sample.lisp"
+ for p in (a, b):
+ p.parent.mkdir(parents=True)
+ p.write_text("(defun init (x) (+ x 1))\n")
+
+ ids_a = {n["id"] for n in extract_commonlisp(a)["nodes"]}
+ ids_b = {n["id"] for n in extract_commonlisp(b)["nodes"]}
+
+ assert not (ids_a & ids_b), (
+ f"same-named .lisp files in different dirs must not share ids, "
+ f"got overlap {sorted(ids_a & ids_b)}"
+ )
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/graphify-0.9.50/tests/test_stat_index_husk.py
new/graphify-0.9.51/tests/test_stat_index_husk.py
--- old/graphify-0.9.50/tests/test_stat_index_husk.py 1970-01-01
01:00:00.000000000 +0100
+++ new/graphify-0.9.51/tests/test_stat_index_husk.py 2026-08-28
02:25:56.000000000 +0200
@@ -0,0 +1,69 @@
+"""The atexit stat-index flush must not resurrect a deleted directory (#2974).
+
+A post-commit hook runs `graphify update . &` in a short-lived worktree; the
+branch merges and `git worktree remove` deletes the tree while the rebuild
+is still running. The exit-time flush then `mkdir -p`'d the dead path back
+into existence, leaving a husk holding nothing but
+`graphify-out/cache/stat-index.json` — 81 of them over a few weeks.
+"""
+from __future__ import annotations
+
+import shutil
+from pathlib import Path
+
+import pytest
+
+from graphify import cache
+
+
[email protected](autouse=True)
+def _fresh_index():
+ def reset():
+ cache._stat_index_root = None
+ cache._stat_index_anchor = None
+ cache._stat_index = {}
+ cache._stat_index_dirty = False
+ reset()
+ yield
+ reset()
+
+
+def _dirty(corpus: Path) -> Path:
+ f = corpus / "a.md"
+ f.write_text("# hello\nbody\n", encoding="utf-8")
+ cache.file_hash(f, corpus) # loads + dirties the index for this corpus
+ assert cache._stat_index_dirty
+ return f
+
+
+def test_a_corpus_deleted_mid_run_stays_deleted(tmp_path):
+ corpus = tmp_path / "husk-race-corpus"
+ corpus.mkdir()
+ _dirty(corpus)
+ shutil.rmtree(corpus)
+ cache._flush_stat_index() # what atexit does
+ assert not corpus.exists(), "the flush resurrected the deleted corpus"
+ assert not cache._stat_index_dirty # nothing left pending for a second
attempt
+
+
+def test_a_redirected_cache_root_that_vanished_is_not_recreated(tmp_path):
+ corpus = tmp_path / "c"
+ corpus.mkdir()
+ elsewhere = tmp_path / "out"
+ elsewhere.mkdir()
+ f = corpus / "a.md"
+ f.write_text("x\n", encoding="utf-8")
+ cache.file_hash(f, corpus, cache_root=elsewhere)
+ shutil.rmtree(elsewhere)
+ cache._flush_stat_index()
+ assert not elsewhere.exists()
+
+
+def test_the_index_is_still_written_for_a_live_run(tmp_path):
+ """A first run writes the index before graphify-out/ exists at all; that
+ stays as it was — the root is live, so creating cache/ under it is fine."""
+ corpus = tmp_path / "c"
+ corpus.mkdir()
+ _dirty(corpus)
+ cache._flush_stat_index()
+ assert (corpus / "graphify-out" / "cache" / "stat-index.json").is_file()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/graphify-0.9.50/tests/test_ts_new_expression_calls.py
new/graphify-0.9.51/tests/test_ts_new_expression_calls.py
--- old/graphify-0.9.50/tests/test_ts_new_expression_calls.py 1970-01-01
01:00:00.000000000 +0100
+++ new/graphify-0.9.51/tests/test_ts_new_expression_calls.py 2026-08-28
02:25:56.000000000 +0200
@@ -0,0 +1,100 @@
+"""TS/JS/TSX `new Foo(...)` constructor calls emit `calls` edges (#3116).
+
+In tree-sitter JS/TS, `new_expression` exposes its callee under the
`constructor`
+field rather than `function`. The generic path in `walk_calls` previously
queried
+only `call_function_field="function"`, dropping constructor calls.
+"""
+from __future__ import annotations
+
+from pathlib import Path
+
+from graphify.extract import extract, extract_js
+
+
+def _calls(tmp_path: 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, encoding="utf-8")
+ r = extract([tmp_path / n for n in files],
+ cache_root=tmp_path / "graphify-out", parallel=False)
+ lbl = {n["id"]: n["label"] for n in r["nodes"]}
+ calls = {(lbl.get(e["source"]), lbl.get(e["target"])) for e in r["edges"]
+ if e["relation"] == "calls"}
+ return calls, r
+
+
+def test_ts_new_expression_emits_calls_edge_in_file(tmp_path: Path):
+ calls, _ = _calls(tmp_path, {
+ "main.ts": (
+ "class Foo {\n"
+ " constructor(x: number) {}\n"
+ "}\n"
+ "function caller() {\n"
+ " const x = new Foo(1);\n"
+ "}\n"
+ )
+ })
+ assert any(s == "caller()" and t == "Foo" for s, t in calls)
+
+
+def test_ts_new_expression_resolves_cross_file(tmp_path: Path):
+ calls, r = _calls(tmp_path, {
+ "foo.ts": "export class Foo {}\n",
+ "caller.ts": (
+ 'import { Foo } from "./foo";\n'
+ "export function caller() {\n"
+ " const x = new Foo();\n"
+ "}\n"
+ ),
+ })
+ assert any(s == "caller()" and t == "Foo" for s, t in calls)
+ cross_edges = [
+ e for e in r["edges"]
+ if e["relation"] == "calls"
+ and "caller" in e["source"]
+ and "foo" in e["target"].lower()
+ ]
+ assert len(cross_edges) == 1
+
+
+def test_js_new_expression_emits_calls_edge(tmp_path: Path):
+ calls, _ = _calls(tmp_path, {
+ "app.js": (
+ "class Service {}\n"
+ "function init() {\n"
+ " const s = new Service();\n"
+ "}\n"
+ )
+ })
+ assert any(s == "init()" and t == "Service" for s, t in calls)
+
+
+def test_tsx_new_expression_emits_calls_edge(tmp_path: Path):
+ calls, _ = _calls(tmp_path, {
+ "comp.tsx": (
+ "class Widget {}\n"
+ "function App() {\n"
+ " const w = new Widget();\n"
+ " return <div>{w}</div>;\n"
+ "}\n"
+ )
+ })
+ assert any(s == "App()" and t == "Widget" for s, t in calls)
+
+
+def test_ts_member_new_expression_raw_calls(tmp_path: Path):
+ file_path = tmp_path / "member.ts"
+ file_path.write_text(
+ "function caller() {\n"
+ " const s = new pkg.Foo();\n"
+ "}\n",
+ encoding="utf-8",
+ )
+ r = extract_js(file_path)
+ assert any(
+ rc["callee"] == "Foo"
+ and rc.get("is_member_call") is True
+ and rc.get("receiver") == "pkg"
+ for rc in r.get("raw_calls", [])
+ )
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/graphify-0.9.50/uv.lock new/graphify-0.9.51/uv.lock
--- old/graphify-0.9.50/uv.lock 2026-08-25 19:24:12.000000000 +0200
+++ new/graphify-0.9.51/uv.lock 2026-08-28 02:25:56.000000000 +0200
@@ -1090,7 +1090,7 @@
[[package]]
name = "graphifyy"
-version = "0.9.50"
+version = "0.9.51"
source = { editable = "." }
dependencies = [
{ name = "networkx", version = "3.4.2", source = { registry =
"https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },