From 6ca0dc70b8c9d9672fd423e80d9f5b8c8e1141c8 Mon Sep 17 00:00:00 2001 From: Mirek Rusin Date: Sat, 4 Jul 2026 18:21:58 +0000 Subject: [PATCH] Add @nothrow mute comments and --fix autofix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Muting, mirroring eslint's disable comments: - `// @nothrow` / `// @nothrow-line` trailing a line mutes that line - `// @nothrow-next-line` mutes the following line A muted throw site (throw, propagating call, Promise.reject) is ignored entirely; muting the declaration line suppresses the function's missing-@throws report, and muting a @throws tag line suppresses its unused-tag warning. Directives are detected via the TS scanner so occurrences inside string literals don't trigger. Autofix: missing-throws diagnostics now carry a text edit that appends `* @throws {Type}` lines to an existing JSDoc block (breaking open single-line blocks), or creates a fresh block above the declaration — above the variable statement for arrows — matching indentation. New `applyFixes()` in @throwscript/core applies edits with dedupe/overlap handling; `throwscript --fix` writes the files and re-checks. Fixes are skipped for declarations that don't start their own line. Bump both packages to 0.2.0. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UG7WqvjcBWuMRj24ssFSrd --- README.md | 51 ++++++- packages/cli/package.json | 2 +- packages/cli/src/main.ts | 96 ++++++++----- packages/cli/test/cli.test.ts | 46 +++++++ packages/core/package.json | 2 +- packages/core/src/analyzer.ts | 180 ++++++++++++++++++++++--- packages/core/src/index.ts | 47 +++++++ packages/core/test/fixtures/fixable.ts | 33 +++++ packages/core/test/fixtures/muted.ts | 65 +++++++++ packages/core/test/mute-fix.test.ts | 104 ++++++++++++++ 10 files changed, 574 insertions(+), 52 deletions(-) create mode 100644 packages/core/test/fixtures/fixable.ts create mode 100644 packages/core/test/fixtures/muted.ts create mode 100644 packages/core/test/mute-fix.test.ts diff --git a/README.md b/README.md index e5eedc0..7a021d2 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,9 @@ throwscript --project path/to/tsconfig.json # check individual files throwscript src/a.ts src/b.ts +# automatically insert the missing @throws tags +throwscript --fix src/a.ts + # machine-readable output throwscript --json src/a.ts ``` @@ -55,6 +58,48 @@ throwscript --json src/a.ts Exit code is `1` when any function is missing a `@throws` tag, `0` otherwise. Unused `@throws` tags are reported as warnings (disable with `--no-unused`). +## Muting with `@nothrow` + +Like eslint's disable comments, individual lines can be muted: + +```ts +throw new CacheMissError(key); // @nothrow — mutes this line +throw new CacheMissError(key); // @nothrow-line — same thing, explicit + +// @nothrow-next-line +throw new CacheMissError(key); // — muted by the line above +``` + +What a muted line means depends on what is on it: + +- a `throw`, a propagating call, or a `Promise.reject` — that throw site is + ignored (it no longer needs documenting, and no longer counts toward a + documented tag being "used") +- the function declaration itself (put `// @nothrow-next-line` directly above + the declaration, below any JSDoc) — the whole function's missing-`@throws` + report is muted +- a `@throws` tag line inside a JSDoc block — the unused-tag warning for that + tag is muted + +The directives work in `//` and `/* */` comments; inside a multi-line comment +a directive applies relative to the line it is written on. + +## Autofix + +`throwscript --fix` inserts the missing tags and then re-checks: + +- a function with an existing JSDoc block gets `* @throws {Type}` lines + appended before the closing `*/` (single-line `/** ... */` blocks are broken + open) +- a function without JSDoc gets a fresh block above the declaration — for + arrow functions assigned to a variable, above the variable statement — + matching the surrounding indentation + +Only missing-`@throws` errors are auto-fixed; unused-tag warnings are left for +a human to judge. Fixes are skipped for declarations that do not start their +own line (e.g. inline callbacks), where a JSDoc block cannot be attached +unambiguously. + ## What counts as "can throw" | Construct | Behaviour | @@ -102,8 +147,10 @@ for (const d of diagnostics) { ``` Each diagnostic carries `kind` (`missing-throws` | `unused-throws`), `severity`, -`file`, `line`, `column`, `functionName`, the error `types` involved, and a -human-readable `message`. +`file`, `line`, `column`, `functionName`, the error `types` involved, a +human-readable `message`, and — for fixable problems — a `fix` text edit. +`applyFixes(diagnostics)` returns the patched file contents keyed by file name +for the caller to write to disk. ## Known limitations diff --git a/packages/cli/package.json b/packages/cli/package.json index 46bf802..5e68413 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "throwscript", - "version": "0.1.0", + "version": "0.2.0", "description": "Linter-like checker: every function that can throw must document it with JSDoc @throws", "type": "module", "license": "MIT", diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index d716f5f..e84542d 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -1,10 +1,11 @@ #!/usr/bin/env node -import { existsSync } from "node:fs"; +import { existsSync, writeFileSync } from "node:fs"; import path from "node:path"; import process from "node:process"; import { analyzeFiles, analyzeProject, + applyFixes, formatDiagnostic, type ThrowsDiagnostic, } from "@throwscript/core"; @@ -19,8 +20,14 @@ directory and checks every file in the project. Async / Promise-returning functions are handled implicitly: a rejection must be documented with the same \`@throws {ErrorType}\` tag as a synchronous throw. +Muting (eslint-style): + // @nothrow trailing comment mutes the throw/call on that line + // @nothrow-line same as @nothrow + // @nothrow-next-line mutes the following line + Options: -p, --project Check all files from the given tsconfig project + --fix Insert missing @throws tags into JSDoc comments --no-unused Do not warn about @throws tags that never throw --json Emit diagnostics as JSON -h, --help Show this help @@ -32,6 +39,7 @@ interface CliOptions { files: string[]; reportUnused: boolean; json: boolean; + fix: boolean; } function parseArgs(argv: string[]): CliOptions | "help" | "version" { @@ -40,6 +48,7 @@ function parseArgs(argv: string[]): CliOptions | "help" | "version" { files: [], reportUnused: true, json: false, + fix: false, }; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; @@ -60,6 +69,9 @@ function parseArgs(argv: string[]): CliOptions | "help" | "version" { options.project = value; break; } + case "--fix": + options.fix = true; + break; case "--no-unused": options.reportUnused = false; break; @@ -78,6 +90,35 @@ function parseArgs(argv: string[]): CliOptions | "help" | "version" { class UsageError extends Error {} +/** + * @throws {UsageError} when the command line is invalid + */ +function analyze(options: CliOptions): ThrowsDiagnostic[] { + const analyzeOptions = { reportUnused: options.reportUnused }; + if (options.files.length > 0) { + if (options.project !== undefined) { + throw new UsageError("pass either --project or a list of files, not both"); + } + const missing = options.files.filter((f) => !existsSync(f)); + if (missing.length > 0) { + throw new UsageError(`file not found: ${missing.join(", ")}`); + } + const targets = new Set(options.files.map((f) => path.resolve(f))); + return analyzeFiles([...targets], analyzeOptions).filter((d) => + targets.has(path.resolve(d.file)), + ); + } + const tsconfig = options.project ?? path.join(process.cwd(), "tsconfig.json"); + if (!existsSync(tsconfig)) { + throw new UsageError( + options.project !== undefined + ? `tsconfig not found: ${tsconfig}` + : "no files given and no tsconfig.json found in the current directory", + ); + } + return analyzeProject(tsconfig, analyzeOptions); +} + function run(): number { let parsed: CliOptions | "help" | "version"; try { @@ -96,45 +137,32 @@ function run(): number { return 0; } if (parsed === "version") { - console.log("throwscript 0.1.0"); + console.log("throwscript 0.2.0"); return 0; } - const analyzeOptions = { reportUnused: parsed.reportUnused }; - let diagnostics: ThrowsDiagnostic[]; const cwd = process.cwd(); + let diagnostics: ThrowsDiagnostic[]; + let fixedCount = 0; try { - if (parsed.files.length > 0) { - if (parsed.project !== undefined) { - console.error("error: pass either --project or a list of files, not both\n"); - return 2; + diagnostics = analyze(parsed); + if (parsed.fix) { + const fixable = diagnostics.filter((d) => d.fix !== undefined); + const updated = applyFixes(diagnostics); + for (const [file, text] of updated) { + writeFileSync(file, text); } - const missing = parsed.files.filter((f) => !existsSync(f)); - if (missing.length > 0) { - console.error(`error: file not found: ${missing.join(", ")}`); - return 2; - } - diagnostics = analyzeFiles( - parsed.files.map((f) => path.resolve(f)), - analyzeOptions, - ); - const targets = new Set(parsed.files.map((f) => path.resolve(f))); - diagnostics = diagnostics.filter((d) => targets.has(path.resolve(d.file))); - } else { - const tsconfig = parsed.project ?? path.join(cwd, "tsconfig.json"); - if (!existsSync(tsconfig)) { - console.error( - parsed.project !== undefined - ? `error: tsconfig not found: ${tsconfig}` - : "error: no files given and no tsconfig.json found in the current directory", - ); - console.error(HELP); - return 2; + if (updated.size > 0) { + fixedCount = fixable.length; + diagnostics = analyze(parsed); } - diagnostics = analyzeProject(tsconfig, analyzeOptions); } } catch (error) { + if (error instanceof UsageError) { + console.error(`error: ${error.message}`); + return 2; + } console.error(`error: ${error instanceof Error ? error.message : String(error)}`); return 2; } @@ -151,12 +179,16 @@ function run(): number { } const errors = diagnostics.filter((d) => d.severity === "error").length; const warnings = diagnostics.length - errors; + const fixedNote = + fixedCount > 0 + ? ` (${fixedCount} problem${fixedCount === 1 ? "" : "s"} fixed)` + : ""; if (diagnostics.length === 0) { - console.log("throwscript: no problems found"); + console.log(`throwscript: no problems found${fixedNote}`); } else { console.log( `\nthrowscript: ${errors} error${errors === 1 ? "" : "s"}, ` + - `${warnings} warning${warnings === 1 ? "" : "s"}`, + `${warnings} warning${warnings === 1 ? "" : "s"}${fixedNote}`, ); } } diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts index 74fd353..c69581b 100644 --- a/packages/cli/test/cli.test.ts +++ b/packages/cli/test/cli.test.ts @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { execFileSync } from "node:child_process"; +import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import path from "node:path"; @@ -54,6 +56,50 @@ test("--json emits machine-readable diagnostics", () => { assert.deepEqual(parsed[0]?.types, ["BoomError"]); }); +test("--fix inserts the missing @throws tags and exits 0", () => { + const workDir = mkdtempSync(path.join(tmpdir(), "throwscript-cli-fix-")); + try { + const target = path.join(workDir, "sample.ts"); + cpSync(fixture, target); + + const fixRun = runCli(["--fix", target]); + assert.equal(fixRun.status, 0, fixRun.stdout + fixRun.stderr); + assert.match(fixRun.stdout, /no problems found \(1 problem fixed\)/); + + const fixedText = readFileSync(target, "utf8"); + assert.match( + fixedText, + /\/\*\*\n \* @throws \{BoomError\}\n \*\/\nexport function undocumented/, + ); + + // A second, plain run confirms the file is clean. + const verifyRun = runCli([target]); + assert.equal(verifyRun.status, 0, verifyRun.stdout + verifyRun.stderr); + } finally { + rmSync(workDir, { recursive: true, force: true }); + } +}); + +test("@nothrow mutes a diagnostic from the CLI", () => { + const workDir = mkdtempSync(path.join(tmpdir(), "throwscript-cli-mute-")); + try { + const target = path.join(workDir, "muted.ts"); + const original = readFileSync(fixture, "utf8"); + const muted = original.replace( + 'throw new BoomError("boom");\n}\n\n/**', + 'throw new BoomError("boom"); // @nothrow\n}\n\n/**', + ); + assert.notEqual(muted, original, "fixture rewrite must apply"); + writeFileSync(target, muted); + + const result = runCli([target]); + assert.equal(result.status, 0, result.stdout + result.stderr); + assert.match(result.stdout, /no problems found/); + } finally { + rmSync(workDir, { recursive: true, force: true }); + } +}); + test("--help exits 0", () => { const result = runCli(["--help"]); assert.equal(result.status, 0); diff --git a/packages/core/package.json b/packages/core/package.json index 25ede77..f7fa0ee 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@throwscript/core", - "version": "0.1.0", + "version": "0.2.0", "description": "Checker that asserts every function that can throw documents it with a JSDoc @throws tag", "type": "module", "license": "MIT", diff --git a/packages/core/src/analyzer.ts b/packages/core/src/analyzer.ts index 632130f..e19e328 100644 --- a/packages/core/src/analyzer.ts +++ b/packages/core/src/analyzer.ts @@ -3,6 +3,13 @@ import ts from "typescript"; export type DiagnosticKind = "missing-throws" | "unused-throws"; export type Severity = "error" | "warning"; +/** A text edit that resolves a diagnostic: replace [start, end) with `text`. */ +export interface ThrowsFix { + start: number; + end: number; + text: string; +} + export interface ThrowsDiagnostic { kind: DiagnosticKind; severity: Severity; @@ -13,6 +20,8 @@ export interface ThrowsDiagnostic { /** Error type names involved (missing or unused, depending on kind). */ types: string[]; message: string; + /** Present when the diagnostic can be auto-fixed (see `applyFixes`). */ + fix?: ThrowsFix; } export interface AnalyzeOptions { @@ -65,15 +74,57 @@ export function analyzeSourceFile( options: AnalyzeOptions, diagnostics: ThrowsDiagnostic[], ): void { + const mutedLines = collectMutedLines(sourceFile); const visit = (node: ts.Node): void => { if (isCheckableFunction(node)) { - checkFunction(node, sourceFile, checker, options, diagnostics); + checkFunction(node, sourceFile, checker, options, diagnostics, mutedLines); } ts.forEachChild(node, visit); }; visit(sourceFile); } +const MUTE_DIRECTIVE = /@nothrow(-next-line|-line)?\b/g; + +/** + * Collect 0-based line numbers muted by `@nothrow` directives in comments, + * mirroring eslint's disable comments: + * + * - `// @nothrow` or `// @nothrow-line` mutes the line the directive is on + * (use it trailing a `throw`, a call, a declaration, or a `@throws` tag) + * - `// @nothrow-next-line` mutes the following line + * + * Both forms also work inside block comments; inside a multi-line comment the + * directive applies relative to the line it is written on. + */ +export function collectMutedLines(sourceFile: ts.SourceFile): Set { + const muted = new Set(); + const scanner = ts.createScanner( + ts.ScriptTarget.Latest, + /* skipTrivia */ false, + ts.LanguageVariant.Standard, + sourceFile.text, + ); + let token = scanner.scan(); + while (token !== ts.SyntaxKind.EndOfFileToken) { + if ( + token === ts.SyntaxKind.SingleLineCommentTrivia || + token === ts.SyntaxKind.MultiLineCommentTrivia + ) { + const commentStart = scanner.getTokenStart(); + const commentText = scanner.getTokenText(); + for (const match of commentText.matchAll(MUTE_DIRECTIVE)) { + const directiveLine = sourceFile.getLineAndCharacterOfPosition( + commentStart + match.index, + ).line; + muted.add(match[1] === "-next-line" ? directiveLine + 1 : directiveLine); + } + } + token = scanner.scan(); + } + return muted; +} + function isCheckableFunction(node: ts.Node): node is ts.FunctionLikeDeclaration { return ( (ts.isFunctionDeclaration(node) || @@ -93,8 +144,16 @@ function checkFunction( checker: ts.TypeChecker, options: AnalyzeOptions, diagnostics: ThrowsDiagnostic[], + mutedLines: Set, ): void { - const thrown = collectThrownTypes(fn, checker); + // A muted throw site (throw statement, propagating call, Promise.reject) + // does not count as an observable throw at all. + const thrown = collectThrownTypes(fn, checker).filter( + (t) => + !mutedLines.has( + sourceFile.getLineAndCharacterOfPosition(t.node.getStart(sourceFile)).line, + ), + ); const documented = getDocumentedThrows(fn, checker); const missing = thrown.filter( @@ -111,20 +170,23 @@ function checkFunction( if (missingByName.size > 0) { const anchor = fn.name ?? fn; const pos = sourceFile.getLineAndCharacterOfPosition(anchor.getStart(sourceFile)); - const types = [...missingByName.keys()]; - diagnostics.push({ - kind: "missing-throws", - severity: "error", - file: sourceFile.fileName, - line: pos.line + 1, - column: pos.character + 1, - functionName: name, - types, - message: - `${name} can throw ${formatTypeList(types)} but has no @throws tag for ` + - `${types.length === 1 ? "it" : "them"}. ` + - `Document with ${types.map((t) => `\`@throws {${t}}\``).join(", ")}.`, - }); + if (!mutedLines.has(pos.line)) { + const types = [...missingByName.keys()]; + diagnostics.push({ + kind: "missing-throws", + severity: "error", + file: sourceFile.fileName, + line: pos.line + 1, + column: pos.character + 1, + functionName: name, + types, + message: + `${name} can throw ${formatTypeList(types)} but has no @throws tag for ` + + `${types.length === 1 ? "it" : "them"}. ` + + `Document with ${types.map((t) => `\`@throws {${t}}\``).join(", ")}.`, + fix: computeMissingThrowsFix(fn, sourceFile, types), + }); + } } if (options.reportUnused !== false) { @@ -133,6 +195,7 @@ function checkFunction( ); for (const d of unused) { const pos = sourceFile.getLineAndCharacterOfPosition(d.tag.getStart(sourceFile)); + if (mutedLines.has(pos.line)) continue; diagnostics.push({ kind: "unused-throws", severity: "warning", @@ -147,6 +210,91 @@ function checkFunction( } } +/** + * Compute the text edit that documents the missing types: append `@throws` + * lines to the function's existing JSDoc block, or create a new block above + * the declaration. Returns undefined when no safe insertion point exists + * (e.g. an inline callback that does not start its line). + */ +function computeMissingThrowsFix( + fn: ts.FunctionLikeDeclaration, + sourceFile: ts.SourceFile, + types: string[], +): ThrowsFix | undefined { + const anchor = fixAnchor(fn); + const text = sourceFile.text; + + const anchorStart = anchor.getStart(sourceFile); + const anchorLine = sourceFile.getLineAndCharacterOfPosition(anchorStart).line; + const lineStart = sourceFile.getPositionOfLineAndCharacter(anchorLine, 0); + // Only fix when the declaration starts its own line — inserting a JSDoc + // block mid-expression would attach it to the wrong node. + if (text.slice(lineStart, anchorStart).trim() !== "") return undefined; + const indent = text.slice(lineStart, anchorStart); + + const jsdoc = findLeadingJSDocRange(sourceFile, anchor); + if (jsdoc === undefined) { + const block = + `${indent}/**\n` + + types.map((t) => `${indent} * @throws {${t}}\n`).join("") + + `${indent} */\n`; + return { start: lineStart, end: lineStart, text: block }; + } + + const closeStart = jsdoc.end - 2; // position of the closing `*/` + const openLine = sourceFile.getLineAndCharacterOfPosition(jsdoc.pos).line; + const closeLine = sourceFile.getLineAndCharacterOfPosition(closeStart).line; + if (closeLine > openLine) { + // Multi-line JSDoc: insert the tags above the closing line. + const closeLineStart = sourceFile.getPositionOfLineAndCharacter(closeLine, 0); + const lines = types.map((t) => `${indent} * @throws {${t}}\n`).join(""); + return { start: closeLineStart, end: closeLineStart, text: lines }; + } + // Single-line JSDoc (`/** desc */`): break it open before the `*/`. + const lines = + `\n` + + types.map((t) => `${indent} * @throws {${t}}`).join("\n") + + `\n${indent} `; + return { start: closeStart, end: closeStart, text: lines }; +} + +/** The node a JSDoc comment for `fn` attaches to (e.g. the variable statement for an arrow). */ +function fixAnchor(fn: ts.FunctionLikeDeclaration): ts.Node { + if (ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) { + const parent = fn.parent; + if ( + ts.isVariableDeclaration(parent) && + ts.isVariableDeclarationList(parent.parent) && + ts.isVariableStatement(parent.parent.parent) + ) { + return parent.parent.parent; + } + if (ts.isPropertyAssignment(parent) || ts.isPropertyDeclaration(parent)) { + return parent; + } + } + return fn; +} + +/** The range of the JSDoc block immediately preceding `node`, if any. */ +function findLeadingJSDocRange( + sourceFile: ts.SourceFile, + node: ts.Node, +): ts.CommentRange | undefined { + const ranges = ts.getLeadingCommentRanges(sourceFile.text, node.pos) ?? []; + for (let i = ranges.length - 1; i >= 0; i--) { + const range = ranges[i]; + if ( + range !== undefined && + range.kind === ts.SyntaxKind.MultiLineCommentTrivia && + sourceFile.text.startsWith("/**", range.pos) + ) { + return range; + } + } + return undefined; +} + /** * Whether a documented @throws type covers a thrown type: exact name match, or * the documented type appears in the thrown type's heritage chain (e.g. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4897bd3..fcbff7b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,10 +8,12 @@ import { export { analyzeProgram, analyzeSourceFile, + collectMutedLines, type AnalyzeOptions, type DiagnosticKind, type Severity, type ThrowsDiagnostic, + type ThrowsFix, } from "./analyzer.js"; const DEFAULT_COMPILER_OPTIONS: ts.CompilerOptions = { @@ -63,6 +65,51 @@ export function analyzeProject( return analyzeProgram(program, options); } +/** + * Apply the auto-fixes carried by the given diagnostics and return the new + * file contents, keyed by file name. Overlapping and duplicate fixes are + * applied once; files without fixable diagnostics are omitted. The caller is + * responsible for writing the results to disk. + */ +export function applyFixes( + diagnostics: ThrowsDiagnostic[], + readFile: (fileName: string) => string | undefined = ts.sys.readFile, +): Map { + const byFile = new Map(); + for (const d of diagnostics) { + if (d.fix === undefined) continue; + const fixes = byFile.get(d.file) ?? []; + fixes.push(d.fix); + byFile.set(d.file, fixes); + } + + const results = new Map(); + for (const [file, fixes] of byFile) { + const original = readFile(file); + if (original === undefined) continue; + const seen = new Set(); + const unique = fixes + .filter((f): f is NonNullable => f !== undefined) + .filter((f) => { + const key = `${f.start}:${f.end}:${f.text}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }) + .sort((a, b) => b.start - a.start); + + let text = original; + let lastApplied = Number.POSITIVE_INFINITY; + for (const fix of unique) { + if (fix.end > lastApplied) continue; // overlaps a fix already applied + text = text.slice(0, fix.start) + fix.text + text.slice(fix.end); + lastApplied = fix.start; + } + results.set(file, text); + } + return results; +} + /** Render a diagnostic as `file:line:col severity message`. */ export function formatDiagnostic(d: ThrowsDiagnostic, cwd?: string): string { const file = diff --git a/packages/core/test/fixtures/fixable.ts b/packages/core/test/fixtures/fixable.ts new file mode 100644 index 0000000..aa44421 --- /dev/null +++ b/packages/core/test/fixtures/fixable.ts @@ -0,0 +1,33 @@ +export class FixError extends Error {} +export class SecondError extends Error {} + +export function noDoc(): void { + throw new FixError("a"); +} + +/** + * Existing multi-line docs. + */ +export function withDoc(): void { + throw new FixError("b"); +} + +/** One-liner. */ +export function oneLine(): void { + throw new FixError("c"); +} + +export const arrow = (): void => { + throw new FixError("d"); +}; + +export class Box { + method(): void { + throw new FixError("e"); + } +} + +export function twoTypes(kind: number): void { + if (kind === 1) throw new FixError("f"); + throw new SecondError("g"); +} diff --git a/packages/core/test/fixtures/muted.ts b/packages/core/test/fixtures/muted.ts new file mode 100644 index 0000000..a2f7c89 --- /dev/null +++ b/packages/core/test/fixtures/muted.ts @@ -0,0 +1,65 @@ +export class MutedError extends Error {} +export class OtherError extends Error {} + +// GOOD: bare @nothrow trailing the throw line mutes it. +export function mutedSameLine(): void { + throw new MutedError("x"); // @nothrow +} + +// GOOD: explicit -line variant. +export function mutedLineSuffix(): void { + throw new MutedError("x"); // @nothrow-line +} + +// GOOD: -next-line variant above the throw. +export function mutedNextLine(): void { + // @nothrow-next-line + throw new MutedError("x"); +} + +// GOOD: -next-line above the declaration mutes the whole function's report. +// @nothrow-next-line +export function mutedFunction(): void { + throw new MutedError("x"); +} + +// BAD: a directive only mutes its own line; the throw on the next line is +// still reported. +export function notMuted(): void { + // @nothrow + throw new MutedError("x"); +} + +// BAD: muting one throw does not mute the others. +export function partiallyMuted(kind: number): void { + if (kind === 1) throw new MutedError("muted"); // @nothrow + throw new OtherError("reported"); +} + +/** + * @throws {MutedError} + */ +export function documentedThrower(): void { + throw new MutedError("boom"); +} + +// GOOD: muted call — the callee's documented error does not propagate. +export function mutedCall(): void { + documentedThrower(); // @nothrow +} + +// GOOD: unused @throws warning muted with a directive on the tag's line. +/** + * @throws {MutedError} kept for API compatibility @nothrow-line + */ +export function overDocumentedMuted(a: number): number { + return a; +} + +// WARNING: control case — same shape without the directive still warns. +/** + * @throws {MutedError} legacy claim + */ +export function overDocumentedReported(a: number): number { + return a; +} diff --git a/packages/core/test/mute-fix.test.ts b/packages/core/test/mute-fix.test.ts new file mode 100644 index 0000000..7f2d649 --- /dev/null +++ b/packages/core/test/mute-fix.test.ts @@ -0,0 +1,104 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { cpSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import { analyzeFiles, applyFixes, type ThrowsDiagnostic } from "../src/index.js"; + +const fixturesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures"); + +function analyzeFixture(name: string): ThrowsDiagnostic[] { + return analyzeFiles([path.join(fixturesDir, name)]).filter((d) => + d.file.endsWith(name), + ); +} + +function errorsFor(diagnostics: ThrowsDiagnostic[], fnName: string): ThrowsDiagnostic[] { + return diagnostics.filter( + (d) => d.kind === "missing-throws" && d.functionName === `'${fnName}'`, + ); +} + +test("@nothrow directives mute throw sites and diagnostics", () => { + const diagnostics = analyzeFixture("muted.ts"); + + assert.equal(errorsFor(diagnostics, "mutedSameLine").length, 0); + assert.equal(errorsFor(diagnostics, "mutedLineSuffix").length, 0); + assert.equal(errorsFor(diagnostics, "mutedNextLine").length, 0); + assert.equal(errorsFor(diagnostics, "mutedFunction").length, 0); + assert.equal(errorsFor(diagnostics, "mutedCall").length, 0); + + const notMuted = errorsFor(diagnostics, "notMuted"); + assert.equal(notMuted.length, 1); + assert.deepEqual(notMuted[0]?.types, ["MutedError"]); + + const partial = errorsFor(diagnostics, "partiallyMuted"); + assert.equal(partial.length, 1); + assert.deepEqual(partial[0]?.types, ["OtherError"]); +}); + +test("@nothrow on a @throws tag line mutes the unused warning", () => { + const diagnostics = analyzeFixture("muted.ts"); + const unused = diagnostics.filter((d) => d.kind === "unused-throws"); + assert.equal(unused.length, 1); + assert.equal(unused[0]?.functionName, "'overDocumentedReported'"); +}); + +test("missing-throws diagnostics carry fixes; applyFixes resolves them", () => { + const workDir = mkdtempSync(path.join(tmpdir(), "throwscript-fix-")); + try { + const target = path.join(workDir, "fixable.ts"); + cpSync(path.join(fixturesDir, "fixable.ts"), target); + + const before = analyzeFiles([target]).filter((d) => d.kind === "missing-throws"); + assert.equal(before.length, 6); + for (const d of before) { + assert.notEqual(d.fix, undefined, `fix for ${d.functionName}`); + } + + const updated = applyFixes(before); + assert.equal(updated.size, 1); + const newText = updated.get(target); + assert.ok(newText !== undefined); + writeFileSync(target, newText); + + // New JSDoc block created above an undocumented function. + assert.match(newText, /\/\*\*\n \* @throws \{FixError\}\n \*\/\nexport function noDoc/); + // Tag appended inside an existing multi-line JSDoc. + assert.match(newText, /Existing multi-line docs\.\n \* @throws \{FixError\}\n \*\//); + // Single-line JSDoc broken open. + assert.match(newText, /\/\*\* One-liner\. \n \* @throws \{FixError\}\n \*\//); + // Arrow: block sits above the variable statement. + assert.match(newText, /\/\*\*\n \* @throws \{FixError\}\n \*\/\nexport const arrow/); + // Method: indentation matches the class body. + assert.match(newText, / \/\*\*\n \* @throws \{FixError\}\n \*\/\n method/); + // Both missing types are inserted for twoTypes. + assert.match( + newText, + /\* @throws \{FixError\}\n \* @throws \{SecondError\}\n \*\/\nexport function twoTypes/, + ); + + // The fixed file is clean: no missing-throws, no unused-throws. + const after = analyzeFiles([target]).filter((d) => d.file.endsWith("fixable.ts")); + assert.deepEqual(after, []); + } finally { + rmSync(workDir, { recursive: true, force: true }); + } +}); + +test("applyFixes deduplicates identical edits", () => { + const diagnostic: ThrowsDiagnostic = { + kind: "missing-throws", + severity: "error", + file: "virtual.ts", + line: 1, + column: 1, + functionName: "'x'", + types: ["E"], + message: "m", + fix: { start: 0, end: 0, text: "/** @throws {E} */\n" }, + }; + const updated = applyFixes([diagnostic, { ...diagnostic }], () => "const a = 1;\n"); + assert.equal(updated.get("virtual.ts"), "/** @throws {E} */\nconst a = 1;\n"); +});