gengx
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
import type { CAC } from 'cac';
|
||||
|
||||
import { access, mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { extname, join } from 'node:path';
|
||||
|
||||
import { execa, getStagedFiles } from '@vben/node-utils';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const circularScannerCli =
|
||||
require.resolve('circular-dependency-scanner/dist/cli.js');
|
||||
|
||||
// 默认配置
|
||||
const DEFAULT_CONFIG = {
|
||||
allowedExtensions: ['.cjs', '.js', '.jsx', '.mjs', '.ts', '.tsx', '.vue'],
|
||||
ignoreDirs: [
|
||||
'dist',
|
||||
'.turbo',
|
||||
'output',
|
||||
'.cache',
|
||||
'scripts',
|
||||
'internal',
|
||||
'packages/effects/request/src/',
|
||||
'packages/@core/ui-kit/menu-ui/src/',
|
||||
'packages/@core/ui-kit/popup-ui/src/',
|
||||
],
|
||||
threshold: 0, // 循环依赖的阈值
|
||||
} as const;
|
||||
|
||||
// 类型定义
|
||||
type CircularDependencyResult = string[];
|
||||
|
||||
interface CheckCircularConfig {
|
||||
allowedExtensions?: string[];
|
||||
ignoreDirs?: string[];
|
||||
threshold?: number;
|
||||
}
|
||||
|
||||
interface CommandOptions {
|
||||
config?: CheckCircularConfig;
|
||||
staged: boolean;
|
||||
verbose: boolean;
|
||||
}
|
||||
|
||||
// 缓存机制
|
||||
const cache = new Map<string, CircularDependencyResult[]>();
|
||||
|
||||
async function detectCircularDependencies({
|
||||
cwd,
|
||||
ignorePattern,
|
||||
staged,
|
||||
}: {
|
||||
cwd: string;
|
||||
ignorePattern: string;
|
||||
staged: boolean;
|
||||
}): Promise<CircularDependencyResult[]> {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), 'vsh-check-circular-'));
|
||||
const outputFile = join(tempDir, 'circles.json');
|
||||
|
||||
try {
|
||||
const args = [circularScannerCli, cwd, '--output', outputFile];
|
||||
|
||||
if (staged) {
|
||||
args.push('--absolute');
|
||||
}
|
||||
|
||||
args.push('--ignore', ignorePattern);
|
||||
|
||||
await execa(process.execPath, args, {
|
||||
cwd,
|
||||
});
|
||||
|
||||
await access(outputFile);
|
||||
const output = await readFile(outputFile, 'utf8');
|
||||
return JSON.parse(output) as CircularDependencyResult[];
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
await rm(tempDir, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化循环依赖的输出
|
||||
* @param circles - 循环依赖结果
|
||||
*/
|
||||
function formatCircles(circles: CircularDependencyResult[]): void {
|
||||
if (circles.length === 0) {
|
||||
console.log('✅ No circular dependencies found');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('⚠️ Circular dependencies found:');
|
||||
circles.forEach((circle, index) => {
|
||||
console.log(`\nCircular dependency #${index + 1}:`);
|
||||
circle.forEach((file) => console.log(` → ${file}`));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查项目中的循环依赖
|
||||
* @param options - 检查选项
|
||||
* @param options.staged - 是否只检查暂存区文件
|
||||
* @param options.verbose - 是否显示详细信息
|
||||
* @param options.config - 自定义配置
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
async function checkCircular({
|
||||
config = {},
|
||||
staged,
|
||||
verbose,
|
||||
}: CommandOptions): Promise<void> {
|
||||
try {
|
||||
// 合并配置
|
||||
const finalConfig = {
|
||||
...DEFAULT_CONFIG,
|
||||
...config,
|
||||
};
|
||||
|
||||
// 生成忽略模式
|
||||
const ignorePattern = `**/{${finalConfig.ignoreDirs.join(',')}}/**`;
|
||||
|
||||
// 检查缓存
|
||||
const cacheKey = `${staged}-${process.cwd()}-${ignorePattern}`;
|
||||
if (cache.has(cacheKey)) {
|
||||
const cachedResults = cache.get(cacheKey);
|
||||
if (cachedResults && verbose) {
|
||||
formatCircles(cachedResults);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 检测循环依赖
|
||||
const results = await detectCircularDependencies({
|
||||
cwd: process.cwd(),
|
||||
ignorePattern,
|
||||
staged,
|
||||
});
|
||||
|
||||
if (staged) {
|
||||
let files = await getStagedFiles();
|
||||
const allowedExtensions = new Set(finalConfig.allowedExtensions);
|
||||
|
||||
// 过滤文件列表
|
||||
files = files.filter((file) => allowedExtensions.has(extname(file)));
|
||||
|
||||
const circularFiles: CircularDependencyResult[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
for (const result of results) {
|
||||
const resultFiles = result.flat();
|
||||
if (resultFiles.includes(file)) {
|
||||
circularFiles.push(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新缓存
|
||||
cache.set(cacheKey, circularFiles);
|
||||
if (verbose) {
|
||||
formatCircles(circularFiles);
|
||||
}
|
||||
} else {
|
||||
// 更新缓存
|
||||
cache.set(cacheKey, results);
|
||||
if (verbose) {
|
||||
formatCircles(results);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果发现循环依赖,只输出警告信息
|
||||
if (results.length > 0) {
|
||||
console.log(
|
||||
'\n⚠️ Warning: Circular dependencies found, please check and fix',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'❌ Error checking circular dependencies:',
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 定义检查循环依赖的命令
|
||||
* @param cac - CAC实例
|
||||
*/
|
||||
function defineCheckCircularCommand(cac: CAC): void {
|
||||
cac
|
||||
.command('check-circular')
|
||||
.option('--staged', 'Only check staged files')
|
||||
.option('--verbose', 'Show detailed information')
|
||||
.option('--threshold <number>', 'Threshold for circular dependencies', {
|
||||
default: 0,
|
||||
})
|
||||
.option('--ignore-dirs <dirs>', 'Directories to ignore, comma separated')
|
||||
.usage('Analyze project circular dependencies')
|
||||
.action(async ({ ignoreDirs, staged, threshold, verbose }) => {
|
||||
const config: CheckCircularConfig = {
|
||||
threshold: Number(threshold),
|
||||
...(ignoreDirs && { ignoreDirs: ignoreDirs.split(',') }),
|
||||
};
|
||||
|
||||
await checkCircular({
|
||||
config,
|
||||
staged,
|
||||
verbose: verbose ?? true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export { type CheckCircularConfig, defineCheckCircularCommand };
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { CAC } from 'cac';
|
||||
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
import { execa } from '@vben/node-utils';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const knipMain = require.resolve('knip');
|
||||
const knipCli = join(dirname(knipMain), '..', 'bin', 'knip.js');
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
ignore: ['dist/**', 'docs/**', 'node_modules/**', 'public/**'],
|
||||
ignoreBinaries: [] as string[],
|
||||
ignoreDependencies: [
|
||||
'@iconify/json',
|
||||
'@vben-core/design',
|
||||
'@vben/commitlint-config',
|
||||
'@vben/eslint-config',
|
||||
'@vben/stylelint-config',
|
||||
'@vben/tailwind-config',
|
||||
'@vben/vite-config',
|
||||
'@vben/oxlint-config',
|
||||
'playwright',
|
||||
'rimraf',
|
||||
'tailwindcss',
|
||||
],
|
||||
ignoreWorkspaces: ['internal/lint-configs/*', 'scripts/*'],
|
||||
};
|
||||
|
||||
interface KnipDependency {
|
||||
col: number;
|
||||
line: number;
|
||||
name: string;
|
||||
pos: number;
|
||||
}
|
||||
|
||||
interface KnipFileIssue {
|
||||
dependencies: KnipDependency[];
|
||||
devDependencies: KnipDependency[];
|
||||
file: string;
|
||||
optionalPeerDependencies: KnipDependency[];
|
||||
}
|
||||
|
||||
interface KnipResult {
|
||||
issues: KnipFileIssue[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化依赖检查结果
|
||||
* @param result - 依赖检查结果
|
||||
*/
|
||||
function formatResult(result: KnipResult): void {
|
||||
let hasIssues = false;
|
||||
|
||||
for (const issue of result.issues) {
|
||||
const hasDeps = issue.dependencies.length > 0;
|
||||
const hasDevDeps = issue.devDependencies.length > 0;
|
||||
|
||||
if (!hasDeps && !hasDevDeps) {
|
||||
continue;
|
||||
}
|
||||
|
||||
hasIssues = true;
|
||||
console.log(`\n📦 ${issue.file}`);
|
||||
|
||||
if (hasDeps) {
|
||||
console.log('⚠️ Unused dependencies:');
|
||||
for (const dep of issue.dependencies) {
|
||||
console.log(` - ${dep.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasDevDeps) {
|
||||
console.log('⚠️ Unused devDependencies:');
|
||||
for (const dep of issue.devDependencies) {
|
||||
console.log(` - ${dep.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasIssues) {
|
||||
console.log('\n✅ Dependency check completed, no issues found');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行依赖检查
|
||||
*/
|
||||
async function runKnipCheck(): Promise<void> {
|
||||
const cwd = process.cwd();
|
||||
const tempDir = await mkdtemp(join(tmpdir(), 'vsh-check-dep-'));
|
||||
const configFile = join(tempDir, 'knip.json');
|
||||
|
||||
try {
|
||||
await writeFile(configFile, JSON.stringify(DEFAULT_CONFIG));
|
||||
|
||||
const args = [
|
||||
knipCli,
|
||||
'--config',
|
||||
configFile,
|
||||
'--include',
|
||||
'dependencies',
|
||||
'--reporter',
|
||||
'json',
|
||||
'--no-config-hints',
|
||||
];
|
||||
|
||||
await execa(process.execPath, args, { cwd });
|
||||
console.log('\n✅ Dependency check completed, no issues found');
|
||||
} catch (error: unknown) {
|
||||
const execaError = error as {
|
||||
exitCode?: number;
|
||||
stdout?: string;
|
||||
};
|
||||
|
||||
if (execaError.exitCode === 1 && execaError.stdout) {
|
||||
const result: KnipResult = JSON.parse(execaError.stdout);
|
||||
formatResult(result);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(
|
||||
'❌ Dependency check failed:',
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
} finally {
|
||||
await rm(tempDir, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 定义依赖检查命令
|
||||
* @param cac - CAC实例
|
||||
*/
|
||||
function defineCheckDepCommand(cac: CAC): void {
|
||||
cac
|
||||
.command('check-dep')
|
||||
.usage('Analyze project dependencies using knip')
|
||||
.action(async () => {
|
||||
await runKnipCheck();
|
||||
});
|
||||
}
|
||||
|
||||
export { defineCheckDepCommand };
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { CAC } from 'cac';
|
||||
|
||||
import { join, relative } from 'node:path';
|
||||
|
||||
import {
|
||||
colors,
|
||||
consola,
|
||||
findMonorepoRoot,
|
||||
formatFile,
|
||||
getPackages,
|
||||
gitAdd,
|
||||
outputJSON,
|
||||
toPosixPath,
|
||||
} from '@vben/node-utils';
|
||||
|
||||
const CODE_WORKSPACE_FILE = join('vben-admin.code-workspace');
|
||||
|
||||
interface CodeWorkspaceCommandOptions {
|
||||
autoCommit?: boolean;
|
||||
spaces?: number;
|
||||
}
|
||||
|
||||
async function createCodeWorkspace({
|
||||
autoCommit = false,
|
||||
spaces = 2,
|
||||
}: CodeWorkspaceCommandOptions) {
|
||||
const { packages, rootDir } = await getPackages();
|
||||
|
||||
let folders = packages.map((pkg) => {
|
||||
const { dir, packageJson } = pkg;
|
||||
return {
|
||||
name: packageJson.name,
|
||||
path: toPosixPath(relative(rootDir, dir)),
|
||||
};
|
||||
});
|
||||
|
||||
folders = folders.filter(Boolean);
|
||||
|
||||
const monorepoRoot = findMonorepoRoot();
|
||||
const outputPath = join(monorepoRoot, CODE_WORKSPACE_FILE);
|
||||
await outputJSON(outputPath, { folders }, spaces);
|
||||
|
||||
await formatFile(outputPath);
|
||||
if (autoCommit) {
|
||||
await gitAdd(CODE_WORKSPACE_FILE, monorepoRoot);
|
||||
}
|
||||
}
|
||||
|
||||
async function runCodeWorkspace({
|
||||
autoCommit,
|
||||
spaces,
|
||||
}: CodeWorkspaceCommandOptions) {
|
||||
await createCodeWorkspace({
|
||||
autoCommit,
|
||||
spaces,
|
||||
});
|
||||
if (autoCommit) {
|
||||
return;
|
||||
}
|
||||
consola.log('');
|
||||
consola.success(colors.green(`${CODE_WORKSPACE_FILE} is updated!`));
|
||||
consola.log('');
|
||||
}
|
||||
|
||||
function defineCodeWorkspaceCommand(cac: CAC) {
|
||||
cac
|
||||
.command('code-workspace')
|
||||
.usage('Update the `.code-workspace` file')
|
||||
.option('--spaces [number]', '.code-workspace JSON file spaces.', {
|
||||
default: 2,
|
||||
})
|
||||
.option('--auto-commit', 'auto commit .code-workspace JSON file.', {
|
||||
default: false,
|
||||
})
|
||||
.action(runCodeWorkspace);
|
||||
}
|
||||
|
||||
export { defineCodeWorkspaceCommand };
|
||||
@@ -0,0 +1,77 @@
|
||||
import { colors, consola } from '@vben/node-utils';
|
||||
|
||||
import { cac } from 'cac';
|
||||
|
||||
import { version } from '../package.json';
|
||||
import { defineCheckCircularCommand } from './check-circular';
|
||||
import { defineCheckDepCommand } from './check-dep';
|
||||
import { defineCodeWorkspaceCommand } from './code-workspace';
|
||||
import { defineLintCommand } from './lint';
|
||||
import { definePubLintCommand } from './publint';
|
||||
|
||||
// 命令描述
|
||||
const COMMAND_DESCRIPTIONS = {
|
||||
'check-circular': 'Check for circular dependencies',
|
||||
'check-dep': 'Check for unused dependencies',
|
||||
'code-workspace': 'Manage VS Code workspace settings',
|
||||
lint: 'Run linting on the project',
|
||||
publint: 'Check package.json files for publishing standards',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Initialize and run the CLI
|
||||
*/
|
||||
async function main(): Promise<void> {
|
||||
try {
|
||||
const vsh = cac('vsh');
|
||||
|
||||
// Register commands
|
||||
defineLintCommand(vsh);
|
||||
definePubLintCommand(vsh);
|
||||
defineCodeWorkspaceCommand(vsh);
|
||||
defineCheckCircularCommand(vsh);
|
||||
defineCheckDepCommand(vsh);
|
||||
|
||||
// Set up CLI
|
||||
vsh.usage('vsh <command> [options]');
|
||||
vsh.help();
|
||||
vsh.version(version);
|
||||
|
||||
// Parse arguments without auto-running to detect unknown commands
|
||||
// Note: cac v7 removed EventEmitter; use matchedCommand after parse instead
|
||||
vsh.parse(undefined, { run: false });
|
||||
|
||||
if (!vsh.matchedCommand && vsh.args.length > 0) {
|
||||
const unknownCmd = String(vsh.args[0]);
|
||||
consola.error(
|
||||
colors.red(`Invalid command: ${unknownCmd}`),
|
||||
'\n',
|
||||
colors.yellow('Available commands:'),
|
||||
'\n',
|
||||
Object.entries(COMMAND_DESCRIPTIONS)
|
||||
.map(([name, desc]) => ` ${colors.cyan(name)} - ${desc}`)
|
||||
.join('\n'),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await vsh.runMatchedCommand();
|
||||
} catch (error) {
|
||||
consola.error(
|
||||
colors.red('An unexpected error occurred:'),
|
||||
'\n',
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the CLI
|
||||
main().catch((error) => {
|
||||
consola.error(
|
||||
colors.red('Failed to start CLI:'),
|
||||
'\n',
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { CAC } from 'cac';
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { availableParallelism, freemem } from 'node:os';
|
||||
|
||||
import { execa } from '@vben/node-utils';
|
||||
|
||||
interface LintCommandOptions {
|
||||
/**
|
||||
* Format lint problem.
|
||||
*/
|
||||
format?: boolean;
|
||||
/**
|
||||
* Number of threads for oxfmt and oxlint (default: 2).
|
||||
*/
|
||||
threads?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* CPU 逻辑核心数阈值 4 核。
|
||||
* - 小于等于该值:视为低配机器,检查命令串行执行,避免瞬时占用飙升。
|
||||
*/
|
||||
const CPU_CORE_THRESHOLD = 4;
|
||||
|
||||
/**
|
||||
* 可用内存阈值 8 GB。
|
||||
* 仅当空余内存大于阈值且当前可用内存大于该值时,
|
||||
* oxfmt / oxlint 默认线程数才提升到 4,否则使用 2。
|
||||
*/
|
||||
const FREE_MEMORY_THRESHOLD = 8 * 1024 ** 3;
|
||||
|
||||
/**
|
||||
* 一条命令:可执行文件名 + 参数数组。
|
||||
*
|
||||
* execa v10 移除了 execaCommand,execa(file, args?, options?) 的第一个参数是
|
||||
* 「可执行文件名」而非整条命令行;且默认不走 shell。因此统一用 [file, args] 数组形式,
|
||||
* 参数逐个传入、无需 shell 转义,跨平台行为一致(避免把整条字符串误当文件名)。
|
||||
*/
|
||||
type Command = [file: string, args: string[]];
|
||||
|
||||
/** 执行单条命令。 */
|
||||
function runCommand([file, args]: Command) {
|
||||
return execa(file, args, { stdio: 'inherit' });
|
||||
}
|
||||
|
||||
/** 将命令还原为可读字符串,用于失败信息展示。 */
|
||||
function formatCommand([file, args]: Command) {
|
||||
return [file, ...args].join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 串行执行所有命令:一次只运行一个进程。
|
||||
* 保证一次能看到所有工具的报错(配置低的机器更友好)。
|
||||
*/
|
||||
async function runSerial(commands: Command[]) {
|
||||
const failed: Command[] = [];
|
||||
|
||||
for (const command of commands) {
|
||||
try {
|
||||
await runCommand(command);
|
||||
} catch {
|
||||
failed.push(command);
|
||||
}
|
||||
}
|
||||
|
||||
if (failed.length > 0) {
|
||||
throw new Error(
|
||||
`Lint failed:\n${failed
|
||||
.map((command) => ` - ${formatCommand(command)}`)
|
||||
.join('\n')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 并行执行所有命令:同时启动全部进程。
|
||||
* 任一进程失败时,强制结束其余仍在运行的进程,避免产生遗漏进程。
|
||||
*/
|
||||
async function runParallel(commands: Command[]) {
|
||||
const subprocesses = commands.map((command) => runCommand(command));
|
||||
|
||||
try {
|
||||
await Promise.all(subprocesses);
|
||||
} catch (error) {
|
||||
for (const subprocess of subprocesses) {
|
||||
try {
|
||||
if (process.platform === 'win32' && subprocess.pid) {
|
||||
execSync(`taskkill /F /T /PID ${subprocess.pid}`, {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
} else {
|
||||
subprocess.kill('SIGKILL');
|
||||
}
|
||||
} catch {
|
||||
// process may have already exited
|
||||
}
|
||||
}
|
||||
await Promise.allSettled(subprocesses);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function runLint({ format, threads }: LintCommandOptions) {
|
||||
// process.env.FORCE_COLOR = '3';
|
||||
const cpuCores = availableParallelism();
|
||||
|
||||
// CPU 核心数充足且可用内存充足时,默认线程数提升到 4,否则维持 2;
|
||||
// 用户通过 --threads 显式指定时优先使用其值。
|
||||
const defaultThreads =
|
||||
cpuCores > CPU_CORE_THRESHOLD && freemem() > FREE_MEMORY_THRESHOLD ? 4 : 2;
|
||||
const threadsArg = `--threads=${threads || defaultThreads}`;
|
||||
|
||||
if (format) {
|
||||
await runSerial([
|
||||
['oxlint', ['--fix', '--type-aware', threadsArg]],
|
||||
['oxfmt', [threadsArg]],
|
||||
['eslint', ['.', '--cache', '--fix']],
|
||||
['stylelint', ['**/*.{vue,css,less,scss}', '--cache', '--fix']],
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const commands: Command[] = [
|
||||
['oxlint', ['--type-aware', threadsArg]],
|
||||
['oxfmt', ['--check', threadsArg]],
|
||||
['eslint', ['.', '--cache']],
|
||||
['stylelint', ['**/*.{vue,css,less,scss}', '--cache']],
|
||||
];
|
||||
|
||||
// 低配机器(CPU 核心数较少)串行执行,避免多进程并发导致瞬时占用飙升;
|
||||
// 高配机器并行执行以缩短整体耗时。
|
||||
await (cpuCores <= CPU_CORE_THRESHOLD
|
||||
? runSerial(commands)
|
||||
: runParallel(commands));
|
||||
}
|
||||
|
||||
function defineLintCommand(cac: CAC) {
|
||||
cac
|
||||
.command('lint')
|
||||
.usage('Batch execute project lint check.')
|
||||
.option('--format', 'Format lint problem.')
|
||||
.option('--threads <count>', 'Number of threads for oxfmt and oxlint.')
|
||||
.action(runLint);
|
||||
}
|
||||
|
||||
export { defineLintCommand };
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { CAC } from 'cac';
|
||||
import type { Result } from 'publint';
|
||||
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
|
||||
import {
|
||||
colors,
|
||||
consola,
|
||||
ensureFile,
|
||||
findMonorepoRoot,
|
||||
generatorContentHash,
|
||||
getPackages,
|
||||
outputJSON,
|
||||
readJSON,
|
||||
UNICODE,
|
||||
} from '@vben/node-utils';
|
||||
|
||||
import { publint } from 'publint';
|
||||
import { formatMessage } from 'publint/utils';
|
||||
|
||||
const CACHE_FILE = join(
|
||||
'node_modules',
|
||||
'.cache',
|
||||
'publint',
|
||||
'.pkglintcache.json',
|
||||
);
|
||||
|
||||
interface PubLintCommandOptions {
|
||||
/**
|
||||
* Only errors are checked, no program exit is performed
|
||||
*/
|
||||
check?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get files that require lint
|
||||
* @param files
|
||||
*/
|
||||
async function getLintFiles(files: string[] = []) {
|
||||
const lintFiles: string[] = [];
|
||||
|
||||
if (files?.length > 0) {
|
||||
return files.filter((file) => basename(file) === 'package.json');
|
||||
}
|
||||
|
||||
const { packages } = await getPackages();
|
||||
|
||||
for (const { dir } of packages) {
|
||||
lintFiles.push(join(dir, 'package.json'));
|
||||
}
|
||||
return lintFiles;
|
||||
}
|
||||
|
||||
function getCacheFile() {
|
||||
const root = findMonorepoRoot();
|
||||
return join(root, CACHE_FILE);
|
||||
}
|
||||
|
||||
async function readCache(cacheFile: string) {
|
||||
try {
|
||||
await ensureFile(cacheFile);
|
||||
return await readJSON(cacheFile);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function runPublint(files: string[], { check }: PubLintCommandOptions) {
|
||||
const lintFiles = await getLintFiles(files);
|
||||
const cacheFile = getCacheFile();
|
||||
|
||||
const cacheData = await readCache(cacheFile);
|
||||
const cache: Record<string, { hash: string; result: Result }> = cacheData;
|
||||
|
||||
const results = await Promise.all(
|
||||
lintFiles.map(async (file) => {
|
||||
try {
|
||||
const pkgJson = await readJSON(file);
|
||||
|
||||
if (pkgJson.private) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Reflect.deleteProperty(pkgJson, 'dependencies');
|
||||
Reflect.deleteProperty(pkgJson, 'devDependencies');
|
||||
Reflect.deleteProperty(pkgJson, 'peerDependencies');
|
||||
const content = JSON.stringify(pkgJson);
|
||||
const hash = generatorContentHash(content);
|
||||
|
||||
const publintResult: Result =
|
||||
cache?.[file]?.hash === hash
|
||||
? (cache?.[file]?.result ?? [])
|
||||
: await publint({
|
||||
level: 'suggestion',
|
||||
pkgDir: dirname(file),
|
||||
strict: true,
|
||||
});
|
||||
|
||||
cache[file] = {
|
||||
hash,
|
||||
result: publintResult,
|
||||
};
|
||||
|
||||
return { pkgJson, pkgPath: file, publintResult };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
await outputJSON(cacheFile, cache);
|
||||
printResult(results, check);
|
||||
}
|
||||
|
||||
function printResult(
|
||||
results: Array<null | {
|
||||
pkgJson: Record<string, number | string>;
|
||||
pkgPath: string;
|
||||
publintResult: Result;
|
||||
}>,
|
||||
check?: boolean,
|
||||
) {
|
||||
let errorCount = 0;
|
||||
let warningCount = 0;
|
||||
let suggestionsCount = 0;
|
||||
|
||||
for (const result of results) {
|
||||
if (!result) {
|
||||
continue;
|
||||
}
|
||||
const { pkgJson, pkgPath, publintResult } = result;
|
||||
const messages = publintResult?.messages ?? [];
|
||||
if (messages?.length < 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
consola.log('');
|
||||
consola.log(pkgPath);
|
||||
for (const message of messages) {
|
||||
switch (message.type) {
|
||||
case 'error': {
|
||||
errorCount++;
|
||||
|
||||
break;
|
||||
}
|
||||
case 'suggestion': {
|
||||
suggestionsCount++;
|
||||
break;
|
||||
}
|
||||
case 'warning': {
|
||||
warningCount++;
|
||||
|
||||
break;
|
||||
}
|
||||
// No default
|
||||
}
|
||||
const ruleUrl = `https://publint.dev/rules#${message.code.toLocaleLowerCase()}`;
|
||||
consola.log(
|
||||
` ${formatMessage(message, pkgJson)}${colors.dim(` ${ruleUrl}`)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const totalCount = warningCount + errorCount + suggestionsCount;
|
||||
if (totalCount > 0) {
|
||||
consola.error(
|
||||
colors.red(
|
||||
`${UNICODE.FAILURE} ${totalCount} problem (${errorCount} errors, ${warningCount} warnings, ${suggestionsCount} suggestions)`,
|
||||
),
|
||||
);
|
||||
!check && process.exit(1);
|
||||
} else {
|
||||
consola.log(colors.green(`${UNICODE.SUCCESS} No problem`));
|
||||
}
|
||||
}
|
||||
|
||||
function definePubLintCommand(cac: CAC) {
|
||||
cac
|
||||
.command('publint [...files]')
|
||||
.usage('Check if the monorepo package conforms to the publint standard.')
|
||||
.option('--check', 'Only errors are checked, no program exit is performed.')
|
||||
.action(runPublint);
|
||||
}
|
||||
|
||||
export { definePubLintCommand };
|
||||
Reference in New Issue
Block a user