diff --git a/__tests__/batch-lint.spec.ts b/__tests__/batch-lint.spec.ts index f34e3e1..bd36a90 100644 --- a/__tests__/batch-lint.spec.ts +++ b/__tests__/batch-lint.spec.ts @@ -71,9 +71,9 @@ describe("batchLint", () => { await rm(tmpDir, { recursive: true, force: true }); }); - test("returns empty array when no files are provided", async () => { - const result = await batchLint(2, [], false, false, RULES_NO_EMPTY_LIST); - expect(result).toEqual([]); + test("returns empty result when no files are provided", async () => { + const result = await batchLint(2, [], false, RULES_NO_EMPTY_LIST); + expect(result).toEqual({ allResults: [], actionableResults: [] }); }); describe("路径 payload", () => { @@ -83,16 +83,18 @@ describe("batchLint", () => { await writeFile(fileA, TRIGGER_CONTENT, "utf8"); await writeFile(fileB, TRIGGER_CONTENT, "utf8"); - const result = await batchLint( + const { actionableResults } = await batchLint( 2, [fileA, fileB], false, - false, RULES_NO_EMPTY_LIST ); - expect(result.map((item) => item.path)).toEqual([fileA, fileB]); - result.forEach((item) => { + expect(actionableResults.map((item) => item.path)).toEqual([ + fileA, + fileB, + ]); + actionableResults.forEach((item) => { expect(Array.isArray(item.lintResult)).toBe(true); expect(item.lintResult.length).toBeGreaterThan(0); expect(item.fixedResult == null).toBe(true); @@ -103,17 +105,16 @@ describe("batchLint", () => { const file = path.join(tmpDir, "read-in-worker.md"); await writeFile(file, TRIGGER_CONTENT, "utf8"); - const result = await batchLint( + const { actionableResults } = 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(actionableResults).toHaveLength(1); + expect(actionableResults[0].path).toBe(file); + expect(actionableResults[0].lintResult[0].name).toBe("no-empty-list"); }); }); @@ -127,31 +128,29 @@ describe("batchLint", () => { }) ); - const result = await batchLint( + const { actionableResults } = await batchLint( 3, files, false, - false, RULES_NO_EMPTY_LIST ); - expect(result).toHaveLength(fileCount); - expect(result.map((item) => item.path)).toEqual(files); + expect(actionableResults).toHaveLength(fileCount); + expect(actionableResults.map((item) => item.path)).toEqual(files); }); test("threads greater than files does not error", async () => { const file = path.join(tmpDir, "single.md"); await writeFile(file, TRIGGER_CONTENT, "utf8"); - const result = await batchLint( + const { actionableResults } = await batchLint( 16, [file], false, - false, RULES_NO_EMPTY_LIST ); - expect(result).toHaveLength(1); + expect(actionableResults).toHaveLength(1); }); }); @@ -164,15 +163,18 @@ describe("batchLint", () => { await writeFile(fileB, TRIGGER_CONTENT, "utf8"); await writeFile(fileC, TRIGGER_CONTENT, "utf8"); - const result = await batchLint( + const { actionableResults } = await batchLint( 2, [fileA, fileB, fileC], false, - false, RULES_NO_EMPTY_LIST ); - expect(result.map((item) => item.path)).toEqual([fileA, fileB, fileC]); + expect(actionableResults.map((item) => item.path)).toEqual([ + fileA, + fileB, + fileC, + ]); }); }); @@ -182,7 +184,7 @@ describe("batchLint", () => { await writeFile(file, "# Clean content\n", "utf8"); await expect( - batchLint(2, [file], false, false, RULES_NO_EMPTY_LIST) + batchLint(2, [file], false, RULES_NO_EMPTY_LIST) ).resolves.toBeDefined(); }); @@ -192,7 +194,7 @@ describe("batchLint", () => { try { await expect( - batchLint(1, [file], false, false, RULES_NO_EMPTY_LIST) + batchLint(1, [file], false, RULES_NO_EMPTY_LIST) ).rejects.toThrow(); expect(destroySpy).toHaveBeenCalled(); } finally { @@ -206,33 +208,31 @@ describe("batchLint", () => { const file = path.join(tmpDir, "fixable.md"); await writeFile(file, TRIGGER_CONTENT, "utf8"); - const result = await batchLint( + const { actionableResults } = 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(); + expect(actionableResults).toHaveLength(1); + expect(actionableResults[0].fixedResult).not.toBeNull(); + expect(actionableResults[0].fixedResult?.result).toBeDefined(); }); test("does not return fixedResult when fix mode is disabled", async () => { const file = path.join(tmpDir, "no-fix.md"); await writeFile(file, TRIGGER_CONTENT, "utf8"); - const result = await batchLint( + const { actionableResults } = await batchLint( 1, [file], false, - false, RULES_NO_EMPTY_LIST ); - expect(result).toHaveLength(1); - expect(result[0].fixedResult == null).toBe(true); + expect(actionableResults).toHaveLength(1); + expect(actionableResults[0].fixedResult == null).toBe(true); }); }); }); diff --git a/__tests__/keep-lint-item.spec.ts b/__tests__/keep-lint-item.spec.ts new file mode 100644 index 0000000..0a5aa8d --- /dev/null +++ b/__tests__/keep-lint-item.spec.ts @@ -0,0 +1,91 @@ +import { keepLintItem } from "../src/utils/batch-lint"; +import { FixConvergence } from "@lint-md/core"; +import type { FixedResult } from "@lint-md/core"; +import type { BatchLintItem } from "../src/types"; + +const baseItem = ( + overrides: Partial & { + convergence?: FixConvergence; + } +): BatchLintItem => { + const { convergence, ...rest } = overrides; + const fixedResult: FixedResult = { + result: "", + notAppliedFixes: [], + }; + if (convergence !== undefined) { + fixedResult.convergence = convergence; + } + return { + path: "a.md", + lintResult: [], + fixedResult, + ...rest, + }; +}; + +describe("keepLintItem", () => { + test("keeps items with lint findings", () => { + expect( + keepLintItem( + baseItem({ + lintResult: [ + { + loc: { + start: { line: 1, column: 1 }, + end: { line: 1, column: 2 }, + }, + message: "x", + name: "r", + content: "x", + severity: 2 as any, + }, + ], + }) + ) + ).toBe(true); + }); + + test("keeps items with unapplied fixes", () => { + expect( + keepLintItem( + baseItem({ + fixedResult: { + result: "", + notAppliedFixes: [{ range: [0, 1], text: "x" }], + }, + }) + ) + ).toBe(true); + }); + + test("keeps items with cycle convergence so #98 warning has a target", () => { + expect( + keepLintItem(baseItem({ convergence: FixConvergence.CYCLE_DETECTED })) + ).toBe(true); + }); + + test("keeps items with max convergence so #98 warning has a target", () => { + expect( + keepLintItem(baseItem({ convergence: FixConvergence.MAX_ROUNDS })) + ).toBe(true); + }); + + test("drops items with stable convergence and no other signal", () => { + expect(keepLintItem(baseItem({ convergence: FixConvergence.STABLE }))).toBe( + false + ); + }); + + test("drops items with fixedResult: null", () => { + expect(keepLintItem(baseItem({ fixedResult: null }))).toBe(false); + }); + + test("drops items with fixedResult: undefined", () => { + expect(keepLintItem(baseItem({ fixedResult: undefined }))).toBe(false); + }); + + test("treats pre-#182 cores (no convergence field) like the old behaviour", () => { + expect(keepLintItem(baseItem({}))).toBe(false); + }); +}); diff --git a/__tests__/report-incomplete-fixes.spec.ts b/__tests__/report-incomplete-fixes.spec.ts new file mode 100644 index 0000000..2c8298a --- /dev/null +++ b/__tests__/report-incomplete-fixes.spec.ts @@ -0,0 +1,154 @@ +import { + getFixDevMetrics, + getIncompleteFixWarnings, + isIncompleteFix, +} from "../src/utils/report-incomplete-fixes"; +import { FixConvergence } from "@lint-md/core"; +import type { FixedResult } from "@lint-md/core"; +import type { BatchLintItem } from "../src/types"; + +const makeItem = ( + overrides: Partial & { + convergence?: FixConvergence; + metrics?: FixedResult["metrics"]; + } +): BatchLintItem => { + const { convergence, metrics, ...rest } = overrides; + const fixedResult: FixedResult = { + result: "", + notAppliedFixes: [], + }; + if (convergence !== undefined) { + fixedResult.convergence = convergence; + } + if (metrics) { + fixedResult.metrics = metrics; + } + return { + path: "docs/example.md", + lintResult: [], + fixedResult, + ...rest, + }; +}; + +describe("report-incomplete-fixes", () => { + describe("isIncompleteFix", () => { + test("returns true for cycle convergence", () => { + expect( + isIncompleteFix( + makeItem({ convergence: FixConvergence.CYCLE_DETECTED }) + ) + ).toBe(true); + }); + + test("returns true for max convergence", () => { + expect( + isIncompleteFix(makeItem({ convergence: FixConvergence.MAX_ROUNDS })) + ).toBe(true); + }); + + test("returns false for stable convergence", () => { + expect( + isIncompleteFix(makeItem({ convergence: FixConvergence.STABLE })) + ).toBe(false); + }); + + test("returns false when convergence is undefined (pre-#182 cores)", () => { + expect(isIncompleteFix(makeItem({}))).toBe(false); + }); + + test("returns false when fixedResult is null", () => { + expect(isIncompleteFix(makeItem({ fixedResult: null }))).toBe(false); + }); + }); + + describe("getIncompleteFixWarnings", () => { + test("emits one warning per incomplete item, in input order", () => { + const items: BatchLintItem[] = [ + makeItem({ path: "a.md", convergence: FixConvergence.STABLE }), + makeItem({ path: "b.md", convergence: FixConvergence.CYCLE_DETECTED }), + makeItem({ path: "c.md", convergence: FixConvergence.MAX_ROUNDS }), + makeItem({ path: "d.md", convergence: FixConvergence.STABLE }), + makeItem({ path: "e.md", convergence: FixConvergence.CYCLE_DETECTED }), + ]; + + expect(getIncompleteFixWarnings(items)).toEqual([ + "[lint-md] Fix did not fully converge for b.md: cycle.", + "[lint-md] Fix did not fully converge for c.md: max.", + "[lint-md] Fix did not fully converge for e.md: cycle.", + ]); + }); + + test("skips items without a fixedResult", () => { + const items: BatchLintItem[] = [ + makeItem({ path: "lint-only.md", fixedResult: null }), + makeItem({ + path: "broken.md", + convergence: FixConvergence.CYCLE_DETECTED, + }), + ]; + + expect(getIncompleteFixWarnings(items)).toEqual([ + "[lint-md] Fix did not fully converge for broken.md: cycle.", + ]); + }); + + test("sanitizes the file path", () => { + const items: BatchLintItem[] = [ + makeItem({ + path: "evil\n::error::spoof.md", + convergence: FixConvergence.MAX_ROUNDS, + }), + ]; + + const [warning] = getIncompleteFixWarnings(items); + expect(warning).not.toContain("\n::error::"); + expect(warning).toContain("evil"); + }); + + test("returns no warnings for a clean fix run", () => { + const items: BatchLintItem[] = [ + makeItem({ path: "ok.md", convergence: FixConvergence.STABLE }), + makeItem({ path: "old.md" }), + ]; + + expect(getIncompleteFixWarnings(items)).toEqual([]); + }); + }); + + describe("getFixDevMetrics", () => { + test("emits one line per item that has metrics", () => { + const items: BatchLintItem[] = [ + makeItem({ + path: "a.md", + convergence: FixConvergence.STABLE, + metrics: { rounds: 1, wallTime: 0.5, perRound: [0.5] }, + }), + makeItem({ path: "b.md", convergence: FixConvergence.CYCLE_DETECTED }), + makeItem({ + path: "c.md", + convergence: FixConvergence.MAX_ROUNDS, + metrics: { rounds: 10, wallTime: 3.14159, perRound: [0.1, 0.2] }, + }), + ]; + + expect(getFixDevMetrics(items)).toEqual([ + "[lint-md] Fix metrics: a.md: convergence=stable, rounds=1, wallTime=0.50ms", + "[lint-md] Fix metrics: c.md: convergence=max, rounds=10, wallTime=3.14ms", + ]); + }); + + test("returns no lines when no item exposes metrics", () => { + expect( + getFixDevMetrics([ + makeItem({ path: "a.md", convergence: FixConvergence.STABLE }), + makeItem({ + path: "b.md", + convergence: FixConvergence.CYCLE_DETECTED, + }), + ]) + ).toEqual([]); + }); + }); +}); diff --git a/src/lint-md.ts b/src/lint-md.ts index 4551a00..954fa2f 100644 --- a/src/lint-md.ts +++ b/src/lint-md.ts @@ -23,6 +23,10 @@ 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"; +import { + getFixDevMetrics, + getIncompleteFixWarnings, +} from "./utils/report-incomplete-fixes"; import { formatCoreError } from "./utils/format-core-error"; program @@ -93,6 +97,21 @@ program try { const result = lintMarkdown(content, rules, true); process.stdout.write(result.fixedResult?.result ?? content); + const stdinItem = { + path: "(stdin)", + lintResult: result.lintResult, + fixedResult: result.fixedResult, + fixableErrorCount: result.fixableErrorCount, + fixableWarningCount: result.fixableWarningCount, + }; + for (const warning of getIncompleteFixWarnings([stdinItem])) { + console.error(warning); + } + if (isDev) { + for (const line of getFixDevMetrics([stdinItem])) { + console.error(line); + } + } return; } catch (e) { const formatted = formatCoreError(e); @@ -169,17 +188,16 @@ program } try { - const lintResult = await batchLint( + const { allResults, actionableResults } = await batchLint( effectiveThreads, mdFiles, - isDev, isFixMode, rules ); if (!isFixMode) { const { consoleMessage, errorCount, warningCount } = - getReportData(lintResult); + getReportData(actionableResults); console.log(consoleMessage); @@ -188,7 +206,7 @@ program } } else { await runTasksWithLimit( - lintResult + actionableResults .filter(({ fixedResult }) => fixedResult) .map( ({ path, fixedResult }) => @@ -198,9 +216,18 @@ program effectiveThreads ); - for (const warning of getUnappliedFixesWarnings(lintResult)) { + for (const warning of getIncompleteFixWarnings(actionableResults)) { console.error(warning); } + for (const warning of getUnappliedFixesWarnings(actionableResults)) { + console.error(warning); + } + + if (isDev) { + for (const line of getFixDevMetrics(allResults)) { + console.log(line); + } + } } } catch (e) { const formatted = formatCoreError(e); diff --git a/src/types.ts b/src/types.ts index 50b54ab..22e9526 100644 --- a/src/types.ts +++ b/src/types.ts @@ -40,7 +40,6 @@ export interface LintWorkerOptions { filePath: string; rules?: LintMdRulesConfig; isFixMode?: boolean; - isDev?: boolean; } /** batchLint 单个文件的 lint 结果 */ diff --git a/src/utils/batch-lint.ts b/src/utils/batch-lint.ts index 2fc709a..0827463 100644 --- a/src/utils/batch-lint.ts +++ b/src/utils/batch-lint.ts @@ -5,6 +5,7 @@ import { availableParallelism } from "os"; import { Piscina } from "piscina"; import type { LintMdRulesConfig } from "@lint-md/core"; import type { BatchLintItem, LintWorkerOptions, ThreadCount } from "../types"; +import { isIncompleteFix } from "./report-incomplete-fixes"; const ONE_MIB = 1024 * 1024; const FIVE_MIB = 5 * ONE_MIB; @@ -93,19 +94,30 @@ export const resolveAdaptiveConcurrency = async ( // core left fixes unapplied due to conflicts. notAppliedFixes can in theory // 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). +// Also retain items whose fix pass did not fully converge (cycle / max) so +// the #98 stderr warning has a target. Older cores that predate the +// `convergence` field leave it undefined, which is treated as stable and +// filtered as before. export const keepLintItem = (item: BatchLintItem): boolean => item.lintResult.length > 0 || - Boolean(item.fixedResult?.notAppliedFixes?.length); + Boolean(item.fixedResult?.notAppliedFixes?.length) || + isIncompleteFix(item); + +export interface BatchLintResult { + /** Every worker result, including clean files. Used for dev metrics. */ + allResults: BatchLintItem[]; + /** Worker results filtered through `keepLintItem`. Used for I/O, warnings, and reporting. */ + actionableResults: BatchLintItem[]; +} export const batchLint = async ( threadsCount: number, mdFilePaths: string[], - isDev: boolean, isFixMode: boolean, rules: LintMdRulesConfig -): Promise => { +): Promise => { if (mdFilePaths.length === 0) { - return []; + return { allResults: [], actionableResults: [] }; } const concurrency = Math.min(Math.max(threadsCount, 1), mdFilePaths.length); @@ -116,20 +128,22 @@ export const batchLint = async ( }); try { - const results = await runTasksWithLimit( + const allResults = await runTasksWithLimit( mdFilePaths.map((filePath) => { return () => lintWorkerPool.run({ filePath, isFixMode, rules, - isDev, } as LintWorkerOptions); }), concurrency ); - return results.filter(keepLintItem); + return { + allResults, + actionableResults: allResults.filter(keepLintItem), + }; } finally { await lintWorkerPool.destroy(); } diff --git a/src/utils/lint-worker.ts b/src/utils/lint-worker.ts index c885ebf..c71b350 100644 --- a/src/utils/lint-worker.ts +++ b/src/utils/lint-worker.ts @@ -3,25 +3,11 @@ 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 { filePath, rules, isFixMode } = options; const content = await readFile(filePath, "utf8"); const result = lintMarkdown(content, rules, isFixMode); - const end = new Date().getTime(); - - if (isDev) { - console.log( - "File 耗时:", - end - start, - " 文件:", - filePath, - " 字符串长度:", - content.length - ); - } - return { path: filePath, lintResult: result.lintResult, diff --git a/src/utils/report-incomplete-fixes.ts b/src/utils/report-incomplete-fixes.ts new file mode 100644 index 0000000..62d75cc --- /dev/null +++ b/src/utils/report-incomplete-fixes.ts @@ -0,0 +1,58 @@ +import type { BatchLintItem } from "../types"; +import { FixConvergence } from "@lint-md/core"; +import { sanitizeTerminalText } from "./sanitize-terminal"; + +// @lint-md/core 2.1.5 (see core #182) exposes an optional +// `convergence: "stable" | "cycle" | "max"` on FixedResult. CLI treats +// `cycle` (oscillation) and `max` (MAX_ROUNDS truncation) as quality +// warnings, never hard errors — the fix pass still produced output. +const INCOMPLETE_CONVERGENCE: ReadonlySet = new Set([ + FixConvergence.CYCLE_DETECTED, + FixConvergence.MAX_ROUNDS, +]); + +export const isIncompleteFix = (item: BatchLintItem): boolean => { + const convergence = item.fixedResult?.convergence; + return convergence !== undefined && INCOMPLETE_CONVERGENCE.has(convergence); +}; + +export const getIncompleteFixWarnings = ( + lintResult: BatchLintItem[] +): string[] => { + const warnings: string[] = []; + + for (const item of lintResult) { + const convergence = item.fixedResult?.convergence; + if (convergence !== undefined && INCOMPLETE_CONVERGENCE.has(convergence)) { + warnings.push( + `[lint-md] Fix did not fully converge for ${sanitizeTerminalText( + item.path + )}: ${convergence}.` + ); + } + } + + return warnings; +}; + +export const getFixDevMetrics = (lintResult: BatchLintItem[]): string[] => { + const lines: string[] = []; + + for (const item of lintResult) { + const metrics = item.fixedResult?.metrics; + if (!metrics) { + continue; + } + const convergence = item.fixedResult?.convergence ?? "unknown"; + const wallTime = metrics.wallTime.toFixed(2); + lines.push( + `[lint-md] Fix metrics: ${sanitizeTerminalText( + item.path + )}: convergence=${convergence}, rounds=${ + metrics.rounds + }, wallTime=${wallTime}ms` + ); + } + + return lines; +};