A node without a label is drawn under its id (`G.nodes[n].get("label", n)`),
a third path from text to graph.svg. The previous commit cleaned the labels
and left that fallback as it was, so an unlabelled node whose id holds a C0
control character still produced a graph.svg that a strict parser rejects.
Run the fallback through the same strip: one expression,
`_strip_xml_illegal(str(G.nodes[n].get("label", n)))`, now covers the label
and the id. A clean id is drawn as before.
Tests: for each control character the module already lists, an unlabelled
node whose id carries it; the file must parse and still show the cleaned id.
Assisted-by: assayer harness on Claude Code (https://github.com/Yyunozor/assayer-memory-mcp)
(cherry picked from commit 2e7e28ab32)
extract_rust bound every in-file call through one bare-name index (last one wins),
so `Matcher::new()` reached `Printer::new()` when the file defined both, `Arc::new(x)`
reached the file's own `fn new`, `self.go()` another type's `go`, `drop(x)` the
`impl Drop` method and `x.total()` a free `fn total`, all EXTRACTED.
`Type::f()` / `Self::f()` / `self.f()` now bind only to that type's method in the file
(type = last path segment without generics, on the call and on the impl; `i32` and
other primitives count as types). With none, `Enum::Variant(x)` still gets its
constructor reference and `self.f()` falls through to the cross-file self pass. A bare
`f()` binds only to a free function or a type, `x.f()` only to a method.
Against rust-analyzer: wrong edges ripgrep 1232 -> 532, bat 138 -> 36, fd 29 -> 9;
correct edges 1852 -> 2236, 602 -> 655, 148 -> 154.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8gwEpjajiWHVW3c9smuKf
(cherry picked from commit de9b728ac6)
Print a precise, interactive-only CTA after `graphify extract` succeeds:
faster indexing, fewer tokens, cross-repo search, PR review, and an
always-current SDLC map at app.graphify.com. Shown once per project (marker
under graphify-out/), suppressed when stdout is not a TTY (CI, pipes, the
AI-assistant pipeline) and via GRAPHIFY_NO_TIPS/GRAPHIFY_NO_CTA.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The in-file pass walks only bases declared in the same file, so `Flask.__init__` ->
`App.__init__` (flask/app.py -> flask/sansio/app.py) and every other inherited call
across modules had no calls edge.
_resolve_python_member_calls now walks the caller's class up a single-inheritance
chain and binds the first class that owns the method, as the MRO does. It stops with
no edge at multiple inheritance, a base the inherits edges cannot show (marked
`python_opaque_bases` by the extractor: `mod.Base`, `Generic[T]`), a base outside
the corpus or a self-loop, a caller base its file neither defines nor imports, and
`super(Cls, obj)` with arguments. On a changed-files rebuild an unchanged base is
still a name stub, so the walk rewires copies of the inherits edges the way the full
build does; the opaque-base marker is forwarded with the other context metadata.
AST cache schema 6 -> 7.
flask: 66.7% -> 71.6% of jedi's call pairs found; 16 new edges on httpx/click/flask,
16 confirmed by jedi, 0 lost. Changed-files rebuilds match full builds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8gwEpjajiWHVW3c9smuKf
(cherry picked from commit b241c3bc79)
The typed-receiver arm of _resolve_python_member_calls checks that the receiver's
class is defined in or imported into the caller's file. _file_node returned the
caller's first contains/method parent, which is the file for a top-level function or
method but the enclosing function for a nested def and the outer class for a nested
class's method, so those calls (click's `def callback(ctx: Context, ...): ctx.abort()`)
never got an edge. Climb the parents to the top, which is always the file node.
0.9.80 vs this change: click +10, rich +4 call edges, all 14 confirmed by jedi at the
call site, 0 lost; httpx, flask and typer unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8gwEpjajiWHVW3c9smuKf
(cherry picked from commit 56634ac22d)
One-line definitions (`def a(x), do: b(x)`) have no do_block node: the
body is the value of the `do:` pair in the arguments keywords node. The
extractor only registered do_block bodies for the call-walk pass, so every
call inside a keyword-form body was silently dropped from the graph. When
there is no do_block, take the `do:` pair value as the body and walk it
the same way (Fixes#4207).
AI assistance: implementation drafted with Muse (Anthropic), reviewed and
verified by the author.
Signed-off-by: Raphael Malikian <rtmalikian@gmail.com>
(cherry picked from commit b00d3704db)
A directory-qualified stem fallback could resolve an absolute import
(from werkzeug.wrappers import Request) onto a local wrappers.py, rewiring a
class to inherit from itself. Exclude the importing file's own stems; a
same-named module in another package still resolves by exact/suffix/bare stem.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Maintainers are seeing more AI-assisted pull requests. The rules for
them already exist across this guide (branch off v8, one concern per
PR, regression tests that fail before the fix, never hand-edit
generated skills, Co-Authored-By disclosure), but an agent or a new
contributor has to piece them together.
This adds a short "Working with AI Coding Agents" section under Pull
Requests that collects those existing rules in one place. It adds no
new policy. It also mentions ContribOS as an optional tool that reads
these rules and checks a branch against them; the maintainer agreed
to a PR for it on X.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit d47b311588)
_resolve_typescript_member_calls required a receiver's type name to be unique in the
corpus before applying the #2553 origin gate, so hono's `c: Context` (a class plus two
interfaces and a type named Context) never resolved, though the caller's
`import type { Context } from '../../context'` names the exact one. Apply the origin
gate to the candidates first and emit only when exactly one is visible; one definition
behaves as before, an undecided import still emits nothing.
_key() also folds `#name` into `name`, so `c.newResponse()` could bind to a private
`#newResponse()`. Skip a match whose leading `#` disagrees with the call.
Against the TypeScript compiler, 0.9.80 vs this change: hono obj.method() 20.3% -> 47.3%
(+23 edges), NestJS +5, TypeORM +3, all 31 confirmed at the call site, 0 lost.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8gwEpjajiWHVW3c9smuKf
(cherry picked from commit 7935593eca)
The TS receiver table behind `this.repo.findById()` held constructor parameter
properties, `new` locals and typed parameters, but no class fields, which is how most
NestJS, Angular and TypeORM classes hold their collaborators (`protected repo: Repo`,
`private logger = new Logger()`, `#root: Node`). Those calls got no edge.
_ts_receiver_type_table now records non-static class fields: `name: T`, `name?: T`,
`T | undefined` / `T | null`, an untyped `new T()` initializer, and `#private` names
keyed as the extractor records the receiver. Arrays, generics and two-class unions stay
untyped; the #2553 origin gate and first-binding-wins are unchanged. JS/TS files bypass
the AST cache, so no schema change.
Against the TypeScript compiler, 0.9.80 vs this change: NestJS this.x.method() 47.9% ->
77.0% (+102 edges), TypeORM 64.5% -> 79.6% (+91), all 193 confirmed at the call site,
0 lost; changed-files rebuild == full build.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8gwEpjajiWHVW3c9smuKf
(cherry picked from commit e5fb5e3b95)
AMBIGUOUS-tagged edges (confidence ≈ 0.35, often `conceptually_related_to`
from LLM semantic extraction) could glue otherwise-independent clusters
together through Leiden/Louvain modularity, and the resulting bridge
questions crowded `Suggested Questions` with low-signal entries. The
`AMBIGUOUS` flag was already correct — only downstream consumers
treated it identically to EXTRACTED/INFERRED.
Changes:
- `cluster()` gains `ambiguous_scale: float = 0.5` (overridable via
`GRAPHIFY_AMBIGUOUS_SCALE`). Multiplier is applied to AMBIGUOUS edge
`weight` in a graph copy before partitioning so the raw graph (and
`/graphify path`/`explain` output) stays untouched. 1.0 restores
legacy behavior.
- `suggest_questions()` tags each ambiguous_edge entry with
`low_confidence=True`. Interleave + top_n contract from #3849 is
preserved; the tag is purely metadata.
- `report.py` splits tagged entries into a new "Low-confidence Hints"
section below the main Suggested Questions.
Tests:
- `test_cluster_ambiguous_scale.py` (new, 7 tests): default scale
demotes AMBIGUOUS weight, scale=0.0 drops it entirely, scale=1.0
is no-op, env override + malformed env fail-soft, raw graph not
mutated, no copy when no AMBIGUOUS edges present.
- `test_suggest_questions_ambiguous.py` (new, 4 tests): tag present
on every ambiguous_edge entry, never on other types.
- `test_analyze.py::test_suggest_questions_diversity_preserves_limit_and_candidates`
relaxed to allow the optional `low_confidence` key.
Fixes#4199
---
한국어 요약: AMBIGUOUS edge (LLM 추론, 신뢰도 ~0.35) 가 Leiden 모듈러리티로 독립
클러스터를 묶거나 Suggested Questions top-N 을 잠식하던 문제 수정. cluster() 에
ambiguous_scale 파라미터 (default 0.5, env override) 추가, 원본 그래프는 복사 후
수정하여 /graphify path/explain 영향 없음. suggest_questions 는 low_confidence
플래그만 추가하고 interleave contract (#3849) 유지. report.py 가 분리 렌더링.
regression test 11 개 신규 + 기존 1 개 relax.
(cherry picked from commit ff52a29266)
Seven bugs of one shape (#4152, #4154, #4156, #4164, #4180, #4182, #4188) were each
found by diffing two builds of the same tree by hand: a checkout path leaking into an
id, a rebuild writing a different graph than the first build, CRLF ids differing from
LF, an edge to a node that is never declared. Each fix got its own narrow test, so the
next extractor or write-path change can bring the same class back unseen.
tests/test_graph_invariance.py builds the whole tests/fixtures corpus (every extractor)
through the real CLI, --no-cluster and clustered, and checks: two folders give the same
graph, the checkout path appears nowhere in graph.json, a rebuild of the unchanged tree
changes nothing, LF and CRLF give the same graph, and every raw edge endpoint is declared.
On 0.9.79 the three checks those fixes cover fail; on 0.9.80 all 12 pass in about 20 s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8gwEpjajiWHVW3c9smuKf
(cherry picked from commit 4293a638d4)
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)