Skip to content
Open
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
75 changes: 75 additions & 0 deletions apps/api-extractor/src/analyzer/TypeScriptInternals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,30 @@ export interface IGlobalVariableAnalyzer {
hasGlobalName(name: string): boolean;
}

interface IProgramInternals {
forEachResolvedModule?(
callback: (
resolution: ts.ResolvedModuleWithFailedLookupLocations,
moduleName: string,
mode: ts.ResolutionMode,
filePath: ts.Path
) => void,
file?: ts.SourceFile
): void;

forEachResolvedTypeReferenceDirective?(
callback: (
resolution: ts.ResolvedTypeReferenceDirectiveWithFailedLookupLocations,
moduleName: string,
mode: ts.ResolutionMode,
filePath: ts.Path
) => void,
file?: ts.SourceFile
): void;

getAutomaticTypeDirectiveResolutions?(): ts.ModeAwareCache<ts.ResolvedTypeReferenceDirectiveWithFailedLookupLocations>;
}

export class TypeScriptInternals {
public static getImmediateAliasedSymbol(symbol: ts.Symbol, typeChecker: ts.TypeChecker): ts.Symbol {
// Compiler internal:
Expand Down Expand Up @@ -98,6 +122,57 @@ export class TypeScriptInternals {
return result?.resolvedModule;
}

/**
* Builds a mapping from SourceFiles to their package names using the compiler's cached module resolutions.
*/
public static getPackageNamesBySourceFile(program: ts.Program): ReadonlyMap<ts.SourceFile, string> {
const packageNamesBySourceFile: Map<ts.SourceFile, string> = new Map<ts.SourceFile, string>();
const ambiguousSourceFiles: Set<ts.SourceFile> = new Set<ts.SourceFile>();

const collectPackageName = (
resolvedFileName: string | undefined,
packageId: ts.PackageId | undefined
): void => {
if (!resolvedFileName || !packageId?.name) {
return;
}

const sourceFile: ts.SourceFile | undefined = program.getSourceFile(resolvedFileName);
if (!sourceFile || ambiguousSourceFiles.has(sourceFile)) {
return;
}

const existingPackageName: string | undefined = packageNamesBySourceFile.get(sourceFile);
if (existingPackageName === undefined) {
packageNamesBySourceFile.set(sourceFile, packageId.name);
} else if (existingPackageName !== packageId.name) {
packageNamesBySourceFile.delete(sourceFile);
ambiguousSourceFiles.add(sourceFile);
}
};

const programInternals: IProgramInternals = program as IProgramInternals;
programInternals.forEachResolvedModule?.(({ resolvedModule }) => {
collectPackageName(resolvedModule?.resolvedFileName, resolvedModule?.packageId);
});
programInternals.forEachResolvedTypeReferenceDirective?.(({ resolvedTypeReferenceDirective }) => {
collectPackageName(
resolvedTypeReferenceDirective?.resolvedFileName,
resolvedTypeReferenceDirective?.packageId
);
});
programInternals
.getAutomaticTypeDirectiveResolutions?.()
.forEach(({ resolvedTypeReferenceDirective }) => {
collectPackageName(
resolvedTypeReferenceDirective?.resolvedFileName,
resolvedTypeReferenceDirective?.packageId
);
});

return packageNamesBySourceFile;
}

/**
* Gets the mode required for module resolution required with the addition of Node16/nodenext
*/
Expand Down
103 changes: 103 additions & 0 deletions apps/api-extractor/src/api/test/Extractor-package-name.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import * as path from 'node:path';
import type * as ts from 'typescript';

import { PackageJsonLookup } from '@rushstack/node-core-library';

import { TypeScriptInternals } from '../../analyzer/TypeScriptInternals';
import { CompilerState } from '../CompilerState';
import { Extractor, type ExtractorResult } from '../Extractor';
import { ExtractorConfig } from '../ExtractorConfig';

const testDataFolder: string = path.join(__dirname, 'test-data', 'package-name-resolution');

describe('Extractor package name resolution', () => {
afterEach(() => {
jest.restoreAllMocks();
});

it.each([
{ description: 'disabled', configFileName: 'api-extractor.json' },
{ description: 'enabled', configFileName: 'api-extractor-preserve-symlinks.json' }
])('reuses cached package metadata when preserveSymlinks is $description', ({ configFileName }) => {
const extractorConfig: ExtractorConfig = ExtractorConfig.loadFileAndPrepare(
path.join(testDataFolder, configFileName)
);
const compilerState: CompilerState = CompilerState.create(extractorConfig);
const program: ts.Program = compilerState.program as ts.Program;
const packageNamesBySourceFile: ReadonlyMap<ts.SourceFile, string> =
TypeScriptInternals.getPackageNamesBySourceFile(program);

expect(
packageNamesBySourceFile.get(
_getSourceFile(program, ['@microsoft', 'tsdoc', 'lib-commonjs', 'beta', 'DeclarationReference.d.ts'])
)
).toBe('@microsoft/tsdoc');
expect(packageNamesBySourceFile.get(_getSourceFile(program, ['@types', 'resolve', 'index.d.ts']))).toBe(
'@types/resolve'
);
expect(packageNamesBySourceFile.get(_getSourceFile(program, ['@types', 'semver', 'index.d.ts']))).toBe(
'@types/semver'
);

const packageJsonLookupSpy: jest.SpiedFunction<PackageJsonLookup['tryLoadNodePackageJsonFor']> =
jest.spyOn(PackageJsonLookup.prototype, 'tryLoadNodePackageJsonFor');

const extractorResult: ExtractorResult = Extractor.invoke(extractorConfig, {
compilerState,
localBuild: true
});

expect(extractorResult.succeeded).toBe(true);
expect(_getDeclarationReferenceLookupCalls(packageJsonLookupSpy)).toHaveLength(0);
});

it('falls back to package.json lookup when resolution metadata is unavailable', () => {
const extractorConfig: ExtractorConfig = ExtractorConfig.loadFileAndPrepare(
path.join(testDataFolder, 'api-extractor.json')
);
const compilerState: CompilerState = CompilerState.create(extractorConfig);
jest.spyOn(TypeScriptInternals, 'getPackageNamesBySourceFile').mockReturnValue(new Map());
const packageJsonLookupSpy: jest.SpiedFunction<PackageJsonLookup['tryLoadNodePackageJsonFor']> =
jest.spyOn(PackageJsonLookup.prototype, 'tryLoadNodePackageJsonFor');

const extractorResult: ExtractorResult = Extractor.invoke(extractorConfig, {
compilerState,
localBuild: true
});

expect(extractorResult.succeeded).toBe(true);
expect(_getDeclarationReferenceLookupCalls(packageJsonLookupSpy)).toHaveLength(1);
});
});

function _getSourceFile(program: ts.Program, pathSegments: string[]): ts.SourceFile {
const fileNameSuffix: string = path.join(...pathSegments);
const sourceFile: ts.SourceFile | undefined = program
.getSourceFiles()
.find(({ fileName }) => fileName.endsWith(fileNameSuffix));

if (!sourceFile) {
throw new Error(`Unable to find source file ending with: ${fileNameSuffix}`);
}

return sourceFile;
}

function _getDeclarationReferenceLookupCalls(
packageJsonLookupSpy: jest.SpiedFunction<PackageJsonLookup['tryLoadNodePackageJsonFor']>
): unknown[][] {
const declarationReferencePathSuffix: string = path.join(
'@microsoft',
'tsdoc',
'lib-commonjs',
'beta',
'DeclarationReference.d.ts'
);

return packageJsonLookupSpy.mock.calls.filter(([filePath]) =>
filePath.endsWith(declarationReferencePathSuffix)
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",

"extends": "./api-extractor.json",

"compiler": {
"tsconfigFilePath": "<projectFolder>/tsconfig-preserve-symlinks.json"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",

"mainEntryPointFilePath": "<projectFolder>/index.d.ts",

"apiReport": {
"enabled": false
},

"docModel": {
"enabled": false
},

"dtsRollup": {
"enabled": false
},

"tsdocMetadata": {
"enabled": false
},

"compiler": {
"tsconfigFilePath": "<projectFolder>/tsconfig.json"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/// <reference types="resolve" />

import type { DeclarationReference } from '@microsoft/tsdoc/lib-commonjs/beta/DeclarationReference';

/** @public */
export interface PackageNameResolution {
declarationReference: DeclarationReference;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "package-name-resolution",
"version": "1.0.0"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"$schema": "http://json.schemastore.org/tsconfig",

"extends": "./tsconfig.json",

"compilerOptions": {
"preserveSymlinks": true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"$schema": "http://json.schemastore.org/tsconfig",

"compilerOptions": {
"lib": ["es2020"],
"module": "commonjs",
"moduleResolution": "node",
"preserveSymlinks": false,
"strict": true,
"types": ["semver"]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@ export class DeclarationReferenceGenerator {
public static readonly unknownReference: string = '?';

private _collector: Collector;
private readonly _packageNamesBySourceFile: ReadonlyMap<ts.SourceFile, string>;

public constructor(collector: Collector) {
this._collector = collector;
this._packageNamesBySourceFile = TypeScriptInternals.getPackageNamesBySourceFile(collector.program);
}

/**
Expand Down Expand Up @@ -265,6 +267,11 @@ export class DeclarationReferenceGenerator {

private _getPackageName(sourceFile: ts.SourceFile): string {
if (this._collector.program.isSourceFileFromExternalLibrary(sourceFile)) {
const resolvedPackageName: string | undefined = this._packageNamesBySourceFile.get(sourceFile);
if (resolvedPackageName) {
return resolvedPackageName;
}

const packageJson: INodePackageJson | undefined =
this._collector.packageJsonLookup.tryLoadNodePackageJsonFor(sourceFile.fileName);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"changes": [
{
"comment": "Improve API Extractor performance by reusing TypeScript package resolution metadata when identifying external package names",
"type": "patch",
"packageName": "@microsoft/api-extractor"
}
],
"packageName": "@microsoft/api-extractor",
"email": "322952417+gwosti@users.noreply.github.com"
}