Files
codegraph/__tests__/vbnet-instance-member-refs.test.ts
Colby Mchenry 5a11cd7ab7 fix(vbnet): link fields and properties read or written through a value (#2377)
VB.NET linked a member read only through a type or module name (#2355),
so a read or write through an instance -- a parameter `x.Normal = 3`, a
local `h.Normal`, a field `_h.Title`, `Me._h.Normal` -- produced no edge,
and `codegraph callers` on an instance field or property listed almost
none of its uses.

- Extraction (TS only; VB.NET is not kernel-routed): every member read or
  write is sent with its receiver as a path, whatever the receiver's case:
  `x.Normal`, `Me._h.Normal`, `MyBase.Count`, `.Value` in a `With` block
  (nested `With .Inner` included), `.Switch` in an object initializer
  `New BoolParam With {...}`, and `{T}.X` for a value whose type the code
  writes (`DirectCast(o, T).X`, `New T().X`). A `With` block's `.Run()`
  call, which recorded nothing, is sent the same way. The grammar splits
  `New System.Drawing.Size(...)` into members read through `New System`;
  those, anonymous types' `New With {.X = 1}`, and paths that start with
  System, Microsoft or My are not sent.
- Resolution (vbnet-receivers.ts): each link of the path is typed by the
  rules calls use since #2351 -- declarations, casts, `For Each` over a
  typed collection, call results, type-parameter constraints, Module
  variables, namespaces and Imports -- and the read links the member the
  type declares or inherits. A method named without parentheses is called
  (unless AddressOf or NameOf only names it), and `x.Items(0)`, an index
  that VB.NET writes as a call, reads `Items`. An outside, `Object`-typed,
  ambiguous or untyped receiver links nothing; nothing is guessed by name.
- "Color Color": a value named like its type (`theme As Theme`) now reads
  an instance member through the value, so only the member is linked; a
  Shared member, a Const (read from its declaration) or an Enum case still
  links the type as well, as #2355 did for every such read.
- membersNamed looks a type's members up in its parts' files rather than
  with one query per member name. The graph is identical on SCrawler; on
  staxrip, whose AutoCrop tool redeclares `ServerInfo`, 35 reads that
  main typed through the other project's field, and so left unlinked,
  now link (70 edges).

Verification: vbnet-instance-member-refs.test.ts (11 tests) fails 9/11 on
main and passes; one vbnet-shared-member-refs assertion now expects
`OtherSession::SessionId` from `Shadowed(appSession As OtherSession)`;
the VB.NET suites, tsc and the full suite pass (462 files, 5,859 tests;
22 kernel/platform files skipped). The issue's repro on the built CLI
links every read and write, and `codegraph callers` lists `Run` for
`SharedCache`, `Normal` and `Title`.

Validation, main vs this branch: nodes unchanged, self-loops unchanged.
SCrawler 17,437 -> 31,344 edges, 1 removed (same edge, new metadata).
staxrip 32,569 -> 71,362 edges, 585 removed: 318 same edge with new
metadata (Color Color reads of instance members, qualified-name ->
instance-method) and 267 class links of those reads, every one beside an
instance member. About 42k added two-link reads were re-derived from the
source text by a separate check; its 837 disagreements were all its own
blind spots (constraints, Imports aliases, sibling-block locals, call
results, staxrip's global `p`). No added edge crosses between staxrip
and its AutoCrop tool. Cost (5-run medians): CPU 16.1 -> 19.3 s and
20.2 -> 25.4 s, DB 28.1 -> 43.2 MB and 34.9 -> 59.0 MB; the DB growth is
the new edges plus reads through outside types kept as failed refs for
re-resolution (#1240).

Not covered: unqualified reads, reads through an index or a call result
(`list(0).X`), Handles clauses, and calls through a chained receiver
written directly (`g.MainForm.Update()`), which still resolve by name.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:39:26 +00:00

339 lines
12 KiB
TypeScript

/**
* VB.NET: a field or property read or written through a value — a parameter
* (`x.Normal = 3`), a local (`h.Normal`), a field (`_h.Title`), `Me._h.Normal`,
* `MyBase.Count`, a `With` block's `.Value`, an object initializer's
* `.Switch` — is a use of the member of the type that value is declared as.
* Before, calls through a typed receiver linked (#2351) and reads through a
* type's name (#2305), but no read through a value, so `codegraph callers` on
* an instance field or property listed almost none of its uses.
*
* The receiver is typed by the rules calls use — its declaration (`As`, `As
* New`, a cast, a `For Each` over a typed collection, a type parameter's
* constraint, a Module variable), the namespaces around the read, what the
* type inherits — and a path (`Me._h.Normal`, `n.Child.Value`) one link at a
* time. An outside type, `Object`, an untyped name or an index links nothing:
* a value's member is never guessed by name.
*/
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';
import { extractFromSource } from '../src/extraction';
import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars';
let root = '';
let cg: CodeGraph;
const files: Record<string, string> = {
// The issue's two files, verbatim.
'Holder.vb': `Public Class Holder
Public Shared SharedCache As Integer
Public Normal As Integer
Public Property Title As String
Public Sub EndSession()
End Sub
End Class
`,
'User.vb': `Public Class User
Private _h As New Holder()
Public Sub Run(x As Holder)
Dim b As Integer = x.SharedCache
Dim n As Integer = x.Normal
x.Normal = 3
Dim t As String = _h.Title
Me._h.Normal = 4
Dim h As New Holder()
Dim m As Integer = h.Normal
x.EndSession()
End Sub
End Class
`,
'Node.vb': `Public Class Node
Public Value As Integer
Public Property Child As Node
Public Items As New List(Of String)
Public Event Changed As EventHandler
Public Sub Reset()
End Sub
Public Function Describe() As String
Return ""
End Function
End Class
`,
// Me, MyBase, a path through members, an inherited member; an index into a
// field, a method named without parentheses, an event, AddressOf.
'Views.vb': `Public Class BaseView
Protected Caption As String
Public Overridable Property Count As Integer
End Class
Public Class DerivedView
Inherits BaseView
Private _node As Node
Public Sub New(caption As String)
Me.Caption = caption
End Sub
Public Overrides Property Count As Integer
Get
Return MyBase.Count + Me._node.Child.Value
End Get
Set(value As Integer)
MyBase.Count = value
End Set
End Property
Public Sub Watch(n As Node)
Dim first = n.Items(0)
Dim text = n.Describe
AddHandler n.Changed, AddressOf OnChanged
Dim reset As Action = AddressOf n.Reset
Dim nodes As New List(Of Node)
For Each child In nodes
child.Value = 1
Next
End Sub
Private Sub OnChanged(sender As Object, e As EventArgs)
End Sub
End Class
`,
// With blocks (nested, through Me, through a cast), an object initializer, a
// cast; an anonymous type declares its own members.
'Builder.vb': `Public Class Builder
Private _node As New Node()
Property Seed As New Node With {.Value = 7}
Public Sub Fill(o As Object, n As Node)
With n
.Value = 1
Dim d = .Describe()
With .Child
.Reset()
End With
End With
With Me._node
.Child = Nothing
End With
With DirectCast(o, Node)
.Items.Clear()
End With
Dim v = TryCast(o, Node).Value
Dim anonymous = New With {.Value = 2}
End Sub
End Class
`,
// A Module variable, a type parameter's constraint.
'Globals.vb': `Public Module Globals
Public Current As Node
End Module
`,
'Repo.vb': `Public Class Repo(Of T As Node)
Private _item As T
Public Function Peek() As Integer
Return _item.Value + Current.Value
End Function
End Class
`,
// A field's type is the one the namespace around it declares.
'Sites/Reddit.vb': `Namespace API.Reddit
Friend Class SiteSettings
Friend Property Header As String
End Class
Friend Class UserData
Private _settings As SiteSettings
Friend Function Key() As String
Return _settings.Header
End Function
End Class
End Namespace
`,
'Sites/Twitter.vb': `Namespace API.Twitter
Friend Class SiteSettings
Friend Property Header As String
End Class
End Namespace
`,
// staxrip's encoder options: a property an initializer sets up, written later.
'Encoder.vb': `Public MustInherit Class CommandLineParam
Property Switch As String
Property Text As String
End Class
Public Class BoolParam
Inherits CommandLineParam
Property Value As Boolean
End Class
Public Class x265Enc
Property ConstVBV As New BoolParam With {
.Switch = "--const-vbv",
.Text = "Enable VBV algorithm to be consistent across runs"}
Sub Apply()
ConstVBV.Value = False
End Sub
End Class
`,
// "Color Color": a value named like its type reads an instance member
// through itself, a Shared member and an Enum case through the type. And
// staxrip names enums in lower case.
'Theme.vb': `Public Class Theme
Public Shared Current As Theme
Public Const Version As String = "1"
Public Property General As String
End Class
Public Enum Mode
Fast
End Enum
Public Enum x265RateMode
Bitrate
End Enum
Public Class Painter
Public Property Mode As Mode
Public Sub Paint(theme As Theme)
Dim g = theme.General
Dim c = theme.Current
Dim v = theme.Version
Dim f = Mode.Fast
Dim r = x265RateMode.Bitrate
End Sub
End Class
`,
// What links nothing: an outside type, Object, an untyped name, an index's
// element; and a parameter named like a type is read as the parameter.
'Lookalike.vb': `Public Class Lookalike
Public Length As Integer
Public Count As Integer
Public Value As Integer
End Class
`,
'Negatives.vb': `Public Class Negatives
Public Sub Go(s As String, o As Object, list As List(Of Node), ByVal untyped)
Dim a = s.Length
Dim b = list.Count
Dim c = o.Value
Dim d = untyped.Value
Dim e = list(0).Value
End Sub
Public Sub Shadowed(holder As Node)
Dim f = Holder.SharedCache
Dim g = holder.Value
End Sub
End Class
`,
};
beforeAll(async () => {
await initGrammars();
await loadGrammarsForLanguages(['vbnet']);
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-vb-instance-'));
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 });
});
function nodeId(qualifiedName: string): string {
const found = cg.getNodesInFiles(Object.keys(files)).filter((n) => n.qualifiedName === qualifiedName);
if (found.length !== 1) throw new Error(`${found.length} nodes named ${qualifiedName}`);
return found[0]!.id;
}
/** The qualified names of what links to `qualifiedName` with an edge of `kind`, one per edge. */
function linkedFrom(qualifiedName: string, kind: 'references' | 'calls'): string[] {
return cg
.getIncomingEdgesTo([nodeId(qualifiedName)], [kind])
.map((e) => cg.getNode(e.source)!.qualifiedName)
.sort();
}
describe('VB.NET members read and written through a value', () => {
it('link each read and write of the issue to the member', () => {
expect(linkedFrom('Holder::SharedCache', 'references')).toEqual(['User::Run']);
expect(linkedFrom('Holder::Normal', 'references')).toEqual(['User::Run', 'User::Run', 'User::Run', 'User::Run']);
expect(linkedFrom('Holder::Title', 'references')).toEqual(['User::Run']);
expect(linkedFrom('Holder::EndSession', 'calls')).toEqual(['User::Run']);
expect(cg.getCallers(nodeId('Holder::Normal')).map((c) => c.node.qualifiedName)).toEqual(['User::Run']);
});
it('link the member alone: the type is not written at the read', () => {
expect(linkedFrom('Holder', 'references')).toEqual([]);
expect(linkedFrom('Node', 'references')).toEqual([]);
});
it('read through Me, MyBase and a path of members, one link at a time', () => {
expect(linkedFrom('BaseView::Caption', 'references')).toEqual(['DerivedView::New']);
expect(linkedFrom('BaseView::Count', 'references')).toEqual(['DerivedView::Count', 'DerivedView::Count']);
expect(linkedFrom('DerivedView::_node', 'references')).toEqual(['DerivedView::Count']);
expect(linkedFrom('Node::Child', 'references')).toEqual(['Builder::Fill', 'Builder::Fill', 'DerivedView::Count']);
});
it('index a field, run a method named without parentheses, name an event and a method', () => {
expect(linkedFrom('Node::Items', 'references')).toEqual(['Builder::Fill', 'DerivedView::Watch']);
expect(linkedFrom('Node::Describe', 'calls')).toEqual(['Builder::Fill', 'DerivedView::Watch']);
expect(linkedFrom('Node::Changed', 'references')).toEqual(['DerivedView::Watch']);
expect(linkedFrom('Node::Reset', 'references')).toEqual(['DerivedView::Watch']);
expect(linkedFrom('Node::Reset', 'calls')).toEqual(['Builder::Fill']);
});
it('read through With blocks, an object initializer, a cast, a loop variable, a Module variable and a type parameter', () => {
expect(linkedFrom('Node::Value', 'references')).toEqual([
'Builder::Fill', 'Builder::Fill', 'Builder::Seed', 'DerivedView::Count', 'DerivedView::Watch',
'Negatives::Shadowed', 'Repo::Peek', 'Repo::Peek',
]);
});
it('read the type the namespace around the declaration sees', () => {
expect(linkedFrom('API.Reddit::SiteSettings::Header', 'references')).toEqual(['API.Reddit::UserData::Key']);
expect(linkedFrom('API.Twitter::SiteSettings::Header', 'references')).toEqual([]);
});
it('link an initializer and a later write to the members the type declares or inherits', () => {
expect(linkedFrom('CommandLineParam::Switch', 'references')).toEqual(['x265Enc::ConstVBV']);
expect(linkedFrom('CommandLineParam::Text', 'references')).toEqual(['x265Enc::ConstVBV']);
expect(linkedFrom('BoolParam::Value', 'references')).toEqual(['x265Enc::Apply']);
});
it('read an instance member of a value named like its type through the value, a Shared one through the type', () => {
expect(linkedFrom('Theme::General', 'references')).toEqual(['Painter::Paint']);
expect(linkedFrom('Theme::Current', 'references')).toEqual(['Painter::Paint']);
expect(linkedFrom('Theme::Version', 'references')).toEqual(['Painter::Paint']);
// `theme.Current` and the Const `theme.Version`; not `theme.General`.
expect(linkedFrom('Theme', 'references')).toEqual(['Painter::Paint', 'Painter::Paint']);
expect(linkedFrom('Mode::Fast', 'references')).toEqual(['Painter::Paint']);
expect(linkedFrom('Mode', 'references')).toEqual(['Painter::Paint']);
});
it('read through a type whose name is in lower case', () => {
expect(linkedFrom('x265RateMode::Bitrate', 'references')).toEqual(['Painter::Paint']);
expect(linkedFrom('x265RateMode', 'references')).toEqual(['Painter::Paint']);
});
it('send no read for a qualified type after New, which the grammar splits into members', () => {
// `New System.Windows.Forms.ListBox()` parses as `.Windows.Forms` read through `New System`.
const reads = extractFromSource('Designer.vb', `Public Class Form1
Sub InitializeComponent()
Me.ListBox1 = New System.Windows.Forms.ListBox()
Dim h = New Sites.Holder
Dim n = New Holder().Normal
Dim p = DirectCast(sender, System.Windows.Forms.Control).Parent
End Sub
End Class
`).unresolvedReferences.filter((r) => r.referenceKind === 'references').map((r) => r.referenceName);
expect(reads).toEqual(['Me.ListBox1', '{Holder}.Normal']);
});
it('link nothing through an outside type, Object, an untyped name or an index', () => {
for (const member of ['Lookalike::Length', 'Lookalike::Count', 'Lookalike::Value']) {
expect(linkedFrom(member, 'references')).toEqual([]);
}
expect(linkedFrom('Holder::SharedCache', 'references')).not.toContain('Negatives::Shadowed');
});
});