#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)
Covers a plain data literal getting named and marked as likely data,
a real function staying silent, no overlap with the existing syntax
error warning on a genuinely broken file, and the total size count
across more than one file. Confirmed to fail against pre fix code.
(cherry picked from commit 32ad059ebc)
A code file that parses with zero errors but still produces nothing
beyond its own bare file node is usually a plain data literal the AST
extractor has nothing to model (a const array, a module export of
plain data), not a code file that legitimately has no symbols. Those
two cases looked identical in the output: the file silently entered
the graph as an isolated, zero edge node, and nothing reported it. A
real corpus measured in the issue had 88 such nodes covering roughly
40 percent of the corpus, with not one edge among them.
Add a complementary check next to the existing syntax error warning,
gated on the opposite condition (no parse_errors and no extractor
error) so the two can never double report the same file: a file
already explained by a genuine parse error or a missing optional
dependency is excluded here.
Fixes#3946.
(cherry picked from commit 3b3ee82b1a)
The prune counter included a stub created in this run, so it could
excuse a dedup shrink. Count only nodes that were already saved.
Strip both counters inside to_json so other writers cannot persist them.
Co-Authored-By: Grok 4.7 <noreply@x.ai>
(cherry picked from commit 294946d4ce)
A finished extract force-writes, so the node-count check never sees a
graph that dedup made smaller. Refuse that overwrite and name
--allow-dedup-shrink. A deleted file still writes.
Fixes#3774
Co-Authored-By: Grok 4.7 <noreply@x.ai>
(cherry picked from commit 686bef5855)
Follow-up on #4007: is_interpreter_variable fired for any bare-$VAR
command word, so `"$DIR/run.sh" stage.py` could mint a spurious invokes
edge to the argument on top of the real .sh calls edge. A bare
interpreter variable ($PYTHON) has no path separator; gate on
`"/" not in cmd_word`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A configurable directive such as
export 'service_stub.dart' if (dart.library.io) 'service_io.dart';
was matched only up to its first quoted string, so the `if` alternatives
were dropped. In the usual Flutter facade the stub is an empty placeholder
and the real implementation lives in the conditional branch, which left
the implementation unreachable from the facade and its importers.
Parse the whole directive and emit one edge per URI: the default URI keeps
its plain edge (unchanged), each alternative gets the same relation tagged
context="conditional_uri" with its own source line. A string literal inside
the condition (`if (app.flavor == 'prod')`) is part of the URI test and is
not mistaken for a URI.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 2b965f23fc)
Since #1098 a `part of` file's declarations are redirected to the parent
library so one library has one id namespace. The redirect also dropped
the part's own file node, so a scanned source file could not be found by
name (`explain "<part>.dart"` -> no node).
Keep the redirect and keep the node: the part gets its regular file node,
`library --includes--> part` anchored on the `part of` line, and
`part --contains--> declaration` mirrors every `defines` edge the library
gets from this part. Declaration ids and the library's `defines` edges are
unchanged. test_roadmap_bug_fixes asserted the old "no part node"
behaviour and now asserts the part's own node instead.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit e32f49dbad)
Covers python3/python plus a bare interpreter variable, a non shell
interpreter running a dot sh target landing on its entrypoint node,
and two negative cases: a non interpreter command with a script
looking argument, and a missing target. Confirmed to fail against
pre fix code except for the two negative cases, which hold either way.
(cherry picked from commit 97d4c9207c)
The bash extractor recorded functions, source imports, and calls
between functions, but nothing for the programs a script actually
runs. In a pipeline driven by a shell script that is most of what the
script does, so an orchestrator looked disconnected from every stage
it runs: path between the runner and a stage it invokes found
nothing.
Add an invokes edge when a command's first word is a known
interpreter (python, python3, python2, node, nodejs, ruby, perl) or a
bare variable standing in for one, and an argument resolves to an
existing file in the repo with a recognized script extension. Kept
entirely separate from the existing bash to bash runner handling
(bash/sh/zsh/ksh/dash plus direct dot slash invocation), which still
emits its own calls edge unchanged, so this only adds coverage rather
than risking the existing path.
Gated on the target actually existing on disk, same as every other
heuristic edge in this file, so a plain command that merely happens to
take a dot py argument (cp, a custom tool) is never mistaken for an
interpreter invocation.
Fixes#3802.
(cherry picked from commit 52fe75be93)
The shared cross-file call pass bound an unqualified call to any
same-named def in the corpus. In Elixir an unqualified call can only
reach the caller's own module, Kernel, or a module the file imports or
uses, so DSL calls landed on unrelated project functions: every
migration's `table(:users)` (Ecto.Migration, brought in by `use`) became
a calls edge to a Phoenix component's `table/1`, making it the top god
node.
The Elixir extractor now records, on each raw call, the modules its file
names in `import` or `use` (`alias` and `require` do not bring functions
into scope). The shared pass keeps only candidates whose file defines one
of those modules, the same way Go selectors are already filtered by
import path, and leaves the call unresolved otherwise. The module index
is read from the resolution nodes, so an unchanged file's modules still
count on an incremental rebuild.
Calls into a module that a `use` macro imports indirectly (for example
`use MyAppWeb, :live_view` importing CoreComponents) are no longer
resolved, since that import is not visible in the source.
Refs #4001
(cherry picked from commit 98d4ae9878)
_merge_csharp_partial_class_nodes rewrote every edge in the corpus and
dropped every self-loop, so one partial class split across two files
erased recursive calls in every language of the repo. It also deduped
edges it never rewrote, on a key that ignores confidence and context.
Keep edges the merge does not remap verbatim (the Swift extension merge
rule, #2538) and drop a self-loop only when two distinct endpoints
collapse into one node (the deduplicate_entities rule, #3809), so a
pre-existing self-loop on a merged-away half is rewired, not lost.
Co-Authored-By: Claude <noreply@anthropic.com>
(cherry picked from commit 5992044f32)