diff --git a/packages/bun-test-extended/src/augment-bun-test.ts b/packages/bun-test-extended/src/augment-bun-test.ts index 9fa98bf..5e6317b 100644 --- a/packages/bun-test-extended/src/augment-bun-test.ts +++ b/packages/bun-test-extended/src/augment-bun-test.ts @@ -3,16 +3,10 @@ import 'jest-extended'; import type { JestExtendedMatcherName } from './types.ts'; -/** - * Augment bun:test with only the matchers bun's builtin interfaces don't - * already declare — bun implements many jest-extended-style matchers natively - * with slightly different signatures, and extending over them is a TS2320. - */ +// only the matchers bun's builtin interfaces don't declare: bun implements +// many jest-extended matchers natively with different signatures, and +// extending over one is a TS2320 declare module 'bun:test' { - /** - * toResolve/toReject come from the promise-typed pick: the two async - * matchers return a promise the caller must await. - */ interface Matchers extends Pick< @@ -21,10 +15,8 @@ declare module 'bun:test' { >, Pick>, 'toResolve' | 'toReject'> {} - /** - * `never` (not `any`) so asymmetric matchers fit any value position in - * toEqual/toMatchObject without tripping typescript/no-unsafe-assignment - */ + // `never`, not `any`: an asymmetric matcher then fits any value position in + // toEqual/toMatchObject without tripping typescript/no-unsafe-assignment // oxlint-disable-next-line typescript/no-empty-interface -- module augmentation, emptiness is the point interface AsymmetricMatchers extends Pick< CustomMatchers, diff --git a/packages/bun-test-extended/src/with-jest-context.ts b/packages/bun-test-extended/src/with-jest-context.ts index 6591320..37c55ed 100644 --- a/packages/bun-test-extended/src/with-jest-context.ts +++ b/packages/bun-test-extended/src/with-jest-context.ts @@ -4,12 +4,9 @@ import type { JestExtendedMatcher, MatcherContext } from './types.ts'; // The exact util functions jest-extended matchers destructure off `this.utils`. const jestMatcherUtils = { matcherHint, printExpected, printReceived, printWithType }; -// jest-extended matchers destructure `this.utils` (e.g. `const { printReceived } = -// this.utils`) and expect jest's util signatures. Bun's native utils are brand-checked -// — calling them unbound throws "Expected this to be instanceof ExpectMatcherUtils" -// — so every failing assertion would crash instead of printing its message. Hand -// each matcher a context whose `utils` come from the real jest-matcher-utils package -// and whose remaining methods (`equals`, …) stay bound to the native context. +// jest-extended matchers destructure `this.utils`, and Bun's native utils are +// brand-checked: called unbound they throw "Expected this to be instanceof +// ExpectMatcherUtils", so a failing assertion would crash instead of printing export function withJestContext(matcher: JestExtendedMatcher): JestExtendedMatcher { return function jestContextAdapter( this: Readonly, diff --git a/packages/format-codemod/src/cli.test.ts b/packages/format-codemod/src/cli.test.ts index 8c5f425..c5f52ec 100644 --- a/packages/format-codemod/src/cli.test.ts +++ b/packages/format-codemod/src/cli.test.ts @@ -7,10 +7,6 @@ import { fileURLToPath } from 'node:url'; const cliPath = fileURLToPath(new URL('cli.ts', import.meta.url)); -/** - * Runs the CLI from source under bun; the exit-code contract asserted here is - * what the root format pipeline and the pre-commit hook consume. - */ function runCLI( args: readonly string[], cwd?: string, diff --git a/packages/format-codemod/src/cli/apply-diff-mode.ts b/packages/format-codemod/src/cli/apply-diff-mode.ts index 2c641e5..a75acee 100644 --- a/packages/format-codemod/src/cli/apply-diff-mode.ts +++ b/packages/format-codemod/src/cli/apply-diff-mode.ts @@ -7,11 +7,6 @@ export interface DiffOutput { readonly stdout: string | null; } -/** - * Reached only when the file needs edits. Write mode saves the file here — the - * one file-system effect below the entry; what to print is returned, never - * printed, because the entry owns the output streams. - */ export function applyDiffMode(edit: FileEdit, mode: CLIMode): DiffOutput { if (mode === 'check') { return { message: `DIFF ${edit.file} ${edit.result.edits} edit(s)`, stdout: null }; diff --git a/packages/format-codemod/src/cli/build-unified-diff.ts b/packages/format-codemod/src/cli/build-unified-diff.ts index 98e7c62..0c42c23 100644 --- a/packages/format-codemod/src/cli/build-unified-diff.ts +++ b/packages/format-codemod/src/cli/build-unified-diff.ts @@ -1,8 +1,3 @@ -/** - * A real unified diff — @@ hunk headers with context — so --dry output can be - * applied with patch(1). Inputs are expected to end with a newline, which - * TypeScript sources do and the transform preserves. - */ export function buildUnifiedDiff(before: string, after: string, label: string): string { const ops = new MyersDiff(splitLines(before), splitLines(after)).buildOps(); @@ -35,11 +30,6 @@ interface Position { readonly y: number; } -/** - * Myers O((N+M)·D) line diff. D is the edit distance — for padding diffs just - * the handful of inserted blanks — so this stays near-linear, and being a - * minimal diff it never mis-pairs repeated lines. - */ class MyersDiff { private readonly a: readonly string[]; @@ -60,10 +50,6 @@ class MyersDiff { return this.backtrack(); } - /** - * One snapshot of v per edit-distance round; the round that reaches the end - * stops the search and the snapshots drive the backtrack. - */ private updateTrace(): void { for (let d = 0; d <= this.a.length + this.b.length; d++) { this.trace.push(new Map(this.v)); @@ -149,9 +135,6 @@ class MyersDiff { return { prev: { x: prevX, y: prevX - prevK }, moveDown }; } - /** - * The diagonal walk back from pos to prev — the lines both sides share. - */ private buildEqualOps(pos: Position, prev: Position): DiffOp[] { const ops: DiffOp[] = []; let x = pos.x; @@ -182,15 +165,8 @@ function buildHunks(ops: readonly DiffOp[]): Hunk[] { return buildWindows(ops).map((w) => buildHunk(ops, w)); } -/** - * Context lines per hunk, matching diff -u / git defaults. - */ const CONTEXT_LINES = 3; -/** - * Each changed op pulls a context radius of surrounding ops into its window; - * overlapping or adjacent windows merge into one hunk. - */ function buildWindows(ops: readonly DiffOp[]): Window[] { const windows: Window[] = []; @@ -243,10 +219,6 @@ function countPrecedingLines( return { aLine, bLine }; } -/** - * An empty range anchors to the line before it (already correct 0-based); - * a populated one is 1-based. - */ function formatRange(line: number, count: number): string { return count === 0 ? `${line},0` : `${line + 1},${count}`; } diff --git a/packages/format-codemod/src/cli/expand-inputs.ts b/packages/format-codemod/src/cli/expand-inputs.ts index d9c17cd..b21aba1 100644 --- a/packages/format-codemod/src/cli/expand-inputs.ts +++ b/packages/format-codemod/src/cli/expand-inputs.ts @@ -1,12 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; -/** - * Deduped: overlapping patterns (a `src/**` glob plus a file inside src/) must - * not process — or report — the same file twice. Ignore globs filter the final - * list uniformly, so a file is skipped whether it arrived via a directory, - * a glob, or an explicit path argument. - */ export async function expandInputs( patterns: readonly string[], ignore: readonly string[] = [], @@ -37,13 +31,8 @@ function expandPattern(p: string): Promise { return Promise.resolve([p]); } -/** - * Keeps directory expansion out of dependency trees and nested git dirs — a bare - * `format-codemod .` at a repo root must not descend into installed packages. - * Node passes directories to `exclude` (pruning descent); Bun filters final - * matches — segment matching handles both, and Dirents in case withFileTypes - * semantics ever leak through. - */ +// Node passes directories to `exclude` (pruning descent) while Bun filters +// final matches, so the check matches a segment and accepts a Dirent function isExcluded( entry: string | { readonly name: string; readonly parentPath?: string }, ): boolean { @@ -52,11 +41,6 @@ function isExcluded( return p.split(/[\\/]/u).some((seg) => seg === 'node_modules' || seg === '.git'); } -/** - * A pattern is tried against the path both as expanded and relative to the - * working directory, so `dist/**` ignores dist/ files whether the caller - * passed a relative or an absolute input. - */ function isIgnored(file: string, patterns: readonly string[]): boolean { if (patterns.length === 0) { return false; diff --git a/packages/format-codemod/src/cli/load-format-ignore.ts b/packages/format-codemod/src/cli/load-format-ignore.ts index 113c90c..9a73d08 100644 --- a/packages/format-codemod/src/cli/load-format-ignore.ts +++ b/packages/format-codemod/src/cli/load-format-ignore.ts @@ -1,13 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; -/** - * Reads `.formatignore` from the given directory: one glob per line, blank - * lines and `#`-prefixed comment lines skipped. Lines share --ignore's glob - * semantics — this is not a gitignore dialect, so `!` negation and directory - * anchoring don't apply. A missing file yields an empty list, so callers - * merge unconditionally. - */ export function loadFormatIgnore(dir: string): readonly string[] { const file = path.join(dir, '.formatignore'); diff --git a/packages/format-codemod/src/cli/parse-cli-args.ts b/packages/format-codemod/src/cli/parse-cli-args.ts index 2afb5c4..8d41baa 100644 --- a/packages/format-codemod/src/cli/parse-cli-args.ts +++ b/packages/format-codemod/src/cli/parse-cli-args.ts @@ -11,12 +11,6 @@ export interface CLIArgs { readonly inputs: readonly string[]; } -/** - * Strict parsing: an unknown flag is a usage error, not a silent no-op. That - * matters because the default mode writes files — a typo'd --check must never - * fall through to an in-place rewrite. CLIArgs is always an object, so the - * string (error message) return is unambiguous. - */ export function parseCLIArgs(argv: readonly string[]): CLIArgs | string { try { const parsed = parseArgs({ @@ -48,10 +42,6 @@ export function parseCLIArgs(argv: readonly string[]): CLIArgs | string { } } -/** - * --check wins over --dry when both are passed — the stricter mode's - * no-writes guarantee must hold. - */ function pickMode(check: boolean, dry: boolean): CLIMode { if (check) { return 'check'; diff --git a/packages/format-codemod/src/cli/print-report.ts b/packages/format-codemod/src/cli/print-report.ts index 9dcee32..ce11ec2 100644 --- a/packages/format-codemod/src/cli/print-report.ts +++ b/packages/format-codemod/src/cli/print-report.ts @@ -1,10 +1,5 @@ import type { FileReport } from './types.ts'; -/** - * Prints one file's outcome: the stdout payload (--dry diffs) to stdout, - * messages and per-file progress to stderr. Quiet drops the OK/SKIP noise but - * never the messages. - */ export function printReport(file: string, report: FileReport, quiet: boolean): void { if (report.stdout !== null) { process.stdout.write(report.stdout); diff --git a/packages/format-codemod/src/cli/read-package-version.ts b/packages/format-codemod/src/cli/read-package-version.ts index 4e9ea12..6b30ade 100644 --- a/packages/format-codemod/src/cli/read-package-version.ts +++ b/packages/format-codemod/src/cli/read-package-version.ts @@ -1,10 +1,5 @@ import fs from 'node:fs'; -/** - * Reads the version field of the package manifest at the given path, throwing - * when the file isn't a manifest. Callers own the path so it resolves against - * their own location. - */ export function readPackageVersion(pkgPath: string): string { const parsed: unknown = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); diff --git a/packages/format-codemod/src/cli/try-check-file.ts b/packages/format-codemod/src/cli/try-check-file.ts index b235e7a..4b74a05 100644 --- a/packages/format-codemod/src/cli/try-check-file.ts +++ b/packages/format-codemod/src/cli/try-check-file.ts @@ -1,11 +1,6 @@ import { checkFile } from './check-file.ts'; import type { CLIMode, FileReport } from './types.ts'; -/** - * A failed file must fail the batch: --check would otherwise exit 0 having - * validated nothing, and a throw would kill the process mid-batch with the - * remaining files silently skipped. - */ export function tryCheckFile(file: string, mode: CLIMode): FileReport { if (file.endsWith('.d.ts')) { return { outcome: 'skipped', bytes: 0, parsed: false, message: null, stdout: null }; diff --git a/packages/format-codemod/src/cli/types.ts b/packages/format-codemod/src/cli/types.ts index b367aa6..4e9155e 100644 --- a/packages/format-codemod/src/cli/types.ts +++ b/packages/format-codemod/src/cli/types.ts @@ -4,11 +4,6 @@ export type CLIMode = 'write' | 'check' | 'dry'; export type FileOutcome = 'ok' | 'changed' | 'failed' | 'skipped'; -/** - * Everything the entry needs to report one file: the outcome for exit-code - * logic, sizes for --bench, and the text to print — modules below the entry - * never write to stdout/stderr themselves. - */ export interface FileReport { readonly outcome: FileOutcome; readonly bytes: number; diff --git a/packages/format-codemod/src/transform.ts b/packages/format-codemod/src/transform.ts index c93e9e7..a132de6 100644 --- a/packages/format-codemod/src/transform.ts +++ b/packages/format-codemod/src/transform.ts @@ -4,17 +4,9 @@ import { parseSource } from './transform/parse-source.ts'; import type { TransformResult } from './types.ts'; export interface TransformOptions { - /** - * Picks the parse dialect: `.tsx` enables JSX, anything else is plain - * TypeScript. Defaults to 'source.ts' — JSX callers must say so. - */ readonly filename?: string; } -/** - * Pure transform: source string in, edited source string out, no I/O. On a - * parse error the input is returned untouched alongside the error message. - */ export function transform(src: string, options?: TransformOptions): TransformResult { const parsed = parseSource(src, options?.filename ?? 'source.ts'); diff --git a/packages/format-codemod/src/transform/apply-edits.ts b/packages/format-codemod/src/transform/apply-edits.ts index 19b07ed..ba76dde 100644 --- a/packages/format-codemod/src/transform/apply-edits.ts +++ b/packages/format-codemod/src/transform/apply-edits.ts @@ -1,10 +1,5 @@ import type { Edit } from '../types.ts'; -/** - * Callers must pass edits sorted last-to-first, so each splice's offsets stay - * valid without adjustment. Segments are collected and joined once instead of - * re-copying the whole string per edit. - */ export function applyEdits(src: string, edits: readonly Edit[]): string { const segments: string[] = []; let tail = src.length; diff --git a/packages/format-codemod/src/transform/build-edits-from-ast.test.ts b/packages/format-codemod/src/transform/build-edits-from-ast.test.ts index 1fc422e..c53552f 100644 --- a/packages/format-codemod/src/transform/build-edits-from-ast.test.ts +++ b/packages/format-codemod/src/transform/build-edits-from-ast.test.ts @@ -3,12 +3,6 @@ import type { ParsedSource } from '../types.ts'; import { buildEditsFromAST } from './build-edits-from-ast.ts'; import { parseSource } from './parse-source.ts'; -/** - * Narrows the parser's ParsedSource-or-error-message union for fixtures that - * are known-valid TypeScript, sparing every test the same narrowing dance. - * The throw is unreachable today but makes a fixture that stops parsing fail - * loudly with the parser's own message. - */ function parse(src: string): ParsedSource { const parsed = parseSource(src, 'file.ts'); diff --git a/packages/format-codemod/src/transform/build-edits-from-ast.ts b/packages/format-codemod/src/transform/build-edits-from-ast.ts index 92de073..31e03bb 100644 --- a/packages/format-codemod/src/transform/build-edits-from-ast.ts +++ b/packages/format-codemod/src/transform/build-edits-from-ast.ts @@ -3,9 +3,6 @@ import { collectChildNodes } from './collect-child-nodes.ts'; import { needsBlankLine } from './needs-blank-line.ts'; import { planGapEdit } from './plan-gap-edit.ts'; -/** - * Sorted last-to-first so applying splices in order never shifts later offsets. - */ export function buildEditsFromAST(src: string, parsed: ParsedSource): Edit[] { const file: SourceFile = { src, comments: parsed.comments }; @@ -27,10 +24,6 @@ function walk(file: SourceFile, node: ASTNode): Edit[] { return edits; } -/** - * The statement lists this node directly contains — the sequences whose - * adjacent pairs the padding rules apply to. - */ function getStatementLists(node: ASTNode): (readonly ASTNode[])[] { const bodies: (readonly ASTNode[])[] = []; @@ -72,21 +65,12 @@ function buildPairEdits(file: SourceFile, container: ASTNode, body: readonly AST return edits; } -/** - * Adjacent imports are never padded or collapsed, whatever their shape: the - * interior of an import block belongs to the import sorter, and edits there - * would be reordered or stripped out from under us. The boundary between the - * last import and the first real statement is still padded as usual. - */ +// the interior of an import block belongs to the import sorter, which would +// reorder or strip any edit made there function isImportPair(prev: ASTNode, next: ASTNode): boolean { return prev.type === 'ImportDeclaration' && next.type === 'ImportDeclaration'; } -/** - * A statement that spans multiple lines is separated from both neighbours — - * its shape already reads as a paragraph, so it gets paragraph spacing. - * Single-line statements may sit tight. - */ function isMultiline(src: string, node: ASTNode): boolean { return ( typeof node.start === 'number' && diff --git a/packages/format-codemod/src/transform/collect-ast-nodes.ts b/packages/format-codemod/src/transform/collect-ast-nodes.ts index 6da31c9..90c3f51 100644 --- a/packages/format-codemod/src/transform/collect-ast-nodes.ts +++ b/packages/format-codemod/src/transform/collect-ast-nodes.ts @@ -1,10 +1,6 @@ import type { ASTNode } from '../types.ts'; import { isASTNode } from './is-ast-node.ts'; -/** - * The AST nodes in a property value: the node itself, an array's node - * elements, or nothing for non-node values. - */ export function collectASTNodes(value: unknown): ASTNode[] { if (!Array.isArray(value)) { return isASTNode(value) ? [value] : []; diff --git a/packages/format-codemod/src/transform/collect-child-nodes.ts b/packages/format-codemod/src/transform/collect-child-nodes.ts index c2cf080..52e910a 100644 --- a/packages/format-codemod/src/transform/collect-child-nodes.ts +++ b/packages/format-codemod/src/transform/collect-child-nodes.ts @@ -1,10 +1,6 @@ import type { ASTNode } from '../types.ts'; import { collectASTNodes } from './collect-ast-nodes.ts'; -/** - * A node's direct child nodes across all properties, skipping the location - * and parent back-references a traversal must not follow. - */ export function collectChildNodes(node: ASTNode): ASTNode[] { const children: ASTNode[] = []; diff --git a/packages/format-codemod/src/transform/collect-head-chain.ts b/packages/format-codemod/src/transform/collect-head-chain.ts index 7f88eb7..a46dbca 100644 --- a/packages/format-codemod/src/transform/collect-head-chain.ts +++ b/packages/format-codemod/src/transform/collect-head-chain.ts @@ -1,11 +1,6 @@ import type { ASTNode } from '../types.ts'; import { isASTNode } from './is-ast-node.ts'; -/** - * Wrapper node types whose named property leads toward the head of an - * expression chain — `a.b().c` unwraps call by call, member by member, down - * to `a`. - */ const HEAD_PROPERTY: Readonly> = { CallExpression: 'callee', MemberExpression: 'object', @@ -15,13 +10,6 @@ const HEAD_PROPERTY: Readonly> = { ParenthesizedExpression: 'expression', }; -/** - * The nodes on the walk from an expression to its head, outermost first and - * ending at the head itself — `(await f()).prop` yields the member access, - * the parenthesis, the await, the call, and finally `f`. Nodes outside the - * walk (call arguments, ternary branches) never appear, so a scan of the - * chain sees only what a reader meets before the expression's first token. - */ export function collectHeadChain(expression: ASTNode): ASTNode[] { const chain: ASTNode[] = []; let current: ASTNode | undefined = expression; diff --git a/packages/format-codemod/src/transform/needs-blank-line.test.ts b/packages/format-codemod/src/transform/needs-blank-line.test.ts index 6df4862..23ecc92 100644 --- a/packages/format-codemod/src/transform/needs-blank-line.test.ts +++ b/packages/format-codemod/src/transform/needs-blank-line.test.ts @@ -5,12 +5,6 @@ import { isASTNode } from './is-ast-node.ts'; import { needsBlankLine } from './needs-blank-line.ts'; import { parseSource } from './parse-source.ts'; -/** - * Parses a two-statement fixture inside an async function body and returns - * the block container with the first two statements, so each test can probe - * one adjacent pair. Throws with the parser's message if the fixture is - * broken — unreachable for the known-valid fixtures below. - */ function parsePair(body: string): { container: ASTNode; prev: ASTNode; next: ASTNode } { const parsed = parseSource(`async function f(x) {\n${body}\n}`, 'file.ts'); diff --git a/packages/format-codemod/src/transform/needs-blank-line.ts b/packages/format-codemod/src/transform/needs-blank-line.ts index 5e61a0c..6ec8fe2 100644 --- a/packages/format-codemod/src/transform/needs-blank-line.ts +++ b/packages/format-codemod/src/transform/needs-blank-line.ts @@ -3,10 +3,8 @@ import { collectASTNodes } from './collect-ast-nodes.ts'; import { collectHeadChain } from './collect-head-chain.ts'; import { isASTNode } from './is-ast-node.ts'; -/** - * ESLint maps the `for` keyword to all three for-forms and `while` to the - * while loop only (`do-while` is a separate `do` selector this config omits). - */ +// ESLint maps the `for` keyword to all three for-forms and `while` to the +// while loop only; `do-while` is a separate `do` selector this config omits const CONTROL_FLOW_TYPES = new Set([ 'IfStatement', 'ForStatement', @@ -17,18 +15,6 @@ const CONTROL_FLOW_TYPES = new Set([ 'TryStatement', ]); -/** - * The "always" rules: a blank line between class members, after a var or - * using block before a statement of any other kind, after a directive - * prologue, after the last import of a block, before a return, after a - * function/class declaration, on both sides of a type alias or interface - * declaration, on both sides of a control-flow block — its closing brace - * ends a visual unit just like its opening keyword starts one — - * and at the boundary between statement kinds: bare call vs method call vs - * expect assertion vs mutation, instantiation vs anything else, and awaited - * vs non-awaited. Any - * match means exactly one blank line; a pair matching no rule sits flush. - */ export function needsBlankLine(container: ASTNode, prev: ASTNode, next: ASTNode): boolean { if (container.type === 'ClassBody') { return true; @@ -51,11 +37,6 @@ export function needsBlankLine(container: ASTNode, prev: ASTNode, next: ASTNode) ); } -/** - * Statements that glue into a homogeneous run — var declarations, using - * declarations, directives, imports — take a blank line where the run ends: - * after the last statement of the run, before the first of any other kind. - */ function isRunEnd(prev: ASTNode, next: ASTNode): boolean { return ( (isVarDecl(prev) && !isVarDecl(next)) || @@ -65,24 +46,12 @@ function isRunEnd(prev: ASTNode, next: ASTNode): boolean { ); } -/** - * A directive-prologue statement (`'use strict'`, `'use client'`). The parser - * marks these with a `directive` field, so string-expression statements deeper - * in a body don't count. Runs of directives stay tight; the boundary after the - * last one is padded. - */ function isDirective(node: ASTNode): boolean { return typeof node['directive'] === 'string'; } const TYPE_DECL_TYPES = new Set(['TSTypeAliasDeclaration', 'TSInterfaceDeclaration']); -/** - * Type aliases and interfaces take a blank line on both sides — every one is - * its own paragraph, so runs of them are padded apart rather than glued. The - * breathing room belongs to the declaration, not its wrapper, so this looks - * through `export`. - */ function isTypeDecl(node: ASTNode): boolean { if (TYPE_DECL_TYPES.has(node.type)) { return true; @@ -97,25 +66,12 @@ function isTypeDecl(node: ASTNode): boolean { ); } -/** - * A statement whose value expression is headed by `new` does construction — - * a different kind of work from calling, mutating, or plain declaring, so it - * is boundary-padded from all of them while runs of instantiations stay - * tight. Only the head of the expression chain decides: `new` in argument - * position is incidental and doesn't count. - */ function isNewHeaded(node: ASTNode): boolean { return getStatementValues(node).some( (value) => collectHeadChain(value).at(-1)?.type === 'NewExpression', ); } -/** - * The value expressions a statement is built around: declaration initializers, - * an assignment's right-hand side, or the expression itself. Statements with - * no value expression (control flow, returns, declarations without - * initializers) yield none. - */ function getStatementValues(node: ASTNode): ASTNode[] { if (node.type === 'VariableDeclaration') { return collectASTNodes(node['declarations']) @@ -138,15 +94,6 @@ function getStatementValues(node: ASTNode): ASTNode[] { return [expression]; } -/** - * A statement that suspends on `await` — an `await using` declaration, or any - * statement whose value expression passes through an await on the way to its - * head. Suspension is a different kind of work from synchronous statements, - * so the transition is boundary-padded while runs of awaited statements stay - * tight. As with `new`, only the head chain decides: an await in argument - * position (`use(await f())`) or below a non-chain head (`flag ? await f() : - * g()`) is incidental and doesn't count. - */ function isAwaitHeaded(node: ASTNode): boolean { if (node.type === 'VariableDeclaration' && node.kind === 'await using') { return true; @@ -160,13 +107,6 @@ function isAwaitHeaded(node: ASTNode): boolean { const CALL_TYPES: readonly string[] = ['CallExpression', 'AwaitExpression']; const MUTATION_TYPES: readonly string[] = ['AssignmentExpression', 'UpdateExpression']; -/** - * A transition between statement kinds — declaring ("name something"), - * calling ("do something"), mutating ("track something") — reads as a switch - * to a different kind of work, so a blank line marks it. Runs of one kind - * stay tight, and kindless statements (control flow, returns, throws) don't - * force a boundary here — their own rules govern them. - */ function isKindBoundary(prev: ASTNode, next: ASTNode): boolean { const prevKind = getStatementKind(prev); const nextKind = getStatementKind(next); @@ -190,12 +130,6 @@ function getStatementKind(node: ASTNode): string | null { return isExpressionStatementOf(node, MUTATION_TYPES) ? 'mutation' : null; } -/** - * An assertion statement: the first call a reader meets is `expect` itself or - * an `expect.*` helper (`expect.soft(x)`, `expect.assertions(1)`). Asserting - * is observation rather than action, so a run of assertions stays tight — one - * checklist — while the boundary with any acting statement is padded. - */ function isExpectHeaded(node: ASTNode): boolean { const expression = node['expression']; const callee = isASTNode(expression) ? findDeepestCallee(expression) : null; @@ -209,13 +143,6 @@ function isExpectHeaded(node: ASTNode): boolean { return head?.type === 'Identifier' && head['name'] === 'expect'; } -/** - * Call statements split into two kinds by the first call a reader meets: - * `use(x).report()` opens with a bare function, `fs.writeFileSync(...)` - * opens with a member. The deepest call in the head chain decides, so a - * member call chained onto a bare call's result is still a bare call, and a - * wrapping await doesn't change the kind. - */ function pickCallKind(node: ASTNode): 'bare-call' | 'method-call' { const expression = node['expression']; const callee = isASTNode(expression) ? findDeepestCallee(expression) : null; @@ -223,11 +150,6 @@ function pickCallKind(node: ASTNode): 'bare-call' | 'method-call' { return callee?.type === 'MemberExpression' ? 'method-call' : 'bare-call'; } -/** - * The callee of the deepest CallExpression on the walk from an expression to - * its head — the call performed first in reading order — or null when the - * head chain holds no call (a bare `await value`, for example). - */ function findDeepestCallee(expression: ASTNode): ASTNode | null { const deepestCall = collectHeadChain(expression).findLast( (link) => link.type === 'CallExpression', @@ -248,11 +170,8 @@ function isExpressionStatementOf(node: ASTNode, types: readonly string[]): boole const VAR_DECL_KINDS = new Set(['const', 'let', 'var']); -/** - * Bare variable declarations only. ESLint's padding-line-between-statements does - * not look through `export`, so `export const x = 1` is NOT a `const` for the - * rule — matching that keeps the codemod faithful to the ESLint config. - */ +// ESLint's padding-line-between-statements does not look through `export`, +// so `export const x = 1` is not a `const` for the rule function isVarDecl(node: ASTNode): boolean { if (node.type === 'VariableDeclaration') { return node.kind !== undefined && VAR_DECL_KINDS.has(node.kind); @@ -263,11 +182,6 @@ function isVarDecl(node: ASTNode): boolean { const USING_DECL_KINDS = new Set(['using', 'await using']); -/** - * `using` and `await using` declarations. They register a disposal rather - * than merely naming a value, so they form their own statement kind: runs - * stay tight and every boundary with another statement is padded. - */ function isUsingDecl(node: ASTNode): boolean { if (node.type === 'VariableDeclaration') { return node.kind !== undefined && USING_DECL_KINDS.has(node.kind); @@ -276,11 +190,8 @@ function isUsingDecl(node: ASTNode): boolean { return false; } -/** - * Bare function/class declarations only. As with var declarations, ESLint's - * `prev: ['function', 'class']` does not match `export function`/`export class` - * (those parse as ExportNamedDeclaration), so neither do we. - */ +// ESLint's `prev: ['function', 'class']` does not match `export function` or +// `export class` (those parse as ExportNamedDeclaration), so neither does this function isFnOrClassDecl(node: ASTNode): boolean { return node.type === 'FunctionDeclaration' || node.type === 'ClassDeclaration'; } diff --git a/packages/format-codemod/src/transform/parse-source.ts b/packages/format-codemod/src/transform/parse-source.ts index 1e79795..de0bbb0 100644 --- a/packages/format-codemod/src/transform/parse-source.ts +++ b/packages/format-codemod/src/transform/parse-source.ts @@ -1,14 +1,6 @@ import { parseSync } from 'oxc-parser'; import type { ASTNode, ParsedSource } from '../types.ts'; -/** - * The filename picks the dialect — `.tsx` enables JSX, anything else parses as - * plain TypeScript, so `.ts`-only syntax like `value` assertions and - * un-comma'd generic arrows parse correctly. oxc reports syntax errors as a - * list instead of throwing; any error means node offsets can't be trusted for - * splicing, so the first message is returned and the caller skips the file. - * ParsedSource is always an object, so the string return is unambiguous. - */ export function parseSource(src: string, filename: string): ParsedSource | string { const parsed = parseSync(filename, src); const [firstError] = parsed.errors; @@ -24,11 +16,6 @@ export function parseSource(src: string, filename: string): ParsedSource | strin return { program: parsed.program, comments: parsed.comments }; } -/** - * oxc's typed AST satisfies ASTNode structurally, but interfaces are never - * implicitly assignable to index-signature types; this predicate is the one - * place the tree's static shape crosses that boundary, guarded at runtime. - */ function isASTNode(value: unknown): value is ASTNode { return ( typeof value === 'object' && value !== null && 'type' in value && typeof value.type === 'string' diff --git a/packages/format-codemod/src/transform/plan-gap-edit.ts b/packages/format-codemod/src/transform/plan-gap-edit.ts index f44af62..242eae2 100644 --- a/packages/format-codemod/src/transform/plan-gap-edit.ts +++ b/packages/format-codemod/src/transform/plan-gap-edit.ts @@ -1,9 +1,5 @@ import type { ASTNode, CommentSpan, Edit, SourceFile } from '../types.ts'; -/** - * A pair of adjacent statements in a file and the gap shape they take: one - * blank line between them when `pad` is set, none otherwise. - */ export interface GapEditInput { readonly file: SourceFile; readonly prev: ASTNode; @@ -11,14 +7,6 @@ export interface GapEditInput { readonly pad: boolean; } -/** - * Plans the single whitespace splice that gives the gap between two statements - * its target shape, or null when the gap is already compliant or unsafe to - * touch. Collapsing never crosses a comment: prose between statements marks - * grouping the padding rules can't see, so a comment-bearing gap only ever - * grows. Comment positions come from the parser rather than lexical scanning, - * so comment-lookalike text can't mislead the classification. - */ export function planGapEdit(input: GapEditInput): Edit | null { const gap = buildGap(input.file, input.prev, input.next); @@ -29,9 +17,6 @@ export function planGapEdit(input: GapEditInput): Edit | null { return input.pad ? planBlankLineEdit(gap) : planCollapseEdit(gap); } -/** - * The gap between two statements: the trivia region and the comments in it. - */ interface Gap { readonly src: string; readonly start: number; @@ -63,21 +48,13 @@ function getNodeStart(n: ASTNode): number { return n.start; } -/** - * A gap is resizable only when it holds nothing but trivia and spans a line - * break. Same-line statements happen when a leading semicolon (`;(expr)` ASI - * guard) terminates the previous statement: the parser folds that `;` into the - * prior node, so the gap falls between `;` and `(` — a blank-only codemod - * cannot separate them without orphaning the `;`. - */ +// same-line statements come from a leading-semicolon ASI guard (`;(expr)`): +// the parser folds that `;` into the prior node, so the gap falls between `;` +// and `(` and a blank-only splice would orphan the `;` function isSafeToResize(gap: Gap): boolean { return isTriviaOnly(gap) && gap.src.slice(gap.start, gap.end).includes('\n'); } -/** - * True when everything in the gap outside the comment spans is whitespace — - * the exact form of "this gap holds only trivia". - */ function isTriviaOnly(gap: Gap): boolean { let cursor = gap.start; @@ -92,12 +69,6 @@ function isTriviaOnly(gap: Gap): boolean { return gap.src.slice(cursor, gap.end).trim() === ''; } -/** - * Comments starting on the previous statement's line stay attached to it; the - * gap is measured from just past the last of them. Any comment after that - * point is a leading comment for the next statement, and the blank line goes - * before it. - */ function planBlankLineEdit(gap: Gap): Edit | null { const effectiveStart = getEffectiveGapStart(gap); const leadingComment = gap.comments.find((c) => c.start >= effectiveStart); @@ -123,16 +94,8 @@ function getEffectiveGapStart(gap: Gap): number { return pos; } -/** - * Every rule is "exactly one blank line", so a compliant gap always holds two - * newlines: one ending the previous statement's line, one for the blank. - */ const MIN_NEWLINES = 2; -/** - * The blank line goes before the next statement's leading comment, preserving - * whatever whitespace leads into it. - */ function planLeadingCommentEdit(src: string, restStart: number, commentStart: number): Edit | null { const leading = src.slice(restStart, commentStart); const leadingNewlines = countNewlines(leading); @@ -165,11 +128,6 @@ function countNewlines(s: string): number { return (s.match(/\n/gu) ?? []).length; } -/** - * Every collapse target is "no blank line", so a compliant gap holds exactly - * one newline: the one ending the previous statement's line. Comment-bearing - * gaps are never collapsed — the author's spacing around prose stands. - */ function planCollapseEdit(gap: Gap): Edit | null { if (gap.comments.length > 0) { return null; diff --git a/packages/format-codemod/src/types.ts b/packages/format-codemod/src/types.ts index b6da247..f17c476 100644 --- a/packages/format-codemod/src/types.ts +++ b/packages/format-codemod/src/types.ts @@ -1,8 +1,3 @@ -/** - * The fields of the oxc AST this transform reads; the index signature carries - * every other node property so the generic child walk can recurse untyped. The - * codemod never mutates the tree, so everything is readonly. - */ export interface ASTNode { readonly type: string; readonly kind?: string; @@ -24,10 +19,6 @@ export interface ParsedSource { readonly comments: readonly CommentSpan[]; } -/** - * The raw text and its comment spans together — what gap planning needs to - * classify inter-statement trivia without re-lexing. - */ export interface SourceFile { readonly src: string; readonly comments: readonly CommentSpan[]; diff --git a/packages/oxlint-config/README.md b/packages/oxlint-config/README.md index b495bb2..237920b 100644 --- a/packages/oxlint-config/README.md +++ b/packages/oxlint-config/README.md @@ -4,9 +4,9 @@ Shareable [oxlint](https://oxc.rs/docs/guide/usage/linter.html) config: every ca restriction-rule cherry-picks, comment-style enforcement via `@stylistic`, and a bundled JS plugin (`zgeoff/*` rules) banning shapes no native oxlint rule can express — top-level arrows, nested function declarations, bare named exports, destructured and inline-typed parameters, ternary and -await arguments, awaits hidden inside a control-flow condition or a `&&`/`||`/`??` chain, and -single-line `/** … */` blocks (auto-fixed to multi-line; inline `@type`/`@lends` casts exempt). -`zgeoff/function-verb` enforces the function-naming taxonomy from the shared agent guidelines. +await arguments, awaits hidden inside a control-flow condition or a `&&`/`||`/`??` chain, every +JSDoc block, and any run of more than three line comments. `zgeoff/function-verb` enforces the +function-naming taxonomy from the shared agent guidelines. ## Usage @@ -54,6 +54,23 @@ Options extend the shipped set per repo: } ``` +## `zgeoff/no-jsdoc` and `zgeoff/max-consecutive-line-comments` + +A comment holds only what neither the code, a type, a test name, nor the subsystem doc can hold: the +reason a line does the non-obvious thing. `no-jsdoc` reports every `/** … */` block (inline +`@type`/`@lends` casts exempt). `max-consecutive-line-comments` reports a run of own-line `//` +comments longer than `max` (default 3); tool directives (`oxlint-`, `eslint-`, `@ts-`, …) neither +count nor join the prose around them. Neither rule fixes: a deleted comment is a decision, not a +whitespace change. + +```jsonc +{ + "rules": { + "zgeoff/max-consecutive-line-comments": ["error", { "max": 3 }], + }, +} +``` + ## Notes - The config enables the `typescript`, `unicorn`, `oxc`, `import`, and `promise` plugins and loads diff --git a/packages/oxlint-config/function-verb.js b/packages/oxlint-config/function-verb.js index bd8267b..fcc6813 100644 --- a/packages/oxlint-config/function-verb.js +++ b/packages/oxlint-config/function-verb.js @@ -148,15 +148,6 @@ function planReport(name, verbs, exemptNames) { }; } -/** - * Enforces the function-naming taxonomy on function declarations, - * function-valued variables, and class methods. Object-literal properties are - * exempt — they overwhelmingly implement externally-defined shapes (rule - * visitors, route tables) whose names the author doesn't choose — as are - * names not starting with a lowercase letter (components, classes). - * Options: `verbs` appends repo-local verbs to the shipped taxonomy; - * `exemptNames` skips exact names. - */ const functionVerb = { meta: { type: 'suggestion', diff --git a/packages/oxlint-config/max-consecutive-line-comments.js b/packages/oxlint-config/max-consecutive-line-comments.js new file mode 100644 index 0000000..b0d6e3b --- /dev/null +++ b/packages/oxlint-config/max-consecutive-line-comments.js @@ -0,0 +1,76 @@ +// tool directives are not prose: they neither count toward a run nor join +// the prose on either side of them into one +const directivePattern = + /^\s*(?:oxlint-|eslint-|@ts-|prettier-|oxfmt-|biome-ignore|#region|#endregion|\/\s*<)/u; + +function isOwnLine(text, comment) { + const lineStart = text.lastIndexOf('\n', comment.range[0] - 1) + 1; + + return /^[ \t]*$/u.test(text.slice(lineStart, comment.range[0])); +} + +function isProseLine(text, comment) { + return ( + comment.type === 'Line' && !directivePattern.test(comment.value) && isOwnLine(text, comment) + ); +} + +// a run is prose line comments on consecutive lines; whatever else occupies +// a line between two of them (code, a blank, a directive, a block comment) +// breaks adjacency and so ends the run +function collectRuns(text, comments) { + const runs = []; + + for (const comment of comments.filter((candidate) => isProseLine(text, candidate))) { + const run = runs.at(-1); + + if (run !== undefined && comment.loc.start.line === run.at(-1).loc.end.line + 1) { + run.push(comment); + } else { + runs.push([comment]); + } + } + + return runs; +} + +const maxConsecutiveLineComments = { + meta: { + type: 'suggestion', + messages: { + tooLong: + 'This comment run is {{count}} lines; the limit is {{max}}. Cut it to the fact the code cannot show, or move it into a test name or the subsystem doc.', + }, + schema: [ + { + type: 'object', + properties: { + max: { type: 'integer', minimum: 1 }, + }, + additionalProperties: false, + }, + ], + }, + create(context) { + const max = context.options[0]?.max ?? 3; + + return { + Program() { + const text = context.sourceCode.text; + const runs = collectRuns(text, context.sourceCode.getAllComments()); + + for (const run of runs) { + if (run.length > max) { + context.report({ + loc: { start: run[0].loc.start, end: run.at(-1).loc.end }, + messageId: 'tooLong', + data: { count: String(run.length), max: String(max) }, + }); + } + } + }, + }; + }, +}; + +export default maxConsecutiveLineComments; diff --git a/packages/oxlint-config/no-jsdoc.js b/packages/oxlint-config/no-jsdoc.js new file mode 100644 index 0000000..7345d0d --- /dev/null +++ b/packages/oxlint-config/no-jsdoc.js @@ -0,0 +1,40 @@ +// mirrors eslint-plugin-jsdoc's default singleLineTags: a block that exists to +// cast inline (`/** @type {Foo} */ (bar)`) is a type annotation, not prose +const inlineTagPattern = /^@(?:type|lends)\b/u; + +function isJSDoc(comment) { + return comment.type === 'Block' && comment.value.startsWith('*'); +} + +function isInlineCast(comment) { + return ( + comment.loc.start.line === comment.loc.end.line && + inlineTagPattern.test(comment.value.slice(1).trim()) + ); +} + +const noJSDoc = { + meta: { + type: 'suggestion', + messages: { + jsdoc: + 'Delete this JSDoc block. A rule a caller must respect is a test whose name states it or a sentence in the subsystem doc; the reason a line does the non-obvious thing is a `//` at that line.', + }, + schema: [], + }, + create(context) { + return { + Program() { + const offenders = context.sourceCode + .getAllComments() + .filter((comment) => isJSDoc(comment) && !isInlineCast(comment)); + + for (const comment of offenders) { + context.report({ loc: comment.loc, messageId: 'jsdoc' }); + } + }, + }; + }, +}; + +export default noJSDoc; diff --git a/packages/oxlint-config/oxlintrc.json b/packages/oxlint-config/oxlintrc.json index 372b316..a2c64eb 100644 --- a/packages/oxlint-config/oxlintrc.json +++ b/packages/oxlint-config/oxlintrc.json @@ -25,7 +25,8 @@ "zgeoff/no-await-args": "error", "zgeoff/no-await-in-condition": "error", "zgeoff/no-await-in-logical": "error", - "zgeoff/no-single-line-jsdoc": "error", + "zgeoff/no-jsdoc": "error", + "zgeoff/max-consecutive-line-comments": ["error", { "max": 3 }], "zgeoff/function-verb": "error", // enforced comment style — line comments unless JSDoc or exclamation diff --git a/packages/oxlint-config/package.json b/packages/oxlint-config/package.json index 1446228..338d4a1 100644 --- a/packages/oxlint-config/package.json +++ b/packages/oxlint-config/package.json @@ -12,6 +12,8 @@ "oxlintrc.json", "plugin.js", "function-verb.js", + "max-consecutive-line-comments.js", + "no-jsdoc.js", "index.js" ], "type": "module", diff --git a/packages/oxlint-config/plugin.js b/packages/oxlint-config/plugin.js index 7b01f9b..e6e907e 100644 --- a/packages/oxlint-config/plugin.js +++ b/packages/oxlint-config/plugin.js @@ -1,9 +1,10 @@ import functionVerb from './function-verb.js'; +import maxConsecutiveLineComments from './max-consecutive-line-comments.js'; +import noJSDoc from './no-jsdoc.js'; -// Custom lint rules, loaded through oxlint's jsPlugins (ESLint v9 rule API). -// Each rule bans a shape no native oxlint rule can express — esquery selectors -// for AST shapes, a source-comment scan where the target isn't -// esquery-selectable. +// custom rules loaded through oxlint's jsPlugins (ESLint v9 rule API): each +// bans a shape no native oxlint rule can express, as an esquery selector or a +// source-comment scan function buildBanRule(type, message, selectors) { return { @@ -19,82 +20,6 @@ function buildBanRule(type, message, selectors) { }; } -// mirrors eslint-plugin-jsdoc's default singleLineTags: blocks that exist to -// cast inline (`/** @type {Foo} */ (bar)`) stay single-line by design -const inlineTagPattern = /^@(?:type|lends)\b/u; - -function isSingleLineJSDoc(comment) { - return ( - comment.type === 'Block' && - comment.value.startsWith('*') && - comment.loc.start.line === comment.loc.end.line - ); -} - -/** - * Builds the multi-line replacement for a single-line JSDoc comment, or null - * when the comment shares its line with code — expanding those in place would - * scramble the surrounding statement, so they are reported without a fix. - */ -function planExpansion(text, comment) { - if (!isOwnLine(text, comment)) { - return null; - } - - const lineStart = text.lastIndexOf('\n', comment.range[0] - 1) + 1; - const indent = text.slice(lineStart, comment.range[0]); - const body = comment.value.slice(1).trim(); - const bodyLine = body === '' ? `${indent} *` : `${indent} * ${body}`; - - return `/**\n${bodyLine}\n${indent} */`; -} - -function isOwnLine(text, comment) { - const [start, end] = comment.range; - const lineStart = text.lastIndexOf('\n', start - 1) + 1; - const lineEnd = text.indexOf('\n', end); - const sliceEnd = lineEnd === -1 ? text.length : lineEnd; - - return /^[ \t]*$/u.test(text.slice(lineStart, start)) && text.slice(end, sliceEnd).trim() === ''; -} - -const noSingleLineJSDoc = { - meta: { - type: 'layout', - fixable: 'whitespace', - messages: { - singleLine: - 'Write JSDoc blocks multi-line: `/**` alone, one `*`-prefixed line per point, `*/` alone.', - }, - schema: [], - }, - create(context) { - return { - Program() { - const offenders = context.sourceCode - .getAllComments() - .filter( - (comment) => - isSingleLineJSDoc(comment) && !inlineTagPattern.test(comment.value.slice(1).trim()), - ); - - for (const comment of offenders) { - const replacement = planExpansion(context.sourceCode.text, comment); - - context.report({ - loc: comment.loc, - messageId: 'singleLine', - fix: - replacement === null - ? undefined - : (fixer) => fixer.replaceTextRange(comment.range, replacement), - }); - } - }, - }; - }, -}; - const plugin = { meta: { name: 'zgeoff' }, rules: { @@ -167,7 +92,8 @@ const plugin = { 'Await into a named const instead of chaining it after a && / || / ?? operator.', ['LogicalExpression > AwaitExpression'], ), - 'no-single-line-jsdoc': noSingleLineJSDoc, + 'no-jsdoc': noJSDoc, + 'max-consecutive-line-comments': maxConsecutiveLineComments, 'function-verb': functionVerb, }, }; diff --git a/packages/oxlint-config/plugin.test.ts b/packages/oxlint-config/plugin.test.ts index 73036f8..e784988 100644 --- a/packages/oxlint-config/plugin.test.ts +++ b/packages/oxlint-config/plugin.test.ts @@ -26,8 +26,8 @@ async function createLintTree(source: string, rules: RuleSettings): Promise { - const dir = await createLintTree(source, rules ?? { 'zgeoff/no-single-line-jsdoc': 'error' }); +async function runLint(source: string, fix: boolean, rules: RuleSettings): Promise { + const dir = await createLintTree(source, rules); const fixArgs = fix ? ['--fix'] : []; const args = [oxlintBin, '-c', '.oxlintrc.json', ...fixArgs, 'sample.ts']; @@ -39,11 +39,6 @@ async function runLint(source: string, fix: boolean, rules?: RuleSettings): Prom return { exitCode, stdout, output }; } -/** - * Collects the allowed verbs from the taxonomy tables in the shared agents - * partial: every table row between the section heading and the banned - * paragraph, with `` templates reduced to their leading verb. - */ function collectTaxonomyVerbs(markdown: string): string[] { const start = markdown.indexOf('### Function naming'); const end = markdown.indexOf('**Banned**'); @@ -53,11 +48,6 @@ function collectTaxonomyVerbs(markdown: string): string[] { return [...new Set(verbs)]; } -/** - * Collects the banned verbs from the shared agents partial: the backticked - * words in the banned paragraph, excluding parenthesized asides (replacement - * pointers and the framework-convention carve-out). - */ function collectBannedVerbs(markdown: string): string[] { const start = markdown.indexOf('**Banned**'); const end = markdown.indexOf('Algorithm-native'); @@ -83,45 +73,53 @@ function countOccurrences(text: string, part: string): number { return text.split(part).length - 1; } -test('it flags a single-line JSDoc block', async () => { - const result = await runLint('/** Documents the export. */\nexport const answer = 42;\n', false); +const noJSDoc = { 'zgeoff/no-jsdoc': 'error' }; +const maxRun = { 'zgeoff/max-consecutive-line-comments': 'error' }; + +test('it flags a multi-line JSDoc block', async () => { + const source = '/**\n * Documents the export.\n */\nexport const answer = 42;\n'; + + const result = await runLint(source, false, noJSDoc); expect(result.exitCode).toBe(1); - expect(result.stdout).toInclude('no-single-line-jsdoc'); + expect(result.stdout).toInclude('no-jsdoc'); }); -test('it expands a single-line block to multi-line under --fix', async () => { - const result = await runLint('/** Documents the export. */\nexport const answer = 42;\n', true); +test('it flags a single-line JSDoc block', async () => { + const result = await runLint( + '/** Documents the export. */\nexport const answer = 42;\n', + false, + noJSDoc, + ); - expect(result.exitCode).toBe(0); - expect(result.output).toBe('/**\n * Documents the export.\n */\nexport const answer = 42;\n'); + expect(result.exitCode).toBe(1); + expect(result.stdout).toInclude('no-jsdoc'); }); -test('it preserves indentation when fixing an indented block', async () => { - const source = 'class Box {\n /** Holds the value. */\n value = 1;\n}\n'; +test('it flags a JSDoc block inside a class body', async () => { + const source = 'class Box {\n /**\n * Holds the value.\n */\n value = 1;\n}\n'; - const result = await runLint(source, true); + const result = await runLint(source, false, noJSDoc); - expect(result.exitCode).toBe(0); - expect(result.output).toBe('class Box {\n /**\n * Holds the value.\n */\n value = 1;\n}\n'); + expect(result.exitCode).toBe(1); }); -test('it leaves multi-line blocks, line comments, and plain block comments alone', async () => { +test('it leaves line comments and plain block comments alone', async () => { const source = [ - '/**', - ' * Already multi-line.', - ' */', - 'export const a = 1;', - '', '// line comment', 'export const b = 2;', '', '/* plain block */', 'export const c = 3;', '', + '/*', + ' * plain multi-line block', + ' */', + 'export const d = 4;', + '', ].join('\n'); - const result = await runLint(source, false); + const result = await runLint(source, false, noJSDoc); expect(result.exitCode).toBe(0); }); @@ -129,19 +127,83 @@ test('it leaves multi-line blocks, line comments, and plain block comments alone test('it exempts inline @type and @lends casts', async () => { const source = 'export const config = /** @type {const} */ ({ port: 3000 });\n'; - const result = await runLint(source, false); + const result = await runLint(source, false, noJSDoc); + + expect(result.exitCode).toBe(0); +}); + +test('it flags a run of four line comments and accepts three', async () => { + const four = '// one\n// two\n// three\n// four\nexport const a = 1;\n'; + const three = '// one\n// two\n// three\nexport const a = 1;\n'; + + const fourResult = await runLint(four, false, maxRun); + const threeResult = await runLint(three, false, maxRun); + + expect(fourResult.exitCode).toBe(1); + expect(fourResult.stdout).toInclude('max-consecutive-line-comments'); + expect(fourResult.stdout).toInclude('4 lines; the limit is 3'); + expect(threeResult.exitCode).toBe(0); +}); + +test('it honours the max option', async () => { + const source = '// one\n// two\nexport const a = 1;\n'; + + const result = await runLint(source, false, { + 'zgeoff/max-consecutive-line-comments': ['error', { max: 1 }], + }); + + expect(result.exitCode).toBe(1); +}); + +test('it ends a run at a blank line, a statement, or a trailing comment', async () => { + const source = [ + '// one', + '// two', + '', + '// three', + '// four', + 'export const a = 1; // trailing', + '// five', + '// six', + '', + ].join('\n'); + + const result = await runLint(source, false, maxRun); + + expect(result.exitCode).toBe(0); +}); + +test('it neither counts a tool directive nor joins the prose around it', async () => { + const source = [ + '// one', + '// two', + '// oxlint-disable-next-line no-console -- baseline', + '// three', + '// four', + 'console.log(1);', + '', + ].join('\n'); + + const result = await runLint(source, false, maxRun); expect(result.exitCode).toBe(0); }); -test('it reports but does not fix a block sharing its line with code', async () => { - const source = - 'export function isReady(/** milliseconds */ delay: number): boolean {\n return delay > 0;\n}\n'; +test('it counts an indented run inside a block', async () => { + const source = [ + 'export function run(): void {', + ' // one', + ' // two', + ' // three', + ' // four', + ' return;', + '}', + '', + ].join('\n'); - const result = await runLint(source, true); + const result = await runLint(source, false, maxRun); expect(result.exitCode).toBe(1); - expect(result.output).toBe(source); }); test('it flags a function whose name lacks a taxonomy verb', async () => {