The rebuild bodies read graphify-out/ and its .graphify_root relative to
the cwd, but git does not always start hooks at the top level. With
GIT_DIR exported and no GIT_WORK_TREE (a nested git call from another
hook or tool), git takes the caller's cwd as the work tree and
`git rev-parse --show-toplevel` echoes that subdirectory. A stray
graphify-out/ left there by an earlier run then roots the rebuild at the
subdirectory; for .claude/worktrees/ that walks every linked worktree.
The #3265 guard rejects a `..` marker, but its fallback is the same cwd.
Add a shared _TOPLEVEL_ANCHOR snippet right after _WORKTREE_GUARD in all
four hooks. In the standard layout it cds to the parent of the absolute
common .git dir; with GIT_WORK_TREE or a separate git dir it keeps git's
own --show-toplevel. GIT_DIR is rewritten to its absolute form so later
git calls still resolve. The block already runs in a subshell, so the cd
does not leak into chained hooks.
(cherry picked from commit e6193ab816)
An ObjC class whose @interface(.h) and @implementation(.m) live in
different repositories surfaces as two same-named candidate type nodes
that _merge_decl_def_classes folds within a repo but cannot across the
boundary (its id embeds the directory, #1556). The cross-repo member-call
pass then tripped the single-definition guard (len(candidates) != 1) and
dropped the call edge even though exactly one candidate DEFINES the
selector.
Scoped to lang == "objc": when more than one same-named candidate
survives, union their members and bind the selector to the sole candidate
that DEFINES it (a method node with definition_file set, or a .m/.mm
source). Two candidates that both define it are two unrelated classes and
stay ambiguous, so the guard still catches the collision it was meant to.
Other languages, the C# resolver path, and the single-candidate path are
unchanged.
Co-Authored-By: Claude Code <noreply@anthropic.com>
(cherry picked from commit a82a375c2d)
`extract --no-cluster` and the clustered path disagreed on where a
framework-style `#import <Kit/ToolEvent.h>` lands. The clustered path
re-pointed the edge at the header's real file node (`kit_event_toolevent`);
the raw path minted an external stub for the header's bare basename
(`toolevent`) and left the edge on it, so the two write paths of one
extractor produced different graphs for the same corpus.
The raw path only had a normalization map. Normalization cannot bridge
`toolevent` and `kit_event_toolevent` - they are not the same string
modulo case and punctuation - so re-pointing was impossible. The
clustered path resolves it through the pre-migration alias index, which
maps a file's old-stem spellings onto its canonical id (#1504/#1509).
Move that index into a shared `_alias_index` helper and use it in both
paths, so the clustered and the raw write path resolve an endpoint the
same way. The raw path now re-points endpoints BEFORE it mints stubs, so
an import that names a real file is no longer frozen onto a stub minted
for its unresolved spelling; only a genuinely external import
(`<Foundation/Foundation.h>`) still mints one (#2873).
An alias is committed only when exactly one node claims it, so two files
sharing a bare stem stay ambiguous and the import keeps its stub rather
than binding to whichever same-stem file was seen first.
Tests: two unit tests over `finalize_raw_graph_endpoints` (an old-stem
alias resolves to the real file node; an ambiguous alias stays
unresolved) and one end-to-end test that `extract --no-cluster` and the
clustered path agree on the import target. The new tests fail without
this change.
(cherry picked from commit acbec5f5c3)
Pins the resolver against the node shapes #2032 and the rationale pass
produce, plus end-to-end runs of the issue's repro and the comment
control that was never broken.
(cherry picked from commit 3b04706956)
A path query matches every node in the file, so resolution ends in
_prefer_file_node. Two of its passes compare the label to the bare
basename, which #2032's disambiguation rewrote to a unique path suffix
(alpha/hooks.py); the third pass trusts L1 uniqueness, which a module
docstring (a rationale node on line 1) or a one-line top-level
definition also occupies.
Accept a label that names its own source_file, using the
_is_file_node_label predicate build/analyze already share. Runs before
the L1 fallback, so extra L1 nodes no longer decide the result.
(cherry picked from commit b80626ed3b)
Three fixes on top of #4706's YAML extractor:
- Multi-document YAML (`---` separators) returned zero nodes because it used
yaml.safe_load, which accepts only one document; k8s manifests, Helm output,
and CI pipelines (the headline use cases) were silently empty. Use
safe_load_all and give each document a distinct node (labelled by kind+name
for k8s) so two docs in one file do not merge their keys.
- Add a _is_config_yaml gate mirroring extract_json's _is_config_json: a
top-level data array, an OpenAPI/Swagger spec, a JSON-schema-in-YAML, and a
flat all-scalar bundle (i18n) are skipped as data instead of minting up to
500 orphan nodes. A mapping with any nested value, a recognized config name,
or a .github/workflows path is still walked.
- Bump _AST_CACHE_SCHEMA 22 -> 23 (the PR reclassifies .yaml/.yml and adds a
new extractor, so cached YAML must re-extract).
- Tests: multi-doc, data/OpenAPI/array skip, the safe_load ACE guard, and caps.
- Clearer missing-PyYAML hint (pip install pyyaml).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The rcfile language table maps 'markdown' -> .md; 'md' is not a recognized
alias, so the override was ignored and the watch test never saw a document.
(cherry picked from commit c1890f376a)
- Add tests/test_yaml_extraction.py (classification, key nodes, contains edges,
stable ids, scalar/empty documents).
- Drop .yaml/.yml from the oversized-document slicing parametrization: they are
code now, not documents sent to the semantic pass.
- Point the language-override lifecycle test at .md so it still exercises a
document override (yaml is no longer a document).
(cherry picked from commit b69b32e2f1)
YAML files were classified as documents, so code-only / AST-only runs
(graphify update) produced zero nodes from them. Move .yaml/.yml into the
code path like .json: add a PyYAML-based structural extractor (extract_yaml)
registered in _DISPATCH, add the suffixes to CODE_EXTENSIONS, drop them from
DOC_EXTENSIONS and the splittable-text set.
Deterministic ids derived from the file path; top-level and nested keys become
nodes with contains edges, capped at 500 pairs / depth 6 to avoid exploding on
data-heavy YAML.
(cherry picked from commit 2e5565a684)
Adds a regression test that fails on the pre-fix resolver: a caller that
imports a module for a different symbol and calls a same-named function must
keep the edge INFERRED, never EXTRACTED.
(cherry picked from commit 7d41be8891)
When an ambiguous bare call resolves to a candidate solely because the
caller imported the candidate's source module (not the symbol itself), the
edge was promoted to EXTRACTED, falsely certifying a same-named symbol that
was never actually imported. Keep the resolution (for graph connectivity) but
mark it INFERRED. Symbol-level (from X import Y) imports still promote to
EXTRACTED.
(cherry picked from commit e73ca4e4a7)
The stub rewire skipped dotted labels, but a SQL table is schema.name,
so dotted labels from .sql files now count as definitions. SQL stubs
that share a normalized name and bind to nothing fold into one node
(the smallest id, so file order doesn't matter). .sql joins the
language families, so a SQL read no longer binds to a same-named
definition in another language.
(cherry picked from commit 55bc540bbe)
A table no file defines, read from N files, becomes N per-file stub
nodes, and a schema-qualified read never binds to its definition in
another file. A SQL read of `Orders` also binds to a Python class
Orders. Guards cover same-file binding and different schemas.
(cherry picked from commit 1dfc710497)
GO is a client-tool command, not SQL, and tree-sitter-sql has no rule
for it. Before parsing, each GO [n] line found on the masked text
(comments and strings blanked) becomes `;` plus spaces, so byte
offsets and line numbers don't move. A go line after a comma, or
before a comma or `)`, is a column and stays.
AST cache schema 20 -> 21 so cached .sql extractions re-run.
(cherry picked from commit 2293c31d79)
A GO line with no `;` before it is read as an identifier, so the next
statement lands in an ERROR node and its table is lost. Guards cover
`;`-terminated batches, GO inside a block comment, a column named go,
and GO after a keyword line such as SET NOCOUNT ON.
(cherry picked from commit f00ecc42c9)
tree-sitter-sql has no T-SQL bracket identifiers and no create_procedure
node, so bracket names were mangled and procedures only got a node from
the regex recovery, never an edge. Labels and lookup keys now drop the
brackets (ids are unchanged). Each recovered routine's body, up to the
next CREATE/ALTER, a GO line or EOF, is scanned on the masked text:
FROM/JOIN give reads_from, and INSERT/UPDATE/DELETE/MERGE/SELECT INTO
give writes_to. Alias updates resolve to their table; CTEs, variables
and temp tables are skipped.
AST cache schema 20 -> 21 so cached .sql extractions re-run.
(cherry picked from commit 70f521dba9)
Bracket names like [dbo].[Orders] come out as the label `dbo].[Orders`,
stored procedures get no reads_from edges in any dialect, and
INSERT/UPDATE/DELETE/MERGE make no edge. Guards cover aliases, CTEs,
temp tables, variables, comments, strings and body boundaries.
(cherry picked from commit a2a666c6d7)
The landed docstring listed only the get_node/get_neighbors MCP tools,
but the CLI `explain` command also routes node lookup through this
resolver (cli.py), so a substring-only near-miss now refuses there too.
Name explain in the docstring so the shared surface is accurate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`get_node` / `get_neighbors` share `_resolve_single_node`, which takes the
first node of the winning tier. The substring tier matches a query anywhere
inside a label, so `get_node(label="MailController")` on a codebase with no
`MailController` returned `EmailController` as if it were the exact symbol,
with no hint that the name does not exist.
The resolver now splits the two fuzzy tiers by intent. A prefix match still
resolves, because the caller started typing the real name (`email` ->
`EmailController`). A substring-only match is a search hit, not the symbol
the caller named, so it returns `No exact match for '<query>'` followed by
up to five closest names with their source location, plus a `path::symbol`
retry example. `get_neighbors` inherits the same answer through the shared
resolver, so both tools report one lookup the same way (ADR-0001).
Exact, source-exact and prefix resolution, the ambiguity refusals, the
dotted/Python and `path::symbol` forms and scoring are unchanged. The other
near-miss case in the issue (`get_node(label="edit")` with several `.edit()`
methods) already refused as Ambiguous under ADR-0001; tests cover both.
Tests: the issue's MailController repro, prefix shorthand, exact-over-substring
precedence, the five-candidate cap.
(cherry picked from commit d3875f4f1c)
A cross-repo `calls` edge took its `source_file` from the caller node
(an ObjC method node is minted at its `@interface` in the `.h`) while its
`source_location` is the call-site line, recorded in the `.m`. Pairing the
two pointed `source_file:source_location` into the wrong file — often past
the end of a short header.
The park payload now carries the call-site file alongside the line, and
`link_cross_repo_member_calls` prefers it, falling back to the caller
node's file for pre-#3152 graphs. The parked path is relativized in the
same extract-time pass that relativizes node source_files, so it never
leaks the absolute checkout path into graph.json.
Co-Authored-By: Claude Code <noreply@anthropic.com>
(cherry picked from commit 3281d42492)
The #2543 heal re-queues any manifest-stamped code file that has a
registered extractor but zero nodes in graph.json, treating it as a
poisoned stamp. A file the extractor declines by design (data JSON such
as .xcassets Contents.json, #1224/#2879) is exactly that shape — stamped,
zero nodes — yet legitimate, so the heal re-queued it on every incremental
run forever.
extract() now surfaces the declined files (skipped_sources), save_manifest
marks their row ast_empty: true (preserved across unchanged incrementals,
dropped once a file starts producing nodes), and the heal skips rows
carrying the marker. A pre-fix manifest has no marker, so such a file is
re-queued exactly once, re-stamped with the marker, and left alone after.
Co-Authored-By: Claude Code <noreply@anthropic.com>
(cherry picked from commit c930591f08)
`_merge_decl_def_classes` folds an @interface/@implementation pair into one node
only when both live in the SAME directory (the id embeds the directory path,
#1556). A class whose `.h` sits in `include/` and `.m` in `src/` therefore mints
two same-labelled nodes that never merge, so `[w render]` saw two type-def
candidates, tripped the member-call resolver's single-definition god-node guard
(`if len(type_defs) != 1: continue`), and emitted NO edge -- the same corpus
resolved fine when the pair shared a directory. Header/impl directory splits are
common layout, so this hit ordinary code.
Replace the three per-arm god-node bails with a shared `_resolve_member_target`
that unions the candidates' members and binds the selector to the sole candidate
that DEFINES it (an @implementation in `.m`/`.mm`, or a merged node carrying
`definition_file`). A header-only node is a bare declaration and does not count,
so the split pair resolves to its one real definition. Two unrelated same-named
classes that each define the selector still yield two definitions and stay
ambiguous -- the guard holds for the genuinely-ambiguous case it was meant for.
Co-Authored-By: Claude Code <noreply@anthropic.com>
(cherry picked from commit 510934c686)
The review bot flagged a real hazard in the owner-evidence bookkeeping: a
mention whose `references` edge is already in the graph returned early, so
the document never recorded that it cites the target. A later bare method
mention in the same document then lost its owner and was dropped.
`_add` now records the cited target before it decides whether the edge has
to be written, so the evidence survives an edge that is already there.
The pipeline cannot reach that state today (the resolver is handed a fresh
edge list, and the incremental context only carries contains/method/
inherits/imports edges), so this is hardening rather than a fix for a
reachable failure. The regression test drives the resolver directly with a
pre-existing class edge.
(cherry picked from commit 37efe877cf)
`resolve_markdown_mentions` linked any bare, parenthesis-less code span to
the corpus's single `_callable` carrying that label, and methods qualify.
A prose word in a code span (`redis` the server, `data`, `message`) then
became `heading --references--> SomeClass.word()` at INFERRED 0.95, on a
method that has nothing to do with the sentence. On the reporter's corpus
44 of 1,906 mention edges were words of this kind.
A bare single-name span is now resolved in three passes. Explicit evidence
(a cited path, a dotted qualifier chain, or a trailing `()`) resolves as
before. Bare mentions that do not name a method keep resolving, and their
targets are recorded per document. A bare mention that names a method is
kept only when its owner class is already cited in the same document, so
`redis` no longer links to `Cache.redis` while `successWithWarnings` next
to `HandlerResult` still does. Ambiguity, built-ins and the case-sensitive
label match are unchanged.
Tests cover the issue repro, the owner-cited case, the `()` case, a free
function, and the cross-document guard.
(cherry picked from commit 6bba91fc65)
#4655 added amp/copilot/kilo/kimi to the hand-maintained --help install
line but still omitted antigravity-windows, so it disagreed with
_print_install_usage. Derive the list from the same
[*_PLATFORM_CONFIG, "gemini", "cursor"] expression the install usage
uses so the two cannot drift, and add test_help_lists_every_install_platform
to catch any future platform that is not advertised.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The top-level help left out the `amp install` / `amp uninstall`
subcommands, and its `install --platform` list omitted copilot, kilo and
kimi, although all of them are accepted (`_PLATFORM_CONFIG`) and
documented in the README platform table. `graphify install` with a bad
platform already prints the full list, derived from `_PLATFORM_CONFIG`;
only the hand-written banner had drifted.
(cherry picked from commit 6480046271)
An ObjC category (`@interface Foo (Cat)` in `Foo+Cat.h`) keys its class
node and methods off the base stem `Foo`, so they fold into `Foo.h`'s
class (#1556). extract() turns an id minted from an absolute input path
into the repo-relative form only when it starts with its own file's stem
prefix (`.../Foo+Cat`), so these ids kept the absolute form. A category
on a class outside the corpus (`NSString (Extra)`) got a node id with
the checkout path in it, and so did the methods of a category on an
in-corpus class. graph.json then carried the path (#502), two checkouts
of one repo gave different graphs, and an incremental rebuild in a moved
or copied checkout added duplicate nodes.
The remap now also registers a category file's base-stem prefix, after
the file's own prefixes. On SDWebImage, AFNetworking and Kingfisher,
334 / 51 / 17 ids carried the checkout path; now 0, two checkouts give
identical graphs, and absolute and relative input agree. Every edge,
compared by label and file, is unchanged. Through the CLI, SDWebImage's
graph drops 125 duplicate category nodes (a leaked copy beside the clean
copy reached through its include/ symlink) and one false reference.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 11fe66a3cf)
`--transport http --host ::1` put the bare literal into the DNS-rebinding
allowlist (`::1`, `::1:8080`), but clients reaching http://[::1]:8080
send `Host: [::1]:8080`, so every request was rejected with 421 and the
server was unusable on an IPv6 loopback bind. Bracket an IPv6 literal
before building the allowlist; foreign Host headers are still rejected.
(cherry picked from commit 831b25703c)
Binary captures pick their filename with lstat so they never replace an
existing directory entry (#4393). Text captures (webpages, tweets, arXiv)
still used Path.exists(), which follows symlinks: a dangling symlink named
like the capture reported "free" and write_text() followed it out of the
corpus directory. The loop also stopped at 999 numbered names and then
overwrote the last one. Share the lstat-based selection between both
paths.
(cherry picked from commit 3e483ca077)
The claude and gemini hooks (since #522/#1987) double-quote a resolved graphify path containing spaces, but _install_codex_hook embedded the resolver output raw. On a checkout path like 'D:/Project V2/.venv/Scripts/graphify.EXE', every shell that runs the hook (Git Bash on Windows to cmd.exe to POSIX sh) word-splits the command at the first space: the guard can never fire, and #2165's subcommand-parsing test splits at the same space and fails.
Mirror _claude_pretooluse_hooks/_gemini_hook with the same two-line guard: bare-name (project-scoped, #3129) installs are unaffected, as is any absolute path without spaces. test_codex_hook_command_is_a_real_cli_subcommand now parses hook commands with shlex.split instead of naive .split(), and a new regression test installs all three hosts under a spaced resolver output, proves argv[0] survives intact, and re-verifies uninstall recognition of the quoted entry.
(cherry picked from commit 87e997d3a0)
#4634 suppressed the module-scope node for a require binding so it no
longer shadows the required class. But its guard used
_find_require_call(value), which matches ANY identifier(...) call, so it
also dropped real `const x = factory()` nodes (e.g. express(), NextAuth(),
wrapper()) and regressed #2604/#2552 (10 test failures). Add
_js_value_is_require, which name-checks the callee is literally `require`
the same way _require_imports_js already does, and gate the binding on it.
Bump _AST_CACHE_SCHEMA 20 -> 21: the require-binding change alters
extraction output (drops the shadow node, redirects the calls edge).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A \const X = require('./m')\ binding is an import, not a module-scope data
binding. The node it produced in the importing file shadowed the class the
required file exports, so \
ew X()\ bound to the local node and the
required class got no incoming edge.
(cherry picked from commit 66bb69114a)
Covers the issue's own repro, a delegating wrapper that no longer
self loops and an unrelated same named sibling that no longer steals
the edge, plus three controls: a bare unqualified call still
resolves by name, this dot calls still bind through the caller's own
class, and a TypeScript typed receiver still resolves through its
declared type. A fourth control checks that a factory object's own
sibling methods still call each other, the one legitimate case that
keeps resolving locally instead of deferring.
(cherry picked from commit 49c645d2ff)
A member call whose receiver is neither this nor self reached the
per file bare name map, the same way an unqualified call does. A
delegating wrapper like history.appendFrame(entry) inside a function
also named appendFrame then bound to itself, an EXTRACTED self loop,
and the same map could just as easily bind it to any other same
named function in the file instead of the receiver's own method.
Every such member call now defers, exactly like it already does for
Python, Java and C#. The deferred call still reaches the receiver
typed resolver, which binds it correctly when the receiver's type is
known, and otherwise leaves it unresolved rather than guessing. A
plain call with no receiver, and this dot calls resolved through the
caller's own class, are both untouched.
One case keeps resolving locally rather than through that resolver,
since the resolver only sees whole file state and could still bind
it to a same named member on an unrelated object: a method assigned
onto a local factory object calling a sibling through that very
object, such as store.get() from inside store.set. The receiver name
there reconstructs the caller's own owner id exactly, so it is read
straight off the owner rather than guessed.
(cherry picked from commit 24b1f28fd4)
_parse_pom collected every <dependency> in the file with
".//dependencies/dependency", including <dependencyManagement> entries,
which only pin versions, and the <dependencies> of build plugins. Each
became a depends_on edge, so a parent POM that manages its modules'
versions depended on every module.
Read <dependencies> under <project> and under <profiles><profile>, where
Maven takes a project's dependencies from.
(cherry picked from commit 534177d1ea)