Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<User>("/me")` or `await api.get<T>(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(<App />)` or `render(<Card />)`, 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)
Expand Down
9 changes: 5 additions & 4 deletions __tests__/status-index-health.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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();

Expand Down
98 changes: 98 additions & 0 deletions __tests__/ts-type-only-reexports.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
'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);
});
});
12 changes: 11 additions & 1 deletion src/extraction/languages/typescript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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'],
Expand Down