Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- A file is no longer saved with no symbols when its language parser can't be loaded, which is what happened to every file a background server re-indexed after an upgrade removed its install: the file keeps what it had and is indexed again once the parser loads, and files an earlier version emptied this way are re-indexed by the next sync. A background server also exits on its own once its install is upgraded or removed, so the next session starts one from the current install. Thanks @lipchey for the report. (#2335)
- `codegraph status` no longer says the index is up to date while indexed files are missing their symbols: it now names files the parser couldn't read and files stored without their symbols (which `codegraph sync` repairs), `status --json` counts both, and `codegraph files --json` lists each file's recorded errors. Thanks @lipchey for the report. (#2336)
- Indexing large Python projects is much faster again and needs less memory: since 1.6.2, resolving Python references re-read source files over and over, so a project the size of CPython took several times as long to index. The graph it builds is unchanged. Thanks @bompus for the report. (#2332)
- In VB.NET, reading or setting a `Shared` field or property through its class or module name, like `AppSession.SessionId`, `AppSession.CurrentUser = "demo"` or `AppSession.Items(0)`, now counts as a use of that member and of the class, and so do reading an `Enum` value like `Mode.Fast` and calling a `Shared` function without parentheses. Before, only calls written with parentheses were linked, so `codegraph callers` on such a field, property or class came back empty and impact missed most of the code that depends on it. A local, parameter or field that only shares a class's name is not mistaken for the class. Thanks @serkanince for the report and @ChrisPrapas. (#2305)

## [1.6.2] - 2026-10-03

Expand Down
218 changes: 218 additions & 0 deletions __tests__/vbnet-shared-member-refs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
/**
* 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, and links nothing here (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('link nothing through a local, parameter or field, whatever its case', () => {
expect(linkedFrom('OtherSession::SessionId', 'references')).toEqual([]);
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']);
});
});
43 changes: 41 additions & 2 deletions src/extraction/tree-sitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,7 @@ const PHP_TYPE_NODES: ReadonlySet<string> = new Set([
*/
const MEMBER_ACCESS_TYPES: ReadonlySet<string> = new Set([
'field_access', // java (`Foo.BAR`)
'member_access_expression', // c# (`Foo.Bar`)
'member_access_expression', // c# / vb.net (`Foo.Bar`)
'navigation_expression', // kotlin / swift (`Foo.bar`)
'field_expression', // scala (`Foo.bar`)
'class_constant_access_expression', // php (`Foo::CONST`, `Foo::class`)
Expand All @@ -380,11 +380,21 @@ const MEMBER_ACCESS_TYPES: ReadonlySet<string> = new Set([
* static read is pure duplication) — while adding real graph noise (+1813 edges /
* +2448 `references` on excalidraw, the retrieval-perf benchmark, all pointing at
* already-covered types). Don't re-add `member_expression`/`attribute` here.
* VB.NET (#2305) sends the member with its receiver instead, and its resolver
* decides whether the receiver is a type (see extractVbMemberRead).
*/
const STATIC_MEMBER_LANGS: ReadonlySet<string> = new Set([
'java', 'csharp', 'kotlin', 'swift', 'scala', 'dart', 'php', 'cpp',
'java', 'csharp', 'kotlin', 'swift', 'scala', 'dart', 'php', 'cpp', 'vbnet',
]);

/**
* VB.NET receivers no project type can be named: the namespace roots
* (`System.IO.Path`, `My.Settings`, `Global.X`) and the built-in type keywords
* (`String.Empty`, `Integer.MaxValue`). A read through one is never sent.
*/
const VB_NON_TYPE_RECEIVERS =
/^(?:Global|System|Microsoft|My|Boolean|Byte|Char|Date|Decimal|Double|Integer|Long|Object|SByte|Short|Single|String|UInteger|ULong|UShort)$/i;

/**
* Tree-sitter node kinds that represent constructor invocations
* (`new Foo()` and friends). Used by extractInstantiation to emit
Expand Down Expand Up @@ -5549,6 +5559,10 @@ export class TreeSitterExtractor {
getChildByField(node, 'scope') ??
node.namedChild(0);
if (!recv) return;
if (this.language === 'vbnet') {
this.extractVbMemberRead(node, recv, ownerId);
return;
}
const t = recv.type;
if (
t === 'identifier' || t === 'type_identifier' || t === 'simple_identifier' ||
Expand All @@ -5569,6 +5583,31 @@ export class TreeSitterExtractor {
});
}

/**
* VB.NET: a value read or write through a name — `AppSession.SessionId`,
* `AppSession.CurrentUser = "demo"`, `Logger.Level`, `Mode.Fast` — is a use
* of the member as well as of what the name names (#2305). One `references`
* ref carries both, as `Name.Member` (the receiver kept, as a call's is);
* the resolver links the member and the type when the name means a project
* type or module there, and nothing when it holds a value (a local, a
* parameter, a field), which only it can tell in case-insensitive VB.NET
* (see vbnet-receivers' matchVbMemberRead). Its types are Capitalized all
* the same, so a lowercase receiver — a local, nearly always — is skipped.
*/
private extractVbMemberRead(node: SyntaxNode, recv: SyntaxNode, ownerId: string): void {
const member = getChildByField(node, 'member');
if (recv.type !== 'identifier' || member?.type !== 'identifier') return;
const name = getNodeText(recv, this.source);
if (!/^[A-Z]\w*$/.test(name) || VB_NON_TYPE_RECEIVERS.test(name)) return;
this.unresolvedReferences.push({
fromNodeId: ownerId,
referenceName: `${name}.${getNodeText(member, this.source)}`,
referenceKind: 'references',
line: node.startPosition.row + 1,
column: node.startPosition.column,
});
}

/**
* Find a `class_body` child of an `object_creation_expression` — the
* marker for an anonymous class (`new T() { ... }`). Returns the body
Expand Down
8 changes: 7 additions & 1 deletion src/resolution/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, is
import { isVisibleCppMacro, clearCppMacroVisibility } from './cpp-macro-visibility';
import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor';
import { gateSwiftTypeTarget, clearSwiftTypeVisibility, swiftExtendedConformances } from './swift-type-visibility';
import { clearVbnetReceiverMemos } from './vbnet-receivers';
import { clearVbnetReceiverMemos, isVbMemberRead, matchVbMemberRead } from './vbnet-receivers';
import { gateTypeParameter, clearTypeParameterMemos } from './type-parameters';
import { resolveViaImport, resolvePhpImportedStaticCall, resolvePhpQualifiedClassRef, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, isJsPathImportRef, isBoundToOutOfRepoImport, clearImportResolverMemos, resolveImportPath, isExternalImport } from './import-resolver';
import { ResolverPool, minRefsForPool, shouldEngageAdaptively } from './resolver-pool';
Expand Down Expand Up @@ -1134,6 +1134,12 @@ export class ReferenceResolver {
return this.gateLanguage(matchJsStoreBindingCall(ref, this.context), ref);
}

// A VB.NET value read through a name (`AppSession.SessionId`, #2305) means
// what VB.NET's scoping says the name is — a project type, whose member
// and the type itself it links, or a value, which links nothing here — and
// no framework, import or name strategy guesses past that.
if (isVbMemberRead(ref)) return this.gateLanguage(matchVbMemberRead(ref, this.context), ref);

// Function-as-value refs (#756) get a dedicated, strictly-gated path:
// import-based resolution first (an imported callback resolves through its
// import, the most precise cross-file signal), then matchFunctionRef
Expand Down
Loading