diff --git a/CHANGELOG.md b/CHANGELOG.md index 8611ba779..712ca3640 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 TypeScript, a name imported through a barrel's type-only re-export now links to the declaration the barrel re-exports: `export type * from './types'`, `export type { Options } from './options'`, and a `type` modifier inside a list, like `export { Client, type ClientOptions } from './client'`. Before, such a name was matched by name alone and could land on an unrelated type with the same name. A file containing `export type * from` also no longer reports `parse produced no symbols`. (#2474) - In TypeScript, an awaited call with type arguments like `await fetchApi("/me")` or `await api.get(url)` now links to the function it calls. Before, the call was dropped, so typed fetch wrappers looked like they had no callers. (#2586) - In React, a component rendered at module scope, in a top-level constant or inside a test's `it(…)` callback, like `createRoot(el).render()` or `render()`, now counts as rendered there, so an app's entry reaches its root component and a component's tests show among its callers. (#2587) - In Python, `import a.b as X` written inside a function now links its calls like `X.f()`, as the same import at module level already did. Thanks @Egrn for the report. (#2600) diff --git a/__tests__/status-index-health.test.ts b/__tests__/status-index-health.test.ts index b94562c8e..1ca9a7421 100644 --- a/__tests__/status-index-health.test.ts +++ b/__tests__/status-index-health.test.ts @@ -5,9 +5,10 @@ * "Up to date" used to mean only "every file's content hash matches the * index". Two kinds of file pass that check with nothing usable in the graph: * - * - a file the parser could not read — `export type * from` is valid - * TypeScript 5.0 the bundled grammar has no rule for, so the file is stored - * with a parse error and no symbols (#2336); + * - a file the parser could not read — a statement the grammar has no rule + * for (the report was TypeScript 5.0's `export type * from`, since given + * one in #2474; here a malformed `export`), so the file is stored with a + * parse error and no symbols (#2336); * - a row stored without its symbols — by an engine whose grammar failed to * load (#2335), or the #1541 wipe — which `codegraph sync` re-indexes. * @@ -96,7 +97,7 @@ describe('status reports files indexed without their symbols (#2336, #2335)', () it('a file the parser could not read keeps status from reporting "up to date"', async () => { fs.writeFileSync( path.join(dir, 'src', 'index.ts'), - 'export * from "./constants/index.js";\nexport type * from "./types/database.js";\n' + 'export * from "./constants/index.js";\nexport const = ;\n' ); await index(); diff --git a/__tests__/ts-type-only-reexports.test.ts b/__tests__/ts-type-only-reexports.test.ts new file mode 100644 index 000000000..a363f4cde --- /dev/null +++ b/__tests__/ts-type-only-reexports.test.ts @@ -0,0 +1,98 @@ +/** + * A barrel's type-only re-exports are re-exports: `export type * from + * './types'` (TypeScript 5.0), `export type { Options } from './options'`, and + * a `type` modifier on one name of a list. The re-export reader knew none of + * the three, so a name imported through one was matched by name instead — the + * right declaration while the name was unique, any same-named one otherwise. + * `export type *` is also not in the grammar: the file parsed with an error, + * and a barrel made only of it "produced no symbols" (#2474). + */ +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'; +import { blankExportTypeStar } from '../src/extraction/languages/typescript'; +import { extractReExports } from '../src/resolution/import-resolver'; + +let root = ''; +let cg: CodeGraph; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ts-type-reexport-')); + const files: Record = { + 'src/client.ts': `export class Client {} +export interface ClientOptions { retries: number } +`, + 'src/options.ts': `export interface Options { verbose: boolean } +`, + 'src/types.ts': `export interface Extra { tag: string } +`, + // Same names, declared where nothing re-exports them: what name matching may pick. + 'src/legacy/shapes.ts': `export interface ClientOptions { old: true } +export interface Options { old: true } +export interface Extra { old: true } +`, + 'src/index.ts': `export { Client, type ClientOptions } from './client'; +export type { Options } from './options'; +export type * from './types'; +`, + 'src/app.ts': `import { Client, type ClientOptions, type Options, type Extra } from './index'; + +export function run(options: Options, client: ClientOptions, extra: Extra): Client { + return new Client(); +} +`, + }; + 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 }); +}); + +/** `name -> file (resolvedBy)` of what `src/app.ts` imports by name. */ +const imported = () => { + const file = cg.getNodesInFile('src/app.ts').find((n) => n.kind === 'file')!; + return cg.getOutgoingEdges(file.id).filter((e) => e.kind === 'imports').map((e) => cg.getNode(e.target)!) + .filter((t) => t.kind !== 'file').map((t) => `${t.name} -> ${t.filePath}`).sort(); +}; + +describe('type-only re-exports through a barrel', () => { + it('lead to the declaration the barrel re-exports, in all three forms', () => { + expect(imported()).toEqual([ + 'Client -> src/client.ts', + 'ClientOptions -> src/client.ts', + 'Extra -> src/types.ts', + 'Options -> src/options.ts', + ]); + }); + + it('are read as re-exports', () => { + expect(extractReExports(`export type * from './types';`, 'typescript')).toEqual([{ kind: 'wildcard', source: './types' }]); + expect(extractReExports(`export type * as Types from './types';`, 'typescript')).toEqual([ + { kind: 'namespace', exportedName: 'Types', source: './types' }, + ]); + expect(extractReExports(`export type { Options as Opts } from './options';`, 'typescript')).toEqual([ + { kind: 'named', exportedName: 'Opts', originalName: 'Options', source: './options' }, + ]); + expect(extractReExports(`export { Client, type ClientOptions, type as kind } from './client';`, 'typescript')).toEqual([ + { kind: 'named', exportedName: 'Client', originalName: 'Client', source: './client' }, + { kind: 'named', exportedName: 'ClientOptions', originalName: 'ClientOptions', source: './client' }, + { kind: 'named', exportedName: 'kind', originalName: 'type', source: './client' }, + ]); + }); + + it('`export type *` reads as the `export *` it is to the grammar, offsets kept', () => { + expect(blankExportTypeStar(`export type * from './types';\n export type * as T from './t';`)) + .toBe(`export * from './types';\n export * as T from './t';`); + // A declared type, and a type named in a list, are not re-export-all. + const kept = `export type Star = '*';\nexport type { A } from './a';`; + expect(blankExportTypeStar(kept)).toBe(kept); + }); +}); diff --git a/src/extraction/languages/typescript.ts b/src/extraction/languages/typescript.ts index eae4cc8df..62ef282d5 100644 --- a/src/extraction/languages/typescript.ts +++ b/src/extraction/languages/typescript.ts @@ -57,6 +57,16 @@ export function blankFlowSyntax(source: string, filePath?: string): string { .replace(/\.\.\.(?=\s*[},])/g, ' '); } +/** + * `export type * from './types'` and `export type * as ns from './types'` + * (TypeScript 5.0) are not in the grammar: `type` is an error there, and a + * barrel made only of such lines parses to nothing. With `type` blanked to + * spaces (offsets survive) each reads as the `export *` it is to the graph. + */ +export function blankExportTypeStar(source: string): string { + return source.replace(/^([ \t]*export[ \t]+)type(?=[ \t]*\*)/gm, '$1 '); +} + /** * Whether `node` is declared in a `declare module 'x' { … }` or `declare * global { … }` body, which exports it without an `export` keyword. @@ -135,7 +145,7 @@ export function isAmbientFunctionSignature(node: SyntaxNode, filePath: string): } export const typescriptExtractor: LanguageExtractor = { - preParse: blankFlowSyntax, + preParse: (source, filePath) => blankExportTypeStar(blankFlowSyntax(source, filePath)), functionTypes: ['function_declaration', 'generator_function_declaration', 'arrow_function', 'function_expression', 'generator_function'], isAmbientFunction: isAmbientFunctionSignature, classTypes: ['class_declaration', 'abstract_class_declaration'],