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
51 changes: 49 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,58 @@ 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
```

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 |
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
96 changes: 64 additions & 32 deletions packages/cli/src/main.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 <tsconfig> 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
Expand All @@ -32,6 +39,7 @@ interface CliOptions {
files: string[];
reportUnused: boolean;
json: boolean;
fix: boolean;
}

function parseArgs(argv: string[]): CliOptions | "help" | "version" {
Expand All @@ -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];
Expand All @@ -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;
Expand All @@ -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 {
Expand All @@ -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);
}
Comment on lines +156 to 159
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;
}
Expand All @@ -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}`,
);
}
}
Expand Down
46 changes: 46 additions & 0 deletions packages/cli/test/cli.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading