plugins_ts_tscheck.js

// @ts-check

/**
 * @file src/plugins/tscheck.js
 * @copyright 2025 SPMHOME, LLC
 * @author Scott Meesseman @spmeesseman
 *//** */

const WpwPlugin = require("../base");
const { relative } = require("path");
const { unlink } = require("fs/promises");
const ForkTsCheckerWebpackPlugin = require("fork-ts-checker-webpack-plugin");
const { merge, isString, isObject, pluralize, isError } = require("@spmhome/type-utils");
const { dotPath, findFiles, sanitizeShellCmd, toTscCliArgs } = require("@spmhome/cmn-utils");


/**
 * @augments WpwPlugin
 */
class WpwTsCheckPlugin extends WpwPlugin
{
	/**
	 * @private
	 */
	lXTags = [ "tscheck" ];


    /**
     * @param {WpwPluginOptions} options Plugin options to be applied
     */
	constructor(options)
	{
		super(options);
        this.buildOptions = /** @type {WpwBuildOptionsPluginConfig<"tscheck">} */(this.buildOptions); // reset for typings
        this.typesBuildOptions = /** @type {WpwBuildOptionsPluginConfig<"types">} */(this.build.options.types);
	}


	/**
     * @override
     */
	static create = WpwPlugin.wrap.bind(this);


    /**
     * @override
     * @param {WebpackCompiler} _compiler
     * @returns {WpwPluginTapOptions<any, any, boolean> | undefined}
     */
    onApply(_compiler)
    {
		// if (this.typesBuildOptions?.enabled && this.typesBuildOptions.bundle &&this.typesBuildOptions.mode === "tscheck")
		// {
		// 	/** @type {WpwPluginTapOptions<any, any, boolean>} */
		// 	const applyConfig = {},
		// 	      buildPath = this.build.virtualEntry.dirBuild, // .getDistPath({ build: "types" }),
		// 		  entry = this.build.type === "app" || this.build.type === "lib" ?
		// 		          this.build.wpc.entry[this.build.name] || this.build.wpc.entry.index : null,
		// 		  entryImportPath = isString(entry) ? entry : (entry.import ? entry.import : (entry[0] ?? ""));
		// 	let entryFile = !!entry ? resolve(buildPath, entryImportPath) : null;

		// 	if (!!entryFile) // && this.build.isOnlyBuild)
		// 	{
		// 		if (!existsSync(entryFile)) {
		// 			entryFile = resolve(this.build.getDistPath(), entryImportPath);
		// 		}
		// 		if (existsAsync)
		// 		{
		// 			applyConfig.bundleDtsFiles = {
		// 				async: true,
		// 				hook: "compilation",
		// 				stage: "DERIVED",
		// 				statsProperty: "dts",
		// 				// callback: (...args) => this.execDtsBundle.bind(this)
		// 				callback: () => (new WpwTypeDeclarationsBundle(this, "dts")).bundle()
		// 			};
		// 		}
		// 	}
		// 	else
		// 	{
		// 		applyConfig.bundleDtsFiles = {
		// 			async: true,
		// 			hook: "afterEmit",
		// 			statsProperty: "dts",
		// 			// callback: () => this.execDtsBundle.bind(this)
		// 			callback: () => (new WpwTypeDeclarationsBundle(this, "dts")).bundle()
		// 		};
		// 	}

		// 	config = applyConfig;
		// }

		if (this.build.isTs || (this.build.options.types?.enabled && this.build.options.types.mode === "tscheck"))
		{
			const tsMotherForkerHooks = ForkTsCheckerWebpackPlugin.getCompilerHooks(this.compiler);
			tsMotherForkerHooks.error.tap(this.name, this.tsMotherForkerError.bind(this));
			tsMotherForkerHooks.waiting.tap(this.name, this.tsMotherForkerWaiting.bind(this));
			tsMotherForkerHooks.start.tap(this.name, this.tsMotherForkerStart.bind(this));
			tsMotherForkerHooks.issues.tap(this.name, this.tsMotherForkerIssues.bind(this));
		}

		if (this.buildOptions.javascript?.enabled)
		{   return {
				validateSourceCode:
				{   async: true,
					hook: "beforeCompile",
					callback: this.validateSourceCode.bind(this)
				}
			};
		}
	}


	/**
	 * @private
	 * @param {Error | WpwExecResult} e
	 * @param {WpwExecResult} [result]
	 */
	validationError(e, result)
	{
		const l = this.logger;
		if (this._types_.isError(e))
		{   l.write("   code validation resulted in an error", 1);
			this.addMessage({
				exception: e,
				message: result?.errors[0] || e.message,
				code: this.MsgCode.ERROR_JS_VALIDATION_FAILED
			}, true);
		}
		else
		{   const errCnt = (e.errors?.length) || 0;
			l.write(`   code validation resulted in ${errCnt} ${pluralize("error", errCnt)}`, 1);
			this.addMessage({
				exception: e.exception || new Error(e.errors[0]),
				message: result?.errors[0] || e.exception?.message,
				code: this.MsgCode.ERROR_JS_VALIDATION_FAILED
			}, true);
		}
	}


	/**
	 * @protected
	 */
	async validateSourceCode()
	{
		/** @type {WpwExecResult} */
		let result = this.execUtils.getExecResult(0);
		const build = this.build,
		      cwd = build.getContextPath(),
		      l = this.hookstart("validate source code [javascript]");

		if (build.tsc.hasTsPathsOrExcludes && !build.tsc.configFileRtIsOrig)
		{   l.write("   force to 'project' mode [tsconfig.compilerOptions.paths / tsconfig.exclude]", 1);
			this.buildOptions.javascript.mode = "project";
		}

		if (this.buildOptions.javascript.mode === "entry")
		{
			if (build.entry)
			{   const entryPoints = [],
					  entry = build.entry,
					  tsOpts = toTscCliArgs(build.tsc.getTsConfig({ noEmit: true }));
					  // tsOpts = jsonConfigToArgs(build.tsc.getTranspilationCompilerOptions({ noEmit: true }));
				if (isString(entry)) {
					entryPoints.push(dotPath(entry));
				}
				else if (isObject(entry))
				{   Object.keys(entry).forEach((name) =>
					{   if (isString(entry[name])) {
							entryPoints.push(entry[name]);
						}
						else { entryPoints.push(...this._arr_.asArray(entry[name].import)); }
					});
				}
				for (const ep of entryPoints)
				{   l.value("   check entry point", ep, 2);
					try
					{   result = await this.exec(
							`npx tsc ${tsOpts.join(" ")} ${ep}`, "tsc", true, { cwd, logPad: "   ", throwOnError: true }
						);
					} catch(e) { this.validationError(e, this._obj_.apply(result, { code: 14 })); break; }
				}
			}
			else
			{   result.errors.push("js validation mode is 'entry', but no entry point was found");
				this.validationError(this._obj_.apply(result, { code: 15 }));
			}
		}
		else if (this.buildOptions.javascript.mode === "custom" && this.buildOptions.javascript.cmd)
		{
			try
			{   const cmd = sanitizeShellCmd(this.buildOptions.javascript.cmd, "string", this.logger, "   ");
				result = await this.exec(`${cmd}`, "custom", true, { cwd, logPad: "   ", throwOnError: true });
			}
			catch(e) { this.validationError(e, result); }
		}
		else if (this.buildOptions.javascript.mode === "files")
		{
			if (build.tsconfig.files)
			{   try
				{   const tsOpts = toTscCliArgs(this.build.tsc.getTsConfig({ noEmit: true }));
					// const tsOpts = jsonConfigToArgs(this.build.tsc.getTranspilationCompilerOptions({ noEmit: true }));
					result = await this.exec(
						`npx tsc ${tsOpts.join(" ")} ${build.tsc.tsconfig.files.join(" ")}`, "tsc", true,
						{ cwd, logPad: "   ", throwOnError: true }
					);
				} catch(e) { this.validationError(e, result); }
			}
			else
			{   result.errors.push("js validation mode is 'files', but the config.files property is not set");
				this.validationError(this._obj_.apply(result, { code: 16 }));
			}
		}
		else // if (this.buildOptions.javascript.mode === "project")
		{   if (!build.tsc.configFileRtIsMock)
			{   try
				{   const tsOpts = toTscCliArgs(this.build.tsc.getTsConfig({ noEmit: true }), this.build.tsc.configFileRtPathRel);
				    // const tsOpts = jsonConfigToArgs(this.build.tsc.getTranspilationCompilerOptions({ noEmit: true }));
					result = await this.exec(
						`npx tsc ${tsOpts.join(" ")} `, "tsc", true,
						{ cwd, logPad: "   ", throwOnError: true, timeout: 45000 }
					);
				} catch(e) { this.validationError(e, result); }
			}
			else
			{   result.errors.push("js validation skipped, existing ts/jsconfig file is temp ref only");
				this.validationError(this._obj_.apply(result, { code: 17 }));
			}
		}

		this.hookdone("completed source code validation [javascript]");
	}


	/**
	 * @protected
	 * @param {WebpackStats} _stats
	 */
	async cleanTempFiles(_stats)
	{
		this.hookstart("cleanup | remove temporary files");
		const basePath = this.build.getBasePath(),
			  tmpFiles = await findFiles("**/dts-bundle.tmp.*", { cwd: basePath, absolute: true });
		for (const file of tmpFiles)
		{   this.build.logger.write(`   delete file '${relative(basePath, file)}'`, 1);
			try {
				await unlink(file);
			}
			catch(e)
			{   this.addMessage({
					exception: e, code: this.MsgCode.ERROR_FILESYSTEM, message: `unable to to delete '${file}'`
				});
			}
		}
		this.hookdone("cleanup | remove temporary files");
	};


	// async execDtsBundle()
	// {
	// 	const result = await dtsBundle(this, this.compilation, "tsbundle");
	// 	return result;
	// }

	/**
	 * @override
     * @param {WebpackCompiler} _compiler
     * @param {boolean} [applyFirst]
	 * @returns {WebpackPluginInstance | undefined}
	 */
	getVendorPlugin(_compiler, applyFirst)
	{
		const build = this.build,
		      typesOpts = build.options.types,
			  hasTypes = !!typesOpts?.enabled && typesOpts.mode === "tscheck";

		if ((hasTypes || build.type === "types" || build.isTs) && !applyFirst)
		{
			const compilerOptions = build.tsc.compilerOptions; // build.tsc.getTranspilationCompilerOptions();
			/** @type {ForkTsCheckerTypescriptOptions} */
			const tsOptions = {
				build: false,
				mode: "readonly",
				profile: build.logger.level >= 3,
				context: build.tsc.configFileRtDir,
				configFile: build.tsc.configFileRtPath,
				configOverwrite: {
					compilerOptions,
					exclude: build.tsc.tsconfig.exclude,
					files: build.tsc.tsconfig.files,
					include: build.tsc.tsconfig.include
				},
				diagnosticOptions: {
					syntactic: true, semantic: true, declaration: hasTypes, global: true
				}
			};

			if (build.type === "tests") {
				merge(tsOptions, { mode: "write-tsbuildinfo", diagnosticOptions: { global: true }});
			}
			else if (hasTypes)
			{   merge(tsOptions,
				{   mode: "write-dts",
					configOverwrite:
					{   compilerOptions: {
							declaration: true,
							declarationMap: false,
							emitDeclarationOnly: true
					}   }
				});
			}

			this.build.logger.value("   fork-ts-checker ts options", tsOptions, 2, "", null, null, this.lXTags);

			return new ForkTsCheckerWebpackPlugin(/** @type {ForkTsCheckerOptions} */(
			{
				async: false, formatter: "basic", typescript: tsOptions,
				logger: { error: this.tsMotherForkerLogErr.bind(this), log: this.tsMotherForkerLogMsg.bind(this) }
			}));
		}
	}


	/**
	 * @private
	 * @param {unknown} e
	 * @returns {SpmhError}
	 */
	tsMotherForkerError(e)
	{
		return this.addMessage({
			code: this.MsgCode.ERROR_TYPESCRIPT,
			exception: isError(e) ? e : undefined,
			message: "error encountered in external ts-fork-checker plugin"
		});
	}


	/**
	 * @private
	 * @param {TsCheckIssue[]} issues
	 * @returns {TsCheckIssue[]}
	 */
	tsMotherForkerIssues(issues)
	{
		const l = this.build.logger;
		if (issues.length > 0)
		{
			const sdMsg = `${issues.length} ${pluralize("error", issues)}/${pluralize("warning", issues)}`;
			l.write(`process ${sdMsg}`, 1, "", null, null, false, null, this.lXTags);
			issues.forEach((issue) =>
			{
				const loc = issue.location,
				      e = new Error(issue.message);
				// l[issue.severity](`   ${l.tag(issue.code, "whitesmoke")}: ${issue.message}`);
				if (loc) {
					// l[issue.severity](`   ${issue.message} [${issue.file}:${loc.start.line}:${loc.start.column}]`);
					e.stack = `${issue.file}\n  at ${issue.file}:${loc.start.line}:${loc.start.column}\n  at ${e.stack}`;
				}
				if (issue.severity === "error")
				{   this.addMessage({
						exception: e,
						code: this.MsgCode.ERROR_TYPESCRIPT,
						message: "tscheck: typescript error @ "+ issue.file
					});
				}
				else if (issue.severity === "warning")
				{   this.addMessage({
						exception: e,
						code: this.MsgCode.WARNING_PLUGIN_TSCHECK,
						message: "tscheck: typescript warning @ "+ issue.file
					});
				}
			});
			l.write(`completed processing of ${sdMsg}`, 1, "", null, null, false, null, this.lXTags);
		}
		else {
			l.write("no errors or warnings reported", 1, "", null, null, false, null, this.lXTags);
		}
		return issues;
	}


	/**
	 * @private
	 * @param {any} m
	 */
	tsMotherForkerLogErr(m) { this.build.logger.write(m, 1, "", this.build.logger.icons.error, null, 0, 0, this.lXTags); };


	/**
	 * @private
	 * @param {any} m
	 */
	tsMotherForkerLogMsg(m) { this.build.logger.write(m, 1, "", this.build.logger.icons.error, null, 0, 0, this.lXTags); };


	/**
	 * @private
	 * @param {TsCheckFilesChange} filesChange
	 * @param {WebpackCompilation} _compilation
	 */
	tsMotherForkerStart(filesChange, _compilation)
	{
		const l = this.hookstart("start source code validation check [typescript]");
		if (filesChange.changedFiles)
		{
			l.value("   # of modified files", filesChange.changedFiles.length, 1);
			if (l.level >= 2) {
				filesChange.changedFiles.forEach((f) => { l.write(f, 2, "   ", null, null, false, null, this.lXTags); });
			}
		}
		if (filesChange.deletedFiles)
		{
			l.value("   # of deleted files", filesChange.deletedFiles.length, 1);
			if (l.level >= 2) {
				filesChange.deletedFiles.forEach((f) => { l.write(f, 2, "   ", null, null, false, null, this.lXTags); });
			}
		}
		if (!filesChange.changedFiles && !filesChange.deletedFiles)
		{
			l.write("   0 modifications | no filed have been changed/deleted", 1, "", null, null, false, null, this.lXTags);
		}
		this.hookdone("completed source code validation [typescript]");
	}


	/**
	 * @private
	 */
	tsMotherForkerWaiting()
	{
		this.build.logger.write("wait for source code validation check...", 1, "", null, null, false, null, this.lXTags);
	}
}


module.exports = WpwTsCheckPlugin.create;