Fixes issue 2386.
The existing fallback only reconstructed communities from graph.json
when the sidecar was missing entirely; a sidecar that exists but was
written by an earlier clustering pass looked identical from the
outside and kept winning, since update and watch never regenerate
.graphify_analysis.json.
This compares the node id set each side covers, cheap and already in
memory, rather than the community ids themselves, since those can
renumber run to run even for the same partition. A mismatch means the
sidecar is stale: prefer the fresh reconstruction, recompute cohesion
with score_all so wiki articles and the HTML export do not swap one
silent degradation for a smaller one, reset gods_data so the existing
self heal at the wiki export site recomputes it, and print a warning
naming the exact command that refreshes the sidecar, since the whole
point of this issue is that nothing currently signals the divergence.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
(cherry picked from commit d4eb7af599)
Toward issue 2386: this hoists the reconstruction from graph.json's
per node community attribute out of the "sidecar is missing" branch
so it runs every time, with no behavior change yet since it is still
only used when the sidecar produced nothing. The next commit compares
it against the sidecar to also catch a sidecar that exists but is
stale.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
(cherry picked from commit 0932abc4e0)
A file that only imports a package subpath must resolve just like one
that imports the bare package, using the exact package.json plus two
file shape from the issue, instead of coming out fully disconnected
from the framework it depends on.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 5fb7fc34c1)
A scoped package subpath lands on the scope plus package only, never
on a different package that happens to share the same scope.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 02183335e3)
A plain package subpath now lands on the same resolver target as its
bare import.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit b703d3126e)
An unresolved bare specifier fell through to a ref namespaced id built
from the whole raw string, so a package subpath import like
next/image minted a different id than a bare import of next, even
though both refer to the same package. package.json's own dependency
node for that package is keyed off the bare name alone, so a subpath
import could never land on it and produced no edge at all while the
bare import of the same package resolved fine.
The fallback now strips the specifier down to its package root first:
the first segment for an ordinary package, the first two for a scoped
one, matching how the dependency node itself is keyed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit be0eb7f2a5)
graph.json shipped imports/imports_from/re_exports edges whose target was an
external module (stdlib, a third-party dep) with no entry in nodes[]. Every
loader (networkx node_link_graph) materialises such an endpoint as an
attribute-less phantom, so declared node-count != loaded node-count, and
merge-graphs fragmented the same module into one node per repo.
- build_from_json now mints a typed external stub node (type=external,
external=True) for an import-family edge whose target is otherwise undeclared,
instead of dropping the edge. Non-import relations are unchanged, so a
sourceless external call target stays suppressed (#3156, no phantom god-node).
- The --no-cluster / incremental update path writes the raw merged extraction
rather than a builder graph, so it applies the same stubbing via the shared
mint_external_stubs_in_data helper. Idempotent; grows the node set so the
#479 shrink guard is unaffected.
- prefix_graph_for_global leaves external stubs unprefixed (no repo:: tag), so
the same stdlib/dependency module unifies into one global node across a
cross-repo merge instead of fragmenting per repo.
Adds regression tests for build-level stubbing, serialized-graph consistency,
the #3156 non-import drop, idempotency, cross-repo unification, and an
end-to-end `update --no-cluster` integrity check through the real rebuild.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#3603 resolves cross-file alias/import/require/use targets onto the real
defmodule node. Two guards ported from the competing #3566 so the resolver
does not over-reach:
- Only top-level modules are indexed as resolution targets. extract_elixir
marks them with _elixir_module; a nested defmodule (labeled with its bare
inner name) is left unmarked so an unrelated `use <Name>` from another file
cannot latch onto it. The marker rides through incremental rebuilds via the
resolution-context allow-list in watch.py and cli.py, so the fix does not
decay on the `graphify update` / watch path.
- A same-file alias is left unresolved: the import edge's source is the file
node, which already `contains` that module, so retargeting would duplicate
the pair as a weaker `imports` and clobber `contains` in the non-multi graph.
Adds regression tests for both guards plus an incremental-rebuild test that
routes through the real watch._rebuild_code builder (the path #3566's own
test bypassed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_reconcile_markdown_links treats every AST-tier references edge owned by a re-parsed
Markdown file as an authored [link](file) and prunes any pair it did not author. A code-span
mention is also a references edge owned by its document, so a plain `graphify update` over
an existing graph, with or without changes, dropped every mention edge; only a build from
scratch kept them.
A mention targets a code symbol, never a file representative, so the reconcile now leaves
those edges to the AST ownership rule: re-extracting the document regenerates them and
re-extracting the code side keeps or drops them with the target node.
(cherry picked from commit 73cf2ae56c)
Three findings from the automated review of #3562, all reproduced:
A dotted span with a non-code extension (README.md, pyproject.toml) was classified as a
symbol because only CODE_EXTENSIONS was checked, and every dotted form was reduced to its
last segment, so time.sleep matched a repo's own sleep(). The dotted form now keeps its
whole chain, the file early-exit also checks DOC_EXTENSIONS, and a bare-name match must
carry every qualifier as an owner label or a segment or stem of its source path.
_match_cited_file used lstrip("./"), which strips characters, so a .github/... cite could
never match by suffix. The leading ./ and ../ segments are now stripped as segments, and
the suffix form also matches a root-level file.
An explicit ./ or ../ cite that escaped the corpus fell through to the suffix rule and
could land on an unrelated copy of the file. It now resolves relative to the document
only.
(cherry picked from commit 3f84e7f3e2)
A module with no matching definition anywhere in the corpus, stdlib
or a hex dependency, must stay an external reference rather than
getting fabricated.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit da5e23a921)
Two different files each declaring a module with the same bare name
must leave the reference exactly as extracted rather than guessing
which one the caller meant.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 690d387626)
A module importing something declared in the same file already
resolved before this fix, since both ids share the same stem, and
must keep working exactly as before.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 8320da005a)
A module aliased from a different file now resolves to that module's
real, non dangling node.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 53877c6874)
Fixes issue 2556.
extract_elixir mints a module's own node id with the defining file's
stem but an alias/import/require/use target with just the bare
module name, so the two only ever match when a module refers to
itself. Every reference to a module declared in a different file was
silently dropped as dangling at build time, thirteen percent of
extracted edges on the real project measured in the issue.
Matches a target against the id of a module across the whole corpus,
which needs no heuristics since both sides already run through the
same id builder. Zero or two or more matches leave the edge exactly
as extracted rather than guessing, the same god node guard every
other cross file resolver in this module already applies; the issue's
own testing found id suffix matching produced wrong resolutions on a
real corpus, which is why this matches the full id, not a fragment
of it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 909310cfe7)
Pooling methods across every node sharing a type's bare label is
what makes a split impl block work, but it is only safe when every
one of those nodes really is an impl block for the SAME type. Two
unrelated structs that happen to share a bare name, each with its
own different methods, could still link a caller to a method that
only exists on the wrong one, most concretely when one type
overrides a trait default the other relies on and so never gets an
explicit method node for it.
A struct, enum, or trait declaration gets a contains edge from its
file; an impl block never does. So the type name itself is now
treated as ambiguous, and pooling skipped entirely, whenever two or
more nodes sharing the bare label are real declarations rather than
impl blocks. A single declaration with any number of impl blocks
spread across files, the shape this pass exists for, is unaffected.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 141662b0eb)
A plain dict assignment silently overwrote an earlier target whenever
two method nodes shared both a source impl node and a stripped
label, which would have kept whichever one was seen last instead of
surfacing the case as the ambiguity it is. The index now keeps a set
per key and folds every match into the candidate set before the
exactly one candidate check runs, so a genuine duplicate still bails
instead of guessing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 2cbbf1a670)
The check against a pair already having an edge matched on ANY
relation, so a caller that already had, say, a references edge to
the exact same target (from also naming the type elsewhere) silently
suppressed a real call to it. The two relations are not mutually
exclusive and an existing non calls edge says nothing about whether
a call was resolved. Scoped the check to calls edges only, matching
an existing precedent elsewhere in this module for the identical
class of mistake.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 94735ce241)
Two different structs sharing a bare type name with a colliding
method name must yield no edge rather than a guess, and a self call
to a method that genuinely does not exist anywhere in the corpus
must not fabricate a target.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit afeeefa109)
Covers the issue's own shape (impl Foo split across two files, a
self call in one block reaching a method defined in the other) and a
same file negative control proving the pre existing bare name
resolution is unaffected.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 2d161af639)
Fixes issue 2234.
Pools methods by the bare type name across every node that carries
it, rather than requiring the name to resolve to exactly one node
like the Kotlin and Swift equivalents: Rust routinely splits an impl
block for one type across as many files as it likes, and rust.py
mints a separate graph node per file and type name pair rather than
merging them, so requiring a single node would refuse the exact
split impl block shape this issue is about. A call resolves only
when exactly one method across the whole pool matches the callee
name, so two impl blocks defining the same method name still bail,
a real ambiguity rather than a false one.
Verified end to end: a self call in one file now reaches a method
defined in a different impl block of the same type in another file,
the same file control case is unaffected, an ambiguous type name
shared by two unrelated structs with a colliding method name yields
no edge, and a call to a genuinely undefined method yields no edge.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 7a5ab00ff5)
Detect when a member call's receiver is literally self (a distinct
tree sitter node type, not an identifier) and, when the enclosing
impl block's bare type name is known, carry it on the raw_call as
rust_self_type. Nothing consumes this yet, so the extracted edges
are unchanged; it is the signal the next commit's resolver needs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit 200081cf9b)
Prep for resolving a self dot method call across files: extract_rust
already tracks which impl block a function body belongs to via
parent_impl_nid, but not the block's own bare type name, which is
what self is typed as syntactically inside that block. No behavior
change yet, this just carries the name down to where a call
expression is walked.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit a49fd9139a)
The markdown extractor's docstring promised a heading --references--> node edge
when a backtick Name matches a known pattern, but nothing implemented it, so an
AST build produced no document-to-code edge at all.
extract_markdown now collects inline code spans outside fences and frontmatter as
raw_calls tagged language "markdown", owned by the heading they sit under (the
page before the first heading). A new markdown_mentions language resolver runs
after the id-remap passes and turns them into references edges: the explicit
path::Symbol[::member] form resolves through the cited file's symbols and is
EXTRACTED 1.0; a bare or dotted name resolves only when exactly one callable code
node carries that label and is INFERRED 0.95. Ambiguous names, builtins, missing
files and non-callable matches emit nothing. The shared cross-file call pass
skips markdown raw_calls so prose never emits calls edges.
Tests cover span classification, raw_call ownership, end-to-end resolution,
ambiguity, and cache round-tripping.
(cherry picked from commit 2d833a0a3c)
#3558's fix landed but its diff omitted the 3 tests its description claimed;
add them (self-loop ignored, self-loops-only is 0, 0..1 bound holds). Also drop
a changelog line the #3559 cherry-pick injected into the released 0.9.61 section.
group_skipped feeds the dangling reference pruning pass and the write
loop must agree with it on whether a recovered group is written or
skipped. This proves an edge between two nodes inside the same
recovered group survives instead of being pruned as referencing a
supposedly skipped id.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
(cherry picked from commit 8a23b305fe)
Confirms recovery never runs when allowed_source_files is omitted: a
genuinely bogus path is skipped exactly as before this fix, and a
normal well formed path in the same batch still saves and loads back.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
(cherry picked from commit 12999a80f6)
Two allowed files sharing a basename must not let recovery guess
between them: an unresolvable source_file with an ambiguous basename
still falls through to the previous skip behavior unchanged.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
(cherry picked from commit f59acf7c36)
A recovered group's write KEY pointed at the right file, but every
node, edge, and hyperedge inside it still carried the original
unresolvable source_file string. load_cached rejects an entry on read
if any item's source_file does not match the path it was loaded
under, so the previous commit alone wrote a group that reported as
saved but could never actually be read back, silently reproducing the
exact loss this fix exists for.
This corrects every item's source_file to the recovered path's
portable form before the group is written, only when recovery
actually redirected the key, so an unrecovered group stays byte
identical to before.
Includes the regression test proving the full round trip: a group
recovered by basename now survives a subsequent load_cached call.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
(cherry picked from commit 4b91dcd462)
Fixes issue #2973.
group_skipped and the save_semantic_cache write loop now call
_recover_group_path instead of resolving fpath directly, so a group
whose reported source_file never resolves to a real file gets one more
chance: an unambiguous basename match against the already dispatched
allowlist. An ambiguous or genuinely bogus basename still falls
through to the previous skip behavior unchanged, and unscoped callers
(no allowed_source_files) are byte identical to before since recovery
only runs when an allowlist is available.
This stops an incremental run from silently redoing already processed
chunks every time the adaptive retry split path reports a source_file
that a weak or local backend lost track of.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
(cherry picked from commit 73f5a66370)
save_semantic_cache groups nodes and edges by source_file, then skips
any group whose path does not resolve to a real file. A weak or local
backend's adaptive retry split path sometimes reports a source_file
for a group that never resolves at all, silently dropping that
group's nodes and edges from the cache on every incremental run.
This adds a helper that recovers such a group by an unambiguous
basename match against the known good, already dispatched allowlist,
with no behavior change yet since nothing calls it. The write loop is
switched over in the next commit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
(cherry picked from commit 4528666ba6)
Simulates the VMware HGFS PermissionError this issue reports by
monkeypatching os.replace to always raise it, then confirms an
incremental no_cluster rebuild still succeeds and lands the new
content instead of aborting the whole run.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
(cherry picked from commit 9e75593b02)
Fixes issue 2689 for the main rebuild write site, the second of the
two watch.py call sites reported.
Same reasoning as the reconcile path: existing_graph was read a few
lines earlier in this same process for the same_graph comparison, so
a plain Path.replace can hit the VMware HGFS PermissionError this
issue reports. Both call sites in watch.py now route through the
shared os_replace_with_fallback helper that export.py, cache.py, and
install.py already use.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
(cherry picked from commit b01622eb9f)
Fixes issue 2689 for the reconcile existing graph write site.
This site reads existing_graph a few lines earlier in the same
process, then replaced graph_tmp onto it with a plain Path.replace.
On a VMware HGFS shared folder, os.replace over a destination that
was read earlier in the same process raises PermissionError even
though src and dst are on the same drive; deleting then renaming
succeeds in that exact situation. os_replace_with_fallback already
implements that fallback for PermissionError generally, so this
switches the call over to it instead of duplicating the recovery here.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
(cherry picked from commit 3214e8ee03)
watch.py writes graph.json via a plain Path.replace() at two call
sites instead of the shared os_replace_with_fallback helper that
export.py, cache.py, and install.py already route through. This adds
the import with no behavior change yet; both call sites are switched
over in the next two commits.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
(cherry picked from commit f3694aea49)
#1423 moved the Python rebuild bodies onto GRAPHIFY_OUT, but the shell that
runs BEFORE them still hardcoded graphify-out/. With the documented override
(#686, e.g. GRAPHIFY_OUT=custom-out for worktree/shared-output setups):
- post-checkout exited at `[ ! -d "graphify-out" ]`, so a branch switch never
launched the rebuild - a silent no-op with nothing in the log.
- post-commit's "only graph artifacts changed" filter only recognised
graphify-out/ paths, so a commit touching only custom-out/graph.json
launched a full rebuild - the loop the filter exists to prevent.
Both gates now read ${GRAPHIFY_OUT:-graphify-out}, the same source the rebuild
body reads. The commit filter is a POSIX case loop instead of grep so the dir
name is matched literally (no regex metacharacters); the leading ( on each
pattern keeps bash 3.2 (macOS /bin/sh) from mis-parsing the pattern's ) inside
the $(...) substitution.
Regression: tests/test_hooks.py runs the emitted scripts under sh with the
launcher stubbed and asserts whether the hook reaches the launch line.
Claude-Session: https://claude.ai/code/session_0134ujLF81GyXsCByibLcYsz
(cherry picked from commit 9efd052de4)
cohesion_score() counts self-loops in the numerator (number_of_edges) but
not in the denominator (n * (n - 1) / 2, distinct node pairs), so the ratio
its docstring defines over 0..1 can exceed 1.0. Recursive `calls` self-edges
are deliberately preserved by build_from_json, so they reach this code in
ordinary graphs: a two-node community holding one recursive function scored
2.0, and report.py prints the value unclamped.
(cherry picked from commit 5d66b992ac)