// @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;