The split-host runbook told the agent to skip to Part C when every doc,
paper and image hit the semantic cache. Part C reads
.graphify_semantic.json unconditionally and only the Step B3 merge writes
it, so a second run on an unchanged mixed corpus failed with
FileNotFoundError; writing an empty file instead dropped every cached node.
The all-cached case now skips B1/B2 but still runs B3. Step B0 also clears
.graphify_chunk_*.json before dispatch: B3 merges every chunk on disk, and a
leftover from an interrupted run would otherwise be merged as fresh.
The aider and devin monoliths keep the old sentence; they are pinned to the
v8 baseline by --monolith-roundtrip.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit bab1bb2775)
Pass dedup=False into graph construction so repeated labels do not collapse distinct non-AST identities. Preserve existing AST and document-file twin reconciliation. Fixes#4019.
Assisted-by: OpenAI Codex (GPT-6)
(cherry picked from commit 1f3805f16e)
The call walker only visited `command` nodes, so every method invocation
was dropped from the call graph. A method call parses as an
invokation_expression (callee in the member_name child, args in
argument_list); a bare property read ($this.Name) is a separate
member_access node and correctly produces no call.
Handle invokation_expression and resolve the member name against a
method-only label map so a member call binds to a method, never to a free
function that merely shares the name (fail-closed on unknown receivers).
Fixes#3992
(cherry picked from commit 1e6a9029e4)
graph.json is written "directed": false and carries direction in arc order
(#563), or in _src/_tgt markers on legacy files (#2309). A plain undirected
node_link_graph load re-orders endpoints by node-list position, so every
link whose target precedes its source in the node list came back reversed
(79% of links on graphify's own graph).
query, path/explain, affected, merge-graphs and the MCP server already
restore direction. The remaining reloaders did not:
- export html / neo4j / falkordb (cypher.txt and --push) / obsidian canvas
- graph.html re-rendered by update, watch and the git hooks
- the git merge driver, which wrote the reversal into the committed
graph.json, even when merging three identical copies
Add paths.restore_arc_direction, the inline idiom query and merge-graphs
already use, and apply it in load_node_link_graph and the export loader.
to_cypher, to_canvas and push_to_neo4j/falkordb now read _src/_tgt as
to_html already does. The merge driver writes direction back into arc
order before saving, as to_json does, so the committed file keeps the
post-#563 convention.
global add has the same bug and is fixed separately.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 5a461c99d7)
Preserve subsequent script blocks and byte offsets when a script ends in a line comment. Fixes#4072.
Co-authored-by: Codex <noreply@openai.com>
(cherry picked from commit fc48f659c4)
#3976 made recv?.M() resolve through the receiver-typed path but only
tested field receivers. Add a test that `this?.Save()` inside a class
resolves to the enclosing class's Save() like `this.Save()`, and not to
an unrelated class's Save().
Fixes#4077
(cherry picked from commit 6ce8348026)
A 0-byte file has no line 1, so the file node's source_location=L1 was an
anchor that can never resolve. Leave it None (how other location-less file
nodes already look) in the shared tree-sitter path, and keep the node: an
empty __init__.py is still a module other files import.
Closes#4041
Co-Authored-By: Grok Bot <noreply@x.ai>
(cherry picked from commit 29597adec8)
`class Foo[A: Ordering]` (and the same on case classes, traits and enums)
now emits a `references` edge with the `type_bound` context from the type
to each bound, reusing the method-level path from #2046.
Closes#4080
Co-Authored-By: Grok Bot <noreply@x.ai>
(cherry picked from commit d1257efb7d)
#4027 taught the Elixir extractor defmacro, defmacrop, defguard and
defguardp, but the regression test only exercised the first three. Add a
defguardp clause to the fixture and assert its node is extracted, so
dropping defguardp from the extractor's keyword list fails this test.
Closes#4076
Co-Authored-By: Claude <noreply@anthropic.com>
(cherry picked from commit 345b66b01f)
#4050 emits a node + case_of edge per `union(enum)` variant but
deliberately does not recurse into a variant's nested-struct payload.
Extend the tagged-union test to pin that: `rectangle: struct { w, h }`
is minted as a variant, while its `w`/`h` fields are not, and
`rectangle` owns no case_of edges of its own.
Fixes#4074
Co-Authored-By: Claude <noreply@anthropic.com>
(cherry picked from commit 994bb2e534)
Enum extraction (#4056) is only tested with the plain form,
`TColor = (clRed, clGreen, clBlue)`. The valued form nests an explicit
ordinal under declEnumValue -> defaultValue, beside the value's identifier,
so a change to how the value's name is read could leak the literal into the
label, or emit it as a node, without any test noticing.
Add a test for `TColor = (clRed = 1, clGreen = 2)` asserting the type node,
a `case_of` edge to each bare value name and to nothing else, and that
neither literal becomes a node.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 1b05d1593c)
Objective-C is an object-oriented extractor that already emits @interface,
@implementation and @protocol type nodes, but plain `enum` and
`typedef enum` definitions were dropped entirely: both the enum type and all
of its enumerators were invisible in the graph.
Handle `enum_specifier` (and the `typedef enum { ... } Name;` form, where the
name is the typedef alias), emitting a type node plus a node and `case_of` edge
per enumerator — the same shape every other language with enums uses (Java
#1719, C++ #3939, Swift). A nameless enum and a bodyless `enum X y;` reference
are skipped so no phantom node is minted.
NS_ENUM/NS_OPTIONS is deliberately out of scope: it is a macro call that
tree-sitter-objc parses into an ERROR subtree, so there is no clean AST to
read. Documented in the test.
Add regression tests for the plain, typedef, and skip cases.
(cherry picked from commit 2d30c413d3)
A trigger exists to run a routine, but the create_trigger branch only linked
the trigger to its ON table and ignored the EXECUTE FUNCTION/PROCEDURE clause
entirely. The routine a trigger fires — its whole reason to exist — was never
represented in the graph.
Register function/procedure node ids in a routine_nids map (mirroring
table_nids) and, in create_trigger, read the object_reference after EXECUTE and
emit an `executes` edge to that routine. Resolution is name-based against
routines defined in the same file (a trigger's function is declared before it);
when the routine is not in this file the edge is skipped rather than guessing a
cross-file target or minting a bare-name stub that could never rewire onto a
`name()` function node.
Add regression tests for the resolved case and the fail-closed case.
(cherry picked from commit ea39929459)
A PowerShell enum member is a discriminant case, not a declared field, so it
should hang off its enum with a case_of edge like every other language that
has enums (Java #1719, C#, Swift, Rust, VB.NET). PowerShell was routing enum
members through the same contains path used for class members.
The relation is not cosmetic: case_of targets are excluded from constructor
binding, so an enum member named like a type could previously be mistaken for
one.
Update the existing enum test to expect case_of and add a focused regression
test asserting enum members get case_of (and not contains) while the file
still contains the enum type via contains.
(cherry picked from commit 011dda9b0f)
A Solidity enum value is a discriminant case, not a declared member, so it
should hang off its enum with a case_of edge like every other language that
has enums (Java #1719, C#, Swift, Rust, VB.NET). Solidity was the lone
remaining outlier, routing enum values through the same contains path used
for struct fields.
The relation is not cosmetic: case_of targets are excluded from constructor
binding, so an enum value named like a type could previously be mistaken for
one. Struct fields keep contains, since a field is a real declaration.
Add a regression test asserting enum values get case_of (and not contains)
while a struct field keeps contains.
(cherry picked from commit fdd99502d7)
The top-of-README language switcher already lists every language; keep
the bottom section as a short pointer to docs/translations/ and the
PR-to-contribute call, instead of duplicating the full table.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
List all 32 README languages in a dedicated Translations section (a
#translations anchor), like other large OSS READMEs, with a pointer to
docs/translations/ for improving or adding one.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The PyPI page listed the GitHub repo as both Homepage and Repository, so
nothing on PyPI linked to the project's website or docs. Homepage is now
graphify.com, docs.graphify.com is listed as Documentation, and Repository
still points at GitHub.
(cherry picked from commit 25a73bcaa6)
Follow-ups on #3975 and #4043:
- the PHP language-construct set cited _GO_PREDECLARED_FUNCS, which does
not exist; the real analog is _LANGUAGE_BUILTIN_GLOBALS.
- _EXTERNAL_STUB_RELATIONS said `calls` to an external target is always
suppressed (#3156); after #4043 an imported module does receive a
`calls` edge, so clarify the actual invariant.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Require exact coarse calls to unresolved absolute imports while retaining
package and cross-directory local target isolation.
AI-Assisted-By: OpenAI Codex
(cherry picked from commit e1eeede9b5)
Use existing plain-import provenance to distinguish separate and same-line from-imports. Reject type aliases and dynamic namespace references, and protect changed and unchanged external callers through incremental CLI extraction.
Fixes#3793
AI-Assisted-By: OpenAI Codex
(cherry picked from commit 5e240c8bd3)
`graphify path` and the MCP `shortest_path` tool picked endpoints by token
scoring only, so the forms `explain` already honours were misread:
- a bare label shared by symbols in several files was routed to one of them
silently instead of being refused with the candidate ids;
- `src/b.ts::.nonce()` scored the path half and resolved to the file node;
- a full node id tokenized to its containing class.
Both entry points now share `_resolve_path_endpoint`: an exact
`_find_node` hit (path::symbol, node id, exact label or source path) is used
as-is and refused with the candidate list when it spans several files; only
queries without an exact hit fall through to `_score_nodes`.
Two same-named symbols in one file are refused as well, listing their
locations and ids, since `path::symbol` cannot tell them apart. A plain
file path still resolves to the file node. The ambiguity hint now suggests
`<path>::<label>` with the node's own label instead of echoing the query,
and `explain` prints the same shared message.
(cherry picked from commit c2d558aff7)
Review follow-ups on #3928:
- A `//` comment closing one `<script>` block swallowed a second block on
the same line, since masking leaves them on one line. Terminate each
block with U+2028 then `;`: the line terminator ends the comment and
the `;` ends a statement that had none. Line numbers and byte offsets
are unchanged.
- An unreadable file returns the `error` key again instead of an empty
result, matching the behavior before the masking change.
(cherry picked from commit 5de47e3df3)
extract_svelte fed the whole .svelte file to the JS grammar. The template
is not JS, so every component reported a syntax error at line 1 and no
symbol declared inside <script> was ever reached — only imports survived,
via the regex rescue pass.
Blank everything outside the <script> bodies (keeping newlines so
locations still match) and parse with the grammar the first block's lang
implies: js/jsx -> JS, ts or unset -> TS, a superset of JS and Svelte's
own default. <script module> and <script context="module"> are parsed
alongside the instance block. Scripts with a non-JS type
(application/ld+json, ...) are blanked too, and a script region is
terminated in place of the following `<` so two blocks on one line do not
run together. The existing regex import rescue is unchanged; an import
both passes resolve yields one edge after build.dedupe_edges, as on the
Astro path.
The masker mirrors _astro_mask_non_script and shares the script-tag and
non-JS-type patterns with it instead of duplicating them.
Fixes#3928. Covers the Svelte half of #3942 (markup is blanked, so a
template {#if} no longer mints a spurious if() node).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit b64806924e)
empty(), isset(), eval() and die() parse as an ordinary
function_call_expression, so the call pass resolved them by bare name.
A reserved word has been a legal method name since PHP 7, so a class
that declares empty() collected every empty($x) in the corpus:
EXTRACTED from its sibling methods, INFERRED from every other file
through the case-insensitive fold.
Drop the callee for these constructs in the PHP branch, before the
in-file lookup and before raw_calls, the same way _GO_PREDECLARED_FUNCS
handles Go builtins. $bag->empty() is a member call and still resolves.
(cherry picked from commit 94574eb239)
hook install followed git's hooks path even when core.hooksPath resolved
outside the checkout, then created that directory and wrote post-commit
there. The bytes are Graphify's own hook, not repository-chosen code, but
the write still escapes the repo. Keep in-repo paths such as .husky, and
git's own hooks directory (including a linked worktree's shared .git/hooks).
Anything else falls back to that default.
Fixes#3869
Co-Authored-By: Grok 4.7 <noreply@x.ai>
(cherry picked from commit ee35087aea)
Snap the off-rubric INFERRED confidence scores emitted by the cross-repo
call/type resolvers and interface dispatch (0.8 and 0.9) to the nearest
canonical rubric tier (0.85), so every INFERRED edge carries one of the
documented discrete scores. Label-only change: no edge flips
EXTRACTED<->INFERRED and no edge is produced or dropped. Replaces the
substring-matching guard test with an AST-based one that scans every
module for off-rubric scores.
Squashed from #4045 (authorship preserved).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The exact pass joined equal labels without calling
_crossfile_fileanchored_blocked, so repeated headings collapsed
across source files. The fuzzy pass already had that block.
Fixes#3094
(cherry picked from commit 6a7409c06d)
The skill-folder rule from the previous commit left the files graphify
writes whole into a project still indexed: the always-on rules, steering
and workflow files (.agents/rules/graphify.md, .agents/workflows/graphify.md,
.cursor/rules/graphify.mdc, .kiro/steering/graphify.md,
.windsurf/rules/graphify.md) and the opencode/kilo hook plugins
(.opencode/plugins/graphify.js, .kilo/plugins/graphify.js). With every
platform installed they still added 12 nodes on top of the user's code.
List them in detect.py as a hidden holder dir plus the exact relative
path, the same paths install.py writes, and never match a bare
graphify.md/graphify.js name. detect(), ignored_predicate() and
collect_files() all check the same helper.
Files graphify only adds a section or entry to (AGENTS.md, CLAUDE.md,
.claude/CLAUDE.md, GEMINI.md, CODEBUDDY.md, .github/copilot-instructions.md
and the merged settings/hooks/plugin JSON configs) are still left alone.
The new test runs every project install in install.py plus the kilo,
codebuddy and vscode installers, so a new platform that writes a new file
fails it.
Refs #4057
Co-Authored-By: Grok Bot <noreply@x.ai>
(cherry picked from commit 102f4bca1e)
`graphify install --project` copies graphify's SKILL.md and its
references/ bundle into the project (.claude/skills/graphify/,
.agents/skills/graphify/, ...). detect() treated those files as project
documents, so a small repo's graph was mostly graphify's own docs: in the
#4057 repro, 66 of 69 nodes came from the installed skill and the
always-on files, and only 3 from the user's code.
Prune the folder in _is_noise_dir, next to the .claude/worktrees rule, by
its full path shape as install.py writes it: <hidden dir>/skills/graphify/,
plus .pi/agent/skills/graphify/, .config/kilo/skills/graphify/ and
.aider/graphify/. The bare names "skills" and "graphify" are not matched on
their own (#2479), so graphify's own source tree (graphify/skills/<host>/),
a top-level skills/graphify/ and the user's other skills stay indexed.
Because the rule lives in _is_noise_dir, detect(), ignored_predicate() and
collect_files() all agree on it.
AGENTS.md, CLAUDE.md and .claude/CLAUDE.md are left as they are; those are
the user's files and how to treat graphify's section in them is still an
open question on the issue.
Fixes#4057
Co-Authored-By: Grok Bot <noreply@x.ai>
(cherry picked from commit 0d7087ea5c)
The unique-stub rewire copied a same-label class onto every edge, so a
Python base could inherit a TypeScript class. Refuse that write when the
language families differ. A same-language match still rewires.
Fixes#2207
(cherry picked from commit 48cdc8f484)
`_is_config_json()` decides whether a `.json` file is worth AST-walking. It
matches by filename first, then falls back to a root-key probe. `$schema` was
in that probe's key set -- correctly, since configs legitimately carry it --
but it is also the *defining marker* of a JSON Schema, so every schema
document satisfied the probe and got walked in full. That re-opened the
keyword-node explosion #1224 closed for data JSON: one 160 KB contract
contributed 362 nodes labelled with bare schema keywords (`description` x113,
`type` x83, `$ref` x53, ...) plus ~12 schema-only communities, polluting
clustering, god-node analysis and GRAPH_REPORT.md.
Two changes, both ahead of the existing probe:
- Drop `$ref` from the probe keys. A root-level `$ref` is a schema construct
and never a config signal.
- Return False when the root object carries `$schema` *plus* a
schema-definition marker (`$defs`, `definitions`, or `$id`). This is what
separates "this file IS a schema" from "this config POINTS AT a schema":
the latter points `$schema` at its own tool's schema (biomejs.dev,
docs.renovatebot.com) and never carries these markers.
The blast radius is smaller than it looks. The filename branches run first, so
biome.json / renovate.json / package.json / tsconfig.json never depended on the
probe at all; only arbitrarily-named files did. I confirmed this against a
control matrix before and after -- every config shape keeps byte-identical node
counts.
The root keys are now collected once into a set so the schema check and the
config probe agree on the same read of the file, rather than walking the root
twice.
Documented boundary, pinned by a test: a hand-written schema carrying only
`$schema` + `properties` (no `$defs`/`definitions`/`$id`) is still walked.
Closing that gap needs a keyword-density heuristic, which would risk skipping a
real config whose keys overlap schema vocabulary -- the worse error, since it
drops structure silently. Left for a deliberate change rather than guessed at.
Verified:
- uv run pytest tests/test_json_schema_config.py -q (14 passed; 5 fail pre-fix)
- uv run pytest tests/ -q -> 28 failed, 6262 passed, 101 skipped
- baseline on a clean v0.9.75 checkout: 28 failed, 6248 passed, 101 skipped
The 28 failures are byte-identical between the two (missing tree-sitter
grammars for erlang/R/solidity/vbnet, plus ollama retry tests) and are diffed
test-id-by-test-id, not compared by count. Passing delta is exactly +14.
- uv run ruff check . -> clean
- uv run pyright -> 636 errors, identical to the clean v0.9.75 baseline
Co-Authored-By: Claude <noreply@anthropic.com>
(cherry picked from commit f45ea17288)
The unqualified-call scope added for #4001 collected every `import` and
`use` in a file into one list, so an import in one module leaked into a
sibling module defined in the same file: `MyApp.Sibling.go/1` calling
`fmt(x)` still bound to `MyApp.Helpers.fmt/1` because `MyApp.Importer`
imported it. Elixir scopes `import`/`use` to the module body it appears
in and to the modules nested in it.
Record targets per enclosing module, with the file's top level as its
own scope, and build each call's scope from its own module, the modules
enclosing it, and the top level. Each raw call now gets its own list
rather than sharing one mutable list with every other call in the file.
A module nested in a same-named module shares its node id, so the walk
up the enclosing chain stops at a module it has already visited.
Follow-up to #4015. Refs #4001
(cherry picked from commit e508142dac)