CI: Pretty parallel CI

This commit is contained in:
Deep 2026-06-03 02:45:23 +03:00 committed by BondageProjects
parent ac7a6cb0d4
commit 763be34927
10 changed files with 627 additions and 13 deletions

1
.gitignore vendored
View file

@ -6,6 +6,7 @@
.DS_Store
node_modules
BondageClub/.cache/
web.config
/BondageClub/Tools/Node/.imagemin-cache
BondageClub/1 - prepare.bat

View file

@ -6,6 +6,13 @@ check:
only:
- merge_requests
image: node:20
cache:
key:
files:
- BondageClub/package-lock.json
paths:
- BondageClub/node_modules/
- BondageClub/.cache/
script:
- npm --prefix $NPM_PREFIX ci
- npm --prefix $NPM_PREFIX ci --no-fund --no-audit --loglevel=error
- npm --prefix $NPM_PREFIX run checks

View file

@ -0,0 +1,201 @@
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import path from "node:path";
import {
colorizeOutput,
formatDivider,
formatFailureHeading,
formatRunningHeader,
formatStatusLine,
formatSummaryCounts,
formatWarningHeading,
outputHasWarnings,
} from "./ChecksFormat.js";
// #region Configuration
const ROOT = path.resolve(fileURLToPath(import.meta.url), "../..");
/** @typedef {{ name: string, code: number, output: string, duration?: number }} CheckResult */
/** @type {readonly string[]} */
const CHEAP_CHECKS = [
"assets:typecheck",
"assets:check",
"assets:prettier",
"assets:eslint",
"scripts:typecheck:tsc",
"scripts:typecheck:strict",
"scripts:lint",
"files:case",
"files:csvtypescript",
];
/** @type {readonly string[]} */
const FULL_CHECKS = [
...CHEAP_CHECKS,
"scripts:typecheck-expensive",
"styles:prettier",
];
// #endregion
// #region Check execution
/**
* Creates a child process, buffering the output and resolving with the result.
* @param {string} name
* @returns {Promise<CheckResult>}
*/
function runCheck(name) {
return new Promise((resolve) => {
/** @type {string[]} */
const output = [];
const npmExecPath = process.env.npm_execpath;
const useNodeForNpm = typeof npmExecPath === "string" && /\.m?js$/i.test(npmExecPath);
const command = useNodeForNpm ? process.execPath : "npm";
/** @type {string[]} */
const args = useNodeForNpm
? [npmExecPath, "run", "--silent", name]
: ["run", "--silent", name];
const env = { ...process.env };
if (name === "scripts:typecheck:strict") {
// kill tsc-strict spinner
env.CI = "1";
}
const startTime = performance.now();
const child = spawn(command, args, {
cwd: ROOT,
env,
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => output.push(chunk));
child.stderr.on("data", (chunk) => output.push(chunk));
child.on("close", (code) => {
resolve({
name,
code: code ?? 1,
output: output.join("").trimEnd(),
duration: performance.now() - startTime,
});
});
child.on("error", (err) => {
resolve({
name,
code: 1,
output: String(err),
duration: performance.now() - startTime,
});
});
});
}
/**
* Runs all checks in parallel and returns the results.
* @param {readonly string[]} names
* @returns {Promise<CheckResult[]>}
*/
async function runChecks(names) {
/** @type {Map<string, CheckResult>} */
const resultsByName = new Map();
await Promise.all(
names.map(async (name) => {
resultsByName.set(name, await runCheck(name));
}),
);
return names.map((name) => resultsByName.get(name) ?? {
name,
code: 1,
output: "Check did not run",
});
}
// #endregion
// #region Console output
/**
* Prints the failure output for a check result.
* @param {CheckResult} result
*/
function printFailureOutput(result) {
console.log("\u200B");
console.log(formatFailureHeading(result.name));
console.log(colorizeOutput(result.output));
}
/**
* Prints the warning output for a check that passed but reported warnings.
* @param {CheckResult} result
*/
function printWarningOutput(result) {
console.log("\u200B");
console.log(formatWarningHeading(result.name));
console.log(colorizeOutput(result.output));
}
/**
* Prints the summary of the check results.
* @param {readonly CheckResult[]} results
*/
function printSummary(results) {
const failed = results.filter((result) => result.code !== 0);
const passed = results.length - failed.length;
console.log("\u200B");
console.log(formatDivider());
console.log(formatSummaryCounts(passed, failed.length));
console.log("\u200B");
for (const result of results) {
console.log(formatStatusLine(result));
}
console.log(formatDivider());
}
// #endregion
// #region Entry point
/**
* @param {readonly string[]} names
* @param {boolean} silent
* @returns {Promise<number>}
*/
async function main(names, silent) {
if (!silent) {
console.log(formatRunningHeader(names.length));
}
const results = await runChecks(names);
for (const result of results) {
if (result.code !== 0) {
printFailureOutput(result);
} else if (outputHasWarnings(result.output)) {
printWarningOutput(result);
}
}
if (!silent) {
printSummary(results);
}
return results.some((result) => result.code !== 0) ? 1 : 0;
}
const cheap = process.argv.includes("--cheap") || process.argv.includes("-c");
const isSilent = process.argv.includes("--silent") || process.argv.includes("-s");
process.exitCode = await main(cheap ? CHEAP_CHECKS : FULL_CHECKS, isSilent);
// #endregion

View file

@ -0,0 +1,392 @@
import colors from "ansi-colors";
/** @typedef {{ name: string, code: number, output: string, duration?: number }} CheckResult */
// #region Patterns
/** @type {RegExp} */
const QUOTED_STRING_RE = /"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g;
/** @type {RegExp} */
const FILE_PATH_RE =
/(?:\.\.?\/|\/|[A-Za-z]:[\\/])?[\w./\\-]+\.(?:js|ts|tsx|mts|cts|css|png|csv|json|md|yml|yaml|txt|html|svg|jpe?g|gif|webp)\b/g;
const DIVIDER = "─".repeat(60);
/** Placeholder byte used while stashing quoted strings during colorization. */
const STASH = String.fromCharCode(0);
// #endregion
// #region Primitives
/**
* Returns whether the output has color.
* @param {string} output
* @returns {boolean}
*/
export function outputHasColor(output) {
return output.includes("\x1b[");
}
/**
* Returns the style for a severity.
* @param {string} severity
* @returns {(value: string) => string}
*/
function severityStyle(severity) {
return severity.toLowerCase() === "error" ? colors.red : colors.yellow;
}
/**
* Formats the duration of a check.
* @param {number | undefined} durationMs
* @returns {string}
*/
export function formatDuration(durationMs) {
if (durationMs === undefined) {
return colors.gray("(—)");
}
return colors.gray(`(${durationMs.toFixed(0)}ms)`);
}
/**
* Temporarily replaces quoted strings so other patterns do not match inside them.
* @param {string} text
* @returns {{ text: string, restore: (value: string) => string }}
*/
function stashQuotedStrings(text) {
/** @type {string[]} */
const quoted = [];
const stashed = text.replace(QUOTED_STRING_RE, (match) => {
quoted.push(match);
return `${STASH}${quoted.length - 1}${STASH}`;
});
return {
text: stashed,
restore: (value) =>
value.replace(new RegExp(`${STASH}(\\d+)${STASH}`, "g"), (_, index) => colors.green(quoted[Number(index)] ?? "")),
};
}
/**
* Colorizes the output of an identifier.
* @param {string} part
* @returns {string}
*/
function colorizeIdentifier(part) {
return part === "null" ? colors.dim("null") : colors.cyan(part);
}
/**
* Applies generic inline highlights to plain text.
* @param {string} text
* @returns {string}
*/
function colorizeInline(text) {
const { text: stashed, restore } = stashQuotedStrings(text);
let result = stashed.replace(FILE_PATH_RE, (match) => colors.cyan(match));
result = result.replace(/\b(TS\d+)\b/g, (_, code) => colors.red.bold(code));
result = result.replace(/\bnull\b/g, (match) => colors.dim(match));
return restore(result);
}
/**
* Colorizes the output of a prefixed line.
* @param {string} line
* @param {string} prefix
* @param {(value: string) => string} stylePrefix
* @returns {string}
*/
function colorizePrefixedLine(line, prefix, stylePrefix) {
const match = line.match(/^(\s*)(.+)$/);
if (!match) {
return line;
}
const [, indent, body] = match;
const prefixMatch = body.match(new RegExp(`^(${prefix})(\\s*)(.*)$`, "i"));
if (!prefixMatch) {
return line;
}
const [, label, spacing, rest] = prefixMatch;
return `${indent}${stylePrefix(label)}${spacing}${colorizeInline(rest)}`;
}
// #endregion
// #region Tool-specific line formatters
/**
* Colorizes the output of an AssetCheck error line.
* @param {string} line
* @returns {string}
*/
function colorizeAssetErrorLine(line) {
const match = line.match(
/^(\s*)ERROR:\s*(.+):\s*(Missing asset)\s+("(?:[^"\\]|\\.)*")\s*$/i,
);
if (!match) {
return colorizePrefixedLine(line, "ERROR:", colors.red.bold);
}
const [, indent, ids, label, quotedPath] = match;
const coloredIds = ids.split(":").map(colorizeIdentifier).join(colors.dim(":"));
return `${indent}${colors.red.bold("ERROR:")} ${coloredIds}${colors.dim(":")} ${colors.dim(label)} ${colors.green(quotedPath)}`;
}
/**
* Colorizes the output of a TypeScript check result.
* @param {string} line
* @returns {string}
*/
function colorizeTypeScriptLine(line) {
const parenMatch = line.match(/^(.+?\(\d+,\d+\)):\s*(error|warning)\s+(TS\d+):\s*(.*)$/i);
if (parenMatch) {
const [, location, severity, code, message] = parenMatch;
const style = severityStyle(severity);
return `${colors.cyan(location)}: ${style(severity)} ${colors.red.bold(code)}: ${colorizeInline(message)}`;
}
const colonMatch = line.match(/^(.+?):(\d+):(\d+)\s*-\s*(error|warning)\s+(TS\d+):\s*(.*)$/i);
if (colonMatch) {
const [, file, lineNo, column, severity, code, message] = colonMatch;
const style = severityStyle(severity);
return `${colors.cyan(file)}:${colors.yellow(`${lineNo}:${column}`)} - ${style(severity)} ${colors.red.bold(code)}: ${colorizeInline(message)}`;
}
if (/error TS\d+:/i.test(line)) {
return colorizePrefixedLine(line, "error TS\\d+:", colors.red);
}
return line;
}
/**
* Colorizes the output of an ESLint check result.
* @param {string} line
* @returns {string}
*/
function colorizeEslintLine(line) {
const detailMatch = line.match(
/^(\s*)(\d+):(\d+)\s+(error|warning)\s+(.+?)\s+([@\w-]+(?:\/[\w-]+)?)\s*$/,
);
if (detailMatch) {
const [, indent, lineNo, column, severity, message, rule] = detailMatch;
const style = severityStyle(severity);
return `${indent}${colors.yellow(`${lineNo}:${column}`)} ${style(severity)} ${colorizeInline(message)} ${colors.magenta.dim(rule)}`;
}
const trimmed = line.trim();
if (/^(?:\.\.?\/|\/|[A-Za-z]:)/.test(trimmed) && FILE_PATH_RE.test(trimmed)) {
return colors.cyan.underline(trimmed);
}
return line;
}
/**
* Colorizes the output of a Prettier check result.
* @param {string} line
* @returns {string}
*/
function colorizePrettierLine(line) {
const match = line.match(/^(\[(?:warn|error)\])\s+(.+)$/i);
if (match) {
const [, tag, file] = match;
const tagColor = /error/i.test(tag) ? colors.red : colors.yellow;
return `${tagColor(tag)} ${colors.cyan(file)}`;
}
if (/^Checking formatting\.\.\.$/i.test(line.trim())) {
return colors.dim(line);
}
return line;
}
// #endregion
// #region Output colorization
/**
* Colorizes a single line of check output.
* @param {string} line
* @returns {string}
*/
function colorizeLine(line) {
// we don't want to colorize colored output (e.g. AssetCheck, FileCase)
if (outputHasColor(line)) {
return line;
}
if (/^\s*ERROR:/i.test(line)) {
return colorizeAssetErrorLine(line);
}
if (/^\s*(WARNING|WARN):/i.test(line)) {
return colorizePrefixedLine(line, "(WARNING|WARN):", colors.yellow.bold);
}
if (/\berror TS\d+:/i.test(line) || /\(\d+,\d+\):/.test(line)) {
const colored = colorizeTypeScriptLine(line);
if (colored !== line) {
return colored;
}
}
if (/^\s*\d+:\d+\s+(error|warning)\s+/i.test(line)) {
const colored = colorizeEslintLine(line);
if (colored !== line) {
return colored;
}
}
if (/^\[(?:warn|error)\]/i.test(line.trim()) || /^Checking formatting/i.test(line.trim())) {
const colored = colorizePrettierLine(line);
if (colored !== line) {
return colored;
}
}
const trimmed = line.trim();
if (trimmed && FILE_PATH_RE.test(trimmed) && !/[:]\s/.test(trimmed)) {
return colors.cyan.underline(trimmed);
}
return colorizeInline(line);
}
/**
* Colorizes the output of a check result.
* @param {string} output
* @returns {string}
*/
export function colorizeOutput(output) {
// not sure if this can happen, but anyway
if (!output) {
return colors.dim("(no output)");
}
return output.split("\n").map(colorizeLine).join("\n");
}
/**
* Returns whether a line of check output is a warning (not an error).
* @param {string} line
* @returns {boolean}
*/
export function lineHasWarning(line) {
if (!line.trim()) {
return false;
}
if (/^\s*(WARNING|WARN):/i.test(line)) {
return true;
}
if (/:\s*warning\s+TS\d+:/i.test(line) || / - warning TS\d+:/i.test(line)) {
return true;
}
if (/^\s*\d+:\d+\s+warning\s+/i.test(line)) {
return true;
}
if (/^\[warn\]/i.test(line.trim())) {
return true;
}
if (/\(\d+ errors?, \d+ warnings?\)/i.test(line) || /^\s*✖.*\b\d+\s+warning/i.test(line)) {
return true;
}
return false;
}
/**
* Returns whether check output contains any warnings.
* @param {string} output
* @returns {boolean}
*/
export function outputHasWarnings(output) {
return output.split("\n").some(lineHasWarning);
}
// #endregion
// #region Summary & status formatting
/**
* Formats the name of a check.
* @param {string} name
* @returns {string}
*/
export function formatCheckName(name) {
const colon = name.indexOf(":");
if (colon === -1) {
return colors.bold(name);
}
return colors.dim(name.slice(0, colon + 1)) + colors.bold(name.slice(colon + 1));
}
/**
* Formats the status line for a check result.
* @param {CheckResult} result
* @returns {string}
*/
export function formatStatusLine(result) {
/** @type {string} */
const icon = result.code !== 0
? colors.red("✗")
: outputHasWarnings(result.output)
? colors.yellow("⚠")
: colors.green("✓");
return ` ${icon} ${formatCheckName(result.name)} ${formatDuration(result.duration)}`;
}
/**
* Formats the summary counts for the check results.
* @param {number} passed
* @param {number} failed
* @returns {string}
*/
export function formatSummaryCounts(passed, failed) {
const failedText = failed ? colors.red(String(failed)) : colors.green("0");
return colors.bold(`Summary: ${colors.green(String(passed))} passed, ${failedText} failed`);
}
/**
* Formats the header for the running checks.
* @param {number} count
* @returns {string}
*/
export function formatRunningHeader(count) {
return colors.bold(`Running ${colors.cyan(String(count))} checks…`);
}
/**
* Formats the heading for a failed check.
* @param {string} name
* @returns {string}
*/
export function formatFailureHeading(name) {
return colors.red.bold.underline(name);
}
/**
* Formats the heading for a passed check that reported warnings.
* @param {string} name
* @returns {string}
*/
export function formatWarningHeading(name) {
return colors.yellow.bold.underline(name);
}
/** @returns {string} */
export function formatDivider() {
return colors.bold(DIVIDER);
}
// #endregion

View file

@ -108,7 +108,7 @@ export const errorState = {
* @param {string} text The error
*/
export function error(text) {
console.log("ERROR:", text);
console.error("ERROR:", text);
process.exitCode = 1;
errorState.local = true;
errorState.global = true;

View file

@ -1,6 +1,8 @@
{
"compilerOptions": {
"noEmit": true,
"incremental": true,
"tsBuildInfoFile": "../../.cache/tsc-asset.tsbuildinfo",
"module": "commonjs",
"target": "ES2021",
"lib": ["es2021", "dom", "DOM.Iterable"],

View file

@ -8,6 +8,8 @@
"types": [],
"strict": false,
"skipLibCheck": true,
"incremental": true,
"tsBuildInfoFile": "../../.cache/tsc-csvtypescript.tsbuildinfo",
"plugins": [
{
"name": "typescript-strict-plugin"

View file

@ -1,6 +1,8 @@
{
"compilerOptions": {
"noEmit": true,
"incremental": true,
"tsBuildInfoFile": "../../.cache/tsc-expensive.tsbuildinfo",
"module": "commonjs",
"target": "ES2021",
"lib": ["es2021", "dom", "DOM.Iterable"],

View file

@ -8,22 +8,27 @@
"scripts": {
"changelog:generate": "cd Tools/Node && node GenerateChangelog generate",
"changelog:prepare": "cd Tools/Node && node GenerateChangelog prepare",
"checks": "npm-run-all -c assets:typecheck scripts:typecheck scripts:typecheck-expensive assets:check scripts:lint styles:prettier assets:lint files:case files:csvtypescript",
"checks:cheap": "npm-run-all -c assets:typecheck scripts:typecheck assets:check scripts:lint assets:lint files:case files:csvtypescript",
"checks:expensive": "npm-run-all -c checks",
"checks": "node --unhandled-rejections=strict Tools/Node/Checks.js",
"checks:cheap": "node --unhandled-rejections=strict Tools/Node/Checks.js --cheap",
"checks:expensive": "node --unhandled-rejections=strict Tools/Node/Checks.js",
"assets:checks": "run-p -cs --aggregate-output assets:typecheck assets:check assets:lint",
"assets:typecheck-check": "run-p -cs --aggregate-output assets:typecheck assets:check",
"assets:clean": "gulp --gulpfile Tools/Node/gulpfile.js clean",
"assets:minify": "gulp --gulpfile Tools/Node/gulpfile.js assetMinify --max-old-space-size=8192",
"assets:prettier": "prettier --write Assets/Female3DCG/Female3DCG.js Assets/Female3DCG/Female3DCGExtended.js && git diff --exit-code Assets/Female3DCG/Female3DCG.js Assets/Female3DCG/Female3DCGExtended.js",
"assets:eslint": "eslint Assets/Female3DCG/Female3DCG.js Assets/Female3DCG/Female3DCGExtended.js",
"assets:lint": "npm run assets:prettier && npm run assets:eslint",
"assets:prettier": "prettier --check Assets/Female3DCG/Female3DCG.js Assets/Female3DCG/Female3DCGExtended.js",
"assets:prettier:fix": "prettier --write Assets/Female3DCG/Female3DCG.js Assets/Female3DCG/Female3DCGExtended.js",
"assets:eslint": "eslint --cache --cache-location .cache/eslint Assets/Female3DCG/Female3DCG.js Assets/Female3DCG/Female3DCGExtended.js",
"assets:lint": "run-s -cn assets:prettier assets:eslint",
"assets:lint:fix": "prettier-eslint --write Assets/Female3DCG/Female3DCG.js Assets/Female3DCG/Female3DCGExtended.js",
"assets:check": "cd Tools/Node && node --unhandled-rejections=strict AssetCheck",
"assets:typecheck": "cd ../ && tsc -p BondageClub/Tools/Node/tsconfig-assetcheck.json",
"assets:typecheck": "tsc -p Tools/Node/tsconfig-assetcheck.json",
"assets:posemapping": "cd Tools/Node && node --unhandled-rejections=strict PoseMapping",
"scripts:lint": "eslint \"Scripts/**/*.js\" \"Screens/**/*.js\" \"Tools/**/*.js\" \"Backgrounds/Backgrounds.js\"",
"scripts:lint:fix": "eslint --fix \"Scripts/**/*.js\" \"Screens/**/*.js\" \"Tools/**/*.js\" \"Backgrounds/Backgrounds.js\"",
"scripts:typecheck": "cd ../ && tsc -p BondageClub/tsconfig.json && tsc-strict -p BondageClub/tsconfig.json",
"scripts:typecheck-expensive": "cd ../ && tsc -p BondageClub/Tools/Node/tsconfig-typecheckexpensive.json",
"scripts:lint": "eslint --cache --cache-location .cache/eslint \"Scripts/**/*.js\" \"Screens/**/*.js\" \"Tools/**/*.js\" \"Backgrounds/Backgrounds.js\"",
"scripts:lint:fix": "eslint --cache --cache-location .cache/eslint --fix \"Scripts/**/*.js\" \"Screens/**/*.js\" \"Tools/**/*.js\" \"Backgrounds/Backgrounds.js\"",
"scripts:typecheck": "run-p -cs --aggregate-output scripts:typecheck:tsc scripts:typecheck:strict",
"scripts:typecheck:tsc": "tsc -p tsconfig.json",
"scripts:typecheck:strict": "CI=1 tsc-strict -p tsconfig.json",
"scripts:typecheck-expensive": "tsc -p Tools/Node/tsconfig-typecheckexpensive.json",
"styles:prettier": "prettier --check --print-width 120 CSS/",
"styles:prettier:fix": "prettier --write --print-width 120 CSS/",
"files:case": "cd Tools/Node && node --unhandled-rejections=strict FileCase",

View file

@ -5,6 +5,8 @@
"lib": ["es2021", "dom", "DOM.Iterable"],
"checkJs": true,
"noEmit": true,
"incremental": true,
"tsBuildInfoFile": ".cache/tsc-main.tsbuildinfo",
"types": [],
"strict": false,
"skipLibCheck": true,