Skip to content

fix(vbnet): resolve cross-file calls to Module members, typed locals and C# statics (#4570) - #4573

Open
Deepjyoti-Sarmah wants to merge 2 commits into
Graphify-Labs:v8from
Deepjyoti-Sarmah:fix/4570-vbnet-cross-file-calls
Open

Deepjyoti-Sarmah wants to merge 2 commits into
Graphify-Labs:v8from
Deepjyoti-Sarmah:fix/4570-vbnet-cross-file-calls

Conversation

@Deepjyoti-Sarmah

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes #4570. In a VB.NET project, calls edges appeared only when the caller and the callee were in the same file.

For each invocation, extract_vbnet picks the type that owns the target. An unqualified call or a Me call gets the caller's own type. A qualified call is looked up in owner_by_short, which only holds types declared in that file. Anything else (a Module in another file, a local variable, a C# type) got target_owner = None and was dropped: no edge and no raw call, so no resolver could see it. The only VB resolver, resolve_vbnet_partial_calls, matches (owner, name, arity) for Partial types. The shared name pass skips VB calls because they are marked as member calls.

Call in Caller.vb v8 This PR
LogIt("x"), a Module Sub in another file raw call with owner Caller, no edge LogIt in Helpers.vb, EXTRACTED
Helpers.LogIt("x"), Module in another file dropped EXTRACTED
Dim w As New Worker() then w.Save() dropped Worker.Save, INFERRED
CsRules.IsLocked(1), a C# static dropped EXTRACTED
.Save() inside With w wrong edge to the caller's own Save no edge
handler(1), handler a local Action(Of Integer) wrong edge to a handler method no edge

The extractor now emits a raw call for every invocation, with the written receiver, its kind (none, me, mybase, name, local), the declared type of a local or parameter, the caller's namespace, the file's Imports and the caller's nested types. The raw calls keep is_member_call, so the shared name pass still never sees them. resolve_vbnet_calls replaces the partial-type resolver (the old name stays as an alias and as the registry name) and follows VB's lookup order:

resolve(call)
  unqualified / Me
    nearest of own type, then in-corpus bases, that declares the name -> it decides, at any arity
    a field, property or event with the name -> no edge
    else Modules in scope (same, enclosing, global or imported namespace) with that member
      exactly one Module, exactly one arity match -> edge, else none
  MyBase                -> the base chain only
  local / parameter     -> its declared type, then member lookup          (INFERRED)
  Type.M() / Module.M() -> a field or nested type of that name hides it
                           else the type unique in scope, VB or C#, then member lookup
  a VB target must accept the arity; a C# target must be the only method of that name
  a caller whose base chain leaves the corpus -> INFERRED, context external_base_may_hide

Only a unique match makes an edge. Nothing falls back to a name match across the corpus.

I checked every rule against the compiler before writing it. I used dotnet SDK 8.0 in a container, offline, with small VB projects that print which method ran:

Case Compiler
unqualified call, Module in another file, same namespace binds the Module
Module in another namespace, no Imports BC30451
with Imports, or Module in an enclosing namespace binds the Module
caller's class or its base declares LogIt (any arity) binds the class method, or BC30455
two Modules declare LogIt, even at different arities BC30562
field, property, delegate field or nested type with the name hides the Module
two Helpers in namespaces the caller doesn't import BC30451
As New Worker(), As Worker = New Worker(), Dim w = New Worker() Worker.Save
CsRules.IsLocked(1) and csrules.islocked(1) from VB the C# method
.Save() in With w w.Save
handler(1), handler a local delegate the delegate

watch.py now forwards owner, full_name, accepted_arities and namespace to the context nodes of unchanged files (#2406). Without them, an unchanged Helpers.vb reached the resolver with no owner. This has two side effects: the existing partial-type resolver now works on incremental rebuilds too, and _resolve_csharp_qualified_calls binds the same as a full build on an incremental rebuild. With Svc in both Alpha and Beta and a caller with using Beta, v8 bound an incremental rebuild to Alpha.Svc.Save, and now binds Beta.Svc.Save. Only the VB resolver and that C# resolver read metadata.namespace.

_AST_CACHE_SCHEMA goes from 17 to 18. #4567 and #4572 bump the same line from 17 to 18, so whichever lands later needs 19.

Before (the issue's repro, extract --code-only --force):

A_SameFileSelf -> B_Target()   EXTRACTED
.G_CsToVb      -> LogIt()      EXTRACTED

After:

A_SameFileSelf      -> B_Target()    EXTRACTED
C_UnqualifiedModule -> LogIt()       EXTRACTED  call
D_QualifiedModule   -> LogIt()       EXTRACTED  call
E_TypedLocal        -> Save()        INFERRED   call
F_CsStatic          -> .IsLocked()   EXTRACTED  call
.G_CsToVb           -> LogIt()       EXTRACTED  call

Type of change

  • Bug fix
  • New feature
  • Documentation
  • Tests or CI
  • Refactor
  • Security fix

Verification & Invariants

A VB.NET call gets a calls edge exactly when the VB compiler's lookup gives one target in the corpus, and never to a target the compiler would not pick.

  • Read the CONTRIBUTING.md guide.
  • Reproduced the issue and identified the invariant.
  • Made the smallest fix necessary.
  • Added a regression test (if bug fix) or isolated boundary test.
  • Kept the PR description synchronized with the final implementation.
  • Documented any limitations / unsupported cases explicitly.

Merge Danger

Door: two-way. New raw-call fields, a resolver, four metadata keys forwarded in watch.py, and a cache schema bump. No id, graph format or manifest change. Reverting restores v8 output after one re-extract.

Blast Radius: moderate. VB.NET graphs gain many calls edges, so affected, path, explain, god nodes and communities change for VB projects. Two kinds of wrong v8 edges go away (With blocks, local delegates). C# changes only on incremental rebuilds, where it now matches a full build. Bumping _AST_CACHE_SCHEMA invalidates the whole AST cache, so the first forced run after upgrading re-extracts every code file once.

Limitations

  • A receiver typed by a field (Private _w As Worker, then _w.Save()) is not tracked. Recorded as a strict xfail.
  • The target of a With block is not tracked, so .Save() gets no edge rather than w.Save. Recorded as a strict xfail.
  • Project-level Imports and RootNamespace from the .vbproj are not visible. This can only miss a Module, never pick a wrong one, because the compiler sees a superset of what graphify sees. Exception: in a corpus with several projects that don't reference each other, a Module from an unreferenced project can be bound.
  • Shared versus instance members are not modeled.
  • A C# target with overloads gets no edge from VB, because C# method nodes carry no arity.
  • Found while testing, separate bugs, recorded as strict xfails: the C# member resolver ignores arity when calling an overloaded VB Module (it bound the two-argument overload of LogIt), and nested VB types are not extracted at all.

How was this tested?

New tests in tests/test_vbnet_extractor.py, built on extract() with real files under tmp_path. They find nodes by label and file name, not by id. Each expected result comes from the compiler cases above.

Test v8 This PR
unqualified call reaches a Module Sub in another file fail pass
module-qualified call reaches a Module in another file fail pass
local typed As New T() / As T = New T() (×2) fail pass
VB call reaches a C# static fail pass
LogIt / logit / LOGIT all bind the Module fail pass
incremental rebuild of only the caller keeps the edge (#2406) fail pass
external base: unqualified and qualified Module call is INFERRED (×2) fail pass
.Save() in a With block is not a call on the caller's own Save fail: wrong edge pass
a local delegate call is not a call of a same-named method fail: wrong edge pass
schema-17 cache entry is not served fail pass
inferred local Dim w = New Worker() fail pass
parameter typed As Worker fail pass
Module through Imports, Module in an enclosing namespace fail pass
edge confidence for each form (×4) fail pass
inherited method reached fail pass
lowercase VB call reaches a C# method fail pass
own method wins; inherited method is never replaced by a Module pass pass
class method hides the Module at any arity pass pass
two Modules with the same name give no edge (×2) pass pass
As Object receiver binds to nothing pass pass
never binds a same-named Python function pass pass
same-file and Partial calls keep their edges and context pass pass
C# → VB Module call keeps its edge pass pass
Module in a namespace that isn't imported is out of scope pass pass
unqualified call never binds an unrelated class's method (VB, C#) pass pass
field or nested class named like the Module hides it (×2) pass pass
two Helpers in namespaces not imported give no edge pass pass
property, field or delegate field named like the Sub hides it (×3) pass pass
field-typed receiver xfail xfail (limitation)
With block target xfail xfail (limitation)
C# call to an overloaded VB Module picks by arity xfail xfail (separate bug)
nested class named like the Module is the target xfail xfail (separate bug)

Most of the guards pass on v8 only because v8 binds no cross-file VB calls at all. They protect the new resolver. The three property/field guards failed on an earlier version of this branch, before the member check was added. The cache test fails with the schema set back to 17.

pytest tests/test_vbnet_extractor.py -q (new tests, on v8 86c57be8)   -> 22 failed, 26 passed, 4 xfailed
pytest tests/test_vbnet_extractor.py -q (this branch)                 -> 48 passed, 4 xfailed
pytest -q (full suite, --basetemp=/var/tmp)                           -> 8432 passed, 14 skipped, 4 xfailed
ruff check graphify/ tests/test_vbnet_extractor.py                    -> All checks passed

One full-suite run had a single failure in test_pr10_watch_overrides.py::test_ignored_root_configuration_still_rebuilds_the_graph[True], a PHP watcher test. It passed on the next full run and 8 out of 8 times alone, on both v8 and this branch.

Graphify-specific checklist

  • I updated generated skill artifacts (uv run python -m tools.skillgen --bless) when changing their source fragments. (N/A: no skill fragments touched.)
  • I confirmed that AST/structural extraction remains deterministic (no ambient state dependencies like ENV variables).
  • I reviewed changes for security implications (no unsafe interpolation into shell/Python).
  • I confirmed no API keys or local-only graph data are included.
  • (If applicable) I disclosed AI authorship in my commit messages.

…and C# statics (Graphify-Labs#4570)

VB.NET calls produced a `calls` edge only when caller and callee were in
the same file. A call to a Module Sub declared elsewhere, a qualified
call such as `Helpers.LogIt(...)`, a call through a local declared with
`As New T()`, and a call to a C# static method were all dropped by the
extractor, and the shared name pass skips member calls, so nothing
picked them up.

The extractor now keeps every call it cannot resolve in its own file as
a raw call, with the receiver, the receiver's kind (none, Me, MyBase, a
local, or a name), the declared type of a local, parameter or loop
variable, the caller's namespace and Imports, and the nested types of
the caller's type. A new resolver, resolve_vbnet_calls, binds them with
VB's own scoping rules, taken from compiling each case with the .NET 8
SDK:

- the caller's type and its base classes hide a Module's member of the
  same name, whatever its arity;
- a field, a property or an event of that name also hides the Module's
  Sub (a delegate field is invoked instead);
- a Module is in scope only through its own, an enclosing, or an
  imported namespace, and two in-scope Modules that declare the name
  give no edge;
- a field, a property or a nested type named like the receiver hides
  the type;
- a type name must be unique in scope;
- a C# target needs to be the only method of that name on its type,
  because C# method nodes carry no arity.

Only a unique match makes an edge. Qualified and unqualified Module
calls and base-class calls are EXTRACTED; calls through a typed local
and casefolded C# names are INFERRED (0.85). When the caller's base
chain leaves the corpus (a Form, a Page), the edge is INFERRED with
context `external_base_may_hide`, since that base may declare the name.
The resolver replaces resolve_vbnet_partial_calls, which stays as an
alias and keeps its registry name, and its `partial_type_call` edges
are unchanged.

v8 bound two kinds of call to a same-named method of the caller's own
type, which the compiler does not: `.Save()` inside a With block (it
calls the With object's method) and a call of a local delegate (the
local hides the method). Both wrong edges are gone.

watch.py now forwards the metadata keys `owner`, `full_name`,
`accepted_arities` and `namespace` to the resolvers as context for an
incremental rebuild. Two side effects follow. The partial-class
resolver, which needs `owner` and `accepted_arities`, now works across
changed and unchanged files. And the C# resolvers see the namespace of
unchanged types: a `using Beta` caller of a `Svc` declared in both
Alpha and Beta bound to Alpha.Svc on an incremental rebuild and to
Beta.Svc on a full one, and now binds to Beta.Svc on both.

The AST cache schema goes from 17 to 18 because VB raw calls change.

Limits: project-level Imports and RootNamespace from the .vbproj are not
read; in a multi-project corpus whose projects do not reference each
other, a Module from an unreferenced project can be bound; Shared
versus instance is not modeled; a call through a field and a call
inside a With block are not resolved to the receiver's type.
@github-actions github-actions Bot added the contributor Pull request from a returning community contributor label Oct 19, 2026
@github-actions

Copy link
Copy Markdown

Thanks for the pull request, @Deepjyoti-Sarmah. A maintainer will review it soon.

Want to talk it through while it is in review? Come join us on our Discord server. For longer-form discussion there is also GitHub Discussions.

A couple of things that speed up review: make sure the test suite passes on Python 3.10 and 3.13, and that the change keeps extraction deterministic.

@graphify-labs graphify-labs Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 2 advisory finding(s) below merit a look before merge.

Not checked: tests were not run; formal verification proved 0 of 2 changed function(s) (1 sampled, not proven, 1 not verified).

Formal verification. PR-changed functions: 1/2 verified (0 proven, 1 may-equivalent, 0 distinguished) · 1 not verified (1 unsupported).

Not verified on this run: \_rebuild\_code (unsupported).


Graphify review — findings

Adds a cross-file VB.NET call resolver that builds a corpus-wide index of VB types, members and C# targets, then links calls through receivers, declared local/parameter types, imports and enclosing namespaces, including C# calls into overloaded VB module subs matched by arity. Raw VB calls now carry the receiver, its kind and declared type, the namespace and the imports, so type lookup through interfaces, imported-namespace casing and Global.-qualified names decides which edges resolve; anything it can't type (generics, arrays, untyped locals) is left unlinked rather than guessed. Bumps _AST_CACHE_SCHEMA to 18 to invalidate stale cached VB extractions, and swaps the vbnet_partial_calls language resolver over to resolve_vbnet_calls.

Worth a look

  • VB interface types are indexed but not findable — graphify/extractors/vbnet.py · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Imported namespace casing prevents VB type lookup — graphify/extractors/vbnet.py · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review

Review partial — this diff was larger than one review pass covers, so later files were not reviewed; some findings may be missing.

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 3712 functions depend on the 593 functions this change touches.

Health — this change adds coupling hotspots:

  • worse: extract() — 976 callers, 59 callees
  • worse: _rebuild_code() — 189 callers, 60 callees
  • worse: save_cached() — 49 callers, 9 callees

Verification — 3712 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 3504 function(s) in the blast radius were not formally verified this run

Test selection

Test selection

432 of 432 test file(s) selected (100%) via static blast radius.

Escalated to a full run for safety — the selection is not trustworthy on its own (see below). CI should run the whole suite.

  • tests/pi.test.ts — full-run-safety
  • tests/test_affected_cli.py — full-run-safety
  • tests/test_affected_defines.py — full-run-safety
  • tests/test_affected_member_seed.py — full-run-safety
  • tests/test_affected_overrides.py — impact, full-run-safety
  • tests/test_agents_platform.py — full-run-safety
  • tests/test_alias_folding.py — full-run-safety
  • tests/test_analyze.py — full-run-safety
  • tests/test_anthropic_custom_endpoint.py — full-run-safety
  • tests/test_antigravity_install.py — full-run-safety
  • tests/test_apm_fallback_version.py — full-run-safety
  • tests/test_architecture_doc.py — full-run-safety
  • tests/test_astro_extraction.py — impact, full-run-safety
  • tests/test_astro_import_ids.py — impact, full-run-safety
  • tests/test_atomic_canvas_export.py — full-run-safety
  • tests/test_atomic_version_stamp.py — full-run-safety
  • tests/test_atomic_writes.py — full-run-safety
  • tests/test_backend_env_isolation.py — full-run-safety
  • tests/test_backend_extras.py — full-run-safety
  • tests/test_backend_opt_in.py — full-run-safety
  • tests/test_benchmark.py — full-run-safety
  • tests/test_benchmark_raw_graph.py — full-run-safety
  • tests/test_benchmark_real_query.py — full-run-safety
  • tests/test_blade_extractor.py — impact, full-run-safety
  • tests/test_build.py — impact, full-run-safety
  • tests/test_build_located_semantic_identity.py — full-run-safety
  • tests/test_build_merge_dedup_scope.py — full-run-safety
  • tests/test_build_merge_hyperedges_and_prune.py — full-run-safety
  • tests/test_build_merge_shrink_guard.py — full-run-safety
  • tests/test_builtin_global_type_refs.py — impact, full-run-safety
  • tests/test_c_include_resolution.py — impact, full-run-safety
  • tests/test_cache.py — impact, full-run-safety
  • tests/test_cache_stale_import_target.py — impact, full-run-safety
  • tests/test_callflow_html.py — full-run-safety
  • tests/test_cargo_introspect.py — full-run-safety
  • tests/test_cargo_missing_manifest.py — full-run-safety
  • tests/test_carried_hyperedge_remap.py — full-run-safety
  • tests/test_case_sensitive_resolution.py — impact, full-run-safety
  • tests/test_charmap_encoding.py — impact, full-run-safety
  • tests/test_chunking.py — impact, full-run-safety
  • tests/test_cjs_module_extension.py — impact, full-run-safety
  • tests/test_claude_cli_backend.py — full-run-safety
  • tests/test_claude_md.py — full-run-safety
  • tests/test_cli_broken_pipe.py — full-run-safety
  • tests/test_cli_export.py — full-run-safety
  • tests/test_cli_help.py — full-run-safety
  • tests/test_cloud_cta.py — full-run-safety
  • tests/test_cluster.py — full-run-safety
  • tests/test_cluster_ambiguous_scale.py — full-run-safety
  • tests/test_cluster_exclude_hubs.py — full-run-safety
  • … and 382 more

changed code file(s) with no mapped test (graphify/extractors/vbnet.py) — a coverage gap or a missing link — running the full suite rather than only the selected tests

Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.

Docs that may be stale (advisory)

…and 10 more.

Formal verification

No difference found (not proven): No behavior difference found in extract\_vbnet (not a proof).

The verifier ran both versions of extract\_vbnet on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify \_rebuild\_code.

The verifier did not have enough to check \_rebuild\_code, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: no capturable inputs from the test suite; property tier: nondeterministic output (both versions disagreed with themselves on the witness) — not a behaviour change; a function whose output is not a function of its inputs (random / time / uuid / os.urandom, or state carried across calls) cannot be soundly distinguished by execution, so this tier abstains for the whole function

· 3 more finding(s) on lines outside this diff (see the check run).

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor Pull request from a returning community contributor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: vbnet: cross-file and VB -> C# calls produce no calls edges (Module functions, typed locals, C# statics)

1 participant