Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/comments/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
},
"devDependencies": {
"@vitest/coverage-v8": "5.0.5",
"fast-check": "4.10.2",
"vitest": "5.0.5"
}
}
168 changes: 168 additions & 0 deletions packages/comments/src/__tests__/lib.property.test.ts
Original file line number Diff line number Diff line change
@@ -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<ProcessOptions> = 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,
);
});
});
67 changes: 67 additions & 0 deletions packages/comments/src/__tests__/lib.regressions.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof parseAndTransformComments>[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);
});
});
43 changes: 28 additions & 15 deletions packages/comments/src/__tests__/lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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\./);
});
Expand All @@ -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();
});

Expand All @@ -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
});
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 }/);
});
Expand All @@ -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(/\/\*\*/);
});
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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/);
Expand Down
Loading
Loading