From 889b6a35264e6837f245485d3a9fc33851a55ac3 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 30 Sep 2026 10:15:37 +0000 Subject: [PATCH] fix(vbnet): a member access resolves through what the line names (#2149) VB.NET's extractor keeps a call's last name only, so `Me.Panel.Controls.Add(x)` and `New System.Drawing.Size(1, 2)` arrived as bare `Add` / `Size` and exact / fuzzy matching took any class's member (even an enum case). The receiver is now read from the line: a member candidate needs `Me` / `MyBase` / `MyClass`, its own type or module as the last receiver segment, or a `(Of T)` type argument (`GetService(Of Notifier).Notify()`). Genuinely bare names are unchanged. A/B (edges removed / added): SCrawler -3452/+6, remoteapptool -143/+0, CompactGUI -31/+5, ViVeTool-GUI -1, TaskbarX 0. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/vbnet-member-access.test.ts | 87 +++++++++++++++++++++++++++ src/resolution/name-matcher.ts | 53 ++++++++++++++++ 3 files changed, 141 insertions(+) create mode 100644 __tests__/vbnet-member-access.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a2b73fa8..a7293062d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- In VB.NET, a call through a receiver like `Me.Panel.Controls.Add(…)` or `New System.Drawing.Size(…)` no longer lands on an unrelated class's member with the same name. It now resolves only through `Me`, `MyBase` or `MyClass`, through the member's own class or module (`Logger.Log(…)`), or through a service lookup's type (`GetService(Of Notifier).Notify()`). On SCrawler, the Windows Forms designer code used to link over 700 `Size` calls to a nested enum's case and over 500 `Controls.Add` calls to a collection class. - In CFML, a call no longer lands on an unrelated component's method just because the name matches. A call with no receiver inside a component now reaches only that component's methods and those of the components it extends, so built-ins like `now()` stay built-ins. A call on an untyped variable now needs the variable to be named after the component, so `server.keyExists(…)` is no longer taken for some component's `keyExists`. On coldbox, `now()` used to land on a date helper over 200 times, and TestBox's `fail(…)` in specs now reaches the spec base class. - In Ruby, a call no longer lands on an unrelated class's method just because it's the only method with that name. A call on an untyped receiver now needs the receiver to be named after the method's class, like `web_push_request.legacy_encrypt`. A call with no receiver inside a class now reaches only that class's methods, its superclasses' methods, and those of the modules it includes, extends or prepends. On rubocop, `node.loc` on a syntax node used to land on the project's one `loc` method over 1,200 times, and Kernel's `format(…)` on the language server's `format`. On mastodon, `render` in controllers used to land on an inline renderer, and `authorize` now reaches the authorization concern. - In Kotlin, a call to a top-level or extension function now resolves only to one the file can see: one in its own package, one it imports by name, or one from a package it imports with `*`. On Exposed, tests that imported kotlin.test's or JUnit's `assertEquals`, or the JDBC suite's helper, used to land on the R2DBC suite's helper over 3,000 times. On okhttp, tests that import `mockwebserver3.MockResponse` used to construct the deprecated class of the same name. A function type with a qualified receiver, like `(DatabaseConfig.Builder.() -> Unit)?`, no longer breaks the parse of the class around it. Re-index after upgrading. diff --git a/__tests__/vbnet-member-access.test.ts b/__tests__/vbnet-member-access.test.ts new file mode 100644 index 000000000..d8643ecc5 --- /dev/null +++ b/__tests__/vbnet-member-access.test.ts @@ -0,0 +1,87 @@ +/** + * VB.NET's extractor keeps only the last name of a member access, so + * `Me.Panel.Controls.Add(x)` and `New System.Drawing.Size(1, 2)` reach the + * resolver as bare `Add` / `Size`. A member reached that way must match what + * the line writes before the dot: `Me` / `MyBase` / `MyClass`, the member's + * own type or module (`Logger.Log(…)`), or a service locator's type argument + * (`GetService(Of Notifier).Notify()`). SCrawler's designer code sent + * `New System.Drawing.Size(…)` to a nested enum's `Size` case 713 times and + * `Controls.Add(…)` to a collection class's `Add` 547 times. + */ +import { describe, it, expect, afterAll, beforeAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +let root = ''; +let cg: CodeGraph; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-vb-member-')); + const files: Record = { + 'Editors/UsersInfoForm.vb': `Namespace Editors + Friend Class UsersInfoForm + Private Enum EComparers + Name + Size + End Enum + End Class +End Namespace +`, + 'Collections/DataColorCollection.vb': `Friend Class DataColorCollection + Friend Sub Add(ByVal Item As Object) + End Sub +End Class +`, + 'Services/Notifier.vb': `Public Class Notifier + Public Sub Notify(ByVal Text As String) + End Sub +End Class +`, + 'Logger.vb': `Public Module Logger + Public Sub Log(ByVal Text As String) + End Sub +End Module +`, + 'MainForm.vb': `Public Class MainForm + Private Sub InitializeComponent() + Me.Panel.Controls.Add(Me.Button1) + Me.Panel.Size = New System.Drawing.Size(184, 25) + Logger.Log("ready") + Application.GetService(Of Notifier).Notify("ready") + Me.Refresh() + End Sub + + Private Sub Refresh() + End Sub +End Class +`, + }; + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + cg = await CodeGraph.init(root, { index: true }); +}); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('VB.NET member access', () => { + it('reaches a member only through what the line names', () => { + const ids = cg.getNodesInFile('MainForm.vb').map((n) => n.id); + const targets = cg + .getOutgoingEdgesFrom(ids) + .filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.target)!.qualifiedName) + .sort(); + expect(targets).not.toContain('Editors::UsersInfoForm::EComparers::Size'); + expect(targets).not.toContain('DataColorCollection::Add'); + expect(targets).toContain('Logger::Log'); + expect(targets).toContain('Notifier::Notify'); + expect(targets).toContain('MainForm::Refresh'); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 7729bbd40..f9f01b8eb 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1431,6 +1431,53 @@ function dartHeadOf(decl: Node, context: ResolutionContext): { supers: string[]; }; } +const VB_MEMBER_KINDS: ReadonlySet = new Set(['method', 'property', 'field', 'enum_member', 'constant', 'variable']); + +/** + * What a VB.NET member access is written on, read at the call site: the + * extractor keeps a call's last name only, so `Me.CMB.Buttons.Add(x)` and + * `New System.Drawing.Size(1, 2)` arrive as bare `Add` / `Size`. Returns null + * for a genuinely bare name, `''` for a `With` block's `.Name`, else the + * text before the dot (`Me.CMB.Buttons`, `System.Drawing`). + */ +function vbReceiverOf(ref: UnresolvedRef, context: ResolutionContext): string | null { + const line = context.getFileLines?.(ref.filePath)?.[ref.line - 1] ?? context.readFile(ref.filePath)?.split('\n')[ref.line - 1]; + if (line === undefined) return null; + const lower = line.toLowerCase(); + const name = ref.referenceName.toLowerCase(); + let start = lower.startsWith(name, ref.column) ? ref.column : -1; + if (start < 0) { + const m = new RegExp(`(?>>(); /** @@ -2055,8 +2102,11 @@ export function matchByExactName( const kotlinCall = ref.language === 'kotlin' && ref.referenceKind === 'calls' && /^[A-Za-z_$][\w$]*$/.test(ref.referenceName); const rubyBare = ref.language === 'ruby' && ref.referenceKind === 'calls' && /^[A-Za-z_]\w*[?!]?$/.test(ref.referenceName); const cfmlBare = (ref.language === 'cfml' || ref.language === 'cfscript') && ref.referenceKind === 'calls' && /^[A-Za-z_]\w*$/.test(ref.referenceName); + const vbReceiver = ref.language === 'vbnet' && (ref.referenceKind === 'calls' || ref.referenceKind === 'instantiates') && /^\w+$/.test(ref.referenceName) + ? vbReceiverOf(ref, context) : null; const phpSelf = phpSelfReceiver(ref, context); const filtered = sameName.filter((n) => + !(vbReceiver !== null && !isVbMemberReachable(n, vbReceiver)) && !(rubyBare && n.kind === 'method' && !isRubyMethodInScope(n, ref, context)) && !(cfmlBare && n.kind === 'method' && !isCfmlMethodInScope(n, ref, context)) && !(javaBare && n.kind === 'method' && !isJavaMethodInScope(n, ref, context)) && @@ -5164,6 +5214,8 @@ export function matchFuzzy( const kotlinCall = ref.language === 'kotlin' && ref.referenceKind === 'calls' && /^[A-Za-z_$][\w$]*$/.test(ref.referenceName); const rubyBare = ref.language === 'ruby' && ref.referenceKind === 'calls' && /^[A-Za-z_]\w*[?!]?$/.test(ref.referenceName); const cfmlBare = (ref.language === 'cfml' || ref.language === 'cfscript') && ref.referenceKind === 'calls' && /^[A-Za-z_]\w*$/.test(ref.referenceName); + const vbReceiver = ref.language === 'vbnet' && (ref.referenceKind === 'calls' || ref.referenceKind === 'instantiates') && /^\w+$/.test(ref.referenceName) + ? vbReceiverOf(ref, context) : null; const phpSelf = phpSelfReceiver(ref, context); // Names are case-sensitive in every language but a handful: Rust's // `Bytes` is not the method `bytes`, Python's builtin `dir(…)` not a class @@ -5182,6 +5234,7 @@ export function matchFuzzy( !(kotlinCall && !isKotlinTopLevelVisible(n, ref, context)) && !(rubyBare && n.kind === 'method' && !isRubyMethodInScope(n, ref, context)) && !(cfmlBare && n.kind === 'method' && !isCfmlMethodInScope(n, ref, context)) && + !(vbReceiver !== null && !isVbMemberReachable(n, vbReceiver)) && !(phpSelf && (n.kind !== 'method' || !isPhpMethodInScope(n, ref, phpSelf, context)))) .filter((n) => (ref.referenceKind !== 'references' && ref.referenceKind !== 'function_ref') || sameLanguageFamily(n.language, ref.language));