543 lines
18 KiB
TypeScript
543 lines
18 KiB
TypeScript
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';
|
|
import { TsBuildLogger as log } from '../mod_logger/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;
|
|
const c = log.c;
|
|
|
|
console.log('');
|
|
console.log(c.dim + log.separator() + c.reset);
|
|
console.log(
|
|
`${c.red}❌ Found ${totalErrors} error${totalErrors !== 1 ? 's' : ''} in ${totalFiles} file${totalFiles !== 1 ? 's' : ''}${c.reset}`
|
|
);
|
|
console.log(c.dim + log.separator() + c.reset);
|
|
|
|
// Print file-specific errors
|
|
Object.entries(errorsByFile).forEach(([fileName, fileErrors]) => {
|
|
const displayPath = fileName.replace(process.cwd(), '').replace(/^\//, '');
|
|
|
|
console.log(
|
|
`\n ${c.cyan}File: ${displayPath}${c.reset} ${c.yellow}(${fileErrors.length} error${fileErrors.length !== 1 ? 's' : ''})${c.reset}`
|
|
);
|
|
console.log(` ${c.dim}${'─'.repeat(40)}${c.reset}`);
|
|
|
|
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(
|
|
` ${c.white}Line ${line + 1}, Col ${character + 1}${c.reset}: ${c.brightRed}${errorCode}${c.reset} - ${message}`
|
|
);
|
|
|
|
try {
|
|
const lineContent = diagnostic.file.text.split('\n')[line];
|
|
if (lineContent) {
|
|
console.log(` ${lineContent.trimEnd()}`);
|
|
const indicator = ' '.repeat(character) + `${c.red}^${c.reset}`;
|
|
console.log(` ${indicator}`);
|
|
}
|
|
} catch {
|
|
// Failed to get source text
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// Print general errors
|
|
if (generalErrors.length > 0) {
|
|
console.log(`\n ${c.yellow}General Errors:${c.reset}`);
|
|
console.log(` ${c.dim}${'─'.repeat(40)}${c.reset}`);
|
|
|
|
generalErrors.forEach((diagnostic) => {
|
|
const message = typescript.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
|
|
const errorCode = diagnostic.code ? `TS${diagnostic.code}` : 'Error';
|
|
console.log(` ${c.brightRed}${errorCode}${c.reset}: ${message}`);
|
|
});
|
|
}
|
|
|
|
console.log('');
|
|
console.log(c.dim + log.separator() + c.reset);
|
|
}
|
|
|
|
/**
|
|
* Handle skipLibCheck warning display
|
|
*/
|
|
private async handleSkipLibCheckWarning(): Promise<void> {
|
|
if (this.argvArg?.confirmskiplibcheck) {
|
|
log.warn('WARNING: You are skipping libcheck... Is that really wanted?');
|
|
log.indent('Continuing in 5 seconds...');
|
|
await smartdelay.delayFor(5000);
|
|
} else if (!this.argvArg?.quiet && !this.argvArg?.json) {
|
|
log.warn('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();
|
|
}
|
|
|
|
const startTime = Date.now();
|
|
if (taskInfo) {
|
|
const { taskNumber, totalTasks, sourcePattern, fileCount } = taskInfo;
|
|
const relativeDestDir = taskInfo.destDir.replace(process.cwd(), '').replace(/^\//, '');
|
|
log.step('🔨', `[${taskNumber}/${totalTasks}] Compiling ${fileCount} file${fileCount !== 1 ? 's' : ''} from ${sourcePattern}`);
|
|
log.detail('📁', `Output: ${relativeDestDir}`);
|
|
} else {
|
|
log.step('🔨', `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);
|
|
|
|
if (preEmitErrorSummary.totalErrors > 0) {
|
|
this.displayErrorSummary(preEmitErrorSummary);
|
|
log.error('Pre-emit checks failed');
|
|
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;
|
|
log.success(`[${taskNumber}/${totalTasks}] Completed in ${duration}ms`);
|
|
} else {
|
|
log.success(`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) {
|
|
log.detail('📄', `Generated ${emitResult.emittedFiles.length} files: ${jsFiles} .js, ${dtsFiles} .d.ts, ${mapFiles} source maps`);
|
|
}
|
|
|
|
done.resolve({ emittedFiles: emitResult.emittedFiles || [], errorSummary: combinedErrorSummary });
|
|
} else {
|
|
this.displayErrorSummary(combinedErrorSummary);
|
|
log.error('TypeScript emit failed');
|
|
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) {
|
|
log.header('👷', `TypeScript Compilation Tasks (${totalTasks} task${totalTasks !== 1 ? 's' : ''})`);
|
|
Object.entries(globPatterns).forEach(([source, dest]) => {
|
|
log.detail('📂', `${source} → ${dest}`);
|
|
});
|
|
}
|
|
|
|
// Phase 1: Resolve glob patterns and clean ALL output directories upfront.
|
|
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) {
|
|
log.step('🧹', `Clearing output directory: ${destPath}`);
|
|
}
|
|
await FsHelpers.removeDirectory(destDir);
|
|
}
|
|
|
|
resolvedTasks.push({ pattern, destPath, destDir, absoluteFiles });
|
|
}
|
|
|
|
// Phase 2: Compile all tasks.
|
|
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.
|
|
for (const { pattern, destDir } of pendingUnpacks) {
|
|
await performUnpack(pattern, destDir, this.cwd);
|
|
}
|
|
|
|
// Rewrite import paths in all output directories
|
|
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) {
|
|
log.detail('🔄', `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;
|
|
|
|
log.step('🔍', `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) {
|
|
log.success(`Emit check passed (${fileCount} file${fileCount !== 1 ? 's' : ''})`);
|
|
} else {
|
|
this.displayErrorSummary(combinedErrorSummary);
|
|
log.error('Emit check failed');
|
|
}
|
|
|
|
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;
|
|
|
|
log.step('🔍', `Type checking ${fileCount} 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) {
|
|
log.success(`Type check passed (${fileCount} file${fileCount !== 1 ? 's' : ''})`);
|
|
} else {
|
|
this.displayErrorSummary(errorSummary);
|
|
log.error('Type check failed');
|
|
}
|
|
|
|
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 {
|
|
const c = log.c;
|
|
|
|
if (errorSummary.totalErrors === 0) {
|
|
log.header('📊', `${c.green}Compilation Summary: All tasks completed successfully! ✅${c.reset}`);
|
|
return;
|
|
}
|
|
|
|
log.header('📊', 'Compilation Summary');
|
|
|
|
console.log(
|
|
`${c.brightRed}❌ ${errorSummary.totalErrors} error${errorSummary.totalErrors !== 1 ? 's' : ''} across ${errorSummary.totalFiles} file${errorSummary.totalFiles !== 1 ? 's' : ''}${c.reset}`
|
|
);
|
|
|
|
Object.entries(errorSummary.errorsByFile).forEach(([fileName, errors]) => {
|
|
const displayPath = fileName.replace(process.cwd(), '').replace(/^\//, '');
|
|
log.indent(
|
|
`${c.red}•${c.reset} ${c.cyan}${displayPath}${c.reset} ${c.yellow}(${errors.length} error${errors.length !== 1 ? 's' : ''})${c.reset}`
|
|
);
|
|
});
|
|
|
|
if (errorSummary.generalErrors.length > 0) {
|
|
console.log(`${c.brightRed}❌ General errors: ${errorSummary.generalErrors.length}${c.reset}`);
|
|
}
|
|
}
|
|
}
|