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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Features

- **lint-md:** add `--threads auto` to cap worker concurrency for large Markdown files (issue #77, P1)
- **lint-md:** add `--max-file-size <size>` to skip large Markdown files with a stderr warning (issue #81)

## [2.1.1](https://github.com/lint-md/cli/compare/v2.0.0...v2.1.1) (2026-06-30)

Expand Down
191 changes: 191 additions & 0 deletions __tests__/max-file-size.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { writeFileSync } from 'fs';
import { mkdtemp, readFile, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import * as path from 'path';
import { execFileSync } from 'child_process';
import { filterFilesByMaxSize } from '../src/utils/filter-by-max-size';
import { STAT_CONCURRENCY_LIMIT } from '../src/utils/batch-lint';
import { parseSize } from '../src/utils/parse-size';

const TSX = path.resolve(__dirname, '../node_modules/tsx/dist/cli.mjs');
const CLI = path.resolve(__dirname, '../src/lint-md.ts');

const VIOLATION = '1. hello\n2.\n';

describe('filterFilesByMaxSize (unit)', () => {
let tmpDir: string;

beforeEach(async () => {
tmpDir = await mkdtemp(path.join(tmpdir(), 'max-file-size-unit-'));
});

afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});

test('drops files above the limit and warns to stderr', async () => {
const small = path.join(tmpDir, 'small.md');
const large = path.join(tmpDir, 'large.md');
await writeFile(small, VIOLATION, 'utf8');
await writeFile(large, `${Buffer.alloc(200 * 1024, 'a')}\n${VIOLATION}`, 'utf8');

const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
try {
const kept = await filterFilesByMaxSize([small, large], parseSize('1kb'));
expect(kept).toEqual([small]);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('warning: skipped large Markdown file')
);
expect(errorSpy.mock.calls[0][0]).toContain(large);
}
finally {
errorSpy.mockRestore();
}
});

test('keeps all files when none exceed the limit', async () => {
const a = path.join(tmpDir, 'a.md');
const b = path.join(tmpDir, 'b.md');
await writeFile(a, VIOLATION, 'utf8');
await writeFile(b, VIOLATION, 'utf8');

const kept = await filterFilesByMaxSize([a, b], parseSize('10mb'));
expect(kept.sort()).toEqual([a, b].sort());
});

test('bounds stat concurrency to STAT_CONCURRENCY_LIMIT', async () => {
const fileCount = STAT_CONCURRENCY_LIMIT * 3;
const filePaths = Array.from({ length: fileCount }, (_, index) =>
path.join(tmpDir, `f-${index}.md`)
);
const sizeMap = new Map(filePaths.map((filePath, index) => [filePath, index + 1]));

let inFlight = 0;
let maxInFlight = 0;
const fsPromises = require('fs/promises');
const statSpy = jest.spyOn(fsPromises, 'stat').mockImplementation((filePath: string) => {
inFlight++;
maxInFlight = Math.max(maxInFlight, inFlight);
return new Promise((resolve) => {
setTimeout(() => {
inFlight--;
resolve({ size: sizeMap.get(filePath) ?? 0 });
}, 20);
});
});

try {
const kept = await filterFilesByMaxSize(filePaths, parseSize('10mb'));
expect(kept).toHaveLength(fileCount);
expect(statSpy).toHaveBeenCalledTimes(fileCount);
expect(maxInFlight).toBeGreaterThan(1);
expect(maxInFlight).toBeLessThanOrEqual(STAT_CONCURRENCY_LIMIT);
}
finally {
statSpy.mockRestore();
}
});

test('propagates stat failures', async () => {
const missing = path.join(tmpDir, 'missing.md');
await expect(filterFilesByMaxSize([missing], parseSize('10mb'))).rejects.toThrow();
});
});

describe('--max-file-size CLI behavior', () => {
let tmpDir: string;

beforeEach(async () => {
tmpDir = await mkdtemp(path.join(tmpdir(), 'max-file-size-cli-'));
// enable a rule so violations produce a report
await writeFile(path.join(tmpDir, '.lintmdrc'), JSON.stringify({ rules: { 'no-empty-list': 2 } }), 'utf8');
});

afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});

const runCli = (args: string[]): { stdout: string; stderr: string; status: number } => {
try {
const stdout = execFileSync(process.execPath, [TSX, CLI, ...args], {
cwd: tmpDir,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
});
return { stdout, stderr: '', status: 0 };
}
catch (e: any) {
return { stdout: e.stdout ?? '', stderr: e.stderr ?? '', status: e.status ?? 1 };
}
};

test('invalid size (1.5mb) -> exit 1 + stderr', () => {
const file = path.join(tmpDir, 'a.md');
writeFileSyncHelper(file, VIOLATION);
const { status, stderr } = runCli([file, '--max-file-size', '1.5mb']);
expect(status).toBe(1);
expect(stderr).toContain('--max-file-size must be a valid size');
});

test('no flag -> old behavior, clean file lints without filtering', () => {
const file = path.join(tmpDir, 'a.md');
writeFileSyncHelper(file, '# hello\n');
const { status, stdout } = runCli([file]);
expect(status).toBe(0);
expect(stdout).toContain('Done in');
});

test('oversized file is skipped (warning to stderr, not linted)', () => {
const small = path.join(tmpDir, 'small.md');
const large = path.join(tmpDir, 'large.md');
writeFileSyncHelper(small, VIOLATION);
writeFileSyncHelper(large, `${Buffer.alloc(200 * 1024, 'a')}\n${VIOLATION}`);
const { status, stdout, stderr } = runCli([small, large, '--max-file-size', '100kb']);
// small.md has a violation, so the run exits 1; the point is the large
// file was skipped entirely (no report entry) and warned on stderr.
expect(status).toBe(1);
expect(stderr).toContain('warning: skipped large Markdown file');
expect(stderr).toContain(path.basename(large));
expect(stdout).toContain('no-empty-list');
expect(stdout).not.toContain(path.basename(large));
});

test('--fix also skips the oversized file', async () => {
const small = path.join(tmpDir, 'small.md');
const large = path.join(tmpDir, 'large.md');
const largeOriginal = `${Buffer.alloc(200 * 1024, 'a')}\n${VIOLATION}`;
writeFileSyncHelper(small, VIOLATION);
writeFileSyncHelper(large, largeOriginal);
const { status } = runCli([small, large, '--fix', '--max-file-size', '100kb']);
expect(status).toBe(0);
// large file was skipped, content unchanged
const after = await readFile(large, 'utf8');
expect(after).toBe(largeOriginal);
});

test('all files filtered out -> No markdown files message', () => {
const large = path.join(tmpDir, 'large.md');
writeFileSyncHelper(large, `${Buffer.alloc(200 * 1024, 'a')}\n${VIOLATION}`);
const { status, stdout } = runCli([large, '--max-file-size', '100kb']);
expect(status).toBe(0);
expect(stdout).toContain('🎉 No markdown files to lint 🎉');
});

test('works together with --threads auto on remaining files', () => {
const small = path.join(tmpDir, 'small.md');
const large = path.join(tmpDir, 'large.md');
writeFileSyncHelper(small, VIOLATION);
writeFileSyncHelper(large, `${Buffer.alloc(200 * 1024, 'a')}\n${VIOLATION}`);
const { status, stdout, stderr } = runCli([small, large, '--max-file-size', '100kb', '--threads', 'auto']);
// small.md has a violation, so the run exits 1; verify the large file
// was skipped and the remaining file was still linted.
expect(status).toBe(1);
expect(stderr).toContain('warning: skipped large Markdown file');
expect(stdout).toContain('no-empty-list');
});
});

// synchronous helper for the beforeEach-style writes (tests run serially)
function writeFileSyncHelper(file: string, content: string | Buffer): void {
writeFileSync(file, content);
}
41 changes: 41 additions & 0 deletions __tests__/parse-size.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { formatBytes, parseSize } from '../src/utils/parse-size';

describe('parseSize', () => {
test('parses kb/m/g units case-insensitively', () => {
expect(parseSize('500kb')).toBe(500 * 1024);
expect(parseSize('1mb')).toBe(1024 * 1024);
expect(parseSize('5mb')).toBe(5 * 1024 * 1024);
expect(parseSize('1gb')).toBe(1024 * 1024 * 1024);
expect(parseSize('5MB')).toBe(5 * 1024 * 1024);
expect(parseSize('1GB')).toBe(1024 * 1024 * 1024);
expect(parseSize('5b')).toBe(5);
expect(parseSize('5k')).toBe(5 * 1024);
});

test('rejects invalid input', () => {
expect(() => parseSize('1.5mb')).toThrow();
expect(() => parseSize('1 mb')).toThrow();
expect(() => parseSize('0')).toThrow();
expect(() => parseSize('-1')).toThrow();
expect(() => parseSize('abc')).toThrow();
expect(() => parseSize('')).toThrow();
expect(() => parseSize('10x')).toThrow();
});

test('rejects malformed-but-permissible-looking units', () => {
expect(() => parseSize('kk')).toThrow();
expect(() => parseSize('mbb')).toThrow();
expect(() => parseSize('kbk')).toThrow();
});
});

describe('formatBytes', () => {
test('uses dynamic units with one decimal and drops trailing .0', () => {
expect(formatBytes(5 * 1024 * 1024)).toBe('5 MiB');
expect(formatBytes(Math.round(8.3 * 1024 * 1024))).toBe('8.3 MiB');
expect(formatBytes(500)).toBe('500 B');
expect(formatBytes(1024)).toBe('1 KiB');
expect(formatBytes(200 * 1024)).toBe('200 KiB');
expect(formatBytes(1024 * 1024 * 1024)).toBe('1 GiB');
});
});
20 changes: 17 additions & 3 deletions src/lint-md.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ import {
resolveAdaptiveConcurrency,
runTasksWithLimit,
} from './utils/batch-lint';
import { getLintConfig, getThreadCount } from './utils/configure';
import { getLintConfig, getMaxFileSizeOption, getThreadCount } from './utils/configure';
import type { CLIOptions, ThreadCount } from './types';
import { loadMdFiles } from './utils/load-md-files';
import { getReportData } from './utils/get-report-data';
import { filterFilesByMaxSize } from './utils/filter-by-max-size';

program
.version(
Expand Down Expand Up @@ -44,9 +45,13 @@ program
'-i, --stdin',
'read markdown content from stdin(从标准输入中读取内容)'
)
.option(
'--max-file-size <size>',
'skip Markdown files larger than <size> (e.g. 5mb, 500kb, 1gb), warn to stderr(跳过超过指定大小的 Markdown 文件)'
)
.arguments('[files...]')
.action(async (files: string[], options: CLIOptions) => {
const { fix, config, threads, dev, suppressWarnings, stdin } = options;
const { fix, config, threads, dev, suppressWarnings, stdin, maxFileSize } = options;

const startTime = Date.now();
const isFixMode = Boolean(fix);
Expand All @@ -61,6 +66,9 @@ program
// --threads 参数校验,所有分支共用
const threadCount: ThreadCount = getThreadCount(threads);

// --max-file-size 校验(未传 = null = 不过滤),失败早退,与 --threads 一致
const maxFileSizeBytes = getMaxFileSizeOption(maxFileSize);

// Handle stdin mode
if (stdin) {
const content = readFileSync(process.stdin.fd, 'utf8');
Expand Down Expand Up @@ -116,7 +124,13 @@ program
return;
}

const mdFiles = await loadMdFiles(files, excludeFiles, extensions);
let mdFiles = await loadMdFiles(files, excludeFiles, extensions);

// 过滤超大文件(stderr warning + 跳过),发生在 resolveAdaptiveConcurrency
// 之前,使 --threads auto 只基于剩余文件重算并发,两者互不感知。
if (maxFileSizeBytes !== null) {
mdFiles = await filterFilesByMaxSize(mdFiles, maxFileSizeBytes);
}

if (!mdFiles.length) {
console.log('🎉 No markdown files to lint 🎉');
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface CLIOptions {
suppressWarnings: boolean
threads?: string | boolean
stdin?: boolean
maxFileSize?: string
}

/** CLI lint 结果选项 */
Expand Down
23 changes: 23 additions & 0 deletions src/utils/configure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { availableParallelism } from 'os';
import * as path from 'path';
import chalk from 'chalk';
import type { CLIConfig, ThreadCount } from '../types';
import { parseSize } from './parse-size';

export const getLintConfig = (configFilePath?: string): Required<CLIConfig> => {
if (configFilePath && !fs.existsSync(configFilePath)) {
Expand Down Expand Up @@ -68,3 +69,25 @@ export const getThreadCount = (

return num;
};

// Resolves the optional --max-file-size CLI flag into a byte limit.
// Returns null when the flag is not provided (no filtering, backward
// compatible). Invalid input exits 1 with a stderr message, matching the
// --threads validation style.
export const getMaxFileSizeOption = (
maxFileSize?: string | boolean
): number | null => {
if (maxFileSize === undefined || typeof maxFileSize !== 'string') {
return null;
}

try {
return parseSize(maxFileSize);
}
catch {
console.error(
chalk.red('[lint-md] --max-file-size must be a valid size (e.g. 5mb, 500kb, 1gb).')
);
process.exit(1);
}
};
29 changes: 29 additions & 0 deletions src/utils/filter-by-max-size.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { stat } from 'fs/promises';
import { STAT_CONCURRENCY_LIMIT, runTasksWithLimit } from './batch-lint';
import { formatBytes } from './parse-size';

// Drops Markdown files larger than limitBytes, emitting a stderr warning for
// each skipped file. Stat concurrency is bounded by STAT_CONCURRENCY_LIMIT
// (reused from batch-lint) so this does not reintroduce the N-fd stat burst
// that #80 removed. A stat failure propagates upward, matching the existing
// error boundary.
export const filterFilesByMaxSize = async (
mdFiles: string[],
limitBytes: number
): Promise<string[]> => {
const results = await runTasksWithLimit(
mdFiles.map(file => async () => {
const { size } = await stat(file);
if (size > limitBytes) {
console.error(
`warning: skipped large Markdown file ${file}, size ${formatBytes(size)} exceeds limit ${formatBytes(limitBytes)}`
);
return null;
}
return file;
}),
STAT_CONCURRENCY_LIMIT
);

return results.filter((file): file is string => file !== null);
};
Loading