From 8bcb7fafd246adb61fa721fb3356500f9f952d76 Mon Sep 17 00:00:00 2001 From: Mirek Rusin Date: Sat, 4 Jul 2026 18:07:04 +0000 Subject: [PATCH] Add throwscript: JSDoc @throws checker for TypeScript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pnpm monorepo with two packages: - @throwscript/core: TypeScript compiler API analyzer that asserts every function that can throw documents the error type with a JSDoc @throws tag. Tracks direct throws, propagation from @throws-documented callees, try/catch swallowing and rethrows (including instanceof-narrowed catch variables), and Promise rejections — async throws, Promise.reject, and awaited/returned promises are annotated the same way as sync throws, while fire-and-forget calls and .catch()-handled chains are exempt. Coverage is nominal: @throws {Error} covers subclasses via the extends chain, but structurally identical sibling error classes don't cover each other. - throwscript: CLI wrapper with --project/file args, --json output, unused-@throws warnings (--no-unused), exit code 1 on errors. Tooling: tsc project references build, oxlint, node:test via tsx, CI workflow running build + lint + test. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UG7WqvjcBWuMRj24ssFSrd --- .gitignore | 4 + .oxlintrc.json | 12 + README.md | 116 +++++ package.json | 24 + packages/cli/package.json | 24 + packages/cli/src/main.ts | 167 ++++++ packages/cli/test/cli.test.ts | 67 +++ packages/cli/test/fixtures/sample.ts | 12 + packages/cli/tsconfig.json | 9 + packages/core/package.json | 27 + packages/core/src/analyzer.ts | 521 +++++++++++++++++++ packages/core/src/index.ts | 79 +++ packages/core/test/analyzer.test.ts | 124 +++++ packages/core/test/fixtures/async.ts | 69 +++ packages/core/test/fixtures/basic.ts | 94 ++++ packages/core/test/fixtures/expressions.ts | 68 +++ packages/core/tsconfig.json | 8 + pnpm-lock.yaml | 569 +++++++++++++++++++++ pnpm-workspace.yaml | 2 + tsconfig.base.json | 18 + 20 files changed, 2014 insertions(+) create mode 100644 .gitignore create mode 100644 .oxlintrc.json create mode 100644 README.md create mode 100644 package.json create mode 100644 packages/cli/package.json create mode 100644 packages/cli/src/main.ts create mode 100644 packages/cli/test/cli.test.ts create mode 100644 packages/cli/test/fixtures/sample.ts create mode 100644 packages/cli/tsconfig.json create mode 100644 packages/core/package.json create mode 100644 packages/core/src/analyzer.ts create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/test/analyzer.test.ts create mode 100644 packages/core/test/fixtures/async.ts create mode 100644 packages/core/test/fixtures/basic.ts create mode 100644 packages/core/test/fixtures/expressions.ts create mode 100644 packages/core/tsconfig.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 tsconfig.base.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..62ccde4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.tsbuildinfo +.DS_Store diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..4cfd974 --- /dev/null +++ b/.oxlintrc.json @@ -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/**"] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..e5eedc0 --- /dev/null +++ b/README.md @@ -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 { + 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. diff --git a/package.json b/package.json new file mode 100644 index 0000000..eca14f3 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..46bf802 --- /dev/null +++ b/packages/cli/package.json @@ -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" + } +} diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts new file mode 100644 index 0000000..d716f5f --- /dev/null +++ b/packages/cli/src/main.ts @@ -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 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; + } + + 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(); diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts new file mode 100644 index 0000000..74fd353 --- /dev/null +++ b/packages/cli/test/cli.test.ts @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const entry = path.join(here, "..", "src", "main.ts"); +const fixture = path.join(here, "fixtures", "sample.ts"); + +interface CliResult { + status: number; + stdout: string; + stderr: string; +} + +function runCli(args: string[]): CliResult { + try { + const stdout = execFileSync( + process.execPath, + ["--import", "tsx", entry, ...args], + { encoding: "utf8" }, + ); + return { status: 0, stdout, stderr: "" }; + } catch (error) { + const e = error as { status?: number; stdout?: string; stderr?: string }; + return { + status: e.status ?? -1, + stdout: e.stdout?.toString() ?? "", + stderr: e.stderr?.toString() ?? "", + }; + } +} + +test("reports undocumented throws and exits 1", () => { + const result = runCli([fixture]); + assert.equal(result.status, 1); + assert.match(result.stdout, /undocumented.*can throw \{BoomError\}/); + assert.match(result.stdout, /1 error/); + assert.doesNotMatch(result.stdout, /'documented'/); +}); + +test("--json emits machine-readable diagnostics", () => { + const result = runCli(["--json", fixture]); + assert.equal(result.status, 1); + const parsed = JSON.parse(result.stdout) as Array<{ + kind: string; + functionName: string; + types: string[]; + }>; + assert.equal(parsed.length, 1); + assert.equal(parsed[0]?.kind, "missing-throws"); + assert.equal(parsed[0]?.functionName, "'undocumented'"); + assert.deepEqual(parsed[0]?.types, ["BoomError"]); +}); + +test("--help exits 0", () => { + const result = runCli(["--help"]); + assert.equal(result.status, 0); + assert.match(result.stdout, /Usage:/); +}); + +test("unknown option exits 2", () => { + const result = runCli(["--nope"]); + assert.equal(result.status, 2); + assert.match(result.stderr, /Unknown option/); +}); diff --git a/packages/cli/test/fixtures/sample.ts b/packages/cli/test/fixtures/sample.ts new file mode 100644 index 0000000..a33394b --- /dev/null +++ b/packages/cli/test/fixtures/sample.ts @@ -0,0 +1,12 @@ +export class BoomError extends Error {} + +export function undocumented(): void { + throw new BoomError("boom"); +} + +/** + * @throws {BoomError} always + */ +export function documented(): void { + throw new BoomError("boom"); +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000..3182c75 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"], + "references": [{ "path": "../core" }] +} diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..25ede77 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,27 @@ +{ + "name": "@throwscript/core", + "version": "0.1.0", + "description": "Checker that asserts every function that can throw documents it with a JSDoc @throws tag", + "type": "module", + "license": "MIT", + "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": { + "typescript": "^5.5.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.19.0" + } +} diff --git a/packages/core/src/analyzer.ts b/packages/core/src/analyzer.ts new file mode 100644 index 0000000..632130f --- /dev/null +++ b/packages/core/src/analyzer.ts @@ -0,0 +1,521 @@ +import ts from "typescript"; + +export type DiagnosticKind = "missing-throws" | "unused-throws"; +export type Severity = "error" | "warning"; + +export interface ThrowsDiagnostic { + kind: DiagnosticKind; + severity: Severity; + file: string; + line: number; + column: number; + functionName: string; + /** Error type names involved (missing or unused, depending on kind). */ + types: string[]; + message: string; +} + +export interface AnalyzeOptions { + /** + * Report documented @throws types that the checker cannot see being thrown. + * Defaults to true (reported as warnings). + */ + reportUnused?: boolean; +} + +/** A thrown error type observed while walking a function body. */ +interface ThrownType { + name: string; + /** Resolved type, when available, used for assignability against documented types. */ + type: ts.Type | undefined; + node: ts.Node; +} + +interface DocumentedType { + name: string; + type: ts.Type | undefined; + tag: ts.JSDocTag; +} + +const FALLBACK_ERROR_NAME = "Error"; + +/** + * Analyze a program and return diagnostics for every function-like that can + * throw (or reject, for Promise-returning functions) but does not declare the + * error type in a JSDoc `@throws` tag. + */ +export function analyzeProgram( + program: ts.Program, + options: AnalyzeOptions = {}, +): ThrowsDiagnostic[] { + const checker = program.getTypeChecker(); + const diagnostics: ThrowsDiagnostic[] = []; + + for (const sourceFile of program.getSourceFiles()) { + if (sourceFile.isDeclarationFile) continue; + if (program.isSourceFileFromExternalLibrary(sourceFile)) continue; + analyzeSourceFile(sourceFile, checker, options, diagnostics); + } + return diagnostics; +} + +export function analyzeSourceFile( + sourceFile: ts.SourceFile, + checker: ts.TypeChecker, + options: AnalyzeOptions, + diagnostics: ThrowsDiagnostic[], +): void { + const visit = (node: ts.Node): void => { + if (isCheckableFunction(node)) { + checkFunction(node, sourceFile, checker, options, diagnostics); + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); +} + +function isCheckableFunction(node: ts.Node): node is ts.FunctionLikeDeclaration { + return ( + (ts.isFunctionDeclaration(node) || + ts.isMethodDeclaration(node) || + ts.isConstructorDeclaration(node) || + ts.isGetAccessorDeclaration(node) || + ts.isSetAccessorDeclaration(node) || + ts.isFunctionExpression(node) || + ts.isArrowFunction(node)) && + node.body !== undefined + ); +} + +function checkFunction( + fn: ts.FunctionLikeDeclaration, + sourceFile: ts.SourceFile, + checker: ts.TypeChecker, + options: AnalyzeOptions, + diagnostics: ThrowsDiagnostic[], +): void { + const thrown = collectThrownTypes(fn, checker); + const documented = getDocumentedThrows(fn, checker); + + const missing = thrown.filter( + (t) => !documented.some((d) => covers(d, t, checker)), + ); + // Collapse duplicates by name, keep first occurrence for location. + const missingByName = new Map(); + for (const t of missing) { + if (!missingByName.has(t.name)) missingByName.set(t.name, t); + } + + const name = functionDisplayName(fn); + + if (missingByName.size > 0) { + const anchor = fn.name ?? fn; + const pos = sourceFile.getLineAndCharacterOfPosition(anchor.getStart(sourceFile)); + const types = [...missingByName.keys()]; + diagnostics.push({ + kind: "missing-throws", + severity: "error", + file: sourceFile.fileName, + line: pos.line + 1, + column: pos.character + 1, + functionName: name, + types, + message: + `${name} can throw ${formatTypeList(types)} but has no @throws tag for ` + + `${types.length === 1 ? "it" : "them"}. ` + + `Document with ${types.map((t) => `\`@throws {${t}}\``).join(", ")}.`, + }); + } + + if (options.reportUnused !== false) { + const unused = documented.filter( + (d) => !thrown.some((t) => covers(d, t, checker)), + ); + for (const d of unused) { + const pos = sourceFile.getLineAndCharacterOfPosition(d.tag.getStart(sourceFile)); + diagnostics.push({ + kind: "unused-throws", + severity: "warning", + file: sourceFile.fileName, + line: pos.line + 1, + column: pos.character + 1, + functionName: name, + types: [d.name], + message: `${name} documents @throws {${d.name}} but nothing observable throws it.`, + }); + } + } +} + +/** + * Whether a documented @throws type covers a thrown type: exact name match, or + * the documented type appears in the thrown type's heritage chain (e.g. + * `@throws {Error}` covers a thrown `ValidationError extends Error`). + * + * Deliberately nominal rather than structural: `class A extends Error {}` and + * `class B extends Error {}` are structurally identical, so TypeScript + * assignability would let a documented A "cover" a thrown B. + */ +function covers(doc: DocumentedType, thrown: ThrownType, checker: ts.TypeChecker): boolean { + if (doc.name === thrown.name) return true; + if (thrown.type === undefined) return false; + return heritageNames(thrown.type, checker).has(doc.name); +} + +/** Collect the names of every base class/interface in a type's extends chain. */ +function heritageNames( + type: ts.Type, + checker: ts.TypeChecker, + seen: Set = new Set(), +): Set { + if (!(type.isClassOrInterface() || (type.flags & ts.TypeFlags.Object) !== 0)) { + return seen; + } + let bases: ts.Type[]; + try { + bases = checker.getBaseTypes(type as ts.InterfaceType) ?? []; + } catch { + return seen; + } + for (const base of bases) { + const name = (base.getSymbol() ?? base.aliasSymbol)?.getName(); + if (name === undefined || seen.has(name)) continue; + seen.add(name); + heritageNames(base, checker, seen); + } + return seen; +} + +function getDocumentedThrows( + fn: ts.FunctionLikeDeclaration, + checker: ts.TypeChecker, +): DocumentedType[] { + const result: DocumentedType[] = []; + for (const tag of ts.getJSDocTags(fn)) { + const tagName = tag.tagName.text; + if (tagName !== "throws" && tagName !== "exception") continue; + const typeExpression = ts.isJSDocThrowsTag(tag) ? tag.typeExpression : undefined; + if (typeExpression === undefined) { + // `@throws description` with no {Type}: treat as documenting the base Error. + result.push({ name: FALLBACK_ERROR_NAME, type: undefined, tag }); + continue; + } + for (const typeNode of splitUnionTypeNode(typeExpression.type)) { + let type: ts.Type | undefined; + try { + type = checker.getTypeFromTypeNode(typeNode); + if (type.flags & ts.TypeFlags.Any) type = undefined; + } catch { + type = undefined; + } + result.push({ name: typeNode.getText(), type, tag }); + } + } + return result; +} + +function splitUnionTypeNode(node: ts.TypeNode): ts.TypeNode[] { + if (ts.isUnionTypeNode(node)) return node.types.flatMap(splitUnionTypeNode); + if (ts.isParenthesizedTypeNode(node)) return splitUnionTypeNode(node.type); + return [node]; +} + +/** + * Walk a function body and collect every error type that can escape it: + * direct `throw` statements, `@throws`-documented callees, rethrown caught + * errors, and Promise rejections (`Promise.reject`, awaited/returned promises + * from `@throws`-documented async callees). + */ +function collectThrownTypes( + fn: ts.FunctionLikeDeclaration, + checker: ts.TypeChecker, +): ThrownType[] { + const collected: ThrownType[] = []; + const body = fn.body; + if (body === undefined) return collected; + visitForThrows(body, checker, (t) => collected.push(t)); + return collected; +} + +function visitForThrows( + node: ts.Node, + checker: ts.TypeChecker, + report: (t: ThrownType) => void, + catchContext?: { variableName: string | undefined; caughtTypes: ThrownType[] }, +): void { + // Nested functions own their throws; they are checked independently. + if (ts.isFunctionLike(node)) return; + + if (ts.isTryStatement(node)) { + visitTryStatement(node, checker, report, catchContext); + return; + } + + if (ts.isThrowStatement(node)) { + reportThrowStatement(node, checker, report, catchContext); + // Still walk the thrown expression: `throw makeError()` may call a + // @throws-documented factory that itself can throw something else. + visitForThrows(node.expression, checker, report, catchContext); + return; + } + + if (ts.isCallExpression(node) || ts.isNewExpression(node)) { + reportCallExpression(node, checker, report); + ts.forEachChild(node, (child) => + visitForThrows(child, checker, report, catchContext), + ); + return; + } + + ts.forEachChild(node, (child) => visitForThrows(child, checker, report, catchContext)); +} + +function visitTryStatement( + node: ts.TryStatement, + checker: ts.TypeChecker, + report: (t: ThrownType) => void, + outerCatchContext?: { variableName: string | undefined; caughtTypes: ThrownType[] }, +): void { + const caughtTypes: ThrownType[] = []; + const hasCatch = node.catchClause !== undefined; + + // Throws inside the try block are swallowed by the catch clause (if any); + // otherwise they escape. + visitForThrows( + node.tryBlock, + checker, + hasCatch ? (t) => caughtTypes.push(t) : report, + outerCatchContext, + ); + + if (node.catchClause !== undefined) { + const decl = node.catchClause.variableDeclaration; + const variableName = + decl !== undefined && ts.isIdentifier(decl.name) ? decl.name.text : undefined; + visitForThrows(node.catchClause.block, checker, report, { + variableName, + caughtTypes, + }); + } + + if (node.finallyBlock !== undefined) { + visitForThrows(node.finallyBlock, checker, report, outerCatchContext); + } +} + +function reportThrowStatement( + node: ts.ThrowStatement, + checker: ts.TypeChecker, + report: (t: ThrownType) => void, + catchContext?: { variableName: string | undefined; caughtTypes: ThrownType[] }, +): void { + const expr = unwrapParentheses(node.expression); + + // Rethrowing the caught error propagates whatever the try block could throw, + // unless control flow narrowed the catch variable to a concrete error type. + if ( + catchContext !== undefined && + catchContext.variableName !== undefined && + ts.isIdentifier(expr) && + expr.text === catchContext.variableName + ) { + const narrowed = checker.getTypeAtLocation(expr); + if ((narrowed.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) === 0) { + for (const t of typeToThrownTypes(narrowed, node)) report(t); + return; + } + if (catchContext.caughtTypes.length > 0) { + for (const t of catchContext.caughtTypes) report({ ...t, node }); + } else { + report({ name: FALLBACK_ERROR_NAME, type: undefined, node }); + } + return; + } + + if (ts.isNewExpression(expr)) { + const name = expr.expression.getText(); + let type: ts.Type | undefined; + try { + type = checker.getTypeAtLocation(expr); + } catch { + type = undefined; + } + report({ name, type, node }); + return; + } + + const type = checker.getTypeAtLocation(expr); + const named = typeToThrownTypes(type, node); + if (named.length > 0) { + for (const t of named) report(t); + } else { + report({ name: FALLBACK_ERROR_NAME, type: undefined, node }); + } +} + +function typeToThrownTypes(type: ts.Type, node: ts.Node): ThrownType[] { + if (type.isUnion()) { + return type.types.flatMap((t) => typeToThrownTypes(t, node)); + } + const symbol = type.getSymbol() ?? type.aliasSymbol; + if (symbol === undefined) return []; + const name = symbol.getName(); + if (name === "__type" || name === "__object" || name === "unknown") return []; + return [{ name, type, node }]; +} + +function reportCallExpression( + node: ts.CallExpression | ts.NewExpression, + checker: ts.TypeChecker, + report: (t: ThrownType) => void, +): void { + // Promise.reject(x) — rejects with x. Counts when the promise is observed + // (awaited or returned), same rule as calls to @throws-documented functions. + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.expression.getText() === "Promise" && + node.expression.name.text === "reject" + ) { + if (!isPromiseObserved(node)) return; + const arg = node.arguments[0]; + if (arg === undefined) { + report({ name: FALLBACK_ERROR_NAME, type: undefined, node }); + return; + } + const unwrapped = unwrapParentheses(arg); + if (ts.isNewExpression(unwrapped)) { + report({ + name: unwrapped.expression.getText(), + type: checker.getTypeAtLocation(unwrapped), + node, + }); + } else { + const types = typeToThrownTypes(checker.getTypeAtLocation(unwrapped), node); + if (types.length > 0) for (const t of types) report(t); + else report({ name: FALLBACK_ERROR_NAME, type: undefined, node }); + } + return; + } + + const signature = checker.getResolvedSignature(node); + if (signature === undefined) return; + const declaration = signature.getDeclaration(); + if (declaration === undefined) return; + + const documented = getDocumentedThrows(declaration as ts.FunctionLikeDeclaration, checker); + if (documented.length === 0) return; + + // Synchronous callee: its throws surface here unconditionally. Promise + // returning callee: its rejection surfaces here only when the promise is + // observed (awaited, returned, or chained) — a fire-and-forget call turns + // into an unhandled rejection, not a throw in this function. + const returnsPromise = isPromiseLikeType(checker.getReturnTypeOfSignature(signature), checker); + if (returnsPromise && !isPromiseObserved(node)) return; + if (returnsPromise && isRejectionHandled(node)) return; + + for (const d of documented) { + report({ name: d.name, type: d.type, node }); + } +} + +function isPromiseLikeType(type: ts.Type, checker: ts.TypeChecker): boolean { + const symbol = type.getSymbol() ?? type.aliasSymbol; + const name = symbol?.getName(); + if (name === "Promise" || name === "PromiseLike") return true; + // Thenable duck-typing: has a callable `then` member. + const then = checker.getPropertyOfType(type, "then"); + return then !== undefined && (then.flags & ts.SymbolFlags.Method) !== 0; +} + +/** + * A promise-producing expression's rejection propagates into the enclosing + * function when the promise is awaited, returned, or chained with `.then`. + */ +function isPromiseObserved(node: ts.Expression): boolean { + let current: ts.Node = node; + let parent = current.parent; + while (parent !== undefined) { + if (ts.isParenthesizedExpression(parent)) { + current = parent; + parent = parent.parent; + continue; + } + if (ts.isAwaitExpression(parent)) return true; + if (ts.isReturnStatement(parent)) return true; + // Concise arrow body: `() => doWork()` returns the promise. + if (ts.isArrowFunction(parent) && parent.body === current) return true; + // `foo().then(...)` / `foo().finally(...)` keep the rejection in play. + if ( + ts.isPropertyAccessExpression(parent) && + parent.expression === current && + (parent.name.text === "then" || parent.name.text === "finally") + ) { + return true; + } + return false; + } + return false; +} + +/** `foo().catch(...)` (or `.then(ok, err)`) handles the rejection locally. */ +function isRejectionHandled(node: ts.Expression): boolean { + let current: ts.Node = node; + let parent = current.parent; + while (parent !== undefined) { + if (ts.isParenthesizedExpression(parent) || ts.isAwaitExpression(parent)) { + current = parent; + parent = parent.parent; + continue; + } + if (ts.isPropertyAccessExpression(parent) && parent.expression === current) { + const call = parent.parent; + if (call !== undefined && ts.isCallExpression(call) && call.expression === parent) { + if (parent.name.text === "catch") return true; + if (parent.name.text === "then" && call.arguments.length >= 2) return true; + // `.then(...)`/`.finally(...)` without a rejection handler: keep looking + // up the chain for a `.catch`. + current = call; + parent = call.parent; + continue; + } + } + return false; + } + return false; +} + +function unwrapParentheses(expr: ts.Expression): ts.Expression { + let current = expr; + while (ts.isParenthesizedExpression(current)) current = current.expression; + return current; +} + +function functionDisplayName(fn: ts.FunctionLikeDeclaration): string { + if (fn.name !== undefined && ts.isIdentifier(fn.name)) { + return `'${fn.name.text}'`; + } + if (ts.isConstructorDeclaration(fn)) { + const cls = fn.parent; + const clsName = ts.isClassDeclaration(cls) || ts.isClassExpression(cls) + ? cls.name?.text + : undefined; + return clsName !== undefined ? `constructor of '${clsName}'` : "constructor"; + } + const parent = fn.parent; + if (ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) { + return `'${parent.name.text}'`; + } + if (ts.isPropertyAssignment(parent) && ts.isIdentifier(parent.name)) { + return `'${parent.name.text}'`; + } + if (ts.isPropertyDeclaration(parent) && ts.isIdentifier(parent.name)) { + return `'${parent.name.text}'`; + } + return "anonymous function"; +} + +function formatTypeList(types: string[]): string { + if (types.length === 1) return `{${types[0]}}`; + return types.map((t) => `{${t}}`).join(", "); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..4897bd3 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,79 @@ +import ts from "typescript"; +import { + analyzeProgram, + type AnalyzeOptions, + type ThrowsDiagnostic, +} from "./analyzer.js"; + +export { + analyzeProgram, + analyzeSourceFile, + type AnalyzeOptions, + type DiagnosticKind, + type Severity, + type ThrowsDiagnostic, +} from "./analyzer.js"; + +const DEFAULT_COMPILER_OPTIONS: ts.CompilerOptions = { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + strict: true, + allowJs: true, + checkJs: false, + noEmit: true, + skipLibCheck: true, +}; + +/** Analyze a list of files with default compiler options. */ +export function analyzeFiles( + fileNames: string[], + options: AnalyzeOptions = {}, + compilerOptions: ts.CompilerOptions = DEFAULT_COMPILER_OPTIONS, +): ThrowsDiagnostic[] { + const program = ts.createProgram(fileNames, compilerOptions); + return analyzeProgram(program, options); +} + +/** Load a tsconfig file and analyze every file in the project. */ +export function analyzeProject( + tsconfigPath: string, + options: AnalyzeOptions = {}, +): ThrowsDiagnostic[] { + const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile); + if (configFile.error !== undefined) { + throw new Error(ts.flattenDiagnosticMessageText(configFile.error.messageText, "\n")); + } + const parsed = ts.parseJsonConfigFileContent( + configFile.config, + ts.sys, + dirname(tsconfigPath), + ); + if (parsed.errors.length > 0) { + throw new Error( + parsed.errors + .map((e) => ts.flattenDiagnosticMessageText(e.messageText, "\n")) + .join("\n"), + ); + } + const program = ts.createProgram(parsed.fileNames, { + ...parsed.options, + noEmit: true, + }); + return analyzeProgram(program, options); +} + +/** Render a diagnostic as `file:line:col severity message`. */ +export function formatDiagnostic(d: ThrowsDiagnostic, cwd?: string): string { + const file = + cwd !== undefined && d.file.startsWith(cwd) + ? d.file.slice(cwd.length).replace(/^[/\\]/, "") + : d.file; + return `${file}:${d.line}:${d.column} ${d.severity} ${d.message}`; +} + +function dirname(p: string): string { + const normalized = p.replace(/\\/g, "/"); + const idx = normalized.lastIndexOf("/"); + return idx === -1 ? "." : normalized.slice(0, idx); +} diff --git a/packages/core/test/analyzer.test.ts b/packages/core/test/analyzer.test.ts new file mode 100644 index 0000000..6647b13 --- /dev/null +++ b/packages/core/test/analyzer.test.ts @@ -0,0 +1,124 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import { analyzeFiles, type ThrowsDiagnostic } from "../src/index.js"; + +const fixturesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures"); + +function fixture(name: string): string { + return path.join(fixturesDir, name); +} + +function analyzeFixture(name: string): ThrowsDiagnostic[] { + return analyzeFiles([fixture(name)]).filter((d) => d.file.endsWith(name)); +} + +function errorsFor(diagnostics: ThrowsDiagnostic[], fnName: string): ThrowsDiagnostic[] { + return diagnostics.filter( + (d) => d.kind === "missing-throws" && d.functionName === `'${fnName}'`, + ); +} + +test("basic sync functions", () => { + const diagnostics = analyzeFixture("basic.ts"); + + const missingTag = errorsFor(diagnostics, "missingTag"); + assert.equal(missingTag.length, 1); + assert.deepEqual(missingTag[0]?.types, ["ValidationError"]); + + assert.equal(errorsFor(diagnostics, "documented").length, 0); + assert.equal(errorsFor(diagnostics, "documentedViaBaseClass").length, 0); + + const partial = errorsFor(diagnostics, "partiallyDocumented"); + assert.equal(partial.length, 1); + assert.deepEqual(partial[0]?.types, ["TimeoutError"]); + + assert.equal(errorsFor(diagnostics, "safe").length, 0); + assert.equal(errorsFor(diagnostics, "swallowed").length, 0); + + const rethrows = errorsFor(diagnostics, "rethrows"); + assert.equal(rethrows.length, 1); + assert.deepEqual(rethrows[0]?.types, ["TimeoutError"]); +}); + +test("call propagation through @throws-documented callees", () => { + const diagnostics = analyzeFixture("basic.ts"); + + const caller = errorsFor(diagnostics, "callsDocumented"); + assert.equal(caller.length, 1); + assert.deepEqual(caller[0]?.types, ["NotFoundError"]); + + assert.equal(errorsFor(diagnostics, "callsDocumentedAndDocuments").length, 0); + assert.equal(errorsFor(diagnostics, "callsDocumentedAndCatches").length, 0); +}); + +test("unused @throws tags are reported as warnings", () => { + const diagnostics = analyzeFixture("basic.ts"); + const unused = diagnostics.filter((d) => d.kind === "unused-throws"); + assert.equal(unused.length, 1); + assert.equal(unused[0]?.functionName, "'overDocumented'"); + assert.deepEqual(unused[0]?.types, ["ValidationError"]); + assert.equal(unused[0]?.severity, "warning"); + + const withoutUnused = analyzeFiles([fixture("basic.ts")], { reportUnused: false }); + assert.equal(withoutUnused.filter((d) => d.kind === "unused-throws").length, 0); +}); + +test("async functions and promise rejections", () => { + const diagnostics = analyzeFixture("async.ts"); + + const missing = errorsFor(diagnostics, "fetchMissingTag"); + assert.equal(missing.length, 1); + assert.deepEqual(missing[0]?.types, ["NetworkError"]); + + assert.equal(errorsFor(diagnostics, "fetchDocumented").length, 0); + + const awaited = errorsFor(diagnostics, "awaitsDocumented"); + assert.equal(awaited.length, 1); + assert.deepEqual(awaited[0]?.types, ["NetworkError"]); + + const returned = errorsFor(diagnostics, "returnsPromiseDirectly"); + assert.equal(returned.length, 1); + assert.deepEqual(returned[0]?.types, ["NetworkError"]); + + assert.equal(errorsFor(diagnostics, "fireAndForget").length, 0); + assert.equal(errorsFor(diagnostics, "handledWithCatch").length, 0); + assert.equal(errorsFor(diagnostics, "handledWithTry").length, 0); + + const rejects = errorsFor(diagnostics, "rejectsExplicitly"); + assert.equal(rejects.length, 1); + assert.deepEqual(rejects[0]?.types, ["ParseError"]); + + assert.equal(errorsFor(diagnostics, "rejectsDocumented").length, 0); +}); + +test("arrow functions, methods, and narrowed rethrows", () => { + const diagnostics = analyzeFixture("expressions.ts"); + + const arrow = errorsFor(diagnostics, "parseConfig"); + assert.equal(arrow.length, 1); + assert.deepEqual(arrow[0]?.types, ["ConfigError"]); + + assert.equal(errorsFor(diagnostics, "parseConfigDocumented").length, 0); + + const method = errorsFor(diagnostics, "get"); + assert.equal(method.length, 1); + assert.deepEqual(method[0]?.types, ["ConfigError"]); + + assert.equal(errorsFor(diagnostics, "getDocumented").length, 0); + assert.equal(errorsFor(diagnostics, "has").length, 0); + + const narrowed = errorsFor(diagnostics, "narrowedRethrow"); + assert.equal(narrowed.length, 1); + assert.deepEqual(narrowed[0]?.types, ["ConfigError"]); +}); + +test("diagnostics carry usable positions", () => { + const diagnostics = analyzeFixture("basic.ts"); + for (const d of diagnostics) { + assert.ok(d.line >= 1, `line for ${d.functionName}`); + assert.ok(d.column >= 1, `column for ${d.functionName}`); + assert.ok(d.message.length > 0); + } +}); diff --git a/packages/core/test/fixtures/async.ts b/packages/core/test/fixtures/async.ts new file mode 100644 index 0000000..3e3aeed --- /dev/null +++ b/packages/core/test/fixtures/async.ts @@ -0,0 +1,69 @@ +export class NetworkError extends Error {} +export class ParseError extends Error {} + +// BAD: async function that throws rejects its promise; annotated like sync. +export async function fetchMissingTag(url: string): Promise { + if (!url.startsWith("https://")) { + throw new NetworkError("insecure url"); + } + return url; +} + +// GOOD: async throw documented exactly like a sync one. +/** + * @throws {NetworkError} when the url is not https + */ +export async function fetchDocumented(url: string): Promise { + if (!url.startsWith("https://")) { + throw new NetworkError("insecure url"); + } + return url; +} + +// BAD: awaiting a @throws-documented async function propagates the rejection. +export async function awaitsDocumented(): Promise { + return await fetchDocumented("http://x"); +} + +// BAD: returning the promise without await still propagates the rejection. +export async function returnsPromiseDirectly(): Promise { + return fetchDocumented("http://x"); +} + +// GOOD: fire-and-forget call; the rejection never surfaces in this function. +export function fireAndForget(): void { + void fetchDocumented("http://x"); +} + +// GOOD: rejection handled locally with .catch(). +export async function handledWithCatch(): Promise { + return await fetchDocumented("http://x").catch(() => "fallback"); +} + +// GOOD: awaited call wrapped in try/catch. +export async function handledWithTry(): Promise { + try { + return await fetchDocumented("http://x"); + } catch { + return "fallback"; + } +} + +// BAD: explicit Promise.reject that is returned. +export function rejectsExplicitly(flag: boolean): Promise { + if (flag) { + return Promise.reject(new ParseError("bad input")); + } + return Promise.resolve(1); +} + +// GOOD: documented explicit rejection. +/** + * @throws {ParseError} when flag is set + */ +export function rejectsDocumented(flag: boolean): Promise { + if (flag) { + return Promise.reject(new ParseError("bad input")); + } + return Promise.resolve(1); +} diff --git a/packages/core/test/fixtures/basic.ts b/packages/core/test/fixtures/basic.ts new file mode 100644 index 0000000..2df68c6 --- /dev/null +++ b/packages/core/test/fixtures/basic.ts @@ -0,0 +1,94 @@ +export class NotFoundError extends Error {} +export class ValidationError extends Error {} +export class TimeoutError extends Error {} + +// BAD: throws but has no @throws tag. +export function missingTag(id: string): string { + if (id === "") { + throw new ValidationError("empty id"); + } + return id; +} + +// GOOD: documented. +/** + * Look up a record. + * @throws {NotFoundError} when the id does not exist + */ +export function documented(id: string): string { + if (id === "nope") { + throw new NotFoundError(id); + } + return id; +} + +// GOOD: documents a base class that covers the subclass throw. +/** + * @throws {Error} on any failure + */ +export function documentedViaBaseClass(): void { + throw new ValidationError("still an Error"); +} + +// BAD: documents one type but throws a second, undocumented one. +/** + * @throws {NotFoundError} + */ +export function partiallyDocumented(kind: number): void { + if (kind === 1) throw new NotFoundError("x"); + throw new TimeoutError("y"); +} + +// GOOD: cannot throw, needs no tag. +export function safe(a: number, b: number): number { + return a + b; +} + +// GOOD: throw is swallowed by catch, nothing escapes. +export function swallowed(): number { + try { + throw new ValidationError("contained"); + } catch { + return -1; + } +} + +// BAD: rethrows the caught error, so the try block's throws escape. +export function rethrows(): number { + try { + throw new TimeoutError("escapes"); + } catch (error) { + console.error(error); + throw error; + } +} + +// BAD: calling a @throws-documented function propagates its error type. +export function callsDocumented(): string { + return documented("nope"); +} + +// GOOD: caller documents the propagated type. +/** + * @throws {NotFoundError} + */ +export function callsDocumentedAndDocuments(): string { + return documented("nope"); +} + +// GOOD: caller catches the callee's documented error. +export function callsDocumentedAndCatches(): string { + try { + return documented("nope"); + } catch { + return "fallback"; + } +} + +// WARNING: documents an error type that is never thrown. +/** + * @throws {ValidationError} never actually thrown + */ +export function overDocumented(a: number): number { + return a * 2; +} diff --git a/packages/core/test/fixtures/expressions.ts b/packages/core/test/fixtures/expressions.ts new file mode 100644 index 0000000..620d9ba --- /dev/null +++ b/packages/core/test/fixtures/expressions.ts @@ -0,0 +1,68 @@ +export class ConfigError extends Error {} + +// BAD: arrow functions are checked too. +export const parseConfig = (raw: string): string => { + if (raw === "") { + throw new ConfigError("empty"); + } + return raw.trim(); +}; + +// GOOD: JSDoc on the variable statement covers the arrow. +/** + * @throws {ConfigError} when raw is empty + */ +export const parseConfigDocumented = (raw: string): string => { + if (raw === "") { + throw new ConfigError("empty"); + } + return raw; +}; + +export class Store { + private items = new Map(); + + // BAD: methods are checked. + get(key: string): string { + const value = this.items.get(key); + if (value === undefined) { + throw new ConfigError(`missing ${key}`); + } + return value; + } + + // GOOD: documented method. + /** + * @throws {ConfigError} when key is missing + */ + getDocumented(key: string): string { + const value = this.items.get(key); + if (value === undefined) { + throw new ConfigError(`missing ${key}`); + } + return value; + } + + // GOOD: no throw. + has(key: string): boolean { + return this.items.has(key); + } +} + +// BAD: union rethrow via instanceof narrowing still surfaces the type. +export function narrowedRethrow(): void { + try { + riskyInternal(); + } catch (error) { + if (error instanceof ConfigError) { + throw error; + } + } +} + +/** + * @throws {ConfigError} + */ +function riskyInternal(): void { + throw new ConfigError("boom"); +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..5285d28 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..0c63e45 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,569 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + oxlint: + specifier: ^1.72.0 + version: 1.72.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + packages/cli: + dependencies: + '@throwscript/core': + specifier: workspace:* + version: link:../core + typescript: + specifier: ^5.5.0 + version: 5.9.3 + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.20.0 + tsx: + specifier: ^4.19.0 + version: 4.23.0 + + packages/core: + dependencies: + typescript: + specifier: ^5.5.0 + version: 5.9.3 + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.20.0 + tsx: + specifier: ^4.19.0 + version: 4.23.0 + +packages: + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@oxlint/binding-android-arm-eabi@1.72.0': + resolution: {integrity: sha512-zhCmvn+1Mj3UchAc/90i99S0t7jJUsHmFVSPg4UWrjO8b8eaSGwscgO6QAUtvHBstkjQwBttQNswEnAF1mIQdA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.72.0': + resolution: {integrity: sha512-mtH+aY/ozv1eZoCUC2owjFAtyNBKHpJHygKeEu9zXXnQGW1Q2/qOpvx+I+Lf23+TvTz66F4iiXUbl2cGvoLPCQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.72.0': + resolution: {integrity: sha512-EvnajNPDtfknB3ZieeOOyDTwJn9QXDiwfnF4ZDQqART6RG6hjY4WigQcZdGoK2dkB3e1vrmEzN9aYbQCUkh/gQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.72.0': + resolution: {integrity: sha512-ZkCdEa/G80A7vEHfeCDz/+L3m33DE73v32mDKhgOIgz8Uwf0DFcK7+uu6qC+7LEhmz5fpOe1osWKyjSNMydFIQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.72.0': + resolution: {integrity: sha512-NroXv2vh+sxVY1uya/rM5pjhx1hm8BzlYpx9q67QP0Xhw5MH2bf5GJylpvLEC+781p1Xli/317EoV9AlGwViag==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.72.0': + resolution: {integrity: sha512-0NDywYgfj279Ou/BcQuCYSj7NJwBfmWn5qc5uGO/Ny7fUWmXyIpvawqX/8acQlWG6IXelJsJhj+JAy6sjsKj0A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.72.0': + resolution: {integrity: sha512-4vpXB06h65Ezsy4hRyrGjGrfa1SkVPii09yaajiYhmVpgsFiLD+KNxIx/BNAY+XiO+i1yqp9HHdwqM8VTqa5XQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.72.0': + resolution: {integrity: sha512-immaN4g2ZGFiOkKrvRX9LvzZdd2GkQM5wR+UyzYyUuyhUTXGQ4HKUJH18xp4G8OfhCVaVAJfKZxwE1r8+4hhaQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.72.0': + resolution: {integrity: sha512-JGHS9Mnr7iWyyLDxgCv1MhzVpAckgptg00F2gnxt/GD7lQ2SW1BRcxHqhSTaSdDpjWRrBkBxMMh4+Hn3aVtExg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-ppc64-gnu@1.72.0': + resolution: {integrity: sha512-AOYgBZqxNshrg83P9v0RYv+m8s10Cqkj4/PxXFDhcS3k7FqsIG5+CxErshZCIN7G8iy4Y+VGfAsuEdar8AcbBg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.72.0': + resolution: {integrity: sha512-QMybPS5ij3/vrKG67mqzHwW++91sYxK/PPUVi6SBtNCEzW4niS52fVBdXbQ6nou0wWbUPEpx8Sl/ZjtgE3clXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-musl@1.72.0': + resolution: {integrity: sha512-gOc3W7JV0PXRpIL7stUlLe3Wa9Gp0Kdlup87IT3gHDvPKck2xNgMIl/Gs2lldYY2lyXZDC4rWi3hmoLUobkgbQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-s390x-gnu@1.72.0': + resolution: {integrity: sha512-rpGxph+FjjHcYI5q6uxB3Az+tnfmEnDbSA8+PK9ZE/VzyUAkvBOMeuY7ZQMhu5mpZH7YQDsTdW6Cx4kV/msc6w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-gnu@1.72.0': + resolution: {integrity: sha512-WND+uhf/Ko13SLqQMWQUgsZuLvYYEvL0ZKgg0tgGYfLqxG7l8Ju123fHDMJyYSDl5E3bUbpFUuii/OvMreFQzw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-musl@1.72.0': + resolution: {integrity: sha512-SrpbrUL70nG9vh6zP4/oKHWgLuHquwsr7MW9XOn0olBVgh10Uqr8qscKhQoBGEn6olK/IUpn5GSKcdQ5AjUhGA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/binding-openharmony-arm64@1.72.0': + resolution: {integrity: sha512-qkrsEn6NmgFKr7U/QnezQMb+q/vzAy0Dd9Y95gQGQTyjzDLN+HRZMuM5u70iyH4nBLCfKBzhjMsYCehKay2jyg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.72.0': + resolution: {integrity: sha512-LWR6ZlFZph+KPjXv8opgZsXRDCdrdQe8VL8Cg9zxCoBS73h6znzZpydVgmdnwj8mB9AuSM5jxEgDJDpQkjboeg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.72.0': + resolution: {integrity: sha512-yt6HEh7IsHvtjRWtmeZRX134eaXKHq5Gnqlf1xBJdJl1JtdoRUEJw3nAxpZoUDS860cX/foKbztO441anVBtVQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.72.0': + resolution: {integrity: sha512-b2eKFD2hX7tIwmo/cyH6TDq8vzWRZ2qNHrzoGntUTmq0h3zQh/uX3eTSHCwI8OB/ADQfJCRelLItK8BsxuucDA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@types/node@22.20.0': + resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + oxlint@1.72.0: + resolution: {integrity: sha512-1rhdZIP/EvoI91ABIwNU5Q8+bWf8mjrS5UzIOZld4d4bXxJvtlUhlQvaoTogIGin/qdErMOrwaIJvCSIAKTLhA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=0.22.1' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + + tsx@4.23.0: + resolution: {integrity: sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + +snapshots: + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@oxlint/binding-android-arm-eabi@1.72.0': + optional: true + + '@oxlint/binding-android-arm64@1.72.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.72.0': + optional: true + + '@oxlint/binding-darwin-x64@1.72.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.72.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.72.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.72.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.72.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.72.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.72.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.72.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.72.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.72.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.72.0': + optional: true + + '@types/node@22.20.0': + dependencies: + undici-types: 6.21.0 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + fsevents@2.3.3: + optional: true + + oxlint@1.72.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.72.0 + '@oxlint/binding-android-arm64': 1.72.0 + '@oxlint/binding-darwin-arm64': 1.72.0 + '@oxlint/binding-darwin-x64': 1.72.0 + '@oxlint/binding-freebsd-x64': 1.72.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.72.0 + '@oxlint/binding-linux-arm-musleabihf': 1.72.0 + '@oxlint/binding-linux-arm64-gnu': 1.72.0 + '@oxlint/binding-linux-arm64-musl': 1.72.0 + '@oxlint/binding-linux-ppc64-gnu': 1.72.0 + '@oxlint/binding-linux-riscv64-gnu': 1.72.0 + '@oxlint/binding-linux-riscv64-musl': 1.72.0 + '@oxlint/binding-linux-s390x-gnu': 1.72.0 + '@oxlint/binding-linux-x64-gnu': 1.72.0 + '@oxlint/binding-linux-x64-musl': 1.72.0 + '@oxlint/binding-openharmony-arm64': 1.72.0 + '@oxlint/binding-win32-arm64-msvc': 1.72.0 + '@oxlint/binding-win32-ia32-msvc': 1.72.0 + '@oxlint/binding-win32-x64-msvc': 1.72.0 + + tsx@4.23.0: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..924b55f --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - packages/* diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..2ec4c6a --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "strict": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "composite": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "noUncheckedIndexedAccess": true, + "verbatimModuleSyntax": true + } +}