From 71d288430b0e86626361fcb7146f9cfc3510581b Mon Sep 17 00:00:00 2001 From: luojiyin Date: Mon, 6 Jul 2026 11:27:31 +0800 Subject: [PATCH 1/3] chore(package): add pretest build step for CI --- __tests__/batch-lint.spec.ts | 137 ++++++++++++++++++++++++++++++++++- package.json | 1 + src/types.ts | 10 ++- src/utils/averaged-group.ts | 51 ------------- src/utils/batch-lint.ts | 85 +++++++++------------- src/utils/lint-worker.ts | 26 ++++--- 6 files changed, 193 insertions(+), 117 deletions(-) delete mode 100644 src/utils/averaged-group.ts diff --git a/__tests__/batch-lint.spec.ts b/__tests__/batch-lint.spec.ts index dcfcd34..ffbbae7 100644 --- a/__tests__/batch-lint.spec.ts +++ b/__tests__/batch-lint.spec.ts @@ -1,4 +1,14 @@ -import { runTasksWithLimit } from '../src/utils/batch-lint'; +import { mkdtemp, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import * as path from 'path'; +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 +44,128 @@ 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 file = path.join(tmpDir, 'missing.md'); + + await expect(batchLint(1, [file], false, false, RULES_NO_EMPTY_LIST)).rejects.toThrow(); + }); + }); + + 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; From ba0629d81143de40f96f8de6087cf9d61629170d Mon Sep 17 00:00:00 2001 From: luojiyin Date: Mon, 6 Jul 2026 11:48:31 +0800 Subject: [PATCH 2/3] test(batch-lint): spy on Piscina.destroy in worker-throw test --- __tests__/batch-lint.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/__tests__/batch-lint.spec.ts b/__tests__/batch-lint.spec.ts index ffbbae7..00ee4f2 100644 --- a/__tests__/batch-lint.spec.ts +++ b/__tests__/batch-lint.spec.ts @@ -1,6 +1,7 @@ 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'; @@ -140,9 +141,12 @@ describe('batchLint', () => { }); test('destroys the pool even when a worker throws', async () => { + const destroySpy = jest.spyOn(Piscina.prototype, 'destroy'); const file = path.join(tmpDir, 'missing.md'); await expect(batchLint(1, [file], false, false, RULES_NO_EMPTY_LIST)).rejects.toThrow(); + expect(destroySpy).toHaveBeenCalled(); + destroySpy.mockRestore(); }); }); From 0693ffd2b2aef34921b7813461544922cb90839a Mon Sep 17 00:00:00 2001 From: luojiyin Date: Mon, 6 Jul 2026 11:52:05 +0800 Subject: [PATCH 3/3] test(batch-lint): wrap destroy spy in try/finally to guarantee restore --- __tests__/batch-lint.spec.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/__tests__/batch-lint.spec.ts b/__tests__/batch-lint.spec.ts index 00ee4f2..2e36f50 100644 --- a/__tests__/batch-lint.spec.ts +++ b/__tests__/batch-lint.spec.ts @@ -144,9 +144,13 @@ describe('batchLint', () => { const destroySpy = jest.spyOn(Piscina.prototype, 'destroy'); const file = path.join(tmpDir, 'missing.md'); - await expect(batchLint(1, [file], false, false, RULES_NO_EMPTY_LIST)).rejects.toThrow(); - expect(destroySpy).toHaveBeenCalled(); - destroySpy.mockRestore(); + try { + await expect(batchLint(1, [file], false, false, RULES_NO_EMPTY_LIST)).rejects.toThrow(); + expect(destroySpy).toHaveBeenCalled(); + } + finally { + destroySpy.mockRestore(); + } }); });