import { execSync, spawnSync } from "node:child_process"; import path from "node:path"; import { fileURLToPath } from "node:url"; interface IValidateChangedConfig { pathFilters: { ignore: RegExp[]; include: RegExp[]; }; fileMatchers: { code: RegExp; style: RegExp; docs: RegExp; packageRoot: RegExp; storybookRoot: RegExp; }; triggers: { lintConfigFiles: string[]; heavyConfigFiles: string[]; docsScripts: string[]; }; } export interface ICheckContext { hasPackageCode: boolean; hasStorybook: boolean; hasHeavyConfig: boolean; hasDocsScript: boolean; hasLintScope: boolean; hasOnlyDocs: boolean; } type TMode = "staged" | "working" | "base"; export type TRisk = "low" | "medium" | "high"; interface IOptions { mode: TMode; baseRef: string; isRun: boolean; isExplain: boolean; risk: TRisk; } const args = process.argv.slice(2); const getArgValue = (key: string): string | undefined => { const byEq = args.find((item) => item.startsWith(`${key}=`)); if (byEq) { return byEq.split("=").slice(1).join("="); } const idx = args.findIndex((item) => item === key); if (idx >= 0 && args[idx + 1]) { return args[idx + 1]; } return undefined; }; const hasArg = (key: string): boolean => { return args.includes(key) || args.some((item) => item.startsWith(`${key}=`)); }; const options: IOptions = { mode: (getArgValue("--mode") as TMode) || "working", baseRef: getArgValue("--base") || "origin/main", isRun: hasArg("--run") || !hasArg("--dry-run"), isExplain: hasArg("--explain"), risk: (getArgValue("--risk") as TRisk) || "medium", }; if (![ "low", "medium", "high" ].includes(options.risk)) { console.error(`[validate::changed] invalid --risk value "${options.risk}". Allowed: low|medium|high`); process.exit(1); } const runShell = (command: string): string => { return execSync(command, { stdio: [ "ignore", "pipe", "pipe" ], encoding: "utf8", }).trim(); }; const toFiles = (raw: string): string[] => { return raw .split("\n") .map((item) => item.trim()) .filter(Boolean) .map((item) => item.replaceAll("\\", "/")); }; const uniq = (items: string[]): string[] => { return Array.from(new Set(items)); }; const isMatchedBy = (file: string, patterns: RegExp[]): boolean => { return patterns.some((pattern) => pattern.test(file)); }; const isIncludedByFilters = (file: string): boolean => { if (isMatchedBy(file, validateChangedConfig.pathFilters.include)) { return true; } if (isMatchedBy(file, validateChangedConfig.pathFilters.ignore)) { return false; } return true; }; const getChangedFiles = (opts: IOptions): string[] => { const applyFilters = (items: string[]): string[] => { return items.filter(isIncludedByFilters); }; switch (opts.mode) { case "staged": { return applyFilters(toFiles(runShell("git diff --name-only --diff-filter=ACMR --cached"))); } case "base": { return applyFilters(toFiles(runShell(`git diff --name-only --diff-filter=ACMR ${opts.baseRef}...HEAD`))); } case "working": default: { const tracked = toFiles(runShell("git diff --name-only --diff-filter=ACMR")); const untracked = toFiles(runShell("git ls-files --others --exclude-standard")); return applyFilters(uniq([ ...tracked, ...untracked ])); } } }; const isCodeFile = (file: string): boolean => { return validateChangedConfig.fileMatchers.code.test(file); }; const isStyleFile = (file: string): boolean => { return validateChangedConfig.fileMatchers.style.test(file); }; const isDocFile = (file: string): boolean => { return validateChangedConfig.fileMatchers.docs.test(file); }; const isPackageCoreFile = (file: string): boolean => { return validateChangedConfig.fileMatchers.packageRoot.test(file); }; const isStorybookFile = (file: string): boolean => { return validateChangedConfig.fileMatchers.storybookRoot.test(file); }; const isLintConfigFile = (file: string): boolean => { return validateChangedConfig.triggers.lintConfigFiles.includes(file); }; const isHeavyConfigFile = (file: string): boolean => { return validateChangedConfig.triggers.heavyConfigFiles.includes(file); }; const isDocsScriptFile = (file: string): boolean => { return validateChangedConfig.triggers.docsScripts.includes(file); }; const getMatchingFiles = (files: string[], predicate: (file: string) => boolean): string[] => { return files.filter(predicate); }; const printExplainBlock = (title: string, files: string[]): void => { if (!files.length) { return; } const preview = files.slice(0, 8); console.debug(` - ${title}: ${files.length}`); preview.forEach((file) => console.debug(` • ${file}`)); if (files.length > preview.length) { console.debug(` • ... +${files.length - preview.length} more`); } }; type TRiskPolicy = { checks: string[]; notes: string[]; }; /** * Добавляет описание рисков * @returns {TRiskPolicy} */ export const applyRiskPolicy = ( checks: string[], reasons: Record, context: ICheckContext, risk: TRisk ): TRiskPolicy => { const notes: string[] = []; const current = new Set(checks); if (risk === "medium") { return { checks: checks, notes }; } if (risk === "low") { [ "dpdm", "build::package", "build::storybook" ].forEach((name) => { if (current.delete(name)) { notes.push(`low risk: removed "${name}"`); } }); if (!current.has("lint") && context.hasLintScope) { current.add("lint"); notes.push('low risk: added "lint"'); } if (!current.has("test") && (context.hasPackageCode || context.hasHeavyConfig)) { current.add("test"); reasons.test = uniq([ ...(reasons.test ?? []), ...(reasons.lint ?? []) ]); notes.push('low risk: added "test" for package/heavy-config safety'); } return { checks: uniq(Array.from(current)), notes }; } // high if (context.hasLintScope && !current.has("lint")) { current.add("lint"); notes.push('high risk: added "lint"'); } if ((context.hasPackageCode || context.hasHeavyConfig || context.hasStorybook || context.hasDocsScript) && !current.has("test")) { current.add("test"); notes.push('high risk: added "test"'); } if ((context.hasPackageCode || context.hasHeavyConfig) && !current.has("dpdm")) { current.add("dpdm"); notes.push('high risk: added "dpdm"'); } if ((context.hasPackageCode || context.hasHeavyConfig || context.hasStorybook || context.hasDocsScript) && !current.has("build::package")) { current.add("build::package"); notes.push('high risk: added "build::package"'); } if ((context.hasPackageCode || context.hasHeavyConfig || context.hasStorybook || context.hasDocsScript) && !current.has("build::storybook")) { current.add("build::storybook"); notes.push('high risk: added "build::storybook"'); } return { checks: uniq(Array.from(current)), notes }; }; type TChecks = { checks: string[]; reasons: Record; context: ICheckContext; }; /** * Возвращает проверки * @returns {TChecks} */ export const getChecks = ( files: string[] ): TChecks => { const packageFiles = getMatchingFiles(files, (file) => isPackageCoreFile(file) && (isCodeFile(file) || isStyleFile(file))); const storybookFiles = getMatchingFiles(files, (file) => isStorybookFile(file) && (isCodeFile(file) || isStyleFile(file) || isDocFile(file))); const lintConfigFiles = getMatchingFiles(files, isLintConfigFile); const heavyConfigFiles = getMatchingFiles(files, isHeavyConfigFile); const docsScriptFiles = getMatchingFiles(files, isDocsScriptFile); const lintScopeFiles = uniq([ ...getMatchingFiles(files, (file) => isCodeFile(file) || isStyleFile(file)), ...lintConfigFiles, ]); const hasPackageCode = packageFiles.length > 0; const hasStorybook = storybookFiles.length > 0; const hasHeavyConfig = heavyConfigFiles.length > 0; const hasDocsScript = docsScriptFiles.length > 0; const hasLintScope = lintScopeFiles.length > 0; const hasOnlyDocs = files.every((file) => isDocFile(file) && !isStorybookFile(file)); const context: ICheckContext = { hasPackageCode, hasStorybook, hasHeavyConfig, hasDocsScript, hasLintScope, hasOnlyDocs, }; const reasons: Record = {}; if (hasOnlyDocs) { return { checks: [], reasons, context }; } const checks: string[] = []; if (hasLintScope) { checks.push("lint"); reasons.lint = lintScopeFiles; } if (hasPackageCode || hasHeavyConfig) { checks.push("test", "dpdm", "build::package"); reasons.test = [ ...packageFiles, ...heavyConfigFiles ]; reasons.dpdm = [ ...packageFiles, ...heavyConfigFiles ]; reasons["build::package"] = [ ...packageFiles, ...heavyConfigFiles ]; } if (hasStorybook || hasDocsScript || hasHeavyConfig) { checks.push("build::storybook"); reasons["build::storybook"] = [ ...storybookFiles, ...docsScriptFiles, ...heavyConfigFiles ]; } return { checks: uniq(checks), reasons, context }; }; export const validateChangedConfig: IValidateChangedConfig = { pathFilters: { ignore: [ /^dist\//, /^build\//, /^node_modules\//, /^\.git\//, /^coverage\//, ], include: [], }, fileMatchers: { code: /\.(ts|tsx|js|jsx|mjs|cjs)$/i, style: /\.(pcss|css)$/i, docs: /\.(md|mdx)$/i, packageRoot: /^src\/package\//, storybookRoot: /^src\/storybook\//, }, triggers: { lintConfigFiles: [ "package.json", "tsconfig.json", "eslint.config.ts", "stylelint.config.mjs", "babel.config.json", ".lintstagedrc.json", "commitlint.config.ts", "src/package/rollup.config.ts", "src/storybook/main.ts", ], heavyConfigFiles: [ "tsconfig.json", "src/package/rollup.config.ts", "src/storybook/main.ts", ], docsScripts: [ "src/lib/scripts/docs.ts", ], }, }; const runNpmScript = (name: string): void => { const cmd = process.platform === "win32" ? "npm.cmd" : "npm"; const result = spawnSync(cmd, [ "run", name ], { stdio: "inherit", }); if (result.status !== 0) { process.exit(result.status || 1); } }; const run = (): void => { const files = getChangedFiles(options); const { checks: baseChecks, reasons, context } = getChecks(files); const { checks, notes } = applyRiskPolicy(baseChecks, reasons, context, options.risk); console.debug(`[validate::changed] mode=${options.mode}${options.mode === "base" ? ` base=${options.baseRef}` : ""} risk=${options.risk}`); console.debug(`[validate::changed] files=${files.length}`); files.forEach((file) => console.debug(` - ${file}`)); if (!checks.length) { console.debug("[validate::changed] No relevant checks required."); process.exit(0); } console.debug(`[validate::changed] checks=${checks.join(", ")}`); if (options.isExplain) { console.debug("[validate::changed] explain:"); notes.forEach((note) => console.debug(` - policy: ${note}`)); checks.forEach((checkName) => { printExplainBlock(checkName, uniq(reasons[checkName] ?? [])); }); } if (!options.isRun) { console.debug("[validate::changed] dry-run mode enabled. Nothing executed."); process.exit(0); } checks.forEach(runNpmScript); process.exit(0); }; const isDirectExecution = (): boolean => { const argvPath = process.argv[1]; if (!argvPath) { return false; } const currentFile = fileURLToPath(import.meta.url); return path.resolve(argvPath) === path.resolve(currentFile); }; if (isDirectExecution()) { try { run(); } catch (err) { const error = err as Error; console.error("[validate::changed] failed"); console.error(error.message); process.exit(1); } }