From 29a0969fada817b7ca5ff654bd5d6bf3f29504d8 Mon Sep 17 00:00:00 2001 From: gwosti <322952417+gwosti@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:04:30 +0200 Subject: [PATCH] [api-extractor] Reduce package name lookup overhead --- .../src/analyzer/TypeScriptInternals.ts | 75 +++++++++++++ .../api/test/Extractor-package-name.test.ts | 103 ++++++++++++++++++ .../api-extractor-preserve-symlinks.json | 9 ++ .../api-extractor.json | 25 +++++ .../package-name-resolution/index.d.ts | 8 ++ .../package-name-resolution/package.json | 4 + .../tsconfig-preserve-symlinks.json | 9 ++ .../package-name-resolution/tsconfig.json | 12 ++ .../DeclarationReferenceGenerator.ts | 7 ++ ...ckage-name-lookup_2026-08-31-01-54-33.json | 11 ++ 10 files changed, 263 insertions(+) create mode 100644 apps/api-extractor/src/api/test/Extractor-package-name.test.ts create mode 100644 apps/api-extractor/src/api/test/test-data/package-name-resolution/api-extractor-preserve-symlinks.json create mode 100644 apps/api-extractor/src/api/test/test-data/package-name-resolution/api-extractor.json create mode 100644 apps/api-extractor/src/api/test/test-data/package-name-resolution/index.d.ts create mode 100644 apps/api-extractor/src/api/test/test-data/package-name-resolution/package.json create mode 100644 apps/api-extractor/src/api/test/test-data/package-name-resolution/tsconfig-preserve-symlinks.json create mode 100644 apps/api-extractor/src/api/test/test-data/package-name-resolution/tsconfig.json create mode 100644 common/changes/@microsoft/api-extractor/perf-api-extractor-package-name-lookup_2026-08-31-01-54-33.json diff --git a/apps/api-extractor/src/analyzer/TypeScriptInternals.ts b/apps/api-extractor/src/analyzer/TypeScriptInternals.ts index 88059f26e80..c4dfb213229 100644 --- a/apps/api-extractor/src/analyzer/TypeScriptInternals.ts +++ b/apps/api-extractor/src/analyzer/TypeScriptInternals.ts @@ -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; +} + export class TypeScriptInternals { public static getImmediateAliasedSymbol(symbol: ts.Symbol, typeChecker: ts.TypeChecker): ts.Symbol { // Compiler internal: @@ -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 { + const packageNamesBySourceFile: Map = new Map(); + const ambiguousSourceFiles: Set = new Set(); + + 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 */ diff --git a/apps/api-extractor/src/api/test/Extractor-package-name.test.ts b/apps/api-extractor/src/api/test/Extractor-package-name.test.ts new file mode 100644 index 00000000000..33798712813 --- /dev/null +++ b/apps/api-extractor/src/api/test/Extractor-package-name.test.ts @@ -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 = + 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 = + 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 = + 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 +): unknown[][] { + const declarationReferencePathSuffix: string = path.join( + '@microsoft', + 'tsdoc', + 'lib-commonjs', + 'beta', + 'DeclarationReference.d.ts' + ); + + return packageJsonLookupSpy.mock.calls.filter(([filePath]) => + filePath.endsWith(declarationReferencePathSuffix) + ); +} diff --git a/apps/api-extractor/src/api/test/test-data/package-name-resolution/api-extractor-preserve-symlinks.json b/apps/api-extractor/src/api/test/test-data/package-name-resolution/api-extractor-preserve-symlinks.json new file mode 100644 index 00000000000..ebc3c76b3c5 --- /dev/null +++ b/apps/api-extractor/src/api/test/test-data/package-name-resolution/api-extractor-preserve-symlinks.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + + "extends": "./api-extractor.json", + + "compiler": { + "tsconfigFilePath": "/tsconfig-preserve-symlinks.json" + } +} diff --git a/apps/api-extractor/src/api/test/test-data/package-name-resolution/api-extractor.json b/apps/api-extractor/src/api/test/test-data/package-name-resolution/api-extractor.json new file mode 100644 index 00000000000..063e37512ca --- /dev/null +++ b/apps/api-extractor/src/api/test/test-data/package-name-resolution/api-extractor.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + + "mainEntryPointFilePath": "/index.d.ts", + + "apiReport": { + "enabled": false + }, + + "docModel": { + "enabled": false + }, + + "dtsRollup": { + "enabled": false + }, + + "tsdocMetadata": { + "enabled": false + }, + + "compiler": { + "tsconfigFilePath": "/tsconfig.json" + } +} diff --git a/apps/api-extractor/src/api/test/test-data/package-name-resolution/index.d.ts b/apps/api-extractor/src/api/test/test-data/package-name-resolution/index.d.ts new file mode 100644 index 00000000000..dfeaf3406c2 --- /dev/null +++ b/apps/api-extractor/src/api/test/test-data/package-name-resolution/index.d.ts @@ -0,0 +1,8 @@ +/// + +import type { DeclarationReference } from '@microsoft/tsdoc/lib-commonjs/beta/DeclarationReference'; + +/** @public */ +export interface PackageNameResolution { + declarationReference: DeclarationReference; +} diff --git a/apps/api-extractor/src/api/test/test-data/package-name-resolution/package.json b/apps/api-extractor/src/api/test/test-data/package-name-resolution/package.json new file mode 100644 index 00000000000..46875dd9017 --- /dev/null +++ b/apps/api-extractor/src/api/test/test-data/package-name-resolution/package.json @@ -0,0 +1,4 @@ +{ + "name": "package-name-resolution", + "version": "1.0.0" +} diff --git a/apps/api-extractor/src/api/test/test-data/package-name-resolution/tsconfig-preserve-symlinks.json b/apps/api-extractor/src/api/test/test-data/package-name-resolution/tsconfig-preserve-symlinks.json new file mode 100644 index 00000000000..a1e5b3b4075 --- /dev/null +++ b/apps/api-extractor/src/api/test/test-data/package-name-resolution/tsconfig-preserve-symlinks.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + + "extends": "./tsconfig.json", + + "compilerOptions": { + "preserveSymlinks": true + } +} diff --git a/apps/api-extractor/src/api/test/test-data/package-name-resolution/tsconfig.json b/apps/api-extractor/src/api/test/test-data/package-name-resolution/tsconfig.json new file mode 100644 index 00000000000..87a2fe69081 --- /dev/null +++ b/apps/api-extractor/src/api/test/test-data/package-name-resolution/tsconfig.json @@ -0,0 +1,12 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + + "compilerOptions": { + "lib": ["es2020"], + "module": "commonjs", + "moduleResolution": "node", + "preserveSymlinks": false, + "strict": true, + "types": ["semver"] + } +} diff --git a/apps/api-extractor/src/generators/DeclarationReferenceGenerator.ts b/apps/api-extractor/src/generators/DeclarationReferenceGenerator.ts index bb81031afe4..35b8eeaca35 100644 --- a/apps/api-extractor/src/generators/DeclarationReferenceGenerator.ts +++ b/apps/api-extractor/src/generators/DeclarationReferenceGenerator.ts @@ -23,9 +23,11 @@ export class DeclarationReferenceGenerator { public static readonly unknownReference: string = '?'; private _collector: Collector; + private readonly _packageNamesBySourceFile: ReadonlyMap; public constructor(collector: Collector) { this._collector = collector; + this._packageNamesBySourceFile = TypeScriptInternals.getPackageNamesBySourceFile(collector.program); } /** @@ -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); diff --git a/common/changes/@microsoft/api-extractor/perf-api-extractor-package-name-lookup_2026-08-31-01-54-33.json b/common/changes/@microsoft/api-extractor/perf-api-extractor-package-name-lookup_2026-08-31-01-54-33.json new file mode 100644 index 00000000000..e01aec08635 --- /dev/null +++ b/common/changes/@microsoft/api-extractor/perf-api-extractor-package-name-lookup_2026-08-31-01-54-33.json @@ -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" +} \ No newline at end of file