Files
codegraph/__tests__/vbnet-shared-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

222 lines
8.3 KiB
TypeScript

/**
* VB.NET: a value read or written through a type or module name —
* `AppSession.SessionId`, `AppSession.CurrentUser = "demo"`, `Logger.Level`,
* `Mode.Fast` — is a use of the member and of the type (#2305). Before, only
* calls linked, so `codegraph callers` on a Shared field or property, and on
* the class itself, came back empty.
*
* The name is looked up as VB.NET does, without regard to case: a local, a
* parameter or a field of that name holds a value, read as the type it is
* declared as (vbnet-instance-member-refs.test.ts; a member typed as its own
* name's type, `Property Settings As Settings`, reads that type's members
* either way); a type is the one the namespaces around
* the read, its `Imports` and aliases see — SCrawler declares a
* `SiteSettings` in every site's namespace. A method named without
* parentheses is called, unless `AddressOf` only names it, and an index into
* a Shared field (`AppSession.Items(0)`), which VB.NET writes as a call, reads
* the field.
*/
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;
const files: Record<string, string> = {
// The issue's three files.
'AppSession.vb': `Public Class AppSession
Public Shared SessionId As Guid = Guid.NewGuid()
Public Shared Property CurrentUser As String
Public Shared Items As New List(Of String)
Public Shared Function GetGreeting() As String
Return "Hello " & CurrentUser
End Function
Public Shared Sub OnTick(ByVal sender As Object, ByVal e As EventArgs)
End Sub
Public Shared Function Describe() As String
Return AppSession.CurrentUser
End Function
End Class
`,
'Consumer.vb': `Public Class Consumer
Public Sub Run()
Dim id As Guid = AppSession.SessionId
AppSession.CurrentUser = "demo"
Console.WriteLine(AppSession.GetGreeting())
End Sub
End Class
`,
'Consumer2.vb': `Public Class Consumer2
Public Function Describe() As String
Return AppSession.CurrentUser & AppSession.SessionId.ToString()
End Function
End Class
`,
'OtherSession.vb': `Public Class OtherSession
Public Shared SessionId As Guid = Guid.NewGuid()
Public Property Size As Integer
End Class
`,
'Size.vb': `Public Class Size
End Class
`,
'Logger.vb': `Public Module Logger
Public Level As Integer
End Module
`,
'Mode.vb': `Public Enum Mode
Fast
Slow
End Enum
`,
'Form1.vb': `Public Class Form1
Private Panel1 As Panel
Public Sub Setup()
Logger.Level = 3
Dim a = Panel1.Size
Me.Panel1.Size = New System.Drawing.Size(1, 2)
Dim speed = Mode.Fast
Dim greeting = AppSession.GetGreeting
AddHandler Timer1.Tick, AddressOf AppSession.OnTick
Dim first = AppSession.Items(0)
Dim empty = String.Empty
End Sub
Public Sub Shadowed(ByVal appSession As OtherSession)
Dim id = AppSession.SessionId
End Sub
End Class
`,
// A type declared in each site's namespace, and one read through an import alias.
'Sites/Reddit/SiteSettings.vb': `Namespace API.Reddit
Friend Class SiteSettings
Friend Const Header As String = "r"
End Class
Friend Class UserData
Friend Function Key() As String
Return SiteSettings.Header
End Function
End Class
End Namespace
`,
'Sites/Twitter/SiteSettings.vb': `Namespace API.Twitter
Friend Class SiteSettings
Friend Const Header As String = "t"
End Class
Friend Class UserData
Friend Function Key() As String
Return SiteSettings.Header
End Function
End Class
End Namespace
`,
'Sites/Facebook/UserData.vb': `Imports RS = API.Reddit.SiteSettings
Namespace API.Facebook
Friend Class UserData
Friend Function Key() As String
Return RS.Header
End Function
End Class
End Namespace
`,
// A property typed as the type of its own name; a property initializer.
'Settings.vb': `Public Class Settings
Public Property Theme As String
End Class
`,
'Window.vb': `Public Class Window
Public Property Settings As Settings
Public Property Title As String = AppSession.CurrentUser
Public Function CurrentTheme() As String
Return Settings.Theme
End Function
End Class
`,
// CRLF line endings and multibyte text before the read.
'Weird.vb': [
'Public Class Weird',
' Public Sub Go()',
' Dim s = "SessionId éééé 😀" & AppSession.SessionId',
' End Sub',
'End Class',
'',
].join('\r\n'),
};
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-vb-shared-'));
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`. */
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 Shared members read through their type (#2305)', () => {
it('link a Shared field and property to the methods that read or write them', () => {
expect(linkedFrom('AppSession::SessionId', 'references')).toEqual(['Consumer2::Describe', 'Consumer::Run', 'Weird::Go']);
expect(linkedFrom('AppSession::CurrentUser', 'references'))
.toEqual(['AppSession::Describe', 'Consumer2::Describe', 'Consumer::Run', 'Window::Title']);
});
it('link the class to the code outside it that reads through it', () => {
expect(linkedFrom('AppSession', 'references'))
.toEqual(['Consumer2::Describe', 'Consumer2::Describe', 'Consumer::Run', 'Consumer::Run', 'Form1::Setup', 'Weird::Go', 'Window::Title']);
expect(cg.getCallers(nodeId('AppSession')).map((c) => c.node.qualifiedName).sort())
.toEqual(['Consumer2::Describe', 'Consumer::Run', 'Form1::Setup', 'Weird::Go', 'Window::Title']);
});
it('keep the call to a Shared method, and call one named without parentheses', () => {
expect(linkedFrom('AppSession::GetGreeting', 'calls')).toEqual(['Consumer::Run', 'Form1::Setup']);
expect(linkedFrom('AppSession::OnTick', 'references')).toEqual(['Form1::Setup']);
expect(linkedFrom('AppSession::OnTick', 'calls')).toEqual([]);
});
it('read a Shared field indexed like a call, a Module variable and an Enum value', () => {
expect(linkedFrom('AppSession::Items', 'references')).toEqual(['Form1::Setup']);
expect(linkedFrom('Logger::Level', 'references')).toEqual(['Form1::Setup']);
expect(linkedFrom('Mode::Fast', 'references')).toEqual(['Form1::Setup']);
expect(linkedFrom('Mode', 'references')).toEqual(['Form1::Setup']);
});
it('read through a local, parameter or field, whatever its case, the type it holds', () => {
// `AppSession.SessionId` in `Shadowed(ByVal appSession As OtherSession)`
// reads the parameter: OtherSession's field, never AppSession's.
expect(linkedFrom('OtherSession::SessionId', 'references')).toEqual(['Form1::Shadowed']);
expect(linkedFrom('OtherSession::Size', 'references')).toEqual([]);
expect(linkedFrom('Size', 'references')).toEqual([]);
expect(linkedFrom('OtherSession', 'references')).toEqual([]);
});
it('read the type the namespaces and imports around the read see', () => {
expect(linkedFrom('API.Reddit::SiteSettings::Header', 'references'))
.toEqual(['API.Facebook::UserData::Key', 'API.Reddit::UserData::Key']);
expect(linkedFrom('API.Twitter::SiteSettings::Header', 'references')).toEqual(['API.Twitter::UserData::Key']);
});
it('read through a member typed as the type of its own name', () => {
expect(linkedFrom('Settings::Theme', 'references')).toEqual(['Window::CurrentTheme']);
});
});