Files
codegraph/__tests__/vbnet-member-access.test.ts
Colby Mchenry c9944147bb fix(vbnet): resolve receiver calls by declared type; guard .NET names, bare calls and ties (#2351)
VB.NET's resolver guessed a receiver call's target from the receiver's
name whenever no class matched it, without any of the guards C# has.
Indexing Structure members (#2345) gave those guesses new targets: 23
SCrawler `xxxFile.Delete()` calls on PersonalUtilities' SFile went to a
nested struct's `TempFileConversion::Delete`, and staxrip's main app
sent 90 calls to its AutoCrop tool's duplicate `ColorHSL`.

- Declared types (new src/resolution/vbnet-receivers.ts): a receiver's
  `As` type decides the call (local, parameter, field, property,
  `For Each`, `As New`, ...). The candidates are that type's member, one
  it inherits, or an extension method written for it. A type from
  outside the project gets no link.
- .NET standard method names (`Add`, `Contains`, `Clear`, `Dispose`,
  `ToString`, ...) on an untyped receiver need a receiver named after
  the owner, as in C#. VB.NET matches them case-insensitively.
- A bare call, or one on `Me`, reaches the enclosing class, what it
  inherits, or a Module. A nested type is matched by its bare name only
  from inside its owner. Types resolve through enclosing namespaces,
  `Imports` (aliases included) and the caller's project. A tie between
  equally good guesses goes to the caller's file, then its project, then
  the nearer directory, or gets no link.
- vbReceiverOf reads the name at or after the reference's column, so
  `Me.Size = New System.Drawing.Size(...)` is read through
  `System.Drawing`, not `Me`.

Before/after on main (nodes unchanged): SCrawler 15,092 -> 14,893
edges, staxrip 28,002 -> 26,647. Of the non-containment edges removed,
855 / 2,802 are the same link with new resolver metadata. The rest:

- .NET names on untyped receivers no longer guessed: 196 / 1,378.
  731 of staxrip's are StringBuilder `sb.Append` calls that had gone to
  `LogBuilder::Append`.
- Other receiver guesses dropped: 86 / 103, including the SFile.Delete
  calls.
- Bare calls out of scope: 38 / 834. 590 of staxrip's are
  `New Point(...)` that had gone to a class nested in `ButtonEx`; others
  are WinForms `Refresh()`, `Focus()` and `Activate()`.
- Retargeted: 227 / 532, e.g. 208 bare `Add(...)` calls in encoder
  classes now reach the inherited `CommandLineParams::Add`.

121 / 958 call sites resolve that did not before, e.g. `cms.Add` ->
ContextMenuStripEx and `td.AddButton` -> TaskDialog. Designer -> Size
links fall from 5 / 1 to 0; designer -> Add stays 0.

Known trade-off, shared with C#'s guard: about 31 staxrip calls like
`switches.Join(BR)` that may be the project's own `MiscExtensions`
extension methods are no longer linked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:32:53 +00:00

123 lines
4.3 KiB
TypeScript

/**
* 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.
*
* The name read is the one the reference starts at (or after) — in
* `Me.Size = New System.Drawing.Size(…)` that is `System.Drawing.Size`, not
* `Me.Size` — and a type is one its written qualifier names: staxrip's
* designer `New System.Drawing.Point(…)` went to a nested `Point` class 550
* times. A type nested in a class is named bare only inside it.
*/
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<string, string> = {
'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
`,
'UI/ButtonEx.vb': `Public Class ButtonEx
Public Class SymbolDrawer
Public Class Point
End Class
Public Sub Draw()
Dim p As New Point()
End Sub
End Class
End Class
`,
'Forms/AppsForm.vb': `Public Class AppsForm
Private Sub InitializeComponent()
Me.Size = New System.Drawing.Size(315, 205)
Me.ToolStrip.Location = New System.Drawing.Point(473, 10)
Me.lDescription.Location = New Point(4, 285)
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');
});
it('reads the name the reference starts at, and a type its qualifier names', () => {
const targetsOf = (file: string) => cg
.getOutgoingEdgesFrom(cg.getNodesInFile(file).map((n) => n.id))
.filter((e) => e.kind === 'calls' || e.kind === 'instantiates')
.map((e) => `${e.kind} ${cg.getNode(e.target)!.qualifiedName}`)
.sort();
// `Me.Size = New System.Drawing.Size(…)`, `New System.Drawing.Point(…)`, `New Point(…)` outside ButtonEx.
expect(targetsOf('Forms/AppsForm.vb')).toEqual([]);
expect(targetsOf('UI/ButtonEx.vb')).toEqual(['instantiates ButtonEx::SymbolDrawer::Point']);
});
});