diff --git a/CHANGELOG.md b/CHANGELOG.md index 1815c4150..ac2e09a41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -201,6 +201,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). #### Symbols, tests and the viewer +- **An import that names the emitted extension resolves to its source.** Under `moduleResolution: node16 | nodenext | bundler` TypeScript requires `import { x } from './util.js'` for `util.ts`, and no file of that name exists, so the import resolver returned nothing and every name imported that way fell through to bare-name matching: a method wrapping the same-named helper it imports (`renderDockStyles() { return renderDockStyles(); }`) resolved to itself, and cross-module edges in such projects were name guesses. `.js` / `.jsx` / `.mjs` / `.cjs` specifiers now retry with the source extensions TypeScript compiles from when the emitted file is absent; a real `.js` beside the `.ts` still wins. On a 582-file repo whose `.ts` files import this way, import-backed `calls`/`imports` edges went from 4,002 to 7,312 and the eight wrapper-method self-edges disappeared. Re-index after upgrading. + - **Files under an `e2e/` directory count as tests.** Their calls no longer appear as production callers in Steps, dead-code and test badges. - **Production code under a `samples` or `examples` package path is no longer treated as test code.** A Kotlin or Java project whose package path runs through `com/google/samples/…` (Now in Android, for one) had nearly every file counted as a fixture, so the Map opened on `build-logic`, the entry points hid the app, and dead-code and test badges were wrong. Only the project layout above a `src/` folder decides now; the package path below it never does. diff --git a/__tests__/import-emitted-specifier.test.ts b/__tests__/import-emitted-specifier.test.ts new file mode 100644 index 000000000..1df1b77bc --- /dev/null +++ b/__tests__/import-emitted-specifier.test.ts @@ -0,0 +1,126 @@ +/** + * TypeScript's node16/nodenext/bundler resolution writes the EMITTED extension + * in a relative specifier (`./util.js` for `util.ts`). The import resolver must + * map that back to the source file that is actually in the repo; otherwise the + * imported names fall through to bare-name matching and a method that wraps a + * same-named import resolves to itself. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { CodeGraph } from '../src'; +import { resolveImportPath } from '../src/resolution/import-resolver'; +import type { ResolutionContext } from '../src/resolution'; + +function contextWithFiles(files: string[]): ResolutionContext { + const set = new Set(files); + return { + getNodesInFile: () => [], + getNodesByName: () => [], + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: (p: string) => set.has(p), + readFile: () => null, + getProjectRoot: () => '/test', + getAllFiles: () => files, + getNodesByLowerName: () => [], + getImportMappings: () => [], + } as unknown as ResolutionContext; +} + +describe('emitted-extension import specifiers (`./x.js` naming `x.ts`)', () => { + it('maps a relative .js specifier onto the .ts source', () => { + const ctx = contextWithFiles(['shared/engine.ts', 'shared/util.ts']); + expect(resolveImportPath('./util.js', 'shared/engine.ts', 'typescript', ctx)).toBe('shared/util.ts'); + }); + + it('prefers a real .js file over the remap when both exist', () => { + const ctx = contextWithFiles(['shared/engine.ts', 'shared/util.js', 'shared/util.ts']); + expect(resolveImportPath('./util.js', 'shared/engine.ts', 'typescript', ctx)).toBe('shared/util.js'); + }); + + it('maps .jsx, .mjs and .cjs onto their TypeScript sources', () => { + const ctx = contextWithFiles(['app/a.tsx', 'app/View.tsx', 'app/esm.mts', 'app/cjs.cts']); + expect(resolveImportPath('./View.jsx', 'app/a.tsx', 'tsx', ctx)).toBe('app/View.tsx'); + expect(resolveImportPath('./esm.mjs', 'app/a.tsx', 'tsx', ctx)).toBe('app/esm.mts'); + expect(resolveImportPath('./cjs.cjs', 'app/a.tsx', 'tsx', ctx)).toBe('app/cjs.cts'); + }); + + it('maps an aliased .js specifier through tsconfig paths', () => { + const files = ['src/main.ts', 'src/lib/util.ts']; + const ctx = { + ...contextWithFiles(files), + getProjectAliases: () => ({ + baseUrl: '/test', + patterns: [{ prefix: '@/', suffix: '', hasWildcard: true, replacements: ['src/*'] }], + }), + } as unknown as ResolutionContext; + expect(resolveImportPath('@/lib/util.js', 'src/main.ts', 'typescript', ctx)).toBe('src/lib/util.ts'); + }); + + it('leaves a specifier that names no source unresolved', () => { + const ctx = contextWithFiles(['shared/engine.ts']); + expect(resolveImportPath('./missing.js', 'shared/engine.ts', 'typescript', ctx)).toBeNull(); + }); + + it('does not remap for a language without TypeScript emit (python)', () => { + const ctx = contextWithFiles(['pkg/a.py', 'pkg/b.ts']); + expect(resolveImportPath('./b.js', 'pkg/a.py', 'python', ctx)).toBeNull(); + }); +}); + +describe('end to end: a wrapper method calling the same-named import it wraps', () => { + let tempDir: string; + let cg: CodeGraph | null = null; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-emitted-spec-')); + }); + + afterEach(() => { + cg?.destroy(); + cg = null; + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Windows can still hold the SQLite handle for a moment; the OS temp dir is swept anyway. + } + }); + + it('links the call to the imported function, not to the method itself', async () => { + fs.writeFileSync( + path.join(tempDir, 'template.ts'), + 'export function renderDockStyles(): string {\n return ".dock {}";\n}\n' + ); + fs.writeFileSync( + path.join(tempDir, 'sidebar.ts'), + [ + 'import { renderDockStyles } from "./template.js";', + '', + 'export class Sidebar {', + ' renderDockStyles(): string {', + ' return renderDockStyles();', + ' }', + '}', + '', + ].join('\n') + ); + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + const method = cg.getNodesByKind('method').find((n) => n.name === 'renderDockStyles'); + const fn = cg + .getNodesByKind('function') + .find((n) => n.name === 'renderDockStyles' && n.filePath === 'template.ts'); + expect(method).toBeDefined(); + expect(fn).toBeDefined(); + const targets = cg + .getOutgoingEdges(method!.id) + .filter((e) => e.kind === 'calls') + .map((e) => e.target); + expect(targets).toContain(fn!.id); + expect(targets).not.toContain(method!.id); + }); +}); diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index 58d957475..aa173a2c1 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -452,9 +452,49 @@ function resolveRelativeImport( return relativePath; } + return findSourceForEmittedSpecifier(relativePath, language, context); +} + +/** + * TypeScript under `moduleResolution: node16 | nodenext | bundler` writes the + * EMITTED extension in the specifier (`import x from './util.js'` for + * `util.ts`, `.mjs` for `.mts`, `.cjs` for `.cts`), and the source file with that + * exact name never exists in the repo. Without this remap the import resolver + * returned null for every such import, so each imported name fell through to + * bare-name matching: a method wrapping the same-named helper it imports + * (`renderDockStyles() { return renderDockStyles() }`) resolved to ITSELF, and + * any repo-wide same-named symbol could win the cross-module edge. + */ +const EMITTED_TO_SOURCE_EXTENSIONS: ReadonlyArray = [ + [/\.js$/, ['.ts', '.tsx', '.d.ts']], + [/\.jsx$/, ['.tsx']], + [/\.mjs$/, ['.mts', '.d.mts']], + [/\.cjs$/, ['.cts', '.d.cts']], +]; + +function findSourceForEmittedSpecifier( + relativePath: string, + language: Language, + context: ResolutionContext +): string | null { + if (!EMITTED_SPECIFIER_LANGUAGES.has(language)) return null; + for (const [emitted, sources] of EMITTED_TO_SOURCE_EXTENSIONS) { + if (!emitted.test(relativePath)) continue; + const stem = relativePath.replace(emitted, ''); + for (const ext of sources) { + const candidate = stem + ext; + if (context.fileExists(candidate)) return candidate; + } + return null; + } return null; } +/** Languages whose import specifiers can name the emitted `.js` of a `.ts` source. */ +const EMITTED_SPECIFIER_LANGUAGES: ReadonlySet = new Set([ + 'typescript', 'tsx', 'javascript', 'jsx', 'vue', 'svelte', 'astro', 'arkts', +]); + /** * Resolve an aliased/absolute import. * @@ -479,7 +519,7 @@ function resolveAliasedImport( if (context.fileExists(candidate)) return candidate; } if (context.fileExists(basePath)) return basePath; - return null; + return findSourceForEmittedSpecifier(basePath, language, context); }; // 1. Project tsconfig/jsconfig paths.