Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 144 additions & 1 deletion __tests__/batch-lint.spec.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -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);
});
});
});
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 9 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export interface CliErrorCount {
}

export interface LintWorkerOptions {
contentList: string[]
filePath: string
rules?: LintMdRulesConfig
isFixMode?: boolean
isDev?: boolean
Expand All @@ -49,4 +49,12 @@ export interface BatchLintItem {
content: string
severity: number
}[]
fixedResult?: {
result: string
notAppliedFixes: {
range: number[]
text: string
data?: Record<string, unknown>
}[]
} | null
}
51 changes: 0 additions & 51 deletions src/utils/averaged-group.ts

This file was deleted.

85 changes: 33 additions & 52 deletions src/utils/batch-lint.ts
Original file line number Diff line number Diff line change
@@ -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<T>(
tasks: (() => Promise<T>)[],
Expand All @@ -24,68 +23,50 @@ export async function runTasksWithLimit<T>(
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<BatchLintItem[]> => {
if (mdFilePaths.length === 0) {
return [];
}

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<typeof lintMarkdown>[]
= await lintWorkerPool.run({
contentList: groupItem.items.map((value) => {
return value.content;
}),
isFixMode,
rules,
isDev,
} as LintWorkerOptions);
try {
const results = await runTasksWithLimit<BatchLintItem>(
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();
}
};
26 changes: 14 additions & 12 deletions src/utils/lint-worker.ts
Original file line number Diff line number Diff line change
@@ -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;