Files
tsbuild/ts/mod_compiler/classes.tscompiler.ts
Juergen Kunz a6a006aaec fix(compiler): move output directory cleaning to separate phase before compilation
Restructured compileGlob() into three distinct phases:
1. Resolve glob patterns and clean ALL output directories upfront
2. Compile all TypeScript tasks (no filesystem cleanup during this phase)
3. Unpack all outputs after all compilations complete

This prevents XFS metadata corruption from rm operations overlapping
with TypeScript compilation writes to sibling directories.
2026-03-05 16:45:07 +00:00

586 lines
21 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { CompilerOptions, Diagnostic, Program } from 'typescript';
import typescript from 'typescript';
import * as smartdelay from '@push.rocks/smartdelay';
import * as smartpromise from '@push.rocks/smartpromise';
import * as smartpath from '@push.rocks/smartpath';
import { TsConfig } from '../mod_config/index.js';
import { FsHelpers } from '../mod_fs/index.js';
import { performUnpack } from '../mod_unpack/index.js';
import { TsPathRewriter } from '../mod_pathrewrite/index.js';
/**
* Interface for error summary data
*/
export interface IErrorSummary {
errorsByFile: Record<string, Diagnostic[]>;
generalErrors: Diagnostic[];
totalErrors: number;
totalFiles: number;
}
/**
* Interface for task information
*/
export interface ITaskInfo {
taskNumber: number;
totalTasks: number;
sourcePattern: string;
destDir: string;
fileCount: number;
}
/**
* Interface for compilation result
*/
export interface ICompileResult {
emittedFiles: string[];
errorSummary: IErrorSummary;
}
/**
* TsCompiler handles TypeScript compilation with error tracking,
* configuration management, and output unpacking.
*/
export class TsCompiler {
private config: TsConfig;
private cwd: string;
private argvArg?: any;
constructor(cwd: string = process.cwd(), argvArg?: any) {
this.cwd = cwd;
this.config = new TsConfig(cwd);
this.argvArg = argvArg;
}
/**
* Get the current working directory
*/
public getCwd(): string {
return this.cwd;
}
/**
* Get the TsConfig instance
*/
public getConfig(): TsConfig {
return this.config;
}
/**
* Create compiler options by merging defaults, tsconfig.json, and custom options
*/
public createOptions(customOptions: CompilerOptions = {}): CompilerOptions {
return this.config.merge(customOptions, this.argvArg);
}
/**
* Create a TypeScript program from file names and options
*/
private createProgram(fileNames: string[], options: CompilerOptions): Program {
return typescript.createProgram(fileNames, options);
}
/**
* Process TypeScript diagnostics and return error summary
*/
private processDiagnostics(diagnostics: readonly Diagnostic[]): IErrorSummary {
const errorsByFile: Record<string, Diagnostic[]> = {};
const generalErrors: Diagnostic[] = [];
diagnostics.forEach((diagnostic) => {
if (diagnostic.file) {
const fileName = diagnostic.file.fileName;
if (!errorsByFile[fileName]) {
errorsByFile[fileName] = [];
}
errorsByFile[fileName].push(diagnostic);
} else {
generalErrors.push(diagnostic);
}
});
return {
errorsByFile,
generalErrors,
totalErrors: diagnostics.length,
totalFiles: Object.keys(errorsByFile).length,
};
}
/**
* Display error summary to console
*/
private displayErrorSummary(errorSummary: IErrorSummary): void {
if (errorSummary.totalErrors === 0) {
return;
}
const { errorsByFile, generalErrors, totalErrors, totalFiles } = errorSummary;
// Print error summary header
console.log('\n' + '='.repeat(80));
console.log(
`❌ Found ${totalErrors} error${totalErrors !== 1 ? 's' : ''} in ${totalFiles} file${totalFiles !== 1 ? 's' : ''}:`
);
console.log('='.repeat(80));
// Color codes for error formatting
const colors = {
reset: '\x1b[0m',
red: '\x1b[31m',
yellow: '\x1b[33m',
cyan: '\x1b[36m',
white: '\x1b[37m',
brightRed: '\x1b[91m',
};
// Print file-specific errors
Object.entries(errorsByFile).forEach(([fileName, fileErrors]) => {
// Show relative path if possible for cleaner output
const displayPath = fileName.replace(process.cwd(), '').replace(/^\//, '');
console.log(
`\n${colors.cyan}File: ${displayPath} ${colors.yellow}(${fileErrors.length} error${fileErrors.length !== 1 ? 's' : ''})${colors.reset}`
);
console.log('-'.repeat(80));
fileErrors.forEach((diagnostic) => {
if (diagnostic.file && diagnostic.start !== undefined) {
const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);
const message = typescript.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
const errorCode = diagnostic.code ? `TS${diagnostic.code}` : 'Error';
console.log(
`${colors.white}Line ${line + 1}, Col ${character + 1}${colors.reset}: ${colors.brightRed}${errorCode}${colors.reset} - ${message}`
);
// Try to show the code snippet if possible
try {
const lineContent = diagnostic.file.text.split('\n')[line];
if (lineContent) {
console.log(` ${lineContent.trimEnd()}`);
const indicator = ' '.repeat(character) + `${colors.red}^${colors.reset}`;
console.log(` ${indicator}`);
}
} catch {
// Failed to get source text, skip showing the code snippet
}
}
});
});
// Print general errors
if (generalErrors.length > 0) {
console.log(`\n${colors.yellow}General Errors:${colors.reset}`);
console.log('-'.repeat(80));
generalErrors.forEach((diagnostic) => {
const message = typescript.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
const errorCode = diagnostic.code ? `TS${diagnostic.code}` : 'Error';
console.log(`${colors.brightRed}${errorCode}${colors.reset}: ${message}`);
});
}
console.log('\n' + '='.repeat(80) + '\n');
}
/**
* Handle skipLibCheck warning display
*/
private async handleSkipLibCheckWarning(): Promise<void> {
if (this.argvArg?.confirmskiplibcheck) {
console.log('\n⚠ WARNING ⚠️');
console.log('You are skipping libcheck... Is that really wanted?');
console.log('Continuing in 5 seconds...\n');
await smartdelay.delayFor(5000);
} else if (!this.argvArg?.quiet && !this.argvArg?.json) {
console.log('⚠️ skipLibCheck enabled; use --confirmskiplibcheck to pause with warning.');
}
}
/**
* Compile files with error tracking (returns result instead of throwing)
*/
public async compileFiles(
fileNames: string[],
customOptions: CompilerOptions = {},
taskInfo?: ITaskInfo
): Promise<ICompileResult> {
const options = this.createOptions(customOptions);
if (options.skipLibCheck) {
await this.handleSkipLibCheckWarning();
}
// Enhanced logging with task info
const startTime = Date.now();
if (taskInfo) {
const { taskNumber, totalTasks, sourcePattern, fileCount } = taskInfo;
const relativeDestDir = taskInfo.destDir.replace(process.cwd(), '').replace(/^\//, '');
console.log(
`\n🔨 [${taskNumber}/${totalTasks}] Compiling ${fileCount} file${fileCount !== 1 ? 's' : ''} from ${sourcePattern}`
);
console.log(` 📁 Output: ${relativeDestDir}`);
} else {
console.log(`🔨 Compiling ${fileNames.length} files...`);
}
const done = smartpromise.defer<ICompileResult>();
const program = this.createProgram(fileNames, options);
// Check for pre-emit diagnostics first
const preEmitDiagnostics = typescript.getPreEmitDiagnostics(program);
const preEmitErrorSummary = this.processDiagnostics(preEmitDiagnostics);
// Only continue to emit phase if no pre-emit errors
if (preEmitErrorSummary.totalErrors > 0) {
this.displayErrorSummary(preEmitErrorSummary);
console.error('\n❌ TypeScript pre-emit checks failed. Please fix the issues listed above before proceeding.');
console.error(' Type errors must be resolved before the compiler can emit output files.\n');
done.resolve({ emittedFiles: [], errorSummary: preEmitErrorSummary });
return done.promise;
}
// If no pre-emit errors, proceed with emit
const emitResult = program.emit();
// Yield to the event loop so any pending microtasks, nextTick callbacks,
// or deferred I/O from TypeScript's emit (e.g. libuv write completions)
// can settle before we read or modify the output directory.
await new Promise<void>((resolve) => process.nextTick(resolve));
const emitErrorSummary = this.processDiagnostics(emitResult.diagnostics);
// Combine error summaries
const combinedErrorSummary: IErrorSummary = {
errorsByFile: { ...preEmitErrorSummary.errorsByFile, ...emitErrorSummary.errorsByFile },
generalErrors: [...preEmitErrorSummary.generalErrors, ...emitErrorSummary.generalErrors],
totalErrors: preEmitErrorSummary.totalErrors + emitErrorSummary.totalErrors,
totalFiles: Object.keys({ ...preEmitErrorSummary.errorsByFile, ...emitErrorSummary.errorsByFile }).length,
};
const exitCode = emitResult.emitSkipped ? 1 : 0;
if (exitCode === 0) {
const endTime = Date.now();
const duration = endTime - startTime;
if (taskInfo) {
const { taskNumber, totalTasks } = taskInfo;
console.log(`✅ [${taskNumber}/${totalTasks}] Task completed in ${duration}ms`);
} else {
console.log(`✅ TypeScript emit succeeded! (${duration}ms)`);
}
// Get count of emitted files by type
const jsFiles = emitResult.emittedFiles?.filter((f) => f.endsWith('.js')).length || 0;
const dtsFiles = emitResult.emittedFiles?.filter((f) => f.endsWith('.d.ts')).length || 0;
const mapFiles = emitResult.emittedFiles?.filter((f) => f.endsWith('.map')).length || 0;
if (emitResult.emittedFiles && emitResult.emittedFiles.length > 0) {
console.log(
` 📄 Generated ${emitResult.emittedFiles.length} files: ${jsFiles} .js, ${dtsFiles} .d.ts, ${mapFiles} source maps`
);
}
done.resolve({ emittedFiles: emitResult.emittedFiles || [], errorSummary: combinedErrorSummary });
} else {
this.displayErrorSummary(combinedErrorSummary);
console.error('\n❌ TypeScript emit failed. Please investigate the errors listed above!');
console.error(' No output files have been generated.\n');
done.resolve({ emittedFiles: [], errorSummary: combinedErrorSummary });
}
return done.promise;
}
/**
* Compile files (throws on error)
*/
public async compileFilesOrThrow(fileNames: string[], customOptions: CompilerOptions = {}): Promise<string[]> {
const result = await this.compileFiles(fileNames, customOptions);
if (result.errorSummary.totalErrors > 0) {
throw new Error('TypeScript compilation failed.');
}
return result.emittedFiles;
}
/**
* Compile glob patterns with automatic unpacking
*/
public async compileGlob(
globPatterns: Record<string, string>,
customOptions: CompilerOptions = {}
): Promise<ICompileResult> {
const emittedFiles: string[] = [];
const errorSummaries: IErrorSummary[] = [];
const successfulOutputDirs: string[] = [];
const totalTasks = Object.keys(globPatterns).length;
let currentTask = 0;
const isQuiet = this.argvArg?.quiet === true;
const isJson = this.argvArg?.json === true;
if (!isQuiet && !isJson) {
console.log(`\n👷 TypeScript Compilation Tasks (${totalTasks} task${totalTasks !== 1 ? 's' : ''}):`);
Object.entries(globPatterns).forEach(([source, dest]) => {
console.log(` 📂 ${source}${dest}`);
});
console.log('');
}
// Phase 1: Resolve glob patterns and clean ALL output directories upfront.
// This ensures no rm/rmSync activity overlaps with TypeScript compilation,
// preventing XFS metadata corruption from concurrent metadata operations.
interface IResolvedTask {
pattern: string;
destPath: string;
destDir: string;
absoluteFiles: string[];
}
const resolvedTasks: IResolvedTask[] = [];
for (const pattern of Object.keys(globPatterns)) {
const destPath = globPatterns[pattern];
if (!pattern || !destPath) continue;
const files = await FsHelpers.listFilesWithGlob(this.cwd, pattern);
const absoluteFiles = smartpath.transform.toAbsolute(files, this.cwd) as string[];
const destDir = smartpath.transform.toAbsolute(destPath, this.cwd) as string;
if (await FsHelpers.directoryExists(destDir)) {
if (!isQuiet && !isJson) {
console.log(`🧹 Clearing output directory: ${destPath}`);
}
await FsHelpers.removeDirectory(destDir);
}
resolvedTasks.push({ pattern, destPath, destDir, absoluteFiles });
}
// Phase 2: Compile all tasks. No filesystem cleanup happens during this phase.
const pendingUnpacks: Array<{ pattern: string; destDir: string }> = [];
for (const task of resolvedTasks) {
const options: CompilerOptions = {
...customOptions,
outDir: task.destDir,
listEmittedFiles: true,
};
currentTask++;
const taskInfo: ITaskInfo = {
taskNumber: currentTask,
totalTasks,
sourcePattern: task.pattern,
destDir: task.destPath,
fileCount: task.absoluteFiles.length,
};
const result = await this.compileFiles(task.absoluteFiles, options, taskInfo);
emittedFiles.push(...result.emittedFiles);
errorSummaries.push(result.errorSummary);
if (result.errorSummary.totalErrors === 0) {
pendingUnpacks.push({ pattern: task.pattern, destDir: task.destDir });
successfulOutputDirs.push(task.destDir);
}
}
// Phase 3: Perform all unpacks after all compilations are done.
// This ensures no output directory is modified while subsequent compilations
// are performing heavy filesystem writes to sibling directories.
for (const { pattern, destDir } of pendingUnpacks) {
await performUnpack(pattern, destDir, this.cwd);
}
// Rewrite import paths in all output directories to handle cross-module references
// This must happen after ALL compilations so all destination folders exist
// Use fromProjectDirectory to detect ALL ts_* folders, not just the ones being compiled
if (successfulOutputDirs.length > 0) {
const rewriter = await TsPathRewriter.fromProjectDirectory(this.cwd);
let totalRewritten = 0;
for (const outputDir of successfulOutputDirs) {
totalRewritten += await rewriter.rewriteDirectory(outputDir);
}
if (totalRewritten > 0 && !isQuiet && !isJson) {
console.log(` 🔄 Rewrote import paths in ${totalRewritten} file${totalRewritten !== 1 ? 's' : ''}`);
}
}
// Merge all error summaries
const finalErrorSummary = this.mergeErrorSummaries(errorSummaries);
// Output summary based on mode
if (isJson) {
const result = {
success: finalErrorSummary.totalErrors === 0,
totals: {
errors: finalErrorSummary.totalErrors,
filesWithErrors: finalErrorSummary.totalFiles,
tasks: totalTasks,
},
errorsByFile: Object.fromEntries(
Object.entries(finalErrorSummary.errorsByFile).map(([file, diags]) => [
file,
diags.map((d) => ({
code: d.code,
message: typescript.flattenDiagnosticMessageText(d.messageText as any, '\n'),
})),
])
),
};
console.log(JSON.stringify(result));
} else if (!isQuiet) {
this.displayFinalSummary(finalErrorSummary);
}
// Attach summary to argvArg for CLI exit behavior
if (this.argvArg && typeof this.argvArg === 'object') {
(this.argvArg as any).__tsbuildFinalErrorSummary = finalErrorSummary;
}
return {
emittedFiles,
errorSummary: finalErrorSummary,
};
}
/**
* Check if files can be emitted without actually emitting
*/
public async checkEmit(fileNames: string[], customOptions: CompilerOptions = {}): Promise<boolean> {
const options = { ...this.createOptions(customOptions), noEmit: true };
const fileCount = fileNames.length;
console.log(`\n🔍 Checking if ${fileCount} file${fileCount !== 1 ? 's' : ''} can be emitted...`);
const program = this.createProgram(fileNames, options);
const preEmitDiagnostics = typescript.getPreEmitDiagnostics(program);
const preEmitErrorSummary = this.processDiagnostics(preEmitDiagnostics);
const emitResult = program.emit(undefined, undefined, undefined, true);
const emitErrorSummary = this.processDiagnostics(emitResult.diagnostics);
const combinedErrorSummary: IErrorSummary = {
errorsByFile: { ...preEmitErrorSummary.errorsByFile, ...emitErrorSummary.errorsByFile },
generalErrors: [...preEmitErrorSummary.generalErrors, ...emitErrorSummary.generalErrors],
totalErrors: preEmitErrorSummary.totalErrors + emitErrorSummary.totalErrors,
totalFiles: Object.keys({ ...preEmitErrorSummary.errorsByFile, ...emitErrorSummary.errorsByFile }).length,
};
const success = combinedErrorSummary.totalErrors === 0 && !emitResult.emitSkipped;
if (success) {
console.log('\n✅ TypeScript emit check passed! All files can be emitted successfully.');
console.log(` ${fileCount} file${fileCount !== 1 ? 's' : ''} ${fileCount !== 1 ? 'are' : 'is'} ready to be compiled.\n`);
} else {
this.displayErrorSummary(combinedErrorSummary);
console.error('\n❌ TypeScript emit check failed. Please fix the issues listed above.');
console.error(' The compilation cannot proceed until these errors are resolved.\n');
}
return success;
}
/**
* Check TypeScript files for type errors without emission
*/
public async checkTypes(fileNames: string[], customOptions: CompilerOptions = {}): Promise<boolean> {
const options = { ...this.createOptions(customOptions), noEmit: true };
const fileCount = fileNames.length;
console.log(`\n🔍 Type checking ${fileCount} TypeScript file${fileCount !== 1 ? 's' : ''}...`);
const program = this.createProgram(fileNames, options);
const diagnostics = typescript.getPreEmitDiagnostics(program);
const errorSummary = this.processDiagnostics(diagnostics);
const success = errorSummary.totalErrors === 0;
if (success) {
console.log('\n✅ TypeScript type check passed! No type errors found.');
console.log(` All ${fileCount} file${fileCount !== 1 ? 's' : ''} passed type checking successfully.\n`);
} else {
this.displayErrorSummary(errorSummary);
console.error('\n❌ TypeScript type check failed. Please fix the type errors listed above.');
console.error(' The type checker found issues that need to be resolved.\n');
}
return success;
}
/**
* Merge multiple error summaries into one
*/
private mergeErrorSummaries(summaries: IErrorSummary[]): IErrorSummary {
const mergedErrorsByFile: Record<string, Diagnostic[]> = {};
const mergedGeneralErrors: Diagnostic[] = [];
let totalErrors = 0;
summaries.forEach((summary) => {
Object.entries(summary.errorsByFile).forEach(([fileName, errors]) => {
if (!mergedErrorsByFile[fileName]) {
mergedErrorsByFile[fileName] = [];
}
mergedErrorsByFile[fileName] = mergedErrorsByFile[fileName].concat(errors);
});
mergedGeneralErrors.push(...summary.generalErrors);
totalErrors += summary.totalErrors;
});
return {
errorsByFile: mergedErrorsByFile,
generalErrors: mergedGeneralErrors,
totalErrors,
totalFiles: Object.keys(mergedErrorsByFile).length,
};
}
/**
* Display final compilation summary
*/
private displayFinalSummary(errorSummary: IErrorSummary): void {
if (errorSummary.totalErrors === 0) {
console.log('\n📊 \x1b[32mCompilation Summary: All tasks completed successfully! ✅\x1b[0m\n');
return;
}
const colors = {
reset: '\x1b[0m',
red: '\x1b[31m',
yellow: '\x1b[33m',
cyan: '\x1b[36m',
brightRed: '\x1b[91m',
brightYellow: '\x1b[93m',
};
console.log('\n' + '='.repeat(80));
console.log(`📊 ${colors.brightYellow}Final Compilation Summary${colors.reset}`);
console.log('='.repeat(80));
if (errorSummary.totalFiles > 0) {
console.log(`${colors.brightRed}❌ Files with errors (${errorSummary.totalFiles}):${colors.reset}`);
Object.entries(errorSummary.errorsByFile).forEach(([fileName, errors]) => {
const displayPath = fileName.replace(process.cwd(), '').replace(/^\//, '');
console.log(
` ${colors.red}${colors.reset} ${colors.cyan}${displayPath}${colors.reset} ${colors.yellow}(${errors.length} error${errors.length !== 1 ? 's' : ''})${colors.reset}`
);
});
}
if (errorSummary.generalErrors.length > 0) {
console.log(`${colors.brightRed}❌ General errors: ${errorSummary.generalErrors.length}${colors.reset}`);
}
console.log(
`\n${colors.brightRed}Total: ${errorSummary.totalErrors} error${errorSummary.totalErrors !== 1 ? 's' : ''} across ${errorSummary.totalFiles} file${errorSummary.totalFiles !== 1 ? 's' : ''}${colors.reset}`
);
console.log('='.repeat(80) + '\n');
}
}