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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules/
dist/
*.tsbuildinfo
.DS_Store
12 changes: 12 additions & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"categories": {
"correctness": "error",
"suspicious": "warn"
},
"env": {
"node": true,
"es2022": true
},
"ignorePatterns": ["**/dist/**", "**/node_modules/**", "**/test/fixtures/**"]
}
116 changes: 116 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# throwscript

A linter-like checker that asserts **every function that can throw has a JSDoc
`@throws` tag with the appropriate error type**.

Built on the TypeScript compiler API. Promise-returning functions are handled
implicitly: a rejection is treated exactly like a throw and is annotated the
same way as in synchronous code.

```ts
// ✗ error: '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;
}

// ✓ ok — async rejection, annotated the same way as a sync throw
/**
* @throws {NetworkError} when the request fails
*/
export async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/users/${id}`);
if (!res.ok) throw new NetworkError(res.statusText);
return res.json();
}
```

## Usage

```sh
# check every file in the tsconfig.json project in the current directory
throwscript

# check a specific project
throwscript --project path/to/tsconfig.json

# check individual files
throwscript src/a.ts src/b.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`).

## What counts as "can throw"

| Construct | Behaviour |
| --- | --- |
| `throw new SomeError()` | must be documented as `@throws {SomeError}` |
| `throw` inside `async` function | rejects the promise — documented the same way |
| `return Promise.reject(new E())` | must be documented as `@throws {E}` |
| calling a function documented with `@throws {E}` | propagates `E` to the caller |
| `await`ing / returning a promise from a `@throws`-documented function | propagates `E` to the caller |
| fire-and-forget promise call (not awaited/returned) | does **not** propagate (it becomes an unhandled rejection, not a throw here) |
| throw inside `try` with a `catch` clause | swallowed — nothing to document |
| `throw error` rethrow in a `catch` block | propagates everything the `try` block could throw (or the `instanceof`-narrowed type) |
| `foo().catch(...)` / `try { await foo() } catch` | rejection handled locally — nothing to document |

A documented type covers a thrown type when the names match **or** the thrown
type is assignable to it, so `@throws {Error}` covers `@throws {ValidationError}`
subclasses.

## Monorepo layout

| Package | Description |
| --- | --- |
| [`@throwscript/core`](packages/core) | The analyzer: walks a `ts.Program` and returns structured diagnostics |
| [`throwscript`](packages/cli) | The CLI wrapper |

## Development

```sh
pnpm install
pnpm run build # tsc project references build
pnpm run lint # oxlint
pnpm run test # node:test via tsx (builds first)
pnpm run check # build + lint + test
```

## API

```ts
import { analyzeFiles, analyzeProject, formatDiagnostic } from "@throwscript/core";

const diagnostics = analyzeProject("tsconfig.json");
for (const d of diagnostics) {
console.log(formatDiagnostic(d, process.cwd()));
}
```

Each diagnostic carries `kind` (`missing-throws` | `unused-throws`), `severity`,
`file`, `line`, `column`, `functionName`, the error `types` involved, and a
human-readable `message`.

## Known limitations

- Rejections of promises stored in a variable and awaited later
(`const p = f(); await p`) are not tracked.
- A throwing callback passed to another function (`arr.map(cb)`) is checked as
its own function, but its error type is not propagated to the caller of
`map`.
- `@throws` on overloads is read from the implementation signature that the
checker resolves for the call.
24 changes: 24 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "throwscript-monorepo",
"private": true,
"type": "module",
"scripts": {
"build": "tsc -b packages/core packages/cli",
"lint": "oxlint .",
"test": "pnpm run build && pnpm -r run test",
"check": "pnpm run build && pnpm run lint && pnpm run test"
},
"packageManager": "pnpm@10.33.0",
"pnpm": {
"onlyBuiltDependencies": [
"esbuild"
]
},
"engines": {
"node": ">=20"
},
"devDependencies": {
"oxlint": "^1.72.0",
"typescript": "^5.9.3"
}
}
24 changes: 24 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "throwscript",
"version": "0.1.0",
"description": "Linter-like checker: every function that can throw must document it with JSDoc @throws",
"type": "module",
"license": "MIT",
"bin": {
"throwscript": "./dist/main.js"
},
"main": "./dist/main.js",
"files": ["dist"],
"scripts": {
"build": "tsc -b",
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"@throwscript/core": "workspace:*",
"typescript": "^5.5.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.19.0"
}
}
167 changes: 167 additions & 0 deletions packages/cli/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
#!/usr/bin/env node
import { existsSync } from "node:fs";
import path from "node:path";
import process from "node:process";
import {
analyzeFiles,
analyzeProject,
formatDiagnostic,
type ThrowsDiagnostic,
} from "@throwscript/core";

const HELP = `throwscript — assert every function that can throw has a JSDoc @throws tag

Usage:
throwscript [options] [files...]

When no files are given, throwscript looks for a tsconfig.json in the current
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.

Options:
-p, --project <tsconfig> Check all files from the given tsconfig project
--no-unused Do not warn about @throws tags that never throw
--json Emit diagnostics as JSON
-h, --help Show this help
-v, --version Show version
`;

interface CliOptions {
project: string | undefined;
files: string[];
reportUnused: boolean;
json: boolean;
}

function parseArgs(argv: string[]): CliOptions | "help" | "version" {
const options: CliOptions = {
project: undefined,
files: [],
reportUnused: true,
json: false,
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === undefined) continue;
switch (arg) {
case "-h":
case "--help":
return "help";
case "-v":
case "--version":
return "version";
case "-p":
case "--project": {
const value = argv[++i];
if (value === undefined) {
throw new UsageError("--project requires a path to a tsconfig file");
}
options.project = value;
break;
}
case "--no-unused":
options.reportUnused = false;
break;
case "--json":
options.json = true;
break;
default:
if (arg.startsWith("-")) {
throw new UsageError(`Unknown option: ${arg}`);
}
options.files.push(arg);
}
}
return options;
}

class UsageError extends Error {}

function run(): number {
let parsed: CliOptions | "help" | "version";
try {
parsed = parseArgs(process.argv.slice(2));
} catch (error) {
if (error instanceof UsageError) {
console.error(`error: ${error.message}\n`);
console.error(HELP);
return 2;
}
throw error;
}

if (parsed === "help") {
console.log(HELP);
return 0;
}
if (parsed === "version") {
console.log("throwscript 0.1.0");
return 0;
Comment on lines +98 to +100
}

const analyzeOptions = { reportUnused: parsed.reportUnused };
let diagnostics: ThrowsDiagnostic[];
const cwd = process.cwd();

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;
}
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;
}
diagnostics = analyzeProject(tsconfig, analyzeOptions);
}
} catch (error) {
console.error(`error: ${error instanceof Error ? error.message : String(error)}`);
return 2;
}

diagnostics.sort(
(a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column,
);

if (parsed.json) {
console.log(JSON.stringify(diagnostics, null, 2));
} else {
for (const d of diagnostics) {
console.log(formatDiagnostic(d, cwd));
}
const errors = diagnostics.filter((d) => d.severity === "error").length;
const warnings = diagnostics.length - errors;
if (diagnostics.length === 0) {
console.log("throwscript: no problems found");
} else {
console.log(
`\nthrowscript: ${errors} error${errors === 1 ? "" : "s"}, ` +
`${warnings} warning${warnings === 1 ? "" : "s"}`,
);
}
}

return diagnostics.some((d) => d.severity === "error") ? 1 : 0;
}

process.exitCode = run();
Loading