diff --git a/packages/comments/package.json b/packages/comments/package.json index f8c0814..88419fc 100644 --- a/packages/comments/package.json +++ b/packages/comments/package.json @@ -45,6 +45,7 @@ }, "devDependencies": { "@vitest/coverage-v8": "5.0.5", + "fast-check": "4.10.2", "vitest": "5.0.5" } } diff --git a/packages/comments/src/__tests__/lib.property.test.ts b/packages/comments/src/__tests__/lib.property.test.ts new file mode 100644 index 0000000..33ce0cd --- /dev/null +++ b/packages/comments/src/__tests__/lib.property.test.ts @@ -0,0 +1,168 @@ +import fc from "fast-check"; +import { describe, it } from "vitest"; +import { type ProcessOptions, parseAndTransformComments } from "../lib.js"; +import { realisticSource, realisticWidth } from "./realistic-source.js"; + +/** + * Tokens that the heuristics in lib.ts branch on, mixed with random words so + * the generator reaches tags, headings, lists, fences, NOTE splitting, + * directives, URLs, structured blocks, and merge eligibility. + */ +const token = fc.oneof( + fc.constantFrom( + "the", + "Overview:", + "NOTE:", + "note:", + "aNOTE:", + "@param", + "@auth0/auth0-react", + "@media", + "-", + "1.", + "```", + "->", + "(?:", + "done.", + "end!", + "why?", + "foo)", + "https://example.com/a.html", + "biome-ignore", + "license", + "Pattern explanation:", + ), + fc.stringMatching(/^[a-zA-Z]{1,12}$/), +); +const text = fc.array(token, { maxLength: 12 }).map((w) => w.join(" ")); +const indent = fc.constantFrom("", "\t", " "); +// Code lines never contain comment markers, so stripComments() below is exact. +const code = fc.constantFrom( + "}", + "const x = 1;", + "foo(),", + "return [", + "]", + "if (a) {", +); +const line = fc.oneof( + fc.tuple(indent, text).map(([i, s]) => `${i}// ${s}`), + fc.tuple(indent, code, text).map(([i, c, s]) => `${i}${c} // ${s}`), + fc + .tuple(indent, fc.array(text, { maxLength: 4 })) + .map( + ([i, ls]) => + `${i}/**\n${ls.map((l) => `${i} * ${l}`).join("\n")}\n${i} */`, + ), + fc.tuple(indent, text).map(([i, s]) => `${i}/** ${s} */`), + fc.constant(""), + code, +); +const source = fc.array(line, { maxLength: 10 }).map((ls) => ls.join("\n")); +const options: fc.Arbitrary = fc.record({ + width: fc.integer({ min: 20, max: 120 }), + wrapLineComments: fc.boolean(), + mergeLineComments: fc.boolean(), +}); + +const run = (input: string, opts: ProcessOptions) => + parseAndTransformComments(input, opts).transformed; + +function stripComments(input: string): string { + return input + .replace(/\/\*\*[\s\S]*?\*\//g, "") + .replace(/\/\/.*$/gm, "") + .split("\n") + .map((l) => l.trimEnd()) + .filter(Boolean) + .join("\n"); +} + +/** + * The words of every comment, ignoring delimiters, a trailing period (the tool + * adds those), and case (the tool upper-cases "note:"). + */ +function commentWords(input: string): string { + return input + .replace(/\/\*\*|\*\/|\/\//g, " ") + .replace(/^\s*\*/gm, " ") + .split(/\s+/) + .map((w) => w.replace(/\.+$/, "").toLowerCase()) + .filter((w) => w && w !== "*") + .sort() + .join(" "); +} + +const RUNS = { numRuns: 1000 }; + +describe("parseAndTransformComments properties", () => { + /** + * The `changed` flag drives `--dry-run` exit codes in CI, so it must be true + * exactly when the output differs from the input. Mutant: `changed: true` in + * parseAndTransformComments; fails after 1 run. + */ + it("reports changed exactly when the output differs", () => { + fc.assert( + fc.property(source, options, (input, opts) => { + const res = parseAndTransformComments(input, opts); + return ( + res.original === input && res.changed === (res.transformed !== input) + ); + }), + RUNS, + ); + }); + + /** + * It is a comment tool (README: "reflow, normalize, wrap, and merge + * comments"), so code outside comments must come out byte for byte. Mutant: + * `out.push(line)` → `out.push(line.trim())` for non-comment lines in + * wrapLineComments; fails after 8 runs. + */ + it("never changes code outside comments", () => { + fc.assert( + fc.property(source, options, (input, opts) => { + return stripComments(run(input, opts)) === stripComments(input); + }), + RUNS, + ); + }); + + /** + * A second pass changes nothing. Stated intent: the unit test "is idempotent + * (second pass unchanged)", and `comments --dry-run` in CI fails if a file + * still has changes after a fix. Scope (decided 2026-10-23): realistic prose + * only (see realistic-source.ts); degenerate input such as an empty or stacked + * "note:" can still need two passes. Mutants: skip the merge after wrapping + * (fails after 137 runs), flush a NOTE only at a line end (258 runs), disable + * the wrap guard (red in 9 of 10 runs). + */ + it("is idempotent on realistic prose", () => { + const realisticOptions = fc.record({ + width: realisticWidth, + wrapLineComments: fc.boolean(), + mergeLineComments: fc.boolean(), + }); + fc.assert( + fc.property(realisticSource, realisticOptions, (input, opts) => { + const once = run(input, opts); + return run(once, opts) === once; + }), + RUNS, + ); + }); + + /** + * Reflowing rearranges words; it never splits, drops, or invents one (a URL or + * a regex token must survive whole). Mutant: the NOTE sentence terminator back + * to `/[.!?]/g` in splitNoteSentences; fails after 40 runs. + */ + it("keeps every comment word intact", () => { + fc.assert( + fc.property(source, options, (input, opts) => { + return commentWords(run(input, opts)) === commentWords(input); + }), + RUNS, + ); + }); +}); diff --git a/packages/comments/src/__tests__/lib.regressions.test.ts b/packages/comments/src/__tests__/lib.regressions.test.ts new file mode 100644 index 0000000..c700f10 --- /dev/null +++ b/packages/comments/src/__tests__/lib.regressions.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { parseAndTransformComments } from "../lib.js"; + +/** + * Minimal inputs for the bugs that lib.property.test.ts found, kept as plain + * cases so a regression names itself. + */ +const run = ( + input: string, + opts: Parameters[1], +) => parseAndTransformComments(input, opts).transformed; + +describe("parseAndTransformComments regressions found by the properties", () => { + const opts = { width: 80, wrapLineComments: true, mergeLineComments: true }; + + it("does not split a URL inside a NOTE sentence", () => { + const input = + "/**\n * Retries a few times. NOTE: see https://example.com/docs/retry.html for the table.\n */"; + expect(run(input, opts)).toContain("https://example.com/docs/retry.html"); + }); + + it("settles in one pass when a wrap lands before a dash", () => { + const input = + "/**\n * The cache is keyed by user id and locale and it is flushed whenever the sessi - and only then - ends.\n */"; + const once = run(input, opts); + expect(run(once, opts)).toBe(once); + }); + + it("settles in one pass when a long // line wraps in merge mode", () => { + const input = + "function f() {\n\t// Flush the cache whenever the session ends so that a stale locale never leaks into the next request\n\treturn 1;\n}"; + const once = run(input, opts); + expect(run(once, opts)).toBe(once); + }); + + it("keeps the sentence before a word that ends in NOTE:", () => { + const input = + "/**\n * The table below lists each case. FOOTNOTE: values are rounded.\n */"; + expect(run(input, opts)).toContain("The table below lists each case."); + }); + + it("settles in one pass when a NOTE sentence wraps before another sentence", () => { + const input = + "/**\n * Retries the request. NOTE: the backoff doubles on every attempt and is capped at thirty seconds so a dead upstream never stalls the queue. Callers can override it.\n */"; + const once = run(input, opts); + expect(run(once, opts)).toBe(once); + }); + + it("settles in one pass when text follows a wrapped NOTE sentence on its line", () => { + const input = + "/**\n * Retries the request.\n *\n * NOTE: the backoff doubles\n * on every attempt. Callers\n * can override it in the\n * config.\n * See retry.ts.\n */"; + const once = run(input, opts); + expect(run(once, opts)).toBe(once); + }); + + it("does not merge the tail of a // comment that follows unfinished code", () => { + const input = + "const timeout = base\n\t// Doubles on each retry (see retry.ts)\n\t// and caps at thirty seconds so a dead\n\t// upstream never stalls the queue\n\t* 2;"; + expect(run(input, opts)).not.toContain("/**"); + }); + + it("does not grow an unclosed code fence on each pass", () => { + const input = "/**\n * ```\n * \n */"; + const once = run(input, opts); + expect(run(once, opts)).toBe(once); + }); +}); diff --git a/packages/comments/src/__tests__/lib.test.ts b/packages/comments/src/__tests__/lib.test.ts index cb39096..d1cea08 100644 --- a/packages/comments/src/__tests__/lib.test.ts +++ b/packages/comments/src/__tests__/lib.test.ts @@ -356,8 +356,10 @@ describe("parseAndTransformComments", () => { expect(out).not.toMatch(/merged\./); expect(out).not.toMatch(/spurious\./); expect(out).not.toMatch(/introducing a\./); - // Should still retain existing legitimate period after 'wraps).' and final - // 'list.' + /** + * Should still retain existing legitimate period after 'wraps).' and final + * 'list.' + */ expect(out).toMatch(/wraps\)\./); expect(out).toMatch(/list\./); }); @@ -380,8 +382,10 @@ describe("parseAndTransformComments", () => { // Expect merged into a JSDoc immediately after the statement. const re = /compute\(\);\n\/\*\*[\s\S]*?\n\s*\*\//; expect(re.test(out)).toBe(true); - // Ensure only one sentence-final period appended (present on final 'list.' - // already). + /** + * Ensure only one sentence-final period appended (present on final 'list.' + * already). + */ expect(out.match(/merged\./)).toBeNull(); }); @@ -400,8 +404,10 @@ describe("parseAndTransformComments", () => { }).transformed; // The 3-line group should have been converted to a JSDoc block. expect(/compute\(\);\n\/\*\*/.test(out)).toBe(true); - // Ensure original lines no longer start with // (inside block now) and period - // appended once. + /** + * Ensure original lines no longer start with // (inside block now) and period + * appended once. + */ expect(out).toMatch(/First explanatory line that starts with a capital/); expect(out).not.toMatch(/third line completes it\n\/\//); // no stray // after }); @@ -473,9 +479,11 @@ describe("parseAndTransformComments", () => { out, ), ).toBe(true); - // Preceding sentence fragments present and end with period somewhere before - // NOTE line. - expect(out).toMatch(/This operation performs work and may take time\n/); + /** + * Preceding sentence fragments present and end with period somewhere before + * NOTE line. + */ + expect(out).toMatch(/This operation performs work and may take time\.\n/); // Trailing sentence appears after NOTE line with terminal period. expect(/NOTE: use with caution[\s\S]*It returns a result\./.test(out)).toBe( true, @@ -564,11 +572,11 @@ describe("parseAndTransformComments", () => { mergeLineComments: true, }).transformed; - // First comment group after array should be merged + // First comment group after array should be merged. expect(out).toMatch(/];\n\/\*\*/); expect(out).toMatch(/Comments after array with trailing comma/); - // Second comment group after function should be merged + // Second comment group after function should be merged. expect(out).toMatch(/}\n\/\*\*/); expect(out).toMatch(/Comments after function ending with }/); }); @@ -585,7 +593,7 @@ describe("parseAndTransformComments", () => { mergeLineComments: true, }).transformed; - // Single comment should remain as line comment, not merged to JSDoc + // Single comment should remain as line comment, not merged to JSDoc. expect(out).toMatch(/\/\/ single comment that should not be merged/); expect(out).not.toMatch(/\/\*\*/); }); @@ -635,8 +643,10 @@ describe("parseAndTransformComments", () => { ...baseOpts, width: 100, }).transformed; - // New structural policy: first NOTE line always gets period even if followed - // by prose. + /** + * New structural policy: first NOTE line always gets period even if followed + * by prose. + */ expect(out).toMatch(/when the code is not in\./); // Ensure final sentence has period. expect(/completely random\./.test(out)).toBe(true); @@ -664,7 +674,10 @@ describe("parseAndTransformComments", () => { ...baseOpts, width: 100, }).transformed; - // Test that NOTE content is processed properly (without making assumptions about splitting) + /** + * Test that NOTE content is processed properly (without making assumptions + * about splitting). + */ expect(out).toMatch(/This performs work/); expect(out).toMatch(/NOTE: first note without period/); expect(out).toMatch(/NOTE: second note/); diff --git a/packages/comments/src/__tests__/realistic-source.ts b/packages/comments/src/__tests__/realistic-source.ts new file mode 100644 index 0000000..f7ee699 --- /dev/null +++ b/packages/comments/src/__tests__/realistic-source.ts @@ -0,0 +1,113 @@ +import fc from "fast-check"; + +/** + * Source files with comments shaped like real prose: sentences that start with + * a capital and usually end with punctuation, NOTE only at the start of a + * sentence and never empty, tags, lists, and headings on their own lines, code + * fences closed, and directives on their own // line. Mid-sentence tokens that + * the heuristics branch on (dashes, scoped packages, CSS at-rules, URLs) still + * appear, because real prose has them. + */ + +const word = fc.oneof( + { weight: 10, arbitrary: fc.stringMatching(/^[a-z]{1,10}$/) }, + // A dash is the mid-sentence token most likely to land at a wrap. + { weight: 2, arbitrary: fc.constant("-") }, + { + weight: 1, + arbitrary: fc.constantFrom( + "@auth0/auth0-react", + "@media", + "https://example.com/docs/retry.html", + "`run()`", + "(for example)", + "API", + "v2", + "#123", + "10", + ), + }, +); +const words = (min: number, max: number) => + fc.array(word, { minLength: min, maxLength: max }).map((w) => w.join(" ")); +const capital = fc + .stringMatching(/^[a-z]{1,10}$/) + .map((w) => w[0].toUpperCase() + w.slice(1)); + +const sentence = fc + .tuple(capital, words(1, 12), fc.constantFrom(".", ".", ".", "!", "?", "")) + .map(([first, rest, end]) => `${first} ${rest}${end}`); +const noteSentence = sentence.map((s) => `NOTE: ${s}`); +const paragraph = fc + .array(fc.oneof({ weight: 4, arbitrary: sentence }, noteSentence), { + minLength: 1, + maxLength: 4, + }) + .map((s) => s.join(" ")); + +// Splits prose into lines of `size` words, as an author wrapping by hand would. +const handWrapped = fc + .tuple(paragraph, fc.integer({ min: 4, max: 30 })) + .map(([p, size]) => { + const all = p.split(" "); + const lines: string[] = []; + for (let i = 0; i < all.length; i += size) { + lines.push(all.slice(i, i + size).join(" ")); + } + return lines; + }); + +const jsDocBlock = fc.oneof( + { weight: 4, arbitrary: handWrapped }, + fc.array( + words(1, 8).map((w) => `- ${w}`), + { minLength: 1, maxLength: 4 }, + ), + fc.array( + words(1, 8).map((w) => `1. ${w}`), + { minLength: 1, maxLength: 3 }, + ), + fc + .tuple(fc.constantFrom("Overview:", "Performance Notes:"), handWrapped) + .map(([h, p]) => [h, ...p]), + fc.constant(["```ts", "const value = run();", "```"]), +); +const tags = fc.array( + fc.oneof( + words(1, 8).map((w) => `@param name ${w}`), + words(1, 8).map((w) => `@returns ${w}`), + ), + { maxLength: 2 }, +); + +const indent = fc.constantFrom("", "\t", "\t\t"); +const jsDoc = fc + .tuple(indent, fc.array(jsDocBlock, { minLength: 1, maxLength: 3 }), tags) + .map(([i, blocks, t]) => { + const body = blocks.flatMap((b, k) => (k ? ["", ...b] : b)).concat(t); + return [`${i}/**`, ...body.map((l) => `${i} * ${l}`.trimEnd()), `${i} */`]; + }); +const lineComment = fc + .tuple(indent, handWrapped) + .map(([i, ls]) => ls.map((l) => `${i}// ${l}`)); +const directive = indent.map((i) => [ + `${i}// biome-ignore lint/suspicious/noExplicitAny: legacy API`, +]); +const code = fc + .tuple(indent, fc.constantFrom("const x = 1;", "if (a) {", "}", "return x;")) + .map(([i, c]) => [`${i}${c}`]); + +export const realisticSource = fc + .array( + fc.oneof( + { weight: 2, arbitrary: jsDoc }, + { weight: 2, arbitrary: lineComment }, + directive, + { weight: 3, arbitrary: code }, + fc.constant([""]), + ), + { maxLength: 8 }, + ) + .map((chunks) => chunks.flat().join("\n")); + +export const realisticWidth = fc.integer({ min: 60, max: 120 }); diff --git a/packages/comments/src/lib.ts b/packages/comments/src/lib.ts index a93c31d..b41350b 100644 --- a/packages/comments/src/lib.ts +++ b/packages/comments/src/lib.ts @@ -113,9 +113,11 @@ function splitNoteSentences(text: string): string[] { return [text]; } const segments: string[] = []; + // `i` is where the current segment starts; `search` skips past non-boundaries. let i = 0; + let search = 0; while (i < text.length) { - const idx = text.indexOf("NOTE: ", i); + const idx = text.indexOf("NOTE: ", search); if (idx === -1) { const tail = text.slice(i).trim(); if (tail) { @@ -133,20 +135,21 @@ function splitNoteSentences(text: string): string[] { /[.!?] /.test(text.slice(Math.max(0, idx - 2), idx + 1)) || text[idx - 1] === " "; if (!boundary) { - i = idx + 6; // skip this occurrence + search = idx + 6; // skip this occurrence, keep the text before it continue; } const before = text.slice(i, idx).trim(); if (before) { segments.push(before); } - let end = idx; - while (end < text.length && !/[.!?]/.test(text[end])) { - end++; - } - if (end < text.length && /[.!?]/.test(text[end])) { - end++; - } + /** + * The NOTE sentence ends at terminal punctuation followed by a space or the + * end of text, so a "." or "?" inside a URL or a regex token does not cut it. + */ + const terminator = /(?<=\S)[.!?]['")\]]*(?= |$)/g; + terminator.lastIndex = idx; + const stop = terminator.exec(text); + const end = stop ? stop.index + stop[0].length : text.length; let note = text.slice(idx, end).trim(); if (!/[.!?]$/.test(note)) { note += "."; @@ -156,6 +159,7 @@ function splitNoteSentences(text: string): string[] { if (text[i] === " ") { i++; } + search = i; } return segments.filter(Boolean); } @@ -235,9 +239,9 @@ function isVisuallyIndentedCode(line: string): boolean { } /** - * Detects linter/tool directive comments that should be left untouched. - * These include ESLint, Biome, Prettier, TypeScript, StyleLint, and coverage - * tool directives. + * Detects linter/tool directive comments that should be left untouched. These + * include ESLint, Biome, Prettier, TypeScript, StyleLint, and coverage tool + * directives. */ function isLinterDirective(text: string): boolean { const trimmed = text.trim(); @@ -246,32 +250,75 @@ function isLinterDirective(text: string): boolean { ); } -function wrapWords(text: string, width: number): string[] { +/** + * A wrapped line that opens with a list marker, code fence, tag, or NOTE (or, + * in a // comment, a linter directive) is read as that structure on the next + * pass, which changes the comment (a stray period, a fake list item). `line` is + * the full rest of the text, since a list marker needs words after it. + */ +function opensStructure(line: string, lineComment: boolean): boolean { + return ( + isListLike(line) || + isCodeFence(line) || + isTagLine(line) || + /^note:(?: |$)/i.test(line) || + (lineComment && isLinterDirective(line)) + ); +} + +function wrapWords(text: string, width: number, lineComment = false): string[] { const words = text.split(/\s+/).filter(Boolean); const lines: string[] = []; let cur = ""; - for (const w of words) { + for (let i = 0; i < words.length; i++) { + const w = words[i]; if (!cur.length) { cur = w; continue; } if (cur.length + 1 + w.length <= width) { cur += " " + w; + continue; + } + // Carry words down until the new line no longer opens a structure. + const rest = words.slice(i).join(" "); + let carried = ""; + while (opensStructure(carried + rest, lineComment) && cur.includes(" ")) { + const cut = cur.lastIndexOf(" "); + carried = `${cur.slice(cut + 1)} ${carried}`; + cur = cur.slice(0, cut); + } + const next = `${carried}${w}`; + if (opensStructure(carried + rest, lineComment)) { + cur += ` ${next}`; } else { lines.push(cur); - cur = w; + cur = next; } } if (cur) { lines.push(cur); } + /** + * A wrapped line that reads as a heading ("Default Options:") would end the + * paragraph on the next pass, so join it to a neighbor. + */ + for (let k = 0; k < lines.length && lines.length > 1; k++) { + if (isHeadingLike(lines[k])) { + const into = k + 1 < lines.length ? k : k - 1; + lines.splice(into, 2, `${lines[into]} ${lines[into + 1]}`); + k = into - 1; + } + } return lines.length ? lines : [""]; } function buildJsDoc(indent: string, rawBody: string, width: number): string { let lines = rawBody.split(/\n/).map((l) => l.replace(/^\s*\*? ?/, "")); - // Remove a single leading blank line (artifact of regex capture starting after - // /**) if content follows. + /** + * Remove a single leading blank line (artifact of regex capture starting after + * /**) if content follows. + */ while ( lines.length > 1 && lines[0].trim() === "" && @@ -293,6 +340,8 @@ function buildJsDoc(indent: string, rawBody: string, width: number): string { } const out: string[] = []; let para: string[] = []; + // True while `para` holds a NOTE sentence that runs onto the next lines. + let noteOpen = false; let inFence = false; const prefix = indent + " * "; const avail = Math.max(10, width - prefix.length); @@ -336,11 +385,16 @@ function buildJsDoc(indent: string, rawBody: string, width: number): string { text = maybeAddPeriod(text); const pieces = splitNoteSentences(text); for (const piece of pieces) { - for (const l of wrapWords(piece, avail)) { + /** + * Each piece starts its own line, so it gets the NOTE casing and the period + * that the next pass would give it. + */ + for (const l of wrapWords(maybeAddPeriod(normalizeNote(piece)), avail)) { out.push(prefix + l); } } para = []; + noteOpen = false; } let lineIndex = -1; @@ -430,13 +484,50 @@ function buildJsDoc(indent: string, rawBody: string, width: number): string { noteLine += "."; } } - out.push(prefix + noteLine); + if ( + /^NOTE: /.test(noteLine) && + !/(?<=\S)[.!?]['")\]]*(?= |$)/.test(noteLine) + ) { + /** + * The NOTE sentence runs onto the next lines: collect it until the + * sentence ends, so its tail does not join the following paragraph. + */ + para.push(noteLine); + noteOpen = true; + } else { + out.push(prefix + noteLine); + } } continue; } para.push(trimmed); + if (noteOpen) { + // Flush the NOTE sentence where it ends; the rest starts the next paragraph. + const joined = para.join(" "); + const stop = /(?<=\S)[.!?]['")\]]*(?= |$)/.exec(joined); + if (stop) { + const rest = joined.slice(stop.index + stop[0].length).trim(); + para = [joined.slice(0, stop.index + stop[0].length)]; + flush(); + if (rest) { + para = [rest]; + } + } + } } flush(); + /** + * A fence left open runs to the closing delimiter, and each pass would add one + * more trailing blank line inside it: keep only one. + */ + while ( + inFence && + out.length > 1 && + out[out.length - 1] === prefix.trimEnd() && + out[out.length - 2] === prefix.trimEnd() + ) { + out.pop(); + } /** * Style: ensure a space precedes the closing *\/ for consistency with blocks * generated elsewhere in this tool (merged line comment groups). Previously we @@ -531,7 +622,7 @@ function wrapLineComments( let text = body.replace(/\s+/g, " ").trim(); text = normalizeNote(text); text = maybeAddPeriod(text); - const wrapped = wrapWords(text, avail).map((w) => prefix + w); + const wrapped = wrapWords(text, avail, true).map((w) => prefix + w); if (wrapped.join("\n") !== line) { changed = true; } @@ -554,7 +645,7 @@ function wrapLineComments( if (k === group.length - 1 && !/:$/.test(text.trim())) { text = maybeAddPeriod(text); } - const wrapped = wrapWords(text, avail).map((w) => prefix + w); + const wrapped = wrapWords(text, avail, true).map((w) => prefix + w); if (wrapped.join("\n") !== group[k].raw) { changed = true; } @@ -574,56 +665,21 @@ function mergeLineCommentGroups(content: string): { let i = 0; let merged = false; - function qualifiesExplanatoryAfterStatement(start: number): boolean { - /** - * Peek ahead to collect consecutive // lines (excluding triple slash) to - * decide if they form a sufficiently large explanatory paragraph that - * deserves merging directly after a terminated statement. - */ - const collected: string[] = []; - for (let k = start; k < lines.length; k++) { - const lm = /^(\s*)\/\/( ?)(.*)$/.exec(lines[k]); - if (!lm || /^\/\/\//.test(lines[k])) { - break; - } - const body = lm[3]; - if (isLinterDirective(body) || /https?:\/\//.test(body)) { - break; - } - collected.push(body.trim()); - } - if (collected.length < 3) { - return false; // lowered threshold from 4 -> 3 to permit shorter explanatory groups - } - if (!/^[A-Z]/.test(collected[0])) { - return false; // start with capitalized sentence - } - /** - * Avoid matching directive-like or list-lists: require at least one line - * containing a space (a sentence fragment vs a single token). - */ - return collected.some((c) => /\s/.test(c)); - } while (i < lines.length) { if (/^\s*\/\//.test(lines[i]) && !/^\s*\/\/\//.test(lines[i])) { const prev = i > 0 ? lines[i - 1] : ""; const prevTrim = prev.trim(); - let contextEligible = prevTrim === "" || /[{}\]),;]$/.test(prevTrim); - /** - * Heuristic: also allow large explanatory group after a statement ending - * with ';' when it qualifies as explanatory so it can be elevated to a block - * comment rather than remaining a run of // lines. - */ - if ( - !contextEligible && - /;\s*$/.test(prevTrim) && - qualifiesExplanatoryAfterStatement(i) - ) { - contextEligible = true; - } + const contextEligible = prevTrim === "" || /[{}\]),;]$/.test(prevTrim); if (!contextEligible) { - out.push(lines[i]); - i++; + // Keep the whole run of // lines, so no group starts mid-comment. + do { + out.push(lines[i]); + i++; + } while ( + i < lines.length && + /^\s*\/\//.test(lines[i]) && + !/^\s*\/\/\//.test(lines[i]) + ); continue; } const group: { indent: string; text: string }[] = []; @@ -730,6 +786,14 @@ export function parseAndTransformComments( if (options.wrapLineComments) { const w = wrapLineComments(working, options.width); working = w.content; + /** + * Wrapping can split one long // line into a group that merging takes, so + * merge again now instead of on the next run. + */ + if (options.mergeLineComments && w.applied) { + working = mergeLineCommentGroups(working).content; + working = reflowJsDocBlocks(working, options.width).content; + } } return { original: input, transformed: working, changed: working !== input }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 635681e..f2cc5ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -324,6 +324,9 @@ importers: '@vitest/coverage-v8': specifier: 5.0.5 version: 5.0.5(vitest@5.0.5) + fast-check: + specifier: 4.10.2 + version: 4.10.2 vitest: specifier: 5.0.5 version: 5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(yaml@2.9.1)) @@ -1838,6 +1841,10 @@ packages: resolution: {integrity: sha512-yblEwXAbGv1VQDmow7s38W77hzAgJAO50ztBLMcUyUBfxv1HC+LGwtiEN+Co6LtlqT/5uwVOxsD4TNIilWhwdQ==} engines: {node: '>=4'} + fast-check@4.10.2: + resolution: {integrity: sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw==} + engines: {node: '>=12.17.0'} + fast-copy@4.2.0: resolution: {integrity: sha512-WSlnvPXaMdUjFdgxPkovE4e5aslybVod4TNKrEKKyoA8GoHZzbDLlhDXZOvqjtFgjtOHd9TYCstkUcGXy1+SEA==} @@ -2501,6 +2508,9 @@ packages: pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + pure-rand@8.4.2: + resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} + quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} @@ -4030,6 +4040,10 @@ snapshots: ext-list: 2.2.2 sort-keys-length: 1.0.1 + fast-check@4.10.2: + dependencies: + pure-rand: 8.4.2 + fast-copy@4.2.0: {} fast-decode-uri-component@1.0.1: {} @@ -4643,6 +4657,8 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 + pure-rand@8.4.2: {} + quick-format-unescaped@4.0.4: {} quick-lru@5.1.1: {}