Resolves a call on a local or parameter whose class is known from an annotation
(c: Client), an Optional/union/string annotation, or a constructor/with binding,
to the owning class's method - fail-closed to a single in-file-or-imported class
that owns the method. Complements the self.<attr> arm from #4176.
Rebased onto the landed #4176: reuses its _emit_call(inferred=True) INFERRED/0.85
path instead of the PR's own confidence= parameter.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
extract()'s id remap called Path.resolve() on the same file paths once
per input, once per target_file-stamped edge and once per node: ~24k
calls for ~1k distinct paths on graphify's own repo. Resolve each
distinct path once per extract() call through a local memo; a hit
returns the same Path the call would, and a path that raises is never
stored.
_path_proximity_winner (the #1553 god-node tie-break) re-parsed every
candidate's parent directory with PurePosixPath on every ambiguous call
site (~227k Path objects per update on graphify's repo), although the
same candidate files come back for every site of a common name. Memoize
the parent-directory parts per path string; equal tuples are exactly
equal PurePosixPath parents, so every tier decides as before.
graphify's own repo (18,830 nodes), `graphify update --force`,
alternated: cold 28.1 -> 26.3 s, no-change update 24.8 -> 22.5 s and
20.9 -> 18.0 s. graph.json identical (all attributes) cold and warm;
old vs new tie-break identical on 200,000 random cases.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit d5966eda8a)
0.23/0.24 cap at ABI 14 while pinned grammars ship ABI 15, so a resolver could
pair a 0.23 runtime with ABI-15 grammars and fail every parse. Floor at 0.25,
the oldest runtime that loads them (uv.lock already resolves there). Lock
regenerated on 0.9.80.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The raw --no-cluster writers skip build_from_json. `update --no-cluster`
only minted the #2873 import stubs, and `extract --no-cluster` did not
even do that, so graph.json kept edges to undeclared nodes. The common
case is a markdown link to a missing document: extract_markdown keeps
it dangling on purpose and builds the target id from the absolute
resolved path, so the checkout folder and OS username reached
graph.json.
finalize_raw_graph_endpoints() now runs on both raw writers: mint the
import stubs, drop links that still lack two declared endpoints (after
the same normalization remap build_from_json does), and drop stubs no
link references any more, so a stub minted on the extract path cannot
outlive its import.
On graphify's own repo: 186 dangling edges -> 0 (149 carried the
checkout path), node set unchanged, and every pair the clustered build
links is still linked.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 97027ccf04)
extract() returns extracted_sources (#3411) and failed_sources (#2543)
next to nodes/edges. They steer the current run and hold the absolute
paths of its inputs. The raw --no-cluster writers dumped the extraction
as graph.json, so the first `graphify update --no-cluster` and every
`graphify extract --no-cluster` wrote the checkout path and OS username
into the graph. A rebuild dropped them again, and also added a
`hyperedges` key the first build lacked, so the first build and a
rebuild of the same tree wrote different files.
Name the run-only keys once (extract.RUN_ONLY_EXTRACTION_KEYS) and leave
them out of both raw writers; the update writer always writes
`hyperedges`, as the extract writer and the clustered path already do.
Nothing reads these keys back from graph.json: build_merge and
merge_raw_extraction take them from the in-memory extraction.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 76cfcef556)
External import stubs (#2873) are minted without an _origin stamp. On
the next update the #2334 tier backfill stamped them by shape, and a
stub has no L<line> location, so it became `semantic`. Reconcile keeps
semantic nodes, so once the last file importing a module stopped
importing it, the stub stayed in graph.json forever with no edge. It
also made a rebuild of an unchanged tree differ from the first build.
A stub is derived from its import edges and minted again on every
write, so it has no tier. The backfill (now one helper shared by
_load_existing_graph and the reconcile path) skips stubs and removes a
stamp an earlier run gave one; every tier decision reads an unstamped
stub exactly as before. Reconcile drops a preserved stub that no fresh
or preserved edge references.
On graphify's own repo a warm rebuild now equals the cold build (125
stub nodes differed), a long-lived graph heals to the cold node set
(8 ghost stubs removed), and the changed-files path equals the full
rebuild for edits and additions.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit d3fbf6458f)
A re-clone that copies graphify-out/ while the old checkout is still on disk
left absolute manifest keys pointing at the old path, which survived the
exists()/in-root prune and persisted alongside the new relative keys. On a full
scan, drop stale absolute keys that duplicate a freshly-written relative key
under a real shared directory prefix.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Imports under `if TYPE_CHECKING:` never execute, but the Python extractor
emitted them as ordinary `imports_from` edges, so the Import Cycles section
listed cycles that cannot happen at runtime.
Stamp them `type_only`, the marker find_import_cycles already skips for
TypeScript `import type` (#3123); the edge stays in the graph. Covers
`TYPE_CHECKING` and `<module>.TYPE_CHECKING`, `import x`, `from x import y`,
relative imports and nesting. The `else:` branch and the body of
`if not TYPE_CHECKING:` stay runtime imports.
build_from_json collapses the import edges of one file pair into one, and
dedupe_edges those of one (source, target, relation), keeping the marker of
either side. Stamping alone would let a guarded import mask a runtime one
(for example `from . import b` plus a guarded `from .b import B`) and lose a
real cycle. The collapsed edge is therefore `type_only` only if every import
folded into it is: an import that runs always wins. The same collapse already
hid a real cycle for TypeScript `import type` next to a value import of one
file. In the default undirected graph, the reverse-direction duplicate that
#1061 drops is the other file's import, so it leaves the surviving edge's
marker alone.
Not covered: `from pkg import submodule` under the guard. That edge comes from
the resolution pass and is not stamped, so its cycle is still reported.
Assisted-by: assayer harness on Claude Code (https://github.com/Yyunozor/assayer-memory-mcp)
(cherry picked from commit 685b0fffb5)
An AST cache entry is keyed by its own file's content, so after
pkg/slice.py is renamed or deleted, the unchanged importer pkg/user.py
is a hit and replays its import edges resolved while slice.py existed:
a target id minted from the absolute path and a target_file stamp to
the old file. extract() maps a stamped target to a portable id only
when it exists, so the edge kept the absolute-path id and the #2873
stub was minted with it: graph.json held the checkout folder and OS
username, and a warm update differed from a cold build (pkg_slice).
save_cached now records the target_file stamps that name an existing
file when an AST entry is written (made portable like the entry's
ids). load_cached returns a miss when any recorded file is gone, so
the importer is extracted again exactly as on a cold build. Stamps
that did not exist at write time are not recorded and never cause a
miss; entries without the list load as before.
A no-change warm rebuild of graphify's own repo has the same 16 cache
misses as before, and its cold build is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 48578d04f1)
Keep warning path formatting from aborting a recovered extraction. AI assistance: Codex drafted the change and the actual extraction regression.
Co-Authored-By: Codex <noreply@openai.com>
(cherry picked from commit 1e91fc2f1b)
_spawn_cannot_reimport_main (#3669) skips the process pool when spawn
workers cannot re-import __main__, judged by os.path.isfile on
__main__.__file__. The pip/uv console script on Windows is a launcher
exe with a zipped __main__.py, so __file__ is
...\Scripts\graphify.exe\__main__.py, not a file on disk, and every
`graphify update` / `graphify extract` from the normal Windows command
extracted on one core.
The pool works there: __main__ has a module spec, so
multiprocessing.spawn passes the module NAME to the worker, and a spec
named __main__ is skipped in the worker altogether ("zip archives ...
we don't even try"). Return False whenever __main__ has a spec,
mirroring get_preparation_data. stdin, -c and the REPL have no spec, so
#3669 is unchanged for them. The two stdin/REPL tests now also set
__spec__ = None, as a real stdin caller has.
Cold build of graphify's own repo with graphify.exe, alternated:
39.6 s -> 26.5 s median (12 workers), identical graph.json.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit f64594d7e5)
Two files whose paths normalize to the same id (a-b/x.ts and a/b/x.ts
both give a_b_x) share a source id until _disambiguate_colliding_node_ids
salts them by source_file. _apply_symbol_resolution_facts deduped its
edges on (source, target, relation, context), so when both files
re-exported the same module the second edge was dropped as a copy of the
first, and which file kept it depended on processing order. In zod's
src/, v4/mini/index.ts and v4-mini/index.ts traded the edge with the
file order.
Key the dedup on the emitting file as well, so only true duplicates from
one file collapse.
(cherry picked from commit 075ee2fea5)
CommonMark writes a link destination with a space either in angle
brackets ([x](<My Note.md>)) or percent-encoded ([x](My%20Note.md)).
The inline and reference-definition regexes stopped the capture at the
first space inside <...>, and the destination was never decoded, so
both forms kept an id derived from a nonexistent path and the
references edge dropped at build.
Capture the whole bracketed destination in both regexes; an unclosed <
falls back to the bare form, so it does not swallow the links after it.
Percent-decode inline and reference destinations, keep a file whose name
literally contains the escape (with or without the .md completion), and
re-run the external-link guard on the decoded form. Wikilinks stay
verbatim.
Assisted-by: assayer harness on Claude Code (https://github.com/Yyunozor/assayer-memory-mcp)
(cherry picked from commit 49ba8aafc0)
A Java/Groovy anonymous class (`new Runnable() { … }`) keeps its body as a
`class_body` hanging off an `object_creation_expression`. That node is not
a class_type and the function branch never recurses into bodies, so the
anonymous class's methods — and every call made inside them — got no nodes
at all. Anonymous listeners/callbacks are routine in Java, so this silently
dropped a large slice of the call graph (e.g. a listener's `onClick`
calling a sibling method vanished).
Mirror the Kotlin anonymous-object handling (#2347): scan a function body
for anonymous classes (without crossing a nested method/lambda boundary or
descending into a found one), emit an owner node per anonymous class, and
walk its class_body like the class branch so members and their calls flow
through the normal machinery. The base type is recorded only as the node's
label, not an implements/inherits edge — an anonymous class usually
realises a library interface/class whose kind is unknowable from this file,
so a guessed subtype relation is withheld (fail-closed).
(cherry picked from commit 0a6990c0ee)
Grammar and SDK tests for erlang, r, solidity, vbnet, ollama and the sql encoding case failed instead of skipping on a default uv sync. Guard them with the existing _needs_* skipif pattern and point CONTRIBUTING at uv sync --all-extras to match CI.
Co-Authored-By: Claude <noreply@anthropic.com>
(cherry picked from commit d7f443eeb1)
graph.json already records built_at_commit, but node_link_graph drops
top-level keys, so the MCP server never saw it and graph_stats only
reported counts. Stash the commit in _load_graph and append it to the
graph_stats output. At query time, compare it with HEAD of the repo
holding the graph and note when they differ; with no git or no repo the
comparison is skipped. Graphs without a commit render as before.
Closes#3354
Co-Authored-By: Grok Bot <noreply@x.ai>
(cherry picked from commit fe993d3fd5)
query_graph copied every edge of the loaded graph into an undirected
traversal view on every call, and shortest_path rebuilt a sorted
DiGraph (or Graph) of every edge on every call. The structures are the
same for a given graph object, so build each once and cache it on
G.graph, exactly like the trigram index: a hot reload swaps in a new G,
so a cached structure never outlives its graph.
Sharing is safe because nothing writes to them: _filter_graph_by_context
builds a new graph when a filter applies, the traversals and the
renderer only read the view, and only nx.shortest_path reads the path
graphs. The path graph is still built from sorted node and edge lists,
so #2074's deterministic route is unchanged. The traversal-view
docstring that chose a fresh copy per query is updated.
graphify's own graph (18,865 nodes / 38,458 edges), the same calls run
interleaved with the cache cleared vs kept, 240 outputs identical:
query_graph 524 -> 316 ms median (filtered "what calls X": 508 -> 360),
shortest_path 211 -> 0.8 ms directed, 109 -> 0.7 ms undirected.
Memory: +8 MB for the view, +24 MB for both path graphs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit fbb21791da)
0.9.78's #4167 added schema_version/graphify_version under graph.json's
"graph" key, but the candidate topology built from node_link_data(G) has no
such metadata, so _canonical_topology_for_compare judged every incremental
update a topology change and needlessly re-clustered and re-rendered. Strip the
two metadata keys before comparing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
graph_diff keyed undirected edges by their sorted endpoints, so a call
that reversed (a->b became b->a) dropped out of the --update summary.
When both snapshots carry _src/_tgt and they differ, the edge is now
reported as one removed and one new edge, with source/target taken from
the markers. Graphs without the markers diff as before.
The skill's diff step loaded the old graph.json with a plain
node_link_graph call, which drops the stored direction, so it now uses
paths.load_node_link_graph (#4066/#4088). Skills regenerated with
skillgen, and the aider/devin monolith round-trip allows the changed
lines.
Closes#4067
Co-Authored-By: Grok Bot <noreply@x.ai>
(cherry picked from commit 917fab9fbb)
C++ projects using module export macros (class MYLIB_EXPORT Foo {}) lost the
class and its inherits edge because tree-sitter-cpp fails to parse the macro and
error-recovers over the declaration. Blank the ALL-CAPS macro token before
parsing, preserving byte length and newlines so offsets and ids stay stable;
guarded so legitimate all-caps class names, 'final', range-for, and brace-init
are never touched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
tree-sitter reads UTF-8, but extractors handed it raw file bytes, so a UTF-16
file yielded zero nodes and a cp1252 name like CaféOrder was truncated. Decode
via a shared reader that returns UTF-8 bytes unchanged (ids/offsets/cache keys
for working files are byte-identical) and re-encodes UTF-16/UTF-32/non-UTF-8
using a deterministic cp1252->latin-1 fallback. The semantic whole-file and
slice readers now share the same decoder.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On an incremental reconcile the corpus sweep read a never-scanned referenced
path (unresolved dynamic import target, .sln/.csproj reference) as a deleted
source and evicted the placeholder node a referrer minted, dropping the
unchanged referrer's preserved cross-file edges. Skip eviction for such
referenced-but-unscanned paths (purely additive; never causes a new eviction),
with deletion-evidence guards so genuinely deleted files are still reaped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the name-only member-call resolver with receiver-proven binding:
$this.M() binds to the enclosing class only when its name is unique, and
[Type]::M() only to a single unambiguous local class; value/cast/dynamic/
duplicate/external receivers fall through to raw_calls. Fixes the same-name-
method caveat from the 0.9.77 member-call work (#3992).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
_reconcile_existing_graph asks _StoredSourcePaths.identity() for every
node, edge and hyperedge, several times per item. On graphify's own repo
that is ~150k calls for under 1k distinct files, each rebuilding Path
objects and an abspath. The answer depends only on the stored path and
roots fixed in __init__, so cache it (and in_watch_root) per instance.
Reconcile step on this repo: 10.3 s -> 2.7 s. graph.json is byte-identical
with and without the change for a full build and for an incremental
update with an edited, a deleted and an added file.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8gwEpjajiWHVW3c9smuKf
(cherry picked from commit 1360728f0f)
A relative import of a module with no file behind it (`from .absent
import x`) built its target from the attempted absolute path, so the id
became e.g. `c_users_<user>_..._pkg_absent_py`. The root-relative id
remap never rewrites it (there is no node to anchor on), so the checkout
location and the OS username ended up in graph.json and every clone got
a different id.
Use the dotted module name relative to the scan root instead
(`pkg_absent`), the id an unresolved absolute import of the same module
already gets, so `from .absent` and `from pkg.absent` now meet on one
node. An import that climbs above the scan root falls back to a `ref`
id. Imports that resolve, and attempted paths that exist, are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8gwEpjajiWHVW3c9smuKf
(cherry picked from commit 8cdbb0ea4f)
((IStore)_obj).Save() and (_obj as IStore).Save() produced no calls edge:
the receiver capture didn't handle a parenthesized cast, so the call never
reached the receiver-typed resolver. Use the cast's type as the receiver so
it resolves like IStore.Save().
IStore Store => ...; Store.Save() bound to the concrete Store.Save() because
a capitalised receiver was looked up as a type name first. A field, property
or local of that name shadows the type, so try the recorded receiver_type
first and fall back to the type name.
Co-Authored-By: Grok Bot <noreply@x.ai>
(cherry picked from commit 2d26dad2ab)
A CommonJS require() of a relative module with no file behind it resolved
to the attempted absolute path, and the target id plus every destructured
symbol under it were minted from that path. The checkout location and the
OS username ended up in node ids, so the same repo gave a different
graph.json in every clone.
#2457 fixed this for static imports only; require() goes through
_require_imports_js. Apply the same rule there: a resolved path that is
not a file is unresolved and gets the stable ref id.
The two destructured/member require tests asserted symbol edges into
cjs_require.js's missing ./foundation and ./helpers, i.e. those
absolute-path ids. They now run the fixture next to real modules, so
they still check one imports edge per binder.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8gwEpjajiWHVW3c9smuKf
(cherry picked from commit 5759ab1331)
C# namespace scope ids (the scope_chain / scope_id node metadata a
namespace-scoped `using` is matched against) were `s{start_byte}`. A
CRLF checkout (Windows with core.autocrlf) has one more byte per line
than an LF checkout of the same commit, so the two wrote different
metadata into graph.json: on Newtonsoft.Json's 242 source files, 3,096
of 4,352 nodes (71%) differed by line endings alone.
Use row:column, which is the same for both and just as unique. The ids
are only compared within one file, so resolution is unchanged: all
11,627 edges are identical before and after, and the nodes only differ
in the id text (identical once each id is mapped to its scope).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8gwEpjajiWHVW3c9smuKf
(cherry picked from commit ffd855a6fa)
Keep operator scopes separate from their containing file and ordinary normalized identifiers, including nested bindings.
Co-Authored-By: OpenAI Codex <noreply@openai.com>
(cherry picked from commit dc43c7c646)
extract_dmf minted each element id from its window's node id, which
already embeds the file stem. The stem is absolute at extraction time and
extract()'s id remap only rewrites the leading copy, so the nested copy
kept the checkout path and the OS username inside every element id.
Use the window name instead. Element ids no longer depend on where the
repo is checked out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8gwEpjajiWHVW3c9smuKf
(cherry picked from commit 3d9021fab7)
The split-host runbook told the agent to skip to Part C when every doc,
paper and image hit the semantic cache. Part C reads
.graphify_semantic.json unconditionally and only the Step B3 merge writes
it, so a second run on an unchanged mixed corpus failed with
FileNotFoundError; writing an empty file instead dropped every cached node.
The all-cached case now skips B1/B2 but still runs B3. Step B0 also clears
.graphify_chunk_*.json before dispatch: B3 merges every chunk on disk, and a
leftover from an interrupted run would otherwise be merged as fresh.
The aider and devin monoliths keep the old sentence; they are pinned to the
v8 baseline by --monolith-roundtrip.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit bab1bb2775)
Pass dedup=False into graph construction so repeated labels do not collapse distinct non-AST identities. Preserve existing AST and document-file twin reconciliation. Fixes#4019.
Assisted-by: OpenAI Codex (GPT-6)
(cherry picked from commit 1f3805f16e)
The call walker only visited `command` nodes, so every method invocation
was dropped from the call graph. A method call parses as an
invokation_expression (callee in the member_name child, args in
argument_list); a bare property read ($this.Name) is a separate
member_access node and correctly produces no call.
Handle invokation_expression and resolve the member name against a
method-only label map so a member call binds to a method, never to a free
function that merely shares the name (fail-closed on unknown receivers).
Fixes#3992
(cherry picked from commit 1e6a9029e4)
graph.json is written "directed": false and carries direction in arc order
(#563), or in _src/_tgt markers on legacy files (#2309). A plain undirected
node_link_graph load re-orders endpoints by node-list position, so every
link whose target precedes its source in the node list came back reversed
(79% of links on graphify's own graph).
query, path/explain, affected, merge-graphs and the MCP server already
restore direction. The remaining reloaders did not:
- export html / neo4j / falkordb (cypher.txt and --push) / obsidian canvas
- graph.html re-rendered by update, watch and the git hooks
- the git merge driver, which wrote the reversal into the committed
graph.json, even when merging three identical copies
Add paths.restore_arc_direction, the inline idiom query and merge-graphs
already use, and apply it in load_node_link_graph and the export loader.
to_cypher, to_canvas and push_to_neo4j/falkordb now read _src/_tgt as
to_html already does. The merge driver writes direction back into arc
order before saving, as to_json does, so the committed file keeps the
post-#563 convention.
global add has the same bug and is fixed separately.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 5a461c99d7)
Preserve subsequent script blocks and byte offsets when a script ends in a line comment. Fixes#4072.
Co-authored-by: Codex <noreply@openai.com>
(cherry picked from commit fc48f659c4)