fix(resolution): a C#/VB.NET type position names a type, never a same-named member (#2121)

Found by the README-wide sweep: AutoMapper's most-depended-on list held a
test class's `List` property (519) and an attribute's `Type` property (259);
eShop's held MSTest's `TestContext` and MAUI's `Easing` as properties.

A `references`/`type_of`/`returns` ref whose SITE is a type position — read
from the source at its column: `Type name` (a declaration names its type
first), `List<`, `Type?` (not `?.`), `Type[]`, a generic argument, `new X`,
VB's `As` / `New` / `Of` — and every `instantiates` ref (its column is the
`new`) now has only type candidates in exact-name and fuzzy matching: a
property, method (a constructor is one), field or enum case sharing the name
is not one. With the member out of the way the real type wins: `TypeMap
typeMap` now links the `TypeMap` class instead of a `TypeMap` property beside
it. Anything else — `Builder.Services`, `MapGet("/x", GetItems)`, a route's
reference to its action — keeps every candidate.

A/B vs main (edge diffs, every change classified): AutoMapper −1,871/+777,
Newtonsoft.Json −3,612/+599, serilog −447/+187, eShop −78/+15,
CleanArchitecture −9. Losses are BCL/framework types that had been pinned on
project members (List, Type, DateTime, CancellationToken, Exception,
TimeSpan, Easing, TestContext…); gains are the project's own types
(TypeMap, MemberMap, LogEvent, LogEventProperty, TypeNameHandling,
JTokenReader, OrderStatus). An earlier cut that dropped every member
candidate also dropped method groups, `Builder.Services` reads and ASP.NET
route→action references — the position reading keeps all of those.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-30 04:50:16 +00:00
committed by GitHub
parent 7a51969fb0
commit e453e820a8
3 changed files with 117 additions and 1 deletions
+1
View File
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
- In C# and VB.NET, a type written in code, like `Type sourceType`, `List<int>`, `new TypeMap()` or `Dictionary<string, Type>`, now links to the type and no longer to a property, method or field that shares its name. `List<…>` used to link to some test class's `List` property, `Type` to an attribute's `Type` property, and a parameter of type `TypeMap` to a `TypeMap` property instead of the `TypeMap` class. Those members showed up with hundreds of made-up dependents. Reading a property or passing a method, like `Builder.Services` or `MapGet("/x", GetItems)`, still links as before. Re-index C# and VB.NET projects after upgrading.
- JavaScript and TypeScript calls are no longer linked to a class member that merely shares their name. A call on a namespace import, like zod's `z.string()` (`import * as z from 'zod'`), or on something imported from an npm package, used to link to whichever class in the project had a `string` member, and a bare call like mocha's `it(…)` or `describe(…)` to an interface's `it` property or a class's `describe` field. On zod one test helper's getter had thousands of made-up callers and topped the most-depended-on list. A React hook or component imported from a package, like `useQuery` from `@tanstack/react-query`, no longer links to a same-named one in the project, and a hook nested inside another function is no longer a target at all. Re-index JavaScript and TypeScript projects after upgrading.
- A Swift call through a nested type, like `API.PackageController.GetRoute.query(on: db)`, now links to that type's method. Only `query` used to be kept, so the call linked to whichever type's `query` came first; in a Vapor app every route type has one, so callers, impact and `codegraph_explore` answers pointed at another endpoint's code. The same applies to nested types' initializers (`API.PackageController.Model(name:)`), to enum cases (`Gitlab.Error.requestFailed(status:)`), to types declared in an `extension API.PackageController { … }`, and to The Composable Architecture's `Path.State.detail(…)`. A call on a type path that names no type in the project, such as an SDK's `Plot.Node.footer(…)`, is left unlinked. Re-index Swift projects after upgrading.
- NestJS routes are now named by the path a request takes: the global prefix from `app.setGlobalPrefix('api')` is applied, along with its `exclude` list, and so is URI versioning from `app.enableVersioning(...)`, with `@Version`, `VERSION_NEUTRAL` and a controller's own `version` taken into account. A route that used to appear as `GET /user` is now `GET /api/v1/user`, so a front end's `this.http.get('/api/v1/user')` or `fetch` call connects to the controller method that serves it. Re-index NestJS projects after upgrading.
@@ -0,0 +1,72 @@
/**
* A C# type position names a type. `Type sourceType`, `List<int>`, `new
* TypeMap()` used to resolve to whichever same-named member was nearest: on
* AutoMapper `Type` bound to an attribute's `Type` property, `List<…>` to a
* test class's `List` property (519 dependents), and `TypeMap typeMap` to a
* `TypeMap` property sitting beside the `TypeMap` class.
*/
import { describe, it, expect, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
const roots: string[] = [];
afterAll(() => {
for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true });
});
const FILES: Record<string, string> = {
'src/TypeMap.cs': `namespace App;
public class TypeMap {
public string Name { get; set; }
}
public class MemberMap {
public MemberMap() { }
}
`,
'src/Attrs.cs': `namespace App;
public class ValueConverterAttribute {
public System.Type Type { get; set; }
}
public class Source {
public System.Collections.Generic.List<int> List { get; set; }
}
`,
'src/Profile.cs': `namespace App;
using System;
using System.Collections.Generic;
public class Profile {
public TypeMap TypeMap { get; set; }
public void Configure(TypeMap typeMap, MemberMap memberMap, Type sourceType) {
var items = new List<int>();
var copy = new TypeMap();
}
}
`,
};
describe('C#: a type position names a type', () => {
it('never a property, method or case that shares the name', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-cs-type-refs-'));
roots.push(root);
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);
}
const cg = await CodeGraph.init(root, { index: true });
try {
const configure = cg.getNodesByName('Configure').find((n) => n.kind === 'method')!;
const targets = cg
.getOutgoingEdgesFrom([configure.id], ['references', 'instantiates', 'type_of'])
.map((e) => cg.getNode(e.target))
.map((n) => `${n!.kind}:${n!.name}`);
expect(targets).toContain('class:TypeMap');
expect(targets).toContain('class:MemberMap');
// A property, a constructor, or a type the project only has a member named after.
expect(targets.filter((t) => t.startsWith('property:') || t.startsWith('method:'))).toEqual([]);
} finally {
cg.close();
}
});
});
+44 -1
View File
@@ -995,6 +995,46 @@ const BARE_CALL_KEYWORDS: ReadonlySet<string> = new Set([
'return', 'await', 'yield', 'typeof', 'void', 'new', 'else', 'case', 'throw', 'in', 'of', 'instanceof', 'go', 'defer',
]);
/**
* A C# / VB.NET reference whose site is a TYPE position: `Type sourceType`,
* `List<int>`, `new TypeMap()`, `Exception? e`, `Dictionary<string, Type>`,
* VB's `As Type` / `New List(Of T)`. Read from the source at the reference's
* column; anything else — a member read (`Builder.Services`), a method group
* (`MapGet("/x", GetItems)`), a route's handler — keeps every candidate.
*/
function isDotNetTypeRef(ref: UnresolvedRef, context: ResolutionContext): boolean {
if (ref.language !== 'csharp' && ref.language !== 'vbnet') return false;
if (ref.referenceKind !== 'references' && ref.referenceKind !== 'instantiates' &&
ref.referenceKind !== 'type_of' && ref.referenceKind !== 'returns') return false;
const name = ref.referenceName;
if (!/^[A-Za-z_]\w*$/.test(name)) return false;
// `new TypeMap()` constructs a type; the reference's column is the `new`.
if (ref.referenceKind === 'instantiates') return true;
const line = context.getFileLines?.(ref.filePath)?.[ref.line - 1]
?? context.readFile(ref.filePath)?.split('\n')[ref.line - 1];
if (line === undefined || !line.startsWith(name, ref.column)) return false;
const before = line.slice(0, ref.column);
const after = line.slice(ref.column + name.length);
if (ref.language === 'vbnet') return /\b(?:As|New|Of)\s+$/i.test(before);
if (/\bnew\s+$/.test(before)) return true;
// `Type name` — a declaration names its type first.
if (/^\s+@?[A-Za-z_]/.test(after)) return !/^\s+(?:is|as|and|or|not|when|in|switch|with)\b/.test(after);
// `List<int>`, `Type?`, `Type[]`, and the arguments of a generic.
if (/^<|^\?(?![.?\[])|^\[\s*[,\]]/.test(after)) return true;
return /^\s*[,>]/.test(after) && /<[^<>()]*$/.test(before);
}
/**
* Whether a candidate can be what a .NET type position names. A property, a
* method (a constructor is one) or an enum case shares the type's name, not
* its meaning: AutoMapper's `Type sourceType` bound to an attribute's `Type`
* property and `TypeMap typeMap` to a `TypeMap` property beside the `TypeMap`
* class. A field never names a type either.
*/
function canNameInTypePosition(n: Node): boolean {
return !(n.kind === 'property' || n.kind === 'method' || n.kind === 'enum_member' || n.kind === 'field');
}
/** What only exists inside a type, reachable through a receiver alone. */
const TYPE_MEMBER_KINDS: ReadonlySet<string> = new Set(['method', 'property', 'field', 'enum_member']);
@@ -1203,8 +1243,10 @@ export function matchByExactName(
// upstream's language gate on the chosen result.
const cMacroCall = ref.referenceKind === 'calls' && (ref.language === 'c' || ref.language === 'cpp') &&
sameName.some((n) => n.kind === 'constant' && CPP_DEFINE_SIGNATURE.test(n.signature ?? ''));
const typeRef = isDotNetTypeRef(ref, context);
const candidates = sameName.filter((n) =>
!(cMacroCall && n.kind !== 'function' && n.kind !== 'method') &&
!(typeRef && !canNameInTypePosition(n)) &&
// Type/value references retain same-family eligibility: a native namesake
// must not hide the actual web type. Calls still gate only the winner.
(!valueRef || sameLanguageFamily(n.language, ref.language)) &&
@@ -4200,7 +4242,8 @@ export function matchFuzzy(
// Filter to callable kinds only (function, method, class)
const callableKinds = new Set(['function', 'method', 'class']);
const callableCandidates = candidates.filter((n) => callableKinds.has(n.kind))
const typeRef = isDotNetTypeRef(ref, context);
const callableCandidates = candidates.filter((n) => callableKinds.has(n.kind) && !(typeRef && !canNameInTypePosition(n)))
.filter((n) => (ref.referenceKind !== 'references' && ref.referenceKind !== 'function_ref') ||
sameLanguageFamily(n.language, ref.language));