Covers #4578/#4579/#4580/#4581 (Python/Go/C++/Rust aliases + Rust union),
#4591 (#4584, C# overload nodes), and #4572 (#4569, package.json dep link).
Each PR shipped a stale 20->21 bump against an older base; this is the single
reconciled bump on top of HEAD's 24.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Applied from PR #4572 (authorship preserved); stale cache-schema bump dropped for one combined bump.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Applied from PR #4591 (authorship preserved); stale cache-schema bump dropped for one combined bump.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Applied from PR #4581 (authorship preserved); stale cache-schema bump dropped for one combined bump.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Applied from PR #4580 (authorship preserved); stale cache-schema bump dropped for one combined bump.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Applied from PR #4579 (authorship preserved); stale cache-schema bump dropped for one combined bump.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Applied from PR #4578 (authorship preserved); the PR's stale cache-schema
bump is dropped in favour of one combined bump for the alias cluster.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Under a deep root, the cache entry
<root>/graphify-out/cache/ast/v<ver>-s<schema>-g<grammars>/<64 hex>.json,
and mkstemp's even longer <hash>.<random>.tmp temp name, overrun Windows
MAX_PATH. save_cached raised FileNotFoundError and `graphify extract`
failed with "AST extraction failed" (e.g. any project under the default
%TEMP% of a long user name).
Move detect._os_path (the \?\ helper from #1655) to paths.os_long_path
so cache.py can share it, and use it for the entry and temp file in
save_cached and load_cached. detect._os_path stays as an alias. Keys are
unaffected: _normalize_path still strips the prefix.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit abf9c9f6ab)
The 0.9.95 YAML structural extractor (#4706) imports PyYAML, but it was
only a transitive dependency, so a fresh `pip install graphifyy` produced
zero nodes from .yaml/.yml files (the extractor degrades gracefully but
silently). manifest_ingest and markdown also use it via guarded imports.
Declare it like tree-sitter-json so the feature works out of the box.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Bump _AST_CACHE_SCHEMA 23 -> 24: #4705 (ObjC NS_CLOSED_ENUM) and #4677 (SQL
CREATE TABLE ERROR-node recovery) both change extraction output, and neither
PR included the bump, so a warm ObjC/SQL cache would serve stale results.
- #4677: display the recovered table label through _clean_ident (consistent
with the routine/trigger recoveries) while keeping the id keyed off the raw
name so FK edges still resolve to it.
- Add regression tests the #4708/#4709/#4710 security PRs shipped without
(multicast/unspecified SSRF block, Obsidian tag injection, Google shortcut
frontmatter escaping).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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)