From ff75a6ca5f6a3ccadae43ed55b1edc5304e80259 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 30 Sep 2026 23:56:34 +0000 Subject: [PATCH] fix(csharp): namespaces, usings and nested types follow the language's scopes (#2220) A block namespace qualifies only its own body (a nested one is Outer.Inner), mirrored in the kernel; a using names the project's namespace, never another file's using; a global using is its own project's; a nested type is reached by bare name only from inside its owner or a type deriving from it, through any partial part; visible C# types are filtered before ranking. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/csharp-namespace-scope.test.ts | 172 ++++++++++++++++++++ __tests__/fixtures/kernel-parity/Torture.cs | 5 + codegraph-kernel/src/csharp.rs | 75 ++++++--- src/extraction/languages/csharp.ts | 8 +- src/extraction/tree-sitter.ts | 26 +++ src/resolution/name-matcher.ts | 155 +++++++++++++++--- 7 files changed, 391 insertions(+), 51 deletions(-) create mode 100644 __tests__/csharp-namespace-scope.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b5dd83c8..fbb2017ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- C# names now follow the language's scopes. A block `namespace X { … }` qualifies only the types inside it, so a type declared after the block, or in a file's second namespace, is no longer filed under the first. A namespace written inside another is `Outer.Inner`. A `using` links to the project's namespace instead of another file's `using` of the same name. A `global using` applies only within its own project. A nested type is reachable by bare name only from inside its owner or a type deriving from it, including through another partial part. Before, serilog's `Guard` was filed under `JetBrains.Annotations`, its tests' `Some.InformationEvent()` went to the performance tests' `Some`, and AutoMapper's same-file `new Source()` could reach another test class's nested `Source`. - A function or name passed as a value now resolves to what is in scope where it's written. A function nested inside another function is only reachable from inside it. In Python, a parameter or local of the same name is that local. A pytest fixture is a test's parameter only in its own module or under its `conftest.py`. Before, httpx's `self._build_auth(auth)` linked to an `auth` a test defines inside another function, and `auth_flow(self, request)` handing `request` on linked to the package's `request()` function. - In Python, an attribute passed as a value through an object nothing types, like netbox's `device=self.parent.device`, is no longer linked to the project's only method of that name when the object isn't named after the method's class. Before, netbox's model-field reads went to a GraphQL filter's `device` method, and healthchecks' `check.last_ping` went to a notification transport's. - In Scala, a kind-projector placeholder in a type, like `Align[Either[A, *]]`, is no longer linked to an operator method named `*`. A symbolic name in a type, like cats' `F ~> G`, now resolves only to a type. diff --git a/__tests__/csharp-namespace-scope.test.ts b/__tests__/csharp-namespace-scope.test.ts new file mode 100644 index 000000000..b185d3f33 --- /dev/null +++ b/__tests__/csharp-namespace-scope.test.ts @@ -0,0 +1,172 @@ +/** + * C# names follow the scopes the language gives them: + * - a block `namespace X { … }` holds only its body — serilog's Guard.cs + * declares `static class Guard` after a `namespace JetBrains.Annotations` + * block, and a file's second namespace (or one nested inside another, + * `Outer.Inner`) is its own; + * - `using X;` names the project's namespace X, never another file's using; + * - a `global using` is its own project's — serilog's two test projects each + * `global using` their own `Support` namespace, and both define `Some`; + * - a nested type is reached by bare name only from inside its owner or a + * type deriving from it, through any partial part. + */ +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-ns-')); + const files: Record = { + 'src/Lib/Lib.csproj': '\n', + 'src/Lib/Guard.cs': `using JetBrains.Annotations; + +namespace JetBrains.Annotations +{ + sealed class NoEnumerationAttribute : System.Attribute { } +} + +static class Guard +{ + public static T AgainstNull(T value) where T : class => value; +} +`, + 'src/Lib/Multi.cs': `namespace Alpha +{ + class X { } +} + +namespace Beta +{ + class Y { } + + namespace Inner + { + class Z { } + } +} +`, + 'src/Lib/Uses.cs': `using Beta; + +namespace Gamma +{ + class Consumer { } +} +`, + 'src/Lib/Reader.cs': `namespace Lib +{ + public abstract class Reader + { + internal enum State { Start, Done } + } + + public partial class TextReader : Reader + { + } +} +`, + 'src/Lib/TextReader.Async.cs': `namespace Lib +{ + public partial class TextReader + { + object Peek() => State.Start; + } +} +`, + 'src/Lib/Writer.cs': `namespace Lib +{ + public abstract class Writer + { + internal enum State { Start, Done } + + object Current() => State.Done; + } +} +`, + 'test/Unit/Unit.csproj': '\n', + 'test/Unit/GlobalUsings.cs': 'global using Unit.Support;\n', + 'test/Unit/Support/Some.cs': `namespace Unit.Support; + +static class Some +{ + public static int InformationEvent() => 1; +} +`, + 'test/Unit/LogTests.cs': `namespace Unit.Context; + +class LogTests +{ + int Run() => Some.InformationEvent(); +} +`, + 'test/Unit/MappingTests.cs': `namespace Unit.Mapping; + +class FirstCase +{ + class Source { } + object Make() => new Source(); +} + +class SecondCase +{ + class Source { } + object Make() => new Source(); +} +`, + 'test/Perf/Perf.csproj': '\n', + 'test/Perf/GlobalUsings.cs': 'global using Perf.Support;\n', + 'test/Perf/Support/Some.cs': `namespace Perf.Support; + +static class Some +{ + public static int InformationEvent() => 2; +} +`, + }; + 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 }); +}); + +const qualifiedNames = (file: string) => cg.getNodesInFile(file).filter((n) => n.kind !== 'file').map((n) => `${n.kind} ${n.qualifiedName}`); +const targetsAt = (file: string, line: number, kind?: string) => { + const ids = cg.getNodesInFile(file).map((n) => n.id); + return cg.getOutgoingEdgesFrom(ids).filter((e) => e.line === line && (!kind || e.kind === kind)) + .map((e) => cg.getNode(e.target)!).map((t) => `${t.kind} ${t.filePath}:${t.qualifiedName}`); +}; + +describe('C# namespaces, usings and nested types', () => { + it('a block namespace qualifies only its own body', () => { + expect(qualifiedNames('src/Lib/Guard.cs')).toEqual(expect.arrayContaining([ + 'class JetBrains.Annotations::NoEnumerationAttribute', 'class Guard', 'method Guard::AgainstNull', + ])); + expect(qualifiedNames('src/Lib/Multi.cs')).toEqual(expect.arrayContaining([ + 'class Alpha::X', 'class Beta::Y', 'class Beta.Inner::Z', + ])); + }); + + it('a using names the project’s namespace', () => { + expect(targetsAt('src/Lib/Uses.cs', 1, 'imports')).toEqual(['namespace src/Lib/Multi.cs:Beta']); + }); + + it('a global using is its own project’s', () => { + expect(targetsAt('test/Unit/LogTests.cs', 5, 'calls')).toEqual(['method test/Unit/Support/Some.cs:Unit.Support::Some::InformationEvent']); + }); + + it('a nested type is its owner’s, or a derived type’s through any partial part', () => { + expect(targetsAt('test/Unit/MappingTests.cs', 12, 'instantiates')).toEqual(['class test/Unit/MappingTests.cs:Unit.Mapping::SecondCase::Source']); + expect(targetsAt('src/Lib/TextReader.Async.cs', 5)).toContain('enum src/Lib/Reader.cs:Lib::Reader::State'); + expect(targetsAt('src/Lib/Writer.cs', 7)).toContain('enum src/Lib/Writer.cs:Lib::Writer::State'); + }); +}); diff --git a/__tests__/fixtures/kernel-parity/Torture.cs b/__tests__/fixtures/kernel-parity/Torture.cs index 520b1d4e3..39e7743db 100644 --- a/__tests__/fixtures/kernel-parity/Torture.cs +++ b/__tests__/fixtures/kernel-parity/Torture.cs @@ -179,3 +179,8 @@ namespace Torture.Beta { public class Other { } } + +static class TopLevelGuard +{ + public static T AgainstNull(T value) where T : class => value; +} diff --git a/codegraph-kernel/src/csharp.rs b/codegraph-kernel/src/csharp.rs index aec1656f8..7d481662a 100644 --- a/codegraph-kernel/src/csharp.rs +++ b/codegraph-kernel/src/csharp.rs @@ -205,34 +205,21 @@ pub fn extract(file_path: &str, source: &str) -> Result { w.node_ids.push(ids::file_node_id(file_path)); w.stack.push(Scope { row: 0, kind: "file", name: base_name.to_string() }); - // extractFilePackage: the FIRST top-level namespace declaration mints ONE - // `namespace` node that stays pushed for the ENTIRE file — a second - // top-level namespace's types nest under the first's node/QN, nested - // namespaces leave no trace, and every import ref in a namespaced file - // hangs off this node (checklist §namespace). + // extractFilePackage: a top-level file-scoped `namespace X;` mints ONE + // `namespace` node that stays pushed for the ENTIRE file, so every import + // ref in the file hangs off it (checklist §namespace). A block namespace + // scopes only its own body — visit_node. let root = tree.root_node(); let mut pkg_pushed = false; for i in 0..root.named_child_count() { let Some(child) = root.named_child(i) else { continue }; - if child.kind() != "namespace_declaration" - && child.kind() != "file_scoped_namespace_declaration" - { + if child.kind() != "file_scoped_namespace_declaration" { continue; } - // csharpExtractor.extractPackage: `name` field ?? first - // qualified_name/identifier named child. No trim. - let name_node = child.child_by_field_name("name").or_else(|| { - (0..child.named_child_count()) - .filter_map(|j| child.named_child(j)) - .find(|c| matches!(c.kind(), "qualified_name" | "identifier")) - }); - if let Some(name_node) = name_node { - let pkg = w.text(name_node).to_string(); - if !pkg.is_empty() { - if let Some(row) = w.create_node("namespace", &pkg, child, Extra::default()) { - w.stack.push(Scope { row, kind: "namespace", name: pkg }); - pkg_pushed = true; - } + if let Some(pkg) = namespace_name(&w, child) { + if let Some(row) = w.create_node("namespace", &pkg, child, Extra::default()) { + w.stack.push(Scope { row, kind: "namespace", name: pkg }); + pkg_pushed = true; } } break; @@ -265,6 +252,18 @@ fn record_is_struct(node: Node) -> bool { .any(|c| c.kind() == "struct") } +/// csharpExtractor.extractPackage: `name` field ?? first qualified_name / +/// identifier named child. No trim; None when empty. +fn namespace_name(w: &Walker, node: Node) -> Option { + let name_node = node.child_by_field_name("name").or_else(|| { + (0..node.named_child_count()) + .filter_map(|j| node.named_child(j)) + .find(|c| matches!(c.kind(), "qualified_name" | "identifier")) + })?; + let name = w.text(name_node); + (!name.is_empty()).then(|| name.to_string()) +} + impl<'t> Walker<'t> { fn text(&self, node: Node) -> &'t str { &self.src[node.byte_range()] @@ -518,6 +517,38 @@ impl<'t> Walker<'t> { let kind = node.kind(); let mut skip_children = false; + // A block namespace scopes only its body (tree-sitter.ts visitNode): + // one written inside another is `Outer.Inner`, taking the outer's + // place on the stack while its body is walked. + if kind == "namespace_declaration" { + if let Some(ns_name) = namespace_name(self, node) { + let outer = match self.stack.last() { + Some(top) if top.kind == "namespace" => self.stack.pop(), + _ => None, + }; + let full = match &outer { + Some(o) => format!("{}.{}", o.name, ns_name), + None => ns_name, + }; + let row = self.create_node("namespace", &full, node, Extra::default()); + if let Some(row) = row { + self.stack.push(Scope { row, kind: "namespace", name: full }); + } + for i in 0..node.named_child_count() { + if let Some(c) = node.named_child(i) { + self.visit_node(c); + } + } + if row.is_some() { + self.stack.pop(); + } + if let Some(o) = outer { + self.stack.push(o); + } + return; + } + } + self.maybe_capture_fn_refs(node); if kind == "class_declaration" || kind == "record_declaration" { diff --git a/src/extraction/languages/csharp.ts b/src/extraction/languages/csharp.ts index a43539a7f..a88b01f81 100644 --- a/src/extraction/languages/csharp.ts +++ b/src/extraction/languages/csharp.ts @@ -82,10 +82,10 @@ export const csharpExtractor: LanguageExtractor = { typeAliasTypes: [], // Namespaces qualify type names so same-named types in different namespaces are // distinguishable (e.g. `ApplicationCore.Entities.CatalogBrand` vs - // `BlazorShared.Models.CatalogBrand`). Both block (`namespace Foo { … }`, which - // nests its types) and file-scoped (`namespace Foo;`) forms — extractFilePackage - // pushes the namespace onto the scope so nested/top-level types pick it up. - packageTypes: ['namespace_declaration', 'file_scoped_namespace_declaration'], + // `BlazorShared.Models.CatalogBrand`). A file-scoped `namespace Foo;` covers the + // whole file (extractFilePackage); a block `namespace Foo { … }` only its body + // (the visitor scopes it). + packageTypes: ['file_scoped_namespace_declaration'], extractPackage: (node: SyntaxNode, source: string) => { const name = node.childForFieldName('name') ?? diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 51086c3bf..7c9baa69d 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -1140,6 +1140,32 @@ export class TreeSitterExtractor { } } + // C# block namespaces scope only their own body: serilog's Guard.cs opens + // `namespace JetBrains.Annotations { … }` and then declares `static class + // Guard` at the top level, and a file's second namespace is its own. A + // namespace written inside another is `Outer.Inner` — the dotted name a + // type's qualifiedName leads with — so it takes the outer's place on the + // scope while its body is walked. (A file-scoped `namespace X;` covers + // the whole file: extractFilePackage.) Mirrored in the kernel (csharp.rs). + if (this.language === 'csharp' && nodeType === 'namespace_declaration') { + const nsName = this.extractor.extractPackage?.(node, this.source); + if (nsName) { + const topId = this.nodeStack[this.nodeStack.length - 1]; + const top = this.nodes.find((n) => n.id === topId); + const outer = top?.kind === 'namespace' ? top : null; + if (outer) this.nodeStack.pop(); + const ns = this.createNode('namespace', outer ? `${outer.name}.${nsName}` : nsName, node); + if (ns) this.nodeStack.push(ns.id); + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child) this.visitNode(child); + } + if (ns) this.nodeStack.pop(); + if (outer) this.nodeStack.push(outer.id); + return; + } + } + // Function-as-value capture (#756) — independent of the dispatch ladder // below (the captured container types have no other handler there), so it // can never shadow or be shadowed by an extraction branch. diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index e4f45f48a..83dbda245 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1339,7 +1339,8 @@ export function isVisibleAcrossFiles(candidate: Node, ref: UnresolvedRef, contex // not a test entity `Record`, tokio's `Output` not a `runtime/tests` type. if (isTestSuitePath(candidate.filePath) && !isTestPath(ref.filePath)) return false; if (candidate.language === 'csharp' && ref.language === 'csharp' && CSHARP_TYPE_KINDS.has(candidate.kind) && - /^[A-Za-z_]\w*$/.test(ref.referenceName) && !isCsharpTypeVisible(candidate, ref, context)) return false; + /^[A-Za-z_]\w*$/.test(ref.referenceName) && + (!isCsharpTypeVisible(candidate, ref, context) || !isCsharpNestedTypeInScope(candidate, ref, context))) return false; const lang = candidate.language as string; if (lang === 'c' || lang === 'cpp') { return ( @@ -3265,12 +3266,13 @@ function csharpProjectStaticUsings(dir: string, context: ResolutionContext, memo } const CSHARP_NAMESPACE_SCOPES = new WeakMap; aliases: Map }>>(); -const CSHARP_PROJECT_USINGS = new WeakMap>>(); +const CSHARP_PROJECT_USINGS = new WeakMap; project: boolean }>>(); /** * The namespaces a C# file's code runs in and the ones it imports: its - * `namespace` declarations, its `using X;`, every file's `global using X;` - * and the `` of the project files above it. + * `namespace` declarations, its `using X;`, the `global using X;` of its + * project's files (every file's, outside any project) and the + * `` of the project files above it. */ function csharpNamespaceScope(file: string, context: ResolutionContext): { namespaces: string[]; usings: Set; aliases: Map } { let memo = CSHARP_NAMESPACE_SCOPES.get(context); @@ -3281,7 +3283,9 @@ function csharpNamespaceScope(file: string, context: ResolutionContext): { names const namespaces = [...text.matchAll(/^\s*namespace\s+([\w.]+)/gm)].map((m) => m[1]!); let projectMemo = CSHARP_PROJECT_USINGS.get(context); if (!projectMemo) CSHARP_PROJECT_USINGS.set(context, (projectMemo = new Map())); - const usings = new Set(csharpProjectUsings(path.posix.dirname(file), context, projectMemo)); + const project = csharpProjectUsings(path.posix.dirname(file), context, projectMemo); + const usings = new Set(project.usings); + if (!project.project) for (const u of csharpGlobalUsings('.', context)) usings.add(u); const aliases = new Map(); for (const m of text.matchAll(/^\s*(?:global\s+)?using\s+(?!static\b)(?:([A-Za-z_]\w*)\s*=\s*)?([\w.]+)\s*;/gm)) { if (m[1]) aliases.set(m[1], m[2]!); @@ -3292,42 +3296,65 @@ function csharpNamespaceScope(file: string, context: ResolutionContext): { names return scope; } -/** `global using X;` from any file, and `` of the project files from `dir` up. */ -function csharpProjectUsings(dir: string, context: ResolutionContext, memo: Map>): Set { +/** + * The `` of the project files from `dir` up, and the + * `global using X;` of each project's own files — a global using is its + * project's alone: serilog's Serilog.Tests and Serilog.PerformanceTests each + * `global using` their own `Support` namespace, and both define `Some`. + * `project` says whether a `.csproj` sits at `dir` or above it. + */ +function csharpProjectUsings(dir: string, context: ResolutionContext, memo: Map; project: boolean }>): { usings: Set; project: boolean } { const key = dir; const hit = memo.get(key); if (hit) return hit; - let usings: Set; - if (dir === '.' || dir === '' || dir === '/') { - usings = new Set(); - for (const f of context.getAllFiles()) { - if (!f.endsWith('.cs') || (context.fileContains && !context.fileContains(f, 'global using'))) continue; - for (const m of (context.readFile(f) ?? '').matchAll(/^\s*global\s+using\s+(?!static\b)([\w.]+)\s*;/gm)) usings.add(m[1]!); - } - } else { - usings = new Set(csharpProjectUsings(path.posix.dirname(dir), context, memo)); - } + const root = dir === '.' || dir === '' || dir === '/'; + const parent = root ? null : csharpProjectUsings(path.posix.dirname(dir), context, memo); + const usings = new Set(parent?.usings ?? []); + let project = parent?.project ?? false; let entries: string[] = []; try { - entries = fs.readdirSync(path.join(context.getProjectRoot(), dir === '.' ? '' : dir)); + entries = fs.readdirSync(path.join(context.getProjectRoot(), root ? '' : dir)); } catch { entries = []; } for (const entry of entries) { if (!/\.(?:csproj|props)$/i.test(entry)) continue; - let project = ''; + let text = ''; try { - project = fs.readFileSync(path.join(context.getProjectRoot(), dir === '.' ? '' : dir, entry), 'utf8'); + text = fs.readFileSync(path.join(context.getProjectRoot(), root ? '' : dir, entry), 'utf8'); } catch { continue; } - for (const m of project.matchAll(/]*\bStatic\s*=\s*"true")[^>]*>/gi)) usings.add(m[1]!); + if (/\.csproj$/i.test(entry) && !project) { + project = true; + for (const u of csharpGlobalUsings(root ? '.' : dir, context)) usings.add(u); + } + for (const m of text.matchAll(/]*\bStatic\s*=\s*"true")[^>]*>/gi)) usings.add(m[1]!); // The SDK's implicit usings (serilog's own `System.TimeProvider` polyfill is seen through them). - if (/\s*(?:enable|true)\s*<\/ImplicitUsings>/i.test(project)) { + if (/\s*(?:enable|true)\s*<\/ImplicitUsings>/i.test(text)) { for (const ns of CSHARP_IMPLICIT_USINGS) usings.add(ns); } } - memo.set(key, usings); + const result = { usings, project }; + memo.set(key, result); + return result; +} + +const CSHARP_GLOBAL_USINGS = new WeakMap>>(); + +/** The `global using X;` of the `.cs` files under `dir` (`.` = the whole repository). */ +function csharpGlobalUsings(dir: string, context: ResolutionContext): Set { + let memo = CSHARP_GLOBAL_USINGS.get(context); + if (!memo) CSHARP_GLOBAL_USINGS.set(context, (memo = new Map())); + const hit = memo.get(dir); + if (hit) return hit; + const usings = new Set(); + const prefix = dir === '.' ? '' : `${dir}/`; + for (const f of context.getAllFiles()) { + if (!f.endsWith('.cs') || !f.startsWith(prefix) || (context.fileContains && !context.fileContains(f, 'global using'))) continue; + for (const m of (context.readFile(f) ?? '').matchAll(/^\s*global\s+using\s+(?!static\b)([\w.]+)\s*;/gm)) usings.add(m[1]!); + } + memo.set(dir, usings); return usings; } @@ -3356,6 +3383,60 @@ function isCsharpTypeVisible(n: Node, ref: UnresolvedRef, context: ResolutionCon return scope.usings.has(ns); } +/** + * Whether a bare C# name can reach `n` as a nested type: only from inside the + * type that declares it (any partial part, any depth) or a class deriving from + * it. AutoMapper's tests each declare their own nested `Source`, and a + * same-file `new Source()` went to whichever test class came first. + */ +function isCsharpNestedTypeInScope(n: Node, ref: UnresolvedRef, context: ResolutionContext): boolean { + const cut = n.qualifiedName.lastIndexOf('::'); + if (cut < 0) return true; + const owner = n.qualifiedName.slice(0, cut); + const ownerType = context.getNodesInFile(n.filePath).find((p) => CSHARP_TYPE_KINDS.has(p.kind) && p.qualifiedName === owner); + // Declared in a namespace, not a type. + if (!ownerType) return true; + const enclosing = context.getNodesInFile(ref.filePath) + .filter((p) => CSHARP_TYPE_KINDS.has(p.kind) && p.startLine <= ref.line && p.endLine >= ref.line); + if (enclosing.some((p) => p.qualifiedName === owner || p.qualifiedName.startsWith(`${owner}::`))) return true; + // Inherited: a type around the ref derives from the owner (`class SourceA : + // Source`), through any partial part — Newtonsoft's JsonTextReader.Async.cs + // is `partial class JsonTextReader` with no base list, reading JsonReader's `State`. + return enclosing.some((p) => csharpAncestorNames(p.qualifiedName, context).has(ownerType.name)); +} + +const CSHARP_ANCESTORS = new WeakMap>>(); + +/** The simple names of the C# types `qn` derives from, through every partial part and base, a few levels up. */ +function csharpAncestorNames(qn: string, context: ResolutionContext, depth = 0): Set { + let memo = CSHARP_ANCESTORS.get(context); + if (!memo) CSHARP_ANCESTORS.set(context, (memo = new Map())); + const hit = memo.get(qn); + if (hit) return hit; + const names = new Set(); + memo.set(qn, names); // a cycle reads what is gathered so far + for (const decl of context.getNodesByQualifiedName(qn)) { + 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 header = ''; + for (let i = decl.startLine - 1; i < Math.min(lines.length, decl.startLine + 6) && !header.includes('{'); i++) header += `${lines[i] ?? ''} `; + const list = /:\s*([^{;]*)/.exec(header.split('{')[0]!.replace(/\bwhere\b[\s\S]*$/, ''))?.[1] ?? ''; + for (const base of splitCppTopLevel(list)) { + const name = /([A-Za-z_]\w*)\s*(?:<.*)?$/.exec(base.trim())?.[1]; + if (name) names.add(name); + } + } + if (depth < 4) { + for (const base of [...names]) { + for (const t of context.getNodesByName(base)) { + if (t.language !== 'csharp' || !CSHARP_TYPE_KINDS.has(t.kind) || t.qualifiedName === qn) continue; + for (const up of csharpAncestorNames(t.qualifiedName, context, depth + 1)) names.add(up); + } + } + } + return names; +} + const OBJC_SUPERS = new WeakMap>(); const OBJC_MEMBER_KINDS: ReadonlySet = new Set(['method', 'property', 'field']); @@ -4395,6 +4476,13 @@ export function matchByExactName( (!importRef || isImportableKind(n.kind)) && // Nested locals are only reachable from inside their container (#1230). isLexicallyReachable(n, ref, context) && + // A C# type name is a type its namespaces can see — ahead of the ranking, + // so a visible namesake wins where the veto after it would drop the + // ref: eShop's `WebhookType.OrderPaid` under `using Webhooks.API.Model;`. + // A nested type, only from inside its owner: AutoMapper's same-file `new + // Source()` in one test class is not the previous test class's `Source`. + !(ref.language === 'csharp' && n.language === 'csharp' && CSHARP_TYPE_KINDS.has(n.kind) && /^[A-Za-z_]\w*$/.test(ref.referenceName) && + (!isCsharpNestedTypeInScope(n, ref, context) || (n.filePath !== ref.filePath && !isCsharpTypeVisible(n, ref, context)))) && // Preserve import ranking; calls reject the winner without promoting another. (!importRef || n.filePath === ref.filePath || !ESM_FAMILY.has(n.language) || !isSealedModule(n.filePath, context)) && @@ -4474,7 +4562,14 @@ export function matchByQualifiedName( ) : nodes; - const candidates = keepForRef(context.getNodesByQualifiedName(ref.referenceName)); + let candidates = keepForRef(context.getNodesByQualifiedName(ref.referenceName)); + // A C# `using X.Y;` names a namespace: one the project declares, else it is + // the file's own (external) using — never another file's using of that name. + if (ref.language === 'csharp' && ref.referenceKind === 'imports') { + const namespaces = candidates.filter((n) => n.kind === 'namespace'); + candidates = namespaces.length > 0 ? preferCallSiteFile(namespaces, ref.filePath).slice(0, 1) + : candidates.filter((n) => n.kind !== 'import' || n.filePath === ref.filePath); + } if (candidates.length === 1) { return { @@ -5508,6 +5603,8 @@ export function clearNameMatcherMemos(context: ResolutionContext): void { CSHARP_STATIC_USINGS.delete(context); CSHARP_NAMESPACE_SCOPES.delete(context); CSHARP_PROJECT_USINGS.delete(context); + CSHARP_GLOBAL_USINGS.delete(context); + CSHARP_ANCESTORS.delete(context); SCALA_SUPERS.delete(context); SCALA_IMPORTS.delete(context); ESM_EXPORT_LISTS.delete(context); @@ -6756,10 +6853,18 @@ export function matchMethodCall( // own file first — otherwise the first-indexed class wins and a call in `b/` // resolves to `a/`'s method (#1079). const strat1 = nmTimedT('mc-class', ref, (): ResolvedRef | null => { - const classCandidates = preferCallSiteFile( + let classCandidates = preferCallSiteFile( context.getNodesByName(objectOrClass!).filter(isMethodOwnerKind), ref.filePath, ); + // A C# class the call's namespaces can see before one they can't: + // serilog's `Some.InformationEvent()` in Serilog.Tests is its own + // Support namespace's `Some`, not the performance tests'. + if (ref.language === 'csharp' && classCandidates.length > 1) { + const typeRef = { ...ref, referenceName: objectOrClass! }; + const visible = classCandidates.filter((c) => c.language !== 'csharp' || isCsharpTypeVisible(c, typeRef, context)); + classCandidates = [...visible, ...classCandidates.filter((c) => !visible.includes(c))]; + } for (const classNode of classCandidates) { // Skip cross-language class matches