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
5 changes: 3 additions & 2 deletions .changeset/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ uniquely named files never conflict between concurrent pull requests. Format:
---
"@mirek/throwscript-core": minor
"@mirek/throwscript-cli": minor
"@mirek/eslint-plugin-throwscript": minor
---

One-line summary of the change.
```

The two packages are released in lockstep (`fixed` in `config.json`): naming either one
bumps both to the same version. Pick the bump the change needs:
All packages are released in lockstep (`fixed` in `config.json`): naming any of them
bumps all to the same version. Pick the bump the change needs:

- `patch` — fixes and documentation;
- `minor` — new features;
Expand Down
2 changes: 1 addition & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "https://unpkg.com/@changesets/config@3.1.4/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [["@mirek/throwscript-core", "@mirek/throwscript-cli"]],
"fixed": [["@mirek/throwscript-core", "@mirek/throwscript-cli", "@mirek/eslint-plugin-throwscript"]],
"linked": [],
"access": "public",
"baseBranch": "main",
Expand Down
7 changes: 7 additions & 0 deletions .changeset/eslint-plugin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@mirek/throwscript-core": minor
"@mirek/throwscript-cli": minor
"@mirek/eslint-plugin-throwscript": minor
---

Add `@mirek/eslint-plugin-throwscript`: the analyzer as ESLint rules (`throwscript/missing-throws`, auto-fixable, and `throwscript/unused-throws`) with a `recommended` flat config, running on typescript-eslint's typed parser services.
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,35 @@ throwscript uses the `typescript` package installed in your project (any
5.5+ or 6.x release); tsconfig options this compiler does not understand are
printed as warnings and the analysis proceeds, as `tsc` would.

## ESLint plugin

The same analysis is available as ESLint rules, so problems show up in the
editor and `eslint --fix` inserts the tags. The rules need typed linting
(`@typescript-eslint/parser` with `parserOptions.projectService`):

```sh
pnpm add -D @mirek/eslint-plugin-throwscript @typescript-eslint/parser
```

```js
// eslint.config.js
import throwscript from "@mirek/eslint-plugin-throwscript";
import tsParser from "@typescript-eslint/parser";

export default [
throwscript.configs.recommended, // missing-throws: error, unused-throws: warn
{
files: ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"],
languageOptions: {
parser: tsParser,
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
},
},
];
```

See the [plugin README](packages/eslint-plugin#readme) for rule options.

## Accepted `@throws` forms

```ts
Expand Down Expand Up @@ -207,6 +236,7 @@ subclasses.
| --- | --- |
| [`@mirek/throwscript-core`](packages/core) | The analyzer: walks a `ts.Program` and returns structured diagnostics |
| [`@mirek/throwscript-cli`](packages/cli) | The `throwscript` command-line tool |
| [`@mirek/eslint-plugin-throwscript`](packages/eslint-plugin) | The same checks as ESLint rules (`throwscript/missing-throws`, `throwscript/unused-throws`) |

## Development

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"private": true,
"type": "module",
"scripts": {
"build": "tsc -b packages/core packages/cli",
"build": "tsc -b packages/core packages/cli packages/eslint-plugin",
"lint": "oxlint .",
"test": "pnpm run build && pnpm -r run test",
"pack:check": "node scripts/pack-check.mjs",
Expand Down
167 changes: 167 additions & 0 deletions packages/eslint-plugin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# @mirek/eslint-plugin-throwscript

ESLint rules asserting **every function that can throw has a JSDoc `@throws`
tag with the appropriate error type** — the [throwscript](https://github.com/mirek/throwscript)
analyzer as an ESLint plugin, so the diagnostics show up in your editor, run
with the rest of your lint, and `eslint --fix` inserts the missing tags.

```ts
// ✗ throwscript/missing-throws: 'loadUser' can throw {NotFoundError} but has no @throws tag
export function loadUser(id: string): User {
const user = db.get(id);
if (!user) throw new NotFoundError(id);
return user;
}

// ✓ ok
/**
* @throws {NotFoundError} when the id does not exist
*/
export function loadUser(id: string): User {
const user = db.get(id);
if (!user) throw new NotFoundError(id);
return user;
}
```

## Setup

The rules need type information, so the file must be parsed by
`@typescript-eslint/parser` with
[typed linting](https://typescript-eslint.io/getting-started/typed-linting)
enabled (`parserOptions.projectService` or `parserOptions.project`).

```sh
pnpm add -D @mirek/eslint-plugin-throwscript @typescript-eslint/parser eslint typescript
```

```js
// eslint.config.js
import throwscript from "@mirek/eslint-plugin-throwscript";
import tsParser from "@typescript-eslint/parser";

export default [
throwscript.configs.recommended,
{
files: ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"],
languageOptions: {
parser: tsParser,
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
},
},
];
```

If you already use `typescript-eslint`, add the plugin's config next to yours:

```js
import tseslint from "typescript-eslint";
import throwscript from "@mirek/eslint-plugin-throwscript";

export default tseslint.config(
tseslint.configs.recommendedTypeChecked,
throwscript.configs.recommended,
{ languageOptions: { parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname } } },
);
```

`configs.recommended` enables `throwscript/missing-throws` as an error and
`throwscript/unused-throws` as a warning for `**/*.{ts,tsx,mts,cts}` — keep
the typed parser configured for those same files, as above. To pick
severities, options, or files yourself, register the plugin and set the rules
directly — keep them scoped to files the typed parser handles, since a file
parsed without type information makes the rules throw:

```js
export default [
{
files: ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"],
plugins: { throwscript },
rules: {
"throwscript/missing-throws": ["error", { ignoreExternal: true }],
"throwscript/unused-throws": "off",
},
},
];
```

The package is ESM only; it requires ESLint 9 or newer (flat config).

Prefer `projectService` over the legacy `project` option. With `project`,
typescript-eslint builds the program once per process when it detects a
one-shot run (`CI=true` or the `eslint` binary), and a *second* parse of the
same file — the ESLint API linting a file twice, or `RuleTester` re-linting a
fix's output — falls back to an isolated program with no type information, so
cross-file and lib `@throws` are silently missed. The `eslint` CLI parses each
file once (and `--fix` disables the one-shot mode), so it is unaffected; for
programmatic use set `parserOptions.disallowAutomaticSingleRunInference: true`.

## Rules

Both rules accept one options object:

| Option | Default | Meaning |
| --- | --- | --- |
| `ignoreExternal` | `false` | Ignore `@throws` documented in `.d.ts` files (the TypeScript lib, `@types/node`, `node_modules`). By default calling `JSON.parse` propagates its documented `SyntaxError` into the caller. |

### `throwscript/missing-throws` 🔧

Reports every function, method, constructor, accessor, or arrow function that
can throw — or reject, for `async` / Promise-returning functions — an error
type its JSDoc does not document with `@throws`. Reported on the function
name.

Auto-fixable: `eslint --fix` appends `* @throws {Type}` lines to the existing
JSDoc block, or creates a block above the declaration. Fixes are skipped for
declarations that do not start their own line (inline callbacks), where a
JSDoc block cannot be attached unambiguously. Documenting a callee can make
its callers' tags incomplete in turn, so a call chain may need `--fix` run
more than once.

### `throwscript/unused-throws`

Reports a `@throws {Type}` tag for which nothing observable in the function
body throws `Type`. Such a tag is either stale (delete it) or documents a throw
the checker cannot see — for example from an undocumented callee, in which
case document the callee. Reported on the tag.

## Muting

Standard ESLint disable comments work as usual:

```ts
// eslint-disable-next-line throwscript/missing-throws
export function assertNever(x: never): never {
throw new Error(`unexpected ${x}`);
}
```

The analyzer's own `@nothrow` directives work too and are more targeted: a
muted throw site is not counted at all, so it neither needs documenting nor
counts toward a documented tag being "used", and the same source checks
identically under the `throwscript` CLI:

```ts
throw new CacheMissError(key); // @nothrow — mutes this line (and the next)
throw new CacheMissError(key); // @nothrow-line — only this line
// @nothrow-next-line — only the following line
throw new CacheMissError(key);
```

## What counts as "can throw"

See the [throwscript README](https://github.com/mirek/throwscript#what-counts-as-can-throw)
for the full table: `throw` statements, `Promise.reject`, calls to
`@throws`-documented functions (including `.d.ts` ones such as `JSON.parse`),
awaited or returned promises from documented functions, rethrows in `catch`
blocks; `try`/`catch` and `.catch(...)` swallow. A documented type covers a
thrown type when the names match or the thrown type is assignable to it, so
`@throws {Error}` covers every `Error` subclass.

## Related

- [`@mirek/throwscript-cli`](https://github.com/mirek/throwscript/tree/main/packages/cli) —
the standalone `throwscript` command, with `--fix` that iterates to a fixed
point and a Markdown report mode for reviewers and LLMs.
- [`@mirek/throwscript-core`](https://github.com/mirek/throwscript/tree/main/packages/core) —
the analyzer both of these are built on.
61 changes: 61 additions & 0 deletions packages/eslint-plugin/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
{
"name": "@mirek/eslint-plugin-throwscript",
"version": "0.5.0",
"description": "ESLint plugin: every function that can throw must document it with JSDoc @throws.",
"keywords": [
"throwscript",
"throws",
"jsdoc",
"typescript",
"eslint",
"eslint-plugin",
"eslintplugin",
"exceptions",
"errors"
],
"author": "Mirek Rusin (https://github.com/mirek)",
"license": "MIT",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc -b",
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"@mirek/throwscript-core": "workspace:*"
},
"peerDependencies": {
"eslint": ">=9.0.0",
"typescript": ">=5.5.0 <7"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@typescript-eslint/parser": "^8.68.0",
"eslint": "^10.9.1",
"tsx": "^4.19.0",
"typescript": "^5.9.3"
},
"engines": {
"node": ">=20"
},
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/mirek/throwscript.git",
"directory": "packages/eslint-plugin"
},
"homepage": "https://github.com/mirek/throwscript/tree/main/packages/eslint-plugin#readme",
"bugs": "https://github.com/mirek/throwscript/issues"
}
Loading
Loading