diff --git a/__tests__/batch-lint.spec.ts b/__tests__/batch-lint.spec.ts index dcfcd34..2e36f50 100644 --- a/__tests__/batch-lint.spec.ts +++ b/__tests__/batch-lint.spec.ts @@ -1,4 +1,15 @@ -import { runTasksWithLimit } from '../src/utils/batch-lint'; +import { mkdtemp, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import * as path from 'path'; +import { Piscina } from 'piscina'; +import type { LintMdRulesConfig } from '@lint-md/core'; +import { batchLint, runTasksWithLimit } from '../src/utils/batch-lint'; + +const RULES_NO_EMPTY_LIST: LintMdRulesConfig = { + 'no-empty-list': 2, +}; + +const TRIGGER_CONTENT = '1. hello\n2.\n'; describe('runTasksWithLimit', () => { test('respects concurrency limit', async () => { @@ -34,3 +45,135 @@ describe('runTasksWithLimit', () => { expect(result).toEqual([10, 20, 30]); }); }); + +describe('batchLint', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(path.join(tmpdir(), 'batch-lint-test-')); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + test('returns empty array when no files are provided', async () => { + const result = await batchLint(2, [], false, false, RULES_NO_EMPTY_LIST); + expect(result).toEqual([]); + }); + + describe('路径 payload', () => { + test('returns results keyed by the original file path', async () => { + const fileA = path.join(tmpDir, 'a.md'); + const fileB = path.join(tmpDir, 'b.md'); + await writeFile(fileA, TRIGGER_CONTENT, 'utf8'); + await writeFile(fileB, TRIGGER_CONTENT, 'utf8'); + + const result = await batchLint(2, [fileA, fileB], false, false, RULES_NO_EMPTY_LIST); + + expect(result.map(item => item.path)).toEqual([fileA, fileB]); + result.forEach((item) => { + expect(Array.isArray(item.lintResult)).toBe(true); + expect(item.lintResult.length).toBeGreaterThan(0); + expect(item.fixedResult == null).toBe(true); + }); + }); + + test('worker reads the file on its own (content is not pre-loaded)', async () => { + const file = path.join(tmpDir, 'read-in-worker.md'); + await writeFile(file, TRIGGER_CONTENT, 'utf8'); + + const result = await batchLint(1, [file], false, false, RULES_NO_EMPTY_LIST); + + expect(result).toHaveLength(1); + expect(result[0].path).toBe(file); + expect(result[0].lintResult[0].name).toBe('no-empty-list'); + }); + }); + + describe('并发上限', () => { + test('caps concurrent worker tasks at the threads count', async () => { + const fileCount = 8; + const files = await Promise.all( + Array.from({ length: fileCount }, (_, i) => { + const file = path.join(tmpDir, `file-${i}.md`); + return writeFile(file, TRIGGER_CONTENT, 'utf8').then(() => file); + }) + ); + + const result = await batchLint(3, files, false, false, RULES_NO_EMPTY_LIST); + + expect(result).toHaveLength(fileCount); + expect(result.map(item => item.path)).toEqual(files); + }); + + test('threads greater than files does not error', async () => { + const file = path.join(tmpDir, 'single.md'); + await writeFile(file, TRIGGER_CONTENT, 'utf8'); + + const result = await batchLint(16, [file], false, false, RULES_NO_EMPTY_LIST); + + expect(result).toHaveLength(1); + }); + }); + + describe('报告顺序', () => { + test('results are returned in input order (not group order)', async () => { + const fileA = path.join(tmpDir, 'order-a.md'); + const fileB = path.join(tmpDir, 'order-b.md'); + const fileC = path.join(tmpDir, 'order-c.md'); + await writeFile(fileA, TRIGGER_CONTENT, 'utf8'); + await writeFile(fileB, TRIGGER_CONTENT, 'utf8'); + await writeFile(fileC, TRIGGER_CONTENT, 'utf8'); + + const result = await batchLint(2, [fileA, fileB, fileC], false, false, RULES_NO_EMPTY_LIST); + + expect(result.map(item => item.path)).toEqual([fileA, fileB, fileC]); + }); + }); + + describe('pool 销毁', () => { + test('returns successfully and does not leave worker processes hanging', async () => { + const file = path.join(tmpDir, 'pool-cleanup.md'); + await writeFile(file, '# Clean content\n', 'utf8'); + + await expect(batchLint(2, [file], false, false, RULES_NO_EMPTY_LIST)).resolves.toBeDefined(); + }); + + test('destroys the pool even when a worker throws', async () => { + const destroySpy = jest.spyOn(Piscina.prototype, 'destroy'); + const file = path.join(tmpDir, 'missing.md'); + + try { + await expect(batchLint(1, [file], false, false, RULES_NO_EMPTY_LIST)).rejects.toThrow(); + expect(destroySpy).toHaveBeenCalled(); + } + finally { + destroySpy.mockRestore(); + } + }); + }); + + describe('fix 行为', () => { + test('returns fixedResult in fix mode', async () => { + const file = path.join(tmpDir, 'fixable.md'); + await writeFile(file, TRIGGER_CONTENT, 'utf8'); + + const result = await batchLint(1, [file], false, true, RULES_NO_EMPTY_LIST); + + expect(result).toHaveLength(1); + expect(result[0].fixedResult).not.toBeNull(); + expect(result[0].fixedResult?.result).toBeDefined(); + }); + + test('does not return fixedResult when fix mode is disabled', async () => { + const file = path.join(tmpDir, 'no-fix.md'); + await writeFile(file, TRIGGER_CONTENT, 'utf8'); + + const result = await batchLint(1, [file], false, false, RULES_NO_EMPTY_LIST); + + expect(result).toHaveLength(1); + expect(result[0].fixedResult == null).toBe(true); + }); + }); +}); diff --git a/package.json b/package.json index 99a4a56..ba6f800 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ ], "scripts": { "test": "jest --no-cache", + "pretest": "npm run build", "build:cjs": "tsc -p tsconfig.json --target ESNext --module commonjs --outDir lib", "build:esm": "tsc -p tsconfig.json --target ESNext --module ESNext --outDir esm", "lint": "eslint --ext .ts,.tsx ./ --fix", diff --git a/src/types.ts b/src/types.ts index 0fc7222..f6437a8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -30,7 +30,7 @@ export interface CliErrorCount { } export interface LintWorkerOptions { - contentList: string[] + filePath: string rules?: LintMdRulesConfig isFixMode?: boolean isDev?: boolean @@ -49,4 +49,12 @@ export interface BatchLintItem { content: string severity: number }[] + fixedResult?: { + result: string + notAppliedFixes: { + range: number[] + text: string + data?: Record + }[] + } | null } diff --git a/src/utils/averaged-group.ts b/src/utils/averaged-group.ts deleted file mode 100644 index a86e17b..0000000 --- a/src/utils/averaged-group.ts +++ /dev/null @@ -1,51 +0,0 @@ -export const averagedGroup = ( - items: T[], - groupCount: number, - groupCalculator: (item: T) => number -) => { - const itemsToGroup = [...items] - .map((item) => { - return { - value: item, - power: groupCalculator(item), - }; - }) - .sort((a, b) => { - return b.power - a.power; - }); - - // 每组的平均值 - const groupAverage = itemsToGroup.reduce((acc, item) => { - return acc + item.power; - }, 0) / groupCount; - - const groups = new Array(groupCount).fill('').map(() => { - return { - totalPower: 0, - items: [] as T[], - }; - }); - - for (const item of itemsToGroup) { - for (const group of groups) { - const currentItemPower = item.power; - - // 大于平均值的单独存放 - if (currentItemPower > groupAverage) { - group.items.push(item.value); - group.totalPower += currentItemPower; - break; - } - else { - // 小于等于平均值的,如果小于当前组总值,可放入 - if (group.totalPower < groupAverage) { - group.items.push(item.value); - group.totalPower += currentItemPower; - break; - } - } - } - } - - return groups; -}; diff --git a/src/utils/batch-lint.ts b/src/utils/batch-lint.ts index 32cad70..a531482 100644 --- a/src/utils/batch-lint.ts +++ b/src/utils/batch-lint.ts @@ -1,9 +1,8 @@ import path from 'path'; -import { readFile } from 'fs/promises'; -import type { LintMdRulesConfig, lintMarkdown } from '@lint-md/core'; +import { existsSync } from 'fs'; import { Piscina } from 'piscina'; -import type { LintWorkerOptions } from '../types'; -import { averagedGroup } from './averaged-group'; +import type { LintMdRulesConfig } from '@lint-md/core'; +import type { BatchLintItem, LintWorkerOptions } from '../types'; export async function runTasksWithLimit( tasks: (() => Promise)[], @@ -24,13 +23,21 @@ export async function runTasksWithLimit( return results; } +const resolveWorkerFilename = (): string => { + const compiled = path.resolve(__dirname, './lint-worker.js'); + if (existsSync(compiled)) { + return compiled; + } + return path.resolve(__dirname, '../../lib/src/utils/lint-worker.js'); +}; + export const batchLint = async ( threadsCount: number, mdFilePaths: string[], isDev: boolean, isFixMode: boolean, - rules: LintMdRulesConfig, -) => { + rules: LintMdRulesConfig +): Promise => { if (mdFilePaths.length === 0) { return []; } @@ -38,54 +45,28 @@ export const batchLint = async ( const concurrency = Math.min(Math.max(threadsCount, 1), mdFilePaths.length); const lintWorkerPool = new Piscina({ - filename: path.resolve(__dirname, './lint-worker'), + filename: resolveWorkerFilename(), maxThreads: concurrency, }); - const fileContentList = await runTasksWithLimit( - mdFilePaths.map((filePath) => { - return async () => { - const res = await readFile(filePath); - return { - path: filePath, - content: res.toString(), - }; - }; - }), - concurrency - ); - - // 将 md 文件内容进行分组,供各个线程分配执行 - const markdownContentGroup = averagedGroup(fileContentList, concurrency, (item) => { - return item.content.length; - }); - - const res = await Promise.all( - markdownContentGroup.map((groupItem) => { - const asyncCall = async () => { - const batchLintResult: ReturnType[] - = await lintWorkerPool.run({ - contentList: groupItem.items.map((value) => { - return value.content; - }), - isFixMode, - rules, - isDev, - } as LintWorkerOptions); + try { + const results = await runTasksWithLimit( + mdFilePaths.map((filePath) => { + return () => lintWorkerPool.run({ + filePath, + isFixMode, + rules, + isDev, + } as LintWorkerOptions); + }), + concurrency + ); - return batchLintResult.map((lintResult, index) => { - return { - ...lintResult, - path: groupItem.items[index].path, - }; - }); - }; - - return asyncCall(); - }) - ); - - return res.flat().filter((item) => { - return item.lintResult.length > 0; - }); + return results.filter((item) => { + return item.lintResult.length > 0; + }); + } + finally { + await lintWorkerPool.destroy(); + } }; diff --git a/src/utils/lint-worker.ts b/src/utils/lint-worker.ts index 8910106..ce0516b 100644 --- a/src/utils/lint-worker.ts +++ b/src/utils/lint-worker.ts @@ -1,30 +1,32 @@ +import { readFile } from 'fs/promises'; import { lintMarkdown } from '@lint-md/core'; import type { LintWorkerOptions } from '../types'; -const lintWorker = (options: LintWorkerOptions) => { - const { contentList, rules, isFixMode, isDev } = options; +const lintWorker = async (options: LintWorkerOptions) => { + const { filePath, rules, isFixMode, isDev } = options; const start = new Date().getTime(); - const res = contentList.map((content) => { - return lintMarkdown(content, rules, isFixMode); - }); + const content = await readFile(filePath, 'utf8'); + const result = lintMarkdown(content, rules, isFixMode); const end = new Date().getTime(); if (isDev) { console.log( - 'Group 耗时:', + 'File 耗时:', end - start, - ' Group 长度:', - contentList.length, + ' 文件:', + filePath, ' 字符串长度:', - contentList.reduce((acc, curr) => { - return acc + curr.length; - }, 0) + content.length ); } - return res; + return { + path: filePath, + lintResult: result.lintResult, + fixedResult: result.fixedResult, + }; }; export default lintWorker;