diff --git a/CHANGELOG.md b/CHANGELOG.md index 620784862..f7c350fe8 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 +- In C# and VB.NET, a type written in code, like `Type sourceType`, `List`, `new TypeMap()` or `Dictionary`, 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. diff --git a/__tests__/csharp-type-position-refs.test.ts b/__tests__/csharp-type-position-refs.test.ts new file mode 100644 index 000000000..c000d9210 --- /dev/null +++ b/__tests__/csharp-type-position-refs.test.ts @@ -0,0 +1,72 @@ +/** + * A C# type position names a type. `Type sourceType`, `List`, `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 = { + '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 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(); + 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(); + } + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index c517b4061..629eb70d3 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -995,6 +995,46 @@ const BARE_CALL_KEYWORDS: ReadonlySet = 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`, `new TypeMap()`, `Exception? e`, `Dictionary`, + * 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`, `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 = 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));