From 2cc6dc49f9916427cbba8162637e7a5e3bc2e1b3 Mon Sep 17 00:00:00 2001 From: Mirek Rusin Date: Sun, 30 Aug 2026 20:39:38 +0200 Subject: [PATCH 1/4] Add @mirek/eslint-plugin-throwscript ESLint plugin exposing the analyzer as two rules on typescript-eslint's typed parser services: throwscript/missing-throws (auto-fixable, reuses the core fix edits) and throwscript/unused-throws, plus a recommended flat config. Analysis is cached per ts.SourceFile so both rules share one pass. Wire the package into the build, the changesets fixed group, and pack-check, which now installs eslint + @typescript-eslint/parser into the consumer and asserts a real eslint run reports the sample throw. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QswtWoNc2nD627xnAp7ZsV --- .changeset/README.md | 5 +- .changeset/config.json | 2 +- .changeset/eslint-plugin.md | 7 + README.md | 30 + package.json | 2 +- packages/eslint-plugin/README.md | 155 +++++ packages/eslint-plugin/package.json | 61 ++ packages/eslint-plugin/src/analysis.ts | 95 +++ packages/eslint-plugin/src/index.ts | 35 + .../eslint-plugin/src/rules/missing-throws.ts | 41 ++ .../eslint-plugin/src/rules/unused-throws.ts | 35 + packages/eslint-plugin/test/eslint.test.ts | 149 +++++ packages/eslint-plugin/test/fixtures/clean.ts | 15 + .../eslint-plugin/test/fixtures/errors.ts | 36 + packages/eslint-plugin/test/fixtures/file.ts | 0 .../eslint-plugin/test/fixtures/tsconfig.json | 11 + packages/eslint-plugin/test/rules.test.ts | 179 +++++ packages/eslint-plugin/tsconfig.json | 9 + pnpm-lock.yaml | 615 ++++++++++++++++++ scripts/pack-check.mjs | 30 +- 20 files changed, 1505 insertions(+), 7 deletions(-) create mode 100644 .changeset/eslint-plugin.md create mode 100644 packages/eslint-plugin/README.md create mode 100644 packages/eslint-plugin/package.json create mode 100644 packages/eslint-plugin/src/analysis.ts create mode 100644 packages/eslint-plugin/src/index.ts create mode 100644 packages/eslint-plugin/src/rules/missing-throws.ts create mode 100644 packages/eslint-plugin/src/rules/unused-throws.ts create mode 100644 packages/eslint-plugin/test/eslint.test.ts create mode 100644 packages/eslint-plugin/test/fixtures/clean.ts create mode 100644 packages/eslint-plugin/test/fixtures/errors.ts create mode 100644 packages/eslint-plugin/test/fixtures/file.ts create mode 100644 packages/eslint-plugin/test/fixtures/tsconfig.json create mode 100644 packages/eslint-plugin/test/rules.test.ts create mode 100644 packages/eslint-plugin/tsconfig.json diff --git a/.changeset/README.md b/.changeset/README.md index 04b3e80..b2cb404 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -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; diff --git a/.changeset/config.json b/.changeset/config.json index 8cdf59d..8fd4743 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -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", diff --git a/.changeset/eslint-plugin.md b/.changeset/eslint-plugin.md new file mode 100644 index 0000000..7598696 --- /dev/null +++ b/.changeset/eslint-plugin.md @@ -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. diff --git a/README.md b/README.md index d77186d..b2ada7a 100644 --- a/README.md +++ b/README.md @@ -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"], + 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 @@ -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 diff --git a/package.json b/package.json index 4a58e72..f3284e2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md new file mode 100644 index 0000000..69c83a7 --- /dev/null +++ b/packages/eslint-plugin/README.md @@ -0,0 +1,155 @@ +# @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"], + 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. To pick severities or options +yourself, register the plugin and set the rules directly: + +```js +export default [ + { + files: ["**/*.ts"], + 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). + +## 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. diff --git a/packages/eslint-plugin/package.json b/packages/eslint-plugin/package.json new file mode 100644 index 0000000..4355c79 --- /dev/null +++ b/packages/eslint-plugin/package.json @@ -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" +} diff --git a/packages/eslint-plugin/src/analysis.ts b/packages/eslint-plugin/src/analysis.ts new file mode 100644 index 0000000..6a66a8c --- /dev/null +++ b/packages/eslint-plugin/src/analysis.ts @@ -0,0 +1,95 @@ +import type { Rule } from "eslint"; +import type ts from "typescript"; +import { analyzeSourceFile, type ThrowsDiagnostic } from "@mirek/throwscript-core"; + +export interface RuleOptions { + /** Ignore `@throws` documented in `.d.ts` files (lib, `@types/*`). */ + ignoreExternal?: boolean; +} + +export const OPTIONS_SCHEMA = [ + { + type: "object", + properties: { + ignoreExternal: { type: "boolean" }, + }, + additionalProperties: false, + }, +]; + +export const DOCS_URL = + "https://github.com/mirek/throwscript/tree/main/packages/eslint-plugin#rules"; + +/** The subset of typescript-eslint's parser services this plugin relies on. */ +interface TypedParserServices { + program?: ts.Program | null; + esTreeNodeToTSNodeMap?: { get(node: unknown): ts.Node | undefined }; +} + +/** + * Both rules run the analyzer over the same file during the same lint pass; + * the result is computed once per source file and option set. Keyed on the + * `ts.SourceFile` object so a re-parsed file (project service, watch mode) + * gets fresh diagnostics and stale programs can be garbage collected. + */ +const cache = new WeakMap>(); + +/** + * Diagnostics for the file being linted, of every kind; each rule picks the + * kind it reports. + */ +export function getDiagnostics( + context: Rule.RuleContext, + ruleName: string, + options: RuleOptions, +): ThrowsDiagnostic[] { + const services = context.sourceCode.parserServices as TypedParserServices | undefined; + const program = services?.program; + if (program === undefined || program === null) { + throw new Error( + `throwscript/${ruleName} requires type information. Parse with ` + + "@typescript-eslint/parser and set parserOptions.projectService (or " + + "parserOptions.project) — see https://typescript-eslint.io/getting-started/typed-linting", + ); + } + const sourceFile = + (services?.esTreeNodeToTSNodeMap?.get(context.sourceCode.ast) as ts.SourceFile | undefined) ?? + program.getSourceFile(context.filename); + if (sourceFile === undefined) return []; + + const key = String(options.ignoreExternal === true); + let byOptions = cache.get(sourceFile); + if (byOptions === undefined) { + byOptions = new Map(); + cache.set(sourceFile, byOptions); + } + let diagnostics = byOptions.get(key); + if (diagnostics === undefined) { + diagnostics = []; + analyzeSourceFile( + sourceFile, + program.getTypeChecker(), + { reportUnused: true, ignoreExternal: options.ignoreExternal === true }, + diagnostics, + ); + byOptions.set(key, diagnostics); + } + return diagnostics; +} + +/** + * Convert the analyzer's 1-based line/column into an ESLint location that + * spans the token at that position (the function name, `function` keyword, + * or `@throws` tag), or a point when the position is inside a comment. + */ +export function locationOf( + context: Rule.RuleContext, + diagnostic: ThrowsDiagnostic, + fallbackLength: number, +): { start: { line: number; column: number }; end: { line: number; column: number } } { + const start = { line: diagnostic.line, column: diagnostic.column - 1 }; + const index = context.sourceCode.getIndexFromLoc(start); + const token = context.sourceCode.getTokenByRangeStart(index); + if (token !== null) return { start, end: token.loc.end }; + return { start, end: { line: start.line, column: start.column + fallbackLength } }; +} diff --git a/packages/eslint-plugin/src/index.ts b/packages/eslint-plugin/src/index.ts new file mode 100644 index 0000000..065b8f6 --- /dev/null +++ b/packages/eslint-plugin/src/index.ts @@ -0,0 +1,35 @@ +import { createRequire } from "node:module"; +import type { ESLint, Linter } from "eslint"; +import missingThrows from "./rules/missing-throws.js"; +import unusedThrows from "./rules/unused-throws.js"; + +const VERSION = (createRequire(import.meta.url)("../package.json") as { version: string }).version; + +export const rules = { + "missing-throws": missingThrows, + "unused-throws": unusedThrows, +}; + +const plugin = { + meta: { + name: "@mirek/eslint-plugin-throwscript", + version: VERSION, + }, + rules, + configs: {} as { + /** Flat config: `missing-throws` as an error, `unused-throws` as a warning. */ + recommended: Linter.Config; + }, +} satisfies ESLint.Plugin; + +plugin.configs.recommended = { + name: "throwscript/recommended", + plugins: { throwscript: plugin }, + rules: { + "throwscript/missing-throws": "error", + "throwscript/unused-throws": "warn", + }, +}; + +export const configs = plugin.configs; +export default plugin; diff --git a/packages/eslint-plugin/src/rules/missing-throws.ts b/packages/eslint-plugin/src/rules/missing-throws.ts new file mode 100644 index 0000000..d08cd4c --- /dev/null +++ b/packages/eslint-plugin/src/rules/missing-throws.ts @@ -0,0 +1,41 @@ +import type { Rule } from "eslint"; +import { DOCS_URL, getDiagnostics, locationOf, OPTIONS_SCHEMA, type RuleOptions } from "../analysis.js"; + +const rule: Rule.RuleModule = { + meta: { + type: "problem", + docs: { + description: + "require a JSDoc @throws tag on every function that can throw or reject", + recommended: true, + url: DOCS_URL, + }, + fixable: "code", + schema: OPTIONS_SCHEMA, + messages: { + missingThrows: "{{message}}", + }, + }, + create(context) { + const options = (context.options[0] ?? {}) as RuleOptions; + return { + Program() { + for (const d of getDiagnostics(context, "missing-throws", options)) { + if (d.kind !== "missing-throws") continue; + const fix = d.fix; + context.report({ + messageId: "missingThrows", + data: { message: d.message }, + loc: locationOf(context, d, d.functionName.length), + fix: + fix === undefined + ? null + : (fixer) => fixer.replaceTextRange([fix.start, fix.end], fix.text), + }); + } + }, + }; + }, +}; + +export default rule; diff --git a/packages/eslint-plugin/src/rules/unused-throws.ts b/packages/eslint-plugin/src/rules/unused-throws.ts new file mode 100644 index 0000000..6d88673 --- /dev/null +++ b/packages/eslint-plugin/src/rules/unused-throws.ts @@ -0,0 +1,35 @@ +import type { Rule } from "eslint"; +import { DOCS_URL, getDiagnostics, locationOf, OPTIONS_SCHEMA, type RuleOptions } from "../analysis.js"; + +const rule: Rule.RuleModule = { + meta: { + type: "suggestion", + docs: { + description: + "disallow JSDoc @throws tags for error types nothing observable throws", + recommended: true, + url: DOCS_URL, + }, + schema: OPTIONS_SCHEMA, + messages: { + unusedThrows: "{{message}}", + }, + }, + create(context) { + const options = (context.options[0] ?? {}) as RuleOptions; + return { + Program() { + for (const d of getDiagnostics(context, "unused-throws", options)) { + if (d.kind !== "unused-throws") continue; + context.report({ + messageId: "unusedThrows", + data: { message: d.message }, + loc: locationOf(context, d, "@throws".length), + }); + } + }, + }; + }, +}; + +export default rule; diff --git a/packages/eslint-plugin/test/eslint.test.ts b/packages/eslint-plugin/test/eslint.test.ts new file mode 100644 index 0000000..f0ea3d2 --- /dev/null +++ b/packages/eslint-plugin/test/eslint.test.ts @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import { ESLint, type Linter } from "eslint"; +import tsParser from "@typescript-eslint/parser"; +import plugin from "../src/index.js"; + +const fixturesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures"); + +function createESLint(options: { fix?: boolean } = {}): ESLint { + const config: Linter.Config[] = [ + plugin.configs.recommended, + { + files: ["**/*.ts"], + languageOptions: { + parser: tsParser, + parserOptions: { project: "./tsconfig.json", tsconfigRootDir: fixturesDir }, + }, + }, + ]; + return new ESLint({ + cwd: fixturesDir, + overrideConfigFile: true, + overrideConfig: config, + fix: options.fix ?? false, + }); +} + +test("recommended config reports missing and unused @throws through ESLint", async () => { + const [result] = await createESLint().lintFiles(["errors.ts"]); + assert.ok(result !== undefined); + const messages = result.messages.map((m) => ({ + ruleId: m.ruleId, + severity: m.severity, + line: m.line, + message: m.message, + })); + assert.deepEqual(messages, [ + { + ruleId: "throwscript/missing-throws", + severity: 2, + line: 10, + message: + "'undocumented' can throw {BoomError} but has no @throws tag for it. " + + "Document with `@throws {BoomError}`.", + }, + { + ruleId: "throwscript/missing-throws", + severity: 2, + line: 14, + message: + "'propagates' can throw {BoomError} but has no @throws tag for it. " + + "Document with `@throws {BoomError}`.", + }, + { + ruleId: "throwscript/missing-throws", + severity: 2, + line: 22, + message: + "'parses' can throw {SyntaxError} but has no @throws tag for it. " + + "Document with `@throws {SyntaxError}`.", + }, + { + ruleId: "throwscript/unused-throws", + severity: 1, + line: 27, + message: "'stale' documents @throws {BoomError} but nothing observable throws it.", + }, + ]); + assert.equal(result.fixableErrorCount, 3); +}); + +test("works with parserOptions.projectService", async () => { + const eslint = new ESLint({ + cwd: fixturesDir, + overrideConfigFile: true, + overrideConfig: [ + plugin.configs.recommended, + { + files: ["**/*.ts"], + languageOptions: { + parser: tsParser, + parserOptions: { projectService: true, tsconfigRootDir: fixturesDir }, + }, + }, + ], + }); + const [result] = await eslint.lintFiles(["errors.ts"]); + assert.deepEqual( + result?.messages.map((m) => [m.ruleId, m.line]), + [ + ["throwscript/missing-throws", 10], + ["throwscript/missing-throws", 14], + ["throwscript/missing-throws", 22], + ["throwscript/unused-throws", 27], + ], + ); +}); + +test("a clean file produces no messages", async () => { + const [result] = await createESLint().lintFiles(["clean.ts"]); + assert.deepEqual(result?.messages, []); +}); + +test("ignoreExternal drops @throws documented in .d.ts files", async () => { + const eslint = new ESLint({ + cwd: fixturesDir, + overrideConfigFile: true, + overrideConfig: [ + { + files: ["**/*.ts"], + plugins: { throwscript: plugin }, + languageOptions: { + parser: tsParser, + parserOptions: { project: "./tsconfig.json", tsconfigRootDir: fixturesDir }, + }, + rules: { "throwscript/missing-throws": ["error", { ignoreExternal: true }] }, + }, + ], + }); + const [result] = await eslint.lintFiles(["errors.ts"]); + const names = result?.messages.map((m) => m.message.match(/^'(\w+)'/)?.[1]); + assert.deepEqual(names, ["undocumented", "propagates"]); +}); + +test("--fix inserts the missing @throws tags", async () => { + const [result] = await createESLint({ fix: true }).lintFiles(["errors.ts"]); + assert.ok(result?.output !== undefined); + assert.match( + result.output, + /\/\*\*\n \* @throws \{BoomError\}\n \*\/\nexport function undocumented\(\)/, + ); + assert.match( + result.output, + /\/\*\*\n \* @throws \{SyntaxError\}\n \*\/\nexport function parses\(/, + ); + // Only the unused-tag warning is left: it has no fix. + assert.deepEqual( + result.messages.map((m) => m.ruleId), + ["throwscript/unused-throws"], + ); +}); + +test("plugin exposes its meta and rules", () => { + assert.equal(plugin.meta.name, "@mirek/eslint-plugin-throwscript"); + assert.match(plugin.meta.version, /^\d+\.\d+\.\d+/); + assert.deepEqual(Object.keys(plugin.rules).toSorted(), ["missing-throws", "unused-throws"]); +}); diff --git a/packages/eslint-plugin/test/fixtures/clean.ts b/packages/eslint-plugin/test/fixtures/clean.ts new file mode 100644 index 0000000..b8c8c13 --- /dev/null +++ b/packages/eslint-plugin/test/fixtures/clean.ts @@ -0,0 +1,15 @@ +/** + * @throws {RangeError} when n is negative + */ +export function checked(n: number): number { + if (n < 0) throw new RangeError("negative"); + return n; +} + +export function safe(n: number): number { + try { + return checked(n); + } catch { + return 0; + } +} diff --git a/packages/eslint-plugin/test/fixtures/errors.ts b/packages/eslint-plugin/test/fixtures/errors.ts new file mode 100644 index 0000000..f1bcd8b --- /dev/null +++ b/packages/eslint-plugin/test/fixtures/errors.ts @@ -0,0 +1,36 @@ +export class BoomError extends Error {} + +/** + * @throws {BoomError} when asked to + */ +export function documented(): void { + throw new BoomError("boom"); +} + +export function undocumented(): void { + throw new BoomError("boom"); +} + +export function propagates(): void { + documented(); +} + +export function muted(): void { + throw new BoomError("boom"); // @nothrow +} + +export function parses(text: string): unknown { + return JSON.parse(text); +} + +/** + * @throws {BoomError} never actually thrown + */ +export function stale(): number { + return 1; +} + +// eslint-disable-next-line throwscript/missing-throws +export function disabledByEslint(): void { + throw new BoomError("boom"); +} diff --git a/packages/eslint-plugin/test/fixtures/file.ts b/packages/eslint-plugin/test/fixtures/file.ts new file mode 100644 index 0000000..e69de29 diff --git a/packages/eslint-plugin/test/fixtures/tsconfig.json b/packages/eslint-plugin/test/fixtures/tsconfig.json new file mode 100644 index 0000000..b012f1d --- /dev/null +++ b/packages/eslint-plugin/test/fixtures/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["*.ts"] +} diff --git a/packages/eslint-plugin/test/rules.test.ts b/packages/eslint-plugin/test/rules.test.ts new file mode 100644 index 0000000..614cab1 --- /dev/null +++ b/packages/eslint-plugin/test/rules.test.ts @@ -0,0 +1,179 @@ +import { describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import { RuleTester } from "eslint"; +import tsParser from "@typescript-eslint/parser"; +import { rules } from "../src/index.js"; + +const fixturesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures"); +const filename = path.join(fixturesDir, "file.ts"); + +RuleTester.describe = describe; +RuleTester.it = it; +RuleTester.itOnly = it.only; + +const ruleTester = new RuleTester({ + languageOptions: { + parser: tsParser, + parserOptions: { + project: "./tsconfig.json", + tsconfigRootDir: fixturesDir, + }, + }, +}); + +ruleTester.run("missing-throws", rules["missing-throws"], { + valid: [ + { + filename, + code: ` + /** @throws {RangeError} when negative */ + export function f(n: number): number { + if (n < 0) throw new RangeError("negative"); + return n; + } + `, + }, + { + filename, + code: ` + export function f(): void { + throw new Error("guard"); // @nothrow + } + `, + }, + { + filename, + code: ` + export function f(): void { + try { throw new Error("x"); } catch { /* handled */ } + } + `, + }, + { + filename, + options: [{ ignoreExternal: true }], + code: ` + export function parse(text: string): unknown { + return JSON.parse(text); + } + `, + }, + ], + invalid: [ + { + filename, + code: ` + export function f(): void { + throw new Error("x"); + } + `, + errors: [ + { + messageId: "missingThrows", + data: { + message: + "'f' can throw {Error} but has no @throws tag for it. Document with `@throws {Error}`.", + }, + line: 2, + column: 25, + endColumn: 26, + }, + ], + output: ` + /** + * @throws {Error} + */ + export function f(): void { + throw new Error("x"); + } + `, + }, + { + filename, + code: ` + /** Rejects on failure. */ + export async function f(): Promise { + throw new TypeError("x"); + } + `, + errors: [{ messageId: "missingThrows", line: 3 }], + output: ` + /** + * Rejects on failure. + * @throws {TypeError} + */ + export async function f(): Promise { + throw new TypeError("x"); + } + `, + }, + { + filename, + code: ` + export function parse(text: string): unknown { + return JSON.parse(text); + } + `, + errors: [{ messageId: "missingThrows", line: 2 }], + output: ` + /** + * @throws {SyntaxError} + */ + export function parse(text: string): unknown { + return JSON.parse(text); + } + `, + }, + ], +}); + +ruleTester.run("unused-throws", rules["unused-throws"], { + valid: [ + { + filename, + code: ` + /** @throws {RangeError} when negative */ + export function f(n: number): number { + if (n < 0) throw new RangeError("negative"); + return n; + } + `, + }, + { + filename, + code: ` + /** + * @throws {RangeError} documented for callers // @nothrow-line + */ + export function f(): number { + return 1; + } + `, + }, + ], + invalid: [ + { + filename, + code: ` + /** + * @throws {RangeError} never thrown + */ + export function f(): number { + return 1; + } + `, + errors: [ + { + messageId: "unusedThrows", + data: { + message: "'f' documents @throws {RangeError} but nothing observable throws it.", + }, + line: 3, + column: 12, + endColumn: 19, + }, + ], + }, + ], +}); diff --git a/packages/eslint-plugin/tsconfig.json b/packages/eslint-plugin/tsconfig.json new file mode 100644 index 0000000..3182c75 --- /dev/null +++ b/packages/eslint-plugin/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"], + "references": [{ "path": "../core" }] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81e1a80..83b2add 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,6 +46,28 @@ importers: specifier: ^5.9.3 version: 5.9.3 + packages/eslint-plugin: + dependencies: + '@mirek/throwscript-core': + specifier: workspace:* + version: link:../core + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.20.0 + '@typescript-eslint/parser': + specifier: ^8.68.0 + version: 8.68.0(eslint@10.9.1)(typescript@5.9.3) + eslint: + specifier: ^10.9.1 + version: 10.9.1 + tsx: + specifier: ^4.19.0 + version: 4.23.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + packages: '@babel/runtime@7.29.7': @@ -263,6 +285,56 @@ packages: cpu: [x64] os: [win32] + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.7.0': + resolution: {integrity: sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.2': + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} @@ -412,12 +484,71 @@ packages: cpu: [x64] os: [win32] + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} '@types/node@22.20.0': resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==} + '@typescript-eslint/parser@8.68.0': + resolution: {integrity: sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.68.0': + resolution: {integrity: sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.68.0': + resolution: {integrity: sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.68.0': + resolution: {integrity: sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.68.0': + resolution: {integrity: sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.68.0': + resolution: {integrity: sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.68.0': + resolution: {integrity: sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -436,10 +567,18 @@ packages: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + better-path-resolve@1.0.0: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -451,6 +590,18 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + detect-indent@6.1.0: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} @@ -468,21 +619,89 @@ packages: engines: {node: '>=18'} hasBin: true + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.9.1: + resolution: {integrity: sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} hasBin: true + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fastq@1.20.3: resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -491,6 +710,17 @@ packages: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} @@ -508,6 +738,10 @@ packages: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + globby@11.1.0: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} @@ -527,6 +761,10 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -558,13 +796,33 @@ packages: resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + lodash.startcase@4.4.0: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} @@ -576,10 +834,24 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} @@ -604,10 +876,18 @@ packages: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + p-locate@4.1.0: resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} engines: {node: '>=8'} + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + p-map@2.1.0: resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} engines: {node: '>=6'} @@ -638,15 +918,27 @@ packages: resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + pify@4.0.1: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + prettier@2.8.8: resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} engines: {node: '>=10.13.0'} hasBin: true + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -710,15 +1002,29 @@ packages: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + tsx@4.23.0: resolution: {integrity: sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==} engines: {node: '>=18.0.0'} hasBin: true + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -731,11 +1037,22 @@ packages: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + snapshots: '@babel/runtime@7.29.7': {} @@ -961,6 +1278,52 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@eslint-community/eslint-utils@4.10.1(eslint@10.9.1)': + dependencies: + eslint: 10.9.1 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.6 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.7.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.2': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + '@inquirer/external-editor@1.0.3(@types/node@22.20.0)': dependencies: chardet: 2.2.0 @@ -1053,12 +1416,83 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.72.0': optional: true + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + '@types/node@12.20.55': {} '@types/node@22.20.0': dependencies: undici-types: 6.21.0 + '@typescript-eslint/parser@8.68.0(eslint@10.9.1)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.68.0 + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/typescript-estree': 8.68.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.68.0 + debug: 4.4.3 + eslint: 10.9.1 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.68.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.68.0(typescript@5.9.3) + '@typescript-eslint/types': 8.68.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.68.0': + dependencies: + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/visitor-keys': 8.68.0 + + '@typescript-eslint/tsconfig-utils@8.68.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/types@8.68.0': {} + + '@typescript-eslint/typescript-estree@8.68.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.68.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.68.0(typescript@5.9.3) + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/visitor-keys': 8.68.0 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.68.0': + dependencies: + '@typescript-eslint/types': 8.68.0 + eslint-visitor-keys: 5.0.1 + + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + ansi-colors@4.1.3: {} ansi-regex@5.0.1: {} @@ -1071,10 +1505,16 @@ snapshots: array-union@2.1.0: {} + balanced-match@4.0.4: {} + better-path-resolve@1.0.0: dependencies: is-windows: 1.0.2 + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -1087,6 +1527,12 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + detect-indent@6.1.0: {} dir-glob@3.0.1: @@ -1127,10 +1573,78 @@ snapshots: '@esbuild/win32-ia32': 0.28.1 '@esbuild/win32-x64': 0.28.1 + escape-string-regexp@4.0.0: {} + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.9.1: + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.7.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.6 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 5.0.1 + esprima@4.0.1: {} + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + extendable-error@0.1.7: {} + fast-deep-equal@3.1.3: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -1139,10 +1653,22 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + fastq@1.20.3: dependencies: reusify: 1.1.0 + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -1152,6 +1678,18 @@ snapshots: locate-path: 5.0.0 path-exists: 4.0.0 + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.4 + keyv: 4.5.4 + + flatted@3.4.4: {} + fs-extra@7.0.1: dependencies: graceful-fs: 4.2.11 @@ -1171,6 +1709,10 @@ snapshots: dependencies: is-glob: 4.0.3 + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + globby@11.1.0: dependencies: array-union: 2.1.0 @@ -1190,6 +1732,8 @@ snapshots: ignore@5.3.2: {} + imurmurhash@0.1.4: {} + is-extglob@2.1.1: {} is-glob@4.0.3: @@ -1215,14 +1759,33 @@ snapshots: dependencies: argparse: 2.0.1 + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + jsonfile@4.0.0: optionalDependencies: graceful-fs: 4.2.11 + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + locate-path@5.0.0: dependencies: p-locate: 4.1.0 + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + lodash.startcase@4.4.0: {} merge2@1.4.1: {} @@ -1232,8 +1795,25 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + mri@1.2.0: {} + ms@2.1.3: {} + + natural-compare@1.4.0: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + outdent@0.5.0: {} oxlint@1.72.0: @@ -1266,10 +1846,18 @@ snapshots: dependencies: p-try: 2.2.0 + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + p-locate@4.1.0: dependencies: p-limit: 2.3.0 + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + p-map@2.1.0: {} p-try@2.2.0: {} @@ -1288,10 +1876,16 @@ snapshots: picomatch@2.3.2: {} + picomatch@4.0.7: {} + pify@4.0.1: {} + prelude-ls@1.2.1: {} + prettier@2.8.8: {} + punycode@2.3.1: {} + quansync@0.2.11: {} queue-microtask@1.2.3: {} @@ -1340,22 +1934,43 @@ snapshots: term-size@2.2.1: {} + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + tsx@4.23.0: dependencies: esbuild: 0.28.1 optionalDependencies: fsevents: 2.3.3 + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + typescript@5.9.3: {} undici-types@6.21.0: {} universalify@0.1.2: {} + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + which@2.0.2: dependencies: isexe: 2.0.0 + + word-wrap@1.2.5: {} + + yocto-queue@0.1.0: {} diff --git a/scripts/pack-check.mjs b/scripts/pack-check.mjs index 8686c1d..55a3ffa 100644 --- a/scripts/pack-check.mjs +++ b/scripts/pack-check.mjs @@ -1,7 +1,8 @@ // Packs every workspace package and installs the tarballs into a throwaway // consumer project, then checks what a user would actually get: no // `workspace:` ranges leaked into the manifests, no source/test files in the -// tarball, the `throwscript` bin runs, and the core package imports. +// tarball, the `throwscript` bin runs, the core package imports, and the +// eslint plugin reports through a real eslint run. import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { spawnSync } from 'node:child_process' @@ -12,7 +13,7 @@ const pnpmCli = process.env.npm_execpath if (!pnpmCli) throw new Error('pack-check must be run through pnpm') const root = fileURLToPath(new URL('..', import.meta.url)) -const packageDirectories = ['packages/core', 'packages/cli'].map(p => path.join(root, p)) +const packageDirectories = ['packages/core', 'packages/cli', 'packages/eslint-plugin'].map(p => path.join(root, p)) function run(command, args, options = {}) { const result = spawnSync(command, args, { encoding: 'utf8', ...options }) @@ -67,7 +68,12 @@ try { name: 'throwscript-consumer', private: true, type: 'module', - dependencies: { ...dependencies, typescript: '^5.9.3' } + dependencies: { + ...dependencies, + typescript: '^5.9.3', + eslint: '^10.9.1', + '@typescript-eslint/parser': '^8.68.0' + } }, null, 2)) // Route the CLI's dependency on core to the local tarball as well. writeFileSync( @@ -92,6 +98,24 @@ try { 'if (d.length !== 1 || !formatReport(d).includes("boom")) throw new Error("core API check failed");\n' ) run(process.execPath, ['api.mjs'], { cwd: consumer }) + + writeFileSync(path.join(consumer, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { strict: true, noEmit: true, skipLibCheck: true }, + include: ['sample.ts'] + })) + writeFileSync( + path.join(consumer, 'eslint.config.mjs'), + 'import throwscript from "@mirek/eslint-plugin-throwscript";\n' + + 'import tsParser from "@typescript-eslint/parser";\n' + + 'export default [\n' + + ' throwscript.configs.recommended,\n' + + ' { files: ["**/*.ts"], languageOptions: { parser: tsParser, parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname } } },\n' + + '];\n' + ) + const lint = spawnSync(process.execPath, [pnpmCli, 'exec', 'eslint', 'sample.ts'], { cwd: consumer, encoding: 'utf8' }) + if (lint.status !== 1 || !/'boom' can throw \{Error\}.*throwscript\/missing-throws/.test(lint.stdout)) { + throw new Error(`eslint plugin did not report the sample throw (status ${lint.status}):\n${lint.stdout}${lint.stderr}`) + } console.log('pack-check ok') } finally { rmSync(temporaryDirectory, { recursive: true, force: true }) From b5d659a9fa6af31141952e171391e17ec6426db5 Mon Sep 17 00:00:00 2001 From: Mirek Rusin Date: Sun, 30 Aug 2026 20:51:23 +0200 Subject: [PATCH 2/4] debug: probe JSON.parse declaration across lints (temporary) --- packages/eslint-plugin/test/zz-debug.test.ts | 90 ++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 packages/eslint-plugin/test/zz-debug.test.ts diff --git a/packages/eslint-plugin/test/zz-debug.test.ts b/packages/eslint-plugin/test/zz-debug.test.ts new file mode 100644 index 0000000..dbd86dc --- /dev/null +++ b/packages/eslint-plugin/test/zz-debug.test.ts @@ -0,0 +1,90 @@ +import { test } from "node:test"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import { ESLint, type Linter, type Rule } from "eslint"; +import tsParser from "@typescript-eslint/parser"; +import ts from "typescript"; +import plugin from "../src/index.js"; + +const fixturesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures"); +const errorsFile = path.join(fixturesDir, "errors.ts"); +let previousSourceFile: ts.SourceFile | undefined; +let previousLib: ts.SourceFile | undefined; + +const debugRule: Rule.RuleModule = { + meta: { type: "problem", schema: [] }, + create(context) { + return { + Program() { + const services = context.sourceCode.parserServices as any; + const program: ts.Program = services.program; + const checker = program.getTypeChecker(); + const sf: ts.SourceFile = services.esTreeNodeToTSNodeMap.get(context.sourceCode.ast); + let call: ts.CallExpression | undefined; + const visit = (n: ts.Node) => { + if (ts.isCallExpression(n) && n.expression.getText(sf) === "JSON.parse") call = n; + ts.forEachChild(n, visit); + }; + visit(sf); + const decl = call && checker.getResolvedSignature(call)?.getDeclaration(); + const lib = decl?.getSourceFile(); + console.error("[debug]", JSON.stringify({ + file: path.basename(sf.fileName), + sameSourceFile: sf === previousSourceFile, + sameLib: lib === previousLib, + libFile: lib?.fileName, + libHasThrowsText: lib?.text.includes("@throws {SyntaxError}"), + tags: decl ? ts.getJSDocTags(decl).map((t) => t.tagName.text) : null, + jsDocLen: (decl as any)?.jsDoc?.length, + declKind: decl && ts.SyntaxKind[decl.kind], + declText: decl?.getText(lib).slice(0, 60), + libIsDeclaration: lib?.isDeclarationFile, + libFromExternal: lib && program.isSourceFileFromExternalLibrary(lib), + libIsDefault: lib && program.isSourceFileDefaultLibrary(lib), + jsDocParsingMode: (program as any).getCompilerOptions?.()?.jsDocParsingMode, + })); + previousSourceFile = sf; + previousLib = lib; + }, + }; + }, +}; + +function createESLint(ignoreExternal: boolean): ESLint { + const config: Linter.Config[] = [ + { + files: ["**/*.ts"], + plugins: { throwscript: plugin, dbg: { rules: { probe: debugRule } } }, + languageOptions: { + parser: tsParser, + parserOptions: { project: "./tsconfig.json", tsconfigRootDir: fixturesDir }, + }, + rules: { + "throwscript/missing-throws": ["error", { ignoreExternal }], + "dbg/probe": "error", + }, + }, + ]; + return new ESLint({ cwd: fixturesDir, overrideConfigFile: true, overrideConfig: config }); +} + +test("debug sequence", async () => { + console.error("[debug] env", JSON.stringify({ CI: process.env.CI, node: process.version, ts: ts.version, cpus: (await import("node:os")).availableParallelism() })); + const text = readFileSync(errorsFile, "utf8"); + const names = (r: ESLint.LintResult | undefined) => + r?.messages.map((m) => m.message.match(/^'(\w+)'/)?.[1]).filter(Boolean); + const steps: Array<[string, () => Promise]> = [ + ["files false #1", () => createESLint(false).lintFiles(["errors.ts"])], + ["files true", () => createESLint(true).lintFiles(["errors.ts"])], + ["files false #2", () => createESLint(false).lintFiles(["errors.ts"])], + ["text modified false", () => createESLint(false).lintText(text + "\n// changed\n", { filePath: errorsFile })], + ["files false #3", () => createESLint(false).lintFiles(["errors.ts"])], + ["text modified2 false", () => createESLint(false).lintText(text + "\n// changed again\n", { filePath: errorsFile })], + ["text original false", () => createESLint(false).lintText(text, { filePath: errorsFile })], + ]; + for (const [label, run] of steps) { + const [r] = await run(); + console.error("[debug] step", label, JSON.stringify(names(r))); + } +}); From c865d252628564486e83d7375722278cb7afd937 Mon Sep 17 00:00:00 2001 From: Mirek Rusin Date: Sun, 30 Aug 2026 20:54:59 +0200 Subject: [PATCH 3/4] Scope recommended config to TS files; survive typescript-estree single-run mode Codex review: the recommended config had no files selector, so `eslint .` ran the rules on .js files parsed without type information and aborted. Scope it to **/*.{ts,tsx,mts,cts}. CI failure: under CI=true typescript-estree infers a single run and builds the program once per process; a second parse of the same file (the ESLint API linting a file twice, RuleTester re-linting a fix's output) falls back to an isolated lib-less program, silently losing external and cross-file @throws. The tests lint the same fixtures repeatedly, so they opt out with disallowAutomaticSingleRunInference; README documents the pitfall and recommends projectService. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QswtWoNc2nD627xnAp7ZsV --- packages/eslint-plugin/README.md | 15 +++- packages/eslint-plugin/src/index.ts | 7 +- packages/eslint-plugin/test/eslint.test.ts | 37 +++++++- packages/eslint-plugin/test/rules.test.ts | 5 ++ packages/eslint-plugin/test/zz-debug.test.ts | 90 -------------------- 5 files changed, 59 insertions(+), 95 deletions(-) delete mode 100644 packages/eslint-plugin/test/zz-debug.test.ts diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index 69c83a7..60c24a8 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -66,8 +66,10 @@ export default tseslint.config( ``` `configs.recommended` enables `throwscript/missing-throws` as an error and -`throwscript/unused-throws` as a warning. To pick severities or options -yourself, register the plugin and set the rules directly: +`throwscript/unused-throws` as a warning for `**/*.{ts,tsx,mts,cts}`. 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 [ @@ -84,6 +86,15 @@ export default [ 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: diff --git a/packages/eslint-plugin/src/index.ts b/packages/eslint-plugin/src/index.ts index 065b8f6..30454e0 100644 --- a/packages/eslint-plugin/src/index.ts +++ b/packages/eslint-plugin/src/index.ts @@ -17,13 +17,18 @@ const plugin = { }, rules, configs: {} as { - /** Flat config: `missing-throws` as an error, `unused-throws` as a warning. */ + /** + * Flat config: `missing-throws` as an error, `unused-throws` as a warning, + * for TypeScript files only — the rules need type information, which the + * default (Espree) parser cannot provide for `.js` files. + */ recommended: Linter.Config; }, } satisfies ESLint.Plugin; plugin.configs.recommended = { name: "throwscript/recommended", + files: ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"], plugins: { throwscript: plugin }, rules: { "throwscript/missing-throws": "error", diff --git a/packages/eslint-plugin/test/eslint.test.ts b/packages/eslint-plugin/test/eslint.test.ts index f0ea3d2..4bee7ee 100644 --- a/packages/eslint-plugin/test/eslint.test.ts +++ b/packages/eslint-plugin/test/eslint.test.ts @@ -7,6 +7,15 @@ import tsParser from "@typescript-eslint/parser"; import plugin from "../src/index.js"; const fixturesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures"); +// Under CI=true typescript-estree infers a "single run" and builds the program +// once; every later parse of the same file falls back to an isolated, lib-less +// program that cannot resolve anything. These tests lint the same file +// repeatedly, so opt out (a real `eslint` CLI run parses each file once). +const projectOptions = { + project: "./tsconfig.json", + tsconfigRootDir: fixturesDir, + disallowAutomaticSingleRunInference: true, +}; function createESLint(options: { fix?: boolean } = {}): ESLint { const config: Linter.Config[] = [ @@ -15,7 +24,7 @@ function createESLint(options: { fix?: boolean } = {}): ESLint { files: ["**/*.ts"], languageOptions: { parser: tsParser, - parserOptions: { project: "./tsconfig.json", tsconfigRootDir: fixturesDir }, + parserOptions: { ...projectOptions }, }, }, ]; @@ -113,7 +122,7 @@ test("ignoreExternal drops @throws documented in .d.ts files", async () => { plugins: { throwscript: plugin }, languageOptions: { parser: tsParser, - parserOptions: { project: "./tsconfig.json", tsconfigRootDir: fixturesDir }, + parserOptions: { ...projectOptions }, }, rules: { "throwscript/missing-throws": ["error", { ignoreExternal: true }] }, }, @@ -142,6 +151,30 @@ test("--fix inserts the missing @throws tags", async () => { ); }); +test("recommended config applies only to TypeScript files", () => { + assert.deepEqual(plugin.configs.recommended.files, [ + "**/*.ts", + "**/*.tsx", + "**/*.mts", + "**/*.cts", + ]); +}); + +test("a JavaScript file next to the config is left alone", async () => { + const eslint = new ESLint({ + cwd: fixturesDir, + overrideConfigFile: true, + overrideConfig: [ + plugin.configs.recommended, + { files: ["**/*.ts"], languageOptions: { parser: tsParser, parserOptions: projectOptions } }, + ], + }); + const [result] = await eslint.lintText("export function boom() {\n throw new Error('x');\n}\n", { + filePath: path.join(fixturesDir, "plain.js"), + }); + assert.deepEqual(result?.messages, []); +}); + test("plugin exposes its meta and rules", () => { assert.equal(plugin.meta.name, "@mirek/eslint-plugin-throwscript"); assert.match(plugin.meta.version, /^\d+\.\d+\.\d+/); diff --git a/packages/eslint-plugin/test/rules.test.ts b/packages/eslint-plugin/test/rules.test.ts index 614cab1..db9ce86 100644 --- a/packages/eslint-plugin/test/rules.test.ts +++ b/packages/eslint-plugin/test/rules.test.ts @@ -18,6 +18,11 @@ const ruleTester = new RuleTester({ parserOptions: { project: "./tsconfig.json", tsconfigRootDir: fixturesDir, + // Under CI=true typescript-estree infers a "single run" and builds the + // program once; a second parse of the same file (RuleTester re-lints the + // fixed output) then falls back to an isolated, lib-less program that + // cannot resolve anything. Tests lint the same file repeatedly, so opt out. + disallowAutomaticSingleRunInference: true, }, }, }); diff --git a/packages/eslint-plugin/test/zz-debug.test.ts b/packages/eslint-plugin/test/zz-debug.test.ts deleted file mode 100644 index dbd86dc..0000000 --- a/packages/eslint-plugin/test/zz-debug.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { test } from "node:test"; -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import path from "node:path"; -import { ESLint, type Linter, type Rule } from "eslint"; -import tsParser from "@typescript-eslint/parser"; -import ts from "typescript"; -import plugin from "../src/index.js"; - -const fixturesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures"); -const errorsFile = path.join(fixturesDir, "errors.ts"); -let previousSourceFile: ts.SourceFile | undefined; -let previousLib: ts.SourceFile | undefined; - -const debugRule: Rule.RuleModule = { - meta: { type: "problem", schema: [] }, - create(context) { - return { - Program() { - const services = context.sourceCode.parserServices as any; - const program: ts.Program = services.program; - const checker = program.getTypeChecker(); - const sf: ts.SourceFile = services.esTreeNodeToTSNodeMap.get(context.sourceCode.ast); - let call: ts.CallExpression | undefined; - const visit = (n: ts.Node) => { - if (ts.isCallExpression(n) && n.expression.getText(sf) === "JSON.parse") call = n; - ts.forEachChild(n, visit); - }; - visit(sf); - const decl = call && checker.getResolvedSignature(call)?.getDeclaration(); - const lib = decl?.getSourceFile(); - console.error("[debug]", JSON.stringify({ - file: path.basename(sf.fileName), - sameSourceFile: sf === previousSourceFile, - sameLib: lib === previousLib, - libFile: lib?.fileName, - libHasThrowsText: lib?.text.includes("@throws {SyntaxError}"), - tags: decl ? ts.getJSDocTags(decl).map((t) => t.tagName.text) : null, - jsDocLen: (decl as any)?.jsDoc?.length, - declKind: decl && ts.SyntaxKind[decl.kind], - declText: decl?.getText(lib).slice(0, 60), - libIsDeclaration: lib?.isDeclarationFile, - libFromExternal: lib && program.isSourceFileFromExternalLibrary(lib), - libIsDefault: lib && program.isSourceFileDefaultLibrary(lib), - jsDocParsingMode: (program as any).getCompilerOptions?.()?.jsDocParsingMode, - })); - previousSourceFile = sf; - previousLib = lib; - }, - }; - }, -}; - -function createESLint(ignoreExternal: boolean): ESLint { - const config: Linter.Config[] = [ - { - files: ["**/*.ts"], - plugins: { throwscript: plugin, dbg: { rules: { probe: debugRule } } }, - languageOptions: { - parser: tsParser, - parserOptions: { project: "./tsconfig.json", tsconfigRootDir: fixturesDir }, - }, - rules: { - "throwscript/missing-throws": ["error", { ignoreExternal }], - "dbg/probe": "error", - }, - }, - ]; - return new ESLint({ cwd: fixturesDir, overrideConfigFile: true, overrideConfig: config }); -} - -test("debug sequence", async () => { - console.error("[debug] env", JSON.stringify({ CI: process.env.CI, node: process.version, ts: ts.version, cpus: (await import("node:os")).availableParallelism() })); - const text = readFileSync(errorsFile, "utf8"); - const names = (r: ESLint.LintResult | undefined) => - r?.messages.map((m) => m.message.match(/^'(\w+)'/)?.[1]).filter(Boolean); - const steps: Array<[string, () => Promise]> = [ - ["files false #1", () => createESLint(false).lintFiles(["errors.ts"])], - ["files true", () => createESLint(true).lintFiles(["errors.ts"])], - ["files false #2", () => createESLint(false).lintFiles(["errors.ts"])], - ["text modified false", () => createESLint(false).lintText(text + "\n// changed\n", { filePath: errorsFile })], - ["files false #3", () => createESLint(false).lintFiles(["errors.ts"])], - ["text modified2 false", () => createESLint(false).lintText(text + "\n// changed again\n", { filePath: errorsFile })], - ["text original false", () => createESLint(false).lintText(text, { filePath: errorsFile })], - ]; - for (const [label, run] of steps) { - const [r] = await run(); - console.error("[debug] step", label, JSON.stringify(names(r))); - } -}); From a0d24b6ebe041ba05079bf5bc4da4db6498d1ba0 Mon Sep 17 00:00:00 2001 From: Mirek Rusin Date: Sun, 30 Aug 2026 20:59:33 +0200 Subject: [PATCH 4/4] Key the diagnostics cache by program; align README parser globs Codex review follow-ups: a rebuilt program (project service, watch mode) can reuse an unchanged file's SourceFile object even though a callee's @throws contract changed, so cache diagnostics under the Program first. And the README examples now configure the typed parser for the same **/*.{ts,tsx,mts,cts} set the recommended config enables. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QswtWoNc2nD627xnAp7ZsV --- README.md | 2 +- packages/eslint-plugin/README.md | 7 ++++--- packages/eslint-plugin/src/analysis.ts | 23 +++++++++++++++++------ 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index b2ada7a..f706c16 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ import tsParser from "@typescript-eslint/parser"; export default [ throwscript.configs.recommended, // missing-throws: error, unused-throws: warn { - files: ["**/*.ts"], + files: ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"], languageOptions: { parser: tsParser, parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname }, diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index 60c24a8..19f45af 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -43,7 +43,7 @@ import tsParser from "@typescript-eslint/parser"; export default [ throwscript.configs.recommended, { - files: ["**/*.ts", "**/*.tsx"], + files: ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"], languageOptions: { parser: tsParser, parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname }, @@ -66,7 +66,8 @@ export default tseslint.config( ``` `configs.recommended` enables `throwscript/missing-throws` as an error and -`throwscript/unused-throws` as a warning for `**/*.{ts,tsx,mts,cts}`. To pick +`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: @@ -74,7 +75,7 @@ parsed without type information makes the rules throw: ```js export default [ { - files: ["**/*.ts"], + files: ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"], plugins: { throwscript }, rules: { "throwscript/missing-throws": ["error", { ignoreExternal: true }], diff --git a/packages/eslint-plugin/src/analysis.ts b/packages/eslint-plugin/src/analysis.ts index 6a66a8c..6f5d0d4 100644 --- a/packages/eslint-plugin/src/analysis.ts +++ b/packages/eslint-plugin/src/analysis.ts @@ -28,11 +28,17 @@ interface TypedParserServices { /** * Both rules run the analyzer over the same file during the same lint pass; - * the result is computed once per source file and option set. Keyed on the - * `ts.SourceFile` object so a re-parsed file (project service, watch mode) - * gets fresh diagnostics and stale programs can be garbage collected. + * the result is computed once per program, source file, and option set. Keyed + * on the `ts.Program` first: a rebuilt program (project service, watch mode) + * can reuse an unchanged file's `ts.SourceFile` object even though a callee's + * `@throws` contract changed, so diagnostics cached against an old program + * must never be served. Old programs and re-parsed files are garbage + * collected together with their cache entries. */ -const cache = new WeakMap>(); +const cache = new WeakMap< + ts.Program, + WeakMap> +>(); /** * Diagnostics for the file being linted, of every kind; each rule picks the @@ -58,10 +64,15 @@ export function getDiagnostics( if (sourceFile === undefined) return []; const key = String(options.ignoreExternal === true); - let byOptions = cache.get(sourceFile); + let byFile = cache.get(program); + if (byFile === undefined) { + byFile = new WeakMap(); + cache.set(program, byFile); + } + let byOptions = byFile.get(sourceFile); if (byOptions === undefined) { byOptions = new Map(); - cache.set(sourceFile, byOptions); + byFile.set(sourceFile, byOptions); } let diagnostics = byOptions.get(key); if (diagnostics === undefined) {