Repository navigation
fix(vbnet): resolve cross-file calls to Module members, typed locals and C# statics (#4570) - #4573
Deepjyoti-Sarmah wants to merge 2 commits into
Conversation
…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.
|
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. |
There was a problem hiding this comment.
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-safetytests/test_affected_cli.py— full-run-safetytests/test_affected_defines.py— full-run-safetytests/test_affected_member_seed.py— full-run-safetytests/test_affected_overrides.py— impact, full-run-safetytests/test_agents_platform.py— full-run-safetytests/test_alias_folding.py— full-run-safetytests/test_analyze.py— full-run-safetytests/test_anthropic_custom_endpoint.py— full-run-safetytests/test_antigravity_install.py— full-run-safetytests/test_apm_fallback_version.py— full-run-safetytests/test_architecture_doc.py— full-run-safetytests/test_astro_extraction.py— impact, full-run-safetytests/test_astro_import_ids.py— impact, full-run-safetytests/test_atomic_canvas_export.py— full-run-safetytests/test_atomic_version_stamp.py— full-run-safetytests/test_atomic_writes.py— full-run-safetytests/test_backend_env_isolation.py— full-run-safetytests/test_backend_extras.py— full-run-safetytests/test_backend_opt_in.py— full-run-safetytests/test_benchmark.py— full-run-safetytests/test_benchmark_raw_graph.py— full-run-safetytests/test_benchmark_real_query.py— full-run-safetytests/test_blade_extractor.py— impact, full-run-safetytests/test_build.py— impact, full-run-safetytests/test_build_located_semantic_identity.py— full-run-safetytests/test_build_merge_dedup_scope.py— full-run-safetytests/test_build_merge_hyperedges_and_prune.py— full-run-safetytests/test_build_merge_shrink_guard.py— full-run-safetytests/test_builtin_global_type_refs.py— impact, full-run-safetytests/test_c_include_resolution.py— impact, full-run-safetytests/test_cache.py— impact, full-run-safetytests/test_cache_stale_import_target.py— impact, full-run-safetytests/test_callflow_html.py— full-run-safetytests/test_cargo_introspect.py— full-run-safetytests/test_cargo_missing_manifest.py— full-run-safetytests/test_carried_hyperedge_remap.py— full-run-safetytests/test_case_sensitive_resolution.py— impact, full-run-safetytests/test_charmap_encoding.py— impact, full-run-safetytests/test_chunking.py— impact, full-run-safetytests/test_cjs_module_extension.py— impact, full-run-safetytests/test_claude_cli_backend.py— full-run-safetytests/test_claude_md.py— full-run-safetytests/test_cli_broken_pipe.py— full-run-safetytests/test_cli_export.py— full-run-safetytests/test_cli_help.py— full-run-safetytests/test_cloud_cta.py— full-run-safetytests/test_cluster.py— full-run-safetytests/test_cluster_ambiguous_scale.py— full-run-safetytests/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)
ARCHITECTURE.md§ Calling extract() from your own code (lines 36-49): references changed symbolsresolveCHANGELOG.md§ 0.9.87 (2026-10-12) (lines 102-124): references changed symbolsresolveCHANGELOG.md§ 0.9.85 (2026-10-10) (lines 147-169): references changed symbolsresolveCHANGELOG.md§ 0.9.84 (2026-10-10) (lines 170-183): references changed symbols__init__CHANGELOG.md§ 0.9.83 (2026-10-09) (lines 184-198): references changed symbolsresolveCHANGELOG.md§ 0.9.80 (2026-10-07) (lines 224-243): references changed symbolsresolveCHANGELOG.md§ 0.9.77 (2026-10-05) (lines 264-283): references changed symbols__init__CHANGELOG.md§ 0.9.65 (2026-09-20) (lines 441-453): references changed symbolsresolveCHANGELOG.md§ 0.9.61 (2026-09-12) (lines 493-497): references changed symbolsresolveCHANGELOG.md§ 0.9.58 (2026-09-10) (lines 516-533): references changed symbols__init__,first,resolve
…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).
What does this PR do?
Fixes #4570. In a VB.NET project,
callsedges appeared only when the caller and the callee were in the same file.For each invocation,
extract_vbnetpicks the type that owns the target. An unqualified call or aMecall gets the caller's own type. A qualified call is looked up inowner_by_short, which only holds types declared in that file. Anything else (a Module in another file, a local variable, a C# type) gottarget_owner = Noneand 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)forPartialtypes. The shared name pass skips VB calls because they are marked as member calls.Caller.vbLogIt("x"), a Module Sub in another fileCaller, no edgeLogItinHelpers.vb, EXTRACTEDHelpers.LogIt("x"), Module in another fileDim w As New Worker()thenw.Save()Worker.Save, INFERREDCsRules.IsLocked(1), a C# static.Save()insideWith wSavehandler(1),handlera localAction(Of Integer)handlermethodThe 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'sImportsand the caller's nested types. The raw calls keepis_member_call, so the shared name pass still never sees them.resolve_vbnet_callsreplaces the partial-type resolver (the old name stays as an alias and as the registry name) and follows VB's lookup order: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:
ImportsImports, or Module in an enclosing namespaceLogIt(any arity)LogIt, even at different aritiesHelpersin namespaces the caller doesn't importAs New Worker(),As Worker = New Worker(),Dim w = New Worker()Worker.SaveCsRules.IsLocked(1)andcsrules.islocked(1)from VB.Save()inWith ww.Savehandler(1),handlera local delegatewatch.pynow forwardsowner,full_name,accepted_aritiesandnamespaceto the context nodes of unchanged files (#2406). Without them, an unchangedHelpers.vbreached 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_callsbinds the same as a full build on an incremental rebuild. WithSvcin bothAlphaandBetaand a caller withusing Beta, v8 bound an incremental rebuild toAlpha.Svc.Save, and now bindsBeta.Svc.Save. Only the VB resolver and that C# resolver readmetadata.namespace._AST_CACHE_SCHEMAgoes 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):After:
Type of change
Verification & Invariants
A VB.NET call gets a
callsedge exactly when the VB compiler's lookup gives one target in the corpus, and never to a target the compiler would not pick.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
callsedges, soaffected,path,explain, god nodes and communities change for VB projects. Two kinds of wrong v8 edges go away (Withblocks, local delegates). C# changes only on incremental rebuilds, where it now matches a full build. Bumping_AST_CACHE_SCHEMAinvalidates the whole AST cache, so the first forced run after upgrading re-extracts every code file once.Limitations
Private _w As Worker, then_w.Save()) is not tracked. Recorded as a strict xfail.Withblock is not tracked, so.Save()gets no edge rather thanw.Save. Recorded as a strict xfail.ImportsandRootNamespacefrom the.vbprojare 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.Sharedversus instance members are not modeled.LogIt), and nested VB types are not extracted at all.How was this tested?
New tests in
tests/test_vbnet_extractor.py, built onextract()with real files undertmp_path. They find nodes by label and file name, not by id. Each expected result comes from the compiler cases above.As New T()/As T = New T()(×2)LogIt/logit/LOGITall bind the Module.Save()in aWithblock is not a call on the caller's ownSaveDim w = New Worker()As WorkerImports, Module in an enclosing namespaceAs Objectreceiver binds to nothingPartialcalls keep their edges and contextHelpersin namespaces not imported give no edgeWithblock targetMost 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.
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
uv run python -m tools.skillgen --bless) when changing their source fragments. (N/A: no skill fragments touched.)