diff --git a/CHANGELOG.md b/CHANGELOG.md index 06ca319d98..d110b82eda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixes + +- In VB.NET, reading or setting a `Shared` field or property through its class name, like `AppSession.SessionId` or `AppSession.CurrentUser = "demo"`, now counts as a use of that field or property and of the class, so `codegraph callers` and impact no longer miss it. (#2305) ## [1.6.2] - 2026-10-03 diff --git a/__tests__/vbnet-shared-member-refs.test.ts b/__tests__/vbnet-shared-member-refs.test.ts new file mode 100644 index 0000000000..4414cdc880 --- /dev/null +++ b/__tests__/vbnet-shared-member-refs.test.ts @@ -0,0 +1,146 @@ +/** + * VB.NET: reading or writing a Shared field/property through its class name + * (`AppSession.SessionId`, `AppSession.CurrentUser = "demo"`) links the caller + * to the member and to the class (#2305), without binding look-alike members. + */ +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; + +function nodeByQn(qn: string) { + const found = allNodes().find((n) => n.qualifiedName === qn); + if (!found) throw new Error(`no node with qualifiedName ${qn}`); + return found; +} + +function allNodes() { + return fs + .readdirSync(root) + .filter((f) => f.endsWith('.vb')) + .flatMap((f) => cg.getNodesInFile(f)); +} + +/** Qualified names of the symbols with a `references` edge into `qn`. */ +function referencedBy(qn: string): string[] { + const id = nodeByQn(qn).id; + return cg + .getIncomingEdgesTo([id], ['references']) + .map((e) => cg.getNode(e.source)!.qualifiedName) + .sort(); +} + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-vb-shared-')); + const files: Record = { + 'AppSession.vb': `Public Class AppSession + Public Shared SessionId As Guid = Guid.NewGuid() + Public Shared Property CurrentUser As String + Public Shared Function GetGreeting() As String + Return "Hello " & 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 +`, + 'Form1.vb': `Public Class Form1 + Public Sub Setup() + Logger.Level = 3 + Dim a = Panel1.Size + Me.Panel.Size = New System.Drawing.Size(1, 2) + End Sub +End Class +`, + // CRLF line endings and multibyte text before the read: the resolver reads + // the receiver back out of the source line by column. + 'Weird.vb': [ + 'Public Class Weird', + ' Public Sub Go()', + ' Dim s = "SessionId éééé 😀" & AppSession.SessionId', + ' End Sub', + 'End Class', + '', + ].join('\r\n'), + }; + 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 Shared member reads through the class name (#2305)', () => { + it('links a Shared field to every method that reads it', () => { + expect(referencedBy('AppSession::SessionId')).toEqual( + expect.arrayContaining(['Consumer::Run', 'Consumer2::Describe']), + ); + }); + + it('links a Shared property to every method that reads or writes it', () => { + expect(referencedBy('AppSession::CurrentUser')).toEqual( + expect.arrayContaining(['Consumer::Run', 'Consumer2::Describe']), + ); + }); + + it('links the class to the methods that use its members', () => { + expect(referencedBy('AppSession')).toEqual( + expect.arrayContaining(['Consumer::Run', 'Consumer2::Describe']), + ); + }); + + it('keeps the existing call edge to a Shared method', () => { + const id = nodeByQn('AppSession::GetGreeting').id; + const callers = cg.getCallers(id).map((c) => c.node.qualifiedName); + expect(callers).toEqual(['Consumer::Run']); + }); + + it('links a Module variable read through the module name', () => { + expect(referencedBy('Logger::Level')).toContain('Form1::Setup'); + }); + + it('does not bind a same-named member of a different class', () => { + expect(referencedBy('OtherSession::SessionId')).toEqual([]); + }); + + it('does not bind a dotted read to a member or class that merely shares its name', () => { + expect(referencedBy('OtherSession::Size')).toEqual([]); + expect(referencedBy('Size')).not.toContain('Form1::Setup'); + }); + + it('resolves on a CRLF file with multibyte text before the read', () => { + expect(referencedBy('AppSession::SessionId')).toContain('Weird::Go'); + }); +}); diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 5c27523acc..744c7d88c0 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -359,7 +359,7 @@ const MEMBER_ACCESS_TYPES: ReadonlySet = new Set([ * already-covered types). Don't re-add `member_expression`/`attribute` here. */ const STATIC_MEMBER_LANGS: ReadonlySet = new Set([ - 'java', 'csharp', 'kotlin', 'swift', 'scala', 'dart', 'php', 'cpp', + 'java', 'csharp', 'kotlin', 'swift', 'scala', 'dart', 'php', 'cpp', 'vbnet', ]); /** @@ -5450,7 +5450,18 @@ export class TreeSitterExtractor { t === 'name' || t === 'scoped_type_identifier' ) { const text = getNodeText(recv, this.source); - if (/^[A-Z][A-Za-z0-9_]*$/.test(text)) this.pushStaticMemberRef(text, ownerId, recv); + if (/^[A-Z][A-Za-z0-9_]*$/.test(text)) { + this.pushStaticMemberRef(text, ownerId, recv); + // VB.NET (#2305): a Shared field/property read or write is a use of the + // member too, not just of its class. The bare member name is resolved + // against the receiver written on the line (see vbReceiverOf). + if (this.language === 'vbnet' && node.type === 'member_access_expression') { + const member = getChildByField(node, 'member'); + if (member?.type === 'identifier') { + this.pushStaticMemberRef(getNodeText(member, this.source), ownerId, member); + } + } + } } } diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index e0b4ad28b8..ee7a1f38c9 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -5432,8 +5432,9 @@ export function matchByExactName( const kotlinBare = kotlinCall && isReceiverLessKotlinCall(ref, context); 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) + const vbReceiver = ref.language === 'vbnet' && (ref.referenceKind === 'calls' || ref.referenceKind === 'instantiates' || ref.referenceKind === 'references') && /^\w+$/.test(ref.referenceName) ? vbReceiverOf(ref, context) : null; + const vbMemberRead = vbReceiver !== null && ref.referenceKind === 'references'; const objcShape = ref.language === 'objc' && ref.referenceKind === 'calls' && /^[A-Za-z_]\w*:*(?:\w+:)*$/.test(ref.referenceName) ? objcCallShape(ref, context) : null; const csharpBare = ref.language === 'csharp' && (ref.referenceKind === 'calls' || ref.referenceKind === 'references') && /^[A-Za-z_]\w*$/.test(ref.referenceName); @@ -5455,6 +5456,7 @@ export function matchByExactName( !(objcShape === 'self-send' && !isObjcSelfSendTarget(n, ref, context)) && !(objcShape === 'super-send' && !isObjcSelfSendTarget(n, ref, context, true)) && !(vbReceiver !== null && !isVbMemberReachable(n, vbReceiver)) && + !(vbMemberRead && !VB_MEMBER_KINDS.has(n.kind)) && !(rubyBare && n.kind === 'method' && !isRubyMethodInScope(n, ref, context)) && !(cfmlBare && n.kind === 'method' && !isCfmlMethodInScope(n, ref, context)) && !(javaBare && n.kind === 'method' && !isJavaMethodInScope(n, ref, context)) && @@ -9246,8 +9248,9 @@ export function matchFuzzy( const kotlinBare = kotlinCall && isReceiverLessKotlinCall(ref, context); 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) + const vbReceiver = ref.language === 'vbnet' && (ref.referenceKind === 'calls' || ref.referenceKind === 'instantiates' || ref.referenceKind === 'references') && /^\w+$/.test(ref.referenceName) ? vbReceiverOf(ref, context) : null; + const vbMemberRead = vbReceiver !== null && ref.referenceKind === 'references'; const objcShape = ref.language === 'objc' && ref.referenceKind === 'calls' && /^[A-Za-z_]\w*:*(?:\w+:)*$/.test(ref.referenceName) ? objcCallShape(ref, context) : null; const csharpBare = ref.language === 'csharp' && (ref.referenceKind === 'calls' || ref.referenceKind === 'references') && /^[A-Za-z_]\w*$/.test(ref.referenceName); @@ -9283,6 +9286,7 @@ export function matchFuzzy( !(rubyBare && n.kind === 'method' && !isRubyMethodInScope(n, ref, context)) && !(cfmlBare && n.kind === 'method' && !isCfmlMethodInScope(n, ref, context)) && !(vbReceiver !== null && !isVbMemberReachable(n, vbReceiver)) && + !(vbMemberRead && !VB_MEMBER_KINDS.has(n.kind)) && !(objcShape === 'c-call' && OBJC_MEMBER_KINDS.has(n.kind)) && !(objcShape === 'self-send' && !isObjcSelfSendTarget(n, ref, context)) && !(objcShape === 'super-send' && !isObjcSelfSendTarget(n, ref, context, true)) &&