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'" },

Reply via email to