diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index 5d045e7..0000000 --- a/.eslintrc.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - extends: '@attachments/eslint-config', - rules: { - 'no-console': 'off', - }, -}; diff --git a/__tests__/batch-lint.spec.ts b/__tests__/batch-lint.spec.ts index 55d6f09..f34e3e1 100644 --- a/__tests__/batch-lint.spec.ts +++ b/__tests__/batch-lint.spec.ts @@ -1,32 +1,39 @@ -import { mkdtemp, rm, writeFile } from 'fs/promises'; -import { availableParallelism, tmpdir } from 'os'; -import * as path from 'path'; -import { Piscina } from 'piscina'; -import type { LintMdRulesConfig } from '@lint-md/core'; -import { STAT_CONCURRENCY_LIMIT, batchLint, getMaxFileSize, keepLintItem, resolveAdaptiveConcurrency, runTasksWithLimit } from '../src/utils/batch-lint'; -import type { BatchLintItem } from '../src/types'; +import { mkdtemp, rm, writeFile } from "fs/promises"; +import { availableParallelism, tmpdir } from "os"; +import * as path from "path"; +import { Piscina } from "piscina"; +import type { LintMdRulesConfig } from "@lint-md/core"; +import { + STAT_CONCURRENCY_LIMIT, + batchLint, + getMaxFileSize, + keepLintItem, + resolveAdaptiveConcurrency, + runTasksWithLimit, +} from "../src/utils/batch-lint"; +import type { BatchLintItem } from "../src/types"; const makeItem = (overrides: Partial = {}): BatchLintItem => ({ - path: 'doc.md', + path: "doc.md", lintResult: [], ...overrides, }); const RULES_NO_EMPTY_LIST: LintMdRulesConfig = { - 'no-empty-list': 2, + "no-empty-list": 2, }; -const TRIGGER_CONTENT = '1. hello\n2.\n'; +const TRIGGER_CONTENT = "1. hello\n2.\n"; -describe('runTasksWithLimit', () => { - test('respects concurrency limit', async () => { +describe("runTasksWithLimit", () => { + test("respects concurrency limit", async () => { let running = 0; let maxRunning = 0; const tasks = Array.from({ length: 10 }, () => async () => { running++; maxRunning = Math.max(maxRunning, running); - await new Promise(resolve => setTimeout(resolve, 10)); + await new Promise((resolve) => setTimeout(resolve, 10)); running--; return true; }); @@ -35,50 +42,56 @@ describe('runTasksWithLimit', () => { expect(maxRunning).toBe(2); }); - test('preserves result order', async () => { - const tasks = [3, 1, 4, 1, 5].map(n => async () => n); + test("preserves result order", async () => { + const tasks = [3, 1, 4, 1, 5].map((n) => async () => n); const result = await runTasksWithLimit(tasks, 2); expect(result).toEqual([3, 1, 4, 1, 5]); }); - test('handles empty task list', async () => { + test("handles empty task list", async () => { const result = await runTasksWithLimit([], 3); expect(result).toEqual([]); }); - test('limit greater than tasks count still works', async () => { - const tasks = [1, 2, 3].map(n => async () => n * 10); + test("limit greater than tasks count still works", async () => { + const tasks = [1, 2, 3].map((n) => async () => n * 10); const result = await runTasksWithLimit(tasks, 10); expect(result).toEqual([10, 20, 30]); }); }); -describe('batchLint', () => { +describe("batchLint", () => { let tmpDir: string; beforeEach(async () => { - tmpDir = await mkdtemp(path.join(tmpdir(), 'batch-lint-test-')); + 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 () => { + 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); + 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]); + 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); @@ -86,98 +99,137 @@ describe('batchLint', () => { }); }); - 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'); + 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); + 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'); + expect(result[0].lintResult[0].name).toBe("no-empty-list"); }); }); - describe('并发上限', () => { - test('caps concurrent worker tasks at the threads count', async () => { + 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); + return writeFile(file, TRIGGER_CONTENT, "utf8").then(() => file); }) ); - const result = await batchLint(3, files, false, false, RULES_NO_EMPTY_LIST); + const result = await batchLint( + 3, + files, + false, + false, + RULES_NO_EMPTY_LIST + ); expect(result).toHaveLength(fileCount); - expect(result.map(item => item.path)).toEqual(files); + 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'); + 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); + 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); + 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]); + 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'); + 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(); + 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'); + 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(); + await expect( + batchLint(1, [file], false, false, RULES_NO_EMPTY_LIST) + ).rejects.toThrow(); expect(destroySpy).toHaveBeenCalled(); - } - finally { + } 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); + 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'); + 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); + const result = await batchLint( + 1, + [file], + false, + false, + RULES_NO_EMPTY_LIST + ); expect(result).toHaveLength(1); expect(result[0].fixedResult == null).toBe(true); @@ -185,88 +237,116 @@ describe('batchLint', () => { }); }); -describe('keepLintItem', () => { - test('keeps items with a non-empty lint report', () => { - expect(keepLintItem(makeItem({ lintResult: [{ message: 'x', name: 'y', content: 'z', severity: 2, loc: { start: { line: 1, column: 1 }, end: { line: 1, column: 1 } } }] }))).toBe(true); +describe("keepLintItem", () => { + test("keeps items with a non-empty lint report", () => { + expect( + keepLintItem( + makeItem({ + lintResult: [ + { + message: "x", + name: "y", + content: "z", + severity: 2, + loc: { + start: { line: 1, column: 1 }, + end: { line: 1, column: 1 }, + }, + }, + ], + }) + ) + ).toBe(true); }); - test('drops items with empty lint report and null fixedResult', () => { + test("drops items with empty lint report and null fixedResult", () => { expect(keepLintItem(makeItem({ fixedResult: null }))).toBe(false); }); - test('drops items with empty lint report and empty notAppliedFixes', () => { - expect(keepLintItem(makeItem({ fixedResult: { result: 'x', notAppliedFixes: [] } }))).toBe(false); + test("drops items with empty lint report and empty notAppliedFixes", () => { + expect( + keepLintItem( + makeItem({ fixedResult: { result: "x", notAppliedFixes: [] } }) + ) + ).toBe(false); }); - test('keeps items with empty lint report but non-empty notAppliedFixes', () => { + test("keeps items with empty lint report but non-empty notAppliedFixes", () => { expect( keepLintItem( makeItem({ - fixedResult: { result: 'x', notAppliedFixes: [{ range: [0, 1], text: 'y' }] }, + fixedResult: { + result: "x", + notAppliedFixes: [{ range: [0, 1], text: "y" }], + }, }) ) ).toBe(true); }); }); -describe('getMaxFileSize', () => { +describe("getMaxFileSize", () => { let tmpDir: string; beforeEach(async () => { - tmpDir = await mkdtemp(path.join(tmpdir(), 'batch-lint-max-')); + tmpDir = await mkdtemp(path.join(tmpdir(), "batch-lint-max-")); }); afterEach(async () => { await rm(tmpDir, { recursive: true, force: true }); }); - test('returns 0 for empty list', async () => { + test("returns 0 for empty list", async () => { expect(await getMaxFileSize([])).toBe(0); }); - test('returns size of the only file', async () => { - const file = path.join(tmpDir, 'only.md'); - await writeFile(file, 'hello'); + test("returns size of the only file", async () => { + const file = path.join(tmpDir, "only.md"); + await writeFile(file, "hello"); expect(await getMaxFileSize([file])).toBe(5); }); - test('returns size of the largest file among many', async () => { - const small = path.join(tmpDir, 'small.md'); - const large = path.join(tmpDir, 'large.md'); - const medium = path.join(tmpDir, 'medium.md'); - await writeFile(small, 'a'.repeat(10)); - await writeFile(large, 'b'.repeat(1000)); - await writeFile(medium, 'c'.repeat(500)); + test("returns size of the largest file among many", async () => { + const small = path.join(tmpDir, "small.md"); + const large = path.join(tmpDir, "large.md"); + const medium = path.join(tmpDir, "medium.md"); + await writeFile(small, "a".repeat(10)); + await writeFile(large, "b".repeat(1000)); + await writeFile(medium, "c".repeat(500)); expect(await getMaxFileSize([small, large, medium])).toBe(1000); }); - test('rejects when a file cannot be stat-ed', async () => { - const missing = path.join(tmpDir, 'missing.md'); + test("rejects when a file cannot be stat-ed", async () => { + const missing = path.join(tmpDir, "missing.md"); await expect(getMaxFileSize([missing])).rejects.toThrow(); }); - test('bounds concurrent stat calls to STAT_CONCURRENCY_LIMIT', async () => { + test("bounds concurrent stat calls to STAT_CONCURRENCY_LIMIT", async () => { const fileCount = STAT_CONCURRENCY_LIMIT * 3; const filePaths = Array.from({ length: fileCount }, (_, index) => path.join(tmpDir, `bounded-${index}.md`) ); - const sizeMap = new Map(filePaths.map((filePath, index) => [filePath, index + 1])); + 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); + 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 result = await getMaxFileSize(filePaths); @@ -275,18 +355,17 @@ describe('getMaxFileSize', () => { expect(statSpy).toHaveBeenCalledTimes(fileCount); expect(maxInFlight).toBeGreaterThan(1); expect(maxInFlight).toBeLessThanOrEqual(STAT_CONCURRENCY_LIMIT); - } - finally { + } finally { statSpy.mockRestore(); } }); }); -describe('resolveAdaptiveConcurrency', () => { +describe("resolveAdaptiveConcurrency", () => { let tmpDir: string; beforeEach(async () => { - tmpDir = await mkdtemp(path.join(tmpdir(), 'batch-lint-adaptive-')); + tmpDir = await mkdtemp(path.join(tmpdir(), "batch-lint-adaptive-")); }); afterEach(async () => { @@ -299,30 +378,30 @@ describe('resolveAdaptiveConcurrency', () => { return file; }; - describe('numeric threadCount (preserves existing behavior)', () => { - test('numeric 2 with 3 files → 2', async () => { + describe("numeric threadCount (preserves existing behavior)", () => { + test("numeric 2 with 3 files → 2", async () => { const files = await Promise.all([ - writeSizedFile('a.md', 100), - writeSizedFile('b.md', 100), - writeSizedFile('c.md', 100), + writeSizedFile("a.md", 100), + writeSizedFile("b.md", 100), + writeSizedFile("c.md", 100), ]); expect(await resolveAdaptiveConcurrency(2, files)).toBe(2); }); - test('numeric threads > fileCount is clamped to fileCount', async () => { - const file = await writeSizedFile('only.md', 100); + test("numeric threads > fileCount is clamped to fileCount", async () => { + const file = await writeSizedFile("only.md", 100); expect(await resolveAdaptiveConcurrency(100, [file])).toBe(1); }); - test('numeric 0 is clamped to 1 (matches existing min clamp)', async () => { + test("numeric 0 is clamped to 1 (matches existing min clamp)", async () => { const files = await Promise.all([ - writeSizedFile('a.md', 100), - writeSizedFile('b.md', 100), + writeSizedFile("a.md", 100), + writeSizedFile("b.md", 100), ]); expect(await resolveAdaptiveConcurrency(0, files)).toBe(1); }); - test('numeric threads ignores file size', async () => { + test("numeric threads ignores file size", async () => { const files = await Promise.all( Array.from({ length: 8 }, (_, index) => writeSizedFile(`huge-${index}.md`, 10 * 1024 * 1024) @@ -333,52 +412,58 @@ describe('resolveAdaptiveConcurrency', () => { }); }); - describe('auto threadCount', () => { - test('empty file list → 0', async () => { - expect(await resolveAdaptiveConcurrency('auto', [])).toBe(0); + describe("auto threadCount", () => { + test("empty file list → 0", async () => { + expect(await resolveAdaptiveConcurrency("auto", [])).toBe(0); }); - test('small files (< 1 MiB) use cpuLimit clamped to fileCount', async () => { + test("small files (< 1 MiB) use cpuLimit clamped to fileCount", async () => { const files = await Promise.all([ - writeSizedFile('a.md', 1024), - writeSizedFile('b.md', 2048), - writeSizedFile('c.md', 4096), + writeSizedFile("a.md", 1024), + writeSizedFile("b.md", 2048), + writeSizedFile("c.md", 4096), ]); const cpuLimit = availableParallelism(); - expect(await resolveAdaptiveConcurrency('auto', files)).toBe(Math.min(cpuLimit, files.length)); + expect(await resolveAdaptiveConcurrency("auto", files)).toBe( + Math.min(cpuLimit, files.length) + ); }); - test('max file exactly 1 MiB caps at 2', async () => { + test("max file exactly 1 MiB caps at 2", async () => { const files = await Promise.all([ - writeSizedFile('small.md', 1024), - writeSizedFile('one-mib.md', 1024 * 1024), + writeSizedFile("small.md", 1024), + writeSizedFile("one-mib.md", 1024 * 1024), ]); - expect(await resolveAdaptiveConcurrency('auto', files)).toBeLessThanOrEqual(2); + expect( + await resolveAdaptiveConcurrency("auto", files) + ).toBeLessThanOrEqual(2); }); - test('max file 1.5 MiB caps at 2', async () => { - const file = await writeSizedFile('medium.md', 1.5 * 1024 * 1024); - expect(await resolveAdaptiveConcurrency('auto', [file])).toBeLessThanOrEqual(2); + test("max file 1.5 MiB caps at 2", async () => { + const file = await writeSizedFile("medium.md", 1.5 * 1024 * 1024); + expect( + await resolveAdaptiveConcurrency("auto", [file]) + ).toBeLessThanOrEqual(2); }); - test('max file exactly 5 MiB forces 1', async () => { - const file = await writeSizedFile('five-mib.md', 5 * 1024 * 1024); - expect(await resolveAdaptiveConcurrency('auto', [file])).toBe(1); + test("max file exactly 5 MiB forces 1", async () => { + const file = await writeSizedFile("five-mib.md", 5 * 1024 * 1024); + expect(await resolveAdaptiveConcurrency("auto", [file])).toBe(1); }); - test('max file 6 MiB forces 1', async () => { - const file = await writeSizedFile('six-mib.md', 6 * 1024 * 1024); - expect(await resolveAdaptiveConcurrency('auto', [file])).toBe(1); + test("max file 6 MiB forces 1", async () => { + const file = await writeSizedFile("six-mib.md", 6 * 1024 * 1024); + expect(await resolveAdaptiveConcurrency("auto", [file])).toBe(1); }); - test('single small file → 1', async () => { - const file = await writeSizedFile('only.md', 100); - expect(await resolveAdaptiveConcurrency('auto', [file])).toBe(1); + test("single small file → 1", async () => { + const file = await writeSizedFile("only.md", 100); + expect(await resolveAdaptiveConcurrency("auto", [file])).toBe(1); }); - test('medium cap respects fileCount when files < 2', async () => { - const file = await writeSizedFile('one-mib.md', 1.2 * 1024 * 1024); - expect(await resolveAdaptiveConcurrency('auto', [file])).toBe(1); + test("medium cap respects fileCount when files < 2", async () => { + const file = await writeSizedFile("one-mib.md", 1.2 * 1024 * 1024); + expect(await resolveAdaptiveConcurrency("auto", [file])).toBe(1); }); }); }); diff --git a/__tests__/cli.spec.ts b/__tests__/cli.spec.ts index 657ebb1..29ba278 100644 --- a/__tests__/cli.spec.ts +++ b/__tests__/cli.spec.ts @@ -1,11 +1,11 @@ -describe('cli tests', () => { +describe("cli tests", () => { let mockExit: jest.SpyInstance; beforeEach(() => { process.argv = []; jest.resetModules(); - mockExit = jest.spyOn(process, 'exit').mockImplementation((() => { - throw new Error('process.exit'); + mockExit = jest.spyOn(process, "exit").mockImplementation((() => { + throw new Error("process.exit"); }) as never); }); @@ -13,8 +13,8 @@ describe('cli tests', () => { mockExit.mockRestore(); }); - test('if user does not pass any argument, process.exit is called', () => { - expect(() => require('../src/lint-md')).toThrow('process.exit'); + test("if user does not pass any argument, process.exit is called", () => { + expect(() => require("../src/lint-md")).toThrow("process.exit"); expect(mockExit).toHaveBeenCalled(); }); }); diff --git a/__tests__/configure.spec.ts b/__tests__/configure.spec.ts index 1f114ba..7de93ca 100644 --- a/__tests__/configure.spec.ts +++ b/__tests__/configure.spec.ts @@ -1,15 +1,17 @@ -import { cpus } from 'os'; -import { getThreadCount } from '../src/utils/configure'; +import { cpus } from "os"; +import { getThreadCount } from "../src/utils/configure"; -describe('getThreadCount', () => { +describe("getThreadCount", () => { let mockExit: jest.SpyInstance; let mockError: jest.SpyInstance; beforeEach(() => { - mockExit = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => { + mockExit = jest.spyOn(process, "exit").mockImplementation((( + code?: number + ) => { throw new Error(`process.exit: ${code}`); }) as never); - mockError = jest.spyOn(console, 'error').mockImplementation(() => {}); + mockError = jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -17,38 +19,41 @@ describe('getThreadCount', () => { mockError.mockRestore(); }); - test('undefined → cpus().length', () => { + test("undefined → cpus().length", () => { expect(getThreadCount(undefined)).toBe(cpus().length); }); - test('false → cpus().length', () => { + test("false → cpus().length", () => { expect(getThreadCount(false)).toBe(cpus().length); }); - test('true (--threads 无值) → cpus().length', () => { + test("true (--threads 无值) → cpus().length", () => { expect(getThreadCount(true)).toBe(cpus().length); }); - test('number 2 → 2', () => { + test("number 2 → 2", () => { expect(getThreadCount(2)).toBe(2); }); test('string "1" → 1', () => { - expect(getThreadCount('1')).toBe(1); + expect(getThreadCount("1")).toBe(1); }); test('string "4" → 4', () => { - expect(getThreadCount('4')).toBe(4); + expect(getThreadCount("4")).toBe(4); }); - test.each([0, -1, '0', '-1', 'abc', '1.5', '0x10', '1e3'])('%s → exit 1 + stderr', (value) => { - expect(() => getThreadCount(value)).toThrow('process.exit: 1'); - expect(mockError).toHaveBeenCalledWith( - expect.stringContaining('--threads must be a positive integer'), - ); - }); + test.each([0, -1, "0", "-1", "abc", "1.5", "0x10", "1e3"])( + "%s → exit 1 + stderr", + (value) => { + expect(() => getThreadCount(value)).toThrow("process.exit: 1"); + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining("--threads must be a positive integer") + ); + } + ); test('"auto" → "auto"', () => { - expect(getThreadCount('auto')).toBe('auto'); + expect(getThreadCount("auto")).toBe("auto"); }); }); diff --git a/__tests__/get-report-data.spec.ts b/__tests__/get-report-data.spec.ts index bd79300..79d3db0 100644 --- a/__tests__/get-report-data.spec.ts +++ b/__tests__/get-report-data.spec.ts @@ -1,34 +1,30 @@ -import type { BatchLintItem } from '../src/types'; -import type { LintReportItem } from '@lint-md/core'; -import { getReportData } from '../src/utils/get-report-data'; +import type { BatchLintItem } from "../src/types"; +import type { LintReportItem } from "@lint-md/core"; +import { getReportData } from "../src/utils/get-report-data"; const makeReportItem = ( severity: number, overrides: Partial = {} ): LintReportItem => ({ - name: 'rule-x', - message: 'some problem', - content: 'x', + name: "rule-x", + message: "some problem", + content: "x", severity, loc: { start: { line: 1, column: 1 }, end: { line: 1, column: 2 } }, ...overrides, }); const makeItem = (overrides: Partial = {}): BatchLintItem => ({ - path: 'doc.md', + path: "doc.md", lintResult: [], ...overrides, }); -describe('getReportData', () => { - test('counts errors (severity 2) and warnings (severity 1) separately', () => { +describe("getReportData", () => { + test("counts errors (severity 2) and warnings (severity 1) separately", () => { const result = getReportData([ makeItem({ - lintResult: [ - makeReportItem(2), - makeReportItem(2), - makeReportItem(1), - ], + lintResult: [makeReportItem(2), makeReportItem(2), makeReportItem(1)], fixableErrorCount: 0, fixableWarningCount: 0, }), @@ -38,14 +34,14 @@ describe('getReportData', () => { expect(result.warningCount).toBe(1); }); - test('aggregates error/warning counts across multiple files', () => { + test("aggregates error/warning counts across multiple files", () => { const result = getReportData([ makeItem({ - path: 'a.md', + path: "a.md", lintResult: [makeReportItem(2), makeReportItem(1)], }), makeItem({ - path: 'b.md', + path: "b.md", lintResult: [makeReportItem(2), makeReportItem(2), makeReportItem(1)], }), ]); @@ -54,7 +50,7 @@ describe('getReportData', () => { expect(result.warningCount).toBe(2); }); - test('shows the fixable summary when fixable counts are present', () => { + test("shows the fixable summary when fixable counts are present", () => { const { consoleMessage } = getReportData([ makeItem({ lintResult: [makeReportItem(2), makeReportItem(1)], @@ -63,10 +59,12 @@ describe('getReportData', () => { }), ]); - expect(consoleMessage).toContain('2 errors and 1 warning potentially fixable with the `--fix` option.'); + expect(consoleMessage).toContain( + "2 errors and 1 warning potentially fixable with the `--fix` option." + ); }); - test('omits the fixable summary when fixable counts are zero', () => { + test("omits the fixable summary when fixable counts are zero", () => { const { consoleMessage } = getReportData([ makeItem({ lintResult: [makeReportItem(2), makeReportItem(1)], @@ -75,10 +73,12 @@ describe('getReportData', () => { }), ]); - expect(consoleMessage).not.toContain('potentially fixable with the `--fix` option.'); + expect(consoleMessage).not.toContain( + "potentially fixable with the `--fix` option." + ); }); - test('propagates real fixable counts through aggregation', () => { + test("propagates real fixable counts through aggregation", () => { const { consoleMessage } = getReportData([ makeItem({ lintResult: [makeReportItem(2)], @@ -92,16 +92,22 @@ describe('getReportData', () => { }), ]); - expect(consoleMessage).toContain('4 errors and 4 warnings potentially fixable with the `--fix` option.'); + expect(consoleMessage).toContain( + "4 errors and 4 warnings potentially fixable with the `--fix` option." + ); }); - test('drops files with no problems from the report', () => { + test("drops files with no problems from the report", () => { const { errorCount, warningCount, consoleMessage } = getReportData([ - makeItem({ lintResult: [], fixableErrorCount: 0, fixableWarningCount: 0 }), + makeItem({ + lintResult: [], + fixableErrorCount: 0, + fixableWarningCount: 0, + }), ]); expect(errorCount).toBe(0); expect(warningCount).toBe(0); - expect(consoleMessage).toBe(''); + expect(consoleMessage).toBe(""); }); }); diff --git a/__tests__/max-file-size.spec.ts b/__tests__/max-file-size.spec.ts index 44a4620..03b9dcd 100644 --- a/__tests__/max-file-size.spec.ts +++ b/__tests__/max-file-size.spec.ts @@ -1,187 +1,231 @@ -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'; +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 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'; +const VIOLATION = "1. hello\n2.\n"; -describe('filterFilesByMaxSize (unit)', () => { +describe("filterFilesByMaxSize (unit)", () => { let tmpDir: string; beforeEach(async () => { - tmpDir = await mkdtemp(path.join(tmpdir(), 'max-file-size-unit-')); + 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'); + 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(() => {}); + const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); try { - const kept = await filterFilesByMaxSize([small, large], parseSize('1kb')); + const kept = await filterFilesByMaxSize([small, large], parseSize("1kb")); expect(kept).toEqual([small]); expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining('warning: skipped large Markdown file') + expect.stringContaining("warning: skipped large Markdown file") ); expect(errorSpy.mock.calls[0][0]).toContain(large); - } - finally { + } 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'); + 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')); + const kept = await filterFilesByMaxSize([a, b], parseSize("10mb")); expect(kept.sort()).toEqual([a, b].sort()); }); - test('bounds stat concurrency to STAT_CONCURRENCY_LIMIT', async () => { + 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])); + 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); + 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')); + 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 { + } finally { statSpy.mockRestore(); } }); - test('propagates stat failures', async () => { - const missing = path.join(tmpDir, 'missing.md'); - await expect(filterFilesByMaxSize([missing], parseSize('10mb'))).rejects.toThrow(); + 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', () => { +describe("--max-file-size CLI behavior", () => { let tmpDir: string; beforeEach(async () => { - tmpDir = await mkdtemp(path.join(tmpdir(), 'max-file-size-cli-')); + 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'); + 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 } => { + 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'], + 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 }; + 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'); + 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']); + 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'); + 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'); + 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'); + 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'); + 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']); + 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("warning: skipped large Markdown file"); expect(stderr).toContain(path.basename(large)); - expect(stdout).toContain('no-empty-list'); + 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}`; + 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']); + 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'); + 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']); + 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 🎉'); + 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'); + 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']); + 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'); + expect(stderr).toContain("warning: skipped large Markdown file"); + expect(stdout).toContain("no-empty-list"); }); }); diff --git a/__tests__/parse-size.spec.ts b/__tests__/parse-size.spec.ts index e33e864..1d42761 100644 --- a/__tests__/parse-size.spec.ts +++ b/__tests__/parse-size.spec.ts @@ -1,41 +1,41 @@ -import { formatBytes, parseSize } from '../src/utils/parse-size'; +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); +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 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(); + 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'); +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/__tests__/report-unapplied-fixes.spec.ts b/__tests__/report-unapplied-fixes.spec.ts index eed7b54..194a950 100644 --- a/__tests__/report-unapplied-fixes.spec.ts +++ b/__tests__/report-unapplied-fixes.spec.ts @@ -1,94 +1,94 @@ -import { getUnappliedFixesWarnings } from '../src/utils/report-unapplied-fixes'; -import type { BatchLintItem } from '../src/types'; +import { getUnappliedFixesWarnings } from "../src/utils/report-unapplied-fixes"; +import type { BatchLintItem } from "../src/types"; const makeItem = (overrides: Partial = {}): BatchLintItem => ({ - path: 'doc.md', + path: "doc.md", lintResult: [], ...overrides, }); -describe('getUnappliedFixesWarnings', () => { - test('returns no warnings for an empty result', () => { +describe("getUnappliedFixesWarnings", () => { + test("returns no warnings for an empty result", () => { expect(getUnappliedFixesWarnings([])).toEqual([]); }); - test('returns no warnings when fixedResult is null', () => { + test("returns no warnings when fixedResult is null", () => { const result: BatchLintItem[] = [makeItem({ fixedResult: null })]; expect(getUnappliedFixesWarnings(result)).toEqual([]); }); - test('returns no warnings when notAppliedFixes is empty', () => { + test("returns no warnings when notAppliedFixes is empty", () => { const result: BatchLintItem[] = [ - makeItem({ fixedResult: { result: 'x', notAppliedFixes: [] } }), + makeItem({ fixedResult: { result: "x", notAppliedFixes: [] } }), ]; expect(getUnappliedFixesWarnings(result)).toEqual([]); }); - test('warns once with the correct count for a single unapplied fix', () => { + test("warns once with the correct count for a single unapplied fix", () => { const result: BatchLintItem[] = [ makeItem({ - path: 'a.md', + path: "a.md", fixedResult: { - result: 'x', - notAppliedFixes: [{ range: [0, 1], text: 'y' }], + result: "x", + notAppliedFixes: [{ range: [0, 1], text: "y" }], }, }), ]; expect(getUnappliedFixesWarnings(result)).toEqual([ - '[lint-md] a.md: 1 fixes were not applied due to conflicts.', + "[lint-md] a.md: 1 fixes were not applied due to conflicts.", ]); }); - test('reports the count for multiple unapplied fixes and preserves the path', () => { + test("reports the count for multiple unapplied fixes and preserves the path", () => { const result: BatchLintItem[] = [ makeItem({ - path: 'conflict.md', + path: "conflict.md", fixedResult: { - result: 'x', + result: "x", notAppliedFixes: [ - { range: [0, 1], text: 'y' }, - { range: [2, 3], text: 'z' }, - { range: [4, 5], text: 'w' }, + { range: [0, 1], text: "y" }, + { range: [2, 3], text: "z" }, + { range: [4, 5], text: "w" }, ], }, }), ]; expect(getUnappliedFixesWarnings(result)).toEqual([ - '[lint-md] conflict.md: 3 fixes were not applied due to conflicts.', + "[lint-md] conflict.md: 3 fixes were not applied due to conflicts.", ]); }); - test('only warns for files that have unapplied fixes', () => { + test("only warns for files that have unapplied fixes", () => { const result: BatchLintItem[] = [ makeItem({ - path: 'ok.md', - fixedResult: { result: 'x', notAppliedFixes: [] }, + path: "ok.md", + fixedResult: { result: "x", notAppliedFixes: [] }, }), makeItem({ - path: 'bad.md', + path: "bad.md", fixedResult: { - result: 'x', - notAppliedFixes: [{ range: [0, 1], text: 'y' }], + result: "x", + notAppliedFixes: [{ range: [0, 1], text: "y" }], }, }), ]; expect(getUnappliedFixesWarnings(result)).toEqual([ - '[lint-md] bad.md: 1 fixes were not applied due to conflicts.', + "[lint-md] bad.md: 1 fixes were not applied due to conflicts.", ]); }); - test('sanitizes file path in warning output', () => { + test("sanitizes file path in warning output", () => { const result: BatchLintItem[] = [ makeItem({ - path: 'bad\u001B[31m.md\nnext-line', + path: "bad\u001B[31m.md\nnext-line", fixedResult: { - result: 'x', - notAppliedFixes: [{ range: [0, 1], text: 'y' }], + result: "x", + notAppliedFixes: [{ range: [0, 1], text: "y" }], }, }), ]; - expect(getUnappliedFixesWarnings(result)[0]).not.toContain('\u001B'); - expect(getUnappliedFixesWarnings(result)[0]).not.toContain('\n'); + expect(getUnappliedFixesWarnings(result)[0]).not.toContain("\u001B"); + expect(getUnappliedFixesWarnings(result)[0]).not.toContain("\n"); }); }); diff --git a/__tests__/sanitize-terminal.spec.ts b/__tests__/sanitize-terminal.spec.ts index 0fc2804..436fe97 100644 --- a/__tests__/sanitize-terminal.spec.ts +++ b/__tests__/sanitize-terminal.spec.ts @@ -1,36 +1,36 @@ -import { sanitizeTerminalText } from '../src/utils/sanitize-terminal'; +import { sanitizeTerminalText } from "../src/utils/sanitize-terminal"; -describe('sanitizeTerminalText', () => { - test('strips OSC title sequence', () => { - expect(sanitizeTerminalText('\u001B]0;evil title\u0007')).toBe(''); +describe("sanitizeTerminalText", () => { + test("strips OSC title sequence", () => { + expect(sanitizeTerminalText("\u001B]0;evil title\u0007")).toBe(""); }); - test('strips OSC 8 hyperlink sequence', () => { - const input = '\u001B]8;;https://evil.com\u001B\\click\u001B]8;;\u001B\\'; - expect(sanitizeTerminalText(input)).toBe('click'); + test("strips OSC 8 hyperlink sequence", () => { + const input = "\u001B]8;;https://evil.com\u001B\\click\u001B]8;;\u001B\\"; + expect(sanitizeTerminalText(input)).toBe("click"); }); - test('replaces CR with ^M', () => { - expect(sanitizeTerminalText('file\rname')).toBe('file^Mname'); + test("replaces CR with ^M", () => { + expect(sanitizeTerminalText("file\rname")).toBe("file^Mname"); }); - test('replaces NUL with ^@', () => { - expect(sanitizeTerminalText('file\u0000name')).toBe('file^@name'); + test("replaces NUL with ^@", () => { + expect(sanitizeTerminalText("file\u0000name")).toBe("file^@name"); }); - test('replaces BEL with ^G', () => { - expect(sanitizeTerminalText('file\u0007name')).toBe('file^Gname'); + test("replaces BEL with ^G", () => { + expect(sanitizeTerminalText("file\u0007name")).toBe("file^Gname"); }); - test('preserves Unicode', () => { - expect(sanitizeTerminalText('文件.md')).toBe('文件.md'); + test("preserves Unicode", () => { + expect(sanitizeTerminalText("文件.md")).toBe("文件.md"); }); - test('replaces tab with ^I', () => { - expect(sanitizeTerminalText('col1\tcol2')).toBe('col1^Icol2'); + test("replaces tab with ^I", () => { + expect(sanitizeTerminalText("col1\tcol2")).toBe("col1^Icol2"); }); - test('replaces newline with ^J', () => { - expect(sanitizeTerminalText('line1\nline2')).toBe('line1^Jline2'); + test("replaces newline with ^J", () => { + expect(sanitizeTerminalText("line1\nline2")).toBe("line1^Jline2"); }); }); diff --git a/__tests__/stdin-fix.spec.ts b/__tests__/stdin-fix.spec.ts index 7b38dac..8443372 100644 --- a/__tests__/stdin-fix.spec.ts +++ b/__tests__/stdin-fix.spec.ts @@ -1,77 +1,77 @@ -import { execFileSync } from 'child_process'; -import * as path from 'path'; -import * as fs from 'fs'; -import { lintMarkdown } from '@lint-md/core'; +import { execFileSync } from "child_process"; +import * as path from "path"; +import * as fs from "fs"; +import { lintMarkdown } from "@lint-md/core"; -const TSX = path.resolve(__dirname, '../node_modules/tsx/dist/cli.mjs'); -const CLI = path.resolve(__dirname, '../src/lint-md.ts'); -const EXAMPLE = path.resolve(__dirname, '../examples/space-around.md'); +const TSX = path.resolve(__dirname, "../node_modules/tsx/dist/cli.mjs"); +const CLI = path.resolve(__dirname, "../src/lint-md.ts"); +const EXAMPLE = path.resolve(__dirname, "../examples/space-around.md"); -describe('--stdin --fix output', () => { - test('stdout only contains fixed markdown, no timing info', () => { - const input = fs.readFileSync(EXAMPLE, 'utf8'); +describe("--stdin --fix output", () => { + test("stdout only contains fixed markdown, no timing info", () => { + const input = fs.readFileSync(EXAMPLE, "utf8"); const stdout = execFileSync( process.execPath, - [TSX, CLI, '--stdin', '--fix'], + [TSX, CLI, "--stdin", "--fix"], { input, - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'], - }, + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + } ); - expect(stdout).not.toContain('Done in'); - expect(stdout).not.toContain('⌛️'); + expect(stdout).not.toContain("Done in"); + expect(stdout).not.toContain("⌛️"); expect(stdout.length).toBeGreaterThan(0); }); - test('fixed output passes lint with zero errors', () => { - const input = fs.readFileSync(EXAMPLE, 'utf8'); + test("fixed output passes lint with zero errors", () => { + const input = fs.readFileSync(EXAMPLE, "utf8"); const stdout = execFileSync( process.execPath, - [TSX, CLI, '--stdin', '--fix'], + [TSX, CLI, "--stdin", "--fix"], { input, - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'], - }, + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + } ); const result = lintMarkdown(stdout, {}, false); expect(result.lintResult).toHaveLength(0); }); - test('clean markdown → stdout equals input, no report/timing', () => { - const clean = '# Hello\n\nThis is clean.\n'; + test("clean markdown → stdout equals input, no report/timing", () => { + const clean = "# Hello\n\nThis is clean.\n"; const stdout = execFileSync( process.execPath, - [TSX, CLI, '--stdin', '--fix'], + [TSX, CLI, "--stdin", "--fix"], { input: clean, - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'], - }, + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + } ); expect(stdout).toBe(clean); - expect(stdout).not.toContain('Done in'); - expect(stdout).not.toContain('⌛️'); + expect(stdout).not.toContain("Done in"); + expect(stdout).not.toContain("⌛️"); }); - test('whitespace-only stdin in fix mode is passed through unchanged', () => { - const input = ' \n\n'; + test("whitespace-only stdin in fix mode is passed through unchanged", () => { + const input = " \n\n"; const stdout = execFileSync( process.execPath, - [TSX, CLI, '--stdin', '--fix'], + [TSX, CLI, "--stdin", "--fix"], { input, - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'], - }, + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + } ); expect(stdout).toBe(input); diff --git a/__tests__/symlink-security.spec.ts b/__tests__/symlink-security.spec.ts index 9f1f9a6..5948f04 100644 --- a/__tests__/symlink-security.spec.ts +++ b/__tests__/symlink-security.spec.ts @@ -1,61 +1,61 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { safeWriteFile } from '../src/utils/safe-write-file'; -import { loadMdFiles } from '../src/utils/load-md-files'; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { safeWriteFile } from "../src/utils/safe-write-file"; +import { loadMdFiles } from "../src/utils/load-md-files"; let tmpDir: string; beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lint-md-symlink-test-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lint-md-symlink-test-")); }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); -describe('symlink security', () => { - test('safeWriteFile writes to regular file', async () => { - const filePath = path.join(tmpDir, 'normal.md'); - fs.writeFileSync(filePath, '# old'); +describe("symlink security", () => { + test("safeWriteFile writes to regular file", async () => { + const filePath = path.join(tmpDir, "normal.md"); + fs.writeFileSync(filePath, "# old"); - await safeWriteFile(filePath, '# new'); + await safeWriteFile(filePath, "# new"); - expect(fs.readFileSync(filePath, 'utf8')).toBe('# new'); + expect(fs.readFileSync(filePath, "utf8")).toBe("# new"); }); - test('loadMdFiles includes normal .md files', async () => { - const filePath = path.join(tmpDir, 'normal.md'); - fs.writeFileSync(filePath, '# normal'); + test("loadMdFiles includes normal .md files", async () => { + const filePath = path.join(tmpDir, "normal.md"); + fs.writeFileSync(filePath, "# normal"); - const result = await loadMdFiles([path.join(tmpDir, '*.md')], []); + const result = await loadMdFiles([path.join(tmpDir, "*.md")], []); expect(result).toContain(filePath); }); - test('loadMdFiles filters .md symlinks', async () => { - const realFile = path.join(tmpDir, 'real.md'); - const symlinkFile = path.join(tmpDir, 'link.md'); - fs.writeFileSync(realFile, '# real'); + test("loadMdFiles filters .md symlinks", async () => { + const realFile = path.join(tmpDir, "real.md"); + const symlinkFile = path.join(tmpDir, "link.md"); + fs.writeFileSync(realFile, "# real"); fs.symlinkSync(realFile, symlinkFile); - const result = await loadMdFiles([path.join(tmpDir, '*.md')], []); + const result = await loadMdFiles([path.join(tmpDir, "*.md")], []); expect(result).toContain(realFile); expect(result).not.toContain(symlinkFile); }); - test('loadMdFiles does not filter files inside symlinked directories', async () => { - const realDir = path.join(tmpDir, 'real'); - const linkDir = path.join(tmpDir, 'link'); + test("loadMdFiles does not filter files inside symlinked directories", async () => { + const realDir = path.join(tmpDir, "real"); + const linkDir = path.join(tmpDir, "link"); fs.mkdirSync(realDir); - fs.writeFileSync(path.join(realDir, 'a.md'), '# a'); - fs.symlinkSync(realDir, linkDir, 'dir'); + fs.writeFileSync(path.join(realDir, "a.md"), "# a"); + fs.symlinkSync(realDir, linkDir, "dir"); - const result = await loadMdFiles([path.join(tmpDir, '**', '*.md')], []); + const result = await loadMdFiles([path.join(tmpDir, "**", "*.md")], []); - const realResult = path.join(realDir, 'a.md'); - const linkResult = path.join(linkDir, 'a.md'); + const realResult = path.join(realDir, "a.md"); + const linkResult = path.join(linkDir, "a.md"); expect(result).toContain(realResult); expect(result).toContain(linkResult); // Note: safeWriteFile rejects a symlink as the final path component, @@ -63,12 +63,12 @@ describe('symlink security', () => { // This test documents the current loadMdFiles behavior. }); - test('loadMdFiles deduplicates overlapping patterns', async () => { - const filePath = path.join(tmpDir, 'dup.md'); - fs.writeFileSync(filePath, '# dup'); + test("loadMdFiles deduplicates overlapping patterns", async () => { + const filePath = path.join(tmpDir, "dup.md"); + fs.writeFileSync(filePath, "# dup"); const result = await loadMdFiles( - [path.join(tmpDir, '*.md'), path.join(tmpDir, '*.md')], + [path.join(tmpDir, "*.md"), path.join(tmpDir, "*.md")], [] ); @@ -76,12 +76,12 @@ describe('symlink security', () => { expect(result).toContain(filePath); }); - test('loadMdFiles returns absolute paths for relative patterns', async () => { - const filePath = path.join(tmpDir, 'rel.md'); - fs.writeFileSync(filePath, '# rel'); + test("loadMdFiles returns absolute paths for relative patterns", async () => { + const filePath = path.join(tmpDir, "rel.md"); + fs.writeFileSync(filePath, "# rel"); const cwd = process.cwd(); - const relativePattern = path.relative(cwd, path.join(tmpDir, '*.md')); + const relativePattern = path.relative(cwd, path.join(tmpDir, "*.md")); const result = await loadMdFiles([relativePattern], []); @@ -89,41 +89,38 @@ describe('symlink security', () => { expect(path.isAbsolute(result[0])).toBe(true); }); - test('loadMdFiles handles nonexistent paths gracefully', async () => { - const result = await loadMdFiles([path.join(tmpDir, 'no-such-*.md')], []); + test("loadMdFiles handles nonexistent paths gracefully", async () => { + const result = await loadMdFiles([path.join(tmpDir, "no-such-*.md")], []); expect(result).toEqual([]); }); - test('loadMdFiles respects extensions filter', async () => { - fs.writeFileSync(path.join(tmpDir, 'a.md'), '# a'); - fs.writeFileSync(path.join(tmpDir, 'b.txt'), 'text'); + test("loadMdFiles respects extensions filter", async () => { + fs.writeFileSync(path.join(tmpDir, "a.md"), "# a"); + fs.writeFileSync(path.join(tmpDir, "b.txt"), "text"); - const result = await loadMdFiles( - [path.join(tmpDir, '*')], - [], - ['.md'] - ); + const result = await loadMdFiles([path.join(tmpDir, "*")], [], [".md"]); - expect(result).toContain(path.join(tmpDir, 'a.md')); - expect(result).not.toContain(path.join(tmpDir, 'b.txt')); + expect(result).toContain(path.join(tmpDir, "a.md")); + expect(result).not.toContain(path.join(tmpDir, "b.txt")); }); - test('safeWriteFile rejects symlink', async () => { - const target = path.join(tmpDir, 'target.md'); - const symlink = path.join(tmpDir, 'link.md'); - fs.writeFileSync(target, '# target'); + test("safeWriteFile rejects symlink", async () => { + const target = path.join(tmpDir, "target.md"); + const symlink = path.join(tmpDir, "link.md"); + fs.writeFileSync(target, "# target"); fs.symlinkSync(target, symlink); - await expect(safeWriteFile(symlink, '# hacked')) - .rejects.toThrow(/ELOOP|ENOENT/); + await expect(safeWriteFile(symlink, "# hacked")).rejects.toThrow( + /ELOOP|ENOENT/ + ); }); - test('safeWriteFile rejects file replaced with symlink after scan', async () => { - const filePath = path.join(tmpDir, 'file.md'); - const target = path.join(tmpDir, 'target.md'); - fs.writeFileSync(filePath, '# original'); - fs.writeFileSync(target, '# target'); + test("safeWriteFile rejects file replaced with symlink after scan", async () => { + const filePath = path.join(tmpDir, "file.md"); + const target = path.join(tmpDir, "target.md"); + fs.writeFileSync(filePath, "# original"); + fs.writeFileSync(target, "# target"); // Simulate TOCTOU: scan sees regular file, then attacker replaces with symlink const stat = fs.lstatSync(filePath); @@ -133,28 +130,29 @@ describe('symlink security', () => { fs.unlinkSync(filePath); fs.symlinkSync(target, filePath); - await expect(safeWriteFile(filePath, '# hacked')) - .rejects.toThrow(/ELOOP|ENOENT/); + await expect(safeWriteFile(filePath, "# hacked")).rejects.toThrow( + /ELOOP|ENOENT/ + ); }); - test('safeWriteFile preserves original file mode', async () => { - const filePath = path.join(tmpDir, 'mode-test.md'); - fs.writeFileSync(filePath, '# old'); + test("safeWriteFile preserves original file mode", async () => { + const filePath = path.join(tmpDir, "mode-test.md"); + fs.writeFileSync(filePath, "# old"); fs.chmodSync(filePath, 0o666); - await safeWriteFile(filePath, '# new'); + await safeWriteFile(filePath, "# new"); const mode = fs.statSync(filePath).mode & 0o777; expect(mode).toBe(0o666); - expect(fs.readFileSync(filePath, 'utf8')).toBe('# new'); + expect(fs.readFileSync(filePath, "utf8")).toBe("# new"); }); - test('safeWriteFile truncates old tail when new content is shorter', async () => { - const filePath = path.join(tmpDir, 'shorter.md'); - fs.writeFileSync(filePath, '# old content with tail'); + test("safeWriteFile truncates old tail when new content is shorter", async () => { + const filePath = path.join(tmpDir, "shorter.md"); + fs.writeFileSync(filePath, "# old content with tail"); - await safeWriteFile(filePath, '# new'); + await safeWriteFile(filePath, "# new"); - expect(fs.readFileSync(filePath, 'utf8')).toBe('# new'); + expect(fs.readFileSync(filePath, "utf8")).toBe("# new"); }); }); diff --git a/__tests__/test-utils.ts b/__tests__/test-utils.ts index 2244e3d..d209162 100644 --- a/__tests__/test-utils.ts +++ b/__tests__/test-utils.ts @@ -1,4 +1,4 @@ -import * as path from 'path'; +import * as path from "path"; // 获取样例目录 -export const examplePath = path.resolve(process.cwd(), 'examples'); +export const examplePath = path.resolve(process.cwd(), "examples"); diff --git a/__tests__/threads-validation.spec.ts b/__tests__/threads-validation.spec.ts index d0a2db1..15edaff 100644 --- a/__tests__/threads-validation.spec.ts +++ b/__tests__/threads-validation.spec.ts @@ -1,79 +1,76 @@ -import { execFileSync } from 'child_process'; -import { mkdtempSync, rmSync, writeFileSync } from 'fs'; -import { tmpdir } from 'os'; -import * as path from 'path'; +import { execFileSync } from "child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import * as path from "path"; -const TSX = path.resolve(__dirname, '../node_modules/tsx/dist/cli.mjs'); -const CLI = path.resolve(__dirname, '../src/lint-md.ts'); +const TSX = path.resolve(__dirname, "../node_modules/tsx/dist/cli.mjs"); +const CLI = path.resolve(__dirname, "../src/lint-md.ts"); -describe('--threads validation across CLI paths', () => { - test('stdin + --threads abc → exit 1 + stderr', () => { +describe("--threads validation across CLI paths", () => { + test("stdin + --threads abc → exit 1 + stderr", () => { try { execFileSync( process.execPath, - [TSX, CLI, '--stdin', '--threads', 'abc'], + [TSX, CLI, "--stdin", "--threads", "abc"], { - input: '# title\n', - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'], - }, + input: "# title\n", + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + } ); - throw new Error('should have thrown'); - } - catch (e: any) { + throw new Error("should have thrown"); + } catch (e: any) { expect(e.status).toBe(1); - expect(e.stderr).toContain('--threads must be a positive integer'); + expect(e.stderr).toContain("--threads must be a positive integer"); } }); - test('no matching files + --threads abc → exit 1 + stderr', () => { + test("no matching files + --threads abc → exit 1 + stderr", () => { try { execFileSync( process.execPath, - [TSX, CLI, '/tmp/nonexistent-dir', '--threads', 'abc'], + [TSX, CLI, "/tmp/nonexistent-dir", "--threads", "abc"], { - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'], - }, + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + } ); - throw new Error('should have thrown'); - } - catch (e: any) { + throw new Error("should have thrown"); + } catch (e: any) { expect(e.status).toBe(1); - expect(e.stderr).toContain('--threads must be a positive integer'); + expect(e.stderr).toContain("--threads must be a positive integer"); } }); - test('stdin + --threads auto → does not exit 1 (numeric validation skipped)', () => { + test("stdin + --threads auto → does not exit 1 (numeric validation skipped)", () => { const result = execFileSync( process.execPath, - [TSX, CLI, '--stdin', '--threads', 'auto'], + [TSX, CLI, "--stdin", "--threads", "auto"], { - input: '# title\n', - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'], - }, + input: "# title\n", + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + } ); - expect(result).toContain('Done in'); + expect(result).toContain("Done in"); }); - test('files + --threads auto → exit 0 on a small markdown file', () => { - const tmpDir = mkdtempSync(path.join(tmpdir(), 'threads-auto-test-')); + test("files + --threads auto → exit 0 on a small markdown file", () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), "threads-auto-test-")); try { - const file = path.join(tmpDir, 'small.md'); - writeFileSync(file, '# hello\n', 'utf8'); + const file = path.join(tmpDir, "small.md"); + writeFileSync(file, "# hello\n", "utf8"); const result = execFileSync( process.execPath, - [TSX, CLI, file, '--threads', 'auto'], + [TSX, CLI, file, "--threads", "auto"], { - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'], - }, + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + } ); - expect(result).toContain('Done in'); - } - finally { + expect(result).toContain("Done in"); + } finally { rmSync(tmpDir, { recursive: true, force: true }); } }); diff --git a/package.json b/package.json index 0104801..b04ae78 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "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", + "typecheck": "tsc -p tsconfig.json --noEmit", + "lint": "npm run typecheck && prettier --check \"src/**/*.ts\" \"__tests__/**/*.ts\"", "build": "rm -rf esm lib && run-p build:*", "clean": "rm -rf lib esm", "watch": "tsc -w", @@ -65,10 +66,8 @@ "text-table": "^0.2.0" }, "devDependencies": { - "@attachments/eslint-config": "^0.3.4", "@types/jest": "^30.0.0", "@types/node": "^22.9.3", - "eslint": "8.26.0", "jest": "^30.0.0", "npm-run-all": "^4.1.5", "prettier": "^2.7.1", diff --git a/src/index.ts b/src/index.ts index 4be8bd4..c8ba790 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,2 @@ // type declarations -export { CLILintResult, CLIConfig, CliErrorCount, CLIOptions } from './types'; +export { CLILintResult, CLIConfig, CliErrorCount, CLIOptions } from "./types"; diff --git a/src/lint-md.ts b/src/lint-md.ts index 125784f..d2c3a9b 100644 --- a/src/lint-md.ts +++ b/src/lint-md.ts @@ -1,58 +1,63 @@ #!/usr/bin/env node -import * as process from 'process'; -import { readFileSync } from 'fs'; -import { availableParallelism } from 'os'; -import { program } from 'commander'; -import { lintMarkdown } from '@lint-md/core'; -import { version } from '../package.json'; -import { safeWriteFile } from './utils/safe-write-file'; +import * as process from "process"; +import { readFileSync } from "fs"; +import { availableParallelism } from "os"; +import { program } from "commander"; +import { lintMarkdown } from "@lint-md/core"; +import { version } from "../package.json"; +import { safeWriteFile } from "./utils/safe-write-file"; import { batchLint, getMaxFileSize, resolveAdaptiveConcurrency, runTasksWithLimit, -} from './utils/batch-lint'; -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'; -import { getUnappliedFixesWarnings } from './utils/report-unapplied-fixes'; +} from "./utils/batch-lint"; +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"; +import { getUnappliedFixesWarnings } from "./utils/report-unapplied-fixes"; program .version( version, - '-v, --version', - 'output the version number(查看当前版本)' + "-v, --version", + "output the version number(查看当前版本)" ) - .usage(' [files...]') - .description('lint your markdown files') + .usage(" [files...]") + .description("lint your markdown files") .option( - '-c, --config [configure-file]', - 'use the configure file, default .lintmdrc(使用配置文件,默认为 .lintmdrc)' + "-c, --config [configure-file]", + "use the configure file, default .lintmdrc(使用配置文件,默认为 .lintmdrc)" ) - .option('-f, --fix', 'fix the errors automatically(开启修复模式)') - .option('-d, --dev', 'open dev mode(开启开发者模式)') + .option("-f, --fix", "fix the errors automatically(开启修复模式)") + .option("-d, --dev", "open dev mode(开启开发者模式)") .option( - '-t, --threads [thread-count]', + "-t, --threads [thread-count]", 'Number of worker threads, or "auto" to cap concurrency for large files. Default: CPU count.(执行 Lint / Fix 的线程数,传 "auto" 时根据文件大小自适应)' ) .option( - '-s, --suppress-warnings', - 'suppress all warnings, that means warnings will not block CI(抑制所有警告,这意味着警告不会阻止 CI)' + "-s, --suppress-warnings", + "suppress all warnings, that means warnings will not block CI(抑制所有警告,这意味着警告不会阻止 CI)" ) .option( - '-i, --stdin', - 'read markdown content from stdin(从标准输入中读取内容)' + "-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 文件)' + "--max-file-size ", + "skip Markdown files larger than (e.g. 5mb, 500kb, 1gb), warn to stderr(跳过超过指定大小的 Markdown 文件)" ) - .arguments('[files...]') + .arguments("[files...]") .action(async (files: string[], options: CLIOptions) => { - const { fix, config, threads, dev, suppressWarnings, stdin, maxFileSize } = options; + const { fix, config, threads, dev, suppressWarnings, stdin, maxFileSize } = + options; const startTime = Date.now(); const isFixMode = Boolean(fix); @@ -72,7 +77,7 @@ program // Handle stdin mode if (stdin) { - const content = readFileSync(process.stdin.fd, 'utf8'); + const content = readFileSync(process.stdin.fd, "utf8"); if (isFixMode) { if (content.length === 0) { @@ -88,35 +93,34 @@ program const result = lintMarkdown(content, rules, true); process.stdout.write(result.fixedResult?.result ?? content); return; - } - catch (e) { + } catch (e) { console.error(e); process.exit(1); } } if (!content.trim()) { - console.error('No content to lint'); + console.error("No content to lint"); process.exit(0); } try { const result = lintMarkdown(content, rules, false); - const { consoleMessage, errorCount, warningCount } - = getReportData([{ - path: '(stdin)', + const { consoleMessage, errorCount, warningCount } = getReportData([ + { + path: "(stdin)", lintResult: result.lintResult, fixableErrorCount: result.fixableErrorCount, fixableWarningCount: result.fixableWarningCount, - }]); + }, + ]); console.log(consoleMessage); if (errorCount > 0 || (!suppressWarnings && warningCount !== 0)) { process.exit(1); } - } - catch (e) { + } catch (e) { console.error(e); process.exit(1); } @@ -139,14 +143,17 @@ program } if (!mdFiles.length) { - console.log('🎉 No markdown files to lint 🎉'); + console.log("🎉 No markdown files to lint 🎉"); process.exit(0); return; } - const effectiveThreads = await resolveAdaptiveConcurrency(threadCount, mdFiles); + const effectiveThreads = await resolveAdaptiveConcurrency( + threadCount, + mdFiles + ); - if (isDev && threadCount === 'auto') { + if (isDev && threadCount === "auto") { const maxFileSize = await getMaxFileSize(mdFiles); const adaptiveApplied = maxFileSize >= 1024 * 1024; const requested = availableParallelism(); @@ -168,20 +175,23 @@ program ); if (!isFixMode) { - const { consoleMessage, errorCount, warningCount } - = getReportData(lintResult); + const { consoleMessage, errorCount, warningCount } = + getReportData(lintResult); console.log(consoleMessage); if (errorCount > 0 || (!suppressWarnings && warningCount !== 0)) { process.exit(1); } - } - else { + } else { await runTasksWithLimit( lintResult .filter(({ fixedResult }) => fixedResult) - .map(({ path, fixedResult }) => () => safeWriteFile(path, fixedResult!.result)), + .map( + ({ path, fixedResult }) => + () => + safeWriteFile(path, fixedResult!.result) + ), effectiveThreads ); @@ -189,8 +199,7 @@ program console.error(warning); } } - } - catch (e) { + } catch (e) { console.error(e); process.exit(1); } @@ -201,7 +210,7 @@ program program.parse(process.argv); -const isStdin = process.argv.includes('--stdin') || process.argv.includes('-i'); +const isStdin = process.argv.includes("--stdin") || process.argv.includes("-i"); if (!program.args.length && !isStdin) { program.help(); } diff --git a/src/types.ts b/src/types.ts index e650ea2..50b54ab 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,49 +1,53 @@ /** CLI 配置 */ -import type { LintMdRulesConfig, LintReportItem, FixedResult } from '@lint-md/core'; +import type { + LintMdRulesConfig, + LintReportItem, + FixedResult, +} from "@lint-md/core"; -export type ThreadCount = number | 'auto'; +export type ThreadCount = number | "auto"; export interface CLIConfig { - excludeFiles?: string[] - rules?: LintMdRulesConfig - extensions?: string[] + excludeFiles?: string[]; + rules?: LintMdRulesConfig; + extensions?: string[]; } /** 用户传入的 CLI 选项 */ export interface CLIOptions { - fix?: boolean - dev?: boolean - config?: string - suppressWarnings: boolean - threads?: string | boolean - stdin?: boolean - maxFileSize?: string + fix?: boolean; + dev?: boolean; + config?: string; + suppressWarnings: boolean; + threads?: string | boolean; + stdin?: boolean; + maxFileSize?: string; } /** CLI lint 结果选项 */ export interface CLILintResult { - path: string - file: string + path: string; + file: string; } /** CLI lint 错误统计信息 */ export interface CliErrorCount { - error: number - warning: number + error: number; + warning: number; } export interface LintWorkerOptions { - filePath: string - rules?: LintMdRulesConfig - isFixMode?: boolean - isDev?: boolean + filePath: string; + rules?: LintMdRulesConfig; + isFixMode?: boolean; + isDev?: boolean; } /** batchLint 单个文件的 lint 结果 */ export interface BatchLintItem { - path: string - lintResult: LintReportItem[] - fixedResult?: FixedResult | null - fixableErrorCount?: number - fixableWarningCount?: number + path: string; + lintResult: LintReportItem[]; + fixedResult?: FixedResult | null; + fixableErrorCount?: number; + fixableWarningCount?: number; } diff --git a/src/utils/batch-lint.ts b/src/utils/batch-lint.ts index 0a4d1ef..2fc709a 100644 --- a/src/utils/batch-lint.ts +++ b/src/utils/batch-lint.ts @@ -1,10 +1,10 @@ -import path from 'path'; -import { existsSync } from 'fs'; -import { stat } from 'fs/promises'; -import { availableParallelism } from 'os'; -import { Piscina } from 'piscina'; -import type { LintMdRulesConfig } from '@lint-md/core'; -import type { BatchLintItem, LintWorkerOptions, ThreadCount } from '../types'; +import path from "path"; +import { existsSync } from "fs"; +import { stat } from "fs/promises"; +import { availableParallelism } from "os"; +import { Piscina } from "piscina"; +import type { LintMdRulesConfig } from "@lint-md/core"; +import type { BatchLintItem, LintWorkerOptions, ThreadCount } from "../types"; const ONE_MIB = 1024 * 1024; const FIVE_MIB = 5 * ONE_MIB; @@ -28,17 +28,19 @@ export async function runTasksWithLimit( } } - const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => runNext()); + const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => + runNext() + ); await Promise.all(workers); return results; } const resolveWorkerFilename = (): string => { - const compiled = path.resolve(__dirname, './lint-worker.js'); + const compiled = path.resolve(__dirname, "./lint-worker.js"); if (existsSync(compiled)) { return compiled; } - return path.resolve(__dirname, '../../lib/src/utils/lint-worker.js'); + return path.resolve(__dirname, "../../lib/src/utils/lint-worker.js"); }; // getMaxFileSize() stats every file but bounds the in-flight stat calls to @@ -54,7 +56,9 @@ export const getMaxFileSize = async (filePaths: string[]): Promise => { return 0; } const sizes = await runTasksWithLimit( - filePaths.map(filePath => () => stat(filePath).then(stats => stats.size)), + filePaths.map( + (filePath) => () => stat(filePath).then((stats) => stats.size) + ), STAT_CONCURRENCY_LIMIT ); return sizes.reduce((max, current) => (current > max ? current : max), 0); @@ -68,7 +72,7 @@ export const resolveAdaptiveConcurrency = async ( return 0; } - if (typeof threadCount === 'number') { + if (typeof threadCount === "number") { return Math.min(Math.max(threadCount, 1), mdFilePaths.length); } @@ -78,8 +82,7 @@ export const resolveAdaptiveConcurrency = async ( let limit = cpuLimit; if (maxFileSize >= ADAPTIVE_HUGE_FILE_THRESHOLD) { limit = 1; - } - else if (maxFileSize >= ADAPTIVE_LARGE_FILE_THRESHOLD) { + } else if (maxFileSize >= ADAPTIVE_LARGE_FILE_THRESHOLD) { limit = Math.min(limit, ADAPTIVE_MEDIUM_CAP); } @@ -91,8 +94,8 @@ export const resolveAdaptiveConcurrency = async ( // occur without a lint report, so we must not drop it (see #86 / P1-6 // "partially-unfixed is observable", which the #89 stderr warning surfaces). export const keepLintItem = (item: BatchLintItem): boolean => - item.lintResult.length > 0 - || Boolean(item.fixedResult?.notAppliedFixes?.length); + item.lintResult.length > 0 || + Boolean(item.fixedResult?.notAppliedFixes?.length); export const batchLint = async ( threadsCount: number, @@ -115,19 +118,19 @@ export const batchLint = async ( try { const results = await runTasksWithLimit( mdFilePaths.map((filePath) => { - return () => lintWorkerPool.run({ - filePath, - isFixMode, - rules, - isDev, - } as LintWorkerOptions); + return () => + lintWorkerPool.run({ + filePath, + isFixMode, + rules, + isDev, + } as LintWorkerOptions); }), concurrency ); return results.filter(keepLintItem); - } - finally { + } finally { await lintWorkerPool.destroy(); } }; diff --git a/src/utils/configure.ts b/src/utils/configure.ts index e21b802..c485210 100644 --- a/src/utils/configure.ts +++ b/src/utils/configure.ts @@ -1,9 +1,9 @@ -import * as fs from 'fs'; -import { availableParallelism } from 'os'; -import * as path from 'path'; -import chalk from 'chalk'; -import type { CLIConfig, ThreadCount } from '../types'; -import { parseSize } from './parse-size'; +import * as fs from "fs"; +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)) { @@ -15,17 +15,15 @@ export const getLintConfig = (configFilePath?: string): Required => { let config: CLIConfig; - const configPath = path.resolve(configFilePath || './.lintmdrc'); + const configPath = path.resolve(configFilePath || "./.lintmdrc"); // 如果不存在文件直接返回空对象 if (!fs.existsSync(configPath)) { config = {}; - } - else { + } else { try { config = JSON.parse(fs.readFileSync(configPath).toString()); - } - catch (e) { + } catch (e) { console.log( chalk.red(`[lint-md] Configure file '${configPath}' is invalid.`) ); @@ -35,9 +33,9 @@ export const getLintConfig = (configFilePath?: string): Required => { } return { - excludeFiles: ['**/node_modules/**', '**/.git/**'], + excludeFiles: ["**/node_modules/**", "**/.git/**"], rules: {}, - extensions: ['.md', '.markdown', '.mdx'], + extensions: [".md", ".markdown", ".mdx"], ...config, }; }; @@ -45,25 +43,25 @@ export const getLintConfig = (configFilePath?: string): Required => { export const getThreadCount = ( threadCount?: string | number | boolean ): ThreadCount => { - if (threadCount === 'auto') { - return 'auto'; + if (threadCount === "auto") { + return "auto"; } // 只接受 number 或 string,其他(undefined / boolean)视为未指定 - if (typeof threadCount !== 'number' && typeof threadCount !== 'string') { + if (typeof threadCount !== "number" && typeof threadCount !== "string") { return availableParallelism(); } // 字符串必须是十进制正整数(拒绝 0x10、1e3、010 等) - if (typeof threadCount === 'string' && !/^[1-9]\d*$/.test(threadCount)) { - console.error(chalk.red('[lint-md] --threads must be a positive integer.')); + if (typeof threadCount === "string" && !/^[1-9]\d*$/.test(threadCount)) { + console.error(chalk.red("[lint-md] --threads must be a positive integer.")); process.exit(1); } const num = Number(threadCount); if (!Number.isInteger(num) || num <= 0) { - console.error(chalk.red('[lint-md] --threads must be a positive integer.')); + console.error(chalk.red("[lint-md] --threads must be a positive integer.")); process.exit(1); } @@ -77,16 +75,17 @@ export const getThreadCount = ( export const getMaxFileSizeOption = ( maxFileSize?: string | boolean ): number | null => { - if (maxFileSize === undefined || typeof maxFileSize !== 'string') { + if (maxFileSize === undefined || typeof maxFileSize !== "string") { return null; } try { return parseSize(maxFileSize); - } - catch { + } catch { console.error( - chalk.red('[lint-md] --max-file-size must be a valid size (e.g. 5mb, 500kb, 1gb).') + 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 index c58050b..af308f2 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 { formatBytes } from './parse-size'; +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 @@ -12,11 +12,13 @@ export const filterFilesByMaxSize = async ( limitBytes: number ): Promise => { const results = await runTasksWithLimit( - mdFiles.map(file => async () => { + 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)}` + `warning: skipped large Markdown file ${file}, size ${formatBytes( + size + )} exceeds limit ${formatBytes(limitBytes)}` ); return null; } diff --git a/src/utils/get-report-data.ts b/src/utils/get-report-data.ts index 07de4ed..a049a5a 100644 --- a/src/utils/get-report-data.ts +++ b/src/utils/get-report-data.ts @@ -1,8 +1,8 @@ -import chalk from 'chalk'; -import table from 'text-table'; -import stripAnsi from 'strip-ansi'; -import type { BatchLintItem } from '../types'; -import { sanitizeTerminalText } from './sanitize-terminal'; +import chalk from "chalk"; +import table from "text-table"; +import stripAnsi from "strip-ansi"; +import type { BatchLintItem } from "../types"; +import { sanitizeTerminalText } from "./sanitize-terminal"; function pluralize(word: string, count: number): string { return count === 1 ? word : `${word}s`; @@ -10,18 +10,18 @@ function pluralize(word: string, count: number): string { interface Result { messages: { - fatal: boolean - severity: number - line: number - column: number - message: string - ruleId: string - }[] - errorCount: number - warningCount: number - fixableErrorCount: number - fixableWarningCount: number - filePath: string + fatal: boolean; + severity: number; + line: number; + column: number; + message: string; + ruleId: string; + }[]; + errorCount: number; + warningCount: number; + fixableErrorCount: number; + fixableWarningCount: number; + filePath: string; } export const getReportData = (problemResult: BatchLintItem[]) => { @@ -30,11 +30,11 @@ export const getReportData = (problemResult: BatchLintItem[]) => { const { path, lintResult } = res; const errorCount = lintResult.filter( - item => item.severity === 2 + (item) => item.severity === 2 ).length; const warningCount = lintResult.filter( - item => item.severity === 1 + (item) => item.severity === 1 ).length; if (errorCount + warningCount === 0) { @@ -62,12 +62,12 @@ export const getReportData = (problemResult: BatchLintItem[]) => { }) .filter(Boolean); - let output = '\n'; + let output = "\n"; let errorCount = 0; let warningCount = 0; let fixableErrorCount = 0; let fixableWarningCount = 0; - let summaryColor = 'yellow'; + let summaryColor = "yellow"; results.forEach((result) => { const messages = result.messages; @@ -88,34 +88,33 @@ export const getReportData = (problemResult: BatchLintItem[]) => { let messageType; if (message.fatal || message.severity === 2) { - messageType = chalk.red('error'); - summaryColor = 'red'; - } - else { - messageType = chalk.yellow('warning'); + messageType = chalk.red("error"); + summaryColor = "red"; + } else { + messageType = chalk.yellow("warning"); } return [ - '', + "", message.line || 0, message.column || 0, messageType, - sanitizeTerminalText(message.message).replace(/([^ ])\.$/u, '$1'), - chalk.dim(sanitizeTerminalText(message.ruleId || '')), + sanitizeTerminalText(message.message).replace(/([^ ])\.$/u, "$1"), + chalk.dim(sanitizeTerminalText(message.ruleId || "")), ]; }), { - align: ['', 'r', 'l'], + align: ["", "r", "l"], stringLength(str) { return stripAnsi(str).length; }, } ) - .split('\n') - .map(el => + .split("\n") + .map((el) => el.replace(/(\d+)\s+(\d+)/u, (m, p1, p2) => chalk.dim(`${p1}:${p2}`)) ) - .join('\n')}\n\n`; + .join("\n")}\n\n`; }); const total = errorCount + warningCount; @@ -123,37 +122,37 @@ export const getReportData = (problemResult: BatchLintItem[]) => { if (total > 0) { output += chalk[summaryColor].bold( [ - '\u2716 ', + "\u2716 ", total, - pluralize(' problem', total), - ' (', + pluralize(" problem", total), + " (", errorCount, - pluralize(' error', errorCount), - ', ', + pluralize(" error", errorCount), + ", ", warningCount, - pluralize(' warning', warningCount), - ')\n', - ].join('') + pluralize(" warning", warningCount), + ")\n", + ].join("") ); if (fixableErrorCount > 0 || fixableWarningCount > 0) { output += chalk[summaryColor].bold( [ - ' ', + " ", fixableErrorCount, - pluralize(' error', fixableErrorCount), - ' and ', + pluralize(" error", fixableErrorCount), + " and ", fixableWarningCount, - pluralize(' warning', fixableWarningCount), - ' potentially fixable with the `--fix` option.\n', - ].join('') + pluralize(" warning", fixableWarningCount), + " potentially fixable with the `--fix` option.\n", + ].join("") ); } } // Resets output color, for prevent change on top level return { - consoleMessage: total > 0 ? chalk.reset(output) : '', + consoleMessage: total > 0 ? chalk.reset(output) : "", errorCount, warningCount, }; diff --git a/src/utils/lint-worker.ts b/src/utils/lint-worker.ts index e94160b..c885ebf 100644 --- a/src/utils/lint-worker.ts +++ b/src/utils/lint-worker.ts @@ -1,23 +1,23 @@ -import { readFile } from 'fs/promises'; -import { lintMarkdown } from '@lint-md/core'; -import type { LintWorkerOptions } from '../types'; +import { readFile } from "fs/promises"; +import { lintMarkdown } from "@lint-md/core"; +import type { LintWorkerOptions } from "../types"; const lintWorker = async (options: LintWorkerOptions) => { const { filePath, rules, isFixMode, isDev } = options; const start = new Date().getTime(); - const content = await readFile(filePath, 'utf8'); + const content = await readFile(filePath, "utf8"); const result = lintMarkdown(content, rules, isFixMode); const end = new Date().getTime(); if (isDev) { console.log( - 'File 耗时:', + "File 耗时:", end - start, - ' 文件:', + " 文件:", filePath, - ' 字符串长度:', + " 字符串长度:", content.length ); } diff --git a/src/utils/load-md-files.ts b/src/utils/load-md-files.ts index 7d1db97..c710bd0 100644 --- a/src/utils/load-md-files.ts +++ b/src/utils/load-md-files.ts @@ -1,4 +1,4 @@ -import { glob } from 'glob'; +import { glob } from "glob"; /** * 读取所有文件 @@ -11,7 +11,7 @@ import { glob } from 'glob'; export const loadMdFiles = async ( globList: string[], excludeFiles: string[], - extensions: string[] = ['.md', '.markdown', '.mdx'] + extensions: string[] = [".md", ".markdown", ".mdx"] ) => { const entries = await glob([...new Set(globList)], { ignore: excludeFiles, @@ -23,11 +23,9 @@ export const loadMdFiles = async ( const files = new Set(); for (const entry of entries) { - if (entry.isSymbolicLink()) - continue; + if (entry.isSymbolicLink()) continue; const fullPath = entry.fullpath(); - if (extensions.some(ext => fullPath.endsWith(ext))) - files.add(fullPath); + if (extensions.some((ext) => fullPath.endsWith(ext))) files.add(fullPath); } return [...files]; diff --git a/src/utils/parse-size.ts b/src/utils/parse-size.ts index e0f0c9c..0a5c9d0 100644 --- a/src/utils/parse-size.ts +++ b/src/utils/parse-size.ts @@ -16,17 +16,17 @@ const SIZE_UNITS: Record = { export const parseSize = (input: string): number => { const match = /^(\d+)(b|k|kb|m|mb|g|gb)$/i.exec(input.trim()); if (!match) { - throw new Error('invalid size'); + throw new Error("invalid size"); } const multiplier = SIZE_UNITS[match[2].toLowerCase()]; const bytes = Number(match[1]) * multiplier; if (!Number.isFinite(bytes) || bytes <= 0) { - throw new Error('invalid size'); + throw new Error("invalid size"); } return bytes; }; -const SIZE_LABELS = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; +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 @@ -41,5 +41,5 @@ export const formatBytes = (bytes: number): string => { value /= 1024; unit++; } - return `${value.toFixed(1).replace(/\.0$/, '')} ${SIZE_LABELS[unit]}`; + return `${value.toFixed(1).replace(/\.0$/, "")} ${SIZE_LABELS[unit]}`; }; diff --git a/src/utils/report-unapplied-fixes.ts b/src/utils/report-unapplied-fixes.ts index 6fdb4df..1632b52 100644 --- a/src/utils/report-unapplied-fixes.ts +++ b/src/utils/report-unapplied-fixes.ts @@ -1,19 +1,23 @@ -import type { BatchLintItem } from '../types'; -import { sanitizeTerminalText } from './sanitize-terminal'; +import type { BatchLintItem } from "../types"; +import { sanitizeTerminalText } from "./sanitize-terminal"; // Returns stderr warning lines for files whose --fix pass left fixes // unapplied due to conflicts. `fixedResult.notAppliedFixes` (from // @lint-md/core) contains only the final-round conflicting fixes after // core stopped dropping them across rounds, so the range coordinates are // based on the returned result text and safe to report directly. -export const getUnappliedFixesWarnings = (lintResult: BatchLintItem[]): string[] => { +export const getUnappliedFixesWarnings = ( + lintResult: BatchLintItem[] +): string[] => { const warnings: string[] = []; for (const item of lintResult) { const count = item.fixedResult?.notAppliedFixes?.length ?? 0; if (count > 0) { warnings.push( - `[lint-md] ${sanitizeTerminalText(item.path)}: ${count} fixes were not applied due to conflicts.` + `[lint-md] ${sanitizeTerminalText( + item.path + )}: ${count} fixes were not applied due to conflicts.` ); } } diff --git a/src/utils/safe-write-file.ts b/src/utils/safe-write-file.ts index 3c508d6..bdacdb5 100644 --- a/src/utils/safe-write-file.ts +++ b/src/utils/safe-write-file.ts @@ -1,5 +1,5 @@ -import { constants } from 'fs'; -import { open } from 'fs/promises'; +import { constants } from "fs"; +import { open } from "fs/promises"; /** * 安全写入文件,拒绝符号链接。 @@ -14,10 +14,7 @@ import { open } from 'fs/promises'; * 注意:这不是完全原子写入;崩溃时仍可能出现部分新内容或旧尾部残留。 */ export async function safeWriteFile(filePath: string, content: string) { - const handle = await open( - filePath, - constants.O_RDWR | constants.O_NOFOLLOW - ); + const handle = await open(filePath, constants.O_RDWR | constants.O_NOFOLLOW); try { const stat = await handle.stat(); @@ -26,7 +23,7 @@ export async function safeWriteFile(filePath: string, content: string) { throw new Error(`Refusing to write non-regular file: ${filePath}`); } - const buf = Buffer.from(content, 'utf8'); + const buf = Buffer.from(content, "utf8"); let written = 0; while (written < buf.length) { @@ -46,8 +43,7 @@ export async function safeWriteFile(filePath: string, content: string) { await handle.truncate(buf.length); await handle.sync(); - } - finally { + } finally { await handle.close(); } } diff --git a/src/utils/sanitize-terminal.ts b/src/utils/sanitize-terminal.ts index 7af3f77..ac3200e 100644 --- a/src/utils/sanitize-terminal.ts +++ b/src/utils/sanitize-terminal.ts @@ -1,4 +1,4 @@ -import { stripVTControlCharacters } from 'util'; +import { stripVTControlCharacters } from "util"; /** * 清理文本中的终端控制字符,防止 CI 日志伪造或终端劫持 @@ -8,15 +8,12 @@ import { stripVTControlCharacters } from 'util'; */ export function sanitizeTerminalText(text: string): string { // 先去除 OSC 序列(ESC ] ... BEL 或 ESC \) - // eslint-disable-next-line no-control-regex - let result = text.replace(/\u001B\].*?(?:\u0007|\u001B\\)/g, ''); + let result = text.replace(/\u001B\].*?(?:\u0007|\u001B\\)/g, ""); // 再去除 ANSI CSI 序列 result = stripVTControlCharacters(result); // 替换所有 C0 控制字符和 DEL 为可见转义 - // eslint-disable-next-line no-control-regex result = result.replace(/[\x00-\x1F\x7F]/g, (ch) => { - if (ch === '\x7F') - return '^?'; + if (ch === "\x7F") return "^?"; return `^${String.fromCharCode(ch.charCodeAt(0) + 0x40)}`; }); return result;