diff --git a/CHANGELOG.md b/CHANGELOG.md index bde61afc1..f05f15e31 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 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 `-style classes implemented itself. diff --git a/__tests__/nestjs-provider-same-file.test.ts b/__tests__/nestjs-provider-same-file.test.ts new file mode 100644 index 000000000..fe219d0c9 --- /dev/null +++ b/__tests__/nestjs-provider-same-file.test.ts @@ -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 = { + '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'); + }); +}); diff --git a/src/resolution/frameworks/nestjs.ts b/src/resolution/frameworks/nestjs.ts index dbadbbc9e..d3effb119 100644 --- a/src/resolution/frameworks/nestjs.ts +++ b/src/resolution/frameworks/nestjs.ts @@ -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 {