Commit Graph

941 Commits

Author SHA1 Message Date
Synvoya bb5e5192df fix(csharp): emit type references for properties
The C# class-body walker only handled field_declaration, so a
property's type produced no references(field) edge. In idiomatic C#,
auto-properties (`public Widget Main { get; set; }`) — not bare fields
— are the standard way to declare state, so this silently dropped most
of a class's type relationships.

Add a property_declaration branch alongside the field_declaration
handler, guarded the same way (ts_module == tree_sitter_c_sharp,
parent_class_nid set). A property exposes its type on the node directly
(no variable_declaration wrapper), so read it via
child_by_field_name("type") and collect refs with
_csharp_collect_type_refs, mirroring the Java/PHP/Kotlin siblings so
List<Widget> yields both the List field ref and the Widget generic_arg
ref. Only emit when target != parent_class_nid.
2026-07-01 16:36:50 +01:00
Synvoya 51f805e953 fix(php): emit type references for promoted constructor properties
PHP 8 constructor property promotion (`__construct(private Repo $repo)`)
parses the promoted parameter as `property_promotion_parameter`, not
`simple_parameter`. The PHP parameter loop filtered on `simple_parameter`
only, so promoted params were skipped entirely: their type emitted no
`parameter_type` edge on the constructor, and — because a promoted param
is also a real class field — no `field` edge on the class either. A
non-promoted param in the same signature still emitted `parameter_type`,
so the type reference was silently dropped for exactly the promoted case.

The promoted param's type sits in the same direct named-child shape the
loop already reads for `simple_parameter`, so widening the filter to
accept `property_promotion_parameter` makes the existing type extraction
emit the `parameter_type` edge. Additionally, for a promoted param, emit
a `field`-context references edge on the class (mirroring the
`property_declaration` handler), guarded so it only fires when a parent
class is in scope and the target is not the class node itself. Normal
`simple_parameter` behaviour is unchanged.

Adds a promoted-property constructor to tests/fixtures/sample.php and
test_php_constructor_property_promotion_contexts asserting the promoted
type appears as both `field` and `parameter_type`, and that a
non-promoted param does not leak a field edge.
2026-07-01 16:36:49 +01:00
Synvoya cd3a376030 fix(objc): emit implements edge for protocol-to-protocol adoption
`@protocol Derived <Base>` dropped the protocol-adoption (inheritance)
edge. The protocol_declaration handler in extract_objc walked children
for method declarations but ignored the protocol_reference_list child
that holds the adopted protocols, so no implements edge was ever emitted
for protocol-on-protocol adoption.

The extractor already handled `@interface Foo <Proto>` adoption, but that
nests the protocol name under a parameterized_arguments node; protocol-on-
protocol adoption uses a different grammar node (protocol_reference_list)
whose adopted-name is a direct `identifier` child, so it was never
matched. Walk protocol_reference_list and emit an implements edge for each
adopted protocol, mirroring the @interface handling.

Adds a defined Base/Derived protocol pair to the ObjC fixture and a
regression test asserting the Derived->Base implements edge.
2026-07-01 16:36:48 +01:00
Synvoya a129ff2cd6 fix(powershell): emit inherits/implements edges for class base types
The `class_statement` handler read only the first `simple_name` child —
the class name — and never inspected the base type(s) after the `:`
token. As a result `class Dog : Animal` dropped the Dog->Animal
inheritance edge entirely; derived classes appeared as isolated nodes.

Walk the class_statement children, and once the `:` token is seen treat
each following `simple_name` as a base type. Matching the C# convention
(PowerShell has no syntactic base-vs-interface split), the first base is
emitted as `inherits` and the rest as `implements`, resolved via
ensure_named_node.

Adds a Shape/Circle inheritance pair to tests/fixtures/sample.ps1 and a
regression test asserting ("Circle","Shape") in the inherits edges.
2026-07-01 16:36:47 +01:00
Synvoya 67b4525f32 fix(scala): emit field type references for var declarations
The Scala field handler matched only `val_definition`, so a mutable field
(`var b: Repo`), which parses as `var_definition`, had its type reference
silently dropped from the graph. val and var nodes are structurally identical
(both expose a `type` field), so the existing type-collection logic works
unchanged. Widen the guard to accept var_definition.

Adds a var field to the Scala fixture and a regression test.
2026-07-01 16:36:47 +01:00
safishamsi 784e9c833e fix(extract): case-sensitive cross-file resolution in case-sensitive languages (#1581)
Cross-file name resolution folded case for every language, so `from pathlib
import Path` resolved to a shell script's `export PATH=...` node — one variable
becoming the corpus's #1 god-node (266 false incoming edges on a real repo),
polluting god-node rankings, affected blast-radius, and clustering. Reported
with a precise diagnosis by @sheik-hiiobd.

Case is semantic in Python/Rust/Go/Java/C#/Kotlin/Swift/Ruby/C/C++/JS/TS: `Path`
(class), `PATH` (env var), `path` (variable) are distinct. Fix gates folding by
language at the two resolution sites the repro exercised:

- global cross-file CALL resolver: index by exact case; a folded index is built
  only for case-insensitive-language nodes (PHP/SQL/Nim) and consulted only when
  the calling file is such a language.
- type-reference STUB rewire (_rewire_unique_stub_nodes): match stubs to real
  defs by exact case, with a folded fallback restricted to case-insensitive-
  language definitions — so a case-sensitive `PATH` can never absorb a `Path`.

For case-sensitive languages this only ever removes false edges. Concept/doc
dedup (dedup.py, guarded to non-code nodes) is intentionally left folding.
Regression tests: Python `Path` no longer hits shell `PATH`; a case-differing
cross-file ref doesn't resolve; exact-case resolution still works; PHP fold
preserved. Full suite 2777.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 15:09:42 +01:00
safishamsi 532a20e775 docs(changelog): note julia/rust-tuple-struct/systemverilog fixes (#1580, #1582, #1583)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 13:51:27 +01:00
Synvoya 297075c3f3 fix(systemverilog): emit field references for qualified class properties
The SystemVerilog class-body field regex in _augment_systemverilog_semantics
matched only unqualified `<type> <name>;` declarations. Its `^\s*` prefix
consumes leading whitespace but not leading class-property qualifiers, so a
qualified field such as `rand Config m_cfg;` (three tokens) failed the
two-token shape and its type reference was silently dropped from the graph.

Consume optional leading qualifiers (rand/randc/local/protected/static/const/
automatic/var) before the type token. Zero qualifiers preserves the existing
behavior; the type and name capture are unchanged.

Adds test_systemverilog_qualified_field_references plus rand- and
protected-qualified fields (and a Config class) to the shared .sv fixture.
2026-07-01 13:45:53 +01:00
Synvoya 7eb847bcf7 fix(rust): emit field type references for tuple structs
extract_rust() only traversed field_declaration_list (named-struct
bodies), so tuple structs -- whose positional fields nest under
ordered_field_declaration_list -- had every field type reference
silently dropped from the graph.

This is the same node shape the enum handler already accounts for
(tuple variants nest their types under ordered_field_declaration_list);
the struct path was simply left behind. Add an additive branch that,
for each type node in a tuple struct's ordered_field_declaration_list,
collects type refs via _rust_collect_type_refs and emits references
edges with the appropriate field / generic_arg context. The
named-struct path is untouched.

For `struct Wrapper(Logger, Config);` with Logger/Config defined
in-file, no field edges were produced before; both are now emitted.

Adds test_rust_tuple_struct_field_references and a tuple struct to the
shared Rust fixture covering plain and generic positional field types.
2026-07-01 13:45:53 +01:00
Synvoya 984a6a8f0a fix(julia): emit imports for qualified, relative, and scoped-selected forms
Only bare-identifier imports (`using Foo`) emitted edges. tree-sitter-julia
wraps qualified paths in `scoped_identifier` (`using Base.Threads`), relative
paths in `import_path` (`using ..Sibling`), and the package of a
`selected_import` may itself be a `scoped_identifier`
(`import Base.Threads: nthreads`). None of those were matched, so qualified and
relative imports were silently dropped, and scoped selected-imports pointed at
the selected symbol instead of the module.

Resolve the module name from identifier / scoped_identifier / import_path in
all three positions. Adds fixture lines + a regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 13:45:52 +01:00
safishamsi 7e24c3b7e4 docs(changelog): note elixir/fortran/rust extractor fixes (#1577, #1578, #1579)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 12:00:48 +01:00
Synvoya 674184d462 fix(rust): emit references edges for enum variant field types
Enum variant payload types were silently dropped — `struct_item` and
`trait_item` had type-reference handlers but `enum_item` had none, so the
variant field types were never traversed.

Add an `enum_item` branch that walks
`enum_variant_list -> enum_variant -> ordered_field_declaration_list`
(tuple variants, `Click(Logger)`) and `field_declaration_list` (struct
variants, `Resize { size: Dim }`), emitting a `references` edge from the enum
to each field type. Reuses the same type collection as the struct path. Adds
an enum to the fixture and a regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:58:39 +01:00
Synvoya b8f41c77eb fix(fortran): emit calls edges for function invocations
Function calls (`y = f(x)`) were silently dropped — only `subroutine_call`
(`call sub(...)`) was handled in walk_calls. tree-sitter-fortran represents a
function invocation as a `call_expression`, which had no branch, so every
function-to-function call produced no edge.

Handle `call_expression`. Because Fortran uses the same `name(...)` syntax for
array indexing, the callee is resolved against procedures defined in the file
(`target_nid in seen_ids`) before emitting — so array accesses like `arr(i)`
cannot fabricate spurious `calls` edges. Adds a function + caller to the
fixture and a regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:58:38 +01:00
Synvoya f2ea6a6087 fix(elixir): expand multi-alias brace form into per-module imports edges
`alias Foo.{Bar, Baz}` (and the same import/require/use brace form) emitted
NO imports edges. tree-sitter-elixir represents it as a `dot` node holding the
base alias plus a trailing `tuple` of member aliases, but the import handler
only matched a bare `alias` child, so every multi-alias import was silently
dropped.

Add `_get_alias_modules`, which expands the brace form to `Foo.Bar`,
`Foo.Baz`, … while leaving the single form (`alias Foo.Bar`) unchanged. Adds a
fixture line + regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:58:38 +01:00
safishamsi 8127ff9a9c fix(cluster): detect stale community labels on cluster-only re-cluster
Reported from a real run: after re-scoping a repo (5807->3710 nodes), the
228-community re-cluster kept run-1's 300 saved labels, so cids now covering a
different community wore the wrong (LLM) names — silently. cluster-only reused
.graphify_labels.json wholesale, and the overlap-based cid remap grabs a prior
cid on any overlap, inheriting a stale name.

Fix: write a per-community membership signature (sha256 of sorted member ids)
beside the labels. On reuse, keep a saved label only when the community's
signature is unchanged; a changed community is renamed by its deterministic
hub (correct-by-construction) with a warning to run `graphify label` for fresh
LLM names. For label files predating the signature, fall back to a community-
count check (a differing count means a different clustering -> don't trust cid
labels). Unchanged graphs reuse labels silently — no false warnings.

Verified: stale legacy labels (42) on a 12-community graph -> warned + hub-
renamed all + sig written; rerun on the unchanged graph -> silent reuse, labels
stable. Unit tests for the signature (deterministic, order-independent, changes
on membership change). Full suite 2771.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:52:50 +01:00
safishamsi e34e27c24c fix(extract): cross-file indirect_call survives id relativization (0.9.4 regression)
Local install-testing of 0.9.4 surfaced that `graphify extract .` dropped every
cross-file indirect_call edge — the headline feature, broken on the primary code
path — while the extract() API worked. Root cause: the cross-file callable-target
guard unioned per-file `callable_nids` (pre-remap ids), but extract() rewrites node
ids afterward (id_remap / prefix sym_remap / _disambiguate_colliding_node_ids). When
the scan root relativizes ids (cache_root == project root, which the CLI passes), the
guard set went stale and `tgt not in callable_nids` rejected every remapped target.
In-file indirect edges survived (emitted with consistently-remapped endpoints), which
masked it — only cross-file dropped.

Fix: mark callable defs with a `_callable` attribute on the node dict instead of
exporting an id list. A marker rides through every id remap; callable_nids is rebuilt
from the final (post-remap) nodes right before the pass that uses it, and the marker
is stripped before output (like origin_file). Regression test extracts with
cache_root == project root (the CLI shape) and asserts the cross-file edge survives
and _callable never ships to graph.json. Full suite 2769.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:43:05 +01:00
safishamsi b7f88afc30 release: 0.9.4
indirect_call dispatch arc (call args + cross-file, dispatch tables,
assignment/return, getattr, JS/TS), two incremental-update data fixes
(hyperedge preservation + ghost-node prune, incl. symlinked-root hardening),
direction-aware skill-version warning, deterministic hub community labels,
Ruby/Groovy inheritance edges, corrupt-graph.json error handling, cross-chunk
collision warning, Windows hook worker limit.

Built wheel validated in a clean venv: CLI reports 0.9.4, import resolves to
the installed package, new-feature smoke battery green, and a real `graphify
extract` produces indirect_call + inherits edges end-to-end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v0.9.4
2026-07-01 11:17:59 +01:00
safishamsi de7d362537 fix(build): relativize source_file across a symlinked root (#1571 follow-up)
Rigorous smoke testing surfaced an edge case the canonical-tmp unit tests
couldn't reach: when the scan root is under a symlink (macOS /var ->
/private/var, a symlinked home or git worktree), the absolute prune path and
the resolved root differ by prefix, so _norm_source_file's lexical
relative_to fails and the prune/replace match silently misses — deleted
files' ghost nodes survive. Latent in the pre-existing #1007 path too, now
that build_merge resolves the root.

Fix: when lexical relative_to fails, retry with both sides fully resolved.
Only the failure path resolves, so the common lexical match stays
filesystem-free (no per-node stat on the hot replace-per-source loop).

Adds a symlinked-root prune regression test (POSIX-only). Full suite 2768,
and the full end-to-end smoke battery (indirect_call all contexts, JS,
Ruby/Groovy inherits, hyperedge preservation, symlinked-root ghost prune,
corrupt-json errors, dedup collision warning) is green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:14:48 +01:00
safishamsi 6eb7c014f4 test(build): add regression tests for corrupt graph.json (#1537)
#1537 shipped with a manual test checklist only. Add automated tests that
corrupt a graph.json and assert the actionable RuntimeError at all three load
paths (build_merge, affected.load_graph, diagnostics._read_json_file) plus a
happy-path guard. Also record the six merged small fixes in the changelog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 10:49:43 +01:00
hibinm 64a6093376 fix(groovy): emit inherits/implements edges for extends/implements
tree-sitter-groovy exposes inheritance via the same `superclass` and
`interfaces`/`type_list` fields as tree-sitter-java, but the inheritance-
emitting block in `_extract_generic` was gated on
`ts_module == "tree_sitter_java"`. Groovy was the only class-based JVM
language in the file with no inheritance handler, so every Groovy
`extends`/`implements` was silently dropped (contains/methods/imports/calls
were unaffected).

Widen the gate to include `tree_sitter_groovy`; the existing
`_emit_java_parent_type` path handles the identical node shapes verbatim.
Adds a base class + interface to the Groovy fixture and two regression
tests (extends -> inherits, implements -> implements).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 10:47:10 +01:00
hibinm a19b9e90ec fix(ruby): emit inherits edge for class superclass
`class Dog < Animal` exposes the base in the `superclass` field, but the
inheritance handler in `_extract_generic` had branches for
java/kotlin/c#/scala/cpp/php/swift/python and none for Ruby, so every Ruby
`inherits` edge was silently dropped (contains/methods/calls unaffected).

Add a Ruby branch that reads the `superclass` field, handling both a bare
`constant` (`< Animal`) and a `scope_resolution` (`< Foo::Bar` -> Bar).
Adds a subclass to the Ruby fixture and a regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 10:47:10 +01:00
Matias Duarte 879c05894d fix(hooks): limit Windows hook rebuild workers 2026-07-01 10:47:10 +01:00
Varun Nuthalapati 5320aa8eb1 fix(dedup): warn on cross-chunk node ID collision to surface silent data loss (#1504)
When two LLM extraction chunks each process a file with the same name in
different directories, they independently generate the same node IDs and
deduplicate_entities() silently drops one node (first-writer-wins). The
data loss had no indication in any log, counter, or output.

Adds a stderr WARNING when a duplicate ID comes from a different
source_file, telling the user which files collided and recommending the
per-subfolder extract + merge-graphs workflow to avoid it.
2026-07-01 10:47:10 +01:00
guy oron 4a8d6bad97 fix: harden graph JSON loading against corruption (#1536)
Wrap unguarded json.loads() in build_merge(), load_graph(), and
_read_json_file() so corrupted graph.json files produce an actionable
RuntimeError instead of an unhelpful JSONDecodeError traceback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-01 10:47:10 +01:00
tpateeq 93e8e445dd docs(build): correct deduplicate_by_label docstring — dormant, not auto-called
deduplicate_by_label is never wired into build(); the active dedup path is
deduplicate_entities (imported and called in build). Its docstring claimed
"Called in build() automatically," which was never true. Correct it to say the
helper is dormant/unused and to warn that it merges by label alone with no
file_type guard, so it must not be enabled for code nodes — same-label symbols
from different files/packages (e.g. two Account types) would collapse, the
cross-file conflation deduplicate_entities deliberately avoids for code (#1205).

Docstring only; no behavior change. The function is unused and superseded, so it
could reasonably be deleted instead — left in place here, flagged for your call.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:46:59 +01:00
safishamsi 20547c4767 docs(changelog): note getattr slice 3 (#1575) and hub labels (#1576)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 10:32:59 +01:00
Sheik Ershad 1aab291caf feat(cluster): deterministic hub community labels (readable without an LLM)
Community labels defaulted to "Community N" whenever no LLM backend was configured,
making the report + suggested questions unreadable ("why does log_action connect
Community 70 to Community 129?"). Add `label_communities_by_hub`: name each community
after its highest-degree member — the structural hub — so the report reads "log_action"
/ "auth" with zero LLM cost. Ties break by node id for run-to-run stability; a community
with no members in the graph keeps the "Community N" placeholder.

Wired as the default base at both label-building sites — the label/standalone path in
__main__ and the watch/detect rebuild in watch.py. A configured LLM naming pass still
runs and overrides these with richer names; its no-backend placeholder fallback is
guarded so it can't clobber the hub labels.
2026-07-01 10:30:47 +01:00
Sheik Ershad 8fdbf50197 feat(extract): capture getattr(obj, "name") indirect_call edges (#1566 slice 3)
Reflective dispatch by string literal — getattr(obj, "handler") — resolves the
attribute name to a callable def and emits it under indirect_call (context
"getattr", INFERRED) at both function and module scope, so `graphify affected
handler` now covers getattr call sites.

The name is a STRING, not an identifier: it names an attribute and is never
shadowed by a param/local, so it resolves without the identifier shadow guard —
the inverse of the #1565/#1566 identifier paths. A dynamic name (a variable,
f-string, concatenation, or any expression) is not statically resolvable and emits
nothing; obj.getattr(...) (a method, not the builtin) and the 1-arg form are ignored.

Refactors the shared resolve-and-emit core out of _emit_indirect_ref into
_emit_indirect_by_name so the getattr path reuses it (callable-target-only,
cross-file deferral, dedup) without duplicating the guard; the identifier wrapper
is behavior-preserving. Full suite green.
2026-07-01 10:30:47 +01:00
safishamsi 4f40967e8d fix(build): preserve hyperedges + harden prune root in build_merge (#1574, #1571)
build_merge backs `graphify --update`. Two incremental-update data bugs:

#1574 — it read only nodes+edges from the existing graph.json, never
hyperedges, and build() only sees the new chunks' hyperedges. So every
--update collapsed the graph's hyperedge set (the highest-signal semantic
groupings) down to just the re-extracted files'. Now existing hyperedges are
carried forward via attach_hyperedges (id-dedup): re-extracted files' prior
hyperedges are replaced by their new version (by source_file), deleted files'
are dropped via the prune set, and unchanged/global ones are preserved. This
mirrors what watch.py already did.

#1571 — when a caller omits `root`, absolute prune_sources (from
detect_incremental) never relativized to the stored relative source_file
keys, so deleted files' nodes survived as ghosts and accumulated across runs.
Added _infer_merge_root: fall back to the committed graphify-out/.graphify_root
marker, else the output dir's parent. This root now drives BOTH the prune set
and the replace-per-source normalization, so both work without an explicit
root. The CLI --update path and all shipped runbooks already pass root; this
hardens the library for any other caller.

5 tests: hyperedge preservation (unchanged/global kept, re-extracted replaced,
with and without root), deleted-file hyperedge prune, and root-less prune via
both the grandparent and .graphify_root-marker fallbacks. Full suite 2742.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 10:12:46 +01:00
safishamsi 69b3997386 docs(changelog): note assignment/return indirect_call (#1569)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 23:27:46 +01:00
Sheik Ershad 311e63a7fc feat(extract): capture assignment/return indirect_call edges (#1566 slice 2)
A function bound to a name (cb = handler) or returned from a factory
(def make(): return handler) is a real reference, but indirect_call only
covered call arguments and dispatch tables, so `affected` still dropped these
callers.

Emit indirect_call (context "assignment"/"return", INFERRED) for the value-side
identifiers of a Python assignment RHS and a return, at function scope (owner =
enclosing function) and module scope (owner = file node). Reuses the shared
_emit_indirect_ref guard. Scans the VALUE side only -- the assignment target is
a new local binding, not a reference -- so the existing param/local shadow guard
still rejects the false edges #1565 fixed.

Negatives covered: param-shadow, local-shadow, non-callable emit nothing.
Full suite green; ruff clean.
2026-06-30 23:26:13 +01:00
safishamsi 47033c8b75 fix(cli): make skill-version warning direction-aware (#1568)
_check_skill_version advised "Run 'graphify install' to update" on ANY
version mismatch. But `install` writes the package's OWN bundled skill and
re-stamps .graphify_version, so when the skill on disk is NEWER than the
running package, following that advice silently DOWNGRADES the skill to
silence the warning. The docstring even said "warn if the skill is from an
OLDER version" but the code never checked direction.

Compare versions numerically (new _version_tuple, so 0.10 > 0.9 and a
malformed stamp degrades instead of raising). When the skill is newer than
the package, recommend upgrading the package (uv tool upgrade / pip install
-U) instead of install; the older-skill case is unchanged. Hit in practice
by a stale `uv tool` CLI and by contributors whose dev checkout stamped a
newer skill. Reported by @TPAteeq.

4 tests (numeric ordering, both mismatch directions, silent-when-equal).
Full suite 2730 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 23:15:52 +01:00
safishamsi 6dc1cb0dd7 feat(extract): capture indirect dispatch for JS/TS (#1566 slice 5)
Brings the indirect_call model to JavaScript and TypeScript, the next-
biggest callback-passing ecosystem. Now captured:

- callbacks passed by name as call arguments: `arr.map(fn)`, `setTimeout(fn)`,
  `el.addEventListener("x", fn)` — function-scoped, attributed to the caller.
- module-level callback registration (idiomatic in JS, unlike Python):
  Express routes `app.get("/", handler)`, event wiring `emitter.on("e", h)`,
  timers — attributed to the file.
- object/array dispatch tables: `const ROUTES = { create: handler }`,
  `const HOOKS = [onStart, onStop]`, object shorthand `{ handler }`.

Arrow-const functions (`const cb = () => {}`) are registered as callable
targets (threaded callable_def_nids + local_bound_names through
_js_extra_walk). Guards mirror Python: shadowing by param/local/module
reassignment is rejected (JS module shadow set excludes function-valued
declarators so arrow-consts stay resolvable), object KEYS and non-callable
values are excluded, inline arrows/function expressions are direct defs not
references, single-definition god-node guard cross-file.

Two cross-file fixes the JS import model exposed:
- a name that resolves to an import-surfaced FOREIGN symbol (JS named
  imports map the real node into the importing file's label map) is now
  deferred to the cross-file resolver instead of being mistaken for a local
  non-callable; the global callable_nids guard still rejects imported data.
- indirect_call dedup is now call-aware: a benign `imports` edge from a file
  to the symbol it imports no longer suppresses the indirect_call to it
  (only a real calls/indirect_call edge does).

Shared the resolve-and-emit helper across Python and JS/TS. 9 new tests
(func-arg, module object/array, Express-style registration, inline-arrow
negative, param-shadow, key/data exclusion, shorthand, cross-file import,
TS typed params). Full suite 2726 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 22:34:48 +01:00
safishamsi 8288829613 feat(extract): capture dispatch tables as indirect_call edges (#1566)
Slice 1 of #1566. A function referenced as a VALUE in a dict/list/set/
tuple literal — the registry/route-table idiom (`ROUTES = {"create":
create_user}`, `HOOKS = [on_start, on_stop]`) — is an indirect dependency
that blast-radius must see, but the call-argument capture (#1565) didn't
cover it. Emit an indirect_call edge for each callable value:

- module-level tables attribute to the file node; function-scoped tables
  attribute to the enclosing function. Same-file and cross-file (an
  imported handler in a table routes through the cross-file resolver).
- dict KEYS are excluded (only values are references); non-callable values
  (a number, a string) never resolve; a name shadowed by a param/local or
  rebound at module scope is the local value, not the function.

Refactors the resolve-and-emit logic shared by the argument and table
paths into one guarded helper (_emit_python_indirect_ref) and threads the
edge `context` ("argument" | "collection") through the cross-file pass.
Adds _python_module_bound_names for the module-scope shadow set.

7 new tests (module dict/list, function-scoped, dict-keys-excluded,
non-callable-value, module-reassign shadow, cross-file table). Full suite
2717 passed. Python only; assignment/return refs + other languages remain
on #1566.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 22:17:53 +01:00
safishamsi cf747abeaf feat(extract): resolve cross-file indirect dispatch as indirect_call
#1565 captured a function passed by name as a call argument
(executor.submit(fn), Thread(target=fn), map(fn, xs)) only when the
callback was defined in the SAME file — it resolved through the in-file
label map. But the dominant real-world shape is cross-module: the callback
is imported (`from .handlers import on_event; pool.submit(on_event)`), so
the same-file map can't see it and the edge was dropped — exactly the
caller a blast-radius query must not miss.

When the argument identifier isn't defined in-file (and isn't shadowed by
a param/local — that guard already ran), emit an `indirect` raw_call and
let the existing cross-file resolution pass handle it, branching to a
distinct indirect_call/INFERRED edge instead of calls. It rides the same
single-definition god-node guard and import-evidence disambiguation as
direct calls (parity: when a direct call resolves, so does the indirect
one; when the name is ambiguous, both bail). Two added safeguards: the
target must be a real callable def (per-file callable_nids unioned across
the corpus) so an imported data constant can never become a dispatch
target, and an existing direct `calls` edge for the pair pre-empts it.

Smoke-verified: imported single-def callback resolves; ambiguous name
bails (same as direct); imported data constant rejected; direct+indirect
to the same target keeps only the direct edge; cross-file keyword
target=cb resolves. Full suite 2710 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 22:08:14 +01:00
Sheik Ershad 19e7a31343 feat(extract): capture indirect dispatch as indirect_call edges (#1565)
`graphify affected` (blast radius) was blind to a function passed BY NAME as
a call argument — `executor.submit(fn)`, `Thread(target=fn)`, `map(fn, xs)`,
callbacks — so those callers were silently dropped from the affected set (an
under-counting blast radius reads complete while missing exactly where
regressions hide). Capture them as a distinct `indirect_call` relation
(INFERRED, context "argument"), kept separate from `calls` so strict
call-graph queries stay precise, and add it to DEFAULT_AFFECTED_RELATIONS.

Hardened over the original PR against the name-collision false edge (the
Doxygen #3748 trap): emit only when the argument identifier resolves to a
callable definition AND is not shadowed by a parameter or local binding in
the enclosing function. So `def via(pool, handler): pool.submit(handler)`
(handler is the param, not the module function) and `process(config)` where
`config` is local data emit no edge, while a genuine module function passed
by name still resolves. Dedup-safe against existing direct `calls` edges.
Python only; dict-literal / getattr-by-string / decorator dispatch deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 21:53:46 +01:00
safishamsi 0c551ace36 release: 0.9.3
Cross-file member-call resolution for C++/ObjC (#1547/#1556) and
namespace-aware C# type resolution (#1562), the work-memory overlay
(#1441), test-mock call-graph fix (#1553), hyperedge member-key aliases
(#1561), plus the TS/JS/ObjC resolution fixes (#1316/#1544/#1552/#1475).
See CHANGELOG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v0.9.3
2026-06-30 20:00:00 +01:00
TheFedaikin b9d8067d0a feat(csharp): namespace-aware cross-file type resolution (#1562)
Extends the C# type-reference resolver (#1466) to be namespace-aware,
advancing the #1318 shadow-node umbrella for C#:

- The namespace is folded into the C# node id (_make_id(stem, namespace,
  name)), so two same-named types in different namespaces in one file no
  longer collapse — replacing #1466's detect-and-skip workaround for
  multi-namespace files.
- Lexical per-block using-scope: a `using` applies only where it is in
  scope (file-level, or the enclosing namespace block via a scope chain),
  so sibling namespace blocks no longer share each other's usings.
- Qualified references (`Namespace.Type`) resolve via in-scope aliases
  (`using Q = X.Y`) then exact known namespaces; generics are stripped.

Preserves (and tightens) the refuse-rather-than-guess discipline: a bare
reference resolves only when exactly one in-scope namespace provides the
type; an ambiguous reference (e.g. `using A; using B;` both defining
`Widget`) resolves to nothing rather than fanning out. Verified: `using A`
-> A.Widget only; ambiguous -> no edge; qualified `B.Widget` -> B.Widget
regardless of usings; sibling-block using-scope isolated; no dangling
edges or fan-out.

Reconciled onto current v8 (the PR predated the C++/ObjC member-call
resolvers); full suite green, the C++/ObjC resolution coexists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 19:32:10 +01:00
safishamsi 49252d3cb7 feat(extract): cross-file member-call resolution for C++ and ObjC (#1547, #1556)
Connects paired classes across files: Main.cpp's `Foo f; f.bar()` now resolves
to Foo::bar, and ObjC `Foo *f = [[Foo alloc] init]; [f doThing]` to Foo's
doThing — the "connect with other classes" goal of #1547/#1556.

Design grounded in prior-art research (ctags qualified-name matching, Doxygen's
name-keyed false-edge failure modes, PAIGE's receiver-type approach, Clang USR):
resolve by RECEIVER TYPE, never bare name, and skip when the type can't be
inferred rather than guess (a false call edge / god-node is worse than a missing
one). Mirrors the existing Swift/Python/Ruby/TS member-call resolvers.

- C++ extractor now captures the member-call receiver (field_expression /
  qualified_identifier / pointer access) and builds a per-file type table from
  local declarations (`Foo f;`, `Foo* f;`, `Foo *f = ...;`); emits raw_calls.
- ObjC extractor emits raw_calls for message sends with the receiver + selector
  and a type table from `Foo *f = ...;` locals (existing in-file selector /
  alloc-init / dot-syntax / @selector matching preserved).
- New _resolve_cpp_member_calls / _resolve_objc_member_calls, registered for
  their suffixes. Receiver tiers: `Foo::bar()` / capitalized ObjC receiver and
  this/self/super (enclosing class) -> EXTRACTED; local-var-typed -> INFERRED.
  Single-definition god-node guard (skip unless exactly one type def matches);
  the just-shipped decl/def class merge makes a paired class one def so the
  guard resolves it. Verified: a.run() -> A::run only (not a same-named B::run);
  an uninferable receiver with run() in two classes emits zero edges (no
  fan-out); ObjC [f doThing] -> Foo only.
- build.py: the cross-language INFERRED-call prune treated .h/.cpp/.m as
  different families and dropped header/impl interop calls; unified the C family
  (.c .h .cc .cpp .hpp .cxx .hh .hxx .cu .cuh .metal .m .mm) so a .cpp/.m call to
  a .h-declared method survives.

Still open (tracked on #1547/#1556): the file-level `#include` edge can stay
uncanonicalized when the project root isn't symlink-resolved (the extract()
id-remap `continue`s on a /var-vs-/private/var mismatch) — the class connection
above is robust to it; include-reachability candidate narrowing and ObjC
dynamic-dispatch/id-typed receivers also deferred (expected low ObjC recall, per
the research).

Reported by @c0dezer019 (#1547) and @JabberYQ (#1556).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:30:05 +01:00
safishamsi 3bc3feed54 fix(extract): merge header/impl class fragmentation + C++/ObjC header routing (#1556, #1547)
A class declared in a header (Foo.h/@interface) and defined in its impl
(Foo.cpp/Foo.m/@implementation) fragmented into two nodes: _file_stem
drops the extension so Foo.h and Foo.cpp share a node id, which
_disambiguate_colliding_node_ids then split apart by path — and the two
"defs" tripped every resolver's single-definition god-node guard,
cascading into missing .h<->.m/.cpp linkage and cross-file/cross-language
edges.

- Routing: a `.h` using `#import` now routes to extract_objc (#1556 bridging
  headers — extract_c drops `#import` as a preproc_call), and a `.h` with
  C++-only signals (class/namespace/template/::/access-specifiers) routes
  to extract_cpp (#1547 — the C grammar has no class_specifier, so a C++
  header previously yielded a junk node and lost every method). ObjC sniff
  keeps priority; a plain C header still routes to extract_c.
- Merge: a new _merge_decl_def_classes post-pass collapses the header/impl
  id-collision onto the header (declaration) variant, modeled on
  _merge_swift_extensions, gated so it fires ONLY for a clean sibling
  header/impl pair (same dir, same base stem, exactly one header) — two
  same-named classes in different directories have different stems and
  never collide, so they are never merged (god-node guard verified). C++
  method definitions retain their `Foo::` qualifier so a `Foo::bar` def
  keys onto the header declaration (one method node, not two); free
  functions keep their bare-name ids.

Result: one canonical class node per .h/.m or .h/.cpp pair with methods
unified, which unblocks the existing member-call resolvers (verified
Swift->ObjC calls and Swift `extension` folding now resolve). Strict
improvement over v8 (which produced junk/fragmented nodes here, verified).
Still open as follow-ups: cross-file C++ #include edge resolution and a
C++/ObjC cross-file member-call resolver (a pre-existing gap, not a
regression).

Reported by @JabberYQ (#1556) and @c0dezer019 (#1547).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:23:46 +01:00
safishamsi bee3849810 fix(resolve): test mocks no longer erase the real cross-file call graph (#1553)
The cross-file call resolver bailed (#543/#1219 god-node guard) whenever a
bare callee name had 2+ definitions without unique import evidence — so a
single same-named test mock (or any same-named symbol) dropped the real
`calls` edge, erasing the call graph wherever a mock existed (the reporter
saw a 76-stub Pester suite wipe everything).

Replace the blunt bail with a smarter guard: when a name is ambiguous and
import evidence doesn't resolve it, apply tie-breakers — non-test
preference (a shared, segment-aware _is_test_path classifier) then path
proximity — and emit an INFERRED edge ONLY if exactly one candidate
survives, else keep bailing. A real def + a test mock resolves to the real
def; two genuine non-test defs still bail (god-node guard intact, no
fan-out). Wired into both the extract.py pass and the symbol_resolution.py
copy via the shared classifier.

Reported by @Schweinehund.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:59:16 +01:00
safishamsi bd885cc97d fix(hyperedge): accept members/node_ids alias keys for the member list (#1561)
A hyperedge's member list is canonically keyed `nodes`, but producers
(LLM/subagent drift, externally-supplied graph.json) sometimes emit
`members` or `node_ids` — graphify only read `nodes`, so those hyperedges
silently lost their members, and semantic_cleanup's prune dropped them
entirely. Normalize the member key to `nodes` at one ingest chokepoint in
build_from_json (and in semantic_cleanup, which runs pre-build), deduping
and warning, so every downstream consumer sees the canonical key. Mirrors
the existing from/to edge-endpoint aliasing.

Reported by @askalot-io.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:58:23 +01:00
safishamsi c865a3c9b0 fix(reflect): layout-ordered source resolution for overlay staleness (#1558)
Refines the staleness file resolution (00e00a0) by folding in the two
genuine merits of @TPAteeq's parallel fix (#1558), which independently
and correctly diagnosed the same root-mismatch bug:

- Layout-ordered candidates: try the layout-appropriate root FIRST (the
  graphify-out parent for the standard layout, graph.json's own dir for a
  flat layout) before the other. The prior order tried the grandparent
  first unconditionally, which in a flat layout (graph.json at the project
  root) could fingerprint a same-named file one directory up. Existence
  checking is kept on top, so a defeated name heuristic or a stale
  .graphify_root marker still falls through to the real file.
- Adds @TPAteeq's .graphify_root-marker-driven regression test, plus a
  flat-layout test that pins the ordering (editing the real file flips
  stale; editing the same-named decoy one dir up does not).

Co-Authored-By: tpateeq <mohammedateequddin399@gmail.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 12:57:33 +01:00
safishamsi 2cdc212b43 docs: README work-memory overlay note + Unreleased changelog
README: document that `reflect --graph` writes the .graphify_learning.json
overlay and that explain/query surface a Lesson hint (with the
code-changed staleness flag).

CHANGELOG: add an Unreleased section for the post-0.9.2 work — the
work-memory overlay (#1441/#1542), this.field.method() injected-field
resolution (#1316), TS wildcard path aliases (#1544), JS namespace
re-exports (#1552), and the ObjC dot-syntax/@selector edges (#1475/#1543).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 12:42:25 +01:00
safishamsi 00e00a0b5f fix(reflect): work-memory staleness false-positive on relative source paths
The overlay fingerprint resolved a node's source_file against
graph_path.parent (the graphify-out/ dir), but source_file is stored
relative to the PROJECT root — so graphify-out/auth.py never existed and
_is_stale flagged EVERY verdict "code changed since — re-verify" the
moment it was written. (The original staleness test used an absolute
source_file, which masked it.)

Fix: resolve the file by trying the likely roots in order (.graphify_root
marker, graphify-out's parent, graph.json's own dir, cwd) and use the
first that exists — the same search at write and read — and fingerprint
file CONTENT only (sha256 of bytes, no path mixed in) so the hash is
root-independent and a committed sidecar stays valid across checkouts.
Drops the brittle directory-name-based root guess.

Adds a regression test with a relative source_file under the graphify-out
layout (stale=False right after reflect, True after an edit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 12:40:20 +01:00
safishamsi 5779767fd3 feat(reflect): work-memory overlay — surface learned verdicts as a graph sidecar (#1441)
Projects the verdicts `graphify reflect` already distills (preferred /
tentative / contested, exponential time-decayed) into a derived
experiential layer the read surfaces consume, so accumulated agent
experience actually shows up where you look — without polluting the
structural graph.

Design (grounded in agent-memory + provenance literature; a redesign of
the #1542 approach):
- SIDECAR, not graph.json stamping. `reflect` writes `.graphify_learning.json`
  next to graph.json (an additional output, so the git hooks produce it
  automatically). graph.json stays purely structural; nothing leaks into
  GraphML; no graph.json churn. Mirrors the named-graph / event-sourcing
  separation of durable truth from a derived layer.
- Reuses the existing reflect aggregate (its `_decay` is the
  recency-weighted exponential model; `_finalize_sources` the
  classification) — no new scoring.
- PROVENANCE: each verdict carries the source questions/dates that produced
  it (cap 5, most-recent first).
- STALENESS: each verdict stores the node's file fingerprint; on read, a
  changed source file flags the verdict stale ("code changed since —
  re-verify") rather than presenting a confident lesson on rewritten code.
- CONTESTED surfaced distinctly (useful N / dead-end M), not averaged away.
- DEAD-ENDS stay QUERY-SCOPED — never a node-level status; they appear only
  in the report as question -> nodes.
- Read surfaces (explain / query+MCP / GRAPH_REPORT / graph.html) merge the
  overlay at read time, sanitized; un-annotated graphs are byte-identical.

Deferred (logged): letting verdicts influence query/seed traversal — the
recommender feedback-loop / Matthew-effect risk means that needs
propensity correction + exploration, not naive biasing.

Builds on the idea in #1441/#1542 (thanks @TPAteeq).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 11:19:54 +01:00
guy oron 0792b419fc feat(objc): dot-syntax property accesses and @selector() call edges (#1475, #1543)
`self.product.name` dot-syntax now emits an `accesses` edge and
`@selector(method)` emits a `calls` edge, both resolved only to an
unambiguous in-scope definition (a sibling method of the same class for
dot-syntax; exactly one method by exact selector name for @selector) so
no false-edge fan-out occurs when multiple classes share a name.

Hardened over the original PR: resolution now matches the method node id
EXACTLY (a method id is _make_id(container, name)) rather than by
`endswith` suffix. The substring match would mis-resolve `self.name` to a
sibling `-surname` (false positive) and, when a substring-colliding
sibling existed, suppress the correct edge (false negative); exact
matching fixes both. Adds substring-collision regression tests
(`-name`/`-surname`, `-doThing`/`-reallyDoThing`).

Completes the #1475 ObjC follow-ups (Bug 5 dot-syntax accesses, Bug 6b
@selector target-action).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:54:28 +01:00
guy oron 1801da0634 feat(ts): resolve this.field.method() calls via constructor-injection types (#1316)
A member call through a constructor-injected dependency
(`constructor(private db: Database)` ... `this.db.query()`) now produces
a calls edge to the field type's method. The field->type map is captured
from constructor parameter-properties, and resolution reuses the existing
single-definition god-node guard (like the Swift/Python/Ruby member-call
resolvers): the edge is emitted only when the field's type name resolves
to exactly one class definition that owns the method, so an ambiguous or
unknown/untyped field produces no edge — no global name-match fan-out.
Edges are EXTRACTED (the type is explicit from the annotation). TS/JS-only
and additive; scope is constructor parameter-property injection.

Adds the decisive regression tests the implementation needed: two classes
defining the same method name where the injected field is typed to one of
them (must resolve to that one only), and an ambiguous type-name case
(must emit no edge).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:46:16 +01:00
oleksii-tumanov c8c604d08c feat(js): resolve namespace re-export bindings (export * as ns from) (#1552)
`export * as ns from './mod'` now creates a real symbol node for the
namespace binding `ns`, registers it as a named export (so a downstream
`import { ns }` resolves to it), and emits a file-level `re_exports` edge
to the target module. The binding is treated as a single opaque symbol —
`ns.member` accesses are deliberately NOT expanded into per-member
name-matching, avoiding the over-linking that would fan false edges.
Includes re-export cycle and deep-chain recursion guards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:42:49 +01:00
oleksii-tumanov 57469641cd feat(js): resolve TypeScript wildcard path aliases (#1544)
Extends the tsconfig path-alias resolver (#1531) with single-`*` wildcard
capture and substitution: a pattern like `@app/*` or `@*/interfaces`
captures the matched segment and substitutes it into each target in
declared order, honoring baseUrl and tsc's longest-prefix / exact-wins
specificity rules, and preserving #1531's first-existing-target-wins
fallback (no false edge when nothing resolves). Builds on the
_resolve_tsconfig_alias helper rather than reintroducing inline loops;
multi-star patterns remain out of scope.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:41:50 +01:00