From 09c3807d17e40d285c87493e380f5e751ebb10f9 Mon Sep 17 00:00:00 2001 From: Zack Chapple Date: Wed, 29 Jul 2026 14:09:55 -0400 Subject: [PATCH 1/2] fix(dts): preserve inferred roots and diagnostics --- .changeset/calm-lions-debug.md | 5 + apps/website-new/docs/en/configure/dts.mdx | 2 +- .../docs/en/guide/troubleshooting/type.mdx | 7 + packages/dts-plugin/package.json | 1 + .../core/configurations/remotePlugin.test.ts | 113 ++++++++++++- .../src/core/configurations/remotePlugin.ts | 157 ++++++++++-------- .../src/core/lib/DTSManager.advance.spec.ts | 3 + .../src/core/lib/DTSManager.spec.ts | 12 ++ .../dts-plugin/src/core/lib/DtsWorker.spec.ts | 3 + .../src/core/lib/typeScriptCompiler.test.ts | 88 +++++++++- .../src/core/lib/typeScriptCompiler.ts | 46 +++-- .../src/core/lib/typeScriptDiagnostics.ts | 97 +++++++++++ pnpm-lock.yaml | 3 + 13 files changed, 441 insertions(+), 96 deletions(-) create mode 100644 .changeset/calm-lions-debug.md create mode 100644 packages/dts-plugin/src/core/lib/typeScriptDiagnostics.ts diff --git a/.changeset/calm-lions-debug.md b/.changeset/calm-lions-debug.md new file mode 100644 index 00000000000..b6fb9fcc47b --- /dev/null +++ b/.changeset/calm-lions-debug.md @@ -0,0 +1,5 @@ +--- +'@module-federation/dts-plugin': patch +--- + +Preserve inferred source roots in temporary DTS projects and retain reproducible TypeScript diagnostics after failed dependency scans or declaration generation. diff --git a/apps/website-new/docs/en/configure/dts.mdx b/apps/website-new/docs/en/configure/dts.mdx index 81151db7a5a..5ced6b5883b 100644 --- a/apps/website-new/docs/en/configure/dts.mdx +++ b/apps/website-new/docs/en/configure/dts.mdx @@ -204,7 +204,7 @@ Instance of compiled type - Required: No - Default value: `true` -Whether to delete the temporary tsconfig configuration file. +Whether to delete the temporary tsconfig configuration file. If type generation fails, a diagnostic copy of the effective config and compiler output is retained under `.mf/diagnostics/dts/` even when this option is enabled. ## consumeTypes diff --git a/apps/website-new/docs/en/guide/troubleshooting/type.mdx b/apps/website-new/docs/en/guide/troubleshooting/type.mdx index ca972ad7b2e..39bab2e7803 100644 --- a/apps/website-new/docs/en/guide/troubleshooting/type.mdx +++ b/apps/website-new/docs/en/guide/troubleshooting/type.mdx @@ -25,6 +25,13 @@ When compiling TS types for exposed (`exposes`) files, the current project's `ts 1. Remove tsconfig.json `incremental` and `tsBuildInfoFile` from the `cmd` command. 2. Run the `cmd` from the error message in terminal and fix the file or `tsconfig` based on the output. +When the TypeScript dependency scan or declaration generation fails, Module Federation copies the effective temporary configuration and compiler output to: + +- `.mf/diagnostics/dts/list-files/` for dependency-scan warnings +- `.mf/diagnostics/dts/generate-types/` for fatal `TYPE-001` failures + +Each directory contains `tsconfig.json` and `compiler.log`. The log records the exact compiler command and TypeScript version, so the failure can be reproduced even when [`deleteTsConfig`](/configure/dts.html#deletetsconfig) is enabled. + If you want to ignore TS type checking errors, set [`compilerOptions.noCheck`](https://www.typescriptlang.org/tsconfig/#noCheck) to `true` in `tsconfig.json` (requires TS 5.5+). If the `cmd` runs without error but you still get a TS compile failure, check the `exposes` field in `ModuleFederationPlugin`: diff --git a/packages/dts-plugin/package.json b/packages/dts-plugin/package.json index c555951b2d6..de137cacd17 100644 --- a/packages/dts-plugin/package.json +++ b/packages/dts-plugin/package.json @@ -82,6 +82,7 @@ "directory-tree": "3.5.2", "rimraf": "~6.0.1", "typescript": "6.0.3", + "typescript-5": "npm:typescript@5.9.3", "typescript-7": "npm:typescript@7.0.2", "vue": "^3.5.13", "vue-tsc": "^2.2.10", diff --git a/packages/dts-plugin/src/core/configurations/remotePlugin.test.ts b/packages/dts-plugin/src/core/configurations/remotePlugin.test.ts index fd1f882631c..08aaa35212c 100644 --- a/packages/dts-plugin/src/core/configurations/remotePlugin.test.ts +++ b/packages/dts-plugin/src/core/configurations/remotePlugin.test.ts @@ -1,4 +1,12 @@ -import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'fs'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'fs'; import { createRequire } from 'module'; import os from 'os'; import { dirname, join, resolve } from 'path'; @@ -44,15 +52,19 @@ describe('hostPlugin', () => { return context; }; - const installTypeScript7 = (context: string) => { - const typeScript7PackageJsonPath = requireFromTest.resolve( - 'typescript-7/package.json', + const installTypeScript = (context: string, packageName: string) => { + const typeScriptPackageJsonPath = requireFromTest.resolve( + `${packageName}/package.json`, ); - const typeScript7Root = dirname(typeScript7PackageJsonPath); - const typeScriptRoot = join(context, 'node_modules', 'typescript'); - mkdirSync(dirname(typeScriptRoot), { recursive: true }); - symlinkSync(typeScript7Root, typeScriptRoot, 'junction'); + const installedTypeScriptRoot = dirname(typeScriptPackageJsonPath); + const projectTypeScriptRoot = join(context, 'node_modules', 'typescript'); + mkdirSync(dirname(projectTypeScriptRoot), { recursive: true }); + symlinkSync(installedTypeScriptRoot, projectTypeScriptRoot, 'junction'); }; + const installTypeScript5 = (context: string) => + installTypeScript(context, 'typescript-5'); + const installTypeScript7 = (context: string) => + installTypeScript(context, 'typescript-7'); afterEach(() => { for (const tempDir of tempDirs.splice(0)) { @@ -74,6 +86,11 @@ describe('hostPlugin', () => { }; const typeScriptScenarios = [ + { + name: 'TypeScript 5', + setup: installTypeScript5, + expectedModuleResolution: 'node10', + }, { name: 'TypeScript 6', setup: undefined, @@ -368,6 +385,50 @@ describe('hostPlugin', () => { expect(tsConfig.references).toBeUndefined(); }); + it('includes exposed workspace sources when rootDir is inferred', () => { + const context = createTemporaryProject({ + 'src/index.ts': 'export const local = 1;\n', + 'shared/button.ts': "export { dependency } from './dependency';\n", + 'shared/dependency.ts': 'export const dependency = 1;\n', + }); + writeFileSync( + join(context, 'tsconfig.json'), + JSON.stringify( + { + compilerOptions: { + target: 'es2017', + module: 'esnext', + moduleResolution: 'node10', + strict: true, + }, + include: ['src'], + }, + null, + 2, + ), + ); + + const { tsConfig } = retrieveRemoteConfig({ + context, + tsConfigPath: './tsconfig.json', + moduleFederationConfig: withTypeScriptScenario(scenario, context, { + name: 'remotePluginTestHost', + filename: 'remoteEntry.js', + exposes: { + './button': './shared/button.ts', + }, + }), + }); + + expect(tsConfig.compilerOptions.rootDir).toBe(context); + expect(tsConfig.files).toEqual( + expect.arrayContaining([ + resolve(context, 'shared/button.ts'), + resolve(context, 'shared/dependency.ts'), + ]), + ); + }); + it('applies custom output folders', () => { const context = createProjectWithDependencies(); const { tsConfig, remoteOptions } = retrieveRemoteConfig({ @@ -521,6 +582,41 @@ describe('hostPlugin', () => { resolve(context, 'src/components/foo/index.ts'), ); }); + + if (scenario.name === 'TypeScript 7') { + it('preserves diagnostics when dependency scanning fails', () => { + const { context } = resolveExpose( + './src/components/foo.generated.jsx', + { + 'src/components/foo.generated.jsx': + 'export const Foo = () => null;\n', + }, + ); + const diagnosticDir = join( + context, + '.mf/diagnostics/dts/list-files', + ); + const diagnosticConfigPath = join(diagnosticDir, 'tsconfig.json'); + const diagnosticLogPath = join(diagnosticDir, 'compiler.log'); + + expect(existsSync(diagnosticConfigPath)).toBe(true); + expect(existsSync(diagnosticLogPath)).toBe(true); + const diagnosticConfig = JSON.parse( + readFileSync(diagnosticConfigPath, 'utf8'), + ); + expect(diagnosticConfig.compilerOptions.rootDir).toBe(context); + expect(diagnosticConfig.files).toContain( + resolve(context, 'src/components/foo.generated.jsx'), + ); + expect(readFileSync(diagnosticLogPath, 'utf8')).toContain( + 'TypeScript version: 7.0.2', + ); + expect(readFileSync(diagnosticLogPath, 'utf8')).toContain( + '--listFilesOnly', + ); + expect(readFileSync(diagnosticLogPath, 'utf8')).toContain('TS6504'); + }); + } }); } }); @@ -546,6 +642,7 @@ describe('hostPlugin', () => { const tsConfigPath = join(__dirname, tsConfigFile); const typeScriptContext = createTemporaryProject({}); const { tsConfig } = retrieveRemoteConfig({ + context: typeScriptContext, moduleFederationConfig: withTypeScriptScenario( scenario, typeScriptContext, diff --git a/packages/dts-plugin/src/core/configurations/remotePlugin.ts b/packages/dts-plugin/src/core/configurations/remotePlugin.ts index 0a160e4e755..d62ebd42b9f 100644 --- a/packages/dts-plugin/src/core/configurations/remotePlugin.ts +++ b/packages/dts-plugin/src/core/configurations/remotePlugin.ts @@ -24,6 +24,11 @@ import { getTypeScriptPackageInfo, requireTypeScript, } from '../lib/typeScriptResolver'; +import { + formatCommandForDisplay, + formatCompilerOutput, + preserveTypeScriptDiagnostic, +} from '../lib/typeScriptDiagnostics'; import { logger } from '../../server'; interface ProjectReference { @@ -82,25 +87,46 @@ const defaultOptions = { deleteTsConfig: true, } satisfies Partial; -function getEffectiveRootDir(parsedCommandLine: ParsedConfigContent): string { +const getCommonRootDir = (files: string[]): string => { + let commonRoot = dirname(normalize(files[0])); + + for (const file of files.slice(1)) { + const fileDir = dirname(normalizeFileToRootDir(file, commonRoot)); + let relativePath = relative(commonRoot, fileDir); + while ( + relativePath === '..' || + relativePath.startsWith(`..${sep}`) || + isAbsolute(relativePath) + ) { + const parentDir = dirname(commonRoot); + if (parentDir === commonRoot) { + return commonRoot; + } + commonRoot = parentDir; + relativePath = relative(commonRoot, fileDir); + } + } + + return commonRoot; +}; + +function getEffectiveRootDir( + parsedCommandLine: ParsedConfigContent, + rootFiles: string[], +): string { const compilerOptions = parsedCommandLine.options; if (compilerOptions.rootDir) { return compilerOptions.rootDir; } - // if no set rootDir , infer the commonRoot - const files = parsedCommandLine.fileNames; + // Mirror TypeScript's inferred source root, while also accounting for exposed + // files that are not selected by the source project's include patterns. + const files = [...parsedCommandLine.fileNames, ...rootFiles].filter( + (file) => !file.endsWith('.d.ts'), + ); if (files.length > 0) { - const commonRoot = files - .map((file) => dirname(file)) - .reduce((commonPath, fileDir) => { - while (!fileDir.startsWith(commonPath)) { - commonPath = dirname(commonPath); - } - return commonPath; - }, files[0]); - return commonRoot; + return getCommonRootDir(files); } // if there are project references, infer the commonRoot from references @@ -320,36 +346,6 @@ const writeListFilesTsConfig = ( return tempTsConfigJsonPath; }; -const formatCompilerError = (error: unknown) => { - const readOutput = (value: unknown) => { - if (Buffer.isBuffer(value)) { - return value.toString('utf8'); - } - return typeof value === 'string' ? value : ''; - }; - - if (typeof error === 'object' && error !== null) { - const processError = error as { - stderr?: unknown; - stdout?: unknown; - message?: unknown; - }; - const stderr = readOutput(processError.stderr).trim(); - if (stderr) { - return stderr.split(/\r?\n/)[0]; - } - const stdout = readOutput(processError.stdout).trim(); - if (stdout) { - return stdout.split(/\r?\n/)[0]; - } - if (typeof processError.message === 'string') { - return processError.message; - } - } - - return String(error); -}; - const getDependentFilesWithTsc = ( rootFiles: string[], rootDir: string, @@ -367,23 +363,28 @@ const getDependentFilesWithTsc = ( rootFiles, resolvedTsConfigPath, context, - compilerOptions, + { + ...compilerOptions, + rootDir, + }, + ); + const compilerArgs = [ + typeScriptPackageInfo.tscBinPath, + '--listFilesOnly', + '--project', + listFilesTsConfigPath, + ]; + const compilerCommand = formatCommandForDisplay( + process.execPath, + compilerArgs, ); + let retainTemporaryConfig = false; try { - const stdout = execFileSync( - process.execPath, - [ - typeScriptPackageInfo.tscBinPath, - '--listFilesOnly', - '--project', - listFilesTsConfigPath, - ], - { - cwd: typeScriptContext, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - }, - ); + const stdout = execFileSync(process.execPath, compilerArgs, { + cwd: typeScriptContext, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); const dependentFiles = stdout .split(/\r?\n/) .map((file) => file.trim()) @@ -395,14 +396,28 @@ const getDependentFilesWithTsc = ( .map((file) => normalizeFileToRootDir(file, rootDir)); return dependentFiles.length ? dependentFiles : rootFiles; } catch (error) { + const compilerOutput = formatCompilerOutput(error); + const diagnostics = preserveTypeScriptDiagnostic({ + command: compilerCommand, + compilerOutput, + context, + stage: 'list-files', + tempTsConfigPath: listFilesTsConfigPath, + typeScriptVersion: typeScriptPackageInfo.version, + }); + retainTemporaryConfig = !diagnostics.copied; logger.warn( - `Failed to collect TypeScript dependency files with "tsc --listFilesOnly"; falling back to exposed files only. ${formatCompilerError( - error, - )}`, + `Failed to collect TypeScript dependency files with "tsc --listFilesOnly"; falling back to exposed files only. ${compilerOutput.split(/\r?\n/)[0]} Diagnostics: ${diagnostics.diagnosticConfigPath}${ + diagnostics.diagnosticLogPath + ? `, ${diagnostics.diagnosticLogPath}` + : '' + }`, ); return rootFiles; } finally { - rmSync(listFilesTsConfigPath, { force: true }); + if (!retainTemporaryConfig) { + rmSync(listFilesTsConfigPath, { force: true }); + } } }; @@ -450,7 +465,17 @@ const readTsConfig = ( ); configContent.projectReferences = configContent.projectReferences || []; } - const rootDir = getEffectiveRootDir(configContent); + const excludeExtensions = ['.mdx', '.md']; + const rootFiles = [ + ...Object.values(mapComponentsToExpose), + ...additionalFilesToCompile, + ].filter( + (filename) => !excludeExtensions.some((ext) => filename.endsWith(ext)), + ); + const existingRootFiles = rootFiles + .map((file) => (isAbsolute(file) ? file : resolve(context, file))) + .filter((file) => existsSync(file)); + const rootDir = getEffectiveRootDir(configContent, existingRootFiles); const outDir = resolve( context, @@ -488,14 +513,6 @@ const readTsConfig = ( outputDir || configContent.options.outDir || 'dist', ); - const excludeExtensions = ['.mdx', '.md']; - const rootFiles = [ - ...Object.values(mapComponentsToExpose), - ...additionalFilesToCompile, - ].filter( - (filename) => !excludeExtensions.some((ext) => filename.endsWith(ext)), - ); - const filesToCompile = [ ...(typescript ? getDependentFiles(rootFiles, configContent, rootDir, typescript) diff --git a/packages/dts-plugin/src/core/lib/DTSManager.advance.spec.ts b/packages/dts-plugin/src/core/lib/DTSManager.advance.spec.ts index cd4f2ebc353..7a4a34772c4 100644 --- a/packages/dts-plugin/src/core/lib/DTSManager.advance.spec.ts +++ b/packages/dts-plugin/src/core/lib/DTSManager.advance.spec.ts @@ -197,6 +197,9 @@ describe('DTSManager advance usage', () => { { name: 'typeScriptCompiler.d.ts', }, + { + name: 'typeScriptDiagnostics.d.ts', + }, { name: 'typeScriptResolver.d.ts', }, diff --git a/packages/dts-plugin/src/core/lib/DTSManager.spec.ts b/packages/dts-plugin/src/core/lib/DTSManager.spec.ts index 1840d4bd46a..7b4b7124303 100644 --- a/packages/dts-plugin/src/core/lib/DTSManager.spec.ts +++ b/packages/dts-plugin/src/core/lib/DTSManager.spec.ts @@ -145,6 +145,9 @@ describe('DTSManager', () => { { name: 'typeScriptCompiler.d.ts', }, + { + name: 'typeScriptDiagnostics.d.ts', + }, { name: 'typeScriptResolver.d.ts', }, @@ -272,6 +275,9 @@ describe('DTSManager', () => { { name: 'typeScriptCompiler.d.ts', }, + { + name: 'typeScriptDiagnostics.d.ts', + }, { name: 'typeScriptResolver.d.ts', }, @@ -510,6 +516,9 @@ describe('DTSManager', () => { { name: 'typeScriptCompiler.d.ts', }, + { + name: 'typeScriptDiagnostics.d.ts', + }, { name: 'typeScriptResolver.d.ts', }, @@ -660,6 +669,9 @@ describe('DTSManager', () => { { name: 'typeScriptCompiler.d.ts', }, + { + name: 'typeScriptDiagnostics.d.ts', + }, { name: 'typeScriptResolver.d.ts', }, diff --git a/packages/dts-plugin/src/core/lib/DtsWorker.spec.ts b/packages/dts-plugin/src/core/lib/DtsWorker.spec.ts index 28b8020c37a..5e060949bdc 100644 --- a/packages/dts-plugin/src/core/lib/DtsWorker.spec.ts +++ b/packages/dts-plugin/src/core/lib/DtsWorker.spec.ts @@ -158,6 +158,9 @@ describe('generateTypesInChildProcess', () => { { name: 'typeScriptCompiler.d.ts', }, + { + name: 'typeScriptDiagnostics.d.ts', + }, { name: 'typeScriptResolver.d.ts', }, diff --git a/packages/dts-plugin/src/core/lib/typeScriptCompiler.test.ts b/packages/dts-plugin/src/core/lib/typeScriptCompiler.test.ts index 32ab9fb49e0..f7710ffd83f 100644 --- a/packages/dts-plugin/src/core/lib/typeScriptCompiler.test.ts +++ b/packages/dts-plugin/src/core/lib/typeScriptCompiler.test.ts @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, + readdirSync, rmSync, symlinkSync, writeFileSync, @@ -309,7 +310,7 @@ describe('typeScriptCompiler', () => { await compileTs( mapToExpose, { ...tsConfig, files: [filepath] }, - remoteOptions, + { ...remoteOptions, context: tmpDir }, ); } catch { // expected to throw because execPromise is rejected @@ -325,6 +326,80 @@ describe('typeScriptCompiler', () => { expect(projectPath).not.toContain("'"); }); + it('preserves compiler diagnostics when type generation fails', async () => { + const projectDir = join(tmpDir, 'failedTypeGeneration'); + const srcDir = join(projectDir, 'src'); + mkdirSync(srcDir, { recursive: true }); + linkTypeScriptPackage(projectDir, 'typescript'); + + const entryFile = join(srcDir, 'button.ts'); + writeFileSync(entryFile, 'export const button = 1;\n'); + const compilerError = new Error( + 'TypeScript compilation failed', + ) as Error & { + stderr: string; + }; + compilerError.stderr = `${entryFile}(1,1): error TS1005: expected token.`; + const execPromise = rs.fn().mockRejectedValue(compilerError); + rs.spyOn(util, 'promisify').mockReturnValue( + execPromise as unknown as ReturnType, + ); + + const outDir = join( + projectDir, + 'typesRemoteFolder', + 'compiledTypesFolder', + ); + const failedConfig: TsConfigJson = { + compilerOptions: { + declaration: true, + emitDeclarationOnly: true, + noEmit: false, + outDir, + rootDir: projectDir, + }, + files: [entryFile], + }; + const failedOptions: Required = { + ...remoteOptions, + context: projectDir, + moduleFederationConfig: { + name: 'failedTypeGeneration', + }, + }; + + await expect( + compileTs( + { + './button': entryFile, + }, + failedConfig, + failedOptions, + ), + ).rejects.toThrow('Original Error Message'); + + const diagnosticDir = join( + projectDir, + '.mf/diagnostics/dts/generate-types', + ); + const diagnosticConfigPath = join(diagnosticDir, 'tsconfig.json'); + const diagnosticLogPath = join(diagnosticDir, 'compiler.log'); + expect(existsSync(diagnosticConfigPath)).toBe(true); + expect(existsSync(diagnosticLogPath)).toBe(true); + expect(readFileSync(diagnosticConfigPath, 'utf8')).toContain(entryFile); + + const diagnosticLog = readFileSync(diagnosticLogPath, 'utf8'); + expect(diagnosticLog).toContain('TypeScript version: 6.0.3'); + expect(diagnosticLog).toContain('Command:'); + expect(diagnosticLog).toContain('Fatal compiler diagnostic:'); + expect(diagnosticLog).toContain('TS1005'); + + const temporaryConfigs = readdirSync( + join(projectDir, 'node_modules/.federation'), + ).filter((file) => file.startsWith('tsconfig.')); + expect(temporaryConfigs).toHaveLength(0); + }); + it('ignores inherited declarationDir', async () => { const projectDir = join(tmpDir, 'declarationDirProject'); const srcDir = join(projectDir, 'src'); @@ -440,6 +515,9 @@ describe('typeScriptCompiler', () => { { name: 'typeScriptCompiler.d.ts', }, + { + name: 'typeScriptDiagnostics.d.ts', + }, { name: 'typeScriptResolver.d.ts', }, @@ -633,6 +711,11 @@ describe('typeScriptCompiler', () => { ), ), ).toBe(false); + expect( + readdirSync(join(projectDir, 'node_modules/.federation')).filter( + (file) => file.startsWith('tsconfig.'), + ), + ).toHaveLength(0); }); it('with additionalFilesToCompile', async () => { @@ -723,6 +806,9 @@ describe('typeScriptCompiler', () => { { name: 'typeScriptCompiler.d.ts', }, + { + name: 'typeScriptDiagnostics.d.ts', + }, { name: 'typeScriptResolver.d.ts', }, diff --git a/packages/dts-plugin/src/core/lib/typeScriptCompiler.ts b/packages/dts-plugin/src/core/lib/typeScriptCompiler.ts index f65ff46472a..175027e6db3 100644 --- a/packages/dts-plugin/src/core/lib/typeScriptCompiler.ts +++ b/packages/dts-plugin/src/core/lib/typeScriptCompiler.ts @@ -22,6 +22,11 @@ import { RemoteOptions } from '../interfaces/RemoteOptions'; import { TsConfigJson } from '../interfaces/TsConfigJson'; import { logger } from '../../server'; import { getTypeScriptPackageInfo } from './typeScriptResolver'; +import { + formatCommandForDisplay, + formatCompilerOutput, + preserveTypeScriptDiagnostic, +} from './typeScriptDiagnostics'; const STARTS_WITH_SLASH = /^\//; @@ -230,16 +235,6 @@ const splitCommandArgs = (value: string): string[] => { return args; }; -const formatCommandForDisplay = (executable: string, args: string[]) => { - const formatArg = (arg: string) => { - if (/[\s'"]/.test(arg)) { - return JSON.stringify(arg); - } - return arg; - }; - return [executable, ...args].map(formatArg).join(' '); -}; - const getTypeScriptContext = (remoteOptions: Required) => { const dtsOptions = remoteOptions.moduleFederationConfig.dts; return typeof dtsOptions !== 'boolean' && dtsOptions?.cwd @@ -301,6 +296,7 @@ export const compileTs = async ( : undefined, ); logger.debug(`tempTsConfigJsonPath: ${tempTsConfigJsonPath}`); + let retainTemporaryConfig = false; try { const mfTypePath = retrieveMfTypesPath(tsConfig, remoteOptions); const thirdPartyExtractor = new ThirdPartyExtractor({ @@ -329,14 +325,32 @@ export const compileTs = async ( // noop } } + const typeScriptPackageInfo = getTypeScriptPackageInfo( + getTypeScriptContext(remoteOptions), + ); + const compilerOutput = formatCompilerOutput(err); + const diagnostics = preserveTypeScriptDiagnostic({ + command: compilerCommand.displayCommand, + compilerOutput, + context: remoteOptions.context, + stage: 'generate-types', + tempTsConfigPath: tempTsConfigJsonPath, + typeScriptVersion: typeScriptPackageInfo.version, + }); + retainTemporaryConfig = !diagnostics.copied; logAndReport( TYPE_001, typeDescMap, - { cmd: compilerCommand.displayCommand }, + { + cmd: compilerCommand.displayCommand, + diagnosticConfig: diagnostics.diagnosticConfigPath, + diagnosticLog: diagnostics.diagnosticLogPath, + typeScriptVersion: typeScriptPackageInfo.version, + }, (msg) => { throw new Error(msg); }, - undefined, + compilerOutput, ); } @@ -375,11 +389,11 @@ export const compileTs = async ( if (remoteOptions.extractThirdParty) { await thirdPartyExtractor.copyDts(); } - - if (remoteOptions.deleteTsConfig) { - await rm(tempTsConfigJsonPath); - } } catch (err) { throw err; + } finally { + if (remoteOptions.deleteTsConfig && !retainTemporaryConfig) { + await rm(tempTsConfigJsonPath, { force: true }); + } } }; diff --git a/packages/dts-plugin/src/core/lib/typeScriptDiagnostics.ts b/packages/dts-plugin/src/core/lib/typeScriptDiagnostics.ts new file mode 100644 index 00000000000..b127ae4259d --- /dev/null +++ b/packages/dts-plugin/src/core/lib/typeScriptDiagnostics.ts @@ -0,0 +1,97 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { join, resolve } from 'path'; + +type DiagnosticStage = 'generate-types' | 'list-files'; + +const readProcessOutput = (value: unknown) => { + if (Buffer.isBuffer(value)) { + return value.toString('utf8'); + } + return typeof value === 'string' ? value : ''; +}; + +export const formatCompilerOutput = (error: unknown) => { + if (typeof error === 'object' && error !== null) { + const processError = error as { + stderr?: unknown; + stdout?: unknown; + message?: unknown; + }; + const output = [ + readProcessOutput(processError.stderr), + readProcessOutput(processError.stdout), + ] + .map((value) => value.trim()) + .filter(Boolean) + .join('\n'); + if (output) { + return output; + } + if (typeof processError.message === 'string') { + return processError.message; + } + } + + return String(error); +}; + +export const formatCommandForDisplay = (executable: string, args: string[]) => { + const formatArg = (arg: string) => { + if (/[\s'"]/.test(arg)) { + return JSON.stringify(arg); + } + return arg; + }; + return [executable, ...args].map(formatArg).join(' '); +}; + +export const preserveTypeScriptDiagnostic = ({ + command, + compilerOutput, + context, + stage, + tempTsConfigPath, + typeScriptVersion, +}: { + command: string; + compilerOutput: string; + context: string; + stage: DiagnosticStage; + tempTsConfigPath: string; + typeScriptVersion: string; +}) => { + const diagnosticDir = resolve(context, '.mf', 'diagnostics', 'dts', stage); + const diagnosticConfigPath = join(diagnosticDir, 'tsconfig.json'); + const diagnosticLogPath = join(diagnosticDir, 'compiler.log'); + + try { + mkdirSync(diagnosticDir, { recursive: true }); + writeFileSync(diagnosticConfigPath, readFileSync(tempTsConfigPath, 'utf8')); + writeFileSync( + diagnosticLogPath, + [ + `Stage: ${stage}`, + `TypeScript version: ${typeScriptVersion}`, + `Command: ${command}`, + `Effective temporary config: ${diagnosticConfigPath}`, + '', + stage === 'generate-types' + ? 'Fatal compiler diagnostic:' + : 'Dependency scan diagnostic (type generation continued with exposed files only):', + compilerOutput, + '', + ].join('\n'), + ); + return { + copied: true, + diagnosticConfigPath, + diagnosticLogPath, + }; + } catch { + return { + copied: false, + diagnosticConfigPath: tempTsConfigPath, + diagnosticLogPath: undefined, + }; + } +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b15ef49a5c..e971c5dd85c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3271,6 +3271,9 @@ importers: typescript: specifier: 6.0.3 version: 6.0.3 + typescript-5: + specifier: npm:typescript@5.9.3 + version: typescript@5.9.3 typescript-7: specifier: npm:typescript@7.0.2 version: typescript@7.0.2 From b4ff38f8505e8363bbf5741d216e37491f9de0bd Mon Sep 17 00:00:00 2001 From: Zack Chapple Date: Wed, 29 Jul 2026 15:47:00 -0400 Subject: [PATCH 2/2] fix(dts): preserve runnable diagnostic commands --- .../core/configurations/remotePlugin.test.ts | 12 +++++++--- .../src/core/configurations/remotePlugin.ts | 9 ++++---- .../src/core/lib/typeScriptCompiler.test.ts | 23 +++++++++++++++---- .../src/core/lib/typeScriptCompiler.ts | 7 ++---- .../src/core/lib/typeScriptDiagnostics.ts | 15 ++++++++++-- 5 files changed, 47 insertions(+), 19 deletions(-) diff --git a/packages/dts-plugin/src/core/configurations/remotePlugin.test.ts b/packages/dts-plugin/src/core/configurations/remotePlugin.test.ts index 08aaa35212c..b33df25d5e2 100644 --- a/packages/dts-plugin/src/core/configurations/remotePlugin.test.ts +++ b/packages/dts-plugin/src/core/configurations/remotePlugin.test.ts @@ -12,6 +12,7 @@ import os from 'os'; import { dirname, join, resolve } from 'path'; import { afterEach, describe, expect, it } from '@rstest/core'; +import { formatCommandForDisplay } from '../lib/typeScriptDiagnostics'; import { retrieveRemoteConfig } from './remotePlugin'; describe('hostPlugin', () => { @@ -611,10 +612,15 @@ describe('hostPlugin', () => { expect(readFileSync(diagnosticLogPath, 'utf8')).toContain( 'TypeScript version: 7.0.2', ); - expect(readFileSync(diagnosticLogPath, 'utf8')).toContain( - '--listFilesOnly', + const diagnosticLog = readFileSync(diagnosticLogPath, 'utf8'); + expect(diagnosticLog).toContain('--listFilesOnly'); + expect(diagnosticLog).toContain('TS6504'); + const diagnosticCommand = diagnosticLog + .split(/\r?\n/) + .find((line) => line.startsWith('Command:')); + expect(diagnosticCommand).toContain( + formatCommandForDisplay('', [diagnosticConfigPath]).trim(), ); - expect(readFileSync(diagnosticLogPath, 'utf8')).toContain('TS6504'); }); } }); diff --git a/packages/dts-plugin/src/core/configurations/remotePlugin.ts b/packages/dts-plugin/src/core/configurations/remotePlugin.ts index d62ebd42b9f..7314c8949a8 100644 --- a/packages/dts-plugin/src/core/configurations/remotePlugin.ts +++ b/packages/dts-plugin/src/core/configurations/remotePlugin.ts @@ -25,7 +25,6 @@ import { requireTypeScript, } from '../lib/typeScriptResolver'; import { - formatCommandForDisplay, formatCompilerOutput, preserveTypeScriptDiagnostic, } from '../lib/typeScriptDiagnostics'; @@ -374,10 +373,10 @@ const getDependentFilesWithTsc = ( '--project', listFilesTsConfigPath, ]; - const compilerCommand = formatCommandForDisplay( - process.execPath, - compilerArgs, - ); + const compilerCommand = { + executable: process.execPath, + args: compilerArgs, + }; let retainTemporaryConfig = false; try { const stdout = execFileSync(process.execPath, compilerArgs, { diff --git a/packages/dts-plugin/src/core/lib/typeScriptCompiler.test.ts b/packages/dts-plugin/src/core/lib/typeScriptCompiler.test.ts index f7710ffd83f..52fa5873833 100644 --- a/packages/dts-plugin/src/core/lib/typeScriptCompiler.test.ts +++ b/packages/dts-plugin/src/core/lib/typeScriptCompiler.test.ts @@ -22,6 +22,7 @@ import { retrieveMfTypesPath, retrieveOriginalOutDir, } from './typeScriptCompiler'; +import { formatCommandForDisplay } from './typeScriptDiagnostics'; describe('typeScriptCompiler', () => { const requireFromTest = createRequire(__filename); @@ -368,15 +369,20 @@ describe('typeScriptCompiler', () => { }, }; - await expect( - compileTs( + let compilationError: unknown; + try { + await compileTs( { './button': entryFile, }, failedConfig, failedOptions, - ), - ).rejects.toThrow('Original Error Message'); + ); + } catch (error) { + compilationError = error; + } + expect(compilationError).toBeInstanceOf(Error); + expect(String(compilationError)).toContain('Original Error Message'); const diagnosticDir = join( projectDir, @@ -393,6 +399,15 @@ describe('typeScriptCompiler', () => { expect(diagnosticLog).toContain('Command:'); expect(diagnosticLog).toContain('Fatal compiler diagnostic:'); expect(diagnosticLog).toContain('TS1005'); + const diagnosticCommand = diagnosticLog + .split(/\r?\n/) + .find((line) => line.startsWith('Command:')); + expect(diagnosticCommand).toContain( + formatCommandForDisplay('', [diagnosticConfigPath]).trim(), + ); + expect(String(compilationError)).toContain( + formatCommandForDisplay('', [diagnosticConfigPath]).trim(), + ); const temporaryConfigs = readdirSync( join(projectDir, 'node_modules/.federation'), diff --git a/packages/dts-plugin/src/core/lib/typeScriptCompiler.ts b/packages/dts-plugin/src/core/lib/typeScriptCompiler.ts index 175027e6db3..6861d08d17f 100644 --- a/packages/dts-plugin/src/core/lib/typeScriptCompiler.ts +++ b/packages/dts-plugin/src/core/lib/typeScriptCompiler.ts @@ -23,7 +23,6 @@ import { TsConfigJson } from '../interfaces/TsConfigJson'; import { logger } from '../../server'; import { getTypeScriptPackageInfo } from './typeScriptResolver'; import { - formatCommandForDisplay, formatCompilerOutput, preserveTypeScriptDiagnostic, } from './typeScriptDiagnostics'; @@ -263,7 +262,6 @@ const resolveCompilerCommand = ( return { executable: process.execPath, args, - displayCommand: formatCommandForDisplay(process.execPath, args), shell: false, }; } @@ -273,7 +271,6 @@ const resolveCompilerCommand = ( return { executable, args, - displayCommand: formatCommandForDisplay(executable, args), shell: process.platform === 'win32', }; }; @@ -330,7 +327,7 @@ export const compileTs = async ( ); const compilerOutput = formatCompilerOutput(err); const diagnostics = preserveTypeScriptDiagnostic({ - command: compilerCommand.displayCommand, + command: compilerCommand, compilerOutput, context: remoteOptions.context, stage: 'generate-types', @@ -342,7 +339,7 @@ export const compileTs = async ( TYPE_001, typeDescMap, { - cmd: compilerCommand.displayCommand, + cmd: diagnostics.command, diagnosticConfig: diagnostics.diagnosticConfigPath, diagnosticLog: diagnostics.diagnosticLogPath, typeScriptVersion: typeScriptPackageInfo.version, diff --git a/packages/dts-plugin/src/core/lib/typeScriptDiagnostics.ts b/packages/dts-plugin/src/core/lib/typeScriptDiagnostics.ts index b127ae4259d..cf80fb34630 100644 --- a/packages/dts-plugin/src/core/lib/typeScriptDiagnostics.ts +++ b/packages/dts-plugin/src/core/lib/typeScriptDiagnostics.ts @@ -53,7 +53,10 @@ export const preserveTypeScriptDiagnostic = ({ tempTsConfigPath, typeScriptVersion, }: { - command: string; + command: { + executable: string; + args: string[]; + }; compilerOutput: string; context: string; stage: DiagnosticStage; @@ -63,16 +66,22 @@ export const preserveTypeScriptDiagnostic = ({ const diagnosticDir = resolve(context, '.mf', 'diagnostics', 'dts', stage); const diagnosticConfigPath = join(diagnosticDir, 'tsconfig.json'); const diagnosticLogPath = join(diagnosticDir, 'compiler.log'); + const formatCommandWithConfig = (configPath: string) => + formatCommandForDisplay( + command.executable, + command.args.map((arg) => (arg === tempTsConfigPath ? configPath : arg)), + ); try { mkdirSync(diagnosticDir, { recursive: true }); writeFileSync(diagnosticConfigPath, readFileSync(tempTsConfigPath, 'utf8')); + const diagnosticCommand = formatCommandWithConfig(diagnosticConfigPath); writeFileSync( diagnosticLogPath, [ `Stage: ${stage}`, `TypeScript version: ${typeScriptVersion}`, - `Command: ${command}`, + `Command: ${diagnosticCommand}`, `Effective temporary config: ${diagnosticConfigPath}`, '', stage === 'generate-types' @@ -84,12 +93,14 @@ export const preserveTypeScriptDiagnostic = ({ ); return { copied: true, + command: diagnosticCommand, diagnosticConfigPath, diagnosticLogPath, }; } catch { return { copied: false, + command: formatCommandWithConfig(tempTsConfigPath), diagnosticConfigPath: tempTsConfigPath, diagnosticLogPath: undefined, };