From de9f346ceb6f53df5a214cd5563cb2ed907574ff Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 30 Sep 2026 21:52:10 +0000 Subject: [PATCH] fix(resolution): production code never names a test suite's symbol (#2209) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A test suite is not linked into the program, so a name match from production code into one is coincidence - but only Strategy 3's receiver guess (#2171) ruled it out. Now no strategy's name match reaches a symbol in a test suite from a non-test file: a test source set (`src/test/`, `jvmTest/`), a `tests/` / `__tests__/` / `spec/` / `e2e/` directory, a `FooTest.kt` / `test_foo.py` / `foo.test.ts` / `foo_spec.rb` file. Test-support code a project ships - `testing/`, `fakes/`, `mocks/`, a `*-test` module - stays in reach, and `…Spec.java` (halo's `IndexSpecs`, okhttp's `ConnectionSpec`), `post-spec.ts` models and `useTests.ts` hooks are not test suites. typeorm -64 (`Record` -> a test entity), okhttp -164 (117 sample `@Override` -> a test's nested `Override`), kotlinx.coroutines -318 (`import kotlinx.coroutines.*` onto one test file's package node), sttp -62, Exposed -92, halo 0, element-plus -36 (`isClient` -> a test's vueuse mock), excalidraw -10 (`actionManager.executeAction` -> the test `API` helper), tokio -30, trpc -36, AutoMapper -37, netbox -22, cats -21, mealie -10, pytest -8, Alamofire -7; zod, eShop, react-native-reusables unchanged. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/prod-to-test-suite.test.ts | 86 ++++++++++++++++++++++++++++ src/resolution/name-matcher.ts | 21 +++++++ 3 files changed, 108 insertions(+) create mode 100644 __tests__/prod-to-test-suite.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b67fd7356..c4b2319af 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 +- Production code no longer links to a same-named symbol inside a test suite, since tests aren't built into the program. Before, typeorm's `Record` types reached a test entity called `Record`, and okhttp's sample `@Override` annotations reached a test's nested `Override` class. Test-support code a project ships, such as a `testing/` folder or a `*-test` module, stays in reach. - A Dart call like `ext.endsWith(".avi")` or `map.putIfAbsent(…)` on a value whose type isn't known now counts as Dart's own String, List or Map method. It no longer lands on a project method of the same name. A Dart extension is also matched by the type it extends, not by its own name, so getx's `ext.endsWith` on a String stopped reaching `extension RxStringExt on Rx`. - Kotlin calls inside a DSL lambda now reach the lambda's own receiver. That receiver comes from the function the lambda is passed to, even through a typealias like koin's `Definition = Scope.(…) -> T`. It also wins over other types the project uses as lambda receivers elsewhere. On koin, over 2,000 `get()` calls in `single { … }` now reach `Scope.get` instead of `Koin.get`; on Exposed, `varchar(…)` inside a table object reaches `Table.varchar`; on kotlinx.coroutines, `launch { }` reaches `CoroutineScope.launch`. - A Swift call on a property declared with a type, like Kingfisher's `var cache: ImageCache!` or `self.storage.write(…)`, now reaches that type's method or one it inherits. It no longer lands on a subclass override in a test, or on another type's method of the same name. diff --git a/__tests__/prod-to-test-suite.test.ts b/__tests__/prod-to-test-suite.test.ts new file mode 100644 index 000000000..71669a086 --- /dev/null +++ b/__tests__/prod-to-test-suite.test.ts @@ -0,0 +1,86 @@ +/** + * A test suite is not linked into the program, so production code never + * means one of its symbols by name: typeorm's `Record` bound to a + * test entity `Record`, okhttp's samples' `@Override` to a test's nested + * `Override`. Test-support code a project ships (`testing/`) stays in reach, + * and a test still reaches another test's helpers. + */ +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-prod-test-')); + const files: Record = { + 'package.json': JSON.stringify({ name: 'orm', private: true }), + 'test/functional/entity/Record.ts': `export class Record { + id = 0; +} +`, + 'test/functional/helpers.ts': `export function makeFixture(): number { + return 1; +} +`, + 'test/functional/record.test.ts': `export function uses(): number { + return makeFixture(); +} +`, + 'src/testing/stub.ts': `export function stubConnection(): number { + return 2; +} +`, + 'src/columns.ts': `export function toJson(values: Record): string { + return JSON.stringify(values); +} + +export function connect(): number { + return stubConnection(); +} +`, + 'src/main/java/app/Service.java': `package app; + +public class Service implements Runnable { + @Override + public void run() { } +} +`, + 'src/test/java/app/InterceptorTest.java': `package app; + +public class InterceptorTest { + @interface Override { } +} +`, + }; + 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 targetsFrom = (file: string) => { + const ids = cg.getNodesInFile(file).map((n) => n.id); + return cg.getOutgoingEdgesFrom(ids).filter((e) => e.kind !== 'contains').map((e) => cg.getNode(e.target)!.filePath); +}; + +describe('production code and test suites', () => { + it('production code never names a test suite’s symbol', () => { + expect(targetsFrom('src/columns.ts')).not.toContain('test/functional/entity/Record.ts'); + expect(targetsFrom('src/main/java/app/Service.java')).not.toContain('src/test/java/app/InterceptorTest.java'); + }); + + it('shipped test support stays in reach, and tests reach each other', () => { + expect(targetsFrom('src/columns.ts')).toContain('src/testing/stub.ts'); + expect(targetsFrom('test/functional/record.test.ts')).toContain('test/functional/helpers.ts'); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 9dc397769..866cdb41d 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -933,6 +933,24 @@ function isLuaLocal(candidate: Node, context: ResolutionContext): boolean { const JVM_CALLABLE_KINDS: ReadonlySet = new Set(['method', 'function']); const JVM_TYPE_KINDS: ReadonlySet = new Set(['class', 'interface', 'enum', 'struct', 'trait', 'type_alias', 'annotation']); +/** + * A test suite — a test source set, a `tests/` / `__tests__/` / `spec/` + * directory, a `FooTest.kt` / `test_foo.py` / `foo.test.ts` file — as opposed + * to test-support code a project ships (`testing/`, `fakes/`, a `*-test` + * module like kotlinx-coroutines-test), which its own code may use. + */ +function isTestSuitePath(filePath: string): boolean { + if (!isTestPath(filePath)) return false; + const lower = filePath.toLowerCase(); + const name = lower.slice(lower.lastIndexOf('/') + 1); + const original = filePath.slice(filePath.lastIndexOf('/') + 1); + // (`…Spec.java` alone is no test: halo's `IndexSpecs`, okhttp's `ConnectionSpec`.) + if (name.startsWith('test_') || /[._-](?:test|tests)\.[a-z0-9]+$|[._](?:spec|specs)\.[a-z0-9]+$/.test(name) || + // CamelCase suffixes where the language names tests so: not `useTests.ts`, a React hook. + /(?:Test|Tests|TestCase)\.(?:java|kt|kts|swift|cs|scala|groovy|m|mm|vb|fs)$/.test(original) || name === 'conftest.py') return true; + return /(?:^|\/)(?:tests?|__tests__|specs?|e2e)\//.test(lower) || /(?:^|\/)[A-Za-z0-9]*(?:Test|Tests|Spec)\//.test(filePath); +} + const MINIFIED_SCRIPTS = new WeakMap>(); /** A minified / bundled script, by name (`jquery.min.js`) or by its text. */ @@ -1295,6 +1313,9 @@ export function isVisibleAcrossFiles(candidate: Node, ref: UnresolvedRef, contex // A vendored minified bundle's names are mangled: healthchecks' 369 `$(…)` // (jQuery, a global) went to a one-letter helper inside bootstrap-native.min.js. if (isMinifiedScript(candidate.filePath, context)) return false; + // A test suite is not linked into the program: typeorm's `Record` is + // not a test entity `Record`, tokio's `Output` not a `runtime/tests` type. + if (isTestSuitePath(candidate.filePath) && !isTestPath(ref.filePath)) return false; const lang = candidate.language as string; if (lang === 'c' || lang === 'cpp') { return (