fix(csharp): a bare name reaches only members in scope (#2151)

A bare C# name (a `calls` or `references` ref with no dot, where the line
shows no receiver) can mean a member only of the types around it (outer
classes included), their base types (read from `class X : Base, IFoo`
heads, every partial declaration), or a static using: the file's
`using static`, any `global using static`, or `<Using Include="…"
Static="true"/>` in the .csproj / Directory.Build.props files above it.
Names outside any recovered type are not judged.

A/B (edges removed / added): Newtonsoft.Json -1394/+910 (enum-valued
properties now reach the enum type), AutoMapper -135/+58, eShop -117/+5,
serilog -20/+17, CleanArchitecture 0.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-30 10:41:06 +00:00
committed by GitHub
parent b238e240e2
commit 6eb08d68cf
3 changed files with 218 additions and 0 deletions
+1
View File
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
- In C#, a bare name like `TestContext`, `Easing` or `Helper()` now resolves only to a member of the class it's written in, its outer classes, their base classes, or a type brought in with `using static`. That includes a `global using static` and a project file's `<Using … Static="true"/>`. On eShop, test classes' `TestContext` used to land on one other test class's property; on Newtonsoft.Json, `TypeNameHandling.All` now reaches the enum rather than a same-named property.
- In Objective-C, a message now reaches a method only when it could really be sent there. A message to `self` or `super` reaches only the sender's class and its superclasses, including categories on UIKit and AppKit views. A C-style call like `completionBlock()` is never taken for a method or property. A message to a local variable uses the variable's declared type (`FMResultSet *rs`). On SDWebImage, `[self class]`, `respondsToSelector:` and `isKindOfClass:` used to land on a proxy class's overrides over 150 times, and on fmdb `[rs close]` now reaches the result set.
- In VB.NET, a call through a receiver like `Me.Panel.Controls.Add(…)` or `New System.Drawing.Size(…)` no longer lands on an unrelated class's member with the same name. It now resolves only through `Me`, `MyBase` or `MyClass`, through the member's own class or module (`Logger.Log(…)`), or through a service lookup's type (`GetService(Of Notifier).Notify()`). On SCrawler, the Windows Forms designer code used to link over 700 `Size` calls to a nested enum's case and over 500 `Controls.Add` calls to a collection class.
- In CFML, a call no longer lands on an unrelated component's method just because the name matches. A call with no receiver inside a component now reaches only that component's methods and those of the components it extends, so built-ins like `now()` stay built-ins. A call on an untyped variable now needs the variable to be named after the component, so `server.keyExists(…)` is no longer taken for some component's `keyExists`. On coldbox, `now()` used to land on a date helper over 200 times, and TestBox's `fail(…)` in specs now reaches the spec base class.
@@ -0,0 +1,93 @@
/**
* A bare C# name — `TestContext`, `Easing`, `Helper()` — is a member of the
* types around it, of their base types, or of a type a static using brings in
* (in the file, as `global using static`, or as a project file's
* `<Using … Static="true"/>`). eShop's `TestContext.Current` in one test class
* went to another test class's `TestContext` property; MAUI's `Easing.Linear`
* to an animation class's `Easing` property.
*/
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-csharp-scope-'));
const files: Record<string, string> = {
'tests/OrderProcessorTests.cs': `namespace App.Tests;
public class OrderProcessorTests
{
public TestContext TestContext { get; set; } = null!;
}
`,
'src/Animations/AnimationBase.cs': `namespace App.Animations;
public abstract class AnimationBase
{
public EasingType Easing { get; set; }
}
`,
'src/Execution/ExpressionBuilder.cs': `namespace App.Execution;
public static class ExpressionBuilder
{
public static object Call(object target) => target;
}
`,
'src/App.csproj': `<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<Using Include="App.Execution.ExpressionBuilder" Static="true"/>
</ItemGroup>
</Project>
`,
'src/Specs/SpecBase.cs': `namespace App.Specs;
public abstract class SpecBase
{
protected void AssertValid() { }
}
`,
'src/Specs/CatalogSpec.cs': `namespace App.Specs;
public class CatalogSpec : SpecBase
{
public void Runs()
{
var token = TestContext.Current.CancellationToken;
var easing = Easing.Linear;
AssertValid();
Call(token);
}
}
`,
};
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('bare C# names', () => {
it('reach the base class and a project-wide static using, never another class’s property', () => {
const ids = cg.getNodesInFile('src/Specs/CatalogSpec.cs').map((n) => n.id);
const targets = cg
.getOutgoingEdgesFrom(ids)
.filter((e) => e.kind === 'calls' || e.kind === 'references')
.map((e) => cg.getNode(e.target)!.qualifiedName);
expect(targets).not.toContain('App.Tests::OrderProcessorTests::TestContext');
expect(targets).not.toContain('App.Animations::AnimationBase::Easing');
expect(targets).toContain('App.Specs::SpecBase::AssertValid');
expect(targets).toContain('App.Execution::ExpressionBuilder::Call');
});
});
+124
View File
@@ -4,6 +4,7 @@
* Handles symbol name matching for reference resolution.
*/
import * as fs from 'fs';
import * as path from 'path';
import { Language, Node } from '../types';
import { UnresolvedRef, ResolvedRef, ResolutionContext, isSupertypeTarget, CPP_DEFINE_SIGNATURE, isInheritanceRef, isImportableKind } from './types';
@@ -1431,6 +1432,123 @@ function dartHeadOf(decl: Node, context: ResolutionContext): { supers: string[];
};
}
const CSHARP_TYPE_KINDS: ReadonlySet<string> = new Set(['class', 'interface', 'enum', 'struct', 'record']);
const CSHARP_MEMBER_KINDS: ReadonlySet<string> = new Set(['method', 'property', 'field', 'enum_member', 'constant', 'event']);
const CSHARP_SUPERS = new WeakMap<ResolutionContext, Map<string, string[]>>();
const CSHARP_STATIC_USINGS = new WeakMap<ResolutionContext, Map<string, Set<string>>>();
/**
* Whether a bare C# name — `TestContext`, `Easing`, `Helper()` — can mean the
* member `n`: C# reads a bare name as a member of the types around it (outer
* classes included) or of their base types, or of a `using static` type.
* Never some unrelated class's: eShop's `TestContext.CancellationToken` in
* one test class went to another test class's `TestContext` property, MAUI's
* `Easing.Linear` to an animation class's `Easing`. Chain links the line
* shows a receiver for are not judged.
*/
function isCsharpMemberInScope(n: Node, ref: UnresolvedRef, context: ResolutionContext): boolean {
if (!CSHARP_MEMBER_KINDS.has(n.kind)) return true;
const cut = n.qualifiedName.lastIndexOf('::');
if (cut < 0 || !hasNoReceiverOnLine(ref, context)) return true;
const owner = n.qualifiedName.slice(0, cut).split(/::|\./).pop()!;
if (csharpStaticUsings(ref.filePath, context).has(owner)) return true;
const around = context
.getNodesInFile(ref.filePath)
.filter((t) => CSHARP_TYPE_KINDS.has(t.kind) && t.startLine <= ref.line && t.endLine >= ref.line);
// No type around the name: the declaration wasn't recovered — nothing to judge by.
if (around.length === 0) return true;
const seen = new Set<string>();
const queue = around.map((t) => t.name);
while (queue.length > 0 && seen.size < 40) {
const name = queue.shift()!;
if (seen.has(name)) continue;
seen.add(name);
if (name === owner) return true;
queue.push(...csharpSupertypesOf(name, context));
}
return false;
}
/** The simple names a C# type's declarations (every `partial` one) derive from. */
function csharpSupertypesOf(typeName: string, context: ResolutionContext): string[] {
let memo = CSHARP_SUPERS.get(context);
if (!memo) CSHARP_SUPERS.set(context, (memo = new Map()));
const hit = memo.get(typeName);
if (hit) return hit;
const names: string[] = [];
for (const decl of context.getNodesByName(typeName)) {
if (decl.language !== 'csharp' || !CSHARP_TYPE_KINDS.has(decl.kind)) continue;
const lines = context.getFileLines?.(decl.filePath) ?? context.readFile(decl.filePath)?.split(/\r?\n/) ?? [];
let head = lines.slice(decl.startLine - 1, decl.startLine + 8).join(' ');
head = head.slice(0, (head.indexOf('{') + 1 || head.length + 1) - 1);
let depth = 0;
let flat = '';
for (const ch of head) {
if (ch === '<' || ch === '(') depth++;
else if (ch === '>' || ch === ')') depth = Math.max(0, depth - 1);
else if (depth === 0) flat += ch;
}
const bases = new RegExp(`\\b${typeName}\\s*:\\s*(.*?)(?:\\bwhere\\b|$)`).exec(flat)?.[1] ?? '';
for (const m of bases.matchAll(/([A-Za-z_][\w.]*)/g)) names.push(m[1]!.split('.').pop()!);
}
memo.set(typeName, names);
return names;
}
/**
* The types a C# file sees through static usings: its own `using static
* A.B.Type;`, any file's `global using static`, and `<Using Include="A.B.Type"
* Static="true"/>` in the `.csproj` / `Directory.Build.props` files above it
* (AutoMapper imports its ExpressionBuilder helpers project-wide that way).
*/
function csharpStaticUsings(file: string, context: ResolutionContext): Set<string> {
let memo = CSHARP_STATIC_USINGS.get(context);
if (!memo) CSHARP_STATIC_USINGS.set(context, (memo = new Map()));
const hit = memo.get(file);
if (hit) return hit;
const owners = new Set<string>(csharpProjectStaticUsings(path.posix.dirname(file), context, memo));
for (const m of (context.readFile(file) ?? '').matchAll(/^\s*(?:global\s+)?using\s+static\s+([\w.]+)\s*;/gm)) owners.add(m[1]!.split('.').pop()!);
memo.set(file, owners);
return owners;
}
/** Static usings that apply to every file under `dir`: project files on the way up, and every `global using static`. */
function csharpProjectStaticUsings(dir: string, context: ResolutionContext, memo: Map<string, Set<string>>): Set<string> {
const key = `dir:${dir}`;
const hit = memo.get(key);
if (hit) return hit;
let owners: Set<string>;
if (dir === '.' || dir === '' || dir === '/') {
owners = new Set();
for (const f of context.getAllFiles()) {
if (!f.endsWith('.cs')) continue;
const text = context.readFile(f) ?? '';
if (!text.includes('global using static')) continue;
for (const m of text.matchAll(/^\s*global\s+using\s+static\s+([\w.]+)\s*;/gm)) owners.add(m[1]!.split('.').pop()!);
}
} else {
owners = new Set(csharpProjectStaticUsings(path.posix.dirname(dir), context, memo));
}
let entries: string[] = [];
try {
entries = fs.readdirSync(path.join(context.getProjectRoot(), dir === '.' ? '' : dir));
} catch {
entries = [];
}
for (const entry of entries) {
if (!/\.(?:csproj|props)$/i.test(entry)) continue;
let text = '';
try {
text = fs.readFileSync(path.join(context.getProjectRoot(), dir === '.' ? '' : dir, entry), 'utf8');
} catch {
continue;
}
for (const m of text.matchAll(/<Using\s+Include\s*=\s*"([\w.]+)"[^>]*\bStatic\s*=\s*"true"/gi)) owners.add(m[1]!.split('.').pop()!);
}
memo.set(key, owners);
return owners;
}
const OBJC_SUPERS = new WeakMap<ResolutionContext, Map<string, string[]>>();
const OBJC_MEMBER_KINDS: ReadonlySet<string> = new Set(['method', 'property', 'field']);
@@ -2250,8 +2368,10 @@ export function matchByExactName(
? vbReceiverOf(ref, context) : null;
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);
const phpSelf = phpSelfReceiver(ref, context);
const filtered = sameName.filter((n) =>
!(csharpBare && !isCsharpMemberInScope(n, ref, context)) &&
!(objcShape === 'c-call' && OBJC_MEMBER_KINDS.has(n.kind)) &&
!(objcShape === 'self-send' && !isObjcSelfSendTarget(n, ref, context)) &&
!(vbReceiver !== null && !isVbMemberReachable(n, vbReceiver)) &&
@@ -3376,6 +3496,8 @@ export function clearNameMatcherMemos(context: ResolutionContext): void {
RUBY_ANCESTRY.delete(context);
CFML_CHAINS.delete(context);
OBJC_SUPERS.delete(context);
CSHARP_SUPERS.delete(context);
CSHARP_STATIC_USINGS.delete(context);
LUA_LOCALS.delete(context);
PHP_FILE_SCOPES.delete(context);
JAVA_STATIC_IMPORTS.delete(context);
@@ -5377,6 +5499,7 @@ export function matchFuzzy(
? vbReceiverOf(ref, context) : null;
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);
const phpSelf = phpSelfReceiver(ref, context);
// Names are case-sensitive in every language but a handful: Rust's
// `Bytes` is not the method `bytes`, Python's builtin `dir(…)` not a class
@@ -5398,6 +5521,7 @@ export function matchFuzzy(
!(vbReceiver !== null && !isVbMemberReachable(n, vbReceiver)) &&
!(objcShape === 'c-call' && OBJC_MEMBER_KINDS.has(n.kind)) &&
!(objcShape === 'self-send' && !isObjcSelfSendTarget(n, ref, context)) &&
!(csharpBare && !isCsharpMemberInScope(n, ref, context)) &&
!(phpSelf && (n.kind !== 'method' || !isPhpMethodInScope(n, ref, phpSelf, context))))
.filter((n) => (ref.referenceKind !== 'references' && ref.referenceKind !== 'function_ref') ||
sameLanguageFamily(n.language, ref.language));