Follow-ups from the automated review of the first commit:
- Two same-named nested types under different outers in one file share one
node id. That node took the first declaration's type_path and could join
the other outer's halves; it now gets no type_path and is not merged.
- The shadowed-base step drops only a placeholder spelled with the bare
name. A base written qualified (Other.IFoo), or through a using alias of
that name, can be a different type that only shares the simple name, and
keeps its edge.
- Pin that receiver-qualified calls (base.M(), this.M(), Type.M(), obj.M())
keep their targets: they leave the cross-file pass as member calls before
the own-type binding.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 37ec971f16)
The partial merge grouped halves by (namespace, label) and skipped nested
types, because nested ids omit the enclosing type. A nested partial type split
across files stayed split, and `Box` / `Box<T>` partials were folded into one
node although they are distinct types (#4249).
- The C# extractor records each partial type's path inside its namespace,
enclosing types first, each with its arity (`Outer`1.Inner`), as
metadata.type_path; the merge keys on (assembly, namespace, type_path).
- The cross-file call pass binds a C# unqualified call to the one candidate
that is a member of the caller's own type, so a call into another half of a
partial type is not lost to a same-named method elsewhere (#4252 does this
in-file only). Ties fall through unchanged.
- After C# type resolution, a merged partial type drops a placeholder base
that sits next to a real base of the same name; one half resolving a sibling
nested base and the other not left two bases, and base-chain lookups failed.
- _AST_CACHE_SCHEMA 8 -> 9, so cached C# results without type_path are
re-extracted.
Fixes#4363.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 92d9197787)
engine.py's defer guard only deferred a scoped_call_expression into raw_calls
when member_receiver[:1].isupper() (or is_this_field_call/_csharp_defer), so
self::, static::, parent::, $var::, and lowercase-first class receivers fell
through to the pre-existing per-file bare-name path instead of reaching
_resolve_php_member_calls. That path is last-write-wins across the whole file
and can mis-bind a same-named method on an unrelated class.
Tag scoped_call_expression with is_scoped_call and add it to the defer
condition unconditionally, so every `::` call is deferred to raw_calls and
resolved by the dedicated resolver regardless of receiver casing or sigil.
Also tighten the self::/static:: and $var:: fixtures with a same-named decoy
method on another class, so they actually prove enclosing-class scoping
and no-fabrication instead of passing on a bare-name coincidence.
Addresses review feedback on PR #3874.
(cherry picked from commit 801c2a2dfd)
Addresses 3 findings from the automated review on PR #3874:
- Fully-qualified scopes (\App\Models\User::method() or
App\Models\User::method()) were left parked as unresolved: the class
lookup keyed on the full scope text, which never matches a class
node's own unqualified label. Now normalizes to the last namespace
segment before lookup.
- Method names were matched case-sensitively even though PHP method
calls are case-insensitive; a call spelled with different case than
the declaration silently produced no edge. The lookup key is now
lowercased on both sides.
- A lowercase-first class name (legal PHP, just non-PSR) fell through
the `receiver[:1].isupper()` gate into "dynamic scope" and was
dropped entirely. The gate is now `not receiver.startswith("$")`,
since a `$var::` scope is the only case actually requiring the
declared-type table this resolver doesn't have.
(cherry picked from commit 7ed0e1d70a)
scoped_call_expression (`Helper::format()`) used the scope text as the
callee name instead of the method name, so the shared cross-file
bare-name pass bound the call to whatever node shared the class's
label - almost always the class definition - never the actual method.
It also skipped member-call handling entirely, so the edge went
through the import-evidence confidence gate: same-namespace PHP
classes never need a `use` import for each other, so every
same-namespace static call was stuck at INFERRED 0.85 even though the
receiver class is named explicitly in source.
Capture the real method name as the callee and the scope as the
receiver, mark it a member call, and add a dedicated PHP resolver
(mirroring the existing Java/Swift/C++ member-call resolvers) that
binds self::/static:: to the enclosing class, parent:: through a
single inherits edge, and Class:: to the class's own method - all
EXTRACTED, since the receiver is named explicitly in source. A
dynamic scope ($var::method()) has no declared-type table to resolve
against and is left unresolved rather than guessed.
Fixes#3872
(cherry picked from commit 2ec460168b)
extract() uses cache_root as the fallback anchor for source_file and node ids
when root is not given (#1941). Part A called it with cache_root alone. That is
harmless while the cache sits in the scan root, but the two parameters look
alike and the block gets adapted: pointing only the cache somewhere else
re-anchored every code node. Same node count, every id renamed, every cached
edge into code left dangling - the node-count shrink guard cannot see it.
Pass root=Path('INPUT_PATH') next to cache_root, as the CLI already does, and
say in the block which of the two anchors ids. The rendered call is behaviour
preserving as written; it only stops being a trap when the cache is relocated.
The aider/devin monoliths call extract(code_files) with neither argument and
are left untouched.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit efe15d792c)
Use GRAPHIFY_CLAUDE_CLI_MODEL for plain Claude CLI completions when callers do not pass an explicit model. Preserve explicit-model precedence and the CLI default when the setting is absent.
AI assistance: OpenAI Codex was used to help implement and validate this change.
(cherry picked from commit 109107e407)
An auto-minted external node is rebuilt from import edges. Dropping one
while code grows is not a lost rationale node, so the update writes
without --force.
graphify update accepts any new node list that is at least as long as
the saved one, and a rebuilt code file was treated as a reason to drop
the rationale node on that file. A deleted code file must still write.
The #1116 fixture nodes are marked AST so a removed code symbol stays a
code deletion.
A larger total used to make to_json accept a write that dropped a saved
rationale id. The new stub keeps the semantic count flat, so a count
check would miss the same loss.
Review finding: with the `#` guard gone, `c.run()` on a class that declares only
`#run` (its public `run` inherited from a base) landed on the private method. The
extractor now marks a member node `_ts_public_twin` when its class declares both
`#name` and a public `name` (method or property, e.g. hono's `newResponse` arrow
property), and the resolver only lets a public call reach a `#`-labeled node that
carries the mark. The mark is on the node so a changed-files rebuild, which reads
unchanged files from graph.json, still sees it (forwarded in watch.py and cli.py).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8gwEpjajiWHVW3c9smuKf
The extractor gives a class's `foo()` and `#foo()` the same node id, labeled after
whichever is declared first. Skipping a `c.foo()` match whose label starts with `#`
therefore dropped a real edge when the private member came first (hono's
`c.newResponse()`, whose public arrow property has no node of its own). A call
written `c.foo()` can only mean a public member, so keep the edge to that node.
Only the first commit of #4233 shipped in 0.9.81; this is its second one.
Covered for both declaration orders. vs 0.9.84: hono +3 edges, nothing removed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8gwEpjajiWHVW3c9smuKf
The #3829 fixture hardcoded updatedAt, which crosses the 14-day staleness
window over time and flips the asserted status from APPROVED to STALE. Compute a
recent date instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The functor branch picked the type from the typed expression's identifier and
parametrized-type children. A module-qualified type (`Mod.Box`) is a
`field_expression`, so `(b::Mod.Box)(x)` fell back to the variable `b`, and
`(::Mod.Box)(x)` / `(b::Mod.Box{T})(x)` were dropped. Read the last child (the
type) through a new `_functor_type_name`, which unwraps parametric and
qualified names and returns None for anything else.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`(b::Box{T})(x)` puts the type in a `parametrized_type_expression`, so taking
the last identifier in the typed expression picked the variable `b` and
emitted a bogus `b#call` node, split from `Box#call`. Read the last operand
through `_type_name_of`, which already unwraps parametric heads.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The incremental step dumped every file path into the agent context even
though later steps read the sidecar from disk. Print a short summary
and leave the full result in .graphify_incremental.json.
(cherry picked from commit d23d1c618a)
_pick_seeds appended the per-term guaranteed seeds (#1445) in sorted(best_seed_by_term), alphabetical term order,
and the renderer prints seeds first, so a weak match on an early-alphabet term led a specific term's exact match.
_score_query now returns term_order: the per-term winners by their existing key (-singleton score, which carries
the term's IDF weight, then -degree, label length, node id), the term breaking ties. _query_graph_text passes it to
_pick_seeds. The same seeds are picked and the order is deterministic; without term_order the sorted order is kept.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 7bcbc60ac1)
#3110 makes the semantic tier carry _origin:"semantic" explicitly, so a real
graph.json's semantic nodes are no longer tier-guessed from the L-shape of
source_location. This fixture wrote a baseline graph with no _origin, which
backfilled to AST and mixed with the re-extracted (now semantic) nodes, so the
pair no longer merged and the shrink guard had nothing to catch. Stamp the
fixture nodes semantic to reflect what real post-#3110 graphs contain; the guard
correctly fires again.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
build._is_ast_tier reads _origin when present and otherwise guesses the tier
from the shape of source_location (L<line> means AST). extract() stamps its AST
items _origin:"ast", but nothing stamped the semantic side, so a semantic node
whose location happened to look like "L12" read as AST — and the next
`graphify update` deleted it along with the re-extracted code file it seemed to
belong to. Part C, extract_corpus_parallel, and the CLI merge now setdefault
_origin:"semantic" on every semantic node/edge before the merge, so a semantic
item is never mistaken for an AST one. AST items (stamped "ast") are untouched;
setdefault preserves any existing stamp.
Landed from PR #3110. Skill artifacts regenerated fresh from the updated
fragments (not the PR's stale committed blobs); all five skillgen validators
pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An ambiguous extension (`.inc`, `.h`, TS-family, ...) is hard-mapped to one
global default; when that is wrong the file does not error — it parses as the
wrong language and yields a few incidental nodes, so the graph looks populated
while the real symbols are missing. A project can now declare what an extension
means via `language.<ext>=<language|.ext>` lines in `.graphifyrc` (which already
holds `viz_node_limit`). The declaration flows through file classification,
extractor selection, grammar/resolution, imports, watch, and the AST cache
(salt + manifest language field + incremental invalidation), is thread-local
per scan root, and is forwarded to the worker pool. Default detection is
byte-for-byte unchanged when no declaration is present.
Landed from PR #3789 (squashed), which carried @abhay-codes07's original #3075
forward with reviewed follow-ups. Authorship credited to both contributors.
Co-Authored-By: Andre Brait <andrebrait@gmail.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The read/search nudge was re-injected on every qualifying Read/Glob/Grep/Bash
call with no dedup or session cap (issue #3435 measured ~8,348 injections,
~651k tokens, for 339 graphify calls), and an agent that just ran
`graphify query` was still nudged on its very next read. `_nudge_allowed`
now gates all three soft-nudge sites with a per-session + per-agent counter
(first nudge always fires; later ones suppressed while the query stamp is
fresh; hard cap via GRAPHIFY_HOOK_NUDGE_CAP, default 5), with 24h GC and
fail-open preserved. Subagents get independent budgets. The strict deny path
is unchanged.
Landed from PR #3443 (squashed). The README strict-mode note is omitted here
because that prose moved to docs.graphify.com in the README trim.
Co-Authored-By: Andre Brait <andrebrait@gmail.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
graph.json was pretty-printed (indent=2), so every PR that commits a
rebuilt graph reports tens of thousands of changed lines. GitHub's PR
line counts ignore .gitattributes (-diff, binary), so only the file's
content can fix it: one line of compact JSON is +1 -1.
Every writer of a project graph.json now emits separators=(",", ":")
with no indent: to_json, the `update` raw/no-cluster candidate text,
prune, merge-driver, merge-graphs, `extract --no-cluster` and the global
graph. Readers are unaffected.
`update` compares parsed data, not bytes, so an existing indented graph
would have been "unchanged" forever. It is now rewritten compact once,
then left alone.
Co-Authored-By: Claude <noreply@anthropic.com>
(cherry picked from commit 4c31235c37)
Give each binding of a use tree the import edge that v8 produces for
its equivalent plain use declaration. Preserve aliases, path comments,
separator whitespace, nested prefixes and self/glob binding behavior.
Keep the old group edge as call evidence until the call pass finishes;
exclude per-binding edges from that pass, then remove private markers.
Plain imports and all call edges retain their current-v8 behavior.
AST cache schema 6 -> 7 rejects results written before expansion.
Replace the earlier broad resolver in #4135 with this syntax-only fix.
The resolver is preserved on local branch wip/rust-use-resolver.
On current v8, all 30 new tests and the full 6642-test suite pass.
Fresh ripgrep builds have 864 import edges instead of 247, with every
call unchanged. Existing name-based import rewiring can still produce
wrong matches, just as it does for equivalent plain declarations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
(cherry picked from commit 2669ee4f11)
A committed graphify-out/graph.json is rewritten whole on every build, so
its pull-request diffs are large by design. Assistants reviewing such PRs
flagged them as churn and proposed skipping the rebuild or its hooks. The
Claude, AGENTS.md and Gemini blocks now say the diff is expected.
(cherry picked from commit e6cf17c171)
Persist validated Codex extraction responses as chunk files so the shared B3 merge preserves semantic nodes and edges.
Fail before cache and graph updates when extraction responses are missing or invalid.
Add regression coverage for chunk merging, cached reruns, and failure handling.
Fixes#4311
Co-authored-by: OpenAI Codex <codex@openai.com>
(cherry picked from commit da5850f0ac)
`case class Point(x: Int) derives Show, Eq` (and the same on enums) now
emits a `references` edge with the `derives` context from the type to each
derived typeclass, next to the class-level context bounds it resembles. A
qualified name (`derives cats.Show`) links by its last segment.
Co-Authored-By: Grok Bot <noreply@x.ai>
(cherry picked from commit e6ccd59f20)
global add loaded graph.json with a plain undirected node_link_graph, so
every link whose target precedes its source in the node list was written
to ~/.graphify/global-graph.json reversed. Stamp _src/_tgt with
restore_arc_direction before the load, as the export loader and
load_node_link_graph do since #4088.
The external-node dedup rewired edge endpoints but not the markers, so a
deduplicated repo's markers named a node that no longer exists. Remap
_src/_tgt along with the endpoints.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 477848390e)
explain listed the 20 most connected neighbors and "... and N more" with
no option to see the rest, and the token-budget notices of query,
get_neighbors and get_community said "raise the token budget" without
the value that would return everything, so a caller retried with a guess.
explain gets --all, and its cut line names the total and the option.
The budget notices now give the smallest budget that returns the whole
output, computed with the same rule that does the cut.
Co-Authored-By: Claude <noreply@anthropic.com>
(cherry picked from commit 769184dc9a)
explain and query exited 0 when no node matched, the same status as an
answer, and path exited 1 for an ambiguous endpoint, a missing one and a
missing graph file alike. A script or an agent could not tell a refusal
from an answer without parsing the text.
explain, path and query now exit 3 when a name is ambiguous (the
candidates are listed) and 4 when no node matches. Messages and output
streams are unchanged; "no path found" between two existing nodes is
still an answer. The codes are documented in --help and the README.
Co-Authored-By: Claude <noreply@anthropic.com>
(cherry picked from commit 13dc2400e1)
Run from a subdirectory of the scan root, a root-relative node path is resolved
against the CWD, so the renamed node's key stops matching its edges' and the
edges stay on the survivor. Fails on v8; fixed in the next commit.
Co-authored-by: Gijs Mos <1526544+gmos@users.noreply.github.com>
(cherry picked from commit f3697420c4)
# Conflicts:
# tests/test_dedup.py
_norm built the dedup key with re.sub(r"[\W_]+", " ", ...). \w does not
match Unicode combining marks (Mn/Mc: vowel signs, viramas), so they were
deleted like punctuation: "हिंदी" became "ह द", and "कि", "की", "के" all
collapsed to "क", merging three distinct nodes in Pass 1.
Treat a character as part of a word when it is alphanumeric or a combining
mark; everything else still collapses to a single space. Output is
unchanged for strings without combining marks (checked against the old
implementation on ~190k random strings).
Known gaps: ZWJ/ZWNJ still act as separators; ids.normalize_id and the
dormant build.deduplicate_by_label keep the old recipe.
(cherry picked from commit ccd81ce818)
- Unwrap `::RetType` (alongside `where`) around a definition's call head, so
`function f(x)::Float64` is no longer dropped.
- Name method definitions via `_def_name`: `Base.show`, `Base.:+`, bare
operators, and functors `(c::Circle)(x)`. The id uses the bare name so all
methods of a generic function share one node; the label keeps the qualifier.
- Emit `calls` edges for broadcast calls (`f.(xs)`) and pipes (`x |> f`, `.|>`).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit cf3648e762)
Some OpenAI-compatible gateways return a usage object whose token-count
fields are null. Reading them verbatim left None in result["input_tokens"] /
result["output_tokens"], which crashed extraction for the whole chunk
(TypeError: '<' not supported between 'NoneType' and 'int' in the ollama
low-token warning; None also propagated through the per-chunk merge).
Coerce null counts to 0 at read time in _call_openai_compat, _call_azure,
_call_claude, and _call_bedrock, matching the existing `or 0` idiom already
used on the claude-cli path.
Fixes#4325
Signed-off-by: Raphael Malikian <rtmalikian@gmail.com>
(cherry picked from commit 640005fdb7)
A set-but-empty GRAPHIFY_OUT (a blank .env / docker-compose / CI entry)
or GRAPHIFY_OUT=. made the purge target <project>/"" == the project
itself, and rmtree deleted the whole project.
- paths: treat a blank GRAPHIFY_OUT as unset; "." / ".." fall back to
graphify-out with a warning. GRAPHIFY_OUT_NAME and out_path() follow.
- install: before any uninstaller runs, refuse --purge (exit 1, nothing
changed) when the target is the project, a parent of it, the home
folder or a filesystem root. This also covers an absolute GRAPHIFY_OUT.
- hooks: the rebuild bodies resolve the output dir via graphify.paths
instead of reading the env var directly.
Fixes#4328
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 7d6befbb9c)
_collect_js_symbol_resolution_facts walked every parsed tree in full four
times, and each pass kept only a few node types.
The first pass now walks once and keeps the nodes of the six types the
whole-tree passes filter on, in walk order. Later passes iterate that
list. Each fact list is filled by a single pass, so fact order does not
change.
_walk_js_tree now uses a TreeCursor instead of reading .children at every
node. A cursor is bounded by its start node, so the walks that start
inside a tree (function bodies, and the import and export nodes passed to
_js_named_specifiers) stay inside that subtree.
Overlaps #3353, which also indexes JS nodes once and frees each tree
after use.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 243deb39e7)