From 0caf4143c91b64e8278ee48039e2b30ccebf3b80 Mon Sep 17 00:00:00 2001 From: luojiyin Date: Thu, 9 Jul 2026 10:05:06 +0800 Subject: [PATCH 1/3] feat(cli): add --max-file-size to skip large Markdown files (#81) Add a --max-file-size flag that drops Markdown files larger than the limit (warning to stderr) before linting/fixing. Filtering reuses runTasksWithLimit with STAT_CONCURRENCY_LIMIT so it does not reintroduce the N-fd stat burst fixed in #80. --threads auto recomputes concurrency on the remaining files; behavior is unchanged when the flag is omitted. --- CHANGELOG.md | 1 + __tests__/max-file-size.spec.ts | 191 ++++++++++++++++++++++++++++++++ __tests__/parse-size.spec.ts | 31 ++++++ src/lint-md.ts | 20 +++- src/types.ts | 1 + src/utils/configure.ts | 23 ++++ src/utils/filter-by-max-size.ts | 29 +++++ src/utils/parse-size.ts | 34 ++++++ 8 files changed, 327 insertions(+), 3 deletions(-) create mode 100644 __tests__/max-file-size.spec.ts create mode 100644 __tests__/parse-size.spec.ts create mode 100644 src/utils/filter-by-max-size.ts create mode 100644 src/utils/parse-size.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bb2abc6..62a650e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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) diff --git a/__tests__/max-file-size.spec.ts b/__tests__/max-file-size.spec.ts new file mode 100644 index 0000000..44a4620 --- /dev/null +++ b/__tests__/max-file-size.spec.ts @@ -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); +} diff --git a/__tests__/parse-size.spec.ts b/__tests__/parse-size.spec.ts new file mode 100644 index 0000000..9e594c7 --- /dev/null +++ b/__tests__/parse-size.spec.ts @@ -0,0 +1,31 @@ +import { formatMiB, 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(); + }); +}); + +describe('formatMiB', () => { + test('formats with one decimal and drops trailing .0', () => { + expect(formatMiB(5 * 1024 * 1024)).toBe('5 MiB'); + expect(formatMiB(Math.round(8.3 * 1024 * 1024))).toBe('8.3 MiB'); + }); +}); diff --git a/src/lint-md.ts b/src/lint-md.ts index bcf937f..2a234d5 100644 --- a/src/lint-md.ts +++ b/src/lint-md.ts @@ -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( @@ -44,9 +45,13 @@ program '-i, --stdin', 'read markdown content from stdin(从标准输入中读取内容)' ) + .option( + '--max-file-size ', + 'skip Markdown files larger than (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); @@ -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'); @@ -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 🎉'); diff --git a/src/types.ts b/src/types.ts index bdf4139..3834bed 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,6 +17,7 @@ export interface CLIOptions { suppressWarnings: boolean threads?: string | boolean stdin?: boolean + maxFileSize?: string } /** CLI lint 结果选项 */ diff --git a/src/utils/configure.ts b/src/utils/configure.ts index fd7d625..e21b802 100644 --- a/src/utils/configure.ts +++ b/src/utils/configure.ts @@ -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 => { if (configFilePath && !fs.existsSync(configFilePath)) { @@ -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); + } +}; diff --git a/src/utils/filter-by-max-size.ts b/src/utils/filter-by-max-size.ts new file mode 100644 index 0000000..44b83f9 --- /dev/null +++ b/src/utils/filter-by-max-size.ts @@ -0,0 +1,29 @@ +import { stat } from 'fs/promises'; +import { STAT_CONCURRENCY_LIMIT, runTasksWithLimit } from './batch-lint'; +import { formatMiB } 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 => { + 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 ${formatMiB(size)} exceeds limit ${formatMiB(limitBytes)}` + ); + return null; + } + return file; + }), + STAT_CONCURRENCY_LIMIT + ); + + return results.filter((file): file is string => file !== null); +}; diff --git a/src/utils/parse-size.ts b/src/utils/parse-size.ts new file mode 100644 index 0000000..08e3bee --- /dev/null +++ b/src/utils/parse-size.ts @@ -0,0 +1,34 @@ +const SIZE_UNITS: Record = { + b: 1, + k: 1024, + kb: 1024, + m: 1024 ** 2, + mb: 1024 ** 2, + g: 1024 ** 3, + gb: 1024 ** 3, +}; + +// Parses a human size string (e.g. "5mb", "500kb", "1gb") into bytes. +// Rejects decimals (1.5mb), whitespace (1 mb), 0, negatives, and any +// unrecognized unit. Throws on invalid input so callers can present the +// error in the CLI style (see getMaxFileSizeOption in configure.ts). +export const parseSize = (input: string): number => { + const match = /^(\d+)([bkmg]+)$/i.exec(input.trim()); + if (!match) { + throw new Error('invalid size'); + } + const multiplier = SIZE_UNITS[match[2].toLowerCase()]; + if (!multiplier) { + throw new Error('invalid size'); + } + const bytes = Number(match[1]) * multiplier; + if (!Number.isFinite(bytes) || bytes <= 0) { + throw new Error('invalid size'); + } + return bytes; +}; + +// Formats bytes as "X.X MiB", dropping a trailing ".0" to match the +// issue's warning examples (8.3 MiB, 5 MiB). +export const formatMiB = (bytes: number): string => + `${(bytes / (1024 * 1024)).toFixed(1).replace(/\.0$/, '')} MiB`; From c7b1798c375da864a9c55e319ade2b48ebe1ecde Mon Sep 17 00:00:00 2001 From: luojiyin Date: Thu, 9 Jul 2026 10:15:09 +0800 Subject: [PATCH 2/3] fix(cli): use dynamic units in --max-file-size warning (#81) Replace formatMiB (always MiB) with formatBytes, which picks B / KiB / MiB / GiB dynamically so sub-MiB thresholds no longer render as a misleading "0 MiB". --- __tests__/parse-size.spec.ts | 14 +++++++++----- src/utils/filter-by-max-size.ts | 4 ++-- src/utils/parse-size.ts | 21 +++++++++++++++++---- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/__tests__/parse-size.spec.ts b/__tests__/parse-size.spec.ts index 9e594c7..912d989 100644 --- a/__tests__/parse-size.spec.ts +++ b/__tests__/parse-size.spec.ts @@ -1,4 +1,4 @@ -import { formatMiB, parseSize } from '../src/utils/parse-size'; +import { formatBytes, parseSize } from '../src/utils/parse-size'; describe('parseSize', () => { test('parses kb/m/g units case-insensitively', () => { @@ -23,9 +23,13 @@ describe('parseSize', () => { }); }); -describe('formatMiB', () => { - test('formats with one decimal and drops trailing .0', () => { - expect(formatMiB(5 * 1024 * 1024)).toBe('5 MiB'); - expect(formatMiB(Math.round(8.3 * 1024 * 1024))).toBe('8.3 MiB'); +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'); }); }); diff --git a/src/utils/filter-by-max-size.ts b/src/utils/filter-by-max-size.ts index 44b83f9..c58050b 100644 --- a/src/utils/filter-by-max-size.ts +++ b/src/utils/filter-by-max-size.ts @@ -1,6 +1,6 @@ import { stat } from 'fs/promises'; import { STAT_CONCURRENCY_LIMIT, runTasksWithLimit } from './batch-lint'; -import { formatMiB } from './parse-size'; +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 @@ -16,7 +16,7 @@ export const filterFilesByMaxSize = async ( const { size } = await stat(file); if (size > limitBytes) { console.error( - `warning: skipped large Markdown file ${file}, size ${formatMiB(size)} exceeds limit ${formatMiB(limitBytes)}` + `warning: skipped large Markdown file ${file}, size ${formatBytes(size)} exceeds limit ${formatBytes(limitBytes)}` ); return null; } diff --git a/src/utils/parse-size.ts b/src/utils/parse-size.ts index 08e3bee..cc3e293 100644 --- a/src/utils/parse-size.ts +++ b/src/utils/parse-size.ts @@ -28,7 +28,20 @@ export const parseSize = (input: string): number => { return bytes; }; -// Formats bytes as "X.X MiB", dropping a trailing ".0" to match the -// issue's warning examples (8.3 MiB, 5 MiB). -export const formatMiB = (bytes: number): string => - `${(bytes / (1024 * 1024)).toFixed(1).replace(/\.0$/, '')} MiB`; +const SIZE_LABELS = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; + +// Formats a byte count with a dynamic unit, picking the largest unit where +// the value stays >= 1 so small files read in B / KiB instead of a +// misleading "0 MiB". One decimal, trailing ".0" dropped. +export const formatBytes = (bytes: number): string => { + if (bytes < 1024) { + return `${bytes} B`; + } + let value = bytes; + let unit = 0; + while (value >= 1024 && unit < SIZE_LABELS.length - 1) { + value /= 1024; + unit++; + } + return `${value.toFixed(1).replace(/\.0$/, '')} ${SIZE_LABELS[unit]}`; +}; From 9e161cf638eef43be9190071f28537b5a552b804 Mon Sep 17 00:00:00 2001 From: luojiyin Date: Thu, 9 Jul 2026 10:26:55 +0800 Subject: [PATCH 3/3] fix(cli): make parseSize unit regex explicit (#81) Replace the permissive [bkmg]+ unit class (which accepted then rejected kk/mbb) with an explicit b|k|kb|m|mb|g|gb alternation, matching the issue's format definition. Drop the now-dead multiplier guard and add a test pinning malformed-unit rejection. --- __tests__/parse-size.spec.ts | 6 ++++++ src/utils/parse-size.ts | 12 +++++------- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/__tests__/parse-size.spec.ts b/__tests__/parse-size.spec.ts index 912d989..e33e864 100644 --- a/__tests__/parse-size.spec.ts +++ b/__tests__/parse-size.spec.ts @@ -21,6 +21,12 @@ describe('parseSize', () => { 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', () => { diff --git a/src/utils/parse-size.ts b/src/utils/parse-size.ts index cc3e293..e0f0c9c 100644 --- a/src/utils/parse-size.ts +++ b/src/utils/parse-size.ts @@ -9,18 +9,16 @@ const SIZE_UNITS: Record = { }; // Parses a human size string (e.g. "5mb", "500kb", "1gb") into bytes. -// Rejects decimals (1.5mb), whitespace (1 mb), 0, negatives, and any -// unrecognized unit. Throws on invalid input so callers can present the -// error in the CLI style (see getMaxFileSizeOption in configure.ts). +// Units are explicitly b / k / kb / m / mb / g / gb (case-insensitive); +// decimals, whitespace, 0, negatives, and any other unit are rejected. +// Throws on invalid input so callers can present the error in the CLI +// style (see getMaxFileSizeOption in configure.ts). export const parseSize = (input: string): number => { - const match = /^(\d+)([bkmg]+)$/i.exec(input.trim()); + const match = /^(\d+)(b|k|kb|m|mb|g|gb)$/i.exec(input.trim()); if (!match) { throw new Error('invalid size'); } const multiplier = SIZE_UNITS[match[2].toLowerCase()]; - if (!multiplier) { - throw new Error('invalid size'); - } const bytes = Number(match[1]) * multiplier; if (!Number.isFinite(bytes) || bytes <= 0) { throw new Error('invalid size');