fix(nestjs): the provider convention stays in the reference's feature (#2246)

In a TS/JS module the *Service/*Controller convention picks the file's own
class or the convention file beside it (users.controller.ts → users.service.ts),
never a same-named class elsewhere preferred for its file name; anything
farther is the import resolver's or name matching's.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-01 04:15:09 +00:00
committed by GitHub
parent b2afa75f08
commit c38aa9c973
3 changed files with 68 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 NestJS projects, the provider naming convention (`…Service`, `…Controller`, …) now picks a class only from the reference's own file. Another file's provider is reached through its import, or by ordinary name matching that prefers the nearest declaration. Before, the convention's preference for `*.service.ts` files sent Nest's own specs' `TransientService` and `TestService` to same-named classes in unrelated sample apps.
- In Astro projects, the component-name convention now applies only inside `.astro` files, where components are used. Before, a TypeScript type with a component's name was linked to that component: astrowind's `image?: Image` and `callToAction?: CallToAction` in its type declarations, and starlight's Playwright `page: Page` in its tests.
- In JavaScript and TypeScript, the Express conventions for middleware (`validate…`, `auth`, `logger`, …), controllers and services now pick only the calling file's own declaration. Another file's is reached only through an `import` or `require`. Before, SvelteKit's remote functions linked `validate(arg)` to its config loader's `validate` in another package, and Express's examples linked `logger('dev')` to one example's `logger`.
- An Objective-C class that conforms to a protocol of its own name now links to that protocol instead of to itself. Swift's naming conventions for views, models and controllers also no longer apply to Objective-C code. Before, every one of SDWebImage's `@interface SDDiskCache : NSObject <SDDiskCache>`-style classes implemented itself.
@@ -0,0 +1,54 @@
/**
* The NestJS provider convention (`*Service`, `*Controller`, …) picks a
* class by name only from the reference's own file in a TS/JS module —
* another file's comes through an import, the import resolver's. Its
* file-name preference (`*.service.ts`) beat name matching's proximity:
* Nest's own specs' `TransientService` / `TestService` went to same-named
* classes in unrelated integration apps.
*/
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-nest-provider-'));
const files: Record<string, string> = {
'package.json': JSON.stringify({ name: 'nest', private: true, dependencies: { '@nestjs/common': '^10.0.0' } }),
'integration/injector/src/app.module.ts': `import { Module } from '@nestjs/common';
@Module({})
export class AppModule {}
`,
'integration/injector/src/scoped/transient.service.ts': `export class TransientService {}
`,
'packages/core/test/scope/helpers.ts': `export class TransientService {}
`,
'packages/core/test/scope/transient-scope.spec.ts': `class Holder {
constructor(public transient: TransientService) {}
}
`,
};
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('NestJS provider names', () => {
it('never prefer another file’s class for its file name over a nearer one', () => {
const ids = cg.getNodesInFile('packages/core/test/scope/transient-scope.spec.ts').map((n) => n.id);
const targets = cg.getOutgoingEdgesFrom(ids).filter((e) => e.kind === 'references').map((e) => cg.getNode(e.target)!.filePath);
expect(targets).not.toContain('integration/injector/src/scoped/transient.service.ts');
});
});
+13
View File
@@ -89,6 +89,19 @@ export const nestjsResolver: FrameworkResolver = {
.getNodesByName(ref.referenceName)
.filter((n) => n.kind === 'class');
if (candidates.length === 0) return null;
// A TS module reaches another file's class only by importing it — the
// import resolver's — so a guess by name stays in the feature: the
// file itself, or the convention file beside it (`users.controller.ts`
// → `users.service.ts`). Every Nest sample app declares its own
// `CatsService`, and the file-name preference sent specs across apps.
const own = candidates.find((n) => n.filePath === ref.filePath);
if (own) return { original: ref, targetNodeId: own.id, confidence: 0.85, resolvedBy: 'framework' };
if (/\.(?:m?[jt]sx?|cjs|cts|mts)$/.test(ref.filePath)) {
const dir = ref.filePath.slice(0, ref.filePath.lastIndexOf('/') + 1);
const beside = candidates.find((n) => n.filePath.includes(convention) &&
n.filePath.startsWith(dir) && !n.filePath.slice(dir.length).includes('/'));
return beside ? { original: ref, targetNodeId: beside.id, confidence: 0.85, resolvedBy: 'framework' } : null;
}
const preferred = candidates.find((n) => n.filePath.includes(convention));
const target = preferred ?? candidates[0]!;
return {